mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
578785ee7f | ||
|
|
ed7c8f2208 | ||
|
|
2e1c7c00a6 | ||
|
|
994ca26e92 | ||
|
|
d2f87c612e | ||
|
|
62cd63bfda | ||
|
|
dee35a841d | ||
|
|
fb44e2dd09 | ||
|
|
c42d78e26c | ||
|
|
2dc287f2e0 | ||
|
|
83094abf21 | ||
|
|
aafe93b335 | ||
|
|
cc52c93ad7 | ||
|
|
d7f048a265 | ||
|
|
0d9e7e1530 | ||
|
|
9b4fcfb897 | ||
|
|
9517f12cdd | ||
|
|
40f4d450be | ||
|
|
620eb3fd00 | ||
|
|
c6ff69fd12 | ||
|
|
7b271bbee9 | ||
|
|
68f099707e | ||
|
|
c227863d51 | ||
|
|
3a8c9f16de | ||
|
|
2da31290a1 | ||
|
|
3c53ba01a7 | ||
|
|
07bf8b92fd | ||
|
|
63dc24b5e8 | ||
|
|
8f1d6af3ed | ||
|
|
85f431a072 | ||
|
|
d7fb9b5d35 | ||
|
|
eba6ea5639 | ||
|
|
2ad6e8fb75 | ||
|
|
570bd084c2 | ||
|
|
15278fe2f9 | ||
|
|
c4e6dcfd80 | ||
|
|
701f844755 | ||
|
|
91cf660377 | ||
|
|
78e1aaedb9 | ||
|
|
708b85a6a4 | ||
|
|
1cab4d8c22 | ||
|
|
d8b9a83f8c | ||
|
|
a016bd4ba5 | ||
|
|
983a3db3af | ||
|
|
7969751f1f | ||
|
|
4aaf5cb73b | ||
|
|
e9bad40b10 | ||
|
|
1e28ef5942 | ||
|
|
28f673e4cd | ||
|
|
9ddf69698d | ||
|
|
51631d0324 | ||
|
|
c7538cd256 | ||
|
|
6cf645f87c | ||
|
|
aa556c372f | ||
|
|
126ac24c8c | ||
|
|
3c8be00131 | ||
|
|
f5ae32e53f | ||
|
|
a3a6a85b73 | ||
|
|
db15e0090a | ||
|
|
994192ef4f | ||
|
|
3e60c3b86b | ||
|
|
de27905e88 | ||
|
|
28dde982cb | ||
|
|
de8d756afa | ||
|
|
b1748c4ea9 | ||
|
|
432ebc498c | ||
|
|
abc07bb079 | ||
|
|
78fc7b171e | ||
|
|
4c1f920d58 | ||
|
|
e9e3c46cb7 | ||
|
|
62198286e9 | ||
|
|
af90a9bd1f | ||
|
|
5e4958c889 | ||
|
|
fdc2ec17e6 | ||
|
|
b6740c937f | ||
|
|
10c8f523c4 | ||
|
|
8249ab37db | ||
|
|
9be9c0b68c | ||
|
|
9ad3865690 | ||
|
|
5097b31457 | ||
|
|
aa23648835 | ||
|
|
1a33dc8cc4 | ||
|
|
3510aa22b5 | ||
|
|
77729e2723 | ||
|
|
05ea883e26 | ||
|
|
6566039bc8 | ||
|
|
e50bffb7e3 | ||
|
|
f2804eb2c8 | ||
|
|
559960865a | ||
|
|
a4fb58e428 | ||
|
|
a668aa8b36 | ||
|
|
8d0b5ede28 | ||
|
|
2786ae9de6 | ||
|
|
34b8bf1a6e | ||
|
|
52c7d88340 | ||
|
|
9e2d97f250 | ||
|
|
bfe0a5a873 | ||
|
|
504919a054 | ||
|
|
0d27f45ead | ||
|
|
65380be9c8 | ||
|
|
d596d0844f | ||
|
|
cd04848b97 | ||
|
|
e377c4358c | ||
|
|
3438580213 | ||
|
|
e93e6368fc | ||
|
|
200b1406d7 | ||
|
|
421a83282a | ||
|
|
a2fb09260a | ||
|
|
aa7a811e66 | ||
|
|
2ac784c51a | ||
|
|
6b39d0b453 | ||
|
|
f218c8d40a | ||
|
|
1cce2b38ee | ||
|
|
9d9ed1cc62 | ||
|
|
2d7767eaf2 | ||
|
|
064d9b74e8 | ||
|
|
e87c47c957 | ||
|
|
bdedd6c544 | ||
|
|
9908d3ca2f | ||
|
|
4df6b0636b | ||
|
|
498e06c582 | ||
|
|
e6722126ba | ||
|
|
e619ca386e | ||
|
|
8224434d21 | ||
|
|
fd55f3248f | ||
|
|
08d6c153b7 | ||
|
|
636d6be690 | ||
|
|
7dcf728461 | ||
|
|
fb409787c6 | ||
|
|
e7caedc22a | ||
|
|
eebf4e3ffb | ||
|
|
c7cf84ad39 | ||
|
|
0681aa2566 | ||
|
|
17747df842 | ||
|
|
3fbce499d8 | ||
|
|
1a40e5690f | ||
|
|
7903716ad1 | ||
|
|
f8eae45048 | ||
|
|
a44a1019f8 | ||
|
|
9fba8ed43b | ||
|
|
7f5e915dd1 | ||
|
|
7e06816571 | ||
|
|
e9e66da5a8 | ||
|
|
94bb9354ce | ||
|
|
95982a508f | ||
|
|
563979e91e | ||
|
|
1cb28fd429 | ||
|
|
aa679efe41 | ||
|
|
e2cfaa46e7 | ||
|
|
593fc93659 | ||
|
|
530fb20f25 | ||
|
|
200602b41b | ||
|
|
e2624fb65c | ||
|
|
cc6a8e5174 | ||
|
|
6d5f027848 | ||
|
|
e21b849a92 | ||
|
|
ba345f1052 | ||
|
|
7c01ec9be8 | ||
|
|
52374af940 | ||
|
|
b6a7b8b7df | ||
|
|
1dc0a09009 | ||
|
|
e98ef32eb2 | ||
|
|
863667ce67 | ||
|
|
8f5451587e | ||
|
|
85b593fd2f | ||
|
|
0fcec63b68 | ||
|
|
8553c91c1a | ||
|
|
c66e700f05 | ||
|
|
fdf9751c12 | ||
|
|
1be69e56aa | ||
|
|
e7c029875c | ||
|
|
0678b24d58 | ||
|
|
b369fbca15 | ||
|
|
36ee193e3c | ||
|
|
1dd0f2e5dc | ||
|
|
f34735f462 | ||
|
|
a2d7d41185 | ||
|
|
03ab687686 | ||
|
|
2e756b71f2 | ||
|
|
fbc69322af | ||
|
|
414489d152 | ||
|
|
50a9dd7a64 | ||
|
|
a4156eea10 | ||
|
|
e0ecb55d4b | ||
|
|
897faea333 | ||
|
|
0e08c39999 | ||
|
|
ca9b4d73f2 | ||
|
|
94edcd5d81 | ||
|
|
3673702af5 | ||
|
|
5778739cdb | ||
|
|
81e461f651 | ||
|
|
b7aca87fd0 | ||
|
|
ae1bcdc25d | ||
|
|
263605779c | ||
|
|
9eabd2a5b7 | ||
|
|
d66bc88e68 | ||
|
|
8177f27aa3 | ||
|
|
d5a0124637 | ||
|
|
30b29e51e8 | ||
|
|
9f1effd71c | ||
|
|
6532d5b61e | ||
|
|
57c76584a6 | ||
|
|
82105f260a | ||
|
|
50ad6e2e50 | ||
|
|
f323d84644 | ||
|
|
3a9070359b | ||
|
|
d39fc491ec | ||
|
|
2b8cb628b6 | ||
|
|
8b4b3f4a94 | ||
|
|
624e4eef64 | ||
|
|
a3bbf33c05 | ||
|
|
7c616db07a | ||
|
|
0aef702b66 | ||
|
|
b5827cfd54 | ||
|
|
ea4291a92c | ||
|
|
32ac921f24 | ||
|
|
ed12ea7163 | ||
|
|
eb4237a2b0 | ||
|
|
706b5676a6 | ||
|
|
c31fe2ed9e | ||
|
|
987588eaa1 | ||
|
|
804679d996 | ||
|
|
571d1e1d9b | ||
|
|
3c22e78af0 | ||
|
|
5d9c7abed5 | ||
|
|
ac18c588c8 | ||
|
|
b83284071a | ||
|
|
fd13c72e95 | ||
|
|
3c2e9a43b9 | ||
|
|
ca319d09f2 | ||
|
|
46598dd491 | ||
|
|
3045b3ade1 | ||
|
|
604360d1cc | ||
|
|
25dc5742ea | ||
|
|
1136e290fd | ||
|
|
6506c0ea68 | ||
|
|
1323d1645d | ||
|
|
f1d845c630 | ||
|
|
5688bcba91 | ||
|
|
1a61eb7151 | ||
|
|
1cc6999ada | ||
|
|
88cb79d8b3 | ||
|
|
32ad1dcdce | ||
|
|
c037766040 | ||
|
|
da10822d7d | ||
|
|
5345881b1d | ||
|
|
c5ff640dff | ||
|
|
02eee39ddf | ||
|
|
e652f6e20a | ||
|
|
c4e46c3be8 | ||
|
|
0756c98102 | ||
|
|
7b693ae429 | ||
|
|
d4754c211a | ||
|
|
423f895755 | ||
|
|
c8a6e93f33 | ||
|
|
446042e518 | ||
|
|
1e6926cb9c | ||
|
|
b4b5371640 | ||
|
|
e866ff6ec4 | ||
|
|
c83dced4ba | ||
|
|
d674a04f2a | ||
|
|
9b61c1bdd9 | ||
|
|
add86cc3bc | ||
|
|
dc332d141c | ||
|
|
59bb131df4 | ||
|
|
443537daae | ||
|
|
b588c0bc42 | ||
|
|
4e9c1d7c8b | ||
|
|
a0b313018a | ||
|
|
fafc4d288d | ||
|
|
de084cdfc2 | ||
|
|
7eed2d3b7d | ||
|
|
f7dca22ea5 | ||
|
|
5eb8628ff2 | ||
|
|
002aaaa3d0 | ||
|
|
0f6ad105fe | ||
|
|
28fa458dc7 | ||
|
|
eca8d6b9ab | ||
|
|
81ac8ccd64 | ||
|
|
d24de611f8 | ||
|
|
f83fe6435b | ||
|
|
ee2c8b97bb | ||
|
|
d36f4dd6c6 | ||
|
|
1e8745c887 | ||
|
|
96e9ab8662 | ||
|
|
0eddfe1cfa | ||
|
|
acd2f09ddd | ||
|
|
5929cdd38c | ||
|
|
60756c0ca4 | ||
|
|
b56c61eccd | ||
|
|
f4e3eb1c5a | ||
|
|
fd872550d5 | ||
|
|
f4820c00dc | ||
|
|
de6ef7134d | ||
|
|
e6ea7b9af0 | ||
|
|
9e7aff59df | ||
|
|
00efee4303 | ||
|
|
00b7c855cc | ||
|
|
4a02e54171 | ||
|
|
9192426d2e | ||
|
|
d077002eba | ||
|
|
5f09442fce | ||
|
|
44003d7f6b | ||
|
|
d881bfc0de | ||
|
|
6ff771d3d3 | ||
|
|
efb46db0a9 | ||
|
|
8ba81615e0 | ||
|
|
50ba146298 | ||
|
|
b9083fc7ad | ||
|
|
1bf76d26d7 | ||
|
|
2abce65031 | ||
|
|
6ca1e15b0a | ||
|
|
195b219707 | ||
|
|
66d664ff08 | ||
|
|
4795650d21 | ||
|
|
d522aed65e | ||
|
|
7634e3c23b | ||
|
|
d8694f03bc | ||
|
|
e8cb019fab | ||
|
|
7a7bd3729c | ||
|
|
49ad7021d7 | ||
|
|
4952b919a4 | ||
|
|
fa0685a4a3 | ||
|
|
8040428716 | ||
|
|
4ff136cfd9 | ||
|
|
832cf4df65 | ||
|
|
77b6c5bb0c | ||
|
|
f1aadd48d5 | ||
|
|
5934981e22 | ||
|
|
db01fb98f8 | ||
|
|
3802268e2d | ||
|
|
8e60d57475 | ||
|
|
32064d39e7 | ||
|
|
92a550973a | ||
|
|
b20f29d115 | ||
|
|
a8cfe746b3 | ||
|
|
5705f4a4aa | ||
|
|
11ada80d58 | ||
|
|
1a9898a6a4 | ||
|
|
aa2c4e8d13 | ||
|
|
5a7d469622 | ||
|
|
095b525360 | ||
|
|
027d442f9b | ||
|
|
810d50a00f | ||
|
|
41d712be09 | ||
|
|
3c714d8736 | ||
|
|
636d7debf9 | ||
|
|
3dd717ab0d | ||
|
|
1dc1b44268 | ||
|
|
4ca86c52e3 | ||
|
|
3f1581b2d3 | ||
|
|
e55727d4c8 | ||
|
|
b7ec3cdadf | ||
|
|
3b7753a8e4 | ||
|
|
d5e43ef7d1 | ||
|
|
d6012b0b35 | ||
|
|
53d76ad800 | ||
|
|
4347e5555b | ||
|
|
329b92ef04 | ||
|
|
e2d9b823f6 | ||
|
|
eb594eefc9 | ||
|
|
3af5443b22 | ||
|
|
7ba3e1ac52 | ||
|
|
9fb260439f | ||
|
|
07931bbb18 | ||
|
|
2307aa5ca7 | ||
|
|
1563db3f82 | ||
|
|
e440ac48c7 | ||
|
|
71aa6e3574 | ||
|
|
99ec347eaf | ||
|
|
03e8ab4134 | ||
|
|
bc4a9ae432 | ||
|
|
19da0f240e | ||
|
|
fc589bce17 | ||
|
|
49752060fc | ||
|
|
a6b11ccced | ||
|
|
e0e5e45cd2 | ||
|
|
e39fce270d | ||
|
|
5a2f542ab0 | ||
|
|
b16ed03cad | ||
|
|
3e98a5a1ad | ||
|
|
e471be7302 | ||
|
|
22e1b8144a | ||
|
|
b1737cc841 | ||
|
|
3c5553e253 | ||
|
|
901a77cfb1 |
@@ -58,6 +58,11 @@ Please read the following carefully before using this project:
|
||||
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
|
||||
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)</td>
|
||||
@@ -91,12 +96,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
|
||||
@@ -122,11 +121,6 @@ Please read the following carefully before using this project:
|
||||
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
|
||||
<td>Thanks to <a href="https://www.novada.com/?sub2api/">Novada</a> for sponsoring this project! Novada provides residential, ISP, datacenter, and mobile proxies, along with Web Unlocker and Scraper APIs for developers building AI applications and automation workflows. With global IP coverage, flexible rotating and sticky sessions, and precise geo-targeting, Novada helps teams access web data reliably for AI agent workflows, cross-region testing, web research, and browser automation. Explore Novada to build more stable and scalable AI workflows.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
|
||||
<td>Thanks to Qiniu AI for sponsoring this project! Qiniu AI is the enterprise-grade large-model MaaS platform under Qiniu Cloud (02567.HK), offering one-stop access to 150+ mainstream models worldwide, compatible with the protocols of major global model providers, and covering full-modality capabilities including text, image, audio, video, and file processing, serving over 1.69 million enterprises and developers. Qiniu AI offers an exclusive benefit for Sub2API users: register via <a href="https://s.qiniu.com/u6rQrq">this link</a> — enterprise users get 12 million tokens free, and developers get 3 million tokens free.</td>
|
||||
@@ -162,6 +156,16 @@ Please read the following carefully before using this project:
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>Thanks to AxisNow for sponsoring this project! <a href="https://www.axisnow.io/">AxisNow</a> protects and accelerates websites and APIs, delivering an optimal access experience across mainland China and globally, while extending acceleration and security capabilities to native/mobile apps through client SDKs — <strong>self-hosted private-deployment CDN</strong> | <strong>subscription-based DDoS-protected CDN</strong> | <strong>autonomous, flexibly composable CDN network</strong>.</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
|
||||
+15
-11
@@ -59,6 +59,11 @@
|
||||
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
|
||||
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
|
||||
@@ -94,12 +99,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
|
||||
@@ -125,11 +124,6 @@
|
||||
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
|
||||
<td>感谢 <a href="https://www.novada.com/?sub2api/">Novada</a> 赞助本项目!Novada 为构建 AI 应用与自动化工作流的开发者提供住宅代理、ISP 代理、数据中心代理与移动代理,以及 Web Unlocker 和 Scraper API。凭借全球 IP 覆盖、灵活的轮换与粘性会话以及精准的地理定位,Novada 帮助团队在 AI Agent 工作流、跨区域测试、网络调研与浏览器自动化等场景中稳定获取网络数据。立即体验 Novada,构建更稳定、更可扩展的 AI 工作流。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="七牛云AI" width="150"></a></td>
|
||||
<td>感谢 七牛云AI 赞助本项目!七牛云AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过169万企业及开发者用户。七牛云 AI 为 Sub2API 的用户提供了专属福利:通过<a href="https://s.qiniu.com/u6rQrq">此链接</a>注册,企业用户免费领1200万Token,开发者免费领300万Token。</td>
|
||||
@@ -165,6 +159,16 @@
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>感谢 AxisNow 赞助了本项目!<a href="https://www.axisnow.io/">AxisNow</a> 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — <strong>自建私有部署 CDN</strong>|<strong>订阅式高防 CDN</strong>|<strong>自主可控、灵活组合的 CDN 网络</strong>。</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
|
||||
+15
-11
@@ -58,6 +58,11 @@
|
||||
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
|
||||
<td>OpenAI 公式価格のわずか 3% で本物の GPT-5.6 シリーズを提供 — <a href="https://codex-everywhere.com">CodexEverywhere</a> は世界中の開発者にフロンティアモデルへのアクセスを民主化しています。私たちは透明性と誠実さを信条とし、モデル品質は数か月にわたるアクティブなコミュニティの監視によって検証されています。USD および暗号通貨に対応。<a href="https://codex-everywhere.com">codex-everywhere.com</a> で $20 の無料トライアルから始めましょう。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
|
||||
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます!</td>
|
||||
@@ -93,12 +98,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
|
||||
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
|
||||
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
|
||||
@@ -124,11 +123,6 @@
|
||||
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
|
||||
<td>Novada のご支援に感謝します!<a href="https://www.novada.com/?sub2api/">Novada</a> は、AI アプリケーションや自動化ワークフローを構築する開発者向けに、レジデンシャル、ISP、データセンター、モバイルプロキシに加え、Web Unlocker と Scraper API を提供しています。グローバルな IP カバレッジ、柔軟なローテーション/スティッキーセッション、精密なジオターゲティングにより、AI エージェントワークフロー、クロスリージョンテスト、Web リサーチ、ブラウザ自動化などのシーンで、チームが Web データへ確実にアクセスできるよう支援します。Novada で、より安定しスケーラブルな AI ワークフローを構築しましょう。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
|
||||
<td>七牛云AI のご支援に感謝します!七牛云AI は、七牛云(02567.HK)傘下のエンタープライズ向け大規模モデル MaaS プラットフォームです。世界の主要モデル 150+ をワンストップで利用でき、グローバル主要モデルプロバイダーのプロトコルに対応し、テキスト、画像、音声、動画、ファイル処理などのフルモーダル処理能力をカバー。169万を超える企業・開発者ユーザーにサービスを提供しています。Sub2API ユーザー向けの限定特典として、<a href="https://s.qiniu.com/u6rQrq">こちらのリンク</a>から登録すると、企業ユーザーは 1200万 Token、開発者は 300万 Token を無料で獲得できます。</td>
|
||||
@@ -164,6 +158,16 @@
|
||||
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
|
||||
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
|
||||
<td>AxisNow のご支援に感謝します!<a href="https://www.axisnow.io/">AxisNow</a> はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — <strong>自社構築・プライベートデプロイ CDN</strong>|<strong>サブスクリプション型高防御 CDN</strong>|<strong>自律制御で柔軟に組み合わせられる CDN ネットワーク</strong>。</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 60 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.9 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
@@ -1 +1 @@
|
||||
0.1.179
|
||||
0.2.0
|
||||
|
||||
@@ -178,7 +178,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
|
||||
channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db)
|
||||
channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService)
|
||||
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service)
|
||||
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service, apiKeyService)
|
||||
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
|
||||
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
|
||||
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
# Upstream error proxy attribution
|
||||
|
||||
## Scope
|
||||
|
||||
Gateway inference errors are stored in `ops_error_logs.upstream_errors`. Each
|
||||
array item represents one upstream attempt, including same-account retries and
|
||||
cross-account failover attempts. Proxy attribution is stored on that item; it
|
||||
must not be reconstructed later from `accounts.proxy_id`.
|
||||
|
||||
This repository has one gateway implementation under `backend/internal/service`.
|
||||
There is no separate `gateway/backend` source copy to synchronize.
|
||||
|
||||
## Event contract
|
||||
|
||||
Every newly persisted event contains these fields. `proxy_id` is emitted even
|
||||
when its value is JSON `null`:
|
||||
|
||||
| Field | Meaning |
|
||||
| --- | --- |
|
||||
| `proxy_id` | Managed proxy ID used by the attempt; `null` for direct or unknown routes. |
|
||||
| `proxy_name` | Snapshotted proxy name, `direct/no_proxy`, or `unknown`. |
|
||||
|
||||
Only proxy ID and name are stored. Proxy URL, protocol endpoint, host, port,
|
||||
username, password, and authorization data are excluded.
|
||||
|
||||
The two sentinel names have strict meanings:
|
||||
|
||||
- `direct/no_proxy`: the transport was explicitly given no proxy.
|
||||
- `unknown`: the route cannot be proven from event-time evidence. This includes
|
||||
legacy events, pre-transport credential failures, and OpenAI WebSocket use of
|
||||
the default HTTP client.
|
||||
|
||||
Invariant: `proxy_id` is `null` if and only if `proxy_name` is one of the two
|
||||
sentinels. A managed proxy with a blank name (defensive only; `proxies.name` is
|
||||
non-empty) is labeled `proxy`. Both the struct normalizer used at append time
|
||||
and the JSON normalizer used on detail reads enforce this, so an event can never
|
||||
carry `proxy_id=null` together with a real proxy name.
|
||||
|
||||
## Why the account snapshot is event-time evidence
|
||||
|
||||
Account selection loads `ProxyID` and `Proxy` into one in-memory snapshot. HTTP,
|
||||
TLS-fingerprint, Bedrock, Gemini, Antigravity, OpenAI, and OpenAI WebSocket
|
||||
forwarding use that snapshot for managed proxy routing.
|
||||
Inference transports use a managed proxy only when both the binding ID and its
|
||||
hydrated proxy object are present; the attribution accessors use the same rule.
|
||||
OpenAI WebSocket additionally retains the existing default-client environment
|
||||
proxy behavior when that snapshot does not yield a usable proxy. The transport
|
||||
does not switch to a database backup proxy during a request.
|
||||
|
||||
Expired proxy fallback happens before scheduling by atomically rewriting the
|
||||
account binding:
|
||||
|
||||
- backup proxy: `proxy_id` becomes the backup ID and
|
||||
`proxy_fallback_origin_id` stores the expired proxy ID;
|
||||
- direct fallback: `proxy_id` becomes null and
|
||||
`proxy_fallback_origin_id` stores the expired proxy ID.
|
||||
|
||||
Custom Anthropic relays receive the same snapshotted proxy as their encoded
|
||||
relay proxy parameter. OpenAI WebSocket requests keep the existing transport
|
||||
behavior: configured account proxies use the proxy client, while an empty
|
||||
account proxy leaves the HTTP client unset so `coder/websocket` uses
|
||||
`http.DefaultClient`, including its `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`
|
||||
handling.
|
||||
|
||||
For WebSocket errors without a usable account proxy, the event stores
|
||||
`proxy_id=null` and `proxy_name=unknown`. It does not infer whether the default
|
||||
client selected an environment proxy or a direct connection after the fact.
|
||||
This applies to every event produced while a request is on the WebSocket
|
||||
transport, including WS handshake/fallback errors and the first-output timeout
|
||||
raised by the WS passthrough relay; `opsUpstreamWSProxyAttribution` is the
|
||||
accessor for those sites and never returns `direct/no_proxy`.
|
||||
|
||||
Credential acquisition failures that occur before the inference transport is
|
||||
opened are also recorded as unknown. The account's proxy binding is not treated
|
||||
as proof that the credential operation or an inference request used that route.
|
||||
|
||||
Each failure site constructs a complete `OpsUpstreamErrorEvent`. Its literal
|
||||
sets `proxy_id` and `proxy_name` from the same route input used by the attempt.
|
||||
Credential-free accessors copy only the managed proxy ID and name. A fallback
|
||||
proxy needs no special event flag: its event-time ID and name are the historical
|
||||
route evidence. Shared helpers that assemble the event on behalf of a caller
|
||||
(`handleUpstreamTransportError`, `handleOpenAIUpstreamTransportError`,
|
||||
`appendOpenAICompactFallbackRetryOps`, `recordOpenAIRawStreamTruncation`,
|
||||
`newOpenAIFirstOutputTimeoutError`) stamp attribution themselves, so a caller
|
||||
cannot forget it. Every non-test `OpsUpstreamErrorEvent{` literal in
|
||||
`backend/internal/service` must either set `ProxyName` or be passed to one of
|
||||
those helpers; grep for literals missing `ProxyName` before merging changes
|
||||
that add a failure site.
|
||||
|
||||
`appendOpsUpstreamError` accepts only the completed event. It does not receive
|
||||
an `Account`, query current account state, or infer proxy attribution while
|
||||
appending. Once appended, the event contains only scalar snapshot values, so
|
||||
later account mutation or failover cannot change an earlier event.
|
||||
|
||||
Queue sanitization keeps attempt events newest-first under hard bounds:
|
||||
|
||||
- the newest 16 attempts retain `detail` and `upstream_response_body` (each
|
||||
capped at the queue body limit) plus `upstream_url`/`message` up to 2048
|
||||
bytes;
|
||||
- older attempts keep timestamps, account, proxy, status, kind, stage, scope,
|
||||
reason and a `upstream_url`/`message` trimmed to 512 bytes; an attempt whose
|
||||
only payload was a cleared `detail` is still retained;
|
||||
- at most 256 attempts and roughly 512 KiB of serialized JSON are kept per
|
||||
request. When either bound drops attempts, the oldest retained event carries
|
||||
`dropped_earlier_attempts` with the number of discarded earlier attempts. The
|
||||
newest attempt is always retained because it decides the request outcome.
|
||||
|
||||
## Legacy events and analytics
|
||||
|
||||
Legacy JSON is not rewritten in the database. Detail reads edit only the
|
||||
`proxy_id`/`proxy_name` keys of events that lack valid attribution, so keys
|
||||
written by older struct versions and the stored key order are preserved. The
|
||||
shared parser applies the same rule to the decoded structs. Both materialize
|
||||
missing attribution as:
|
||||
|
||||
```json
|
||||
{
|
||||
"proxy_id": null,
|
||||
"proxy_name": "unknown"
|
||||
}
|
||||
```
|
||||
|
||||
Proxy and region aggregation must use the event fields first. A safe grouping
|
||||
rule is:
|
||||
|
||||
1. A non-null event `proxy_id` groups by that ID and uses the event
|
||||
`proxy_name` as the historical label. A join to `proxies` may enrich current
|
||||
metadata but must not replace the event label.
|
||||
2. `proxy_id=null` and `proxy_name=direct/no_proxy` groups as direct.
|
||||
3. Missing attribution, or `proxy_id=null` and `proxy_name=unknown`, groups as
|
||||
unknown. Do not join through the account's current `proxy_id` and present it
|
||||
as historical attribution.
|
||||
|
||||
If an operator deliberately produces a current-account cohort for legacy data,
|
||||
the report must be labeled as a current snapshot; it is not historical proxy
|
||||
attribution.
|
||||
+50
-4
@@ -122,6 +122,10 @@ type Group struct {
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"`
|
||||
// 是否允许此 OpenAI 分组访问 Live 接口
|
||||
AllowLive bool `json:"allow_live,omitempty"`
|
||||
// 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority
|
||||
ForceOpenaiFast bool `json:"force_openai_fast,omitempty"`
|
||||
// 是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费
|
||||
FreeOpenaiFast bool `json:"free_openai_fast,omitempty"`
|
||||
// 仅允许非 apikey 类型账号关联到此分组
|
||||
RequireOauthOnly bool `json:"require_oauth_only,omitempty"`
|
||||
// 调度时仅允许 privacy 已成功设置的账号
|
||||
@@ -132,11 +136,15 @@ type Group struct {
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||
// 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度
|
||||
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||
// 固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)
|
||||
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"`
|
||||
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
|
||||
RpmLimit int `json:"rpm_limit,omitempty"`
|
||||
// OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
|
||||
// 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
|
||||
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"`
|
||||
@@ -250,15 +258,15 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
|
||||
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort:
|
||||
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit:
|
||||
values[i] = new(sql.NullString)
|
||||
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
|
||||
values[i] = new(sql.NullTime)
|
||||
@@ -616,6 +624,18 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.AllowLive = value.Bool
|
||||
}
|
||||
case group.FieldForceOpenaiFast:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field force_openai_fast", values[i])
|
||||
} else if value.Valid {
|
||||
_m.ForceOpenaiFast = value.Bool
|
||||
}
|
||||
case group.FieldFreeOpenaiFast:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field free_openai_fast", values[i])
|
||||
} else if value.Valid {
|
||||
_m.FreeOpenaiFast = value.Bool
|
||||
}
|
||||
case group.FieldRequireOauthOnly:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i])
|
||||
@@ -650,6 +670,14 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
return fmt.Errorf("unmarshal field models_list_config: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field codex_models_manifest_config", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.CodexModelsManifestConfig); err != nil {
|
||||
return fmt.Errorf("unmarshal field codex_models_manifest_config: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldRpmLimit:
|
||||
if value, ok := values[i].(*sql.NullInt64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field rpm_limit", values[i])
|
||||
@@ -662,6 +690,12 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffort = value.String
|
||||
}
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
if value, ok := values[i].(*sql.NullString); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field max_reasoning_effort_over_limit", values[i])
|
||||
} else if value.Valid {
|
||||
_m.MaxReasoningEffortOverLimit = value.String
|
||||
}
|
||||
case group.FieldReasoningEffortMappings:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field reasoning_effort_mappings", values[i])
|
||||
@@ -955,6 +989,12 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("allow_live=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.AllowLive))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("force_openai_fast=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("free_openai_fast=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.FreeOpenaiFast))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("require_oauth_only=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly))
|
||||
builder.WriteString(", ")
|
||||
@@ -970,12 +1010,18 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("models_list_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("codex_models_manifest_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.CodexModelsManifestConfig))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("rpm_limit=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("max_reasoning_effort=")
|
||||
builder.WriteString(_m.MaxReasoningEffort)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("max_reasoning_effort_over_limit=")
|
||||
builder.WriteString(_m.MaxReasoningEffortOverLimit)
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("reasoning_effort_mappings=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -118,6 +118,10 @@ const (
|
||||
FieldAllowMessagesDispatch = "allow_messages_dispatch"
|
||||
// FieldAllowLive holds the string denoting the allow_live field in the database.
|
||||
FieldAllowLive = "allow_live"
|
||||
// FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database.
|
||||
FieldForceOpenaiFast = "force_openai_fast"
|
||||
// FieldFreeOpenaiFast holds the string denoting the free_openai_fast field in the database.
|
||||
FieldFreeOpenaiFast = "free_openai_fast"
|
||||
// FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database.
|
||||
FieldRequireOauthOnly = "require_oauth_only"
|
||||
// FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database.
|
||||
@@ -128,10 +132,14 @@ const (
|
||||
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
|
||||
// FieldModelsListConfig holds the string denoting the models_list_config field in the database.
|
||||
FieldModelsListConfig = "models_list_config"
|
||||
// FieldCodexModelsManifestConfig holds the string denoting the codex_models_manifest_config field in the database.
|
||||
FieldCodexModelsManifestConfig = "codex_models_manifest_config"
|
||||
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
|
||||
FieldRpmLimit = "rpm_limit"
|
||||
// FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database.
|
||||
FieldMaxReasoningEffort = "max_reasoning_effort"
|
||||
// FieldMaxReasoningEffortOverLimit holds the string denoting the max_reasoning_effort_over_limit field in the database.
|
||||
FieldMaxReasoningEffortOverLimit = "max_reasoning_effort_over_limit"
|
||||
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
|
||||
FieldReasoningEffortMappings = "reasoning_effort_mappings"
|
||||
// FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database.
|
||||
@@ -266,13 +274,17 @@ var Columns = []string{
|
||||
FieldSortOrder,
|
||||
FieldAllowMessagesDispatch,
|
||||
FieldAllowLive,
|
||||
FieldForceOpenaiFast,
|
||||
FieldFreeOpenaiFast,
|
||||
FieldRequireOauthOnly,
|
||||
FieldRequirePrivacySet,
|
||||
FieldDefaultMappedModel,
|
||||
FieldMessagesDispatchModelConfig,
|
||||
FieldModelsListConfig,
|
||||
FieldCodexModelsManifestConfig,
|
||||
FieldRpmLimit,
|
||||
FieldMaxReasoningEffort,
|
||||
FieldMaxReasoningEffortOverLimit,
|
||||
FieldReasoningEffortMappings,
|
||||
FieldProfitControlEnabled,
|
||||
FieldProfitMinMargin,
|
||||
@@ -386,6 +398,10 @@ var (
|
||||
DefaultAllowMessagesDispatch bool
|
||||
// DefaultAllowLive holds the default value on creation for the "allow_live" field.
|
||||
DefaultAllowLive bool
|
||||
// DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field.
|
||||
DefaultForceOpenaiFast bool
|
||||
// DefaultFreeOpenaiFast holds the default value on creation for the "free_openai_fast" field.
|
||||
DefaultFreeOpenaiFast bool
|
||||
// DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field.
|
||||
DefaultRequireOauthOnly bool
|
||||
// DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field.
|
||||
@@ -398,12 +414,18 @@ var (
|
||||
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
|
||||
// DefaultModelsListConfig holds the default value on creation for the "models_list_config" field.
|
||||
DefaultModelsListConfig domain.GroupModelsListConfig
|
||||
// DefaultCodexModelsManifestConfig holds the default value on creation for the "codex_models_manifest_config" field.
|
||||
DefaultCodexModelsManifestConfig domain.GroupCodexModelsManifestConfig
|
||||
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
|
||||
DefaultRpmLimit int
|
||||
// DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field.
|
||||
DefaultMaxReasoningEffort string
|
||||
// MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
MaxReasoningEffortValidator func(string) error
|
||||
// DefaultMaxReasoningEffortOverLimit holds the default value on creation for the "max_reasoning_effort_over_limit" field.
|
||||
DefaultMaxReasoningEffortOverLimit string
|
||||
// MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
MaxReasoningEffortOverLimitValidator func(string) error
|
||||
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
|
||||
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
|
||||
// DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field.
|
||||
@@ -657,6 +679,16 @@ func ByAllowLive(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldAllowLive, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByForceOpenaiFast orders the results by the force_openai_fast field.
|
||||
func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByFreeOpenaiFast orders the results by the free_openai_fast field.
|
||||
func ByFreeOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldFreeOpenaiFast, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByRequireOauthOnly orders the results by the require_oauth_only field.
|
||||
func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc()
|
||||
@@ -682,6 +714,11 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByMaxReasoningEffortOverLimit orders the results by the max_reasoning_effort_over_limit field.
|
||||
func ByMaxReasoningEffortOverLimit(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffortOverLimit, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByProfitControlEnabled orders the results by the profit_control_enabled field.
|
||||
func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc()
|
||||
|
||||
@@ -290,6 +290,16 @@ func AllowLive(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldAllowLive, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFast applies equality check predicate on the "force_openai_fast" field. It's identical to ForceOpenaiFastEQ.
|
||||
func ForceOpenaiFast(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFast applies equality check predicate on the "free_openai_fast" field. It's identical to FreeOpenaiFastEQ.
|
||||
func FreeOpenaiFast(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ.
|
||||
func RequireOauthOnly(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
|
||||
@@ -315,6 +325,11 @@ func MaxReasoningEffort(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimit applies equality check predicate on the "max_reasoning_effort_over_limit" field. It's identical to MaxReasoningEffortOverLimitEQ.
|
||||
func MaxReasoningEffortOverLimit(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ.
|
||||
func ProfitControlEnabled(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
@@ -2270,6 +2285,26 @@ func AllowLiveNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldAllowLive, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFastEQ applies the EQ predicate on the "force_openai_fast" field.
|
||||
func ForceOpenaiFastEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// ForceOpenaiFastNEQ applies the NEQ predicate on the "force_openai_fast" field.
|
||||
func ForceOpenaiFastNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFastEQ applies the EQ predicate on the "free_openai_fast" field.
|
||||
func FreeOpenaiFastEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// FreeOpenaiFastNEQ applies the NEQ predicate on the "free_openai_fast" field.
|
||||
func FreeOpenaiFastNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldFreeOpenaiFast, v))
|
||||
}
|
||||
|
||||
// RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field.
|
||||
func RequireOauthOnlyEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
|
||||
@@ -2460,6 +2495,71 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitEQ applies the EQ predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitNEQ applies the NEQ predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitNEQ(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitIn applies the In predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldMaxReasoningEffortOverLimit, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitNotIn applies the NotIn predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitNotIn(vs ...string) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffortOverLimit, vs...))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitGT applies the GT predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitGT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitGTE applies the GTE predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitGTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitLT applies the LT predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitLT(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitLTE applies the LTE predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitLTE(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitContains applies the Contains predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitContains(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContains(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitHasPrefix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitHasSuffix(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitEqualFold applies the EqualFold predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitEqualFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimitContainsFold applies the ContainsFold predicate on the "max_reasoning_effort_over_limit" field.
|
||||
func MaxReasoningEffortOverLimitContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffortOverLimit, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field.
|
||||
func ProfitControlEnabledEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
|
||||
@@ -704,6 +704,34 @@ func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_c *GroupCreate) SetForceOpenaiFast(v bool) *GroupCreate {
|
||||
_c.mutation.SetForceOpenaiFast(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_c *GroupCreate) SetFreeOpenaiFast(v bool) *GroupCreate {
|
||||
_c.mutation.SetFreeOpenaiFast(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableFreeOpenaiFast(v *bool) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate {
|
||||
_c.mutation.SetRequireOauthOnly(v)
|
||||
@@ -774,6 +802,20 @@ func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConf
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_c *GroupCreate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupCreate {
|
||||
_c.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
func (_c *GroupCreate) SetRpmLimit(v int) *GroupCreate {
|
||||
_c.mutation.SetRpmLimit(v)
|
||||
@@ -802,6 +844,20 @@ func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_c *GroupCreate) SetMaxReasoningEffortOverLimit(v string) *GroupCreate {
|
||||
_c.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate {
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1095,6 +1151,14 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultAllowLive
|
||||
_c.mutation.SetAllowLive(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
|
||||
v := group.DefaultForceOpenaiFast
|
||||
_c.mutation.SetForceOpenaiFast(v)
|
||||
}
|
||||
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
|
||||
v := group.DefaultFreeOpenaiFast
|
||||
_c.mutation.SetFreeOpenaiFast(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
|
||||
v := group.DefaultRequireOauthOnly
|
||||
_c.mutation.SetRequireOauthOnly(v)
|
||||
@@ -1115,6 +1179,10 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultModelsListConfig
|
||||
_c.mutation.SetModelsListConfig(v)
|
||||
}
|
||||
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
|
||||
v := group.DefaultCodexModelsManifestConfig
|
||||
_c.mutation.SetCodexModelsManifestConfig(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||
v := group.DefaultRpmLimit
|
||||
_c.mutation.SetRpmLimit(v)
|
||||
@@ -1123,6 +1191,10 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultMaxReasoningEffort
|
||||
_c.mutation.SetMaxReasoningEffort(v)
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
|
||||
v := group.DefaultMaxReasoningEffortOverLimit
|
||||
_c.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
v := group.DefaultReasoningEffortMappings
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1286,6 +1358,12 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.AllowLive(); !ok {
|
||||
return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
|
||||
return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
|
||||
return &ValidationError{Name: "free_openai_fast", err: errors.New(`ent: missing required field "Group.free_openai_fast"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
|
||||
return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)}
|
||||
}
|
||||
@@ -1306,6 +1384,9 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.ModelsListConfig(); !ok {
|
||||
return &ValidationError{Name: "models_list_config", err: errors.New(`ent: missing required field "Group.models_list_config"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
|
||||
return &ValidationError{Name: "codex_models_manifest_config", err: errors.New(`ent: missing required field "Group.codex_models_manifest_config"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)}
|
||||
}
|
||||
@@ -1317,6 +1398,14 @@ func (_c *GroupCreate) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: errors.New(`ent: missing required field "Group.max_reasoning_effort_over_limit"`)}
|
||||
}
|
||||
if v, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)}
|
||||
}
|
||||
@@ -1560,6 +1649,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
_node.AllowLive = value
|
||||
}
|
||||
if value, ok := _c.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
_node.ForceOpenaiFast = value
|
||||
}
|
||||
if value, ok := _c.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
_node.FreeOpenaiFast = value
|
||||
}
|
||||
if value, ok := _c.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
_node.RequireOauthOnly = value
|
||||
@@ -1580,6 +1677,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
_node.ModelsListConfig = value
|
||||
}
|
||||
if value, ok := _c.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
_node.CodexModelsManifestConfig = value
|
||||
}
|
||||
if value, ok := _c.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
_node.RpmLimit = value
|
||||
@@ -1588,6 +1689,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
_node.MaxReasoningEffort = value
|
||||
}
|
||||
if value, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
_node.MaxReasoningEffortOverLimit = value
|
||||
}
|
||||
if value, ok := _c.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
_node.ReasoningEffortMappings = value
|
||||
@@ -2618,6 +2723,30 @@ func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsert) SetForceOpenaiFast(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldForceOpenaiFast, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldForceOpenaiFast)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsert) SetFreeOpenaiFast(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldFreeOpenaiFast, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateFreeOpenaiFast() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldFreeOpenaiFast)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldRequireOauthOnly, v)
|
||||
@@ -2678,6 +2807,18 @@ func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsert) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsert {
|
||||
u.Set(group.FieldCodexModelsManifestConfig, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateCodexModelsManifestConfig() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldCodexModelsManifestConfig)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
func (u *GroupUpsert) SetRpmLimit(v int) *GroupUpsert {
|
||||
u.Set(group.FieldRpmLimit, v)
|
||||
@@ -2708,6 +2849,18 @@ func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsert) SetMaxReasoningEffortOverLimit(v string) *GroupUpsert {
|
||||
u.Set(group.FieldMaxReasoningEffortOverLimit, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateMaxReasoningEffortOverLimit() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldMaxReasoningEffortOverLimit)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert {
|
||||
u.Set(group.FieldReasoningEffortMappings, v)
|
||||
@@ -3817,6 +3970,34 @@ func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsertOne) SetForceOpenaiFast(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetForceOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateForceOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsertOne) SetFreeOpenaiFast(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetFreeOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateFreeOpenaiFast() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateFreeOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -3887,6 +4068,20 @@ func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsertOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetCodexModelsManifestConfig(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateCodexModelsManifestConfig() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateCodexModelsManifestConfig()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
func (u *GroupUpsertOne) SetRpmLimit(v int) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -3922,6 +4117,20 @@ func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsertOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffortOverLimit(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateMaxReasoningEffortOverLimit() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffortOverLimit()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -5207,6 +5416,34 @@ func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (u *GroupUpsertBulk) SetForceOpenaiFast(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetForceOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateForceOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (u *GroupUpsertBulk) SetFreeOpenaiFast(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetFreeOpenaiFast(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateFreeOpenaiFast() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateFreeOpenaiFast()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -5277,6 +5514,20 @@ func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (u *GroupUpsertBulk) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetCodexModelsManifestConfig(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateCodexModelsManifestConfig() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateCodexModelsManifestConfig()
|
||||
})
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
func (u *GroupUpsertBulk) SetRpmLimit(v int) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
@@ -5312,6 +5563,20 @@ func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (u *GroupUpsertBulk) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetMaxReasoningEffortOverLimit(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateMaxReasoningEffortOverLimit() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateMaxReasoningEffortOverLimit()
|
||||
})
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
|
||||
@@ -989,6 +989,34 @@ func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_u *GroupUpdate) SetForceOpenaiFast(v bool) *GroupUpdate {
|
||||
_u.mutation.SetForceOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_u *GroupUpdate) SetFreeOpenaiFast(v bool) *GroupUpdate {
|
||||
_u.mutation.SetFreeOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableFreeOpenaiFast(v *bool) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate {
|
||||
_u.mutation.SetRequireOauthOnly(v)
|
||||
@@ -1059,6 +1087,20 @@ func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConf
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_u *GroupUpdate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdate {
|
||||
_u.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
func (_u *GroupUpdate) SetRpmLimit(v int) *GroupUpdate {
|
||||
_u.mutation.ResetRpmLimit()
|
||||
@@ -1094,6 +1136,20 @@ func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_u *GroupUpdate) SetMaxReasoningEffortOverLimit(v string) *GroupUpdate {
|
||||
_u.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -1487,6 +1543,11 @@ func (_u *GroupUpdate) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1797,6 +1858,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.AllowLive(); ok {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
}
|
||||
@@ -1812,6 +1879,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
}
|
||||
@@ -1821,6 +1891,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
@@ -3110,6 +3183,34 @@ func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (_u *GroupUpdateOne) SetForceOpenaiFast(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetForceOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetForceOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (_u *GroupUpdateOne) SetFreeOpenaiFast(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetFreeOpenaiFast(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableFreeOpenaiFast(v *bool) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetFreeOpenaiFast(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetRequireOauthOnly(v)
|
||||
@@ -3180,6 +3281,20 @@ func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListC
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (_u *GroupUpdateOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
|
||||
_u.mutation.SetCodexModelsManifestConfig(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetCodexModelsManifestConfig(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
func (_u *GroupUpdateOne) SetRpmLimit(v int) *GroupUpdateOne {
|
||||
_u.mutation.ResetRpmLimit()
|
||||
@@ -3215,6 +3330,20 @@ func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateO
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (_u *GroupUpdateOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpdateOne {
|
||||
_u.mutation.SetMaxReasoningEffortOverLimit(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetMaxReasoningEffortOverLimit(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne {
|
||||
_u.mutation.SetReasoningEffortMappings(v)
|
||||
@@ -3621,6 +3750,11 @@ func (_u *GroupUpdateOne) check() error {
|
||||
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
|
||||
}
|
||||
}
|
||||
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
|
||||
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -3948,6 +4082,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.AllowLive(); ok {
|
||||
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
|
||||
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RequireOauthOnly(); ok {
|
||||
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
|
||||
}
|
||||
@@ -3963,6 +4103,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
|
||||
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.RpmLimit(); ok {
|
||||
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
|
||||
}
|
||||
@@ -3972,6 +4115,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
|
||||
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
}
|
||||
|
||||
@@ -954,13 +954,17 @@ var (
|
||||
{Name: "sort_order", Type: field.TypeInt, Default: 0},
|
||||
{Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false},
|
||||
{Name: "allow_live", Type: field.TypeBool, Default: false},
|
||||
{Name: "force_openai_fast", Type: field.TypeBool, Default: false},
|
||||
{Name: "free_openai_fast", Type: field.TypeBool, Default: false},
|
||||
{Name: "require_oauth_only", Type: field.TypeBool, Default: false},
|
||||
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
|
||||
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
|
||||
{Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "codex_models_manifest_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
|
||||
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
|
||||
{Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"},
|
||||
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "profit_control_enabled", Type: field.TypeBool, Default: false},
|
||||
{Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
@@ -1801,6 +1805,7 @@ var (
|
||||
{Name: "signup_source", Type: field.TypeString, Default: "email"},
|
||||
{Name: "last_login_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "last_active_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "restrict_public_groups", Type: field.TypeBool, Default: false},
|
||||
{Name: "balance_notify_enabled", Type: field.TypeBool, Default: true},
|
||||
{Name: "balance_notify_threshold_type", Type: field.TypeString, Default: "fixed"},
|
||||
{Name: "balance_notify_threshold", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
|
||||
|
||||
+272
-2
@@ -22158,14 +22158,18 @@ type GroupMutation struct {
|
||||
addsort_order *int
|
||||
allow_messages_dispatch *bool
|
||||
allow_live *bool
|
||||
force_openai_fast *bool
|
||||
free_openai_fast *bool
|
||||
require_oauth_only *bool
|
||||
require_privacy_set *bool
|
||||
default_mapped_model *string
|
||||
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
|
||||
models_list_config *domain.GroupModelsListConfig
|
||||
codex_models_manifest_config *domain.GroupCodexModelsManifestConfig
|
||||
rpm_limit *int
|
||||
addrpm_limit *int
|
||||
max_reasoning_effort *string
|
||||
max_reasoning_effort_over_limit *string
|
||||
reasoning_effort_mappings *[]domain.ReasoningEffortMapping
|
||||
appendreasoning_effort_mappings []domain.ReasoningEffortMapping
|
||||
profit_control_enabled *bool
|
||||
@@ -24944,6 +24948,78 @@ func (m *GroupMutation) ResetAllowLive() {
|
||||
m.allow_live = nil
|
||||
}
|
||||
|
||||
// SetForceOpenaiFast sets the "force_openai_fast" field.
|
||||
func (m *GroupMutation) SetForceOpenaiFast(b bool) {
|
||||
m.force_openai_fast = &b
|
||||
}
|
||||
|
||||
// ForceOpenaiFast returns the value of the "force_openai_fast" field in the mutation.
|
||||
func (m *GroupMutation) ForceOpenaiFast() (r bool, exists bool) {
|
||||
v := m.force_openai_fast
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldForceOpenaiFast returns the old "force_openai_fast" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldForceOpenaiFast(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldForceOpenaiFast is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldForceOpenaiFast requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldForceOpenaiFast: %w", err)
|
||||
}
|
||||
return oldValue.ForceOpenaiFast, nil
|
||||
}
|
||||
|
||||
// ResetForceOpenaiFast resets all changes to the "force_openai_fast" field.
|
||||
func (m *GroupMutation) ResetForceOpenaiFast() {
|
||||
m.force_openai_fast = nil
|
||||
}
|
||||
|
||||
// SetFreeOpenaiFast sets the "free_openai_fast" field.
|
||||
func (m *GroupMutation) SetFreeOpenaiFast(b bool) {
|
||||
m.free_openai_fast = &b
|
||||
}
|
||||
|
||||
// FreeOpenaiFast returns the value of the "free_openai_fast" field in the mutation.
|
||||
func (m *GroupMutation) FreeOpenaiFast() (r bool, exists bool) {
|
||||
v := m.free_openai_fast
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldFreeOpenaiFast returns the old "free_openai_fast" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldFreeOpenaiFast(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldFreeOpenaiFast is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldFreeOpenaiFast requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldFreeOpenaiFast: %w", err)
|
||||
}
|
||||
return oldValue.FreeOpenaiFast, nil
|
||||
}
|
||||
|
||||
// ResetFreeOpenaiFast resets all changes to the "free_openai_fast" field.
|
||||
func (m *GroupMutation) ResetFreeOpenaiFast() {
|
||||
m.free_openai_fast = nil
|
||||
}
|
||||
|
||||
// SetRequireOauthOnly sets the "require_oauth_only" field.
|
||||
func (m *GroupMutation) SetRequireOauthOnly(b bool) {
|
||||
m.require_oauth_only = &b
|
||||
@@ -25124,6 +25200,42 @@ func (m *GroupMutation) ResetModelsListConfig() {
|
||||
m.models_list_config = nil
|
||||
}
|
||||
|
||||
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
|
||||
func (m *GroupMutation) SetCodexModelsManifestConfig(dcmmc domain.GroupCodexModelsManifestConfig) {
|
||||
m.codex_models_manifest_config = &dcmmc
|
||||
}
|
||||
|
||||
// CodexModelsManifestConfig returns the value of the "codex_models_manifest_config" field in the mutation.
|
||||
func (m *GroupMutation) CodexModelsManifestConfig() (r domain.GroupCodexModelsManifestConfig, exists bool) {
|
||||
v := m.codex_models_manifest_config
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldCodexModelsManifestConfig returns the old "codex_models_manifest_config" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldCodexModelsManifestConfig(ctx context.Context) (v domain.GroupCodexModelsManifestConfig, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldCodexModelsManifestConfig is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldCodexModelsManifestConfig requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldCodexModelsManifestConfig: %w", err)
|
||||
}
|
||||
return oldValue.CodexModelsManifestConfig, nil
|
||||
}
|
||||
|
||||
// ResetCodexModelsManifestConfig resets all changes to the "codex_models_manifest_config" field.
|
||||
func (m *GroupMutation) ResetCodexModelsManifestConfig() {
|
||||
m.codex_models_manifest_config = nil
|
||||
}
|
||||
|
||||
// SetRpmLimit sets the "rpm_limit" field.
|
||||
func (m *GroupMutation) SetRpmLimit(i int) {
|
||||
m.rpm_limit = &i
|
||||
@@ -25216,6 +25328,42 @@ func (m *GroupMutation) ResetMaxReasoningEffort() {
|
||||
m.max_reasoning_effort = nil
|
||||
}
|
||||
|
||||
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
|
||||
func (m *GroupMutation) SetMaxReasoningEffortOverLimit(s string) {
|
||||
m.max_reasoning_effort_over_limit = &s
|
||||
}
|
||||
|
||||
// MaxReasoningEffortOverLimit returns the value of the "max_reasoning_effort_over_limit" field in the mutation.
|
||||
func (m *GroupMutation) MaxReasoningEffortOverLimit() (r string, exists bool) {
|
||||
v := m.max_reasoning_effort_over_limit
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldMaxReasoningEffortOverLimit returns the old "max_reasoning_effort_over_limit" field's value of the Group entity.
|
||||
// If the Group object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *GroupMutation) OldMaxReasoningEffortOverLimit(ctx context.Context) (v string, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldMaxReasoningEffortOverLimit is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldMaxReasoningEffortOverLimit requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldMaxReasoningEffortOverLimit: %w", err)
|
||||
}
|
||||
return oldValue.MaxReasoningEffortOverLimit, nil
|
||||
}
|
||||
|
||||
// ResetMaxReasoningEffortOverLimit resets all changes to the "max_reasoning_effort_over_limit" field.
|
||||
func (m *GroupMutation) ResetMaxReasoningEffortOverLimit() {
|
||||
m.max_reasoning_effort_over_limit = nil
|
||||
}
|
||||
|
||||
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
|
||||
func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) {
|
||||
m.reasoning_effort_mappings = &dem
|
||||
@@ -25773,7 +25921,7 @@ func (m *GroupMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *GroupMutation) Fields() []string {
|
||||
fields := make([]string, 0, 62)
|
||||
fields := make([]string, 0, 66)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -25927,6 +26075,12 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.allow_live != nil {
|
||||
fields = append(fields, group.FieldAllowLive)
|
||||
}
|
||||
if m.force_openai_fast != nil {
|
||||
fields = append(fields, group.FieldForceOpenaiFast)
|
||||
}
|
||||
if m.free_openai_fast != nil {
|
||||
fields = append(fields, group.FieldFreeOpenaiFast)
|
||||
}
|
||||
if m.require_oauth_only != nil {
|
||||
fields = append(fields, group.FieldRequireOauthOnly)
|
||||
}
|
||||
@@ -25942,12 +26096,18 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.models_list_config != nil {
|
||||
fields = append(fields, group.FieldModelsListConfig)
|
||||
}
|
||||
if m.codex_models_manifest_config != nil {
|
||||
fields = append(fields, group.FieldCodexModelsManifestConfig)
|
||||
}
|
||||
if m.rpm_limit != nil {
|
||||
fields = append(fields, group.FieldRpmLimit)
|
||||
}
|
||||
if m.max_reasoning_effort != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffort)
|
||||
}
|
||||
if m.max_reasoning_effort_over_limit != nil {
|
||||
fields = append(fields, group.FieldMaxReasoningEffortOverLimit)
|
||||
}
|
||||
if m.reasoning_effort_mappings != nil {
|
||||
fields = append(fields, group.FieldReasoningEffortMappings)
|
||||
}
|
||||
@@ -26070,6 +26230,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.AllowMessagesDispatch()
|
||||
case group.FieldAllowLive:
|
||||
return m.AllowLive()
|
||||
case group.FieldForceOpenaiFast:
|
||||
return m.ForceOpenaiFast()
|
||||
case group.FieldFreeOpenaiFast:
|
||||
return m.FreeOpenaiFast()
|
||||
case group.FieldRequireOauthOnly:
|
||||
return m.RequireOauthOnly()
|
||||
case group.FieldRequirePrivacySet:
|
||||
@@ -26080,10 +26244,14 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.MessagesDispatchModelConfig()
|
||||
case group.FieldModelsListConfig:
|
||||
return m.ModelsListConfig()
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
return m.CodexModelsManifestConfig()
|
||||
case group.FieldRpmLimit:
|
||||
return m.RpmLimit()
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.MaxReasoningEffort()
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
return m.MaxReasoningEffortOverLimit()
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.ReasoningEffortMappings()
|
||||
case group.FieldProfitControlEnabled:
|
||||
@@ -26203,6 +26371,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldAllowMessagesDispatch(ctx)
|
||||
case group.FieldAllowLive:
|
||||
return m.OldAllowLive(ctx)
|
||||
case group.FieldForceOpenaiFast:
|
||||
return m.OldForceOpenaiFast(ctx)
|
||||
case group.FieldFreeOpenaiFast:
|
||||
return m.OldFreeOpenaiFast(ctx)
|
||||
case group.FieldRequireOauthOnly:
|
||||
return m.OldRequireOauthOnly(ctx)
|
||||
case group.FieldRequirePrivacySet:
|
||||
@@ -26213,10 +26385,14 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldMessagesDispatchModelConfig(ctx)
|
||||
case group.FieldModelsListConfig:
|
||||
return m.OldModelsListConfig(ctx)
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
return m.OldCodexModelsManifestConfig(ctx)
|
||||
case group.FieldRpmLimit:
|
||||
return m.OldRpmLimit(ctx)
|
||||
case group.FieldMaxReasoningEffort:
|
||||
return m.OldMaxReasoningEffort(ctx)
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
return m.OldMaxReasoningEffortOverLimit(ctx)
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.OldReasoningEffortMappings(ctx)
|
||||
case group.FieldProfitControlEnabled:
|
||||
@@ -26591,6 +26767,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetAllowLive(v)
|
||||
return nil
|
||||
case group.FieldForceOpenaiFast:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetForceOpenaiFast(v)
|
||||
return nil
|
||||
case group.FieldFreeOpenaiFast:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetFreeOpenaiFast(v)
|
||||
return nil
|
||||
case group.FieldRequireOauthOnly:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -26626,6 +26816,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetModelsListConfig(v)
|
||||
return nil
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
v, ok := value.(domain.GroupCodexModelsManifestConfig)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetCodexModelsManifestConfig(v)
|
||||
return nil
|
||||
case group.FieldRpmLimit:
|
||||
v, ok := value.(int)
|
||||
if !ok {
|
||||
@@ -26640,6 +26837,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetMaxReasoningEffort(v)
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
v, ok := value.(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetMaxReasoningEffortOverLimit(v)
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
v, ok := value.([]domain.ReasoningEffortMapping)
|
||||
if !ok {
|
||||
@@ -27332,6 +27536,12 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldAllowLive:
|
||||
m.ResetAllowLive()
|
||||
return nil
|
||||
case group.FieldForceOpenaiFast:
|
||||
m.ResetForceOpenaiFast()
|
||||
return nil
|
||||
case group.FieldFreeOpenaiFast:
|
||||
m.ResetFreeOpenaiFast()
|
||||
return nil
|
||||
case group.FieldRequireOauthOnly:
|
||||
m.ResetRequireOauthOnly()
|
||||
return nil
|
||||
@@ -27347,12 +27557,18 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldModelsListConfig:
|
||||
m.ResetModelsListConfig()
|
||||
return nil
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
m.ResetCodexModelsManifestConfig()
|
||||
return nil
|
||||
case group.FieldRpmLimit:
|
||||
m.ResetRpmLimit()
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffort:
|
||||
m.ResetMaxReasoningEffort()
|
||||
return nil
|
||||
case group.FieldMaxReasoningEffortOverLimit:
|
||||
m.ResetMaxReasoningEffortOverLimit()
|
||||
return nil
|
||||
case group.FieldReasoningEffortMappings:
|
||||
m.ResetReasoningEffortMappings()
|
||||
return nil
|
||||
@@ -48422,6 +48638,7 @@ type UserMutation struct {
|
||||
signup_source *string
|
||||
last_login_at *time.Time
|
||||
last_active_at *time.Time
|
||||
restrict_public_groups *bool
|
||||
balance_notify_enabled *bool
|
||||
balance_notify_threshold_type *string
|
||||
balance_notify_threshold *float64
|
||||
@@ -49347,6 +49564,42 @@ func (m *UserMutation) ResetLastActiveAt() {
|
||||
delete(m.clearedFields, user.FieldLastActiveAt)
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (m *UserMutation) SetRestrictPublicGroups(b bool) {
|
||||
m.restrict_public_groups = &b
|
||||
}
|
||||
|
||||
// RestrictPublicGroups returns the value of the "restrict_public_groups" field in the mutation.
|
||||
func (m *UserMutation) RestrictPublicGroups() (r bool, exists bool) {
|
||||
v := m.restrict_public_groups
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldRestrictPublicGroups returns the old "restrict_public_groups" field's value of the User entity.
|
||||
// If the User object wasn't provided to the builder, the object is fetched from the database.
|
||||
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
|
||||
func (m *UserMutation) OldRestrictPublicGroups(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldRestrictPublicGroups is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldRestrictPublicGroups requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldRestrictPublicGroups: %w", err)
|
||||
}
|
||||
return oldValue.RestrictPublicGroups, nil
|
||||
}
|
||||
|
||||
// ResetRestrictPublicGroups resets all changes to the "restrict_public_groups" field.
|
||||
func (m *UserMutation) ResetRestrictPublicGroups() {
|
||||
m.restrict_public_groups = nil
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (m *UserMutation) SetBalanceNotifyEnabled(b bool) {
|
||||
m.balance_notify_enabled = &b
|
||||
@@ -50373,7 +50626,7 @@ func (m *UserMutation) Type() string {
|
||||
// order to get all numeric fields that were incremented/decremented, call
|
||||
// AddedFields().
|
||||
func (m *UserMutation) Fields() []string {
|
||||
fields := make([]string, 0, 24)
|
||||
fields := make([]string, 0, 25)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, user.FieldCreatedAt)
|
||||
}
|
||||
@@ -50428,6 +50681,9 @@ func (m *UserMutation) Fields() []string {
|
||||
if m.last_active_at != nil {
|
||||
fields = append(fields, user.FieldLastActiveAt)
|
||||
}
|
||||
if m.restrict_public_groups != nil {
|
||||
fields = append(fields, user.FieldRestrictPublicGroups)
|
||||
}
|
||||
if m.balance_notify_enabled != nil {
|
||||
fields = append(fields, user.FieldBalanceNotifyEnabled)
|
||||
}
|
||||
@@ -50490,6 +50746,8 @@ func (m *UserMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.LastLoginAt()
|
||||
case user.FieldLastActiveAt:
|
||||
return m.LastActiveAt()
|
||||
case user.FieldRestrictPublicGroups:
|
||||
return m.RestrictPublicGroups()
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
return m.BalanceNotifyEnabled()
|
||||
case user.FieldBalanceNotifyThresholdType:
|
||||
@@ -50547,6 +50805,8 @@ func (m *UserMutation) OldField(ctx context.Context, name string) (ent.Value, er
|
||||
return m.OldLastLoginAt(ctx)
|
||||
case user.FieldLastActiveAt:
|
||||
return m.OldLastActiveAt(ctx)
|
||||
case user.FieldRestrictPublicGroups:
|
||||
return m.OldRestrictPublicGroups(ctx)
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
return m.OldBalanceNotifyEnabled(ctx)
|
||||
case user.FieldBalanceNotifyThresholdType:
|
||||
@@ -50694,6 +50954,13 @@ func (m *UserMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetLastActiveAt(v)
|
||||
return nil
|
||||
case user.FieldRestrictPublicGroups:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetRestrictPublicGroups(v)
|
||||
return nil
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
@@ -50953,6 +51220,9 @@ func (m *UserMutation) ResetField(name string) error {
|
||||
case user.FieldLastActiveAt:
|
||||
m.ResetLastActiveAt()
|
||||
return nil
|
||||
case user.FieldRestrictPublicGroups:
|
||||
m.ResetRestrictPublicGroups()
|
||||
return nil
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
m.ResetBalanceNotifyEnabled()
|
||||
return nil
|
||||
|
||||
@@ -1157,52 +1157,70 @@ func init() {
|
||||
groupDescAllowLive := groupFields[47].Descriptor()
|
||||
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
|
||||
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
|
||||
// groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field.
|
||||
groupDescForceOpenaiFast := groupFields[48].Descriptor()
|
||||
// group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field.
|
||||
group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool)
|
||||
// groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field.
|
||||
groupDescFreeOpenaiFast := groupFields[49].Descriptor()
|
||||
// group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field.
|
||||
group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool)
|
||||
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
|
||||
groupDescRequireOauthOnly := groupFields[48].Descriptor()
|
||||
groupDescRequireOauthOnly := groupFields[50].Descriptor()
|
||||
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
|
||||
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
|
||||
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
|
||||
groupDescRequirePrivacySet := groupFields[49].Descriptor()
|
||||
groupDescRequirePrivacySet := groupFields[51].Descriptor()
|
||||
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
|
||||
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
|
||||
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
|
||||
groupDescDefaultMappedModel := groupFields[50].Descriptor()
|
||||
groupDescDefaultMappedModel := groupFields[52].Descriptor()
|
||||
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
|
||||
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
|
||||
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
|
||||
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
|
||||
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
|
||||
groupDescMessagesDispatchModelConfig := groupFields[51].Descriptor()
|
||||
groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor()
|
||||
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
|
||||
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
|
||||
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
|
||||
groupDescModelsListConfig := groupFields[52].Descriptor()
|
||||
groupDescModelsListConfig := groupFields[54].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field.
|
||||
groupDescCodexModelsManifestConfig := groupFields[55].Descriptor()
|
||||
// group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field.
|
||||
group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig)
|
||||
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
groupDescRpmLimit := groupFields[53].Descriptor()
|
||||
groupDescRpmLimit := groupFields[56].Descriptor()
|
||||
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
|
||||
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
|
||||
groupDescMaxReasoningEffort := groupFields[54].Descriptor()
|
||||
groupDescMaxReasoningEffort := groupFields[57].Descriptor()
|
||||
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
|
||||
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
|
||||
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
|
||||
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
|
||||
groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor()
|
||||
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
|
||||
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
|
||||
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
|
||||
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
|
||||
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
|
||||
groupDescReasoningEffortMappings := groupFields[55].Descriptor()
|
||||
groupDescReasoningEffortMappings := groupFields[59].Descriptor()
|
||||
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
|
||||
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
|
||||
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
|
||||
groupDescProfitControlEnabled := groupFields[56].Descriptor()
|
||||
groupDescProfitControlEnabled := groupFields[60].Descriptor()
|
||||
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
|
||||
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
|
||||
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
|
||||
groupDescProfitMinMargin := groupFields[57].Descriptor()
|
||||
groupDescProfitMinMargin := groupFields[61].Descriptor()
|
||||
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
|
||||
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
|
||||
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
|
||||
groupDescProfitSafetyBuffer := groupFields[58].Descriptor()
|
||||
groupDescProfitSafetyBuffer := groupFields[62].Descriptor()
|
||||
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
|
||||
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
@@ -2217,24 +2235,28 @@ func init() {
|
||||
user.DefaultSignupSource = userDescSignupSource.Default.(string)
|
||||
// user.SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
|
||||
user.SignupSourceValidator = userDescSignupSource.Validators[0].(func(string) error)
|
||||
// userDescRestrictPublicGroups is the schema descriptor for restrict_public_groups field.
|
||||
userDescRestrictPublicGroups := userFields[15].Descriptor()
|
||||
// user.DefaultRestrictPublicGroups holds the default value on creation for the restrict_public_groups field.
|
||||
user.DefaultRestrictPublicGroups = userDescRestrictPublicGroups.Default.(bool)
|
||||
// userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field.
|
||||
userDescBalanceNotifyEnabled := userFields[15].Descriptor()
|
||||
userDescBalanceNotifyEnabled := userFields[16].Descriptor()
|
||||
// user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field.
|
||||
user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool)
|
||||
// userDescBalanceNotifyThresholdType is the schema descriptor for balance_notify_threshold_type field.
|
||||
userDescBalanceNotifyThresholdType := userFields[16].Descriptor()
|
||||
userDescBalanceNotifyThresholdType := userFields[17].Descriptor()
|
||||
// user.DefaultBalanceNotifyThresholdType holds the default value on creation for the balance_notify_threshold_type field.
|
||||
user.DefaultBalanceNotifyThresholdType = userDescBalanceNotifyThresholdType.Default.(string)
|
||||
// userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field.
|
||||
userDescBalanceNotifyExtraEmails := userFields[18].Descriptor()
|
||||
userDescBalanceNotifyExtraEmails := userFields[19].Descriptor()
|
||||
// user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field.
|
||||
user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string)
|
||||
// userDescTotalRecharged is the schema descriptor for total_recharged field.
|
||||
userDescTotalRecharged := userFields[19].Descriptor()
|
||||
userDescTotalRecharged := userFields[20].Descriptor()
|
||||
// user.DefaultTotalRecharged holds the default value on creation for the total_recharged field.
|
||||
user.DefaultTotalRecharged = userDescTotalRecharged.Default.(float64)
|
||||
// userDescRpmLimit is the schema descriptor for rpm_limit field.
|
||||
userDescRpmLimit := userFields[20].Descriptor()
|
||||
userDescRpmLimit := userFields[21].Descriptor()
|
||||
// user.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
|
||||
user.DefaultRpmLimit = userDescRpmLimit.Default.(int)
|
||||
userallowedgroupFields := schema.UserAllowedGroup{}.Fields()
|
||||
|
||||
@@ -242,6 +242,12 @@ func (Group) Fields() []ent.Field {
|
||||
field.Bool("allow_live").
|
||||
Default(false).
|
||||
Comment("是否允许此 OpenAI 分组访问 Live 接口"),
|
||||
field.Bool("force_openai_fast").
|
||||
Default(false).
|
||||
Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"),
|
||||
field.Bool("free_openai_fast").
|
||||
Default(false).
|
||||
Comment("是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费"),
|
||||
field.Bool("require_oauth_only").
|
||||
Default(false).
|
||||
Comment("仅允许非 apikey 类型账号关联到此分组"),
|
||||
@@ -260,6 +266,10 @@ func (Group) Fields() []ent.Field {
|
||||
Default(domain.GroupModelsListConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
|
||||
field.JSON("codex_models_manifest_config", domain.GroupCodexModelsManifestConfig{}).
|
||||
Default(domain.GroupCodexModelsManifestConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)"),
|
||||
|
||||
// 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。
|
||||
field.Int("rpm_limit").
|
||||
@@ -271,10 +281,14 @@ func (Group) Fields() []ent.Field {
|
||||
MaxLen(20).
|
||||
Default("").
|
||||
Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"),
|
||||
field.String("max_reasoning_effort_over_limit").
|
||||
MaxLen(20).
|
||||
Default("downgrade").
|
||||
Comment("超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问"),
|
||||
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
|
||||
Default([]domain.ReasoningEffortMapping{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
|
||||
Comment("OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限"),
|
||||
|
||||
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
|
||||
// 的 token 分组可启用,composite 分组不能直接启用。
|
||||
|
||||
@@ -96,6 +96,11 @@ func (User) Fields() []ent.Field {
|
||||
Nillable().
|
||||
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
|
||||
|
||||
// 公开分组访问限制:为 false 时用户可绑定任意非专属分组(默认行为),
|
||||
// 为 true 时仅可绑定 user_allowed_groups 中列出的公开分组。
|
||||
field.Bool("restrict_public_groups").
|
||||
Default(false),
|
||||
|
||||
// 余额不足通知
|
||||
field.Bool("balance_notify_enabled").
|
||||
Default(true),
|
||||
|
||||
+12
-1
@@ -53,6 +53,8 @@ type User struct {
|
||||
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
|
||||
// LastActiveAt holds the value of the "last_active_at" field.
|
||||
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
|
||||
// RestrictPublicGroups holds the value of the "restrict_public_groups" field.
|
||||
RestrictPublicGroups bool `json:"restrict_public_groups,omitempty"`
|
||||
// BalanceNotifyEnabled holds the value of the "balance_notify_enabled" field.
|
||||
BalanceNotifyEnabled bool `json:"balance_notify_enabled,omitempty"`
|
||||
// BalanceNotifyThresholdType holds the value of the "balance_notify_threshold_type" field.
|
||||
@@ -237,7 +239,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
|
||||
values := make([]any, len(columns))
|
||||
for i := range columns {
|
||||
switch columns[i] {
|
||||
case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled:
|
||||
case user.FieldTotpEnabled, user.FieldRestrictPublicGroups, user.FieldBalanceNotifyEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
case user.FieldBalance, user.FieldFrozenBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
@@ -381,6 +383,12 @@ func (_m *User) assignValues(columns []string, values []any) error {
|
||||
_m.LastActiveAt = new(time.Time)
|
||||
*_m.LastActiveAt = value.Time
|
||||
}
|
||||
case user.FieldRestrictPublicGroups:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field restrict_public_groups", values[i])
|
||||
} else if value.Valid {
|
||||
_m.RestrictPublicGroups = value.Bool
|
||||
}
|
||||
case user.FieldBalanceNotifyEnabled:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field balance_notify_enabled", values[i])
|
||||
@@ -588,6 +596,9 @@ func (_m *User) String() string {
|
||||
builder.WriteString(v.Format(time.ANSIC))
|
||||
}
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("restrict_public_groups=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.RestrictPublicGroups))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("balance_notify_enabled=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.BalanceNotifyEnabled))
|
||||
builder.WriteString(", ")
|
||||
|
||||
@@ -51,6 +51,8 @@ const (
|
||||
FieldLastLoginAt = "last_login_at"
|
||||
// FieldLastActiveAt holds the string denoting the last_active_at field in the database.
|
||||
FieldLastActiveAt = "last_active_at"
|
||||
// FieldRestrictPublicGroups holds the string denoting the restrict_public_groups field in the database.
|
||||
FieldRestrictPublicGroups = "restrict_public_groups"
|
||||
// FieldBalanceNotifyEnabled holds the string denoting the balance_notify_enabled field in the database.
|
||||
FieldBalanceNotifyEnabled = "balance_notify_enabled"
|
||||
// FieldBalanceNotifyThresholdType holds the string denoting the balance_notify_threshold_type field in the database.
|
||||
@@ -212,6 +214,7 @@ var Columns = []string{
|
||||
FieldSignupSource,
|
||||
FieldLastLoginAt,
|
||||
FieldLastActiveAt,
|
||||
FieldRestrictPublicGroups,
|
||||
FieldBalanceNotifyEnabled,
|
||||
FieldBalanceNotifyThresholdType,
|
||||
FieldBalanceNotifyThreshold,
|
||||
@@ -280,6 +283,8 @@ var (
|
||||
DefaultSignupSource string
|
||||
// SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
|
||||
SignupSourceValidator func(string) error
|
||||
// DefaultRestrictPublicGroups holds the default value on creation for the "restrict_public_groups" field.
|
||||
DefaultRestrictPublicGroups bool
|
||||
// DefaultBalanceNotifyEnabled holds the default value on creation for the "balance_notify_enabled" field.
|
||||
DefaultBalanceNotifyEnabled bool
|
||||
// DefaultBalanceNotifyThresholdType holds the default value on creation for the "balance_notify_threshold_type" field.
|
||||
@@ -390,6 +395,11 @@ func ByLastActiveAt(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldLastActiveAt, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByRestrictPublicGroups orders the results by the restrict_public_groups field.
|
||||
func ByRestrictPublicGroups(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldRestrictPublicGroups, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByBalanceNotifyEnabled orders the results by the balance_notify_enabled field.
|
||||
func ByBalanceNotifyEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldBalanceNotifyEnabled, opts...).ToFunc()
|
||||
|
||||
@@ -145,6 +145,11 @@ func LastActiveAt(v time.Time) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldLastActiveAt, v))
|
||||
}
|
||||
|
||||
// RestrictPublicGroups applies equality check predicate on the "restrict_public_groups" field. It's identical to RestrictPublicGroupsEQ.
|
||||
func RestrictPublicGroups(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyEnabled applies equality check predicate on the "balance_notify_enabled" field. It's identical to BalanceNotifyEnabledEQ.
|
||||
func BalanceNotifyEnabled(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
|
||||
@@ -1115,6 +1120,16 @@ func LastActiveAtNotNil() predicate.User {
|
||||
return predicate.User(sql.FieldNotNull(FieldLastActiveAt))
|
||||
}
|
||||
|
||||
// RestrictPublicGroupsEQ applies the EQ predicate on the "restrict_public_groups" field.
|
||||
func RestrictPublicGroupsEQ(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
|
||||
}
|
||||
|
||||
// RestrictPublicGroupsNEQ applies the NEQ predicate on the "restrict_public_groups" field.
|
||||
func RestrictPublicGroupsNEQ(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldNEQ(FieldRestrictPublicGroups, v))
|
||||
}
|
||||
|
||||
// BalanceNotifyEnabledEQ applies the EQ predicate on the "balance_notify_enabled" field.
|
||||
func BalanceNotifyEnabledEQ(v bool) predicate.User {
|
||||
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
|
||||
|
||||
@@ -270,6 +270,20 @@ func (_c *UserCreate) SetNillableLastActiveAt(v *time.Time) *UserCreate {
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (_c *UserCreate) SetRestrictPublicGroups(v bool) *UserCreate {
|
||||
_c.mutation.SetRestrictPublicGroups(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
|
||||
func (_c *UserCreate) SetNillableRestrictPublicGroups(v *bool) *UserCreate {
|
||||
if v != nil {
|
||||
_c.SetRestrictPublicGroups(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_c *UserCreate) SetBalanceNotifyEnabled(v bool) *UserCreate {
|
||||
_c.mutation.SetBalanceNotifyEnabled(v)
|
||||
@@ -636,6 +650,10 @@ func (_c *UserCreate) defaults() error {
|
||||
v := user.DefaultSignupSource
|
||||
_c.mutation.SetSignupSource(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
|
||||
v := user.DefaultRestrictPublicGroups
|
||||
_c.mutation.SetRestrictPublicGroups(v)
|
||||
}
|
||||
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
|
||||
v := user.DefaultBalanceNotifyEnabled
|
||||
_c.mutation.SetBalanceNotifyEnabled(v)
|
||||
@@ -730,6 +748,9 @@ func (_c *UserCreate) check() error {
|
||||
return &ValidationError{Name: "signup_source", err: fmt.Errorf(`ent: validator failed for field "User.signup_source": %w`, err)}
|
||||
}
|
||||
}
|
||||
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
|
||||
return &ValidationError{Name: "restrict_public_groups", err: errors.New(`ent: missing required field "User.restrict_public_groups"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
|
||||
return &ValidationError{Name: "balance_notify_enabled", err: errors.New(`ent: missing required field "User.balance_notify_enabled"`)}
|
||||
}
|
||||
@@ -844,6 +865,10 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(user.FieldLastActiveAt, field.TypeTime, value)
|
||||
_node.LastActiveAt = &value
|
||||
}
|
||||
if value, ok := _c.mutation.RestrictPublicGroups(); ok {
|
||||
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
|
||||
_node.RestrictPublicGroups = value
|
||||
}
|
||||
if value, ok := _c.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
_node.BalanceNotifyEnabled = value
|
||||
@@ -1384,6 +1409,18 @@ func (u *UserUpsert) ClearLastActiveAt() *UserUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (u *UserUpsert) SetRestrictPublicGroups(v bool) *UserUpsert {
|
||||
u.Set(user.FieldRestrictPublicGroups, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
|
||||
func (u *UserUpsert) UpdateRestrictPublicGroups() *UserUpsert {
|
||||
u.SetExcluded(user.FieldRestrictPublicGroups)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsert) SetBalanceNotifyEnabled(v bool) *UserUpsert {
|
||||
u.Set(user.FieldBalanceNotifyEnabled, v)
|
||||
@@ -1819,6 +1856,20 @@ func (u *UserUpsertOne) ClearLastActiveAt() *UserUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (u *UserUpsertOne) SetRestrictPublicGroups(v bool) *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetRestrictPublicGroups(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
|
||||
func (u *UserUpsertOne) UpdateRestrictPublicGroups() *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateRestrictPublicGroups()
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsertOne) SetBalanceNotifyEnabled(v bool) *UserUpsertOne {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
@@ -2436,6 +2487,20 @@ func (u *UserUpsertBulk) ClearLastActiveAt() *UserUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (u *UserUpsertBulk) SetRestrictPublicGroups(v bool) *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.SetRestrictPublicGroups(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
|
||||
func (u *UserUpsertBulk) UpdateRestrictPublicGroups() *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
s.UpdateRestrictPublicGroups()
|
||||
})
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (u *UserUpsertBulk) SetBalanceNotifyEnabled(v bool) *UserUpsertBulk {
|
||||
return u.Update(func(s *UserUpsert) {
|
||||
|
||||
@@ -321,6 +321,20 @@ func (_u *UserUpdate) ClearLastActiveAt() *UserUpdate {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (_u *UserUpdate) SetRestrictPublicGroups(v bool) *UserUpdate {
|
||||
_u.mutation.SetRestrictPublicGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
|
||||
func (_u *UserUpdate) SetNillableRestrictPublicGroups(v *bool) *UserUpdate {
|
||||
if v != nil {
|
||||
_u.SetRestrictPublicGroups(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_u *UserUpdate) SetBalanceNotifyEnabled(v bool) *UserUpdate {
|
||||
_u.mutation.SetBalanceNotifyEnabled(v)
|
||||
@@ -1069,6 +1083,9 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if _u.mutation.LastActiveAtCleared() {
|
||||
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
|
||||
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
}
|
||||
@@ -1997,6 +2014,20 @@ func (_u *UserUpdateOne) ClearLastActiveAt() *UserUpdateOne {
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
|
||||
func (_u *UserUpdateOne) SetRestrictPublicGroups(v bool) *UserUpdateOne {
|
||||
_u.mutation.SetRestrictPublicGroups(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
|
||||
func (_u *UserUpdateOne) SetNillableRestrictPublicGroups(v *bool) *UserUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetRestrictPublicGroups(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
|
||||
func (_u *UserUpdateOne) SetBalanceNotifyEnabled(v bool) *UserUpdateOne {
|
||||
_u.mutation.SetBalanceNotifyEnabled(v)
|
||||
@@ -2775,6 +2806,9 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
|
||||
if _u.mutation.LastActiveAtCleared() {
|
||||
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
|
||||
}
|
||||
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
|
||||
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
|
||||
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
|
||||
}
|
||||
|
||||
@@ -667,6 +667,8 @@ type PricingConfig struct {
|
||||
DataDir string `mapstructure:"data_dir"`
|
||||
// 回退文件路径
|
||||
FallbackFile string `mapstructure:"fallback_file"`
|
||||
// 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高
|
||||
OverrideFile string `mapstructure:"override_file"`
|
||||
// 更新间隔(小时)
|
||||
UpdateIntervalHours int `mapstructure:"update_interval_hours"`
|
||||
// 哈希校验间隔(分钟)
|
||||
@@ -2279,11 +2281,12 @@ func setDefaults() {
|
||||
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
|
||||
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
|
||||
|
||||
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
|
||||
// Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据
|
||||
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
|
||||
viper.SetDefault("pricing.data_dir", "./data")
|
||||
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
|
||||
viper.SetDefault("pricing.override_file", "")
|
||||
viper.SetDefault("pricing.update_interval_hours", 24)
|
||||
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
|
||||
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package domain
|
||||
|
||||
// GroupCodexModelsManifestConfig controls which accounts fetch the Codex
|
||||
// models manifest for an OpenAI group. When enabled, the group's Codex
|
||||
// /models requests bypass the scheduler and only use the pinned accounts.
|
||||
type GroupCodexModelsManifestConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
// AccountIDs is the ordered list of pinned account IDs. Order decides
|
||||
// merge precedence for duplicate model slugs.
|
||||
AccountIDs []int64 `json:"account_ids,omitempty"`
|
||||
// FallbackToScheduler controls the behavior when no pinned account is
|
||||
// usable or all upstream fetches fail: fall back to the scheduler loop
|
||||
// (true) or return the upstream error / 503 (false, default).
|
||||
FallbackToScheduler bool `json:"fallback_to_scheduler,omitempty"`
|
||||
}
|
||||
@@ -43,7 +43,7 @@ const (
|
||||
const (
|
||||
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
|
||||
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
|
||||
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
|
||||
)
|
||||
|
||||
@@ -98,6 +98,7 @@ const AntigravityGemini31ProAgentModel = "gemini-pro-agent"
|
||||
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
|
||||
var DefaultAntigravityModelMapping = map[string]string{
|
||||
// Claude 白名单
|
||||
"claude-fable-5-1": "claude-fable-5-1", // 官方模型
|
||||
"claude-fable-5": "claude-fable-5", // 官方模型
|
||||
"claude-opus-4-8": "claude-opus-4-8", // 官方模型
|
||||
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
|
||||
@@ -105,11 +106,11 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
"claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射
|
||||
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型
|
||||
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名
|
||||
// Claude 详细版本 ID 映射
|
||||
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型
|
||||
// Claude Haiku → Sonnet(无 Haiku 支持)
|
||||
"claude-haiku-4-5": "claude-sonnet-4-6",
|
||||
"claude-haiku-4-5-20251001": "claude-sonnet-4-6",
|
||||
@@ -158,7 +159,8 @@ var DefaultAntigravityModelMapping = map[string]string{
|
||||
// aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等)
|
||||
var DefaultBedrockModelMapping = map[string]string{
|
||||
// Claude Fable
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-fable-5-1": "anthropic.claude-fable-5-1",
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
// Claude Opus
|
||||
"claude-opus-5": "us.anthropic.claude-opus-5-v1",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
|
||||
@@ -29,8 +29,9 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-fable-5": "claude-fable-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
"claude-fable-5-1": "claude-fable-5-1",
|
||||
"claude-fable-5": "claude-fable-5",
|
||||
"claude-opus-4-8": "claude-opus-4-8",
|
||||
}
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultAntigravityModelMapping[from]
|
||||
@@ -43,6 +44,21 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-sonnet-4-5": "claude-sonnet-4-5",
|
||||
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6",
|
||||
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6",
|
||||
}
|
||||
for model, want := range cases {
|
||||
if got := DefaultAntigravityModelMapping[model]; got != want {
|
||||
t.Fatalf("expected model %q to map to %q, got %q", model, want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -77,8 +93,9 @@ func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[string]string{
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
"claude-fable-5-1": "anthropic.claude-fable-5-1",
|
||||
"claude-fable-5": "anthropic.claude-fable-5",
|
||||
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
|
||||
}
|
||||
for from, want := range cases {
|
||||
got, ok := DefaultBedrockModelMapping[from]
|
||||
|
||||
@@ -1,8 +1,20 @@
|
||||
package domain
|
||||
|
||||
const (
|
||||
ReasoningEffortMatchExact = "exact"
|
||||
ReasoningEffortMatchPrefix = "prefix"
|
||||
ReasoningEffortMatchSuffix = "suffix"
|
||||
)
|
||||
|
||||
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
|
||||
// value to another before the group ceiling is applied.
|
||||
//
|
||||
// Model and MatchType optionally scope the rewrite to a request model:
|
||||
// exact matches the full model id, prefix/suffix match a model-id affix.
|
||||
// Empty MatchType and empty Model mean the mapping applies to every model.
|
||||
type ReasoningEffortMapping struct {
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
From string `json:"from"`
|
||||
To string `json:"to"`
|
||||
MatchType string `json:"match_type,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
}
|
||||
|
||||
@@ -199,6 +199,19 @@ type AccountWithConcurrency struct {
|
||||
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
|
||||
}
|
||||
|
||||
// AccountListItemWithConcurrency is the compact account-list envelope used
|
||||
// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account,
|
||||
// so groups/account_groups never appear in the list payload.
|
||||
type AccountListItemWithConcurrency struct {
|
||||
*dto.AccountListItem
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
|
||||
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
|
||||
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"`
|
||||
ActiveSessions *int `json:"active_sessions,omitempty"`
|
||||
CurrentRPM *int `json:"current_rpm,omitempty"`
|
||||
}
|
||||
|
||||
type AccountSchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
StickyScore float64 `json:"sticky_score"`
|
||||
@@ -223,6 +236,17 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account {
|
||||
out := dto.AccountFromServiceShallow(account)
|
||||
if out != nil && account != nil {
|
||||
out.Proxy = dto.ProxyFromService(account.Proxy)
|
||||
}
|
||||
if h != nil && h.ollamaCloudUsage != nil && out != nil {
|
||||
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
|
||||
item := AccountWithConcurrency{
|
||||
Account: h.accountResponseFromService(account),
|
||||
@@ -650,8 +674,12 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
result := make([]AccountWithConcurrency, len(accounts))
|
||||
for i := range accounts {
|
||||
acc := &accounts[i]
|
||||
accountResponse := h.accountResponseFromService(acc)
|
||||
if lite {
|
||||
accountResponse = h.accountListResponseFromService(acc)
|
||||
}
|
||||
item := AccountWithConcurrency{
|
||||
Account: h.accountResponseFromService(acc),
|
||||
Account: accountResponse,
|
||||
CurrentConcurrency: concurrencyCounts[acc.ID],
|
||||
SchedulerScore: schedulerScores[acc.ID],
|
||||
SchedulerScores: schedulerGroupScores[acc.ID],
|
||||
@@ -683,7 +711,34 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
|
||||
h.enrichShadowParents(c.Request.Context(), result)
|
||||
|
||||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
|
||||
if lite {
|
||||
compact := make([]AccountListItemWithConcurrency, len(result))
|
||||
for i := range result {
|
||||
item := result[i]
|
||||
compact[i] = AccountListItemWithConcurrency{
|
||||
AccountListItem: dto.AccountListItemFromAccount(item.Account),
|
||||
CurrentConcurrency: item.CurrentConcurrency,
|
||||
SchedulerScore: item.SchedulerScore,
|
||||
SchedulerScores: item.SchedulerScores,
|
||||
CurrentWindowCost: item.CurrentWindowCost,
|
||||
ActiveSessions: item.ActiveSessions,
|
||||
CurrentRPM: item.CurrentRPM,
|
||||
}
|
||||
}
|
||||
etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Vary", "If-None-Match")
|
||||
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
response.Paginated(c, compact, total, page, pageSize)
|
||||
return
|
||||
}
|
||||
|
||||
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
c.Header("Vary", "If-None-Match")
|
||||
@@ -696,23 +751,23 @@ func (h *AccountHandler) List(c *gin.Context) {
|
||||
response.Paginated(c, result, total, page, pageSize)
|
||||
}
|
||||
|
||||
func buildAccountsListETag(
|
||||
items []AccountWithConcurrency,
|
||||
func buildAccountsListETag[T any](
|
||||
items []T,
|
||||
total int64,
|
||||
page, pageSize int,
|
||||
platform, accountType, status, search string,
|
||||
lite bool,
|
||||
) string {
|
||||
payload := struct {
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Search string `json:"search"`
|
||||
Lite bool `json:"lite"`
|
||||
Items []AccountWithConcurrency `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
Platform string `json:"platform"`
|
||||
AccountType string `json:"type"`
|
||||
Status string `json:"status"`
|
||||
Search string `json:"search"`
|
||||
Lite bool `json:"lite"`
|
||||
Items []T `json:"items"`
|
||||
}{
|
||||
Total: total,
|
||||
Page: page,
|
||||
@@ -837,6 +892,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -970,6 +1029,10 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -1424,6 +1487,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
|
||||
req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
|
||||
@@ -1897,6 +1964,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(item.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil {
|
||||
failed++
|
||||
results = append(results, gin.H{
|
||||
"name": item.Name,
|
||||
"success": false,
|
||||
"error": err.Error(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk
|
||||
|
||||
@@ -2091,6 +2167,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
|
||||
}
|
||||
// base_rpm 输入校验:负值归零,超过 10000 截断
|
||||
sanitizeExtraBaseRPM(req.Extra)
|
||||
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 确定是否跳过混合渠道检查
|
||||
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
|
||||
@@ -2774,13 +2854,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2793,29 +2875,35 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
|
||||
// POST /api/v1/admin/accounts/models/sync-upstream-preview
|
||||
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
var req struct {
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
Platform string `json:"platform" binding:"required"`
|
||||
Type string `json:"type" binding:"required"`
|
||||
BaseURL string `json:"base_url"`
|
||||
APIKey string `json:"api_key" binding:"required"`
|
||||
ModelMapping map[string]string `json:"model_mapping"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
modelMapping := make(map[string]any, len(req.ModelMapping))
|
||||
for sourceModel, upstreamModel := range req.ModelMapping {
|
||||
modelMapping[sourceModel] = upstreamModel
|
||||
}
|
||||
|
||||
tempAccount := &service.Account{
|
||||
Platform: req.Platform,
|
||||
Type: req.Type,
|
||||
Credentials: map[string]any{
|
||||
"api_key": req.APIKey,
|
||||
"base_url": req.BaseURL,
|
||||
"api_key": req.APIKey,
|
||||
"base_url": req.BaseURL,
|
||||
"model_mapping": modelMapping,
|
||||
},
|
||||
}
|
||||
|
||||
@@ -2824,13 +2912,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
|
||||
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount)
|
||||
if err != nil {
|
||||
var syncErr *service.UpstreamModelSyncError
|
||||
if errors.As(err, &syncErr) {
|
||||
switch syncErr.Kind {
|
||||
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
|
||||
response.BadRequest(c, syncErr.SafeMessage())
|
||||
case service.UpstreamModelSyncErrorInternal:
|
||||
response.InternalError(c, syncErr.SafeMessage())
|
||||
default:
|
||||
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
|
||||
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
|
||||
@@ -2843,7 +2933,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{"models": models})
|
||||
response.Success(c, catalog)
|
||||
}
|
||||
|
||||
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
|
||||
|
||||
@@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
|
||||
}
|
||||
|
||||
type syncUpstreamHTTPUpstream struct {
|
||||
resp *http.Response
|
||||
err error
|
||||
resp *http.Response
|
||||
responses []*http.Response
|
||||
err error
|
||||
}
|
||||
|
||||
func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
|
||||
if u.err != nil {
|
||||
return nil, u.err
|
||||
}
|
||||
if len(u.responses) > 0 {
|
||||
resp := u.responses[0]
|
||||
u.responses = u.responses[1:]
|
||||
return resp, nil
|
||||
}
|
||||
return u.resp, nil
|
||||
}
|
||||
|
||||
@@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
|
||||
)
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
|
||||
router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview)
|
||||
return router
|
||||
}
|
||||
|
||||
@@ -347,6 +354,99 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin
|
||||
require.Contains(t, rec.Body.String(), "No OpenAI API key is available")
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
|
||||
},
|
||||
}
|
||||
upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"models":[{
|
||||
"id":"custom-thinking-model",
|
||||
"reasoning":true,
|
||||
"default_reasoning_level":"high",
|
||||
"supported_reasoning_levels":["low","high"],
|
||||
"input_modalities":["text","image"],
|
||||
"context_window":256000
|
||||
}]}`)),
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(svc, upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data service.UpstreamModelCatalog `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models)
|
||||
metadata := resp.Data.Metadata["custom-thinking-model"]
|
||||
require.NotNil(t, metadata.Reasoning)
|
||||
require.True(t, *metadata.Reasoning)
|
||||
require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
|
||||
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
|
||||
}
|
||||
|
||||
// Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。
|
||||
func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) {
|
||||
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{
|
||||
"configured-provider": {
|
||||
"api": "https://provider.example/v1",
|
||||
"models": {
|
||||
"glm-5.3": {
|
||||
"id": "glm-5.3",
|
||||
"reasoning": true,
|
||||
"reasoning_options": [{"type":"effort","values":["low","high"]}],
|
||||
"modalities": {"input":["text"],"output":["text"]},
|
||||
"limit": {"context":1000000,"output":131072}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)),
|
||||
},
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/models/sync-upstream-preview",
|
||||
strings.NewReader(`{
|
||||
"platform":"openai",
|
||||
"type":"apikey",
|
||||
"base_url":"https://provider.example/v1",
|
||||
"api_key":"key",
|
||||
"model_mapping":{"public-glm":"glm-5.3"}
|
||||
}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data service.UpstreamModelCatalog `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"glm-5.3"}, resp.Data.Models)
|
||||
require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
@@ -377,3 +477,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test
|
||||
require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502")
|
||||
require.NotContains(t, rec.Body.String(), "SECRET_TOKEN")
|
||||
}
|
||||
|
||||
// Scenario: 能力补全失败显示部分成功。
|
||||
func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) {
|
||||
svc := &availableModelsAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: service.Account{
|
||||
ID: 46,
|
||||
Name: "opencode-id-only-model-list",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "opencode-key",
|
||||
"base_url": "https://opencode.ai/zen/v1",
|
||||
},
|
||||
},
|
||||
}
|
||||
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)),
|
||||
},
|
||||
}}
|
||||
router := setupSyncUpstreamModelsRouter(svc, upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp struct {
|
||||
Data struct {
|
||||
Models []string `json:"models"`
|
||||
Warnings []struct {
|
||||
Code string `json:"code"`
|
||||
} `json:"warnings"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models)
|
||||
require.Len(t, resp.Data.Warnings, 1)
|
||||
require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
|
||||
}
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"compress/gzip"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -13,6 +16,101 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
groupID := int64(77)
|
||||
adminSvc.accounts = []service.Account{{
|
||||
ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)},
|
||||
Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive,
|
||||
Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID},
|
||||
Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotEmpty(t, rec.Header().Get("ETag"))
|
||||
|
||||
var litePayload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload))
|
||||
require.Len(t, litePayload.Data.Items, 1)
|
||||
liteItem := litePayload.Data.Items[0]
|
||||
require.Equal(t, float64(501), liteItem["id"])
|
||||
require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"])
|
||||
require.Equal(t, true, liteItem["schedulable"])
|
||||
require.NotContains(t, liteItem, "groups")
|
||||
require.NotContains(t, liteItem, "account_groups")
|
||||
credentials, ok := liteItem["credentials"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, "compact@example.com", credentials["email"])
|
||||
require.NotContains(t, credentials, "access_token")
|
||||
credentialsStatus, ok := liteItem["credentials_status"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, true, credentialsStatus["has_access_token"])
|
||||
|
||||
// The ETag must represent the same compact body and return 304 on refresh.
|
||||
rec304 := httptest.NewRecorder()
|
||||
req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
req304.Header.Set("If-None-Match", rec.Header().Get("ETag"))
|
||||
router.ServeHTTP(rec304, req304)
|
||||
require.Equal(t, http.StatusNotModified, rec304.Code)
|
||||
|
||||
// Omitting lite preserves the legacy full response shape.
|
||||
recFull := httptest.NewRecorder()
|
||||
reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil)
|
||||
router.ServeHTTP(recFull, reqFull)
|
||||
require.Equal(t, http.StatusOK, recFull.Code)
|
||||
var fullPayload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload))
|
||||
require.Contains(t, fullPayload.Data.Items[0], "groups")
|
||||
require.Contains(t, fullPayload.Data.Items[0], "account_groups")
|
||||
}
|
||||
|
||||
func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) {
|
||||
router, adminSvc := setupAccountListRouter()
|
||||
now := time.Now().UTC()
|
||||
accounts := make([]service.Account, 20)
|
||||
for i := range accounts {
|
||||
id := int64(600 + i)
|
||||
groupID := int64(800 + i)
|
||||
accounts[i] = service.Account{
|
||||
ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true,
|
||||
Concurrency: 4, GroupIDs: []int64{groupID},
|
||||
Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}},
|
||||
CreatedAt: now, UpdatedAt: now,
|
||||
}
|
||||
}
|
||||
adminSvc.accounts = accounts
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Less(t, rec.Body.Len(), 80*1024)
|
||||
|
||||
var compressed bytes.Buffer
|
||||
zw := gzip.NewWriter(&compressed)
|
||||
_, err := zw.Write(rec.Body.Bytes())
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, zw.Close())
|
||||
require.Less(t, compressed.Len(), 15*1024)
|
||||
}
|
||||
|
||||
func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// upstreamBillingRatesResponse is deliberately smaller than the regular
|
||||
// account-list response. The frontend uses the returned ID order to detect a
|
||||
// page boundary/order change and only applies snapshots when the page is
|
||||
// otherwise stable.
|
||||
type upstreamBillingRatesResponse struct {
|
||||
Items []service.UpstreamBillingRateSnapshotItem `json:"items"`
|
||||
Total int64 `json:"total"`
|
||||
Page int `json:"page"`
|
||||
PageSize int `json:"page_size"`
|
||||
}
|
||||
|
||||
// GetUpstreamBillingRates returns persisted upstream billing snapshots for the
|
||||
// current account-list page. It never probes an upstream and never loads
|
||||
// today-stats, usage, concurrency, or credentials for the response.
|
||||
//
|
||||
// GET /api/v1/admin/accounts/upstream-billing-rates
|
||||
func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) {
|
||||
if h.adminService == nil {
|
||||
response.Error(c, http.StatusServiceUnavailable, "account service unavailable")
|
||||
return
|
||||
}
|
||||
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
platform := c.Query("platform")
|
||||
accountType := c.Query("type")
|
||||
status := c.Query("status")
|
||||
search := strings.TrimSpace(c.Query("search"))
|
||||
if len(search) > 100 {
|
||||
search = search[:100]
|
||||
}
|
||||
privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
|
||||
sortBy := c.DefaultQuery("sort_by", "name")
|
||||
sortOrder := c.DefaultQuery("sort_order", "asc")
|
||||
|
||||
var groupID int64
|
||||
if groupQuery := c.Query("group"); groupQuery != "" {
|
||||
if groupQuery == accountListGroupUngroupedQueryValue {
|
||||
groupID = service.AccountListGroupUngrouped
|
||||
} else {
|
||||
parsed, err := strconv.ParseInt(groupQuery, 10, 64)
|
||||
if err != nil || parsed < 0 {
|
||||
response.BadRequest(c, "invalid group filter")
|
||||
return
|
||||
}
|
||||
groupID = parsed
|
||||
}
|
||||
}
|
||||
|
||||
accounts, total, err := h.adminService.ListAccounts(
|
||||
c.Request.Context(), page, pageSize, platform, accountType, status,
|
||||
search, groupID, privacyMode, sortBy, sortOrder,
|
||||
)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
payload := upstreamBillingRatesResponse{
|
||||
Items: service.BuildUpstreamBillingRateSnapshotItems(accounts),
|
||||
Total: total,
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
}
|
||||
c.Header("Cache-Control", "private, no-cache")
|
||||
etag := buildUpstreamBillingRatesETag(payload)
|
||||
if etag != "" {
|
||||
c.Header("ETag", etag)
|
||||
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
response.Success(c, payload)
|
||||
}
|
||||
|
||||
func buildUpstreamBillingRatesETag(payload upstreamBillingRatesResponse) string {
|
||||
raw, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return "\"" + hex.EncodeToString(sum[:]) + "\""
|
||||
}
|
||||
@@ -278,13 +278,15 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) {
|
||||
Rules: []dto.OpenAIFastPolicyRule{
|
||||
{ServiceTier: "priority", Action: "filter", Scope: "all"},
|
||||
{ServiceTier: "flex", Action: "block", Scope: "oauth"},
|
||||
{ServiceTier: "ultrafast", Action: "pass", Scope: "all"},
|
||||
{ServiceTier: "all", Action: "pass", Scope: "apikey"},
|
||||
},
|
||||
}
|
||||
out := openaiFastPolicySettingsFromDTO(in)
|
||||
require.Len(t, out.Rules, 3)
|
||||
require.Len(t, out.Rules, 4)
|
||||
require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierFlex, out.Rules[1].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierAny, out.Rules[2].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierUltrafast, out.Rules[2].ServiceTier)
|
||||
require.Equal(t, service.OpenAIFastTierAny, out.Rules[3].ServiceTier)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,20 +57,22 @@ type updateChannelRequest struct {
|
||||
}
|
||||
|
||||
type channelModelPricingRequest struct {
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
Platform string `json:"platform" binding:"omitempty,max=50"`
|
||||
Models []string `json:"models" binding:"required,min=1,max=100"`
|
||||
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
|
||||
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
|
||||
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price" binding:"omitempty,min=0"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier" binding:"omitempty,gt=0"`
|
||||
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
|
||||
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
|
||||
Intervals []pricingIntervalRequest `json:"intervals"`
|
||||
TimePricing *channelTimePricingRequest `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingRequest struct {
|
||||
@@ -92,6 +94,7 @@ type pricingIntervalRequest struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"`
|
||||
@@ -127,21 +130,23 @@ type channelResponse struct {
|
||||
}
|
||||
|
||||
type channelModelPricingResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
ID int64 `json:"id"`
|
||||
Platform string `json:"platform"`
|
||||
Models []string `json:"models"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
FastMultiplier *float64 `json:"fast_multiplier"`
|
||||
FlexMultiplier *float64 `json:"flex_multiplier"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []pricingIntervalResponse `json:"intervals"`
|
||||
TimePricing *channelTimePricingResponse `json:"time_pricing"`
|
||||
}
|
||||
|
||||
type channelTimePricingResponse struct {
|
||||
@@ -164,6 +169,7 @@ type pricingIntervalResponse struct {
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
@@ -254,21 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
|
||||
intervals = append(intervals, intervalToResponse(iv))
|
||||
}
|
||||
return channelModelPricingResponse{
|
||||
ID: p.ID,
|
||||
Platform: platform,
|
||||
Models: models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
ID: p.ID,
|
||||
Platform: platform,
|
||||
Models: models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
FastMultiplier: p.FastMultiplier,
|
||||
FlexMultiplier: p.FlexMultiplier,
|
||||
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingToResponse(p.TimePricing),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -300,6 +308,7 @@ func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: iv.InputMultiplier,
|
||||
OutputMultiplier: iv.OutputMultiplier,
|
||||
@@ -334,6 +343,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
InputMultiplier: inputMultiplier,
|
||||
OutputMultiplier: outputMultiplier,
|
||||
@@ -343,26 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
|
||||
SortOrder: iv.SortOrder,
|
||||
})
|
||||
}
|
||||
var fastMultiplier, flexMultiplier *float64
|
||||
var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64
|
||||
if allowChannelMultipliers {
|
||||
fastMultiplier = r.FastMultiplier
|
||||
flexMultiplier = r.FlexMultiplier
|
||||
maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier
|
||||
}
|
||||
result = append(result, service.ChannelModelPricing{
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
Platform: platform,
|
||||
Models: r.Models,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: r.InputPrice,
|
||||
OutputPrice: r.OutputPrice,
|
||||
CacheWritePrice: r.CacheWritePrice,
|
||||
CacheWrite1hPrice: r.CacheWrite1hPrice,
|
||||
CacheReadPrice: r.CacheReadPrice,
|
||||
FastMultiplier: fastMultiplier,
|
||||
FlexMultiplier: flexMultiplier,
|
||||
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier,
|
||||
ImageInputPrice: r.ImageInputPrice,
|
||||
ImageOutputPrice: r.ImageOutputPrice,
|
||||
PerRequestPrice: r.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
TimePricing: timePricingRequestToService(r.TimePricing),
|
||||
})
|
||||
}
|
||||
return result
|
||||
@@ -597,15 +610,25 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
response.Success(c, gin.H{"found": false})
|
||||
return
|
||||
}
|
||||
cacheWritePrice := pricing.CacheCreationPricePerToken
|
||||
var cacheWrite1hPrice *float64
|
||||
if pricing.SupportsCacheBreakdown {
|
||||
if pricing.CacheCreation5mPrice > 0 {
|
||||
cacheWritePrice = pricing.CacheCreation5mPrice
|
||||
}
|
||||
cacheWrite1hPrice = &pricing.CacheCreation1hPrice
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": pricing.CacheCreationPricePerToken,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": cacheWritePrice,
|
||||
"cache_write_1h_price": cacheWrite1hPrice,
|
||||
"cache_read_price": pricing.CacheReadPricePerToken,
|
||||
"max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier,
|
||||
"image_input_price": pricing.ImageInputPricePerToken,
|
||||
"image_output_price": pricing.ImageOutputPricePerToken,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -320,15 +320,16 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
|
||||
func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
reqs := []channelModelPricingRequest{
|
||||
{
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-4", "gpt-4o"},
|
||||
BillingMode: "per_request",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.03),
|
||||
CacheWritePrice: float64Ptr(0.005),
|
||||
CacheReadPrice: float64Ptr(0.002),
|
||||
ImageOutputPrice: float64Ptr(0.04),
|
||||
PerRequestPrice: float64Ptr(0.5),
|
||||
Platform: "openai",
|
||||
Models: []string{"gpt-4", "gpt-4o"},
|
||||
BillingMode: "per_request",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.03),
|
||||
CacheWritePrice: float64Ptr(0.005),
|
||||
CacheWrite1hPrice: float64Ptr(0.008),
|
||||
CacheReadPrice: float64Ptr(0.002),
|
||||
ImageOutputPrice: float64Ptr(0.04),
|
||||
PerRequestPrice: float64Ptr(0.5),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -341,6 +342,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
|
||||
require.Equal(t, float64Ptr(0.01), r.InputPrice)
|
||||
require.Equal(t, float64Ptr(0.03), r.OutputPrice)
|
||||
require.Equal(t, float64Ptr(0.005), r.CacheWritePrice)
|
||||
require.Equal(t, float64Ptr(0.008), r.CacheWrite1hPrice)
|
||||
require.Equal(t, float64Ptr(0.002), r.CacheReadPrice)
|
||||
require.Equal(t, float64Ptr(0.04), r.ImageOutputPrice)
|
||||
require.Equal(t, float64Ptr(0.5), r.PerRequestPrice)
|
||||
@@ -353,15 +355,16 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
BillingMode: "per_request",
|
||||
Intervals: []pricingIntervalRequest{
|
||||
{
|
||||
MinTokens: 0,
|
||||
MaxTokens: intPtr(2000),
|
||||
TierLabel: "small",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.02),
|
||||
CacheWritePrice: float64Ptr(0.003),
|
||||
CacheReadPrice: float64Ptr(0.001),
|
||||
PerRequestPrice: float64Ptr(0.1),
|
||||
SortOrder: 1,
|
||||
MinTokens: 0,
|
||||
MaxTokens: intPtr(2000),
|
||||
TierLabel: "small",
|
||||
InputPrice: float64Ptr(0.01),
|
||||
OutputPrice: float64Ptr(0.02),
|
||||
CacheWritePrice: float64Ptr(0.003),
|
||||
CacheWrite1hPrice: float64Ptr(0.006),
|
||||
CacheReadPrice: float64Ptr(0.001),
|
||||
PerRequestPrice: float64Ptr(0.1),
|
||||
SortOrder: 1,
|
||||
},
|
||||
{
|
||||
MinTokens: 2000,
|
||||
@@ -384,6 +387,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
|
||||
require.Equal(t, float64Ptr(0.01), iv0.InputPrice)
|
||||
require.Equal(t, float64Ptr(0.02), iv0.OutputPrice)
|
||||
require.Equal(t, float64Ptr(0.003), iv0.CacheWritePrice)
|
||||
require.Equal(t, float64Ptr(0.006), iv0.CacheWrite1hPrice)
|
||||
require.Equal(t, float64Ptr(0.001), iv0.CacheReadPrice)
|
||||
require.Equal(t, float64Ptr(0.1), iv0.PerRequestPrice)
|
||||
require.Equal(t, 1, iv0.SortOrder)
|
||||
@@ -449,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) {
|
||||
// 避免渠道倍率意外污染账号成本口径。
|
||||
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
req := channelModelPricingRequest{
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
Models: []string{"gpt-5"},
|
||||
BillingMode: "token",
|
||||
FastMultiplier: float64Ptr(2.5),
|
||||
FlexMultiplier: float64Ptr(0.5),
|
||||
MaxReasoningEffortMultiplier: float64Ptr(3),
|
||||
Intervals: []pricingIntervalRequest{{
|
||||
MinTokens: 272000,
|
||||
InputMultiplier: float64Ptr(2),
|
||||
@@ -465,6 +470,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
allowed := pricingRequestToService([]channelModelPricingRequest{req}, true)
|
||||
require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier)
|
||||
require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier)
|
||||
require.Equal(t, float64Ptr(3), allowed[0].MaxReasoningEffortMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier)
|
||||
require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier)
|
||||
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier)
|
||||
@@ -473,6 +479,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
|
||||
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
|
||||
require.Nil(t, dropped[0].FastMultiplier)
|
||||
require.Nil(t, dropped[0].FlexMultiplier)
|
||||
require.Nil(t, dropped[0].MaxReasoningEffortMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
|
||||
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
|
||||
@@ -558,3 +565,53 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
|
||||
require.NotNil(t, body.Data.Models, "models must not be null for platform=%s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func setupModelDefaultPricingRouter() *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
h := &ChannelHandler{billingService: service.NewBillingService(nil, nil)}
|
||||
router.GET("/channels/model-pricing", h.GetModelDefaultPricing)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
|
||||
router := setupModelDefaultPricingRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-fable-5-1", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Found bool `json:"found"`
|
||||
CacheWritePrice float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.True(t, body.Data.Found)
|
||||
require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12)
|
||||
require.NotNil(t, body.Data.CacheWrite1hPrice)
|
||||
require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12)
|
||||
require.NotNil(t, body.Data.MaxReasoningEffortMultiplier)
|
||||
require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier)
|
||||
}
|
||||
|
||||
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
|
||||
router := setupModelDefaultPricingRouter()
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-sonnet-4", nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
var body struct {
|
||||
Data struct {
|
||||
Found bool `json:"found"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
|
||||
require.True(t, body.Data.Found)
|
||||
require.Nil(t, body.Data.CacheWrite1hPrice)
|
||||
}
|
||||
|
||||
@@ -262,13 +262,18 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch)
|
||||
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get usage trend")
|
||||
return
|
||||
@@ -349,13 +354,18 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch)
|
||||
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get model statistics")
|
||||
return
|
||||
@@ -426,13 +436,18 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch)
|
||||
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get group statistics")
|
||||
return
|
||||
@@ -701,6 +716,14 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
|
||||
dim.Stream = &s
|
||||
}
|
||||
}
|
||||
if v := strings.TrimSpace(c.Query("native_compaction_v2")); v != "" {
|
||||
value, err := strconv.ParseBool(v)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
dim.NativeCompactionV2 = &value
|
||||
}
|
||||
if v := c.Query("billing_type"); v != "" {
|
||||
if bt, err := strconv.ParseInt(v, 10, 8); err == nil {
|
||||
btVal := int8(bt)
|
||||
|
||||
@@ -15,16 +15,19 @@ import (
|
||||
|
||||
type dashboardUsageRepoCapture struct {
|
||||
service.UsageLogRepository
|
||||
trendRequestType *int16
|
||||
trendStream *bool
|
||||
modelRequestType *int16
|
||||
modelStream *bool
|
||||
trendMismatch *bool
|
||||
modelMismatch *bool
|
||||
groupMismatch *bool
|
||||
rankingLimit int
|
||||
ranking []usagestats.UserSpendingRankingItem
|
||||
rankingTotal float64
|
||||
trendRequestType *int16
|
||||
trendStream *bool
|
||||
trendNativeCompaction *bool
|
||||
modelRequestType *int16
|
||||
modelStream *bool
|
||||
modelNativeCompaction *bool
|
||||
groupNativeCompaction *bool
|
||||
trendMismatch *bool
|
||||
modelMismatch *bool
|
||||
groupMismatch *bool
|
||||
rankingLimit int
|
||||
ranking []usagestats.UserSpendingRankingItem
|
||||
rankingTotal float64
|
||||
}
|
||||
|
||||
func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
|
||||
@@ -35,6 +38,7 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
|
||||
) ([]usagestats.TrendDataPoint, error) {
|
||||
s.trendRequestType = filters.RequestType
|
||||
s.trendStream = filters.Stream
|
||||
s.trendNativeCompaction = filters.NativeCompactionV2
|
||||
s.trendMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.TrendDataPoint{}, nil
|
||||
}
|
||||
@@ -62,6 +66,7 @@ func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource(
|
||||
) ([]usagestats.ModelStat, error) {
|
||||
s.modelRequestType = filters.RequestType
|
||||
s.modelStream = filters.Stream
|
||||
s.modelNativeCompaction = filters.NativeCompactionV2
|
||||
s.modelMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.ModelStat{}, nil
|
||||
}
|
||||
@@ -71,6 +76,7 @@ func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters(
|
||||
startTime, endTime time.Time,
|
||||
filters usagestats.UsageLogFilters,
|
||||
) ([]usagestats.GroupStat, error) {
|
||||
s.groupNativeCompaction = filters.NativeCompactionV2
|
||||
s.groupMismatch = filters.UpstreamModelMismatch
|
||||
return []usagestats.GroupStat{}, nil
|
||||
}
|
||||
@@ -208,6 +214,48 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
}
|
||||
|
||||
func TestDashboardNativeCompactionFilterPropagatesAlongsideTransport(t *testing.T) {
|
||||
resetDashboardReadCachesForTest()
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
router := newDashboardRequestTypeTestRouter(repo)
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/dashboard/trend?request_type=stream&native_compaction_v2=true",
|
||||
"/admin/dashboard/models?request_type=stream&native_compaction_v2=true",
|
||||
"/admin/dashboard/groups?request_type=stream&native_compaction_v2=true",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code, path)
|
||||
}
|
||||
|
||||
require.NotNil(t, repo.trendNativeCompaction)
|
||||
require.True(t, *repo.trendNativeCompaction)
|
||||
require.NotNil(t, repo.modelNativeCompaction)
|
||||
require.True(t, *repo.modelNativeCompaction)
|
||||
require.NotNil(t, repo.groupNativeCompaction)
|
||||
require.True(t, *repo.groupNativeCompaction)
|
||||
require.NotNil(t, repo.trendRequestType)
|
||||
require.Equal(t, int16(service.RequestTypeStream), *repo.trendRequestType)
|
||||
}
|
||||
|
||||
func TestDashboardNativeCompactionFilterRejectsInvalidBoolean(t *testing.T) {
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
router := newDashboardRequestTypeTestRouter(repo)
|
||||
|
||||
for _, path := range []string{
|
||||
"/admin/dashboard/trend?native_compaction_v2=invalid",
|
||||
"/admin/dashboard/models?native_compaction_v2=invalid",
|
||||
"/admin/dashboard/groups?native_compaction_v2=invalid",
|
||||
} {
|
||||
req := httptest.NewRequest(http.MethodGet, path, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) {
|
||||
resetDashboardReadCachesForTest()
|
||||
repo := &dashboardUsageRepoCapture{}
|
||||
|
||||
@@ -28,6 +28,7 @@ type dashboardTrendCacheKey struct {
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
NativeCompactionV2 *bool `json:"native_compaction_v2"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
@@ -42,6 +43,7 @@ type dashboardModelGroupCacheKey struct {
|
||||
ModelSource string `json:"model_source,omitempty"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
NativeCompactionV2 *bool `json:"native_compaction_v2"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
@@ -85,6 +87,7 @@ func (h *DashboardHandler) getUsageTrendCached(
|
||||
model string,
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
nativeCompactionV2 *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.TrendDataPoint, bool, error) {
|
||||
@@ -99,13 +102,14 @@ func (h *DashboardHandler) getUsageTrendCached(
|
||||
Model: model,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
Model: model, RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
Model: model, RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
})
|
||||
@@ -123,6 +127,7 @@ func (h *DashboardHandler) getModelStatsCached(
|
||||
modelSource string,
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
nativeCompactionV2 *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.ModelStat, bool, error) {
|
||||
@@ -136,13 +141,14 @@ func (h *DashboardHandler) getModelStatsCached(
|
||||
ModelSource: usagestats.NormalizeModelSource(modelSource),
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
}, modelSource)
|
||||
})
|
||||
@@ -159,6 +165,7 @@ func (h *DashboardHandler) getGroupStatsCached(
|
||||
userID, apiKeyID, accountID, groupID int64,
|
||||
requestType *int16,
|
||||
stream *bool,
|
||||
nativeCompactionV2 *bool,
|
||||
billingType *int8,
|
||||
upstreamModelMismatch *bool,
|
||||
) ([]usagestats.GroupStat, bool, error) {
|
||||
@@ -171,13 +178,14 @@ func (h *DashboardHandler) getGroupStatsCached(
|
||||
GroupID: groupID,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
|
||||
return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{
|
||||
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
|
||||
RequestType: requestType, Stream: stream, BillingType: billingType,
|
||||
RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
})
|
||||
})
|
||||
|
||||
@@ -44,6 +44,7 @@ type dashboardSnapshotV2Filters struct {
|
||||
Model string
|
||||
RequestType *int16
|
||||
Stream *bool
|
||||
NativeCompactionV2 *bool
|
||||
BillingType *int8
|
||||
UpstreamModelMismatch *bool
|
||||
}
|
||||
@@ -59,6 +60,7 @@ type dashboardSnapshotV2CacheKey struct {
|
||||
Model string `json:"model"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
NativeCompactionV2 *bool `json:"native_compaction_v2"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
IncludeStats bool `json:"include_stats"`
|
||||
@@ -105,6 +107,7 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
|
||||
Model: filters.Model,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
NativeCompactionV2: filters.NativeCompactionV2,
|
||||
BillingType: filters.BillingType,
|
||||
UpstreamModelMismatch: filters.UpstreamModelMismatch,
|
||||
IncludeStats: includeStats,
|
||||
@@ -186,6 +189,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.Model,
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.NativeCompactionV2,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
@@ -207,6 +211,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
usagestats.ModelSourceRequested,
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.NativeCompactionV2,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
@@ -227,6 +232,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
|
||||
filters.GroupID,
|
||||
filters.RequestType,
|
||||
filters.Stream,
|
||||
filters.NativeCompactionV2,
|
||||
filters.BillingType,
|
||||
filters.UpstreamModelMismatch,
|
||||
)
|
||||
@@ -296,6 +302,14 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
|
||||
filters.Stream = &streamVal
|
||||
}
|
||||
|
||||
if nativeCompactionV2Str := strings.TrimSpace(c.Query("native_compaction_v2")); nativeCompactionV2Str != "" {
|
||||
value, err := strconv.ParseBool(nativeCompactionV2Str)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
filters.NativeCompactionV2 = &value
|
||||
}
|
||||
|
||||
if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" {
|
||||
v, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||
if err != nil {
|
||||
|
||||
@@ -81,8 +81,8 @@ func (f optionalLimitField) ToServiceInput() *float64 {
|
||||
if f.value != nil {
|
||||
return f.value
|
||||
}
|
||||
zero := 0.0
|
||||
return &zero
|
||||
unlimited := -1.0
|
||||
return &unlimited
|
||||
}
|
||||
|
||||
// NewGroupHandler creates a new admin group handler
|
||||
@@ -147,16 +147,22 @@ type CreateGroupRequest struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
|
||||
AllowLive bool `json:"allow_live"`
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
RequireOAuthOnly bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"`
|
||||
// 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。
|
||||
CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
// 分组 RPM 上限(0 = 不限制)
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
|
||||
// Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// OpenAI/Codex 推理强度精确映射。
|
||||
// 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
@@ -216,15 +222,21 @@ type UpdateGroupRequest struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
|
||||
AllowLive *bool `json:"allow_live"`
|
||||
ForceOpenAIFast *bool `json:"force_openai_fast"`
|
||||
FreeOpenAIFast *bool `json:"free_openai_fast"`
|
||||
RequireOAuthOnly *bool `json:"require_oauth_only"`
|
||||
RequirePrivacySet *bool `json:"require_privacy_set"`
|
||||
DefaultMappedModel *string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"`
|
||||
// 固定账号 manifest 配置;nil 表示不修改。
|
||||
CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
// Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。
|
||||
MaxReasoningEffort *string `json:"max_reasoning_effort"`
|
||||
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
|
||||
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
|
||||
// nil 不修改,空数组清空,非空数组替换。
|
||||
ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
@@ -550,13 +562,17 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
ForceOpenAIFast: req.ForceOpenAIFast,
|
||||
FreeOpenAIFast: req.FreeOpenAIFast,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
@@ -679,13 +695,17 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
SupportedModelScopes: req.SupportedModelScopes,
|
||||
AllowMessagesDispatch: req.AllowMessagesDispatch,
|
||||
AllowLive: req.AllowLive,
|
||||
ForceOpenAIFast: req.ForceOpenAIFast,
|
||||
FreeOpenAIFast: req.FreeOpenAIFast,
|
||||
RequireOAuthOnly: req.RequireOAuthOnly,
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: req.ReasoningEffortMappings,
|
||||
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUpdateGroupRequestLimitFieldsTriState(t *testing.T) {
|
||||
t.Run("omitted means unchanged", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{}`), &req))
|
||||
require.Nil(t, req.DailyLimitUSD.ToServiceInput())
|
||||
require.Nil(t, req.WeeklyLimitUSD.ToServiceInput())
|
||||
require.Nil(t, req.MonthlyLimitUSD.ToServiceInput())
|
||||
})
|
||||
|
||||
t.Run("null means unlimited", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"daily_limit_usd":null}`), &req))
|
||||
limit := req.DailyLimitUSD.ToServiceInput()
|
||||
require.NotNil(t, limit)
|
||||
require.Negative(t, *limit)
|
||||
})
|
||||
|
||||
t.Run("number is preserved", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"weekly_limit_usd":0,"monthly_limit_usd":42.5}`), &req))
|
||||
require.Equal(t, 0.0, *req.WeeklyLimitUSD.ToServiceInput())
|
||||
require.Equal(t, 42.5, *req.MonthlyLimitUSD.ToServiceInput())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) {
|
||||
var createReq CreateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true,"free_openai_fast":true}`), &createReq))
|
||||
require.True(t, createReq.ForceOpenAIFast)
|
||||
require.True(t, createReq.FreeOpenAIFast)
|
||||
|
||||
var updateReq UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false,"free_openai_fast":false}`), &updateReq))
|
||||
require.NotNil(t, updateReq.ForceOpenAIFast)
|
||||
require.False(t, *updateReq.ForceOpenAIFast)
|
||||
require.NotNil(t, updateReq.FreeOpenAIFast)
|
||||
require.False(t, *updateReq.FreeOpenAIFast)
|
||||
|
||||
var omitted UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted))
|
||||
require.Nil(t, omitted.ForceOpenAIFast)
|
||||
require.Nil(t, omitted.FreeOpenAIFast)
|
||||
}
|
||||
@@ -29,4 +29,15 @@ func TestUpdateGroupRequestReasoningEffortMappingsTriState(t *testing.T) {
|
||||
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
|
||||
require.Equal(t, "xhigh", (*req.ReasoningEffortMappings)[0].To)
|
||||
})
|
||||
|
||||
t.Run("accepts model scoped mappings", func(t *testing.T) {
|
||||
var req UpdateGroupRequest
|
||||
require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[{"from":"max","to":"low","match_type":"prefix","model":"gpt"}]}`), &req))
|
||||
require.NotNil(t, req.ReasoningEffortMappings)
|
||||
require.Len(t, *req.ReasoningEffortMappings, 1)
|
||||
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
|
||||
require.Equal(t, "low", (*req.ReasoningEffortMappings)[0].To)
|
||||
require.Equal(t, "prefix", (*req.ReasoningEffortMappings)[0].MatchType)
|
||||
require.Equal(t, "gpt", (*req.ReasoningEffortMappings)[0].Model)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ type OpenAIOAuthHandler struct {
|
||||
type openAIQuotaService interface {
|
||||
QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
|
||||
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
|
||||
CachePostResetSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error
|
||||
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,12 @@ func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context,
|
||||
return s.cacheErr
|
||||
}
|
||||
|
||||
func (s *openAIQuotaWorkflowStub) CachePostResetSnapshot(ctx context.Context, _ int64, _ *service.OpenAIQuotaUsage) error {
|
||||
s.cacheCalls++
|
||||
s.cacheCtxErr = ctx.Err()
|
||||
return s.cacheErr
|
||||
}
|
||||
|
||||
type openAIAccountStateRecovererStub struct {
|
||||
err error
|
||||
calls int
|
||||
|
||||
@@ -288,6 +288,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
|
||||
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
|
||||
BackendModeEnabled: settings.BackendModeEnabled,
|
||||
OpenAITTFTMode: settings.OpenAITTFTMode,
|
||||
EnableFingerprintUnification: settings.EnableFingerprintUnification,
|
||||
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
|
||||
EnableCCHSigning: settings.EnableCCHSigning,
|
||||
|
||||
@@ -452,6 +452,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
|
||||
changed = append(changed, "enable_fingerprint_unification")
|
||||
}
|
||||
if before.OpenAITTFTMode != after.OpenAITTFTMode {
|
||||
changed = append(changed, "openai_ttft_mode")
|
||||
}
|
||||
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
|
||||
changed = append(changed, "enable_metadata_passthrough")
|
||||
}
|
||||
|
||||
@@ -17,7 +17,14 @@ type TestSMTPRequest struct {
|
||||
SMTPPort int `json:"smtp_port"`
|
||||
SMTPUsername string `json:"smtp_username"`
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||||
SMTPUseTLS *bool `json:"smtp_use_tls"`
|
||||
}
|
||||
|
||||
func resolveSMTPUseTLS(requested *bool, savedConfig *service.SMTPConfig) bool {
|
||||
if requested != nil {
|
||||
return *requested
|
||||
}
|
||||
return savedConfig != nil && savedConfig.UseTLS
|
||||
}
|
||||
|
||||
// TestSMTPConnection 测试SMTP连接
|
||||
@@ -64,7 +71,7 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
|
||||
Port: req.SMTPPort,
|
||||
Username: req.SMTPUsername,
|
||||
Password: password,
|
||||
UseTLS: req.SMTPUseTLS,
|
||||
UseTLS: resolveSMTPUseTLS(req.SMTPUseTLS, savedConfig),
|
||||
}
|
||||
|
||||
err := h.emailService.TestSMTPConnectionWithConfig(config)
|
||||
@@ -85,7 +92,7 @@ type SendTestEmailRequest struct {
|
||||
SMTPPassword string `json:"smtp_password"`
|
||||
SMTPFrom string `json:"smtp_from_email"`
|
||||
SMTPFromName string `json:"smtp_from_name"`
|
||||
SMTPUseTLS bool `json:"smtp_use_tls"`
|
||||
SMTPUseTLS *bool `json:"smtp_use_tls"`
|
||||
}
|
||||
|
||||
// SendTestEmail 发送测试邮件
|
||||
@@ -142,7 +149,7 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
|
||||
Password: password,
|
||||
From: req.SMTPFrom,
|
||||
FromName: req.SMTPFromName,
|
||||
UseTLS: req.SMTPUseTLS,
|
||||
UseTLS: resolveSMTPUseTLS(req.SMTPUseTLS, savedConfig),
|
||||
}
|
||||
|
||||
siteName := h.settingService.GetSiteName(c.Request.Context())
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func bindSMTPRequest[T any](t *testing.T, body string) T {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = req
|
||||
|
||||
var payload T
|
||||
require.NoError(t, c.ShouldBindJSON(&payload))
|
||||
return payload
|
||||
}
|
||||
|
||||
func TestTestSMTPRequestUseTLSOmissionFallsBackToSavedSetting(t *testing.T) {
|
||||
req := bindSMTPRequest[TestSMTPRequest](t, `{}`)
|
||||
|
||||
require.Nil(t, req.SMTPUseTLS)
|
||||
require.True(t, resolveSMTPUseTLS(req.SMTPUseTLS, &service.SMTPConfig{UseTLS: true}))
|
||||
}
|
||||
|
||||
func TestTestSMTPRequestExplicitFalseOverridesSavedUseTLS(t *testing.T) {
|
||||
req := bindSMTPRequest[TestSMTPRequest](t, `{"smtp_use_tls":false}`)
|
||||
|
||||
require.NotNil(t, req.SMTPUseTLS)
|
||||
require.False(t, resolveSMTPUseTLS(req.SMTPUseTLS, &service.SMTPConfig{UseTLS: true}))
|
||||
}
|
||||
|
||||
func TestTestSMTPRequestExplicitTrueOverridesMissingSavedUseTLS(t *testing.T) {
|
||||
req := bindSMTPRequest[TestSMTPRequest](t, `{"smtp_use_tls":true}`)
|
||||
|
||||
require.NotNil(t, req.SMTPUseTLS)
|
||||
require.True(t, resolveSMTPUseTLS(req.SMTPUseTLS, nil))
|
||||
}
|
||||
|
||||
func TestSendTestEmailRequestPreservesUseTLSOmissionSemantics(t *testing.T) {
|
||||
omitted := bindSMTPRequest[SendTestEmailRequest](t, `{"email":"admin@example.com"}`)
|
||||
explicitFalse := bindSMTPRequest[SendTestEmailRequest](t, `{"email":"admin@example.com","smtp_use_tls":false}`)
|
||||
saved := &service.SMTPConfig{UseTLS: true}
|
||||
|
||||
require.True(t, resolveSMTPUseTLS(omitted.SMTPUseTLS, saved))
|
||||
require.False(t, resolveSMTPUseTLS(explicitFalse.SMTPUseTLS, saved))
|
||||
}
|
||||
@@ -154,6 +154,33 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
func (h *SettingHandler) GetOpenAIImagesOAuthUnavailableCooldownSettings(c *gin.Context) {
|
||||
settings, err := h.settingService.GetOpenAIImagesOAuthUnavailableCooldownSettings(c.Request.Context())
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: settings.CooldownMinutes})
|
||||
}
|
||||
|
||||
type UpdateOpenAIImagesOAuthUnavailableCooldownSettingsRequest struct {
|
||||
CooldownMinutes int `json:"cooldown_minutes"`
|
||||
}
|
||||
|
||||
func (h *SettingHandler) UpdateOpenAIImagesOAuthUnavailableCooldownSettings(c *gin.Context) {
|
||||
var req UpdateOpenAIImagesOAuthUnavailableCooldownSettingsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
settings := &service.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: req.CooldownMinutes}
|
||||
if err := h.settingService.SetOpenAIImagesOAuthUnavailableCooldownSettings(c.Request.Context(), settings); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.OpenAIImagesOAuthUnavailableCooldownSettings{CooldownMinutes: settings.CooldownMinutes})
|
||||
}
|
||||
|
||||
// GetPanelRateLimitSettings 获取面板 API 限流配置
|
||||
// GET /api/v1/admin/settings/panel-rate-limit
|
||||
func (h *SettingHandler) GetPanelRateLimitSettings(c *gin.Context) {
|
||||
|
||||
@@ -243,6 +243,7 @@ type UpdateSettingsRequest struct {
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
|
||||
// Gateway forwarding behavior
|
||||
OpenAITTFTMode *string `json:"openai_ttft_mode"`
|
||||
EnableFingerprintUnification *bool `json:"enable_fingerprint_unification"`
|
||||
EnableMetadataPassthrough *bool `json:"enable_metadata_passthrough"`
|
||||
EnableCCHSigning *bool `json:"enable_cch_signing"`
|
||||
@@ -1683,6 +1684,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.EnableFingerprintUnification
|
||||
}(),
|
||||
OpenAITTFTMode: func() string {
|
||||
if req.OpenAITTFTMode != nil {
|
||||
return *req.OpenAITTFTMode
|
||||
}
|
||||
return previousSettings.OpenAITTFTMode
|
||||
}(),
|
||||
EnableMetadataPassthrough: func() bool {
|
||||
if req.EnableMetadataPassthrough != nil {
|
||||
return *req.EnableMetadataPassthrough
|
||||
|
||||
@@ -132,6 +132,12 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
stream = &val
|
||||
}
|
||||
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
var billingType *int8
|
||||
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
||||
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||
@@ -192,6 +198,7 @@ func (h *UsageHandler) List(c *gin.Context) {
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
@@ -276,6 +283,12 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
stream = &val
|
||||
}
|
||||
|
||||
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return
|
||||
}
|
||||
|
||||
var billingType *int8
|
||||
if billingTypeStr := c.Query("billing_type"); billingTypeStr != "" {
|
||||
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||
@@ -344,6 +357,7 @@ func (h *UsageHandler) Stats(c *gin.Context) {
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
UpstreamModelMismatch: upstreamModelMismatch,
|
||||
|
||||
@@ -95,6 +95,32 @@ func TestAdminUsageListInvalidStream(t *testing.T) {
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestAdminUsageListNativeCompactionFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage?request_type=stream&native_compaction_v2=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotNil(t, repo.listFilters.RequestType)
|
||||
require.Equal(t, int16(service.RequestTypeStream), *repo.listFilters.RequestType)
|
||||
require.NotNil(t, repo.listFilters.NativeCompactionV2)
|
||||
require.True(t, *repo.listFilters.NativeCompactionV2)
|
||||
}
|
||||
|
||||
func TestAdminUsageListInvalidNativeCompactionFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage?native_compaction_v2=oops", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
|
||||
func TestAdminUsageListExactTotalTrue(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
@@ -144,6 +170,19 @@ func TestAdminUsageStatsRequestTypePriority(t *testing.T) {
|
||||
require.Nil(t, repo.statsFilters.Stream)
|
||||
}
|
||||
|
||||
func TestAdminUsageStatsNativeCompactionFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/admin/usage/stats?native_compaction_v2=true", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotNil(t, repo.statsFilters.NativeCompactionV2)
|
||||
require.True(t, *repo.statsFilters.NativeCompactionV2)
|
||||
}
|
||||
|
||||
func TestAdminUsageStatsUsesRequestedModelForDisplayModelFilter(t *testing.T) {
|
||||
repo := &adminUsageRepoCapture{}
|
||||
router := newAdminUsageRequestTypeTestRouter(repo)
|
||||
|
||||
@@ -21,6 +21,7 @@ type usageStatsCacheKeyData struct {
|
||||
BillingMode string `json:"billing_mode"`
|
||||
RequestType *int16 `json:"request_type"`
|
||||
Stream *bool `json:"stream"`
|
||||
NativeCompactionV2 *bool `json:"native_compaction_v2"`
|
||||
BillingType *int8 `json:"billing_type"`
|
||||
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
|
||||
}
|
||||
@@ -45,6 +46,7 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
|
||||
BillingMode: filters.BillingMode,
|
||||
RequestType: filters.RequestType,
|
||||
Stream: filters.Stream,
|
||||
NativeCompactionV2: filters.NativeCompactionV2,
|
||||
BillingType: filters.BillingType,
|
||||
UpstreamModelMismatch: filters.UpstreamModelMismatch,
|
||||
})
|
||||
|
||||
@@ -59,30 +59,32 @@ func NewUserHandler(
|
||||
|
||||
// CreateUserRequest represents admin create user request
|
||||
type CreateUserRequest struct {
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Username string `json:"username"`
|
||||
Notes string `json:"notes"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=admin user"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
AllowedGroups []int64 `json:"allowed_groups"`
|
||||
Email string `json:"email" binding:"required,email"`
|
||||
Password string `json:"password" binding:"required,min=6"`
|
||||
Username string `json:"username"`
|
||||
Notes string `json:"notes"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=admin user"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
AllowedGroups []int64 `json:"allowed_groups"`
|
||||
RestrictPublicGroups bool `json:"restrict_public_groups"`
|
||||
}
|
||||
|
||||
// UpdateUserRequest represents admin update user request
|
||||
// 使用指针类型来区分"未提供"和"设置为0"
|
||||
type UpdateUserRequest struct {
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"omitempty,min=6"`
|
||||
Username *string `json:"username"`
|
||||
Notes *string `json:"notes"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=admin user"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
||||
AllowedGroups *[]int64 `json:"allowed_groups"`
|
||||
Email string `json:"email" binding:"omitempty,email"`
|
||||
Password string `json:"password" binding:"omitempty,min=6"`
|
||||
Username *string `json:"username"`
|
||||
Notes *string `json:"notes"`
|
||||
Role string `json:"role" binding:"omitempty,oneof=admin user"`
|
||||
Balance *float64 `json:"balance"`
|
||||
Concurrency *int `json:"concurrency"`
|
||||
RPMLimit *int `json:"rpm_limit"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active disabled"`
|
||||
AllowedGroups *[]int64 `json:"allowed_groups"`
|
||||
RestrictPublicGroups *bool `json:"restrict_public_groups"`
|
||||
// GroupRates 用户专属分组倍率配置
|
||||
// map[groupID]*rate,nil 表示删除该分组的专属倍率
|
||||
GroupRates map[int64]*float64 `json:"group_rates"`
|
||||
@@ -284,16 +286,17 @@ func (h *UserHandler) Create(c *gin.Context) {
|
||||
}
|
||||
|
||||
user, err := h.adminService.CreateUser(c.Request.Context(), &service.CreateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Notes: req.Notes,
|
||||
Role: req.Role,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Notes: req.Notes,
|
||||
Role: req.Role,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
RestrictPublicGroups: req.RestrictPublicGroups,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -342,18 +345,19 @@ func (h *UserHandler) Update(c *gin.Context) {
|
||||
|
||||
// 使用指针类型直接传递,nil 表示未提供该字段
|
||||
user, err := h.adminService.UpdateUser(c.Request.Context(), userID, &service.UpdateUserInput{
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Notes: req.Notes,
|
||||
Role: req.Role,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
Status: req.Status,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
GroupRates: req.GroupRates,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
Email: req.Email,
|
||||
Password: req.Password,
|
||||
Username: req.Username,
|
||||
Notes: req.Notes,
|
||||
Role: req.Role,
|
||||
Balance: req.Balance,
|
||||
Concurrency: req.Concurrency,
|
||||
RPMLimit: req.RPMLimit,
|
||||
Status: req.Status,
|
||||
AllowedGroups: req.AllowedGroups,
|
||||
RestrictPublicGroups: req.RestrictPublicGroups,
|
||||
GroupRates: req.GroupRates,
|
||||
ActorAdminID: getAdminIDFromContext(c),
|
||||
})
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
|
||||
@@ -66,27 +66,30 @@ type userAvailableGroup struct {
|
||||
|
||||
// userSupportedModelPricing 用户可见的定价字段白名单。
|
||||
type userSupportedModelPricing struct {
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
BillingMode string `json:"billing_mode"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"`
|
||||
ImageInputPrice *float64 `json:"image_input_price"`
|
||||
ImageOutputPrice *float64 `json:"image_output_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
Intervals []userPricingIntervalDTO `json:"intervals"`
|
||||
}
|
||||
|
||||
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
|
||||
type userPricingIntervalDTO struct {
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
MinTokens int `json:"min_tokens"`
|
||||
MaxTokens *int `json:"max_tokens"`
|
||||
TierLabel string `json:"tier_label,omitempty"`
|
||||
InputPrice *float64 `json:"input_price"`
|
||||
OutputPrice *float64 `json:"output_price"`
|
||||
CacheWritePrice *float64 `json:"cache_write_price"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
|
||||
CacheReadPrice *float64 `json:"cache_read_price"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
}
|
||||
|
||||
// userSupportedModel 用户可见的支持模型条目。
|
||||
@@ -292,14 +295,15 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval
|
||||
intervals := make([]userPricingIntervalDTO, 0, len(src))
|
||||
for _, iv := range src {
|
||||
intervals = append(intervals, userPricingIntervalDTO{
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
MinTokens: iv.MinTokens,
|
||||
MaxTokens: iv.MaxTokens,
|
||||
TierLabel: iv.TierLabel,
|
||||
InputPrice: iv.InputPrice,
|
||||
OutputPrice: iv.OutputPrice,
|
||||
CacheWritePrice: iv.CacheWritePrice,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
CacheReadPrice: iv.CacheReadPrice,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
@@ -320,14 +324,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
billingMode = string(service.BillingModeToken)
|
||||
}
|
||||
return &userSupportedModelPricing{
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
CacheReadPrice: p.CacheReadPrice,
|
||||
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
|
||||
ImageInputPrice: p.ImageInputPrice,
|
||||
ImageOutputPrice: p.ImageOutputPrice,
|
||||
PerRequestPrice: p.PerRequestPrice,
|
||||
Intervals: intervals,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
@@ -13,11 +14,16 @@ import (
|
||||
)
|
||||
|
||||
type ChannelMonitorV2Handler struct {
|
||||
service *service.ChannelMonitorV2Service
|
||||
service *service.ChannelMonitorV2Service
|
||||
apiKeyService channelMonitorV2GroupAuthorizer
|
||||
}
|
||||
|
||||
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service) *ChannelMonitorV2Handler {
|
||||
return &ChannelMonitorV2Handler{service: svc}
|
||||
type channelMonitorV2GroupAuthorizer interface {
|
||||
GetAvailableGroups(ctx context.Context, userID int64) ([]service.Group, error)
|
||||
}
|
||||
|
||||
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service, apiKeyService *service.APIKeyService) *ChannelMonitorV2Handler {
|
||||
return &ChannelMonitorV2Handler{service: svc, apiKeyService: apiKeyService}
|
||||
}
|
||||
|
||||
// channelMonitorV2IsAdmin is true when the request already passed admin auth
|
||||
@@ -68,13 +74,17 @@ func (h *ChannelMonitorV2Handler) Dimensions(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
admin := channelMonitorV2IsAdmin(c)
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Dimensions(c.Request.Context(), filter)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
// Admin and user share this handler; only non-admin responses strip volume.
|
||||
if !channelMonitorV2IsAdmin(c) {
|
||||
if !admin {
|
||||
service.RedactChannelMonitorV2Dimensions(result)
|
||||
}
|
||||
response.Success(c, result)
|
||||
@@ -94,6 +104,9 @@ func (h *ChannelMonitorV2Handler) snapshot(c *gin.Context, admin bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Snapshot(c.Request.Context(), filter, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -107,6 +120,9 @@ func (h *ChannelMonitorV2Handler) models(c *gin.Context, admin bool) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Models(c.Request.Context(), filter, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -125,6 +141,9 @@ func (h *ChannelMonitorV2Handler) matrix(c *gin.Context, admin bool) {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Matrix(c.Request.Context(), filter, groupBy, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -138,7 +157,11 @@ func (h *ChannelMonitorV2Handler) Errors(c *gin.Context) {
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, channelMonitorV2IsAdmin(c))
|
||||
admin := channelMonitorV2IsAdmin(c)
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -156,6 +179,9 @@ func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
|
||||
response.Error(c, http.StatusUnauthorized, "user not found in context")
|
||||
return
|
||||
}
|
||||
if !h.scopeFilter(c, &filter, admin) {
|
||||
return
|
||||
}
|
||||
result, err := h.service.Users(c.Request.Context(), filter, subject.UserID, admin)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -164,6 +190,32 @@ func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
|
||||
response.Success(c, result)
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) scopeFilter(c *gin.Context, filter *service.ChannelMonitorV2Filter, admin bool) bool {
|
||||
if admin {
|
||||
return true
|
||||
}
|
||||
if h.apiKeyService == nil {
|
||||
response.Error(c, http.StatusInternalServerError, "channel monitor group authorization unavailable")
|
||||
return false
|
||||
}
|
||||
subject, ok := middleware.GetAuthSubjectFromContext(c)
|
||||
if !ok || subject.UserID <= 0 {
|
||||
response.Unauthorized(c, "user not found in context")
|
||||
return false
|
||||
}
|
||||
groups, err := h.apiKeyService.GetAvailableGroups(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return false
|
||||
}
|
||||
filter.RestrictGroups = true
|
||||
filter.AllowedGroupIDs = make([]int64, 0, len(groups))
|
||||
for i := range groups {
|
||||
filter.AllowedGroupIDs = append(filter.AllowedGroupIDs, groups[i].ID)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (h *ChannelMonitorV2Handler) parseFilter(c *gin.Context) (service.ChannelMonitorV2Filter, bool) {
|
||||
groups, err := parseChannelMonitorV2GroupIDs(queryList(c, "group_id"))
|
||||
if err != nil {
|
||||
|
||||
@@ -1,15 +1,28 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"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 channelMonitorV2GroupAuthorizerStub struct {
|
||||
groups []service.Group
|
||||
err error
|
||||
calls []int64
|
||||
}
|
||||
|
||||
func (s *channelMonitorV2GroupAuthorizerStub) GetAvailableGroups(_ context.Context, userID int64) ([]service.Group, error) {
|
||||
s.calls = append(s.calls, userID)
|
||||
return s.groups, s.err
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2QueryListSupportsRepeatedAndCommaValues(t *testing.T) {
|
||||
c, _ := gin.CreateTestContext(nil)
|
||||
c.Request = httptest.NewRequest("GET", "/?platform=openai,grok&platform=anthropic", nil)
|
||||
@@ -29,7 +42,46 @@ func TestChannelMonitorV2MatrixHandlerRejectsInvalidGroupBy(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/matrix?group_by=invalid", nil)
|
||||
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil))
|
||||
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil), nil)
|
||||
h.Matrix(c)
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ScopeFilterUsesAvailableGroupsForOrdinaryUser(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/snapshot", nil)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42})
|
||||
authorizer := &channelMonitorV2GroupAuthorizerStub{groups: []service.Group{{ID: 3}, {ID: 7}}}
|
||||
h := &ChannelMonitorV2Handler{apiKeyService: authorizer}
|
||||
filter := service.ChannelMonitorV2Filter{GroupIDs: []int64{7, 9}}
|
||||
|
||||
require.True(t, h.scopeFilter(c, &filter, false))
|
||||
require.True(t, filter.RestrictGroups)
|
||||
require.Equal(t, []int64{3, 7}, filter.AllowedGroupIDs)
|
||||
require.Equal(t, []int64{42}, authorizer.calls)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ScopeFilterPreservesEmptyOrdinaryUserScope(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/snapshot", nil)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 42})
|
||||
h := &ChannelMonitorV2Handler{apiKeyService: &channelMonitorV2GroupAuthorizerStub{}}
|
||||
filter := service.ChannelMonitorV2Filter{}
|
||||
|
||||
require.True(t, h.scopeFilter(c, &filter, false))
|
||||
require.True(t, filter.RestrictGroups)
|
||||
require.Empty(t, filter.AllowedGroupIDs)
|
||||
}
|
||||
|
||||
func TestChannelMonitorV2ScopeFilterLeavesAdminUnrestricted(t *testing.T) {
|
||||
authorizer := &channelMonitorV2GroupAuthorizerStub{}
|
||||
h := &ChannelMonitorV2Handler{apiKeyService: authorizer}
|
||||
filter := service.ChannelMonitorV2Filter{GroupIDs: []int64{9}}
|
||||
|
||||
require.True(t, h.scopeFilter(nil, &filter, true))
|
||||
require.False(t, filter.RestrictGroups)
|
||||
require.Nil(t, filter.AllowedGroupIDs)
|
||||
require.Empty(t, authorizer.calls)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -59,31 +60,109 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
|
||||
return apiKey.Group.Platform
|
||||
}
|
||||
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
|
||||
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, false
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, false
|
||||
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
|
||||
return "", nil, false
|
||||
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
||||
if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI {
|
||||
return "", nil, "", false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
|
||||
maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings
|
||||
if effectivePlatform == service.PlatformAnthropic {
|
||||
maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings)
|
||||
}
|
||||
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false
|
||||
func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, "", false
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
|
||||
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic {
|
||||
return "", nil, "", false
|
||||
}
|
||||
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings)
|
||||
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
|
||||
}
|
||||
|
||||
func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) {
|
||||
if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" {
|
||||
maxEffort = "low"
|
||||
}
|
||||
normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...)
|
||||
for i := range normalizedMappings {
|
||||
if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" {
|
||||
normalizedMappings[i].To = "low"
|
||||
}
|
||||
}
|
||||
return maxEffort, normalizedMappings
|
||||
}
|
||||
|
||||
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
effort := service.CanonicalRequestedReasoningEffort(body, model)
|
||||
if effort == nil {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithRequestedReasoningEffort(c.Request.Context(), *effort))
|
||||
}
|
||||
|
||||
func stampOpenAIRequestedReasoningEffort(result *service.OpenAIForwardResult, c *gin.Context) {
|
||||
if result == nil || result.RequestedReasoningEffort != nil {
|
||||
return
|
||||
}
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
result.RequestedReasoningEffort = service.RequestedReasoningEffortFromContext(c.Request.Context())
|
||||
}
|
||||
|
||||
func stampForwardRequestedReasoningEffort(result *service.ForwardResult, requested *string) {
|
||||
if result == nil || result.RequestedReasoningEffort != nil {
|
||||
return
|
||||
}
|
||||
result.RequestedReasoningEffort = requested
|
||||
}
|
||||
|
||||
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) {
|
||||
if c == nil || err == nil || write == nil {
|
||||
return
|
||||
}
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
|
||||
write(c, http.StatusForbidden, "permission_error", err.Error())
|
||||
}
|
||||
|
||||
func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
|
||||
maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return body, false, nil
|
||||
}
|
||||
return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
|
||||
}
|
||||
|
||||
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
|
||||
if c == nil || c.Request == nil {
|
||||
return
|
||||
}
|
||||
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
|
||||
// The Messages bridge synthesizes a default OpenAI effort when
|
||||
// output_config.effort is omitted. Bind the group policy only for an
|
||||
// explicit client value so the ceiling does not alter that default.
|
||||
@@ -91,9 +170,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s
|
||||
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
|
||||
return
|
||||
}
|
||||
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit))
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
|
||||
@@ -31,6 +32,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
|
||||
}{
|
||||
{model: "grok-4.3", platform: service.PlatformGrok},
|
||||
{model: "kimi-k2-thinking", platform: service.PlatformKimi},
|
||||
{model: "k3", platform: service.PlatformKimi},
|
||||
{model: "glm-5.2", platform: service.PlatformZhipu},
|
||||
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
|
||||
}
|
||||
@@ -116,12 +118,17 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
|
||||
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
|
||||
require.NotNil(t, requested)
|
||||
require.Equal(t, "max", *requested)
|
||||
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
|
||||
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
|
||||
|
||||
@@ -129,18 +136,100 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
|
||||
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
|
||||
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, omitted)
|
||||
|
||||
denyGroup := *group
|
||||
denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
denyAPIKey := &service.APIKey{Group: &denyGroup}
|
||||
denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
|
||||
_, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
|
||||
require.Error(t, err)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
|
||||
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
|
||||
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
apiKey := &service.APIKey{Group: &service.Group{
|
||||
Platform: service.PlatformAnthropic,
|
||||
MaxReasoningEffort: "xhigh",
|
||||
}}
|
||||
|
||||
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`))
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String())
|
||||
}
|
||||
|
||||
func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`)
|
||||
policy := func(platform string) *service.APIKey {
|
||||
return &service.APIKey{Group: &service.Group{
|
||||
Platform: platform,
|
||||
MaxReasoningEffort: "xhigh",
|
||||
ReasoningEffortMappings: []service.ReasoningEffortMapping{
|
||||
{From: "max", To: "xhigh"},
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
concrete, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got))
|
||||
|
||||
denyMax := policy(service.PlatformAnthropic)
|
||||
denyMax.Group.ReasoningEffortMappings = nil
|
||||
denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
|
||||
_, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body)
|
||||
require.Error(t, err)
|
||||
require.False(t, changed)
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
require.Equal(t, "max", overLimit.Requested)
|
||||
require.Equal(t, "xhigh", overLimit.Max)
|
||||
|
||||
composite, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
composite.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic))
|
||||
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body)
|
||||
require.NoError(t, err)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String())
|
||||
|
||||
openAI, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil)
|
||||
openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI))
|
||||
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body)
|
||||
require.NoError(t, err)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
|
||||
func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) {
|
||||
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}})
|
||||
require.Equal(t, "low", maxEffort)
|
||||
require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings)
|
||||
}
|
||||
|
||||
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
c, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
|
||||
@@ -9,10 +9,15 @@ import (
|
||||
|
||||
const statusClientClosedRequest = 499
|
||||
|
||||
func concurrencyErrorResponse(err error, slotType string) (int, string, string) {
|
||||
const (
|
||||
gatewayQueueFullCode = "gateway_queue_full"
|
||||
gatewayConcurrencyLimitCode = "gateway_concurrency_limit"
|
||||
)
|
||||
|
||||
func concurrencyErrorResponse(err error, slotType string) (int, string, string, string) {
|
||||
var waitQueueFullErr *WaitQueueFullError
|
||||
if errors.As(err, &waitQueueFullErr) {
|
||||
return http.StatusTooManyRequests, "rate_limit_error",
|
||||
return http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode,
|
||||
"Too many pending requests, please retry later"
|
||||
}
|
||||
|
||||
@@ -21,13 +26,13 @@ func concurrencyErrorResponse(err error, slotType string) (int, string, string)
|
||||
if concurrencyErr.SlotType != "" {
|
||||
slotType = concurrencyErr.SlotType
|
||||
}
|
||||
return http.StatusTooManyRequests, "rate_limit_error",
|
||||
return http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode,
|
||||
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType)
|
||||
}
|
||||
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return statusClientClosedRequest, "api_error", "context canceled"
|
||||
return statusClientClosedRequest, "api_error", "", "context canceled"
|
||||
}
|
||||
|
||||
return http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable, please retry later"
|
||||
return http.StatusServiceUnavailable, "api_error", "", "Service temporarily unavailable, please retry later"
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ func TestConcurrencyErrorResponse(t *testing.T) {
|
||||
slotType string
|
||||
wantStatus int
|
||||
wantType string
|
||||
wantCode string
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
@@ -24,8 +25,18 @@ func TestConcurrencyErrorResponse(t *testing.T) {
|
||||
slotType: "user",
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
wantType: "rate_limit_error",
|
||||
wantCode: gatewayConcurrencyLimitCode,
|
||||
wantMessage: "Concurrency limit exceeded for account, please retry later",
|
||||
},
|
||||
{
|
||||
name: "full local wait queue has gateway code",
|
||||
err: &WaitQueueFullError{SlotType: "account"},
|
||||
slotType: "account",
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
wantType: "rate_limit_error",
|
||||
wantCode: gatewayQueueFullCode,
|
||||
wantMessage: "Too many pending requests, please retry later",
|
||||
},
|
||||
{
|
||||
name: "client cancellation is not classified as concurrency limit",
|
||||
err: context.Canceled,
|
||||
@@ -54,9 +65,10 @@ func TestConcurrencyErrorResponse(t *testing.T) {
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
status, errType, message := concurrencyErrorResponse(tt.err, tt.slotType)
|
||||
status, errType, code, message := concurrencyErrorResponse(tt.err, tt.slotType)
|
||||
require.Equal(t, tt.wantStatus, status)
|
||||
require.Equal(t, tt.wantType, errType)
|
||||
require.Equal(t, tt.wantCode, code)
|
||||
require.Equal(t, tt.wantMessage, message)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 固定账号 manifest 配置必须在管理端 DTO 完整序列化:漏映射会让编辑对话框回显
|
||||
// 关闭状态,管理员保存无关字段时会把已开启的配置静默关掉。用户侧 DTO 不携带该字段。
|
||||
func TestGroupMapperRoundTripsCodexModelsManifestConfig(t *testing.T) {
|
||||
group := &service.Group{
|
||||
ID: 31, Name: "codex-manifest-dto", Platform: service.PlatformOpenAI, Status: service.StatusActive,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{101, 202},
|
||||
FallbackToScheduler: true,
|
||||
},
|
||||
}
|
||||
|
||||
adminJSON, err := json.Marshal(GroupFromServiceAdmin(group))
|
||||
require.NoError(t, err)
|
||||
var adminEnvelope struct {
|
||||
CodexModelsManifestConfig *struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
AccountIDs []int64 `json:"account_ids"`
|
||||
FallbackToScheduler bool `json:"fallback_to_scheduler"`
|
||||
} `json:"codex_models_manifest_config"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(adminJSON, &adminEnvelope))
|
||||
require.NotNil(t, adminEnvelope.CodexModelsManifestConfig, "GroupFromServiceAdmin 漏映射 codex_models_manifest_config")
|
||||
require.True(t, adminEnvelope.CodexModelsManifestConfig.Enabled)
|
||||
require.Equal(t, []int64{101, 202}, adminEnvelope.CodexModelsManifestConfig.AccountIDs)
|
||||
require.True(t, adminEnvelope.CodexModelsManifestConfig.FallbackToScheduler)
|
||||
|
||||
userJSON, err := json.Marshal(GroupFromService(group))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(userJSON), "codex_models_manifest_config", "用户侧分组 DTO 不得暴露管理端 manifest 配置")
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) {
|
||||
group := &service.Group{
|
||||
ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true,
|
||||
}
|
||||
|
||||
userJSON, err := json.Marshal(GroupFromService(group))
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(userJSON), "force_openai_fast")
|
||||
require.NotContains(t, string(userJSON), "free_openai_fast")
|
||||
|
||||
adminJSON, err := json.Marshal(GroupFromServiceAdmin(group))
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(adminJSON), `"force_openai_fast":true`)
|
||||
require.Contains(t, string(adminJSON), `"free_openai_fast":true`)
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package dto
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -68,10 +69,11 @@ func UserFromServiceAdmin(u *service.User) *AdminUser {
|
||||
return nil
|
||||
}
|
||||
return &AdminUser{
|
||||
User: *base,
|
||||
Notes: u.Notes,
|
||||
LastUsedAt: u.LastUsedAt,
|
||||
GroupRates: u.GroupRates,
|
||||
User: *base,
|
||||
Notes: u.Notes,
|
||||
LastUsedAt: u.LastUsedAt,
|
||||
GroupRates: u.GroupRates,
|
||||
RestrictPublicGroups: u.RestrictPublicGroups,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -146,6 +148,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
||||
}
|
||||
out := &AdminGroup{
|
||||
Group: groupFromServiceBase(g),
|
||||
ForceOpenAIFast: g.ForceOpenAIFast,
|
||||
FreeOpenAIFast: g.FreeOpenAIFast,
|
||||
ProfitControlEnabled: g.ProfitControlEnabled,
|
||||
ProfitMinMargin: g.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
|
||||
@@ -156,6 +160,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
||||
DefaultMappedModel: g.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: g.ModelsListConfig,
|
||||
CodexModelsManifestConfig: g.CodexModelsManifestConfig,
|
||||
SupportedModelScopes: g.SupportedModelScopes,
|
||||
AccountCount: g.AccountCount,
|
||||
ActiveAccountCount: g.ActiveAccountCount,
|
||||
@@ -219,6 +224,7 @@ func groupFromServiceBase(g *service.Group) Group {
|
||||
RequirePrivacySet: g.RequirePrivacySet,
|
||||
RPMLimit: g.RPMLimit,
|
||||
MaxReasoningEffort: g.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: g.ReasoningEffortMappings,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
@@ -442,6 +448,47 @@ func AccountFromService(a *service.Account) *Account {
|
||||
return out
|
||||
}
|
||||
|
||||
// AccountListItemFromAccount projects a full account response into the
|
||||
// compact shape used by the paginated admin account list. Keeping this
|
||||
// projection separate from Account preserves the existing detail API.
|
||||
func AccountListItemFromAccount(a *Account) *AccountListItem {
|
||||
if a == nil {
|
||||
return nil
|
||||
}
|
||||
return &AccountListItem{
|
||||
ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type,
|
||||
Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra,
|
||||
OllamaCloudUsage: a.OllamaCloudUsage,
|
||||
ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName,
|
||||
Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier,
|
||||
Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt,
|
||||
AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt,
|
||||
Schedulable: a.Schedulable, RateLimitedAt: a.RateLimitedAt, RateLimitResetAt: a.RateLimitResetAt,
|
||||
OverloadUntil: a.OverloadUntil, TempUnschedulableUntil: a.TempUnschedulableUntil,
|
||||
TempUnschedulableReason: a.TempUnschedulableReason, SessionWindowStart: a.SessionWindowStart,
|
||||
SessionWindowEnd: a.SessionWindowEnd, SessionWindowStatus: a.SessionWindowStatus,
|
||||
WindowCostLimit: a.WindowCostLimit, WindowCostStickyReserve: a.WindowCostStickyReserve,
|
||||
MaxSessions: a.MaxSessions, SessionIdleTimeoutMin: a.SessionIdleTimeoutMin, BaseRPM: a.BaseRPM,
|
||||
RPMStrategy: a.RPMStrategy, RPMStickyBuffer: a.RPMStickyBuffer, UserMsgQueueMode: a.UserMsgQueueMode,
|
||||
EnableTLSFingerprint: a.EnableTLSFingerprint, TLSFingerprintProfileID: a.TLSFingerprintProfileID,
|
||||
EnableSessionIDMasking: a.EnableSessionIDMasking, CacheTTLOverrideEnabled: a.CacheTTLOverrideEnabled,
|
||||
CacheTTLOverrideTarget: a.CacheTTLOverrideTarget, CustomBaseURLEnabled: a.CustomBaseURLEnabled,
|
||||
CustomBaseURL: a.CustomBaseURL, QuotaLimit: a.QuotaLimit, QuotaUsed: a.QuotaUsed,
|
||||
QuotaDailyLimit: a.QuotaDailyLimit, QuotaDailyUsed: a.QuotaDailyUsed, QuotaWeeklyLimit: a.QuotaWeeklyLimit,
|
||||
QuotaWeeklyUsed: a.QuotaWeeklyUsed, QuotaDailyResetMode: a.QuotaDailyResetMode,
|
||||
QuotaDailyResetHour: a.QuotaDailyResetHour, QuotaWeeklyResetMode: a.QuotaWeeklyResetMode,
|
||||
QuotaWeeklyResetDay: a.QuotaWeeklyResetDay, QuotaWeeklyResetHour: a.QuotaWeeklyResetHour,
|
||||
QuotaResetTimezone: a.QuotaResetTimezone, QuotaDailyResetAt: a.QuotaDailyResetAt,
|
||||
QuotaWeeklyResetAt: a.QuotaWeeklyResetAt, QuotaNotifyDailyEnabled: a.QuotaNotifyDailyEnabled,
|
||||
QuotaNotifyDailyThreshold: a.QuotaNotifyDailyThreshold, QuotaNotifyWeeklyEnabled: a.QuotaNotifyWeeklyEnabled,
|
||||
QuotaNotifyWeeklyThreshold: a.QuotaNotifyWeeklyThreshold, QuotaNotifyTotalEnabled: a.QuotaNotifyTotalEnabled,
|
||||
QuotaNotifyTotalThreshold: a.QuotaNotifyTotalThreshold, ParentAccountID: a.ParentAccountID,
|
||||
QuotaDimension: a.QuotaDimension, ParentEmail: a.ParentEmail, ParentPlanType: a.ParentPlanType,
|
||||
ParentPrivacyMode: a.ParentPrivacyMode, ParentSubscriptionExpiresAt: a.ParentSubscriptionExpiresAt,
|
||||
ParentChatGPTAccountID: a.ParentChatGPTAccountID, Proxy: a.Proxy, GroupIDs: a.GroupIDs,
|
||||
}
|
||||
}
|
||||
|
||||
func timeToUnixSeconds(value *time.Time) *int64 {
|
||||
if value == nil {
|
||||
return nil
|
||||
@@ -643,7 +690,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
RequestID: l.RequestID,
|
||||
Model: requestedModel,
|
||||
ServiceTier: l.ServiceTier,
|
||||
ReasoningEffort: l.ReasoningEffort,
|
||||
ReasoningEffort: userFacingReasoningEffort(l),
|
||||
InboundEndpoint: l.InboundEndpoint,
|
||||
GroupID: l.GroupID,
|
||||
SubscriptionID: l.SubscriptionID,
|
||||
@@ -665,6 +712,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
|
||||
RequestType: requestType.String(),
|
||||
Stream: stream,
|
||||
OpenAIWSMode: openAIWSMode,
|
||||
NativeCompactionV2: l.NativeCompactionV2,
|
||||
DurationMs: l.DurationMs,
|
||||
FirstTokenMs: l.FirstTokenMs,
|
||||
ImageCount: l.ImageCount,
|
||||
@@ -710,20 +758,54 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
|
||||
usageLog := usageLogFromServiceUser(l)
|
||||
usageLog.UpstreamEndpoint = l.UpstreamEndpoint
|
||||
return &AdminUsageLog{
|
||||
UsageLog: usageLog,
|
||||
UpstreamModel: l.UpstreamModel,
|
||||
UpstreamResponseModel: l.UpstreamResponseModel,
|
||||
UpstreamModelMismatch: l.UpstreamModelMismatch,
|
||||
ChannelID: l.ChannelID,
|
||||
ModelMappingChain: l.ModelMappingChain,
|
||||
BillingTier: l.BillingTier,
|
||||
AccountRateMultiplier: l.AccountRateMultiplier,
|
||||
AccountStatsCost: l.AccountStatsCost,
|
||||
IPAddress: l.IPAddress,
|
||||
Account: AccountSummaryFromService(l.Account),
|
||||
UsageLog: usageLog,
|
||||
UpstreamModel: l.UpstreamModel,
|
||||
UpstreamReasoningEffort: adminUpstreamReasoningEffort(l),
|
||||
UpstreamResponseModel: l.UpstreamResponseModel,
|
||||
UpstreamModelMismatch: l.UpstreamModelMismatch,
|
||||
ChannelID: l.ChannelID,
|
||||
ModelMappingChain: l.ModelMappingChain,
|
||||
UpstreamRequestID: l.UpstreamRequestID,
|
||||
BillingTier: l.BillingTier,
|
||||
AccountRateMultiplier: l.AccountRateMultiplier,
|
||||
AccountStatsCost: l.AccountStatsCost,
|
||||
IPAddress: l.IPAddress,
|
||||
Account: AccountSummaryFromService(l.Account),
|
||||
}
|
||||
}
|
||||
|
||||
func userFacingReasoningEffort(l *service.UsageLog) *string {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
if requested := strings.TrimSpace(derefString(l.RequestedReasoningEffort)); requested != "" {
|
||||
return &requested
|
||||
}
|
||||
return l.ReasoningEffort
|
||||
}
|
||||
|
||||
func adminUpstreamReasoningEffort(l *service.UsageLog) *string {
|
||||
if l == nil {
|
||||
return nil
|
||||
}
|
||||
forwarded := strings.TrimSpace(derefString(l.ReasoningEffort))
|
||||
if forwarded == "" {
|
||||
return nil
|
||||
}
|
||||
requested := userFacingReasoningEffort(l)
|
||||
if requested != nil && service.NormalizeMaxReasoningEffort(*requested) == service.NormalizeMaxReasoningEffort(forwarded) {
|
||||
return nil
|
||||
}
|
||||
return &forwarded
|
||||
}
|
||||
|
||||
func derefString(value *string) string {
|
||||
if value == nil {
|
||||
return ""
|
||||
}
|
||||
return *value
|
||||
}
|
||||
|
||||
func UsageCleanupTaskFromService(task *service.UsageCleanupTask) *UsageCleanupTask {
|
||||
if task == nil {
|
||||
return nil
|
||||
|
||||
@@ -28,6 +28,27 @@ func TestUsageLogFromService_IncludesOpenAIWSMode(t *testing.T) {
|
||||
require.False(t, UsageLogFromServiceAdmin(httpLog).OpenAIWSMode)
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_PreservesNativeCompactionAndStream(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
log := &service.UsageLog{
|
||||
RequestID: "resp_compaction",
|
||||
Model: "gpt-5.6-sol",
|
||||
RequestType: service.RequestTypeStream,
|
||||
Stream: true,
|
||||
NativeCompactionV2: true,
|
||||
}
|
||||
|
||||
userDTO := UsageLogFromService(log)
|
||||
adminDTO := UsageLogFromServiceAdmin(log)
|
||||
require.Equal(t, "stream", userDTO.RequestType)
|
||||
require.True(t, userDTO.Stream)
|
||||
require.True(t, userDTO.NativeCompactionV2)
|
||||
require.Equal(t, "stream", adminDTO.RequestType)
|
||||
require.True(t, adminDTO.Stream)
|
||||
require.True(t, adminDTO.NativeCompactionV2)
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_PrefersRequestTypeForLegacyFields(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -179,6 +200,61 @@ func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *test
|
||||
require.NotContains(t, string(userJSON), "account_cost")
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_UsersSeeRequestedReasoningEffortOnly(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
requested := "max"
|
||||
forwarded := "xhigh"
|
||||
log := &service.UsageLog{
|
||||
RequestID: "req_effort",
|
||||
Model: "gpt-5.4",
|
||||
ReasoningEffort: &forwarded,
|
||||
RequestedReasoningEffort: &requested,
|
||||
}
|
||||
|
||||
userDTO := UsageLogFromService(log)
|
||||
adminDTO := UsageLogFromServiceAdmin(log)
|
||||
|
||||
require.NotNil(t, userDTO.ReasoningEffort)
|
||||
require.Equal(t, requested, *userDTO.ReasoningEffort)
|
||||
require.NotNil(t, adminDTO.ReasoningEffort)
|
||||
require.Equal(t, requested, *adminDTO.ReasoningEffort)
|
||||
require.NotNil(t, adminDTO.UpstreamReasoningEffort)
|
||||
require.Equal(t, forwarded, *adminDTO.UpstreamReasoningEffort)
|
||||
|
||||
userJSON, err := json.Marshal(userDTO)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(userJSON), `"reasoning_effort":"max"`)
|
||||
require.NotContains(t, string(userJSON), "upstream_reasoning_effort")
|
||||
require.NotContains(t, string(userJSON), "requested_reasoning_effort")
|
||||
|
||||
adminJSON, err := json.Marshal(adminDTO)
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(adminJSON), `"reasoning_effort":"max"`)
|
||||
require.Contains(t, string(adminJSON), `"upstream_reasoning_effort":"xhigh"`)
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_OmitsUpstreamReasoningEffortWhenUnmapped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
effort := "high"
|
||||
log := &service.UsageLog{
|
||||
RequestID: "req_effort_same",
|
||||
Model: "gpt-5.4",
|
||||
ReasoningEffort: &effort,
|
||||
RequestedReasoningEffort: &effort,
|
||||
}
|
||||
|
||||
adminDTO := UsageLogFromServiceAdmin(log)
|
||||
require.NotNil(t, adminDTO.ReasoningEffort)
|
||||
require.Equal(t, effort, *adminDTO.ReasoningEffort)
|
||||
require.Nil(t, adminDTO.UpstreamReasoningEffort)
|
||||
|
||||
adminJSON, err := json.Marshal(adminDTO)
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, string(adminJSON), "upstream_reasoning_effort")
|
||||
}
|
||||
|
||||
func TestUsageLogFromService_FallsBackToLegacyModelWhenRequestedModelMissing(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -201,6 +201,7 @@ type SystemSettings struct {
|
||||
BackendModeEnabled bool `json:"backend_mode_enabled"`
|
||||
|
||||
// Gateway forwarding behavior
|
||||
OpenAITTFTMode string `json:"openai_ttft_mode"`
|
||||
EnableFingerprintUnification bool `json:"enable_fingerprint_unification"`
|
||||
EnableMetadataPassthrough bool `json:"enable_metadata_passthrough"`
|
||||
EnableCCHSigning bool `json:"enable_cch_signing"`
|
||||
@@ -448,6 +449,10 @@ type RateLimit429CooldownSettings struct {
|
||||
CooldownSeconds int `json:"cooldown_seconds"`
|
||||
}
|
||||
|
||||
type OpenAIImagesOAuthUnavailableCooldownSettings struct {
|
||||
CooldownMinutes int `json:"cooldown_minutes"`
|
||||
}
|
||||
|
||||
// PanelRateLimitSettings 面板 API 限流配置 DTO
|
||||
type PanelRateLimitSettings struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
|
||||
@@ -48,6 +48,9 @@ type AdminUser struct {
|
||||
// GroupRates 用户专属分组倍率配置
|
||||
// map[groupID]rateMultiplier
|
||||
GroupRates map[int64]float64 `json:"group_rates,omitempty"`
|
||||
// RestrictPublicGroups 为 true 时,该用户仅可使用 allowed_groups 中列出的
|
||||
// 公开分组。这是管理侧的权限开关,不下发给用户自身的接口。
|
||||
RestrictPublicGroups bool `json:"restrict_public_groups"`
|
||||
}
|
||||
|
||||
type APIKey struct {
|
||||
@@ -148,9 +151,11 @@ type Group struct {
|
||||
|
||||
// RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。
|
||||
RPMLimit int `json:"rpm_limit"`
|
||||
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
|
||||
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort"`
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -161,6 +166,10 @@ type Group struct {
|
||||
// 注意:普通用户接口不得返回 model_routing/account_count/account_groups 等内部信息。
|
||||
type AdminGroup struct {
|
||||
Group
|
||||
// ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。
|
||||
ForceOpenAIFast bool `json:"force_openai_fast"`
|
||||
// FreeOpenAIFast 是管理端计费策略,用户侧分组 DTO 无需暴露。
|
||||
FreeOpenAIFast bool `json:"free_openai_fast"`
|
||||
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。
|
||||
// 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出
|
||||
@@ -181,6 +190,8 @@ type AdminGroup struct {
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config"`
|
||||
// 固定账号获取 Codex Model Manifest 配置(仅 openai 平台使用)。
|
||||
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
SupportedModelScopes []string `json:"supported_model_scopes"`
|
||||
@@ -313,6 +324,102 @@ type Account struct {
|
||||
Groups []*Group `json:"groups,omitempty"`
|
||||
}
|
||||
|
||||
// AccountListItem is the compact representation returned by the admin account
|
||||
// list when the lite query parameter is enabled. It contains the fields used
|
||||
// by the account table and runtime indicators, but intentionally omits the
|
||||
// repeated account_groups and groups object graphs. Fetch /admin/accounts/:id
|
||||
// for the complete Account DTO when editing or inspecting an account.
|
||||
type AccountListItem struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Notes *string `json:"notes"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
|
||||
Credentials map[string]any `json:"credentials,omitempty"`
|
||||
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"`
|
||||
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
|
||||
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
|
||||
Concurrency int `json:"concurrency"`
|
||||
LoadFactor *int `json:"load_factor,omitempty"`
|
||||
Priority int `json:"priority"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
Status string `json:"status"`
|
||||
ErrorMessage string `json:"error_message"`
|
||||
LastUsedAt *time.Time `json:"last_used_at"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
|
||||
Schedulable bool `json:"schedulable"`
|
||||
|
||||
RateLimitedAt *time.Time `json:"rate_limited_at"`
|
||||
RateLimitResetAt *time.Time `json:"rate_limit_reset_at"`
|
||||
OverloadUntil *time.Time `json:"overload_until"`
|
||||
|
||||
TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"`
|
||||
TempUnschedulableReason string `json:"temp_unschedulable_reason"`
|
||||
|
||||
SessionWindowStart *time.Time `json:"session_window_start"`
|
||||
SessionWindowEnd *time.Time `json:"session_window_end"`
|
||||
SessionWindowStatus string `json:"session_window_status"`
|
||||
|
||||
WindowCostLimit *float64 `json:"window_cost_limit,omitempty"`
|
||||
WindowCostStickyReserve *float64 `json:"window_cost_sticky_reserve,omitempty"`
|
||||
MaxSessions *int `json:"max_sessions,omitempty"`
|
||||
SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"`
|
||||
BaseRPM *int `json:"base_rpm,omitempty"`
|
||||
RPMStrategy *string `json:"rpm_strategy,omitempty"`
|
||||
RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"`
|
||||
UserMsgQueueMode *string `json:"user_msg_queue_mode,omitempty"`
|
||||
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
|
||||
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
|
||||
EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"`
|
||||
CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"`
|
||||
CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"`
|
||||
CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"`
|
||||
CustomBaseURL *string `json:"custom_base_url,omitempty"`
|
||||
|
||||
QuotaLimit *float64 `json:"quota_limit,omitempty"`
|
||||
QuotaUsed *float64 `json:"quota_used,omitempty"`
|
||||
QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"`
|
||||
QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"`
|
||||
QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"`
|
||||
QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"`
|
||||
|
||||
QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"`
|
||||
QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"`
|
||||
QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"`
|
||||
QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"`
|
||||
QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"`
|
||||
QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"`
|
||||
QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"`
|
||||
QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"`
|
||||
|
||||
QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"`
|
||||
QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"`
|
||||
QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"`
|
||||
QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"`
|
||||
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
|
||||
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
|
||||
|
||||
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
|
||||
QuotaDimension string `json:"quota_dimension,omitempty"`
|
||||
ParentEmail string `json:"parent_email,omitempty"`
|
||||
ParentPlanType string `json:"parent_plan_type,omitempty"`
|
||||
ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"`
|
||||
ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"`
|
||||
ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"`
|
||||
|
||||
Proxy *Proxy `json:"proxy,omitempty"`
|
||||
GroupIDs []int64 `json:"group_ids,omitempty"`
|
||||
}
|
||||
|
||||
type AccountGroup struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
@@ -486,8 +593,9 @@ type UsageLog struct {
|
||||
Model string `json:"model"`
|
||||
// ServiceTier records the OpenAI service tier used for billing, e.g. "priority" / "flex".
|
||||
ServiceTier *string `json:"service_tier,omitempty"`
|
||||
// ReasoningEffort is the request's reasoning effort level.
|
||||
// ReasoningEffort is the client-requested effort (mapping-hidden, like Model).
|
||||
// OpenAI: "low"/"medium"/"high"/"xhigh"; Claude: "low"/"medium"/"high"/"max".
|
||||
// Historical rows without requested_reasoning_effort fall back to the stored effective value.
|
||||
ReasoningEffort *string `json:"reasoning_effort,omitempty"`
|
||||
// InboundEndpoint is the client-facing API endpoint path, e.g. /v1/chat/completions.
|
||||
InboundEndpoint *string `json:"inbound_endpoint,omitempty"`
|
||||
@@ -518,8 +626,11 @@ type UsageLog struct {
|
||||
RequestType string `json:"request_type"`
|
||||
Stream bool `json:"stream"`
|
||||
OpenAIWSMode bool `json:"openai_ws_mode"`
|
||||
DurationMs *int `json:"duration_ms"`
|
||||
FirstTokenMs *int `json:"first_token_ms"`
|
||||
// NativeCompactionV2 is true only for requests positively identified at
|
||||
// runtime as the native OpenAI remote compaction v2 wire.
|
||||
NativeCompactionV2 bool `json:"native_compaction_v2"`
|
||||
DurationMs *int `json:"duration_ms"`
|
||||
FirstTokenMs *int `json:"first_token_ms"`
|
||||
|
||||
// 图片生成字段
|
||||
ImageCount int `json:"image_count"`
|
||||
@@ -563,6 +674,9 @@ type AdminUsageLog struct {
|
||||
// UpstreamModel is the actual model sent to the upstream provider after mapping.
|
||||
// Omitted when no mapping was applied (requested model was used as-is).
|
||||
UpstreamModel *string `json:"upstream_model,omitempty"`
|
||||
// UpstreamReasoningEffort is the effort actually forwarded after group policy /
|
||||
// model-family remapping. Omitted when it matches the client-requested value.
|
||||
UpstreamReasoningEffort *string `json:"upstream_reasoning_effort,omitempty"`
|
||||
// UpstreamResponseModel is the raw model declared by the upstream response.
|
||||
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
|
||||
// UpstreamModelMismatch is nil when the upstream did not declare a model.
|
||||
@@ -572,6 +686,8 @@ type AdminUsageLog struct {
|
||||
ChannelID *int64 `json:"channel_id,omitempty"`
|
||||
// ModelMappingChain 模型映射链,如 "a→b→c"
|
||||
ModelMappingChain *string `json:"model_mapping_chain,omitempty"`
|
||||
// UpstreamRequestID 是直接上游声明的请求标识,仅管理端可见。
|
||||
UpstreamRequestID *string `json:"upstream_request_id,omitempty"`
|
||||
// BillingTier 计费层级标签(per_request/image 模式)
|
||||
BillingTier *string `json:"billing_tier,omitempty"`
|
||||
|
||||
|
||||
@@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc {
|
||||
|
||||
// ──────────────────────────────────────────────────────────
|
||||
// Context helpers — used by handlers before building
|
||||
// RecordUsageInput / RecordUsageLongContextInput.
|
||||
// RecordUsageInput.
|
||||
// ──────────────────────────────────────────────────────────
|
||||
|
||||
// GetInboundEndpoint returns the canonical inbound endpoint stored by
|
||||
@@ -312,6 +312,14 @@ func GetInboundEndpoint(c *gin.Context) string {
|
||||
// and the account platform. Handlers call this after scheduling an
|
||||
// account, passing account.Platform.
|
||||
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 endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
|
||||
return endpoint
|
||||
}
|
||||
}
|
||||
if c != nil {
|
||||
if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok {
|
||||
if endpoint, ok := value.(string); ok && endpoint != "" {
|
||||
|
||||
@@ -184,6 +184,16 @@ func TestGetUpstreamEndpointPrefersRuntimeOverride(t *testing.T) {
|
||||
require.Equal(t, EndpointMessages, GetUpstreamEndpoint(c, service.PlatformAntigravity))
|
||||
}
|
||||
|
||||
func TestGetUpstreamEndpointUsesOpenAIRuntimeOverride(t *testing.T) {
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
|
||||
c.Set(ctxKeyInboundEndpoint, EndpointResponses)
|
||||
|
||||
service.SetActualOpenAIUpstreamEndpoint(c, EndpointChatCompletions)
|
||||
require.Equal(t, EndpointChatCompletions, GetUpstreamEndpoint(c, service.PlatformOpenAI))
|
||||
}
|
||||
|
||||
func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -169,7 +169,21 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
body = parsedReq.Body.Bytes()
|
||||
reqModel := parsedReq.Model
|
||||
reqStream := parsedReq.Stream
|
||||
bindRequestedReasoningEffort(c, body, reqModel)
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
if policyBody, changed, err := applyAnthropicReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
if err := parsedReq.ReplaceBody(policyBody); err != nil {
|
||||
reqLog.Warn("gateway.reasoning_effort_policy_parse_failed", zap.Error(err))
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to apply reasoning effort policy")
|
||||
return
|
||||
}
|
||||
body = parsedReq.Body.Bytes()
|
||||
reqModel = parsedReq.Model
|
||||
reqStream = parsedReq.Stream
|
||||
}
|
||||
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
|
||||
|
||||
// 解析渠道级模型映射
|
||||
@@ -394,7 +408,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted)
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted)
|
||||
return
|
||||
}
|
||||
if err == nil && canWait {
|
||||
@@ -541,6 +555,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
|
||||
stampForwardRequestedReasoningEffort(result, service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort))
|
||||
if result.ReasoningEffort == nil {
|
||||
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(parsedReq.OutputEffort)
|
||||
}
|
||||
@@ -608,6 +623,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
c.Request = c.Request.WithContext(ctx)
|
||||
}
|
||||
|
||||
// 会话槽失败释放:failover 链上每个选中的 Anthropic OAuth 账号都注册过同一会话
|
||||
// (checkAndRegisterSession)。若请求最终失败(转发失败/客户端中断/换号耗尽),
|
||||
// 须立即释放本次会话注册——上游从未真正服务该会话,继续占槽会让 max_sessions
|
||||
// 受限的账号被失败请求的 session hash 卡满整个空闲窗口,后续新会话全部被拒。
|
||||
// 成功请求保持既有空闲超时语义(会话按最后活动时间过期)。
|
||||
sessionSlotAccounts := make(map[int64]*service.Account)
|
||||
upstreamServedSession := false
|
||||
defer func() {
|
||||
if upstreamServedSession {
|
||||
return
|
||||
}
|
||||
// 客户端可能已断开、请求 ctx 已取消,用独立 ctx 执行释放
|
||||
for _, acc := range sessionSlotAccounts {
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
|
||||
}
|
||||
}()
|
||||
|
||||
for {
|
||||
fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession)
|
||||
retryWithFallback := false
|
||||
@@ -717,7 +749,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted)
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted)
|
||||
return
|
||||
}
|
||||
if err == nil && canWait {
|
||||
@@ -761,10 +793,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
|
||||
return
|
||||
}
|
||||
// 尝试被否决(从未转发),立即释放该账号的会话注册
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
|
||||
delete(sessionSlotAccounts, account.ID)
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
// 记录本请求注册过会话槽的账号(profit 准入后账号已定)
|
||||
sessionSlotAccounts[account.ID] = account
|
||||
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
|
||||
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
|
||||
if selection.ProfitGateActive() || !selection.Acquired {
|
||||
@@ -882,6 +919,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
|
||||
stampForwardRequestedReasoningEffort(result, service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort))
|
||||
if result.ReasoningEffort == nil {
|
||||
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)
|
||||
}
|
||||
@@ -980,6 +1018,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
currentSubscription = nil
|
||||
fallbackUsed = true
|
||||
retryWithFallback = true
|
||||
// 原分组账号已确定性失败(prompt too long),先释放其会话注册再走兜底分组
|
||||
for _, acc := range sessionSlotAccounts {
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
|
||||
}
|
||||
sessionSlotAccounts = make(map[int64]*service.Account)
|
||||
break
|
||||
}
|
||||
_ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body)
|
||||
@@ -995,6 +1038,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
|
||||
switch action {
|
||||
case FailoverContinue:
|
||||
// 本次尝试已确定性失败,立即释放该账号的会话注册
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
|
||||
delete(sessionSlotAccounts, account.ID)
|
||||
continue
|
||||
case FailoverExhausted:
|
||||
h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted)
|
||||
@@ -1033,6 +1079,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
// 不会走到这里重复计费。
|
||||
if result != nil {
|
||||
submitForwardUsage(result)
|
||||
// 上游已接受并计量本次会话(流中断),会话槽保持既有语义
|
||||
upstreamServedSession = true
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1058,6 +1106,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
|
||||
submitForwardUsage(result)
|
||||
// 转发成功,会话槽保持既有空闲超时语义
|
||||
upstreamServedSession = true
|
||||
return
|
||||
}
|
||||
if !retryWithFallback {
|
||||
@@ -1140,6 +1190,79 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
|
||||
// CodexModels returns the effective group model list using the manifest shape
|
||||
// expected by Codex custom providers. Official OpenAI groups continue to use
|
||||
// OpenAIGatewayHandler.CodexModels so their live upstream metadata is preserved.
|
||||
func (h *GatewayHandler) CodexModels(c *gin.Context) {
|
||||
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
|
||||
if !ok || apiKey == nil || apiKey.Group == nil {
|
||||
h.errorResponse(c, http.StatusUnauthorized, "invalid_request_error", "API key group is required")
|
||||
return
|
||||
}
|
||||
|
||||
forcedPlatform := ""
|
||||
if value, exists := middleware2.GetForcePlatformFromContext(c); exists {
|
||||
forcedPlatform = strings.TrimSpace(value)
|
||||
}
|
||||
modelIDs := h.codexModelIDsForGroup(c.Request.Context(), apiKey.Group, forcedPlatform)
|
||||
modelIDs = service.FilterCodexModelIDsForGroup(modelIDs, apiKey.Group)
|
||||
body, err := h.gatewayService.BuildCodexModelsManifestForGroup(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
forcedPlatform,
|
||||
modelIDs,
|
||||
)
|
||||
if err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
etag := service.CodexModelsManifestETag(body)
|
||||
c.Header("ETag", etag)
|
||||
if service.CodexModelsManifestETagMatches(c.GetHeader("If-None-Match"), etag) {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", body)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *service.Group, platformOverride string) []string {
|
||||
if h == nil || h.gatewayService == nil || group == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
groupID := &group.ID
|
||||
platform := strings.TrimSpace(platformOverride)
|
||||
if platform == "" {
|
||||
platform = group.Platform
|
||||
}
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(ctx, groupID)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
}
|
||||
return fallbackModels
|
||||
}
|
||||
|
||||
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(platform)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(
|
||||
customModelsListSource(platform, availableModels, fallbackModels),
|
||||
fallbackModels,
|
||||
group.ModelsListConfig.Models,
|
||||
)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
}
|
||||
return fallbackModels
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
|
||||
if h == nil || h.gatewayService == nil {
|
||||
return nil
|
||||
@@ -1234,11 +1357,15 @@ func writeGrokModelsList(c *gin.Context, modelIDs []string) {
|
||||
if grokModelSupportsConfigurableReasoning(modelID) {
|
||||
item.SupportsReasoningEffort = true
|
||||
item.ReasoningEffort = "high"
|
||||
item.ReasoningEfforts = []grokReasoningEffortOption{
|
||||
efforts := []grokReasoningEffortOption{
|
||||
{Value: "low", Label: "Low"},
|
||||
{Value: "medium", Label: "Medium"},
|
||||
{Value: "high", Label: "High", Default: true},
|
||||
}
|
||||
if service.GrokSupportsXHighReasoningEffort(modelID) {
|
||||
efforts = append(efforts, grokReasoningEffortOption{Value: "xhigh", Label: "xHigh"})
|
||||
}
|
||||
item.ReasoningEfforts = efforts
|
||||
}
|
||||
models = append(models, item)
|
||||
}
|
||||
@@ -1340,9 +1467,26 @@ func customModelsListAllowsModel(availablePatterns []string, model string) bool
|
||||
return true
|
||||
}
|
||||
}
|
||||
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
|
||||
if normalizedClaudeModel != model {
|
||||
for _, pattern := range availablePatterns {
|
||||
if pattern == normalizedClaudeModel {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func defaultCodexModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformDeepseek:
|
||||
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
|
||||
default:
|
||||
return defaultModelIDsForPlatform(platform)
|
||||
}
|
||||
}
|
||||
|
||||
func defaultModelIDsForPlatform(platform string) []string {
|
||||
switch platform {
|
||||
case service.PlatformOpenAI:
|
||||
@@ -1361,14 +1505,7 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
}
|
||||
return ids
|
||||
case service.PlatformAnthropic:
|
||||
ids := make([]string, 0, len(claude.DefaultModels)+len(antigravity.DefaultModels()))
|
||||
for _, model := range claude.DefaultModels {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
for _, model := range antigravity.DefaultModels() {
|
||||
ids = append(ids, model.ID)
|
||||
}
|
||||
return mergeModelIDs(ids, nil)
|
||||
return claude.DefaultModelIDs()
|
||||
case service.PlatformGrok:
|
||||
return xai.DefaultModelIDs()
|
||||
case service.PlatformComposite:
|
||||
@@ -1763,8 +1900,8 @@ func (h *GatewayHandler) calculateSubscriptionRemaining(group *service.Group, su
|
||||
|
||||
// handleConcurrencyError handles concurrency-related acquire errors.
|
||||
func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
|
||||
status, errType, message := concurrencyErrorResponse(err, slotType)
|
||||
h.handleStreamingAwareError(c, status, errType, message, streamStarted)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, slotType)
|
||||
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, platform string, streamStarted bool) {
|
||||
@@ -1835,6 +1972,10 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string)
|
||||
|
||||
// handleStreamingAwareError handles errors that may occur after streaming has started
|
||||
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
|
||||
h.handleStreamingAwareErrorWithCode(c, status, errType, "", message, streamStarted)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, status int, errType, code, message string, streamStarted bool) {
|
||||
if streamStarted {
|
||||
// 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。
|
||||
// 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集,
|
||||
@@ -1845,7 +1986,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
|
||||
// response.completed/failed/incomplete/cancelled 集合。
|
||||
// Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。
|
||||
if inboundIsResponses(c) {
|
||||
if writeResponsesFailedSSE(c, errType, message) {
|
||||
if writeResponsesFailedSSE(c, errType, code, message) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -1853,7 +1994,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if ok {
|
||||
// SSE 错误事件固定 schema,使用 Quote 直拼可避免额外 Marshal 分配。
|
||||
errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + `,"message":` + strconv.Quote(message) + `}}` + "\n\n"
|
||||
errorCode := ""
|
||||
if code != "" {
|
||||
errorCode = `,"code":` + strconv.Quote(code)
|
||||
}
|
||||
errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + errorCode + `,"message":` + strconv.Quote(message) + `}}` + "\n\n"
|
||||
if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil {
|
||||
_ = c.Error(err)
|
||||
}
|
||||
@@ -1863,7 +2008,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
|
||||
}
|
||||
|
||||
// Normal case: return JSON response with proper status code
|
||||
h.errorResponse(c, status, errType, message)
|
||||
h.errorResponseWithCode(c, status, errType, code, message)
|
||||
}
|
||||
|
||||
// ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。
|
||||
@@ -1955,12 +2100,17 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool {
|
||||
|
||||
// errorResponse 返回Claude API格式的错误响应
|
||||
func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) {
|
||||
h.errorResponseWithCode(c, status, errType, "", message)
|
||||
}
|
||||
|
||||
func (h *GatewayHandler) errorResponseWithCode(c *gin.Context, status int, errType, code, message string) {
|
||||
errorObject := gin.H{"type": errType, "message": message}
|
||||
if code != "" {
|
||||
errorObject["code"] = code
|
||||
}
|
||||
c.JSON(status, gin.H{
|
||||
"type": "error",
|
||||
"error": gin.H{
|
||||
"type": errType,
|
||||
"message": message,
|
||||
},
|
||||
"type": "error",
|
||||
"error": errorObject,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2073,6 +2223,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
|
||||
if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil {
|
||||
reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
// 错误响应已在 ForwardCountTokens 中处理
|
||||
// 上游未服务该会话,立即释放选号时注册的会话槽(客户端可能已断开,用独立 ctx)
|
||||
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionHash)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
reqModel := modelResult.String()
|
||||
bindRequestedReasoningEffort(c, body, reqModel)
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
if !compositeTargetPlatformResolved(c, apiKey, reqModel) {
|
||||
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
|
||||
@@ -335,6 +336,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
stampForwardRequestedReasoningEffort(result, service.RequestedReasoningEffortFromContext(c.Request.Context()))
|
||||
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
|
||||
@@ -76,6 +76,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
reqModel := modelResult.String()
|
||||
bindRequestedReasoningEffort(c, body, reqModel)
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
if !compositeTargetPlatformResolved(c, apiKey, reqModel) {
|
||||
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
|
||||
@@ -323,6 +324,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
stampForwardRequestedReasoningEffort(result, service.RequestedReasoningEffortFromContext(c.Request.Context()))
|
||||
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
@@ -392,7 +394,7 @@ func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastEr
|
||||
// generic response.failed.
|
||||
service.MarkOpsStreamError(c, code, message, status)
|
||||
if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) {
|
||||
writeResponsesFailedSSE(c, code, message)
|
||||
writeResponsesFailedSSE(c, code, "", message)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -25,6 +25,22 @@ type gatewayModelsResponseForTest struct {
|
||||
Data []gatewayModelItemForTest `json:"data"`
|
||||
}
|
||||
|
||||
type codexModelsResponseForTest struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
||||
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
||||
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
||||
Upgrade json.RawMessage `json:"upgrade"`
|
||||
} `json:"models"`
|
||||
}
|
||||
|
||||
type codexReasoningLevelForTest struct {
|
||||
Effort string `json:"effort"`
|
||||
}
|
||||
|
||||
type gatewayModelItemForTest struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
@@ -52,6 +68,17 @@ func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Cont
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
|
||||
return s.ListSchedulableByGroupID(ctx, groupID)
|
||||
}
|
||||
|
||||
func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]service.Account, error) {
|
||||
if groupID == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return s.ListSchedulableByGroupID(ctx, *groupID)
|
||||
}
|
||||
|
||||
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
|
||||
return &GatewayHandler{
|
||||
gatewayService: service.NewGatewayService(
|
||||
@@ -70,6 +97,247 @@ func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
|
||||
require.Contains(t, compositeIDs, antigravityIDs[0])
|
||||
}
|
||||
|
||||
// Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models.
|
||||
func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) {
|
||||
anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic)
|
||||
require.Contains(t, anthropicIDs, "claude-opus-4-6")
|
||||
require.NotContains(t, anthropicIDs, "gemini-2.5-flash")
|
||||
|
||||
antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
|
||||
require.Contains(t, antigravityIDs, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
|
||||
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
platform string
|
||||
model string
|
||||
efforts []string
|
||||
modalities []string
|
||||
}{
|
||||
{
|
||||
name: "Grok",
|
||||
platform: service.PlatformGrok,
|
||||
model: "grok-4.6",
|
||||
efforts: []string{"low", "medium", "high", "xhigh"},
|
||||
modalities: []string{"text", "image"},
|
||||
},
|
||||
{
|
||||
name: "DeepSeek",
|
||||
platform: service.PlatformDeepseek,
|
||||
model: "deepseek-v4-pro",
|
||||
efforts: []string{"low", "high", "max"},
|
||||
modalities: []string{"text"},
|
||||
},
|
||||
{
|
||||
name: "provider-qualified Claude",
|
||||
platform: service.PlatformAnthropic,
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
efforts: []string{"low", "medium", "high", "max"},
|
||||
modalities: []string{"text"},
|
||||
},
|
||||
}
|
||||
|
||||
for index, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(100 + index)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: tt.platform,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{tt.model: tt.model},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: tt.platform},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Len(t, got.Models, 1)
|
||||
require.Equal(t, tt.model, got.Models[0].Slug)
|
||||
require.NotEmpty(t, got.Models[0].ModelMessages)
|
||||
require.NotEmpty(t, got.Models[0].TruncationPolicy)
|
||||
require.NotNil(t, got.Models[0].AvailabilityNUX)
|
||||
require.NotNil(t, got.Models[0].Upgrade)
|
||||
require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels))
|
||||
require.Equal(t, tt.modalities, got.Models[0].InputModalities)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: Composite manifests aggregate only administrator-configured models.
|
||||
func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 120
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 3,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: service.PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 122
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"},
|
||||
},
|
||||
}},
|
||||
},
|
||||
})
|
||||
group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek}
|
||||
|
||||
first := httptest.NewRecorder()
|
||||
firstContext, _ := gin.CreateTestContext(first)
|
||||
firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(firstContext)
|
||||
|
||||
require.Equal(t, http.StatusOK, first.Code)
|
||||
etag := first.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag)
|
||||
|
||||
second := httptest.NewRecorder()
|
||||
secondContext, _ := gin.CreateTestContext(second)
|
||||
secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
secondContext.Request.Header.Set("If-None-Match", "W/"+etag)
|
||||
secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
||||
h.CodexModels(secondContext)
|
||||
|
||||
require.Equal(t, http.StatusNotModified, second.Code)
|
||||
require.Empty(t, second.Body.Bytes())
|
||||
require.Equal(t, etag, second.Header().Get("ETag"))
|
||||
}
|
||||
|
||||
// Scenario: group models_list_config limits the generated Codex manifest.
|
||||
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 121
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Platform: service.PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformComposite,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"grok-4.6"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
|
||||
func codexModelSlugsForTest(models []struct {
|
||||
Slug string `json:"slug"`
|
||||
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
||||
InputModalities []string `json:"input_modalities"`
|
||||
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
||||
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
||||
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
||||
Upgrade json.RawMessage `json:"upgrade"`
|
||||
}) []string {
|
||||
slugs := make([]string, 0, len(models))
|
||||
for _, model := range models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string {
|
||||
efforts := make([]string, 0, len(levels))
|
||||
for _, level := range levels {
|
||||
efforts = append(efforts, level.Effort)
|
||||
}
|
||||
return efforts
|
||||
}
|
||||
|
||||
func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -103,9 +371,38 @@ func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T) {
|
||||
assertGrokGatewayReasoningEfforts(t, 4409, "grok-4.5", []gatewayReasoningEffortOptionForTest{
|
||||
{Value: "low", Label: "Low"},
|
||||
{Value: "medium", Label: "Medium"},
|
||||
{Value: "high", Label: "High", Default: true},
|
||||
})
|
||||
}
|
||||
|
||||
func TestGatewayModels_Grok46AdvertisesXHighReasoningEffortForGrokBuild(t *testing.T) {
|
||||
xhighEfforts := []gatewayReasoningEffortOptionForTest{
|
||||
{Value: "low", Label: "Low"},
|
||||
{Value: "medium", Label: "Medium"},
|
||||
{Value: "high", Label: "High", Default: true},
|
||||
{Value: "xhigh", Label: "xHigh"},
|
||||
}
|
||||
tests := []struct {
|
||||
groupID int64
|
||||
model string
|
||||
}{
|
||||
{groupID: 4410, model: "grok-4.6"},
|
||||
{groupID: 4411, model: "grok-4.6-latest"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.model, func(t *testing.T) {
|
||||
assertGrokGatewayReasoningEfforts(t, tt.groupID, tt.model, xhighEfforts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func assertGrokGatewayReasoningEfforts(t *testing.T, groupID int64, modelID string, want []gatewayReasoningEffortOptionForTest) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(4409)
|
||||
h := newGatewayModelsHandlerForTest(
|
||||
&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
@@ -114,7 +411,7 @@ func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T)
|
||||
ID: 1,
|
||||
Platform: service.PlatformGrok,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"grok-4.5": "grok-4.5"},
|
||||
"model_mapping": map[string]any{modelID: modelID},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -136,14 +433,10 @@ func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T)
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Len(t, got.Data, 1)
|
||||
model := got.Data[0]
|
||||
require.Equal(t, "grok-4.5", model.ID)
|
||||
require.Equal(t, modelID, model.ID)
|
||||
require.True(t, model.SupportsReasoningEffort)
|
||||
require.Equal(t, "high", model.ReasoningEffort)
|
||||
require.Equal(t, []gatewayReasoningEffortOptionForTest{
|
||||
{Value: "low", Label: "Low"},
|
||||
{Value: "medium", Label: "Medium"},
|
||||
{Value: "high", Label: "High", Default: true},
|
||||
}, model.ReasoningEfforts)
|
||||
require.Equal(t, want, model.ReasoningEfforts)
|
||||
}
|
||||
|
||||
func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
||||
@@ -193,6 +486,62 @@ func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
||||
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
// Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults.
|
||||
func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(64)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAnthropic}},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := codexModelSlugsForTest(got.Models)
|
||||
require.Contains(t, slugs, "claude-opus-4-6")
|
||||
require.NotContains(t, slugs, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
// Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups.
|
||||
func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(65)
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {{ID: 1, Platform: service.PlatformAntigravity}},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
ids := modelIDsForTest(got.Data)
|
||||
require.Contains(t, ids, "claude-opus-4-6")
|
||||
require.Contains(t, ids, "gemini-2.5-flash")
|
||||
}
|
||||
|
||||
func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
@@ -457,6 +806,89 @@ 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, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 130
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := make([]string, 0, len(got.Models))
|
||||
for _, model := range got.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
require.Contains(t, slugs, "deepseek-v4-pro")
|
||||
require.Contains(t, slugs, "deepseek-v4-flash")
|
||||
require.NotContains(t, slugs, "claude-sonnet-4-6")
|
||||
require.NotContains(t, slugs, "claude-opus-4-6")
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 131
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
groupID: {
|
||||
{
|
||||
ID: 1,
|
||||
Platform: service.PlatformDeepseek,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{
|
||||
"foo-*": "deepseek-v4-pro",
|
||||
"deepseek-v4-pro": "deepseek-v4-pro",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
|
||||
})
|
||||
|
||||
h.CodexModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got codexModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
slugs := make([]string, 0, len(got.Models))
|
||||
for _, model := range got.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
require.Equal(t, []string{"deepseek-v4-pro"}, slugs)
|
||||
}
|
||||
|
||||
func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -52,6 +52,11 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if models, ok := customGeminiModelsList(apiKey.Group); ok {
|
||||
c.JSON(http.StatusOK, models)
|
||||
return
|
||||
}
|
||||
|
||||
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
|
||||
if err != nil {
|
||||
// 没有 gemini 账户,检查是否有 antigravity 账户可用
|
||||
@@ -78,6 +83,17 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
writeUpstreamResponse(c, res)
|
||||
}
|
||||
|
||||
func customGeminiModelsList(group *service.Group) (gemini.ModelsListResponse, bool) {
|
||||
if group == nil || !group.CustomModelsListEnabled() {
|
||||
return gemini.ModelsListResponse{}, false
|
||||
}
|
||||
models := make([]gemini.Model, 0, len(group.ModelsListConfig.Models))
|
||||
for _, modelID := range group.ModelsListConfig.Models {
|
||||
models = append(models, gemini.FallbackModel(modelID))
|
||||
}
|
||||
return gemini.ModelsListResponse{Models: models}, true
|
||||
}
|
||||
|
||||
// GeminiV1BetaGetModel proxies:
|
||||
// GET /v1beta/models/{model}
|
||||
func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
@@ -569,33 +585,25 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
forceCacheBilling := fs.ForceCacheBilling
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
// 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。
|
||||
var longContextThreshold int
|
||||
var longContextMultiplier float64
|
||||
if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil {
|
||||
longContextThreshold = rule.Threshold
|
||||
longContextMultiplier = rule.Multiplier
|
||||
}
|
||||
// 长上下文阶梯由目录数据驱动,统一在计费路径内生效,入口无需声明。
|
||||
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
|
||||
Result: result,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
LongContextThreshold: longContextThreshold,
|
||||
LongContextMultiplier: longContextMultiplier,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
APIKey: apiKey,
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gemini_v1beta.models"),
|
||||
|
||||
@@ -3,13 +3,81 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
"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"
|
||||
)
|
||||
|
||||
func TestGeminiV1BetaListModels_CustomGroupListUsesNativeResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
Platform: service.PlatformGemini,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-pro", "models/gemini-custom"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
(&GatewayHandler{}).GeminiV1BetaListModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got gemini.ModelsListResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []gemini.Model{
|
||||
gemini.FallbackModel("gemini-2.5-pro"),
|
||||
gemini.FallbackModel("models/gemini-custom"),
|
||||
}, got.Models)
|
||||
}
|
||||
|
||||
func TestGeminiV1BetaListModels_ForcedAntigravityIgnoresCustomGroupList(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/v1beta/models", nil)
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
Platform: service.PlatformGemini,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-custom"},
|
||||
},
|
||||
},
|
||||
})
|
||||
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
|
||||
|
||||
(&GatewayHandler{}).GeminiV1BetaListModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got antigravity.GeminiModelsListResponse
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, antigravity.FallbackGeminiModelsList(), got)
|
||||
}
|
||||
|
||||
func TestCustomGeminiModelsList_DisabledKeepsExistingFlow(t *testing.T) {
|
||||
group := &service.Group{
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: false,
|
||||
Models: []string{"gemini-2.5-pro"},
|
||||
},
|
||||
}
|
||||
|
||||
_, ok := customGeminiModelsList(group)
|
||||
require.False(t, ok)
|
||||
}
|
||||
|
||||
// TestGeminiV1BetaHandler_PlatformRoutingInvariant 文档化并验证 Handler 层的平台路由逻辑不变量
|
||||
// 该测试确保 gemini 和 antigravity 平台的路由逻辑符合预期
|
||||
func TestGeminiV1BetaHandler_PlatformRoutingInvariant(t *testing.T) {
|
||||
|
||||
@@ -15,7 +15,8 @@ import (
|
||||
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
|
||||
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
|
||||
// - 匿名:仅非专属分组(订阅型照常展示);
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性);
|
||||
// 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。
|
||||
type ModelPlazaHandler struct {
|
||||
plazaService *service.ModelPlazaService
|
||||
apiKeyService *service.APIKeyService
|
||||
@@ -127,11 +128,12 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// allowedExclusive == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
|
||||
var allowedExclusive map[int64]struct{}
|
||||
// allowedGroups == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
|
||||
var allowedGroups map[int64]struct{}
|
||||
var restrictPublicGroups bool
|
||||
var userRates map[int64]float64
|
||||
if authed {
|
||||
allowedExclusive, err = h.apiKeyService.GetUserAllowedGroupIDSet(c.Request.Context(), subject.UserID)
|
||||
allowedGroups, restrictPublicGroups, err = h.apiKeyService.GetUserGroupVisibility(c.Request.Context(), subject.UserID)
|
||||
if err != nil {
|
||||
// 可见性数据拿不到时不能静默降级成匿名视图(会错漏专属分组),直接报错。
|
||||
response.ErrorFrom(c, err)
|
||||
@@ -145,7 +147,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
visible := filterPlazaVisibleGroups(groups, allowedExclusive)
|
||||
visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups)
|
||||
|
||||
out := make([]modelPlazaGroup, 0, len(visible))
|
||||
for i := range visible {
|
||||
@@ -158,18 +160,21 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
|
||||
// allowedExclusive == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
|
||||
// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
|
||||
// restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会
|
||||
// 在广场看到自己实际绑定不了的分组。
|
||||
func filterPlazaVisibleGroups(
|
||||
groups []service.PlazaGroup,
|
||||
allowedExclusive map[int64]struct{},
|
||||
allowedGroups map[int64]struct{},
|
||||
restrictPublicGroups bool,
|
||||
) []service.PlazaGroup {
|
||||
visible := make([]service.PlazaGroup, 0, len(groups))
|
||||
for _, g := range groups {
|
||||
if g.IsExclusive {
|
||||
if allowedExclusive == nil {
|
||||
if g.IsExclusive || (restrictPublicGroups && allowedGroups != nil) {
|
||||
if allowedGroups == nil {
|
||||
continue
|
||||
}
|
||||
if _, ok := allowedExclusive[g.ID]; !ok {
|
||||
if _, ok := allowedGroups[g.ID]; !ok {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func plazaGroups() []service.PlazaGroup {
|
||||
|
||||
func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
|
||||
// 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), nil)
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), nil, false)
|
||||
require.Len(t, visible, 2)
|
||||
ids := []int64{visible[0].ID, visible[1].ID}
|
||||
require.ElementsMatch(t, []int64{1, 3}, ids)
|
||||
@@ -34,7 +34,7 @@ func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
|
||||
func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
|
||||
// 登录:非专属 + 授权的专属;未授权的专属仍不可见。
|
||||
allowed := map[int64]struct{}{2: {}}
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), allowed)
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), allowed, false)
|
||||
require.Len(t, visible, 3)
|
||||
ids := make([]int64, 0, len(visible))
|
||||
for _, g := range visible {
|
||||
@@ -46,10 +46,33 @@ func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
|
||||
func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) {
|
||||
// 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属,
|
||||
// 但语义区分要保持——空集合不能被当作 nil 匿名分支。
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{})
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{}, false)
|
||||
require.Len(t, visible, 2)
|
||||
}
|
||||
|
||||
func TestFilterPlazaVisibleGroups_RestrictedUserSeesOnlyGrantedPublic(t *testing.T) {
|
||||
// 开启公开分组限制后,公开分组也必须落在授权集合内,否则用户会在广场
|
||||
// 看到自己实际绑定不了的分组。
|
||||
allowed := map[int64]struct{}{1: {}, 2: {}}
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), allowed, true)
|
||||
ids := make([]int64, 0, len(visible))
|
||||
for _, g := range visible {
|
||||
ids = append(ids, g.ID)
|
||||
}
|
||||
// 3 是未授权的公开分组,受限后不可见;4 是未授权的专属分组,一贯不可见。
|
||||
require.ElementsMatch(t, []int64{1, 2}, ids)
|
||||
}
|
||||
|
||||
func TestFilterPlazaVisibleGroups_RestrictionDoesNotAffectAnonymous(t *testing.T) {
|
||||
// 匿名没有用户记录,限制标志无从谈起,可见性必须与未受限时一致。
|
||||
visible := filterPlazaVisibleGroups(plazaGroups(), nil, true)
|
||||
ids := make([]int64, 0, len(visible))
|
||||
for _, g := range visible {
|
||||
ids = append(ids, g.ID)
|
||||
}
|
||||
require.ElementsMatch(t, []int64{1, 3}, ids)
|
||||
}
|
||||
|
||||
func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
h := &ModelPlazaHandler{} // settingService == nil → fail-closed
|
||||
|
||||
@@ -42,6 +42,23 @@ func classifySelectionFailureError(err error, fallback noAccountErrorClassificat
|
||||
if err == nil {
|
||||
return fallback
|
||||
}
|
||||
// A 404 model_not_found fallback is authoritative and must not be downgraded
|
||||
// to a rate-limit verdict. classifyNoAccountError only reaches it through
|
||||
// DiagnoseModelAvailabilityForPlatform, a dedicated database query over
|
||||
// persistent eligibility (active + schedulable + model_mapping) that already
|
||||
// established no account in the group can serve this model at all. A transient
|
||||
// per-model cooldown on one of the remaining candidates does not make "all
|
||||
// available accounts are rate-limited" true.
|
||||
//
|
||||
// Reporting 429 here is actively harmful: retrying can never succeed, and
|
||||
// clients that treat 429 as a rate limit retry hard and swallow the body
|
||||
// (Codex surfaces only "exceeded retry limit, last status: 429"), losing the
|
||||
// one message that names the real problem. It also flips the ops attribution
|
||||
// from a local model-configuration issue to routing capacity, because call
|
||||
// sites gate markOpsRoutingCapacityLimitedIfNoAvailable on ModelNotFound.
|
||||
if fallback.ModelNotFound {
|
||||
return fallback
|
||||
}
|
||||
match := selectionModelRateLimitedPattern.FindStringSubmatch(strings.ToLower(err.Error()))
|
||||
if len(match) != 2 {
|
||||
return fallback
|
||||
|
||||
@@ -234,3 +234,72 @@ func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
|
||||
require.False(t, service.HasOpsClientBusinessLimited(nil))
|
||||
require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
|
||||
}
|
||||
|
||||
// 权威的 404 model_not_found 不能被"账号被限流"的 429 盖掉。
|
||||
//
|
||||
// 选号失败的错误串同时携带多种过滤原因,例如
|
||||
// "pool=9, filtered: model_not_supported=8 model_rate_limited=1":8 个账号根本不支持该模型,
|
||||
// 剩下 1 个恰好处于模型级冷却。此时 classifyNoAccountError 已通过持久化判据确认整个分组
|
||||
// 没有账号能服务该模型(ModelNotFound=true),改判成 429 "All available accounts are
|
||||
// currently rate-limited" 是错误诊断——重试永远不会成功,而把 429 当限流的客户端会反复
|
||||
// 重试并吞掉 body(Codex 只显示 "exceeded retry limit"),恰好丢掉唯一说明真实原因的信息。
|
||||
func TestClassifySelectionFailureError_ModelNotFoundIsNotOverriddenByRateLimited(t *testing.T) {
|
||||
modelNotFound := noAccountErrorClassification{
|
||||
Status: http.StatusNotFound,
|
||||
ErrType: "model_not_found",
|
||||
Message: `Model "gpt-5.3-codex" is not supported by any configured account in this group`,
|
||||
ModelNotFound: true,
|
||||
}
|
||||
|
||||
got := classifySelectionFailureError(
|
||||
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
|
||||
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
|
||||
modelNotFound,
|
||||
)
|
||||
|
||||
require.Equal(t, modelNotFound, got,
|
||||
"分组里没有任何账号能服务该模型时,模型级冷却不该把 404 改判成 429")
|
||||
}
|
||||
|
||||
// 真实调用点的顺序:先 classifyNoAccountErrorFromGin,再 classifySelectionFailureError。
|
||||
// 覆盖这条链路是为了同时锁住 ops 归因——调用点用 ModelNotFound 决定是否标记
|
||||
// routing capacity limited,一旦 404 被改判成 429,同一个请求会既被标成
|
||||
// local model configuration 又被标成容量问题,自相矛盾。
|
||||
func TestClassifySelectionFailureError_CallSiteChainKeepsModelNotFoundAttribution(t *testing.T) {
|
||||
c := newTestGinContextWithRequest()
|
||||
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
|
||||
apiKey := &service.APIKey{GroupID: ptrInt64(43)}
|
||||
|
||||
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.3-codex", "gpt-5.3-codex", service.PlatformOpenAI)
|
||||
cls = classifySelectionFailureError(
|
||||
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
|
||||
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
|
||||
cls,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, cls.Status)
|
||||
require.Equal(t, "model_not_found", cls.ErrType)
|
||||
require.True(t, cls.ModelNotFound)
|
||||
require.Contains(t, cls.Message, "gpt-5.3-codex")
|
||||
require.True(t, service.HasOpsClientBusinessLimited(c))
|
||||
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
|
||||
}
|
||||
|
||||
// 池子里确实存在能服务该模型、只是全部在冷却的账号时,429 改判仍需保留:
|
||||
// 这种情况 fallback 是 503(HasModelSupport=true),重试是有意义的。
|
||||
func TestClassifySelectionFailureError_StillUpgradesNonModelNotFoundFallback(t *testing.T) {
|
||||
fallback := noAccountErrorClassification{
|
||||
Status: http.StatusServiceUnavailable,
|
||||
ErrType: "api_error",
|
||||
Message: "Service temporarily unavailable",
|
||||
}
|
||||
|
||||
got := classifySelectionFailureError(
|
||||
fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
|
||||
fallback,
|
||||
)
|
||||
|
||||
require.Equal(t, http.StatusTooManyRequests, got.Status)
|
||||
require.Equal(t, "rate_limit_error", got.ErrType)
|
||||
require.False(t, got.ModelNotFound)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const automationBootstrapPrompt = "Review the project and report any important changes."
|
||||
|
||||
func codexAutomationBootstrap(automationID, lastRun, prompt string) string {
|
||||
return "Automation: Scheduled project review\n" +
|
||||
"Automation ID: " + automationID + "\n" +
|
||||
"Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md\n" +
|
||||
"Last run: " + lastRun + "\n\n" + prompt
|
||||
}
|
||||
|
||||
func codexAutomationBootstrapBody(t *testing.T, output, callID string) []byte {
|
||||
t.Helper()
|
||||
return []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` +
|
||||
mustJSON(t, output) + callID + `}]}`)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
lastRun string
|
||||
crlf bool
|
||||
}{
|
||||
{name: "never", lastRun: "never"},
|
||||
{name: "timestamp", lastRun: "2026-09-01T02:06:34.536Z (1788228394536)"},
|
||||
{name: "crlf", lastRun: "never", crlf: true},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := codexAutomationBootstrap("wiki-maintenance", tt.lastRun, automationBootstrapPrompt)
|
||||
if tt.crlf {
|
||||
output = strings.ReplaceAll(output, "\n", "\r\n")
|
||||
}
|
||||
got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) {
|
||||
output := `<heartbeat><automation_id>wiki</automation_id></heartbeat>`
|
||||
got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
|
||||
|
||||
again, changedAgain := normalizeCodexAutomationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
|
||||
tests := []struct {
|
||||
name string
|
||||
body []byte
|
||||
}{
|
||||
{
|
||||
name: "ordinary missing call id",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"other","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "tui namespace",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_tui","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "valid call id",
|
||||
body: codexAutomationBootstrapBody(t, validOutput, `,"call_id":"call-1"`),
|
||||
},
|
||||
{
|
||||
name: "previous response",
|
||||
body: []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
|
||||
},
|
||||
{
|
||||
name: "real call context",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`),
|
||||
},
|
||||
{
|
||||
name: "mismatched memory id",
|
||||
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
|
||||
},
|
||||
{
|
||||
name: "unsafe automation id",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("../wiki", "never", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "invalid timestamp",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "yesterday", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "mismatched timestamp epoch",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "2026-09-01T02:06:34.536Z (1)", automationBootstrapPrompt), ""),
|
||||
},
|
||||
{
|
||||
name: "missing separator",
|
||||
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "\n\n"+automationBootstrapPrompt, "\n"+automationBootstrapPrompt, 1), ""),
|
||||
},
|
||||
{
|
||||
name: "empty prompt",
|
||||
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "never", " \n"), ""),
|
||||
},
|
||||
{
|
||||
name: "mixed missing call id output",
|
||||
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"computer_call_output","output":"done"}]}`),
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, changed := normalizeCodexAutomationBootstrap(tt.body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, tt.body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
}{
|
||||
{name: "arbitrary tool output", output: `<result><automation_id>wiki</automation_id></result>`},
|
||||
{name: "root attribute", output: `<heartbeat status="ok"><automation_id>wiki</automation_id></heartbeat>`},
|
||||
{name: "namespaced root", output: `<heartbeat xmlns="urn:codex"><automation_id>wiki</automation_id></heartbeat>`},
|
||||
{name: "extra child", output: `<heartbeat><automation_id>wiki</automation_id><status>ok</status></heartbeat>`},
|
||||
{name: "nested id content", output: `<heartbeat><automation_id><value>wiki</value></automation_id></heartbeat>`},
|
||||
{name: "padded id", output: `<heartbeat><automation_id> wiki </automation_id></heartbeat>`},
|
||||
{name: "unsafe id", output: `<heartbeat><automation_id>../wiki</automation_id></heartbeat>`},
|
||||
{name: "comment", output: `<heartbeat><!-- ok --><automation_id>wiki</automation_id></heartbeat>`},
|
||||
{name: "trailing content", output: `<heartbeat><automation_id>wiki</automation_id></heartbeat>extra`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := codexAutomationBootstrapBody(t, tt.output, "")
|
||||
got, changed := normalizeCodexAutomationBootstrap(body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
|
||||
output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`)
|
||||
|
||||
got, changed := normalizeCodexAutomationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
|
||||
require.Equal(t, output, gjson.GetBytes(got, "input.1.content.0.text").String())
|
||||
require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
|
||||
|
||||
again, changedAgain := normalizeCodexAutomationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
@@ -81,7 +81,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
body = cappedBody
|
||||
}
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
@@ -113,6 +116,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
|
||||
// 解析渠道级模型映射
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
|
||||
|
||||
if h.errorPassthroughService != nil {
|
||||
service.BindErrorPassthroughService(c, h.errorPassthroughService)
|
||||
@@ -167,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
sessionHash,
|
||||
reqModel,
|
||||
forwardModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
@@ -267,6 +271,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
stampOpenAIRequestedReasoningEffort(res, c)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,9 +16,9 @@ import (
|
||||
// Codex CLI and the Codex desktop app refresh their model picker from
|
||||
// GET {base_url}/models?client_version=... (custom provider mode) or
|
||||
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
|
||||
// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive
|
||||
// provider-compatibility normalization and use a short-lived, asynchronously
|
||||
// revalidated cache to tolerate canceled client requests.
|
||||
// here. Groups with explicit account model mappings are generated locally;
|
||||
// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests
|
||||
// receive provider-compatibility normalization plus short-lived caching.
|
||||
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
@@ -32,6 +33,62 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
ifNoneMatch := c.GetHeader("If-None-Match")
|
||||
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
ifNoneMatch,
|
||||
)
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if configured {
|
||||
writeCodexModelsManifestResponse(c, configuredManifest)
|
||||
return
|
||||
}
|
||||
|
||||
// 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器;
|
||||
// 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。
|
||||
if apiKey.Group.Platform == service.PlatformOpenAI &&
|
||||
apiKey.Group.CodexModelsManifestConfig.Enabled &&
|
||||
len(apiKey.Group.CodexModelsManifestConfig.AccountIDs) > 0 {
|
||||
pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest(
|
||||
c.Request.Context(),
|
||||
apiKey.Group,
|
||||
c.Query("client_version"),
|
||||
)
|
||||
if pinnedErr != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
if !apiKey.Group.CodexModelsManifestConfig.FallbackToScheduler {
|
||||
if errors.Is(pinnedErr, service.ErrNoPinnedCodexModelsAccounts) {
|
||||
h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available pinned OpenAI accounts")
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, infraerrors.Code(pinnedErr), "upstream_error", infraerrors.Message(pinnedErr))
|
||||
return
|
||||
}
|
||||
// 回退开启:跌入下方调度器循环。
|
||||
} else {
|
||||
// 让 ops 错误日志携带实际拉取成功的首个固定账号。
|
||||
setOpsSelectedAccount(c, pinnedAccount.ID, pinnedAccount.Platform)
|
||||
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, pinnedManifest, ifNoneMatch); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
writeCodexModelsManifestResponse(c, pinnedManifest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
if maxAccountSwitches <= 0 {
|
||||
maxAccountSwitches = 3
|
||||
@@ -56,7 +113,9 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
// 让 ops 错误日志携带实际选中的上游账号,便于定位失效账号(#4544)。
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), c.GetHeader("If-None-Match"))
|
||||
// The client ETag represents the final group-specific body, so fetch the
|
||||
// source manifest before applying local filtering and alias metadata.
|
||||
manifest, err := h.gatewayService.FetchCodexModelsManifest(c.Request.Context(), account, c.Query("client_version"), "")
|
||||
if err != nil {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
@@ -70,18 +129,31 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
h.errorResponse(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.CompleteAPIKeyCodexModelsManifestForClient(manifest, account); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
|
||||
return
|
||||
}
|
||||
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
writeCodexModelsManifestResponse(c, manifest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) {
|
||||
if manifest.ETag != "" {
|
||||
c.Header("ETag", manifest.ETag)
|
||||
}
|
||||
if manifest.NotModified {
|
||||
c.Status(http.StatusNotModified)
|
||||
c.Writer.WriteHeaderNow()
|
||||
return
|
||||
}
|
||||
c.Data(http.StatusOK, "application/json", manifest.Body)
|
||||
}
|
||||
|
||||
@@ -2,20 +2,24 @@ package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sort"
|
||||
"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/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type codexModelsFailoverAccountRepo struct {
|
||||
@@ -43,6 +47,18 @@ func (r codexModelsFailoverAccountRepo) ListSchedulableByPlatform(_ context.Cont
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListSchedulableByGroupID(_ context.Context, _ int64) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
func (r codexModelsFailoverAccountRepo) ListModelAvailabilityCandidates(_ context.Context, _ *int64, _ []string, _ bool) ([]service.Account, error) {
|
||||
return append([]service.Account(nil), r.accounts...), nil
|
||||
}
|
||||
|
||||
type codexModelsFailoverHTTPUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
@@ -116,6 +132,236 @@ func TestCodexModelsCanceledRequestDoesNotWriteResponse(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(43)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "custom-openai",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://upstream.example/v1",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{
|
||||
firstBody: `{"object":"list","data":[{"id":"codex-auto-review"},{"id":"gpt-5.6"}]}`,
|
||||
}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"codex-auto-review", "gpt-5.6"},
|
||||
},
|
||||
}
|
||||
|
||||
first := performCodexModelsRequestForGroup(t, handler, group, "")
|
||||
if first.Code != http.StatusOK {
|
||||
t.Fatalf("first status: got %d, want %d; body=%s", first.Code, http.StatusOK, first.Body.String())
|
||||
}
|
||||
if body := first.Body.String(); !strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
|
||||
t.Fatalf("first body did not include the explicitly selected models: %s", body)
|
||||
}
|
||||
oldETag := first.Header().Get("ETag")
|
||||
if oldETag == "" {
|
||||
t.Fatal("first response did not include an ETag")
|
||||
}
|
||||
|
||||
group.ModelsListConfig.Enabled = false
|
||||
second := performCodexModelsRequestForGroup(t, handler, group, oldETag)
|
||||
if second.Code != http.StatusOK {
|
||||
t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String())
|
||||
}
|
||||
if body := second.Body.String(); strings.Contains(body, "codex-auto-review") || !strings.Contains(body, "gpt-5.6") {
|
||||
t.Fatalf("second body was not the filtered manifest: %s", body)
|
||||
}
|
||||
if newETag := second.Header().Get("ETag"); newETag == "" || newETag == oldETag {
|
||||
t.Fatalf("second ETag: got %q, want a new final-body ETag", newETag)
|
||||
}
|
||||
|
||||
third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag"))
|
||||
if third.Code != http.StatusNotModified {
|
||||
t.Fatalf("third status: got %d, want %d; body=%s", third.Code, http.StatusNotModified, third.Body.String())
|
||||
}
|
||||
if third.Body.Len() != 0 {
|
||||
t.Fatalf("third body: got %q, want empty", third.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "shared-api-key",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-shared",
|
||||
"base_url": "https://upstream.example/v1",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{
|
||||
firstBody: `{"object":"list","data":[{"id":"model-a"},{"id":"model-b"}]}`,
|
||||
}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
groupA := &service.Group{
|
||||
ID: 91,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"model-a"},
|
||||
},
|
||||
}
|
||||
groupB := &service.Group{
|
||||
ID: 92,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"model-b"},
|
||||
},
|
||||
}
|
||||
|
||||
firstA := performCodexModelsRequestForGroup(t, handler, groupA, "")
|
||||
require.Equal(t, http.StatusOK, firstA.Code, firstA.Body.String())
|
||||
require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, firstA))
|
||||
|
||||
firstB := performCodexModelsRequestForGroup(t, handler, groupB, "")
|
||||
require.Equal(t, http.StatusOK, firstB.Code, firstB.Body.String())
|
||||
require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, firstB))
|
||||
|
||||
etagA := firstA.Header().Get("ETag")
|
||||
require.NotEmpty(t, etagA)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
results := make([]*httptest.ResponseRecorder, 8)
|
||||
for i := range results {
|
||||
wg.Add(1)
|
||||
go func(index int) {
|
||||
defer wg.Done()
|
||||
if index%2 == 0 {
|
||||
results[index] = performCodexModelsRequestForGroup(t, handler, groupA, etagA)
|
||||
return
|
||||
}
|
||||
results[index] = performCodexModelsRequestForGroup(t, handler, groupB, "")
|
||||
}(i)
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
sawGroupB := false
|
||||
for _, recorder := range results {
|
||||
require.NotNil(t, recorder)
|
||||
switch recorder.Code {
|
||||
case http.StatusNotModified:
|
||||
require.Empty(t, recorder.Body.Bytes())
|
||||
case http.StatusOK:
|
||||
slugs := codexHandlerManifestSlugs(t, recorder)
|
||||
if len(slugs) == 1 && slugs[0] == "model-b" {
|
||||
sawGroupB = true
|
||||
continue
|
||||
}
|
||||
require.Equal(t, []string{"model-a"}, slugs)
|
||||
default:
|
||||
t.Fatalf("unexpected status %d body=%s", recorder.Code, recorder.Body.String())
|
||||
}
|
||||
}
|
||||
require.True(t, sawGroupB)
|
||||
}
|
||||
|
||||
// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。
|
||||
func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(44)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: 1,
|
||||
Name: "ark-compatible",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Priority: 0,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-ark",
|
||||
"base_url": "https://ark.example/v1",
|
||||
"model_mapping": map[string]any{
|
||||
"glm-5.3": "glm-5.3",
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 2,
|
||||
Name: "chatgpt-oauth",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Priority: 1,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-test",
|
||||
},
|
||||
},
|
||||
}}
|
||||
upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
repo,
|
||||
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
|
||||
|
||||
recorder := performCodexModelsRequestForGroup(t, handler, &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
}, "")
|
||||
|
||||
if got := upstream.calls(); len(got) != 0 {
|
||||
t.Fatalf("upstream account calls: got %v, want none", got)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
var envelope struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" {
|
||||
t.Fatalf("models: got %v, want only glm-5.3", envelope.Models)
|
||||
}
|
||||
if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok {
|
||||
t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
|
||||
handler, upstream, groupID := newCodexModelsFailoverTestHandler(http.StatusServiceUnavailable)
|
||||
|
||||
@@ -148,8 +394,23 @@ func TestCodexModelsFailsOverFromRetryableUpstreamStatus(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: an API-key upstream without /models is excluded only for this discovery request.
|
||||
func TestCodexModelsFailsOverWhenAPIKeyModelsEndpointIsUnavailable(t *testing.T) {
|
||||
for _, status := range []int{http.StatusNotFound, http.StatusMethodNotAllowed} {
|
||||
t.Run(http.StatusText(status), func(t *testing.T) {
|
||||
handler, upstream, groupID := newCodexModelsFailoverTestHandler(status)
|
||||
recorder := performCodexModelsRequest(t, handler, groupID)
|
||||
|
||||
if got, want := upstream.calls(), []int64{1, 2}; !equalInt64Slices(got, want) {
|
||||
t.Fatalf("upstream account calls: got %v, want %v", got, want)
|
||||
}
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -183,9 +444,7 @@ func TestCodexModelsFailsOverFromInvalidManifestEnvelope(t *testing.T) {
|
||||
if recorder.Code != http.StatusOK {
|
||||
t.Fatalf("status: got %d, want %d; body=%s", recorder.Code, http.StatusOK, recorder.Body.String())
|
||||
}
|
||||
if got, want := recorder.Body.String(), `{"models":[{"slug":"gpt-5.6-sol"}]}`; got != want {
|
||||
t.Fatalf("body: got %q, want %q", got, want)
|
||||
}
|
||||
requireCompleteCodexModelsHandlerResponse(t, recorder, "gpt-5.6-sol")
|
||||
}
|
||||
|
||||
func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
|
||||
@@ -193,7 +452,6 @@ func TestCodexModelsDoesNotFailOverFromPermanentUpstreamStatus(t *testing.T) {
|
||||
http.StatusBadRequest,
|
||||
http.StatusUnauthorized,
|
||||
http.StatusForbidden,
|
||||
http.StatusNotFound,
|
||||
600,
|
||||
}
|
||||
for _, status := range statuses {
|
||||
@@ -300,23 +558,79 @@ func newCodexModelsFailoverTestHandlerWithAccountCount(firstStatus, accountCount
|
||||
}
|
||||
|
||||
func performCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, groupID int64) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForPlatform(t, handler, groupID, service.PlatformOpenAI)
|
||||
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: service.PlatformOpenAI}, "")
|
||||
}
|
||||
|
||||
func performCodexModelsRequestForPlatform(t *testing.T, handler *OpenAIGatewayHandler, groupID int64, platform string) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForGroup(t, handler, &service.Group{ID: groupID, Platform: platform}, "")
|
||||
}
|
||||
|
||||
func performCodexModelsRequestForGroup(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models?client_version=0.144.0", nil)
|
||||
if etag != "" {
|
||||
c.Request.Header.Set("If-None-Match", etag)
|
||||
}
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
GroupID: &groupID,
|
||||
Group: &service.Group{ID: groupID, Platform: platform},
|
||||
GroupID: &group.ID,
|
||||
Group: group,
|
||||
})
|
||||
|
||||
handler.CodexModels(c)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func codexHandlerManifestSlugs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Models []struct {
|
||||
Slug string `json:"slug"`
|
||||
} `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
slugs := make([]string, 0, len(envelope.Models))
|
||||
for _, model := range envelope.Models {
|
||||
slugs = append(slugs, model.Slug)
|
||||
}
|
||||
return slugs
|
||||
}
|
||||
|
||||
func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.ResponseRecorder, slug string) {
|
||||
t.Helper()
|
||||
|
||||
var envelope struct {
|
||||
Models []map[string]any `json:"models"`
|
||||
}
|
||||
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
|
||||
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
|
||||
}
|
||||
if len(envelope.Models) != 1 {
|
||||
t.Fatalf("models count: got %d, want 1; body=%s", len(envelope.Models), recorder.Body.String())
|
||||
}
|
||||
model := envelope.Models[0]
|
||||
if got := model["slug"]; got != slug {
|
||||
t.Fatalf("slug: got %v, want %q", got, slug)
|
||||
}
|
||||
if levels, ok := model["supported_reasoning_levels"].([]any); !ok || len(levels) == 0 {
|
||||
t.Fatalf("supported_reasoning_levels must be populated: %v", model["supported_reasoning_levels"])
|
||||
}
|
||||
if messages, ok := model["model_messages"].(map[string]any); !ok || messages["instructions_template"] == "" {
|
||||
t.Fatalf("model_messages.instructions_template must be populated: %v", model["model_messages"])
|
||||
}
|
||||
if policy, ok := model["truncation_policy"].(map[string]any); !ok || len(policy) == 0 {
|
||||
t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"])
|
||||
}
|
||||
modalities, ok := model["input_modalities"].([]any)
|
||||
if !ok || len(modalities) != 2 || modalities[0] != "text" || modalities[1] != "image" {
|
||||
t.Fatalf("known GPT model modalities: got %v, want [text image]", model["input_modalities"])
|
||||
}
|
||||
}
|
||||
|
||||
func equalInt64Slices(got, want []int64) bool {
|
||||
if len(got) != len(want) {
|
||||
return false
|
||||
@@ -328,3 +642,316 @@ func equalInt64Slices(got, want []int64) bool {
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- 固定账号 manifest 模式 ---
|
||||
|
||||
// codexModelsPinnedHTTPUpstream 按账号返回不同 manifest/状态的测试上游。
|
||||
type codexModelsPinnedHTTPUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
calls []int64
|
||||
bodies map[int64]string
|
||||
statuses map[int64]int
|
||||
}
|
||||
|
||||
func (u *codexModelsPinnedHTTPUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
|
||||
u.mu.Lock()
|
||||
u.calls = append(u.calls, accountID)
|
||||
u.mu.Unlock()
|
||||
if status, ok := u.statuses[accountID]; ok {
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Status: http.StatusText(status),
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"upstream boom"}}`)),
|
||||
}, nil
|
||||
}
|
||||
body, ok := u.bodies[accountID]
|
||||
if !ok {
|
||||
body = `{"models":[]}`
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Status: "200 OK",
|
||||
Header: make(http.Header),
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *codexModelsPinnedHTTPUpstream) accountIDs() []int64 {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
ids := append([]int64(nil), u.calls...)
|
||||
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
|
||||
return ids
|
||||
}
|
||||
|
||||
func newPinnedCodexAccount(id int64, status string, schedulable bool, rateLimited bool) service.Account {
|
||||
account := service.Account{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("pinned-%d", id),
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: status,
|
||||
Schedulable: schedulable,
|
||||
Priority: int(id),
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": fmt.Sprintf("sk-pinned-%d", id),
|
||||
"base_url": fmt.Sprintf("https://pinned-%d.example/v1", id),
|
||||
},
|
||||
}
|
||||
if rateLimited {
|
||||
reset := time.Now().Add(10 * time.Minute)
|
||||
account.RateLimitResetAt = &reset
|
||||
}
|
||||
return account
|
||||
}
|
||||
|
||||
func newPinnedCodexTestHandler(accounts []service.Account, upstream *codexModelsPinnedHTTPUpstream, maxSwitches int) *OpenAIGatewayHandler {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
gatewayService := service.NewOpenAIGatewayService(
|
||||
codexModelsFailoverAccountRepo{accounts: accounts},
|
||||
nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil,
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
return &OpenAIGatewayHandler{gatewayService: gatewayService, maxAccountSwitches: maxSwitches}
|
||||
}
|
||||
|
||||
func performPinnedCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder {
|
||||
return performCodexModelsRequestForGroup(t, handler, group, etag)
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsMergeUnionWithoutScheduler(t *testing.T) {
|
||||
// 账号 1 优先级最高(调度器会先选它);固定配置只用 2、3,
|
||||
// 返回并集且不打账号 1,即可证明没有经过调度器。
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
1: `{"models":[{"slug":"scheduler-only"}]}`,
|
||||
2: `{"models":[{"slug":"model-a"}]}`,
|
||||
3: `{"models":[{"slug":"model-a"},{"slug":"model-c"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 77,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"model-a", "model-c"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "固定账号模式不得调用调度器或非固定账号")
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsUseRateLimitedAccountAndSkipUnavailable(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, true), // 限流中:仍被使用
|
||||
newPinnedCodexAccount(3, service.StatusActive, false, false), // 调度开关关闭:跳过
|
||||
newPinnedCodexAccount(4, service.StatusDisabled, true, false), // 停用:跳过
|
||||
newPinnedCodexAccount(5, service.StatusActive, true, false), // 正常
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
2: `{"models":[{"slug":"from-rate-limited"}]}`,
|
||||
5: `{"models":[{"slug":"model-five"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 78,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3, 4, 5, 99}, // 99 不在分组:跳过
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"from-rate-limited", "model-five"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Equal(t, []int64{2, 5}, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsPartialFailureStillSucceeds(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{
|
||||
bodies: map[int64]string{2: `{"models":[{"slug":"model-a"}]}`},
|
||||
statuses: map[int64]int{3: http.StatusServiceUnavailable},
|
||||
}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 79,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, recorder))
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsAllUnavailableReturns503ByDefault(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, false, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 80,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
|
||||
require.Empty(t, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsAllFailedReturnsUpstreamError(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{statuses: map[int64]int{
|
||||
2: http.StatusServiceUnavailable,
|
||||
3: http.StatusGatewayTimeout,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 81,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String())
|
||||
require.Contains(t, recorder.Body.String(), "upstream error 504", "全部失败时返回最后一个上游错误")
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsFallbackToScheduler(t *testing.T) {
|
||||
// 固定账号 2 不可用且回退开启:跌入调度器,选中优先级最高的账号 1。
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, false, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
1: `{"models":[{"slug":"from-scheduler"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 82,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2},
|
||||
FallbackToScheduler: true,
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Equal(t, []int64{1}, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsFallbackToSchedulerOnAllFailed(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{
|
||||
bodies: map[int64]string{1: `{"models":[{"slug":"from-scheduler"}]}`},
|
||||
statuses: map[int64]int{2: http.StatusServiceUnavailable},
|
||||
}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 83,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2},
|
||||
FallbackToScheduler: true,
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder))
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsStillApplyCustomModelsListFilter(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
2: `{"models":[{"slug":"model-a"}]}`,
|
||||
3: `{"models":[{"slug":"model-b"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 84,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
Enabled: true,
|
||||
Models: []string{"model-b"},
|
||||
},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, recorder), "分组自定义模型列表过滤仍生效")
|
||||
}
|
||||
|
||||
func TestCodexModelsPinnedAccountsETagMatchReturns304(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
2: `{"models":[{"slug":"model-a"}]}`,
|
||||
3: `{"models":[{"slug":"model-c"}]}`,
|
||||
}}
|
||||
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{
|
||||
ID: 85,
|
||||
Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
|
||||
Enabled: true,
|
||||
AccountIDs: []int64{2, 3},
|
||||
},
|
||||
}
|
||||
|
||||
first := performPinnedCodexModelsRequest(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||
etag := first.Header().Get("ETag")
|
||||
require.NotEmpty(t, etag)
|
||||
|
||||
second := performPinnedCodexModelsRequest(t, handler, group, etag)
|
||||
require.Equal(t, http.StatusNotModified, second.Code, second.Body.String())
|
||||
require.Empty(t, second.Body.Bytes())
|
||||
}
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const delegationEnvelope = `<codex_delegation><source_thread_id>thread-1</source_thread_id><input>do the work</input></codex_delegation>`
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapSupportedTools(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
namespace string
|
||||
tool string
|
||||
callID string
|
||||
}{
|
||||
{name: "app create missing call id", namespace: "codex_app", tool: "create_thread"},
|
||||
{name: "app send empty call id", namespace: "codex_app", tool: "send_message_to_thread", callID: `,"call_id":""`},
|
||||
{name: "tui create whitespace call id", namespace: "codex_tui", tool: "create_thread", callID: `,"call_id":" "`},
|
||||
{name: "tui send missing call id", namespace: "codex_tui", tool: "send_message_to_thread"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"` + tt.namespace + `","name":"` + tt.tool + `","output":"` + delegationEnvelope + `"` + tt.callID + `}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.0.content.0.text").String())
|
||||
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
item string
|
||||
extra string
|
||||
}{
|
||||
{name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`},
|
||||
{name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`},
|
||||
{name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`},
|
||||
{name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`},
|
||||
{name: "mixed invalid delegation output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"not an envelope"}`},
|
||||
{name: "non string call id", item: `{"type":"function_call_output","call_id":42,"name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded type", item: `{"type":" function_call_output ","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded name", item: `{"type":"function_call_output","name":" create_thread ","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
|
||||
{name: "padded namespace", item: `{"type":"function_call_output","name":"create_thread","namespace":" codex_app ","output":"` + delegationEnvelope + `"}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[` + tt.item + `]` + tt.extra + `}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`)
|
||||
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
|
||||
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
|
||||
require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String())
|
||||
require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String())
|
||||
require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String())
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
value string
|
||||
changed bool
|
||||
}{
|
||||
{name: "absent", changed: true},
|
||||
{name: "blank string", value: `,"previous_response_id":" "`, changed: true},
|
||||
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true},
|
||||
{name: "null", value: `,"previous_response_id":null`},
|
||||
{name: "number", value: `,"previous_response_id":42`},
|
||||
{name: "boolean", value: `,"previous_response_id":false`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]` + tt.value + `}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.Equal(t, tt.changed, changed)
|
||||
if !tt.changed {
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRejectsDuplicateMembers(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "top-level previous response id",
|
||||
body: `{"model":"gpt-5","previous_response_id":"","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate discriminator",
|
||||
body: `{"model":"gpt-5","input":[{"type":"message","type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate call id",
|
||||
body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","call_id":"call-1","call_id":"","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
{
|
||||
name: "candidate output",
|
||||
body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"not a delegation","output":"` + delegationEnvelope + `"}]}`,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
body := []byte(tt.body)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed)
|
||||
require.Equal(t, body, got)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreservesExactNumbers(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","metadata":{"integer":9007199254740993},"input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "9007199254740993", gjson.GetBytes(got, "metadata.integer").Raw)
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapRequiresCompleteEnvelope(t *testing.T) {
|
||||
malformed := []string{
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id></source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input> </input></codex_delegation>`,
|
||||
`prefix<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>suffix`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input><extra>x</extra></codex_delegation>`,
|
||||
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input><nested>work</nested></input></codex_delegation>`,
|
||||
`<x:codex_delegation xmlns:x="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
|
||||
`<x:codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
|
||||
`<codex_delegation xmlns="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation xmlns:x="urn:test"><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
|
||||
`<codex_delegation xmlns=""><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
|
||||
}
|
||||
for _, envelope := range malformed {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":` + mustJSON(t, envelope) + `}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.False(t, changed, envelope)
|
||||
require.Equal(t, body, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeCodexDelegationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_tui","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"message","role":"user","content":"after"}]}`)
|
||||
got, changed := normalizeCodexDelegationBootstrap(body)
|
||||
require.True(t, changed)
|
||||
require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
|
||||
require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.1.content.0.text").String())
|
||||
require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
|
||||
|
||||
again, changedAgain := normalizeCodexDelegationBootstrap(got)
|
||||
require.False(t, changedAgain)
|
||||
require.Equal(t, got, again)
|
||||
}
|
||||
|
||||
func mustJSON(t *testing.T, value string) string {
|
||||
t.Helper()
|
||||
encoded, err := json.Marshal(value)
|
||||
require.NoError(t, err)
|
||||
return string(encoded)
|
||||
}
|
||||
@@ -85,6 +85,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
}
|
||||
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
|
||||
|
||||
subscription, _ := middleware2.GetSubscriptionFromContext(c)
|
||||
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
|
||||
@@ -127,7 +128,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
"",
|
||||
reqModel,
|
||||
forwardModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportHTTPSSE,
|
||||
service.OpenAIEndpointCapabilityEmbeddings,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -102,6 +103,44 @@ func TestClearCyberPolicyTurnState(t *testing.T) {
|
||||
require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message)
|
||||
}
|
||||
|
||||
func TestAdvanceOpenAIWSCyberBlockStateDefersBlockAcrossFailover(t *testing.T) {
|
||||
failoverErr := &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests}
|
||||
|
||||
blocked, pending := advanceOpenAIWSCyberBlockState(false, false, true, failoverErr)
|
||||
require.False(t, blocked, "the replacement account must receive the current turn")
|
||||
require.True(t, pending, "the cyber hit must still block later turns")
|
||||
|
||||
blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, failoverErr)
|
||||
require.False(t, blocked, "additional failover attempts must remain eligible")
|
||||
require.True(t, pending)
|
||||
|
||||
blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, nil)
|
||||
require.True(t, blocked, "the next client turn must be blocked after failover finishes")
|
||||
require.False(t, pending)
|
||||
}
|
||||
|
||||
func TestClearCyberPolicyAttemptStatePreservesRecordedGuardDuringFailover(t *testing.T) {
|
||||
c := newTestGinContext()
|
||||
h := &OpenAIGatewayHandler{}
|
||||
|
||||
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-a", UpstreamStatus: http.StatusOK})
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey))
|
||||
|
||||
clearCyberPolicyAttemptState(c, false)
|
||||
require.Nil(t, service.GetOpsCyberPolicy(c))
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey), "the same logical turn must not record again after failover")
|
||||
|
||||
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-b", UpstreamStatus: http.StatusOK})
|
||||
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
|
||||
require.Equal(t, "account-b", service.GetOpsCyberPolicy(c).Message)
|
||||
require.True(t, c.GetBool(cyberPolicyRecordedKey))
|
||||
|
||||
clearCyberPolicyAttemptState(c, true)
|
||||
require.Nil(t, service.GetOpsCyberPolicy(c))
|
||||
require.False(t, c.GetBool(cyberPolicyRecordedKey), "a completed logical turn must reset the guard")
|
||||
}
|
||||
|
||||
// TestBuildCyberSessionBlockedOpsEntry verifies the locally-rejected request is
|
||||
// auditable: 403 / phase=request / type=cyber_policy_session_blocked — distinct
|
||||
// from upstream cyber_policy hits, and it must NOT touch moderation/violation.
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
@@ -50,6 +53,21 @@ type openAIWSTurnChannelMappingSnapshot struct {
|
||||
mapping service.ChannelMappingResult
|
||||
}
|
||||
|
||||
func advanceOpenAIWSCyberBlockState(blocked, pending, marked bool, turnErr error) (bool, bool) {
|
||||
var failoverErr *service.UpstreamFailoverError
|
||||
isFailover := errors.As(turnErr, &failoverErr)
|
||||
if marked {
|
||||
if isFailover {
|
||||
return false, true
|
||||
}
|
||||
return true, false
|
||||
}
|
||||
if pending && !isFailover {
|
||||
return true, false
|
||||
}
|
||||
return blocked, pending
|
||||
}
|
||||
|
||||
var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch")
|
||||
|
||||
func newOpenAIWSUnsupportedModelSwitchError(model string) error {
|
||||
@@ -61,6 +79,55 @@ func shouldReportOpenAIWSProxyAccountFailure(err error) bool {
|
||||
return err != nil && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch) && !service.IsOpenAIWSSessionPreemptedError(err)
|
||||
}
|
||||
|
||||
// openAIWSIngressEndedByClient reports whether a finished ingress WebSocket turn
|
||||
// ended the way a healthy client ends one, rather than through an upstream or
|
||||
// account fault.
|
||||
//
|
||||
// Three error shapes describe that same benign outcome and only the first was
|
||||
// recognised:
|
||||
//
|
||||
// - *service.OpenAIWSClientCloseError carrying 1000 — the gateway closing the
|
||||
// socket on its own terms, e.g. the inter-turn idle timeout.
|
||||
// - a bare coderws.CloseError{Code: 1000} — what coder/websocket returns when
|
||||
// the client closes cleanly. ReadOpenAIWSClientMessage hands conn.Read's
|
||||
// error back verbatim, so nothing ever wraps it into the type above and an
|
||||
// errors.As against that type cannot see it.
|
||||
// - context.Canceled — the client went away mid-turn. That path closes with
|
||||
// StatusGoingAway (1001) and carries the cancellation as its cause, so a
|
||||
// check for 1000 alone never matched it either.
|
||||
//
|
||||
// The last two fell through to shouldReportOpenAIWSProxyAccountFailure, which
|
||||
// filters only model-switch and session-preemption errors. Everything else
|
||||
// reaches ObserveOpenAIAPIKeyHealthFailure and scheduler.ReportResult(false), so
|
||||
// a client that merely disconnected counted against the upstream account's
|
||||
// health and could trip it out of scheduling.
|
||||
//
|
||||
// failoverClientGone already states the rule this restores for the HTTP failover
|
||||
// path — a cancelled client context "被误报成账号耗尽" is a bug, not a signal —
|
||||
// and summarizeWSCloseErrorForLog already reads the close code the correct way,
|
||||
// which is why the resulting WARN printed close_status=1000(StatusNormalClosure)
|
||||
// for an error that was, in the same breath, being charged to the account.
|
||||
//
|
||||
// Deliberately narrow. StatusGoingAway is not matched on its own: the gateway
|
||||
// emits 1001 when it tears a session down for its own reasons too, and the
|
||||
// client-cancellation case is already covered by context.Canceled.
|
||||
// context.DeadlineExceeded is left out as well — the idle-timeout path wraps it
|
||||
// in a 1000 close error and stays benign through the first check, while any
|
||||
// other deadline is a genuine stall worth reporting.
|
||||
func openAIWSIngressEndedByClient(err error) bool {
|
||||
if err == nil {
|
||||
return true
|
||||
}
|
||||
var closeErr *service.OpenAIWSClientCloseError
|
||||
if errors.As(err, &closeErr) && closeErr.StatusCode() == coderws.StatusNormalClosure {
|
||||
return true
|
||||
}
|
||||
if coderws.CloseStatus(err) == coderws.StatusNormalClosure {
|
||||
return true
|
||||
}
|
||||
return errors.Is(err, context.Canceled)
|
||||
}
|
||||
|
||||
func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping service.ChannelMappingResult, requestedModel, upstreamModel string) string {
|
||||
billingModel := ""
|
||||
if result != nil {
|
||||
@@ -88,6 +155,18 @@ func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping servi
|
||||
return billingModel
|
||||
}
|
||||
|
||||
// openAIChannelForwardModel returns the model used for upstream capability
|
||||
// routing. The client-requested model remains separate for logs, billing, and
|
||||
// responses.
|
||||
func openAIChannelForwardModel(mapping service.ChannelMappingResult, requestedModel string) string {
|
||||
if mapping.Mapped {
|
||||
if mappedModel := strings.TrimSpace(mapping.MappedModel); mappedModel != "" {
|
||||
return mappedModel
|
||||
}
|
||||
}
|
||||
return requestedModel
|
||||
}
|
||||
|
||||
type grokMediaEligibilityProber interface {
|
||||
ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error)
|
||||
}
|
||||
@@ -383,9 +462,24 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
|
||||
return
|
||||
}
|
||||
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
|
||||
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
|
||||
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
|
||||
return
|
||||
} else if changed {
|
||||
body = cappedBody
|
||||
}
|
||||
if normalizedBody, changed := normalizeCodexAutomationBootstrap(body); changed {
|
||||
body = normalizedBody
|
||||
reqLog.Info("openai.codex_automation_bootstrap_normalized",
|
||||
zap.String("normalization", "call_output_to_user_message"),
|
||||
)
|
||||
}
|
||||
if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed {
|
||||
body = normalizedBody
|
||||
reqLog.Info("openai.codex_delegation_bootstrap_normalized",
|
||||
zap.String("normalization", "call_output_to_user_message"),
|
||||
)
|
||||
}
|
||||
|
||||
reqStream, ok := parseOpenAICompatibleStream(body)
|
||||
if !ok {
|
||||
@@ -466,10 +560,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody)
|
||||
seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent)
|
||||
forwardModel := reqModel
|
||||
if channelMapping.Mapped {
|
||||
forwardModel = channelMapping.MappedModel
|
||||
}
|
||||
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIForwardModel(
|
||||
c.Request.Context(),
|
||||
forwardModel,
|
||||
@@ -562,7 +653,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
previousResponseID,
|
||||
sessionHash,
|
||||
reqModel,
|
||||
forwardModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
requiredCapability,
|
||||
@@ -702,6 +793,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
stampOpenAIRequestedReasoningEffort(res, c)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
requestPayloadHash := service.HashUsageRequestPayload(body)
|
||||
@@ -728,6 +820,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, res.UpstreamModel),
|
||||
PricingAt: pricingAt,
|
||||
CyberBlocked: cyberBlocked,
|
||||
NativeCompactionV2: nativeV2,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.responses"),
|
||||
@@ -1133,7 +1226,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
|
||||
promptCacheKey := h.gatewayService.ExtractSessionID(c, body)
|
||||
sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel, body)
|
||||
sessionHash, promptCacheKey = resolveOpenAIMessagesMetadataSession(c, sessionHash, promptCacheKey, reqModel, body)
|
||||
if h.rejectIfCyberSessionBlocked(c, apiKey, body, reqModel, cyberBlockFormatAnthropic) {
|
||||
return
|
||||
}
|
||||
@@ -1266,6 +1359,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
if res == nil {
|
||||
return
|
||||
}
|
||||
stampOpenAIRequestedReasoningEffort(res, c)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
requestPayloadHash := service.HashUsageRequestPayload(body)
|
||||
@@ -1410,10 +1504,19 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
func resolveOpenAIMessagesMetadataSession(sessionHash, promptCacheKey, reqModel string, body []byte) (string, string) {
|
||||
func resolveOpenAIMessagesMetadataSession(c *gin.Context, sessionHash, promptCacheKey, reqModel string, body []byte) (string, string) {
|
||||
// Anthropic metadata.user_id 只作为账号粘性信号。上游 GPT/Codex 缓存键
|
||||
// 交给 ForwardAsAnthropic 从 cache_control 或完整消息 digest 派生,避免
|
||||
// 固定 metadata key 压住后续 turn 的缓存滚动。
|
||||
//
|
||||
// Claude Code 的 X-Claude-Code-Session-Id 是比 body content fallback 更稳定的
|
||||
// 会话边界,但它只用于本地账号粘性;不要把它提升为 prompt_cache_key 或上游
|
||||
// session_id,否则会改变现有 Messages→Codex 缓存滚动语义。
|
||||
if promptCacheKey == "" {
|
||||
if claudeSessionID := service.ClaudeCodeSessionIDFromHeader(c); claudeSessionID != "" {
|
||||
return service.DeriveSessionHashFromSeed(claudeSessionID), promptCacheKey
|
||||
}
|
||||
}
|
||||
if sessionHash != "" {
|
||||
return sessionHash, promptCacheKey
|
||||
}
|
||||
@@ -1520,6 +1623,373 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
|
||||
// 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id;
|
||||
// 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入,
|
||||
// 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true)
|
||||
}
|
||||
|
||||
func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false)
|
||||
}
|
||||
|
||||
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) {
|
||||
if !hasUniqueJSONMembers(body) {
|
||||
return body, false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
decoder.UseNumber()
|
||||
var request map[string]any
|
||||
if err := decoder.Decode(&request); err != nil {
|
||||
return body, false
|
||||
}
|
||||
if previousResponseID, exists := request["previous_response_id"]; exists {
|
||||
value, ok := previousResponseID.(string)
|
||||
if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
input, ok := request["input"].([]any)
|
||||
if !ok {
|
||||
return body, false
|
||||
}
|
||||
|
||||
// Responses built-ins follow the *_call / *_call_output naming convention,
|
||||
// so classify by the wire type shape instead of maintaining an incomplete
|
||||
// allowlist. Delegation may coexist with historical anchors only when their
|
||||
// IDs make them unambiguous; automation retains the bootstrap-only boundary.
|
||||
for _, raw := range input {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
typ := stringField(item, "type")
|
||||
if isCandidate(item) {
|
||||
callIDValue, exists := item["call_id"]
|
||||
callID, isString := callIDValue.(string)
|
||||
if exists && (!isString || strings.TrimSpace(callID) != "") {
|
||||
return body, false
|
||||
}
|
||||
continue
|
||||
}
|
||||
if typ == "item_reference" {
|
||||
if allowHistoricalContext && strings.TrimSpace(stringField(item, "id")) != "" {
|
||||
continue
|
||||
}
|
||||
return body, false
|
||||
}
|
||||
if strings.HasSuffix(typ, "_call") || isResponsesCallOutputType(typ) {
|
||||
if allowHistoricalContext && strings.TrimSpace(stringField(item, "call_id")) != "" {
|
||||
continue
|
||||
}
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
|
||||
changed := false
|
||||
for i, raw := range input {
|
||||
item, ok := raw.(map[string]any)
|
||||
if !ok || !isCandidate(item) {
|
||||
continue
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
input[i] = map[string]any{
|
||||
"type": "message",
|
||||
"role": "user",
|
||||
"content": []any{map[string]any{
|
||||
"type": "input_text",
|
||||
"text": output,
|
||||
}},
|
||||
}
|
||||
changed = true
|
||||
}
|
||||
if !changed {
|
||||
return body, false
|
||||
}
|
||||
normalized, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
return body, false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func hasUniqueJSONMembers(body []byte) bool {
|
||||
decoder := json.NewDecoder(bytes.NewReader(body))
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
_, err := decoder.Token()
|
||||
return err == io.EOF
|
||||
}
|
||||
|
||||
func consumeUniqueJSONValue(decoder *json.Decoder) bool {
|
||||
token, err := decoder.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
delim, ok := token.(json.Delim)
|
||||
if !ok {
|
||||
return true
|
||||
}
|
||||
|
||||
switch delim {
|
||||
case '{':
|
||||
members := make(map[string]struct{})
|
||||
for decoder.More() {
|
||||
keyToken, err := decoder.Token()
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
key, ok := keyToken.(string)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
if _, duplicate := members[key]; duplicate {
|
||||
return false
|
||||
}
|
||||
members[key] = struct{}{}
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
return err == nil && end == json.Delim('}')
|
||||
case '[':
|
||||
for decoder.More() {
|
||||
if !consumeUniqueJSONValue(decoder) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
end, err := decoder.Token()
|
||||
return err == nil && end == json.Delim(']')
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func isResponsesCallOutputType(typ string) bool {
|
||||
return strings.HasSuffix(typ, "_call_output") || typ == "tool_search_output"
|
||||
}
|
||||
|
||||
func isCodexDelegationCandidate(item map[string]any) bool {
|
||||
if stringField(item, "type") != "function_call_output" ||
|
||||
!isCodexDelegationTool(stringField(item, "namespace"), stringField(item, "name")) {
|
||||
return false
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
return ok && validCodexDelegationEnvelope(output)
|
||||
}
|
||||
|
||||
func isCodexAutomationCandidate(item map[string]any) bool {
|
||||
if stringField(item, "type") != "function_call_output" ||
|
||||
stringField(item, "namespace") != "codex_app" ||
|
||||
stringField(item, "name") != "automation_update" {
|
||||
return false
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output))
|
||||
}
|
||||
|
||||
func stringField(item map[string]any, key string) string {
|
||||
value, _ := item[key].(string)
|
||||
return value
|
||||
}
|
||||
|
||||
func isCodexDelegationTool(namespace, name string) bool {
|
||||
return (namespace == "codex_app" || namespace == "codex_tui") &&
|
||||
(name == "create_thread" || name == "send_message_to_thread")
|
||||
}
|
||||
|
||||
func validCodexAutomationBootstrap(value string) bool {
|
||||
normalized := strings.ReplaceAll(value, "\r\n", "\n")
|
||||
if strings.ContainsRune(normalized, '\r') {
|
||||
return false
|
||||
}
|
||||
lines := strings.Split(normalized, "\n")
|
||||
if len(lines) < 6 {
|
||||
return false
|
||||
}
|
||||
if _, ok := codexAutomationHeaderValue(lines[0], "Automation: "); !ok {
|
||||
return false
|
||||
}
|
||||
automationID, ok := codexAutomationHeaderValue(lines[1], "Automation ID: ")
|
||||
if !ok || !validCodexAutomationID(automationID) {
|
||||
return false
|
||||
}
|
||||
expectedMemory := "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md"
|
||||
if lines[2] != expectedMemory {
|
||||
return false
|
||||
}
|
||||
lastRun, ok := codexAutomationHeaderValue(lines[3], "Last run: ")
|
||||
if !ok || !validCodexAutomationLastRun(lastRun) || lines[4] != "" {
|
||||
return false
|
||||
}
|
||||
return strings.TrimSpace(strings.Join(lines[5:], "\n")) != ""
|
||||
}
|
||||
|
||||
func codexAutomationHeaderValue(line, prefix string) (string, bool) {
|
||||
if !strings.HasPrefix(line, prefix) {
|
||||
return "", false
|
||||
}
|
||||
value := strings.TrimPrefix(line, prefix)
|
||||
return value, value != "" && strings.TrimSpace(value) == value
|
||||
}
|
||||
|
||||
func validCodexAutomationID(value string) bool {
|
||||
if len(value) == 0 || len(value) > 128 || value == "." || value == ".." {
|
||||
return false
|
||||
}
|
||||
for i := 0; i < len(value); i++ {
|
||||
c := value[i]
|
||||
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
|
||||
continue
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func validCodexAutomationLastRun(value string) bool {
|
||||
if value == "never" {
|
||||
return true
|
||||
}
|
||||
separator := strings.LastIndex(value, " (")
|
||||
if separator <= 0 || !strings.HasSuffix(value, ")") {
|
||||
return false
|
||||
}
|
||||
runAt, err := time.Parse(time.RFC3339Nano, value[:separator])
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
epochMillis, err := strconv.ParseInt(value[separator+2:len(value)-1], 10, 64)
|
||||
return err == nil && runAt.UnixMilli() == epochMillis
|
||||
}
|
||||
|
||||
func validCodexAutomationHeartbeat(value string) bool {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
var rootSeen, automationIDSeen bool
|
||||
var automationID bytes.Buffer
|
||||
depth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
id := automationID.String()
|
||||
return rootSeen && automationIDSeen && depth == 0 &&
|
||||
strings.TrimSpace(id) == id && validCodexAutomationID(id)
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch current := token.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 {
|
||||
return false
|
||||
}
|
||||
if depth == 1 {
|
||||
if rootSeen || current.Name.Local != "heartbeat" {
|
||||
return false
|
||||
}
|
||||
rootSeen = true
|
||||
} else if automationIDSeen || current.Name.Local != "automation_id" {
|
||||
return false
|
||||
}
|
||||
automationIDSeen = depth == 2
|
||||
case xml.EndElement:
|
||||
if current.Name.Space != "" {
|
||||
return false
|
||||
}
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return false
|
||||
}
|
||||
case xml.CharData:
|
||||
if depth == 2 {
|
||||
_, _ = automationID.Write(current)
|
||||
} else if len(bytes.TrimSpace(current)) != 0 {
|
||||
return false
|
||||
}
|
||||
case xml.Comment, xml.ProcInst, xml.Directive:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func validCodexDelegationEnvelope(value string) bool {
|
||||
decoder := xml.NewDecoder(strings.NewReader(value))
|
||||
var rootSeen, sourceSeen, inputSeen bool
|
||||
var childName string
|
||||
var childText bytes.Buffer
|
||||
depth := 0
|
||||
for {
|
||||
token, err := decoder.Token()
|
||||
if err == io.EOF {
|
||||
return rootSeen && depth == 0 && sourceSeen && inputSeen
|
||||
}
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
switch current := token.(type) {
|
||||
case xml.StartElement:
|
||||
depth++
|
||||
if current.Name.Space != "" || len(current.Attr) != 0 || (depth == 1 && current.Name.Local != "codex_delegation") || depth > 2 {
|
||||
return false
|
||||
}
|
||||
if depth == 1 {
|
||||
if rootSeen {
|
||||
return false
|
||||
}
|
||||
rootSeen = true
|
||||
continue
|
||||
}
|
||||
if current.Name.Local != "source_thread_id" && current.Name.Local != "input" {
|
||||
return false
|
||||
}
|
||||
childName = current.Name.Local
|
||||
childText.Reset()
|
||||
case xml.EndElement:
|
||||
if current.Name.Space != "" {
|
||||
return false
|
||||
}
|
||||
if depth == 2 {
|
||||
if current.Name.Local != childName || strings.TrimSpace(childText.String()) == "" {
|
||||
return false
|
||||
}
|
||||
if childName == "source_thread_id" {
|
||||
if sourceSeen {
|
||||
return false
|
||||
}
|
||||
sourceSeen = true
|
||||
} else {
|
||||
if inputSeen {
|
||||
return false
|
||||
}
|
||||
inputSeen = true
|
||||
}
|
||||
childName = ""
|
||||
}
|
||||
depth--
|
||||
if depth < 0 {
|
||||
return false
|
||||
}
|
||||
case xml.CharData:
|
||||
if depth == 2 {
|
||||
_, _ = childText.Write(current)
|
||||
} else if len(bytes.TrimSpace(current)) != 0 {
|
||||
return false
|
||||
}
|
||||
case xml.Comment:
|
||||
return false
|
||||
case xml.ProcInst, xml.Directive:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) acquireResponsesUserSlot(
|
||||
c *gin.Context,
|
||||
userID int64,
|
||||
@@ -1619,7 +2089,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
|
||||
return h.acquireOpenAIAccountSlot(c, groupID, sessionHash, selection, reqStream, streamStarted, reqLog, nil)
|
||||
}
|
||||
|
||||
type openAISlotErrorWriter func(status int, errType, message string)
|
||||
type openAISlotErrorWriter func(status int, errType, code, message string)
|
||||
|
||||
// acquireOpenAIAccountSlot centralizes scheduler selection admission. The
|
||||
// optional error writer lets non-Responses endpoints retain their wire format
|
||||
@@ -1635,13 +2105,13 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
writeError openAISlotErrorWriter,
|
||||
) (func(), openAISlotAcquireResult) {
|
||||
if writeError == nil {
|
||||
writeError = func(status int, errType, message string) {
|
||||
h.handleStreamingAwareError(c, status, errType, message, *streamStarted)
|
||||
writeError = func(status int, errType, code, message string) {
|
||||
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, *streamStarted, false)
|
||||
}
|
||||
}
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
writeError(http.StatusServiceUnavailable, "api_error", "No available accounts")
|
||||
writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts")
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
@@ -1671,7 +2141,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
}
|
||||
if selection.WaitPlan == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
writeError(http.StatusServiceUnavailable, "api_error", "No available accounts")
|
||||
writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts")
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
@@ -1682,8 +2152,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
status, errType, message := concurrencyErrorResponse(err, "account")
|
||||
writeError(status, errType, message)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, "account")
|
||||
writeError(status, errType, code, message)
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
if fastAcquired {
|
||||
@@ -1713,7 +2183,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
|
||||
)
|
||||
writeError(http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
|
||||
writeError(http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later")
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
@@ -1736,8 +2206,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
|
||||
)
|
||||
if err != nil {
|
||||
reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
status, errType, message := concurrencyErrorResponse(err, "account")
|
||||
writeError(status, errType, message)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, "account")
|
||||
writeError(status, errType, code, message)
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
@@ -1925,6 +2395,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
cyberBlockedThisConn := false
|
||||
cyberBlockPendingAfterFailover := false
|
||||
var cyberTurnBodiesMu sync.Mutex
|
||||
cyberTurnBodies := map[int][]byte{1: append([]byte(nil), firstMessage...)}
|
||||
setCyberTurnBody := func(turn int, payload []byte) {
|
||||
@@ -1942,10 +2413,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
|
||||
// 解析渠道级模型映射
|
||||
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
|
||||
wsForwardModel := reqModel
|
||||
if channelMappingWS.Mapped && strings.TrimSpace(channelMappingWS.MappedModel) != "" {
|
||||
wsForwardModel = strings.TrimSpace(channelMappingWS.MappedModel)
|
||||
}
|
||||
wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel)
|
||||
|
||||
var currentUserRelease func()
|
||||
var currentAccountRelease func()
|
||||
@@ -2108,7 +2576,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
previousResponseID,
|
||||
sessionHash,
|
||||
reqModel,
|
||||
wsForwardModel,
|
||||
failedAccountIDs,
|
||||
requiredTransport,
|
||||
requiredCapability,
|
||||
@@ -2237,7 +2705,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
zap.Int("candidate_count", scheduleDecision.CandidateCount),
|
||||
)
|
||||
|
||||
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
maxReasoningEffort, reasoningEffortMappings, maxReasoningEffortOverLimit, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
|
||||
var requestPayloadHash string
|
||||
var turnStartsMu sync.Mutex
|
||||
turnStarts := make(map[int]time.Time, 4)
|
||||
@@ -2264,16 +2732,24 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
InitialTurnStartedAt: firstTurnStartedAt,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
TurnStarted: recordTurnStart,
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
InitialRequestModel: reqModel,
|
||||
InitialTurnStartedAt: firstTurnStartedAt,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
|
||||
ReasoningEffortMappings: reasoningEffortMappings,
|
||||
TurnStarted: recordTurnStart,
|
||||
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
|
||||
c.Set(securityAuditWSTurnContextKey, turn)
|
||||
service.BeginOpsStreamTurn(c, turn)
|
||||
setCyberTurnBody(turn, payload)
|
||||
// Passthrough ingress intentionally skips BeforeTurn, so enforce only
|
||||
// the connection-level cyber session gate here as well. Native ingress
|
||||
// visits this hook first and gets the same side-effect-free close error;
|
||||
// its original BeforeTurn guard remains as defense in depth.
|
||||
if cyberBlockedThisConn {
|
||||
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
|
||||
}
|
||||
if turn == 1 {
|
||||
return nil
|
||||
}
|
||||
@@ -2360,10 +2836,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) {
|
||||
turnStart := getTurnStart(turn)
|
||||
cyberBlockBody := takeCyberTurnBody(turn)
|
||||
// F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行;
|
||||
// CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行,
|
||||
// 届时 defer 已清除标记)。
|
||||
defer clearCyberPolicyTurnState(c)
|
||||
// 每次 attempt 都清 cyber mark;failover 链结束前保留 recorded guard,
|
||||
// 避免同一逻辑 turn 换号后重复落风控。CyberBlocked 必须在 submit 前
|
||||
// 同步预捕获(task 闭包由 worker 池异步执行,届时 mark 已清除)。
|
||||
defer func() {
|
||||
clearCyberPolicyAttemptState(c, !cyberBlockPendingAfterFailover)
|
||||
}()
|
||||
releaseTurnSlots()
|
||||
turnRequestedModel := reqModel
|
||||
turnUpstreamModel := ""
|
||||
@@ -2385,10 +2863,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
turnUpstreamModel = turnRequestedModel
|
||||
}
|
||||
turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel)
|
||||
cyberMarked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockBody, turnUsageFields, requestPayloadHash)
|
||||
if service.GetOpsCyberPolicy(c) != nil {
|
||||
cyberBlockedThisConn = true
|
||||
}
|
||||
cyberBlockedThisConn, cyberBlockPendingAfterFailover = advanceOpenAIWSCyberBlockState(
|
||||
cyberBlockedThisConn,
|
||||
cyberBlockPendingAfterFailover,
|
||||
cyberMarked,
|
||||
turnErr,
|
||||
)
|
||||
if turnErr != nil {
|
||||
if result == nil || result.ImageCount <= 0 {
|
||||
return
|
||||
@@ -2542,12 +3024,23 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
}
|
||||
|
||||
var closeErr *service.OpenAIWSClientCloseError
|
||||
if errors.As(err, &closeErr) && closeErr.StatusCode() == coderws.StatusNormalClosure {
|
||||
reqLog.Info("openai.websocket_ingress_closed_normally",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("reason", closeErr.Reason()),
|
||||
)
|
||||
closeOpenAIClientWS(wsConn, closeErr.StatusCode(), closeErr.Reason())
|
||||
hasClientCloseErr := errors.As(err, &closeErr)
|
||||
if openAIWSIngressEndedByClient(err) {
|
||||
closedFields := []zap.Field{zap.Int64("account_id", account.ID)}
|
||||
if hasClientCloseErr {
|
||||
closedFields = append(closedFields, zap.String("reason", closeErr.Reason()))
|
||||
} else {
|
||||
closedFields = append(closedFields, zap.Error(err))
|
||||
}
|
||||
reqLog.Info("openai.websocket_ingress_closed_normally", closedFields...)
|
||||
// A bare coderws.CloseError or a plain cancellation carries no
|
||||
// gateway-chosen close frame; mirror the client's clean 1000
|
||||
// rather than the 1011 the proxy-failure tail would have sent.
|
||||
if hasClientCloseErr {
|
||||
closeOpenAIClientWS(wsConn, closeErr.StatusCode(), closeErr.Reason())
|
||||
} else {
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusNormalClosure, "")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -2572,7 +3065,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
proxyFailedFields = append(proxyFailedFields, zap.Int64p("proxy_id", account.ProxyID))
|
||||
}
|
||||
reqLog.Warn("openai.websocket_proxy_failed", proxyFailedFields...)
|
||||
if errors.As(err, &closeErr) {
|
||||
if hasClientCloseErr {
|
||||
closeOpenAIClientWS(wsConn, closeErr.StatusCode(), closeErr.Reason())
|
||||
return
|
||||
}
|
||||
@@ -2768,14 +3261,14 @@ func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, stream
|
||||
if acquired {
|
||||
return release, true
|
||||
}
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted)
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, "Image generation concurrency limit exceeded, please retry later", streamStarted, false)
|
||||
return nil, false
|
||||
}
|
||||
|
||||
// handleConcurrencyError handles concurrency-related acquire errors.
|
||||
func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
|
||||
status, errType, message := concurrencyErrorResponse(err, slotType)
|
||||
h.handleStreamingAwareError(c, status, errType, message, streamStarted)
|
||||
status, errType, code, message := concurrencyErrorResponse(err, slotType)
|
||||
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted, false)
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) {
|
||||
@@ -2955,7 +3448,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareErrorWithCode(
|
||||
// 通用 `event: error` 帧不被识别为终止事件,会导致
|
||||
// "stream closed before response.completed"。
|
||||
if inboundIsResponses(c) {
|
||||
if writeResponsesFailedSSE(c, errType, message) {
|
||||
if writeResponsesFailedSSE(c, errType, code, message) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -3122,7 +3615,7 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType
|
||||
// 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。
|
||||
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
|
||||
service.MarkOpsStreamError(c, errType, message, status)
|
||||
if writeResponsesFailedSSE(c, errType, message) {
|
||||
if writeResponsesFailedSSE(c, errType, "", message) {
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -3440,7 +3933,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe
|
||||
// 写 JSON(#3887)。
|
||||
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
|
||||
service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden)
|
||||
if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) {
|
||||
if writeResponsesFailedSSE(c, "permission_error", "", cyberSessionBlockedClientMsg) {
|
||||
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key)
|
||||
return true
|
||||
}
|
||||
@@ -3606,6 +4099,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
|
||||
}
|
||||
// 提前拍成标量,避免在下方 goroutine 内访问 gin.Context。
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
nativeCompactionV2 := service.IsOpenAINativeCompactionV2(c)
|
||||
apiKeyPrefix := ""
|
||||
if apiKey != nil {
|
||||
apiKeyPrefix = keyPrefix(apiKey.Key, 8)
|
||||
@@ -3673,6 +4167,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
|
||||
SessionID: sessionID,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
APIKeyService: apiKeySvc,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
ChannelUsageFields: channelFields,
|
||||
})
|
||||
}
|
||||
@@ -3682,15 +4177,20 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
|
||||
}()
|
||||
}
|
||||
|
||||
// clearCyberPolicyTurnState resets the cyber mark and the per-request recorded
|
||||
// guard. WS-only: called at the END of AfterTurn, after recordCyberPolicyIfMarked
|
||||
// and RecordUsage (which reads CyberBlocked) have both consumed the mark.
|
||||
// clearCyberPolicyTurnState resets the cyber mark and recorded guard after a
|
||||
// logical WS turn has finished.
|
||||
func clearCyberPolicyTurnState(c *gin.Context) {
|
||||
clearCyberPolicyAttemptState(c, true)
|
||||
}
|
||||
|
||||
func clearCyberPolicyAttemptState(c *gin.Context, resetRecorded bool) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
service.ClearOpsCyberPolicy(c)
|
||||
c.Set(cyberPolicyRecordedKey, false)
|
||||
if resetRecorded {
|
||||
c.Set(cyberPolicyRecordedKey, false)
|
||||
}
|
||||
}
|
||||
|
||||
func summarizeWSCloseErrorForLog(err error) (string, string) {
|
||||
|
||||
@@ -176,7 +176,7 @@ func TestOpenAIResponsesRequiredCapability(t *testing.T) {
|
||||
func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *testing.T) {
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","metadata":{"user_id":"claude-code-session"},"messages":[{"role":"user","content":"hello"}]}`)
|
||||
|
||||
sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession("", "", "claude-sonnet-4-5", body)
|
||||
sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession(nil, "", "", "claude-sonnet-4-5", body)
|
||||
|
||||
require.NotEmpty(t, sessionHash)
|
||||
require.Empty(t, promptCacheKey)
|
||||
@@ -185,12 +185,58 @@ func TestResolveOpenAIMessagesMetadataSession_DoesNotDerivePromptCacheKey(t *tes
|
||||
func TestResolveOpenAIMessagesMetadataSession_PreservesExplicitPromptCacheKey(t *testing.T) {
|
||||
body := []byte(`{"metadata":{"user_id":"claude-code-session"}}`)
|
||||
|
||||
sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession("", "explicit-cache", "claude-sonnet-4-5", body)
|
||||
sessionHash, promptCacheKey := resolveOpenAIMessagesMetadataSession(nil, "", "explicit-cache", "claude-sonnet-4-5", body)
|
||||
|
||||
require.NotEmpty(t, sessionHash)
|
||||
require.Equal(t, "explicit-cache", promptCacheKey)
|
||||
}
|
||||
|
||||
func TestResolveOpenAIMessagesMetadataSession_ClaudeCodeHeaderOverridesContentFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("X-Claude-Code-Session-Id", "claude-session-001")
|
||||
|
||||
body1 := []byte(`{"model":"gpt-5.6-sol","system":"parent","messages":[{"role":"user","content":"parent task"}]}`)
|
||||
body2 := []byte(`{"model":"gpt-5.6-sol","system":"subagent","messages":[{"role":"user","content":"child task"}]}`)
|
||||
|
||||
contentHash1 := (&service.OpenAIGatewayService{}).GenerateSessionHash(c, body1)
|
||||
contentHash2 := (&service.OpenAIGatewayService{}).GenerateSessionHash(c, body2)
|
||||
require.NotEqual(t, contentHash1, contentHash2, "different bodies should prove the content fallback differs")
|
||||
|
||||
hash1, cacheKey1 := resolveOpenAIMessagesMetadataSession(c, contentHash1, "", "gpt-5.6-sol", body1)
|
||||
hash2, cacheKey2 := resolveOpenAIMessagesMetadataSession(c, contentHash2, "", "gpt-5.6-sol", body2)
|
||||
require.Equal(t, service.DeriveSessionHashFromSeed("claude-session-001"), hash1)
|
||||
require.Equal(t, hash1, hash2, "the same Claude Code session must keep one sticky account across changed turn bodies")
|
||||
require.Empty(t, cacheKey1, "routing-only fix must not create an upstream prompt cache key")
|
||||
require.Empty(t, cacheKey2, "routing-only fix must not create an upstream prompt cache key")
|
||||
}
|
||||
|
||||
func TestResolveOpenAIMessagesMetadataSession_OpenAISignalWinsOverClaudeHeader(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("X-Claude-Code-Session-Id", "claude-session-001")
|
||||
|
||||
hash, cacheKey := resolveOpenAIMessagesMetadataSession(c, "content-hash", "explicit-openai-session", "gpt-5.6-sol", []byte(`{"metadata":{"user_id":"opaque"}}`))
|
||||
require.Equal(t, "content-hash", hash, "existing OpenAI session resolution must remain authoritative")
|
||||
require.Equal(t, "explicit-openai-session", cacheKey)
|
||||
}
|
||||
|
||||
func TestResolveOpenAIMessagesMetadataSession_BlankClaudeHeaderKeepsContentFallback(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
c.Request.Header.Set("X-Claude-Code-Session-Id", " ")
|
||||
|
||||
hash, cacheKey := resolveOpenAIMessagesMetadataSession(c, "content-hash", "", "gpt-5.6-sol", []byte(`{"metadata":{"user_id":"opaque"}}`))
|
||||
require.Equal(t, "content-hash", hash)
|
||||
require.Empty(t, cacheKey)
|
||||
}
|
||||
|
||||
func TestOpenAIHandleStreamingAwareError_NonStreaming(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
w := httptest.NewRecorder()
|
||||
@@ -1484,7 +1530,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
|
||||
"each turn must be billed with its own channel-mapped model")
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) {
|
||||
func TestOpenAIResponsesWebSocket_ChannelMappedTargetSelectsAccountWithoutRequestedAlias(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","stream":false}`,
|
||||
@@ -1492,7 +1538,7 @@ func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKey
|
||||
"public-alias": "gpt-5.6-sol",
|
||||
},
|
||||
accountModelMapping: map[string]any{
|
||||
"public-alias": "gpt-5.6-terra",
|
||||
"gpt-5.6-sol": "gpt-5.6-sol",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1664,6 +1710,40 @@ func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) {
|
||||
require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil))
|
||||
}
|
||||
|
||||
func TestOpenAIChannelForwardModelForScheduler(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mapping service.ChannelMappingResult
|
||||
requested string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "mapped model is used for capability filtering",
|
||||
mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " gpt-forward "},
|
||||
requested: "client-alias",
|
||||
want: "gpt-forward",
|
||||
},
|
||||
{
|
||||
name: "unmapped request preserves client model",
|
||||
mapping: service.ChannelMappingResult{MappedModel: "client-model"},
|
||||
requested: "client-model",
|
||||
want: "client-model",
|
||||
},
|
||||
{
|
||||
name: "empty mapped model safely preserves client model",
|
||||
mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " "},
|
||||
requested: "client-model",
|
||||
want: "client-model",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, openAIChannelForwardModel(tt.mapping, tt.requested))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) {
|
||||
t.Run("unsupported client model switch does not penalize account", func(t *testing.T) {
|
||||
err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported"))
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
// 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier
|
||||
// 校验处短路,不会进入账号选择/重试。
|
||||
//
|
||||
// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由
|
||||
// 合法值(fast/priority/flex/auto/default/scale/ultrafast)与省略/null 的接受语义由
|
||||
// service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler
|
||||
// 测试走入真实账号选择/重试路径。
|
||||
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// issue #6105:入站 Responses WebSocket 的正常结束会被记成账号故障。
|
||||
//
|
||||
// 归因发生在 openai_gateway_handler.go 的 ingress 收尾处:只有
|
||||
// *service.OpenAIWSClientCloseError 且状态码为 1000 被认作正常关闭,其余一律落到
|
||||
// shouldReportOpenAIWSProxyAccountFailure —— 而它只排除 model-switch 与
|
||||
// session-preempted 两种。于是客户端干净关闭(底层直接回裸 coderws.CloseError{1000})
|
||||
// 与客户端中途断开(context.Canceled,收尾用 1001 关闭)都会喂给
|
||||
// ObserveOpenAIAPIKeyHealthFailure 与 scheduler.ReportResult(success=false),
|
||||
// 累积到阈值即把上游账号熔断出调度池。
|
||||
//
|
||||
// 这些用例钉住判定本身,与既有的 TestShouldReportOpenAIWSProxyAccountFailure 同一层级:
|
||||
// 调用点位于一个需要真实上游 WS 才能进入的巨型 handler 循环内,仓库既有约定就是直接测判定函数。
|
||||
|
||||
// 缺陷主复现之一:客户端干净关闭。底层 conn.Read 的错误被 ReadOpenAIWSClientMessage
|
||||
// 原样返回,没有任何地方把它包成 *OpenAIWSClientCloseError,所以旧断言看不见它。
|
||||
func TestOpenAIWSIngressEndedByClient_BareNormalClosureIsNotAccountFailure(t *testing.T) {
|
||||
err := coderws.CloseError{Code: coderws.StatusNormalClosure, Reason: "client done"}
|
||||
|
||||
// 前提:这正是旧判据漏掉它的原因——类型不匹配,不是状态码不匹配。
|
||||
var closeErr *service.OpenAIWSClientCloseError
|
||||
require.False(t, errors.As(err, &closeErr),
|
||||
"裸 coderws.CloseError 不是 *OpenAIWSClientCloseError,旧的 errors.As 必然为假")
|
||||
// 而按关闭码读,它确实是 1000。
|
||||
require.Equal(t, coderws.StatusNormalClosure, coderws.CloseStatus(err))
|
||||
|
||||
require.True(t, openAIWSIngressEndedByClient(err))
|
||||
}
|
||||
|
||||
// 同一形状被包一层(例如 ingress 把 read 错误裹进上下文)时也必须认得。
|
||||
func TestOpenAIWSIngressEndedByClient_WrappedBareNormalClosureIsNotAccountFailure(t *testing.T) {
|
||||
err := fmt.Errorf("ingress turn 3: %w",
|
||||
coderws.CloseError{Code: coderws.StatusNormalClosure, Reason: "client done"})
|
||||
|
||||
require.True(t, openAIWSIngressEndedByClient(err))
|
||||
}
|
||||
|
||||
// 缺陷主复现之二:客户端中途断开。ReadOpenAIWSClientMessage 在 controlCtx.Done()
|
||||
// 分支用 StatusGoingAway 收尾并把 context.Canceled 作为 cause,所以「只认 1000」
|
||||
// 这一条判据根本匹配不到它。
|
||||
func TestOpenAIWSIngressEndedByClient_ClientCancelDuringTurnIsNotAccountFailure(t *testing.T) {
|
||||
err := service.NewOpenAIWSClientCloseError(
|
||||
coderws.StatusGoingAway, "websocket request canceled", context.Canceled)
|
||||
|
||||
// 前提:状态码是 1001 不是 1000,旧判据必然放行到账号归因。
|
||||
var closeErr *service.OpenAIWSClientCloseError
|
||||
require.ErrorAs(t, err, &closeErr)
|
||||
require.Equal(t, coderws.StatusGoingAway, closeErr.StatusCode())
|
||||
require.NotEqual(t, coderws.StatusNormalClosure, closeErr.StatusCode())
|
||||
|
||||
require.True(t, openAIWSIngressEndedByClient(err))
|
||||
}
|
||||
|
||||
// 既有行为不得回退:网关自己用 1000 收尾(inter-turn idle timeout 就是这条)
|
||||
// 原本就被认作正常关闭。
|
||||
func TestOpenAIWSIngressEndedByClient_GatewayNormalClosureStillRecognised(t *testing.T) {
|
||||
err := service.NewOpenAIWSClientCloseError(
|
||||
coderws.StatusNormalClosure, "websocket idle timeout", context.DeadlineExceeded)
|
||||
|
||||
require.True(t, openAIWSIngressEndedByClient(err))
|
||||
}
|
||||
|
||||
// 收窄证明:1001 本身不足以豁免。网关也会因自身原因用 GoingAway 收场,
|
||||
// 客户端取消那一支已由 context.Canceled 覆盖,无需整类放行。
|
||||
func TestOpenAIWSIngressEndedByClient_GoingAwayWithoutCancellationStillReported(t *testing.T) {
|
||||
err := service.NewOpenAIWSClientCloseError(
|
||||
coderws.StatusGoingAway, "upstream going away", errors.New("upstream closed session"))
|
||||
|
||||
require.False(t, openAIWSIngressEndedByClient(err))
|
||||
require.True(t, shouldReportOpenAIWSProxyAccountFailure(err), "真实上游故障仍须归因账号")
|
||||
}
|
||||
|
||||
// 契约没有丢:真正的故障仍然惩罚账号。判定组合与调用点一致——
|
||||
// openAIWSIngressEndedByClient 为假才会走到 shouldReportOpenAIWSProxyAccountFailure。
|
||||
func TestOpenAIWSIngressEndedByClient_AbnormalClosuresStillReportAccountFailure(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
err error
|
||||
}{
|
||||
{
|
||||
name: "upstream_policy_violation",
|
||||
err: service.NewOpenAIWSClientCloseError(
|
||||
coderws.StatusPolicyViolation, "upstream websocket authentication failed",
|
||||
errors.New("upstream rejected credentials")),
|
||||
},
|
||||
{
|
||||
name: "upstream_internal_error",
|
||||
err: service.NewOpenAIWSClientCloseError(
|
||||
coderws.StatusInternalError, "upstream websocket proxy failed", nil),
|
||||
},
|
||||
{
|
||||
name: "bare_abnormal_closure",
|
||||
err: coderws.CloseError{Code: coderws.StatusAbnormalClosure, Reason: "connection reset"},
|
||||
},
|
||||
{
|
||||
name: "generic_read_failure",
|
||||
err: errors.New("upstream websocket read failed"),
|
||||
},
|
||||
{
|
||||
// 空闲超时之外的 deadline 是真实停滞:不豁免。
|
||||
name: "deadline_without_normal_close",
|
||||
err: fmt.Errorf("upstream stalled: %w", context.DeadlineExceeded),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require.False(t, openAIWSIngressEndedByClient(tc.err))
|
||||
require.True(t, shouldReportOpenAIWSProxyAccountFailure(tc.err))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 不变式:同一条错误,日志侧与归因侧必须给出一致的结论。
|
||||
// summarizeWSCloseErrorForLog 一直用 coderws.CloseStatus 读关闭码,这正是缺陷时期
|
||||
// WARN 打印 close_status=1000(StatusNormalClosure) 却同时把账号记为故障的原因。
|
||||
// 以后任何一侧改了读法,这条会红。
|
||||
func TestOpenAIWSIngressEndedByClient_MatchesCloseCodeReportedInLog(t *testing.T) {
|
||||
errs := []error{
|
||||
coderws.CloseError{Code: coderws.StatusNormalClosure, Reason: "client done"},
|
||||
fmt.Errorf("ingress turn 3: %w", coderws.CloseError{Code: coderws.StatusNormalClosure}),
|
||||
service.NewOpenAIWSClientCloseError(coderws.StatusNormalClosure, "websocket idle timeout", context.DeadlineExceeded),
|
||||
service.NewOpenAIWSClientCloseError(coderws.StatusGoingAway, "websocket request canceled", context.Canceled),
|
||||
coderws.CloseError{Code: coderws.StatusAbnormalClosure, Reason: "connection reset"},
|
||||
errors.New("upstream websocket read failed"),
|
||||
}
|
||||
|
||||
for _, err := range errs {
|
||||
t.Run(err.Error(), func(t *testing.T) {
|
||||
closeStatus, _ := summarizeWSCloseErrorForLog(err)
|
||||
if closeStatus == "1000(StatusNormalClosure)" {
|
||||
require.True(t, openAIWSIngressEndedByClient(err),
|
||||
"日志按 1000 归类为正常关闭,归因侧不得同时判为账号故障")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/Wei-Shaw/sub2api/internal/testutil"
|
||||
coderws "github.com/coder/websocket"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
type openAIWSPassthroughHandlerHarness struct {
|
||||
clientConn *coderws.Conn
|
||||
handlerDone <-chan struct{}
|
||||
moderationRepo *contentModerationHandlerTestRepo
|
||||
gatewayCache service.GatewayCache
|
||||
apiKey *service.APIKey
|
||||
}
|
||||
|
||||
func newOpenAIWSPassthroughHandlerHarness(t *testing.T, upstreamURL string) *openAIWSPassthroughHandlerHarness {
|
||||
t.Helper()
|
||||
gatewayCache := testutil.NewRedisGatewayCache(t)
|
||||
|
||||
settingRepo := &contentModerationHandlerSettingRepo{values: map[string]string{
|
||||
service.SettingKeyRiskControlEnabled: "true",
|
||||
service.SettingKeyCyberSessionBlockEnabled: "true",
|
||||
service.SettingKeyCyberSessionBlockTTLSeconds: "60",
|
||||
}}
|
||||
moderationRepo := &contentModerationHandlerTestRepo{}
|
||||
moderationSvc := service.NewContentModerationService(settingRepo, moderationRepo, nil, nil, nil, nil, nil, nil)
|
||||
settingSvc := service.NewSettingService(settingRepo, nil)
|
||||
|
||||
groupID := int64(4301)
|
||||
account := service.Account{
|
||||
ID: 9951,
|
||||
Name: "openai-ws-passthrough-cyber",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"api_key": "sk-test", "base_url": upstreamURL},
|
||||
Extra: map[string]any{
|
||||
"openai_apikey_responses_websockets_v2_enabled": true,
|
||||
"openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.RunMode = config.RunModeSimple
|
||||
cfg.Default.RateMultiplier = 1
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Security.URLAllowlist.AllowInsecureHTTP = true
|
||||
cfg.Gateway.OpenAIWS.Enabled = true
|
||||
cfg.Gateway.OpenAIWS.APIKeyEnabled = true
|
||||
cfg.Gateway.OpenAIWS.ResponsesWebsocketsV2 = true
|
||||
cfg.Gateway.OpenAIWS.ModeRouterV2Enabled = true
|
||||
cfg.Gateway.OpenAIWS.DialTimeoutSeconds = 3
|
||||
cfg.Gateway.OpenAIWS.ReadTimeoutSeconds = 3
|
||||
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
|
||||
cfg.Gateway.OpenAIWS.IngressInterTurnIdleTimeoutSeconds = 3
|
||||
|
||||
accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account}
|
||||
usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 2)}
|
||||
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
gatewaySvc := service.NewOpenAIGatewayService(
|
||||
accountRepo, usageRepo, nil, nil, nil, nil, gatewayCache, cfg, nil, nil,
|
||||
service.NewBillingService(cfg, nil), nil, billingCacheSvc, nil, &service.DeferredService{},
|
||||
nil, nil, nil, nil, nil, settingSvc, nil,
|
||||
)
|
||||
concurrencyCache := &concurrencyCacheMock{
|
||||
acquireUserSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
|
||||
acquireAccountSlotFn: func(context.Context, int64, int, string) (bool, error) { return true, nil },
|
||||
}
|
||||
h := &OpenAIGatewayHandler{
|
||||
gatewayService: gatewaySvc,
|
||||
billingCacheService: billingCacheSvc,
|
||||
apiKeyService: &service.APIKeyService{},
|
||||
contentModerationService: moderationSvc,
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(concurrencyCache), SSEPingFormatNone, time.Second),
|
||||
}
|
||||
|
||||
apiKey := &service.APIKey{
|
||||
ID: 1851,
|
||||
Name: "ws-cyber-key",
|
||||
Key: "sk-handler-cyber-test",
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 1751, Status: service.StatusActive},
|
||||
}
|
||||
handlerDone := make(chan struct{})
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.User.ID, Concurrency: 1})
|
||||
c.Next()
|
||||
})
|
||||
router.GET("/openai/v1/responses", func(c *gin.Context) {
|
||||
h.ResponsesWebSocket(c)
|
||||
close(handlerDone)
|
||||
})
|
||||
handlerServer := httptest.NewServer(router)
|
||||
t.Cleanup(handlerServer.Close)
|
||||
|
||||
dialCtx, cancelDial := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
clientConn, _, err := coderws.Dial(dialCtx, "ws"+strings.TrimPrefix(handlerServer.URL, "http")+"/openai/v1/responses", nil)
|
||||
cancelDial()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = clientConn.CloseNow() })
|
||||
|
||||
return &openAIWSPassthroughHandlerHarness{
|
||||
clientConn: clientConn,
|
||||
handlerDone: handlerDone,
|
||||
moderationRepo: moderationRepo,
|
||||
gatewayCache: gatewayCache,
|
||||
apiKey: apiKey,
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocketV2PassthroughCyberMarkIsConsumedAfterTurn(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstreamDone := make(chan struct{})
|
||||
secondUpstreamFrame := make(chan []byte, 1)
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(upstreamDone)
|
||||
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
|
||||
readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
_, _, err = conn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.NoError(t, err)
|
||||
|
||||
failed := []byte(`{"type":"response.failed","response":{"id":"resp_cyber_handler","model":"gpt-5.1","error":{"code":"cyber_policy","message":"blocked by upstream policy"},"usage":{"input_tokens":11,"output_tokens":3}}}`)
|
||||
writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
err = conn.Write(writeCtx, coderws.MessageText, failed)
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
|
||||
_, second, err := conn.Read(readCtx)
|
||||
cancelRead()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
secondUpstreamFrame <- append([]byte(nil), second...)
|
||||
|
||||
completed := []byte(`{"type":"response.completed","response":{"id":"resp_cyber_handler_turn_2","model":"gpt-5.1","usage":{"input_tokens":1,"output_tokens":1}}}`)
|
||||
writeCtx, cancelWrite = context.WithTimeout(r.Context(), 3*time.Second)
|
||||
err = conn.Write(writeCtx, coderws.MessageText, completed)
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
harness := newOpenAIWSPassthroughHandlerHarness(t, upstreamServer.URL)
|
||||
|
||||
requestPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"cyber-session-1","input":"test"}`
|
||||
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err := harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(requestPayload))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, event, err := harness.clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "response.failed", gjson.GetBytes(event, "type").String())
|
||||
|
||||
require.Eventually(t, func() bool {
|
||||
logs := harness.moderationRepo.logSnapshot()
|
||||
return len(logs) == 1 && logs[0].Action == service.ContentModerationActionCyberPolicy &&
|
||||
strings.Contains(logs[0].Error, "upstream_usage=in:11,out:3")
|
||||
}, 3*time.Second, 10*time.Millisecond, "handler AfterTurn must call recordCyberPolicyIfMarked and write the risk-control event")
|
||||
|
||||
keyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
keyCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(requestPayload))
|
||||
blockKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, keyCtx, []byte(requestPayload))
|
||||
require.NotEmpty(t, blockKey)
|
||||
store, ok := harness.gatewayCache.(service.CyberSessionBlockStore)
|
||||
require.True(t, ok)
|
||||
require.Eventually(t, func() bool {
|
||||
matched, findErr := store.FindCyberSessionBlocked(context.Background(), []string{blockKey})
|
||||
return findErr == nil && matched == blockKey
|
||||
}, 3*time.Second, 10*time.Millisecond, "handler AfterTurn must write the cyber session block table")
|
||||
|
||||
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(`{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"cyber-session-1","input":"follow-up"}`))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
readCtx, cancelRead = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, err = harness.clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
var closeErr coderws.CloseError
|
||||
require.ErrorAs(t, err, &closeErr)
|
||||
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
||||
// closeOpenAIClientWS caps close reasons at 120 bytes; passthrough must expose
|
||||
// the same client-visible prefix rather than dropping the close frame.
|
||||
require.Equal(t, "该会话已被网络安全策略屏蔽,请开启新会话 / This session is blocked by cyber-security policy, please ", closeErr.Reason)
|
||||
select {
|
||||
case <-harness.handlerDone:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("websocket handler did not exit")
|
||||
}
|
||||
select {
|
||||
case <-upstreamDone:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("upstream websocket did not exit")
|
||||
}
|
||||
select {
|
||||
case second := <-secondUpstreamFrame:
|
||||
t.Fatalf("blocked follow-up reached upstream: %s", second)
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocketV2PassthroughNonCyberTurnAllowsFollowup(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
upstreamDone := make(chan struct{})
|
||||
secondUpstreamFrame := make(chan []byte, 1)
|
||||
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer close(upstreamDone)
|
||||
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
|
||||
require.NoError(t, err)
|
||||
defer func() { _ = conn.CloseNow() }()
|
||||
|
||||
readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
_, _, err = conn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.NoError(t, err)
|
||||
|
||||
firstCompleted := []byte(`{"type":"response.completed","response":{"id":"resp_non_cyber_handler_turn_1","model":"gpt-5.1","usage":{"input_tokens":2,"output_tokens":1}}}`)
|
||||
writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
|
||||
err = conn.Write(writeCtx, coderws.MessageText, firstCompleted)
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
|
||||
_, second, err := conn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.NoError(t, err)
|
||||
secondUpstreamFrame <- append([]byte(nil), second...)
|
||||
|
||||
secondCompleted := []byte(`{"type":"response.completed","response":{"id":"resp_non_cyber_handler_turn_2","model":"gpt-5.1","usage":{"input_tokens":3,"output_tokens":1}}}`)
|
||||
writeCtx, cancelWrite = context.WithTimeout(r.Context(), 3*time.Second)
|
||||
err = conn.Write(writeCtx, coderws.MessageText, secondCompleted)
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
readCtx, cancelRead = context.WithTimeout(r.Context(), 3*time.Second)
|
||||
_, _, _ = conn.Read(readCtx)
|
||||
cancelRead()
|
||||
}))
|
||||
defer upstreamServer.Close()
|
||||
harness := newOpenAIWSPassthroughHandlerHarness(t, upstreamServer.URL)
|
||||
|
||||
firstPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"non-cyber-session-1","input":"first"}`
|
||||
writeCtx, cancelWrite := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err := harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(firstPayload))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, firstEvent, err := harness.clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "resp_non_cyber_handler_turn_1", gjson.GetBytes(firstEvent, "response.id").String())
|
||||
|
||||
secondPayload := `{"type":"response.create","model":"gpt-5.1","prompt_cache_key":"non-cyber-session-1","input":"follow-up"}`
|
||||
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = harness.clientConn.Write(writeCtx, coderws.MessageText, []byte(secondPayload))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
readCtx, cancelRead = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, secondEvent, err := harness.clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "resp_non_cyber_handler_turn_2", gjson.GetBytes(secondEvent, "response.id").String())
|
||||
require.Empty(t, harness.moderationRepo.logSnapshot())
|
||||
|
||||
keyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
keyCtx.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(firstPayload))
|
||||
blockKey := service.CyberSessionExplicitBlockKey(harness.apiKey.ID, keyCtx, []byte(firstPayload))
|
||||
require.NotEmpty(t, blockKey)
|
||||
store, ok := harness.gatewayCache.(service.CyberSessionBlockStore)
|
||||
require.True(t, ok)
|
||||
matched, findErr := store.FindCyberSessionBlocked(context.Background(), []string{blockKey})
|
||||
require.NoError(t, findErr)
|
||||
require.Empty(t, matched)
|
||||
|
||||
require.NoError(t, harness.clientConn.Close(coderws.StatusNormalClosure, "done"))
|
||||
select {
|
||||
case <-harness.handlerDone:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("non-cyber websocket handler did not exit")
|
||||
}
|
||||
select {
|
||||
case <-upstreamDone:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("non-cyber upstream websocket did not exit")
|
||||
}
|
||||
select {
|
||||
case second := <-secondUpstreamFrame:
|
||||
require.JSONEq(t, secondPayload, string(second))
|
||||
default:
|
||||
t.Fatal("non-cyber follow-up did not reach upstream")
|
||||
}
|
||||
}
|
||||
@@ -2287,6 +2287,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool {
|
||||
strings.Contains(msg, "this group is restricted to claude code clients") ||
|
||||
strings.Contains(msg, "this group does not allow /v1/messages dispatch") ||
|
||||
strings.Contains(msg, "image generation is not enabled for this group") ||
|
||||
(strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "exceeds this group's limit")) ||
|
||||
strings.Contains(msg, "token counting is not supported for this platform") ||
|
||||
strings.Contains(msg, "images api is not supported for this platform") ||
|
||||
(strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) ||
|
||||
|
||||
@@ -1243,6 +1243,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "group reasoning effort over limit deny",
|
||||
errType: "permission_error",
|
||||
message: `reasoning effort "high" exceeds this group's limit of "low"`,
|
||||
code: "",
|
||||
status: http.StatusForbidden,
|
||||
wantErrType: "permission_error",
|
||||
wantPhase: "request",
|
||||
},
|
||||
{
|
||||
name: "route token counting platform unsupported",
|
||||
errType: "not_found_error",
|
||||
@@ -1504,6 +1513,12 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider reasoning effort over limit shaped error",
|
||||
message: `reasoning effort "high" exceeds this group's limit of "low"`,
|
||||
code: "403",
|
||||
status: http.StatusForbidden,
|
||||
},
|
||||
{
|
||||
name: "provider token counting unsupported shaped error",
|
||||
message: "Token counting is not supported for this platform",
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -19,13 +20,16 @@ type responsesFailedError struct {
|
||||
|
||||
// responsesFailedBody 对齐 apicompat.makeResponsesCompletedEvent 输出的 response 子对象字段集。
|
||||
// Output 用空 slice(不是 nil)确保 marshal 为 `[]` 而非 `null`。
|
||||
// CreatedAt 不带 omitempty:严格客户端把它当必填字段,缺失会以
|
||||
// `missing field 'created_at'` 反序列化失败——那正是本文件要避免的"客户端读不懂终止事件"。
|
||||
type responsesFailedBody struct {
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Output []any `json:"output"`
|
||||
Error responsesFailedError `json:"error"`
|
||||
ID string `json:"id"`
|
||||
Object string `json:"object"`
|
||||
CreatedAt int64 `json:"created_at"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Status string `json:"status"`
|
||||
Output []any `json:"output"`
|
||||
Error responsesFailedError `json:"error"`
|
||||
}
|
||||
|
||||
// responsesFailedEvent 是写入 SSE data 行的顶层结构。
|
||||
@@ -52,7 +56,7 @@ type responsesFailedEvent struct {
|
||||
// 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
|
||||
// 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏,
|
||||
// 应当让请求处理函数 return,由上层关闭连接。
|
||||
func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
|
||||
func writeResponsesFailedSSE(c *gin.Context, errType, code, message string) bool {
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
return false
|
||||
@@ -61,13 +65,14 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
|
||||
payload, err := json.Marshal(responsesFailedEvent{
|
||||
Type: "response.failed",
|
||||
Response: responsesFailedBody{
|
||||
ID: synthesizeResponseID(c),
|
||||
Object: "response",
|
||||
Model: requestModel(c),
|
||||
Status: "failed",
|
||||
Output: []any{},
|
||||
ID: synthesizeResponseID(c),
|
||||
Object: "response",
|
||||
CreatedAt: time.Now().Unix(),
|
||||
Model: requestModel(c),
|
||||
Status: "failed",
|
||||
Output: []any{},
|
||||
Error: responsesFailedError{
|
||||
Code: mapResponsesErrorCode(errType),
|
||||
Code: mapResponsesErrorCode(errType, code),
|
||||
Message: message,
|
||||
},
|
||||
},
|
||||
@@ -150,7 +155,10 @@ func requestModel(c *gin.Context) string {
|
||||
|
||||
// mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。
|
||||
// 无明确映射时原样返回,保证至少可读。
|
||||
func mapResponsesErrorCode(errType string) string {
|
||||
func mapResponsesErrorCode(errType, code string) string {
|
||||
if code != "" {
|
||||
return code
|
||||
}
|
||||
switch errType {
|
||||
case "rate_limit_error":
|
||||
return "rate_limit_exceeded"
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// Regression for the production incident on 2026-05-24 around 9:13 CST:
|
||||
@@ -77,6 +78,60 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t
|
||||
assert.Equal(t, "Concurrency limit exceeded for user, please retry later", errObj["message"])
|
||||
}
|
||||
|
||||
func TestOpenAIAdmissionError_SynchronousNonResponsesIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayQueueFullCode, "Too many pending requests, please retry later", false, false)
|
||||
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
|
||||
assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
|
||||
}
|
||||
|
||||
func TestOpenAIAdmissionError_SynchronousResponsesIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointResponses)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", false, false)
|
||||
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
|
||||
assert.Equal(t, gatewayConcurrencyLimitCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
|
||||
}
|
||||
|
||||
func TestOpenAIAdmissionError_ResponsesFailedIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointResponses)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true, false)
|
||||
|
||||
_, errObj := parseResponsesFailedSSE(t, w.Body.String())
|
||||
assert.Equal(t, gatewayConcurrencyLimitCode, errObj["code"])
|
||||
}
|
||||
|
||||
func TestOpenAIAdmissionError_StreamingNonResponsesIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayQueueFullCode, "Too many pending requests, please retry later", true, false)
|
||||
|
||||
body := w.Body.String()
|
||||
assert.True(t, strings.HasPrefix(body, "event: error\n"))
|
||||
payload := body[strings.Index(body, "{"):]
|
||||
assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
|
||||
assert.Equal(t, gatewayQueueFullCode, gjson.Get(payload, "error.code").String())
|
||||
}
|
||||
|
||||
func TestOpenAIUpstreamRateLimitWithoutLocalCodeRemainsUnchanged(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "upstream limited", false)
|
||||
|
||||
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
|
||||
assert.False(t, gjson.GetBytes(w.Body.Bytes(), "error.code").Exists())
|
||||
}
|
||||
|
||||
// 当 setOpsRequestContext 写过 model,合成事件应回填该字段(与 codebase 已有 makeResponsesCompletedEvent 对齐)。
|
||||
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingIncludesModel(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointResponses)
|
||||
@@ -164,6 +219,41 @@ func TestGatewayHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(
|
||||
assert.Equal(t, "upstream gone", errObj["message"])
|
||||
}
|
||||
|
||||
func TestGatewayAdmissionError_SynchronousIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointMessages)
|
||||
h := &GatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayQueueFullCode, "Too many pending requests, please retry later", false)
|
||||
|
||||
assert.Equal(t, http.StatusTooManyRequests, w.Code)
|
||||
assert.Equal(t, "error", gjson.GetBytes(w.Body.Bytes(), "type").String())
|
||||
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
|
||||
assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
|
||||
}
|
||||
|
||||
func TestGatewayAdmissionError_MessagesStreamingIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointMessages)
|
||||
h := &GatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true)
|
||||
|
||||
body := w.Body.String()
|
||||
assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`))
|
||||
payload := body[strings.Index(body, "{"):]
|
||||
assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
|
||||
assert.Equal(t, gatewayConcurrencyLimitCode, gjson.Get(payload, "error.code").String())
|
||||
}
|
||||
|
||||
func TestGatewayAdmissionError_BareResponsesFailedIncludesGatewayCode(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, "/responses")
|
||||
h := &GatewayHandler{}
|
||||
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
|
||||
gatewayQueueFullCode, "Too many pending requests, please retry later", true)
|
||||
|
||||
_, errObj := parseResponsesFailedSSE(t, w.Body.String())
|
||||
assert.Equal(t, gatewayQueueFullCode, errObj["code"])
|
||||
}
|
||||
|
||||
// Gateway handler: /v1/messages preserves the legacy data:{type:error,...} format
|
||||
// (Anthropic spec accepts a type:"error" stream event).
|
||||
func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testing.T) {
|
||||
@@ -227,6 +317,22 @@ func TestOpenAIHandleStreamingAwareError_BareResponsesRouteEmitsResponseFailed(t
|
||||
}
|
||||
|
||||
// Synthesized response.failed id falls back to uuid when no request_id is present.
|
||||
// issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即
|
||||
// `missing field 'created_at'`。合成的终止事件若解析不了,本文件存在的意义
|
||||
// (给客户端一个可识别的终止事件而不是盲重连)就落空了。
|
||||
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesCreatedAt(t *testing.T) {
|
||||
c, w := newGinContextForEndpoint(t, EndpointResponses)
|
||||
h := &OpenAIGatewayHandler{}
|
||||
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
|
||||
|
||||
resp, _ := parseResponsesFailedSSE(t, w.Body.String())
|
||||
raw, ok := resp["created_at"]
|
||||
assert.True(t, ok, "response.failed 必须带 created_at")
|
||||
createdAt, ok := raw.(float64)
|
||||
assert.True(t, ok, "created_at 必须是数字,得到 %T", raw)
|
||||
assert.Greater(t, int64(createdAt), int64(0), "created_at 必须是有效的 unix 时间戳")
|
||||
}
|
||||
|
||||
func TestSynthesizeResponseID_FallbackUUID(t *testing.T) {
|
||||
c, _ := newGinContextForEndpoint(t, EndpointResponses)
|
||||
id := synthesizeResponseID(c)
|
||||
@@ -248,6 +354,7 @@ func TestMapResponsesErrorCode(t *testing.T) {
|
||||
{"custom_thing", "custom_thing"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in), "in=%q", tc.in)
|
||||
assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in, ""), "in=%q", tc.in)
|
||||
}
|
||||
assert.Equal(t, gatewayQueueFullCode, mapResponsesErrorCode("rate_limit_error", gatewayQueueFullCode))
|
||||
}
|
||||
|
||||
@@ -126,6 +126,16 @@ func (h *UsageHandler) parseUserUsageFilters(c *gin.Context, requireRange bool)
|
||||
stream = &val
|
||||
}
|
||||
|
||||
var nativeCompactionV2 *bool
|
||||
if raw := strings.TrimSpace(c.Query("native_compaction_v2")); raw != "" {
|
||||
value, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
|
||||
return nil, false
|
||||
}
|
||||
nativeCompactionV2 = &value
|
||||
}
|
||||
|
||||
var billingType *int8
|
||||
if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" {
|
||||
val, err := strconv.ParseInt(billingTypeStr, 10, 8)
|
||||
@@ -195,17 +205,18 @@ func (h *UsageHandler) parseUserUsageFilters(c *gin.Context, requireRange bool)
|
||||
|
||||
return &userUsageFilters{
|
||||
Filters: usagestats.UsageLogFilters{
|
||||
UserID: subject.UserID,
|
||||
APIKeyID: apiKeyID,
|
||||
GroupID: groupID,
|
||||
Model: strings.TrimSpace(c.Query("model")),
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: startPtr,
|
||||
EndTime: endPtr,
|
||||
UserID: subject.UserID,
|
||||
APIKeyID: apiKeyID,
|
||||
GroupID: groupID,
|
||||
Model: strings.TrimSpace(c.Query("model")),
|
||||
ModelFilterSource: usagestats.ModelSourceRequested,
|
||||
RequestType: requestType,
|
||||
Stream: stream,
|
||||
NativeCompactionV2: nativeCompactionV2,
|
||||
BillingType: billingType,
|
||||
BillingMode: billingMode,
|
||||
StartTime: startPtr,
|
||||
EndTime: endPtr,
|
||||
},
|
||||
StartTime: derefTime(startPtr),
|
||||
EndTime: derefTime(endPtr),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user