mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 20:12:39 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5de5e2bed0 | ||
|
|
7ccc8a6f50 | ||
|
|
270eac6973 | ||
|
|
570106ac17 | ||
|
|
775348b402 | ||
|
|
d9efbf8229 | ||
|
|
233f3dffe6 | ||
|
|
f9d7826711 | ||
|
|
c54897a59d | ||
|
|
29646ba6b4 | ||
|
|
6f0d0ababc | ||
|
|
52f7bcaedd | ||
|
|
95acbf1f03 | ||
|
|
43569bb44c | ||
|
|
15557e7796 | ||
|
|
e2fd418a96 | ||
|
|
4e9b01fd59 | ||
|
|
aa167a25a7 | ||
|
|
5cbb9191a7 | ||
|
|
ae4cc14b28 | ||
|
|
fa556700c2 | ||
|
|
68773aab98 | ||
|
|
1e6114c270 | ||
|
|
9e7039ebca | ||
|
|
1923d1c27d | ||
|
|
b638445234 | ||
|
|
9bcf8a5b80 | ||
|
|
979fe247f9 | ||
|
|
6378fb0cba | ||
|
|
3ee92b40c5 | ||
|
|
4892b8f17e | ||
|
|
28b807a735 | ||
|
|
4e5d67df35 | ||
|
|
dbe92a1c24 | ||
|
|
188e3a9f90 | ||
|
|
10940ecf39 | ||
|
|
3495635a52 | ||
|
|
c8deeb0b05 | ||
|
|
b6ee9f0a32 | ||
|
|
6c8ad0bd4a | ||
|
|
7137fae2a6 | ||
|
|
859de4c8fd | ||
|
|
e835f79819 | ||
|
|
99eba19ffc | ||
|
|
38cfd7e2d9 | ||
|
|
3fc08745a6 | ||
|
|
efc6e4a816 | ||
|
|
df64b5f368 | ||
|
|
772a0382f0 | ||
|
|
8fa67d477d | ||
|
|
4a7739acdf | ||
|
|
14e0a49e17 | ||
|
|
b439dddfda | ||
|
|
b42157a725 | ||
|
|
335fcdc1d6 | ||
|
|
e094a3f40b | ||
|
|
57387445f3 | ||
|
|
2fc24d8879 | ||
|
|
cc91155fe4 | ||
|
|
b7dba62678 | ||
|
|
5485f368b2 | ||
|
|
f8351e9e33 | ||
|
|
941a0487ab | ||
|
|
7f0f579bb0 | ||
|
|
dfb3978b9d | ||
|
|
2031c1d5ad | ||
|
|
6f2295bfc7 | ||
|
|
8193b80a59 | ||
|
|
f6d580200a | ||
|
|
b491c3271b | ||
|
|
d106b3b5a0 | ||
|
|
10fe591e54 | ||
|
|
66cd8a72bc | ||
|
|
dc46daa690 | ||
|
|
65246e69d7 | ||
|
|
de69bf1e03 | ||
|
|
eba85dc478 | ||
|
|
abf70750fd | ||
|
|
88b697d5df | ||
|
|
3eb5a84ea1 | ||
|
|
71e5a4baf7 | ||
|
|
4bfcbd0c78 | ||
|
|
2b1e5e7f5c | ||
|
|
cbb4b7e53e | ||
|
|
81c0f11d7a | ||
|
|
561fc1c3e4 | ||
|
|
43491f67a1 | ||
|
|
c05bc4d3cc | ||
|
|
b9ebc8f209 | ||
|
|
f7c48ba59a | ||
|
|
22c16d0517 | ||
|
|
2cc1e7ef90 | ||
|
|
35e69af41b | ||
|
|
81ff9384cb | ||
|
|
0d3dbae710 | ||
|
|
19382f275e | ||
|
|
0aaed397cc | ||
|
|
909b8c7ef4 | ||
|
|
b8ff74ad70 | ||
|
|
96cf8bd76a | ||
|
|
1f716aa181 | ||
|
|
111c41cf3f | ||
|
|
787a6a33df | ||
|
|
f8e1e7fed6 | ||
|
|
f09a5b602e | ||
|
|
027e3f2d54 | ||
|
|
8116d235ef | ||
|
|
959cbe3d87 | ||
|
|
c0420e2b8a | ||
|
|
9578ddbddd | ||
|
|
e274de45b7 | ||
|
|
ce328fb37a | ||
|
|
be4ab92b2b | ||
|
|
8f7f2b5d92 | ||
|
|
3efeba33a4 | ||
|
|
ce157b32ed | ||
|
|
3cb2381bdc | ||
|
|
a7d2c782df | ||
|
|
a3aa9bae6d | ||
|
|
b939fa9d4a | ||
|
|
d03c42d798 | ||
|
|
6aabbdf547 | ||
|
|
ad4b2f30f5 | ||
|
|
ff758f37d2 | ||
|
|
2e31d8b702 | ||
|
|
8c8fd6e298 | ||
|
|
f62ec2e4a0 | ||
|
|
415a0dc9d0 | ||
|
|
b59f3bf46c | ||
|
|
2db78bd3d6 | ||
|
|
f88d62ad28 | ||
|
|
8363d537e0 | ||
|
|
b8ed245085 | ||
|
|
76efc52fb6 | ||
|
|
dc6b318c34 | ||
|
|
96884dd1ae | ||
|
|
0b061c5fa1 | ||
|
|
958a21ad6e | ||
|
|
28167bbf85 | ||
|
|
985428d9d2 | ||
|
|
4a4fd35e0c | ||
|
|
f46038820b | ||
|
|
05ad6b49a1 | ||
|
|
a3675552bd | ||
|
|
b3ad9b67cf | ||
|
|
f49a393566 | ||
|
|
8a46947865 | ||
|
|
7ae0312091 | ||
|
|
58e35a4f32 | ||
|
|
95023e7d49 | ||
|
|
7a70de401c | ||
|
|
666a5c6f87 | ||
|
|
c7343d2aa2 | ||
|
|
a6430bfe20 | ||
|
|
32bf3d0f33 | ||
|
|
cb31033972 | ||
|
|
3aeab296d8 | ||
|
|
f3bbb95310 | ||
|
|
00eabe8ab9 | ||
|
|
a16070ccf6 | ||
|
|
ab99d56e96 | ||
|
|
b1ce821c49 | ||
|
|
578785ee7f | ||
|
|
ed7c8f2208 | ||
|
|
2e1c7c00a6 | ||
|
|
994ca26e92 | ||
|
|
d2f87c612e | ||
|
|
62cd63bfda | ||
|
|
dee35a841d | ||
|
|
fb44e2dd09 | ||
|
|
c42d78e26c | ||
|
|
2dc287f2e0 | ||
|
|
83094abf21 | ||
|
|
aafe93b335 | ||
|
|
cc52c93ad7 | ||
|
|
d7f048a265 | ||
|
|
55c5eed9fc | ||
|
|
0d9e7e1530 | ||
|
|
9b4fcfb897 | ||
|
|
cff3f89850 | ||
|
|
9517f12cdd | ||
|
|
40f4d450be | ||
|
|
620eb3fd00 | ||
|
|
c6ff69fd12 | ||
|
|
7b271bbee9 | ||
|
|
68f099707e | ||
|
|
3bdb869fca | ||
|
|
c227863d51 | ||
|
|
3a8c9f16de | ||
|
|
2da31290a1 | ||
|
|
3c53ba01a7 | ||
|
|
07bf8b92fd | ||
|
|
63dc24b5e8 | ||
|
|
8f1d6af3ed | ||
|
|
85f431a072 | ||
|
|
d7fb9b5d35 | ||
|
|
eba6ea5639 | ||
|
|
2ad6e8fb75 | ||
|
|
570bd084c2 | ||
|
|
15278fe2f9 | ||
|
|
c4e6dcfd80 | ||
|
|
701f844755 | ||
|
|
91cf660377 | ||
|
|
78e1aaedb9 | ||
|
|
708b85a6a4 | ||
|
|
1cab4d8c22 | ||
|
|
d8b9a83f8c | ||
|
|
a016bd4ba5 | ||
|
|
983a3db3af | ||
|
|
7969751f1f | ||
|
|
6271c517da | ||
|
|
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 | ||
|
|
1859519573 | ||
|
|
9be9c0b68c | ||
|
|
a7f1ed8fa4 | ||
|
|
f5e4da5e72 | ||
|
|
9dc4c40bff | ||
|
|
81fd853005 | ||
|
|
01bd9b71a2 | ||
|
|
9ad3865690 | ||
|
|
5097b31457 | ||
|
|
a774230661 | ||
|
|
222181efd6 | ||
|
|
5fb5dfb34c | ||
|
|
a10ff1255c | ||
|
|
c74a4f521c | ||
|
|
163eb7ffae | ||
|
|
0e08c39999 | ||
|
|
ca9b4d73f2 | ||
|
|
5fea83dcae | ||
|
|
6d339ec93b | ||
|
|
1ee929e4b9 |
@@ -1,6 +1,7 @@
|
||||
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
|
||||
|
||||
FRONTEND_CRITICAL_VITEST := \
|
||||
src/i18n/__tests__/localeKeyCompleteness.spec.ts \
|
||||
src/api/__tests__/client.spec.ts \
|
||||
src/api/__tests__/tokenRefresh.spec.ts \
|
||||
src/api/__tests__/channelMonitorV2.spec.ts \
|
||||
|
||||
@@ -96,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.
|
||||
@@ -127,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>
|
||||
@@ -177,6 +166,16 @@ Please read the following carefully before using this project:
|
||||
<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>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
|
||||
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> is a source-level API gateway with its own massive account pool, purpose-built as an upstream relay for downstream transit stations and high-frequency developers — saving you the hassle of building and maintaining your own account pool. ✅ Direct from source: self-operated account pool with no middleman markup; 🧧 Cost crusher: combined rate multiplier as low as 0.03x, just 0.35% of official pricing; 🚀 Blazing fast: first-token latency < 1s, on par with the official native API. <a href="https://www.pp.dog/register?aff=SUB2API">Get started with PP.dog now</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
|
||||
<td>ColaProxy provides high-quality residential proxies built for web scraping, automation, and multi-account management. Get a free trial with non-expiring traffic, pricing starting as low as $0.3/GB, unlimited concurrent connections, and intelligent IP rotation for a smoother, more stable proxy experience. Use discount code COLA10 to get 10% off and start scaling your projects with reliable residential proxies today. <a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">Get started with ColaProxy</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
@@ -827,7 +826,7 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco
|
||||
|
||||
`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
|
||||
|
||||
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
|
||||
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
|
||||
|
||||
Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations.
|
||||
|
||||
|
||||
+18
-11
@@ -99,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>即可开始体验,轻松构建更稳定、高效的自动化工作流。
|
||||
@@ -130,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>
|
||||
@@ -180,6 +169,16 @@
|
||||
<td>感谢 AxisNow 赞助了本项目!<a href="https://www.axisnow.io/">AxisNow</a> 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — <strong>自建私有部署 CDN</strong>|<strong>订阅式高防 CDN</strong>|<strong>自主可控、灵活组合的 CDN 网络</strong>。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
|
||||
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> 是自建账号池的源头 API 网关,专注为下游中转站与高频开发者提供 API 网关中继服务,帮您省去自建号池的一切麻烦——✅ 源头直供:自持海量账号池,无中间商赚差价;🧧 成本屠夫:综合倍率低至 0.03x,成本仅为官方的千分之3.5;🚀 极速体验:首 Token 延迟 < 1s,流畅媲美官方原生 API。<a href="https://www.pp.dog/register?aff=SUB2API">立即接入PP.dog</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
|
||||
<td>ColaProxy 提供专为网页抓取、自动化和多账号管理打造的高质量住宅代理。免费试用,流量永不过期,价格低至 $0.3/GB,支持无限并发连接和智能 IP 轮换,带来更流畅、更稳定的代理体验。使用优惠码 COLA10 立享 9 折优惠,立即开始使用可靠的住宅代理扩展您的项目。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">立即开始使用 ColaProxy</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
@@ -707,6 +706,14 @@ go generate ./cmd/server
|
||||
|
||||
---
|
||||
|
||||
## OpenAI 图片模型
|
||||
|
||||
支持 `gpt-image-2.5-flare`、`gpt-image-2.5-sunburst` 及其 `2026-09-08` 日期快照,可通过 `/v1/images/generations`、`/v1/images/edits` 调用。`quality` 支持 `xhigh`、`max`、`auto`,合法自定义尺寸和图片 usage 明细保持透传。
|
||||
|
||||
OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_generation` 工具,默认主控为 `gpt-5.6-luna`。可设置 `SUB2API_IMAGES_MAIN_MODEL` 切换为账号支持的文本模型;Docker Compose 用户修改 `.env` 后执行 `docker compose up -d` 重建容器。该配置不会替换所选图片模型,也不会覆盖 `/v1/responses` 请求中已经提供的文本主控模型。
|
||||
|
||||
升级后,无模型限制的账号自动支持新模型。已有显式账号映射或分组白名单需要加入两个 2.5 模型(日期快照按需加入);升级不会自动扩大管理员设置的模型权限。新模型内置价格包含官方文本输入、图片输入和图片输出 token 费率,远端价格表尚未更新时使用内置 2.5 价格;实际按次或按 token 计费仍由既有分组/渠道配置决定。
|
||||
|
||||
## 简易模式
|
||||
|
||||
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
|
||||
|
||||
+10
-11
@@ -98,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>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
|
||||
@@ -129,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>
|
||||
@@ -179,6 +168,16 @@
|
||||
<td>AxisNow のご支援に感謝します!<a href="https://www.axisnow.io/">AxisNow</a> はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — <strong>自社構築・プライベートデプロイ CDN</strong>|<strong>サブスクリプション型高防御 CDN</strong>|<strong>自律制御で柔軟に組み合わせられる CDN ネットワーク</strong>。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
|
||||
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> は自社運用の大規模アカウントプールを持つソースレベルの API ゲートウェイで、下流の中継ステーションや高頻度開発者向けに API ゲートウェイリレーサービスを提供し、アカウントプールの自主構築にかかるすべての手間を省きます。✅ ソース直供:自社運用の大規模アカウントプール、中間マージンなし;🧧 コスト最強:総合レート倍率わずか 0.03x、コストは公式の 0.35% のみ;🚀 超高速体験:ファーストトークンレイテンシ 1 秒未満、公式ネイティブ API に匹敵するスムーズさ。<a href="https://www.pp.dog/register?aff=SUB2API">今すぐ PP.dog に接続</a></td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
|
||||
<td>ColaProxy は、Web スクレイピング、自動化、マルチアカウント管理向けに構築された高品質な住宅プロキシを提供します。有効期限なしのトラフィックで無料トライアルが利用でき、料金は $0.3/GB から、無制限の同時接続とインテリジェントな IP ローテーションにより、よりスムーズで安定したプロキシ体験を実現します。割引コード COLA10 で 10% オフ、信頼性の高い住宅プロキシでプロジェクトを今すぐスケールしましょう。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">ColaProxy を始める</a></td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 6.8 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 75 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 42 KiB |
@@ -1 +1 @@
|
||||
0.1.185
|
||||
0.2.3
|
||||
|
||||
@@ -137,7 +137,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
|
||||
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
|
||||
channelRepository := repository.NewChannelRepository(db)
|
||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
|
||||
channelCachePubSub := repository.NewChannelCache(redisClient)
|
||||
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService, channelCachePubSub)
|
||||
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
|
||||
compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client)
|
||||
compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository)
|
||||
@@ -189,10 +190,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
|
||||
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
|
||||
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
|
||||
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
|
||||
adminService := service.NewAdminService(configConfig, userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
|
||||
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
|
||||
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
|
||||
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
|
||||
groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig)
|
||||
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
|
||||
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
|
||||
grokQuotaFetcher := service.NewGrokQuotaFetcher()
|
||||
@@ -205,12 +206,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
|
||||
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
|
||||
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
|
||||
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
|
||||
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
|
||||
dataManagementService := service.NewDataManagementService()
|
||||
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
|
||||
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
|
||||
dbDumper := repository.NewPgDumper(configConfig)
|
||||
dbDumper := repository.NewPgDumper(configConfig, db)
|
||||
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
|
||||
imageStorageFactory := repository.ProvideImageStorageFactory()
|
||||
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, 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.
|
||||
@@ -182,6 +182,7 @@ const (
|
||||
ProviderKimi Provider = "kimi"
|
||||
ProviderZhipu Provider = "zhipu"
|
||||
ProviderDeepseek Provider = "deepseek"
|
||||
ProviderMinimax Provider = "minimax"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -191,7 +192,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
|
||||
|
||||
@@ -108,6 +108,7 @@ const (
|
||||
ProviderKimi Provider = "kimi"
|
||||
ProviderZhipu Provider = "zhipu"
|
||||
ProviderDeepseek Provider = "deepseek"
|
||||
ProviderMinimax Provider = "minimax"
|
||||
)
|
||||
|
||||
func (pr Provider) String() string {
|
||||
@@ -117,7 +118,7 @@ func (pr Provider) String() string {
|
||||
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
|
||||
func ProviderValidator(pr Provider) error {
|
||||
switch pr {
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
|
||||
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
|
||||
|
||||
+17
-1
@@ -4595,6 +4595,22 @@ func (c *ProxyClient) QueryAccounts(_m *Proxy) *AccountQuery {
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryPrimaryProxies queries the primary_proxies edge of a Proxy.
|
||||
func (c *ProxyClient) QueryPrimaryProxies(_m *Proxy) *ProxyQuery {
|
||||
query := (&ProxyClient{config: c.config}).Query()
|
||||
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
|
||||
id := _m.ID
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(proxy.Table, proxy.FieldID, id),
|
||||
sqlgraph.To(proxy.Table, proxy.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
|
||||
)
|
||||
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
|
||||
return fromV, nil
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryBackupProxy queries the backup_proxy edge of a Proxy.
|
||||
func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
|
||||
query := (&ProxyClient{config: c.config}).Query()
|
||||
@@ -4603,7 +4619,7 @@ func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(proxy.Table, proxy.FieldID, id),
|
||||
sqlgraph.To(proxy.Table, proxy.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
|
||||
)
|
||||
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
|
||||
return fromV, nil
|
||||
|
||||
+23
-10
@@ -134,15 +134,17 @@ type Group struct {
|
||||
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
|
||||
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
|
||||
// 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度
|
||||
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
|
||||
// 分组模型白名单:同时约束模型列表接口与请求准入
|
||||
ModelAllowlist domain.GroupModelAllowlist `json:"model_allowlist,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"`
|
||||
// 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
|
||||
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
|
||||
// OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
|
||||
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"`
|
||||
@@ -256,7 +258,7 @@ 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.FieldModelAllowlist, 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.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
@@ -660,12 +662,20 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldModelsListConfig:
|
||||
case group.FieldModelAllowlist:
|
||||
if value, ok := values[i].(*[]byte); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field models_list_config", values[i])
|
||||
return fmt.Errorf("unexpected type %T for field model_allowlist", values[i])
|
||||
} else if value != nil && len(*value) > 0 {
|
||||
if err := json.Unmarshal(*value, &_m.ModelsListConfig); err != nil {
|
||||
return fmt.Errorf("unmarshal field models_list_config: %w", err)
|
||||
if err := json.Unmarshal(*value, &_m.ModelAllowlist); err != nil {
|
||||
return fmt.Errorf("unmarshal field model_allowlist: %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:
|
||||
@@ -997,8 +1007,11 @@ func (_m *Group) String() string {
|
||||
builder.WriteString("messages_dispatch_model_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("models_list_config=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig))
|
||||
builder.WriteString("model_allowlist=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ModelAllowlist))
|
||||
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))
|
||||
|
||||
@@ -130,8 +130,10 @@ const (
|
||||
FieldDefaultMappedModel = "default_mapped_model"
|
||||
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
|
||||
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
|
||||
// FieldModelsListConfig holds the string denoting the models_list_config field in the database.
|
||||
FieldModelsListConfig = "models_list_config"
|
||||
// FieldModelAllowlist holds the string denoting the model_allowlist field in the database.
|
||||
FieldModelAllowlist = "model_allowlist"
|
||||
// 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.
|
||||
@@ -278,7 +280,8 @@ var Columns = []string{
|
||||
FieldRequirePrivacySet,
|
||||
FieldDefaultMappedModel,
|
||||
FieldMessagesDispatchModelConfig,
|
||||
FieldModelsListConfig,
|
||||
FieldModelAllowlist,
|
||||
FieldCodexModelsManifestConfig,
|
||||
FieldRpmLimit,
|
||||
FieldMaxReasoningEffort,
|
||||
FieldMaxReasoningEffortOverLimit,
|
||||
@@ -409,8 +412,10 @@ var (
|
||||
DefaultMappedModelValidator func(string) error
|
||||
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
|
||||
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
|
||||
// DefaultModelsListConfig holds the default value on creation for the "models_list_config" field.
|
||||
DefaultModelsListConfig domain.GroupModelsListConfig
|
||||
// DefaultModelAllowlist holds the default value on creation for the "model_allowlist" field.
|
||||
DefaultModelAllowlist domain.GroupModelAllowlist
|
||||
// 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.
|
||||
|
||||
+97
-32
@@ -788,16 +788,30 @@ func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_c *GroupCreate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupCreate {
|
||||
_c.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_c *GroupCreate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupCreate {
|
||||
_c.mutation.SetModelAllowlist(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupCreate {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetModelsListConfig(*v)
|
||||
_c.SetModelAllowlist(*v)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1161,9 +1175,13 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultMessagesDispatchModelConfig
|
||||
_c.mutation.SetMessagesDispatchModelConfig(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ModelsListConfig(); !ok {
|
||||
v := group.DefaultModelsListConfig
|
||||
_c.mutation.SetModelsListConfig(v)
|
||||
if _, ok := _c.mutation.ModelAllowlist(); !ok {
|
||||
v := group.DefaultModelAllowlist
|
||||
_c.mutation.SetModelAllowlist(v)
|
||||
}
|
||||
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
|
||||
v := group.DefaultCodexModelsManifestConfig
|
||||
_c.mutation.SetCodexModelsManifestConfig(v)
|
||||
}
|
||||
if _, ok := _c.mutation.RpmLimit(); !ok {
|
||||
v := group.DefaultRpmLimit
|
||||
@@ -1363,8 +1381,11 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
|
||||
return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)}
|
||||
}
|
||||
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.ModelAllowlist(); !ok {
|
||||
return &ValidationError{Name: "model_allowlist", err: errors.New(`ent: missing required field "Group.model_allowlist"`)}
|
||||
}
|
||||
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"`)}
|
||||
@@ -1652,9 +1673,13 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
_node.MessagesDispatchModelConfig = value
|
||||
}
|
||||
if value, ok := _c.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
_node.ModelsListConfig = value
|
||||
if value, ok := _c.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
|
||||
_node.ModelAllowlist = 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)
|
||||
@@ -2770,15 +2795,27 @@ func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsert) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsert {
|
||||
u.Set(group.FieldModelsListConfig, v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsert) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsert {
|
||||
u.Set(group.FieldModelAllowlist, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldModelsListConfig)
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateModelAllowlist() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldModelAllowlist)
|
||||
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
|
||||
}
|
||||
|
||||
@@ -4017,17 +4054,31 @@ func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsertOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertOne {
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsertOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetModelsListConfig(v)
|
||||
s.SetModelAllowlist(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne {
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateModelAllowlist() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateModelsListConfig()
|
||||
s.UpdateModelAllowlist()
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -5449,17 +5500,31 @@ func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (u *GroupUpsertBulk) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertBulk {
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (u *GroupUpsertBulk) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetModelsListConfig(v)
|
||||
s.SetModelAllowlist(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk {
|
||||
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateModelAllowlist() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateModelsListConfig()
|
||||
s.UpdateModelAllowlist()
|
||||
})
|
||||
}
|
||||
|
||||
// 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()
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
+50
-16
@@ -1073,16 +1073,30 @@ func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_u *GroupUpdate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdate {
|
||||
_u.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_u *GroupUpdate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdate {
|
||||
_u.mutation.SetModelAllowlist(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdate {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetModelsListConfig(*v)
|
||||
_u.SetModelAllowlist(*v)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1862,8 +1876,11 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
if value, ok := _u.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, 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)
|
||||
@@ -3250,16 +3267,30 @@ func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenA
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (_u *GroupUpdateOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdateOne {
|
||||
_u.mutation.SetModelsListConfig(v)
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (_u *GroupUpdateOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdateOne {
|
||||
_u.mutation.SetModelAllowlist(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdateOne {
|
||||
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetModelsListConfig(*v)
|
||||
_u.SetModelAllowlist(*v)
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -4069,8 +4100,11 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
|
||||
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ModelsListConfig(); ok {
|
||||
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
|
||||
if value, ok := _u.mutation.ModelAllowlist(); ok {
|
||||
_spec.SetField(group.FieldModelAllowlist, 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)
|
||||
|
||||
@@ -623,7 +623,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}},
|
||||
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
|
||||
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
@@ -776,7 +776,7 @@ var (
|
||||
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
|
||||
{Name: "name", Type: field.TypeString, Size: 100},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
|
||||
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax"}},
|
||||
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
|
||||
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
|
||||
{Name: "extra_headers", Type: field.TypeJSON},
|
||||
@@ -960,7 +960,8 @@ var (
|
||||
{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: "model_allowlist", 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"},
|
||||
@@ -1412,7 +1413,7 @@ var (
|
||||
{Name: "expires_at", Type: field.TypeTime, Nullable: true},
|
||||
{Name: "fallback_mode", Type: field.TypeString, Size: 20, Default: "none"},
|
||||
{Name: "expiry_warn_days", Type: field.TypeInt, Default: 7},
|
||||
{Name: "backup_proxy_id", Type: field.TypeInt64, Unique: true, Nullable: true},
|
||||
{Name: "backup_proxy_id", Type: field.TypeInt64, Nullable: true},
|
||||
}
|
||||
// ProxiesTable holds the schema information for the "proxies" table.
|
||||
ProxiesTable = &schema.Table{
|
||||
|
||||
+195
-58
@@ -22164,7 +22164,8 @@ type GroupMutation struct {
|
||||
require_privacy_set *bool
|
||||
default_mapped_model *string
|
||||
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
|
||||
models_list_config *domain.GroupModelsListConfig
|
||||
model_allowlist *domain.GroupModelAllowlist
|
||||
codex_models_manifest_config *domain.GroupCodexModelsManifestConfig
|
||||
rpm_limit *int
|
||||
addrpm_limit *int
|
||||
max_reasoning_effort *string
|
||||
@@ -25163,40 +25164,76 @@ func (m *GroupMutation) ResetMessagesDispatchModelConfig() {
|
||||
m.messages_dispatch_model_config = nil
|
||||
}
|
||||
|
||||
// SetModelsListConfig sets the "models_list_config" field.
|
||||
func (m *GroupMutation) SetModelsListConfig(dmlc domain.GroupModelsListConfig) {
|
||||
m.models_list_config = &dmlc
|
||||
// SetModelAllowlist sets the "model_allowlist" field.
|
||||
func (m *GroupMutation) SetModelAllowlist(dma domain.GroupModelAllowlist) {
|
||||
m.model_allowlist = &dma
|
||||
}
|
||||
|
||||
// ModelsListConfig returns the value of the "models_list_config" field in the mutation.
|
||||
func (m *GroupMutation) ModelsListConfig() (r domain.GroupModelsListConfig, exists bool) {
|
||||
v := m.models_list_config
|
||||
// ModelAllowlist returns the value of the "model_allowlist" field in the mutation.
|
||||
func (m *GroupMutation) ModelAllowlist() (r domain.GroupModelAllowlist, exists bool) {
|
||||
v := m.model_allowlist
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldModelsListConfig returns the old "models_list_config" field's value of the Group entity.
|
||||
// OldModelAllowlist returns the old "model_allowlist" 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) OldModelsListConfig(ctx context.Context) (v domain.GroupModelsListConfig, err error) {
|
||||
func (m *GroupMutation) OldModelAllowlist(ctx context.Context) (v domain.GroupModelAllowlist, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldModelsListConfig is only allowed on UpdateOne operations")
|
||||
return v, errors.New("OldModelAllowlist is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldModelsListConfig requires an ID field in the mutation")
|
||||
return v, errors.New("OldModelAllowlist requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldModelsListConfig: %w", err)
|
||||
return v, fmt.Errorf("querying old value for OldModelAllowlist: %w", err)
|
||||
}
|
||||
return oldValue.ModelsListConfig, nil
|
||||
return oldValue.ModelAllowlist, nil
|
||||
}
|
||||
|
||||
// ResetModelsListConfig resets all changes to the "models_list_config" field.
|
||||
func (m *GroupMutation) ResetModelsListConfig() {
|
||||
m.models_list_config = nil
|
||||
// ResetModelAllowlist resets all changes to the "model_allowlist" field.
|
||||
func (m *GroupMutation) ResetModelAllowlist() {
|
||||
m.model_allowlist = 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.
|
||||
@@ -25884,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, 65)
|
||||
fields := make([]string, 0, 66)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -26056,8 +26093,11 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.messages_dispatch_model_config != nil {
|
||||
fields = append(fields, group.FieldMessagesDispatchModelConfig)
|
||||
}
|
||||
if m.models_list_config != nil {
|
||||
fields = append(fields, group.FieldModelsListConfig)
|
||||
if m.model_allowlist != nil {
|
||||
fields = append(fields, group.FieldModelAllowlist)
|
||||
}
|
||||
if m.codex_models_manifest_config != nil {
|
||||
fields = append(fields, group.FieldCodexModelsManifestConfig)
|
||||
}
|
||||
if m.rpm_limit != nil {
|
||||
fields = append(fields, group.FieldRpmLimit)
|
||||
@@ -26202,8 +26242,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.DefaultMappedModel()
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
return m.MessagesDispatchModelConfig()
|
||||
case group.FieldModelsListConfig:
|
||||
return m.ModelsListConfig()
|
||||
case group.FieldModelAllowlist:
|
||||
return m.ModelAllowlist()
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
return m.CodexModelsManifestConfig()
|
||||
case group.FieldRpmLimit:
|
||||
return m.RpmLimit()
|
||||
case group.FieldMaxReasoningEffort:
|
||||
@@ -26341,8 +26383,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldDefaultMappedModel(ctx)
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
return m.OldMessagesDispatchModelConfig(ctx)
|
||||
case group.FieldModelsListConfig:
|
||||
return m.OldModelsListConfig(ctx)
|
||||
case group.FieldModelAllowlist:
|
||||
return m.OldModelAllowlist(ctx)
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
return m.OldCodexModelsManifestConfig(ctx)
|
||||
case group.FieldRpmLimit:
|
||||
return m.OldRpmLimit(ctx)
|
||||
case group.FieldMaxReasoningEffort:
|
||||
@@ -26765,12 +26809,19 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetMessagesDispatchModelConfig(v)
|
||||
return nil
|
||||
case group.FieldModelsListConfig:
|
||||
v, ok := value.(domain.GroupModelsListConfig)
|
||||
case group.FieldModelAllowlist:
|
||||
v, ok := value.(domain.GroupModelAllowlist)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetModelsListConfig(v)
|
||||
m.SetModelAllowlist(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)
|
||||
@@ -27503,8 +27554,11 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldMessagesDispatchModelConfig:
|
||||
m.ResetMessagesDispatchModelConfig()
|
||||
return nil
|
||||
case group.FieldModelsListConfig:
|
||||
m.ResetModelsListConfig()
|
||||
case group.FieldModelAllowlist:
|
||||
m.ResetModelAllowlist()
|
||||
return nil
|
||||
case group.FieldCodexModelsManifestConfig:
|
||||
m.ResetCodexModelsManifestConfig()
|
||||
return nil
|
||||
case group.FieldRpmLimit:
|
||||
m.ResetRpmLimit()
|
||||
@@ -37312,33 +37366,36 @@ func (m *PromoCodeUsageMutation) ResetEdge(name string) error {
|
||||
// ProxyMutation represents an operation that mutates the Proxy nodes in the graph.
|
||||
type ProxyMutation struct {
|
||||
config
|
||||
op Op
|
||||
typ string
|
||||
id *int64
|
||||
created_at *time.Time
|
||||
updated_at *time.Time
|
||||
deleted_at *time.Time
|
||||
name *string
|
||||
protocol *string
|
||||
host *string
|
||||
port *int
|
||||
addport *int
|
||||
username *string
|
||||
password *string
|
||||
status *string
|
||||
expires_at *time.Time
|
||||
fallback_mode *string
|
||||
expiry_warn_days *int
|
||||
addexpiry_warn_days *int
|
||||
clearedFields map[string]struct{}
|
||||
accounts map[int64]struct{}
|
||||
removedaccounts map[int64]struct{}
|
||||
clearedaccounts bool
|
||||
backup_proxy *int64
|
||||
clearedbackup_proxy bool
|
||||
done bool
|
||||
oldValue func(context.Context) (*Proxy, error)
|
||||
predicates []predicate.Proxy
|
||||
op Op
|
||||
typ string
|
||||
id *int64
|
||||
created_at *time.Time
|
||||
updated_at *time.Time
|
||||
deleted_at *time.Time
|
||||
name *string
|
||||
protocol *string
|
||||
host *string
|
||||
port *int
|
||||
addport *int
|
||||
username *string
|
||||
password *string
|
||||
status *string
|
||||
expires_at *time.Time
|
||||
fallback_mode *string
|
||||
expiry_warn_days *int
|
||||
addexpiry_warn_days *int
|
||||
clearedFields map[string]struct{}
|
||||
accounts map[int64]struct{}
|
||||
removedaccounts map[int64]struct{}
|
||||
clearedaccounts bool
|
||||
primary_proxies map[int64]struct{}
|
||||
removedprimary_proxies map[int64]struct{}
|
||||
clearedprimary_proxies bool
|
||||
backup_proxy *int64
|
||||
clearedbackup_proxy bool
|
||||
done bool
|
||||
oldValue func(context.Context) (*Proxy, error)
|
||||
predicates []predicate.Proxy
|
||||
}
|
||||
|
||||
var _ ent.Mutation = (*ProxyMutation)(nil)
|
||||
@@ -38102,6 +38159,60 @@ func (m *ProxyMutation) ResetAccounts() {
|
||||
m.removedaccounts = nil
|
||||
}
|
||||
|
||||
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by ids.
|
||||
func (m *ProxyMutation) AddPrimaryProxyIDs(ids ...int64) {
|
||||
if m.primary_proxies == nil {
|
||||
m.primary_proxies = make(map[int64]struct{})
|
||||
}
|
||||
for i := range ids {
|
||||
m.primary_proxies[ids[i]] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// ClearPrimaryProxies clears the "primary_proxies" edge to the Proxy entity.
|
||||
func (m *ProxyMutation) ClearPrimaryProxies() {
|
||||
m.clearedprimary_proxies = true
|
||||
}
|
||||
|
||||
// PrimaryProxiesCleared reports if the "primary_proxies" edge to the Proxy entity was cleared.
|
||||
func (m *ProxyMutation) PrimaryProxiesCleared() bool {
|
||||
return m.clearedprimary_proxies
|
||||
}
|
||||
|
||||
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to the Proxy entity by IDs.
|
||||
func (m *ProxyMutation) RemovePrimaryProxyIDs(ids ...int64) {
|
||||
if m.removedprimary_proxies == nil {
|
||||
m.removedprimary_proxies = make(map[int64]struct{})
|
||||
}
|
||||
for i := range ids {
|
||||
delete(m.primary_proxies, ids[i])
|
||||
m.removedprimary_proxies[ids[i]] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// RemovedPrimaryProxies returns the removed IDs of the "primary_proxies" edge to the Proxy entity.
|
||||
func (m *ProxyMutation) RemovedPrimaryProxiesIDs() (ids []int64) {
|
||||
for id := range m.removedprimary_proxies {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// PrimaryProxiesIDs returns the "primary_proxies" edge IDs in the mutation.
|
||||
func (m *ProxyMutation) PrimaryProxiesIDs() (ids []int64) {
|
||||
for id := range m.primary_proxies {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// ResetPrimaryProxies resets all changes to the "primary_proxies" edge.
|
||||
func (m *ProxyMutation) ResetPrimaryProxies() {
|
||||
m.primary_proxies = nil
|
||||
m.clearedprimary_proxies = false
|
||||
m.removedprimary_proxies = nil
|
||||
}
|
||||
|
||||
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
|
||||
func (m *ProxyMutation) ClearBackupProxy() {
|
||||
m.clearedbackup_proxy = true
|
||||
@@ -38543,10 +38654,13 @@ func (m *ProxyMutation) ResetField(name string) error {
|
||||
|
||||
// AddedEdges returns all edge names that were set/added in this mutation.
|
||||
func (m *ProxyMutation) AddedEdges() []string {
|
||||
edges := make([]string, 0, 2)
|
||||
edges := make([]string, 0, 3)
|
||||
if m.accounts != nil {
|
||||
edges = append(edges, proxy.EdgeAccounts)
|
||||
}
|
||||
if m.primary_proxies != nil {
|
||||
edges = append(edges, proxy.EdgePrimaryProxies)
|
||||
}
|
||||
if m.backup_proxy != nil {
|
||||
edges = append(edges, proxy.EdgeBackupProxy)
|
||||
}
|
||||
@@ -38563,6 +38677,12 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case proxy.EdgePrimaryProxies:
|
||||
ids := make([]ent.Value, 0, len(m.primary_proxies))
|
||||
for id := range m.primary_proxies {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case proxy.EdgeBackupProxy:
|
||||
if id := m.backup_proxy; id != nil {
|
||||
return []ent.Value{*id}
|
||||
@@ -38573,10 +38693,13 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
|
||||
|
||||
// RemovedEdges returns all edge names that were removed in this mutation.
|
||||
func (m *ProxyMutation) RemovedEdges() []string {
|
||||
edges := make([]string, 0, 2)
|
||||
edges := make([]string, 0, 3)
|
||||
if m.removedaccounts != nil {
|
||||
edges = append(edges, proxy.EdgeAccounts)
|
||||
}
|
||||
if m.removedprimary_proxies != nil {
|
||||
edges = append(edges, proxy.EdgePrimaryProxies)
|
||||
}
|
||||
return edges
|
||||
}
|
||||
|
||||
@@ -38590,16 +38713,25 @@ func (m *ProxyMutation) RemovedIDs(name string) []ent.Value {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
case proxy.EdgePrimaryProxies:
|
||||
ids := make([]ent.Value, 0, len(m.removedprimary_proxies))
|
||||
for id := range m.removedprimary_proxies {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
return ids
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ClearedEdges returns all edge names that were cleared in this mutation.
|
||||
func (m *ProxyMutation) ClearedEdges() []string {
|
||||
edges := make([]string, 0, 2)
|
||||
edges := make([]string, 0, 3)
|
||||
if m.clearedaccounts {
|
||||
edges = append(edges, proxy.EdgeAccounts)
|
||||
}
|
||||
if m.clearedprimary_proxies {
|
||||
edges = append(edges, proxy.EdgePrimaryProxies)
|
||||
}
|
||||
if m.clearedbackup_proxy {
|
||||
edges = append(edges, proxy.EdgeBackupProxy)
|
||||
}
|
||||
@@ -38612,6 +38744,8 @@ func (m *ProxyMutation) EdgeCleared(name string) bool {
|
||||
switch name {
|
||||
case proxy.EdgeAccounts:
|
||||
return m.clearedaccounts
|
||||
case proxy.EdgePrimaryProxies:
|
||||
return m.clearedprimary_proxies
|
||||
case proxy.EdgeBackupProxy:
|
||||
return m.clearedbackup_proxy
|
||||
}
|
||||
@@ -38636,6 +38770,9 @@ func (m *ProxyMutation) ResetEdge(name string) error {
|
||||
case proxy.EdgeAccounts:
|
||||
m.ResetAccounts()
|
||||
return nil
|
||||
case proxy.EdgePrimaryProxies:
|
||||
m.ResetPrimaryProxies()
|
||||
return nil
|
||||
case proxy.EdgeBackupProxy:
|
||||
m.ResetBackupProxy()
|
||||
return nil
|
||||
|
||||
+18
-2
@@ -55,11 +55,13 @@ type Proxy struct {
|
||||
type ProxyEdges struct {
|
||||
// Accounts holds the value of the accounts edge.
|
||||
Accounts []*Account `json:"accounts,omitempty"`
|
||||
// PrimaryProxies holds the value of the primary_proxies edge.
|
||||
PrimaryProxies []*Proxy `json:"primary_proxies,omitempty"`
|
||||
// BackupProxy holds the value of the backup_proxy edge.
|
||||
BackupProxy *Proxy `json:"backup_proxy,omitempty"`
|
||||
// loadedTypes holds the information for reporting if a
|
||||
// type was loaded (or requested) in eager-loading or not.
|
||||
loadedTypes [2]bool
|
||||
loadedTypes [3]bool
|
||||
}
|
||||
|
||||
// AccountsOrErr returns the Accounts value or an error if the edge
|
||||
@@ -71,12 +73,21 @@ func (e ProxyEdges) AccountsOrErr() ([]*Account, error) {
|
||||
return nil, &NotLoadedError{edge: "accounts"}
|
||||
}
|
||||
|
||||
// PrimaryProxiesOrErr returns the PrimaryProxies value or an error if the edge
|
||||
// was not loaded in eager-loading.
|
||||
func (e ProxyEdges) PrimaryProxiesOrErr() ([]*Proxy, error) {
|
||||
if e.loadedTypes[1] {
|
||||
return e.PrimaryProxies, nil
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "primary_proxies"}
|
||||
}
|
||||
|
||||
// BackupProxyOrErr returns the BackupProxy value or an error if the edge
|
||||
// was not loaded in eager-loading, or loaded but was not found.
|
||||
func (e ProxyEdges) BackupProxyOrErr() (*Proxy, error) {
|
||||
if e.BackupProxy != nil {
|
||||
return e.BackupProxy, nil
|
||||
} else if e.loadedTypes[1] {
|
||||
} else if e.loadedTypes[2] {
|
||||
return nil, &NotFoundError{label: proxy.Label}
|
||||
}
|
||||
return nil, &NotLoadedError{edge: "backup_proxy"}
|
||||
@@ -221,6 +232,11 @@ func (_m *Proxy) QueryAccounts() *AccountQuery {
|
||||
return NewProxyClient(_m.config).QueryAccounts(_m)
|
||||
}
|
||||
|
||||
// QueryPrimaryProxies queries the "primary_proxies" edge of the Proxy entity.
|
||||
func (_m *Proxy) QueryPrimaryProxies() *ProxyQuery {
|
||||
return NewProxyClient(_m.config).QueryPrimaryProxies(_m)
|
||||
}
|
||||
|
||||
// QueryBackupProxy queries the "backup_proxy" edge of the Proxy entity.
|
||||
func (_m *Proxy) QueryBackupProxy() *ProxyQuery {
|
||||
return NewProxyClient(_m.config).QueryBackupProxy(_m)
|
||||
|
||||
@@ -45,6 +45,8 @@ const (
|
||||
FieldExpiryWarnDays = "expiry_warn_days"
|
||||
// EdgeAccounts holds the string denoting the accounts edge name in mutations.
|
||||
EdgeAccounts = "accounts"
|
||||
// EdgePrimaryProxies holds the string denoting the primary_proxies edge name in mutations.
|
||||
EdgePrimaryProxies = "primary_proxies"
|
||||
// EdgeBackupProxy holds the string denoting the backup_proxy edge name in mutations.
|
||||
EdgeBackupProxy = "backup_proxy"
|
||||
// Table holds the table name of the proxy in the database.
|
||||
@@ -56,6 +58,10 @@ const (
|
||||
AccountsInverseTable = "accounts"
|
||||
// AccountsColumn is the table column denoting the accounts relation/edge.
|
||||
AccountsColumn = "proxy_id"
|
||||
// PrimaryProxiesTable is the table that holds the primary_proxies relation/edge.
|
||||
PrimaryProxiesTable = "proxies"
|
||||
// PrimaryProxiesColumn is the table column denoting the primary_proxies relation/edge.
|
||||
PrimaryProxiesColumn = "backup_proxy_id"
|
||||
// BackupProxyTable is the table that holds the backup_proxy relation/edge.
|
||||
BackupProxyTable = "proxies"
|
||||
// BackupProxyColumn is the table column denoting the backup_proxy relation/edge.
|
||||
@@ -219,6 +225,20 @@ func ByAccounts(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
|
||||
}
|
||||
}
|
||||
|
||||
// ByPrimaryProxiesCount orders the results by primary_proxies count.
|
||||
func ByPrimaryProxiesCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborsCount(s, newPrimaryProxiesStep(), opts...)
|
||||
}
|
||||
}
|
||||
|
||||
// ByPrimaryProxies orders the results by primary_proxies terms.
|
||||
func ByPrimaryProxies(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
sqlgraph.OrderByNeighborTerms(s, newPrimaryProxiesStep(), append([]sql.OrderTerm{term}, terms...)...)
|
||||
}
|
||||
}
|
||||
|
||||
// ByBackupProxyField orders the results by backup_proxy field.
|
||||
func ByBackupProxyField(field string, opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
@@ -232,10 +252,17 @@ func newAccountsStep() *sqlgraph.Step {
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, AccountsTable, AccountsColumn),
|
||||
)
|
||||
}
|
||||
func newPrimaryProxiesStep() *sqlgraph.Step {
|
||||
return sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.To(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
|
||||
)
|
||||
}
|
||||
func newBackupProxyStep() *sqlgraph.Step {
|
||||
return sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.To(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -913,12 +913,35 @@ func HasAccountsWith(preds ...predicate.Account) predicate.Proxy {
|
||||
})
|
||||
}
|
||||
|
||||
// HasPrimaryProxies applies the HasEdge predicate on the "primary_proxies" edge.
|
||||
func HasPrimaryProxies() predicate.Proxy {
|
||||
return predicate.Proxy(func(s *sql.Selector) {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
|
||||
)
|
||||
sqlgraph.HasNeighbors(s, step)
|
||||
})
|
||||
}
|
||||
|
||||
// HasPrimaryProxiesWith applies the HasEdge predicate on the "primary_proxies" edge with a given conditions (other predicates).
|
||||
func HasPrimaryProxiesWith(preds ...predicate.Proxy) predicate.Proxy {
|
||||
return predicate.Proxy(func(s *sql.Selector) {
|
||||
step := newPrimaryProxiesStep()
|
||||
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
|
||||
for _, p := range preds {
|
||||
p(s)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// HasBackupProxy applies the HasEdge predicate on the "backup_proxy" edge.
|
||||
func HasBackupProxy() predicate.Proxy {
|
||||
return predicate.Proxy(func(s *sql.Selector) {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(Table, FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
|
||||
)
|
||||
sqlgraph.HasNeighbors(s, step)
|
||||
})
|
||||
|
||||
@@ -202,6 +202,21 @@ func (_c *ProxyCreate) AddAccounts(v ...*Account) *ProxyCreate {
|
||||
return _c.AddAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
|
||||
func (_c *ProxyCreate) AddPrimaryProxyIDs(ids ...int64) *ProxyCreate {
|
||||
_c.mutation.AddPrimaryProxyIDs(ids...)
|
||||
return _c
|
||||
}
|
||||
|
||||
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
|
||||
func (_c *ProxyCreate) AddPrimaryProxies(v ...*Proxy) *ProxyCreate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _c.AddPrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
|
||||
func (_c *ProxyCreate) SetBackupProxy(v *Proxy) *ProxyCreate {
|
||||
return _c.SetBackupProxyID(v.ID)
|
||||
@@ -432,13 +447,29 @@ func (_c *ProxyCreate) createSpec() (*Proxy, *sqlgraph.CreateSpec) {
|
||||
}
|
||||
_spec.Edges = append(_spec.Edges, edge)
|
||||
}
|
||||
if nodes := _c.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges = append(_spec.Edges, edge)
|
||||
}
|
||||
if nodes := _c.mutation.BackupProxyIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
|
||||
+92
-16
@@ -21,13 +21,14 @@ import (
|
||||
// ProxyQuery is the builder for querying Proxy entities.
|
||||
type ProxyQuery struct {
|
||||
config
|
||||
ctx *QueryContext
|
||||
order []proxy.OrderOption
|
||||
inters []Interceptor
|
||||
predicates []predicate.Proxy
|
||||
withAccounts *AccountQuery
|
||||
withBackupProxy *ProxyQuery
|
||||
modifiers []func(*sql.Selector)
|
||||
ctx *QueryContext
|
||||
order []proxy.OrderOption
|
||||
inters []Interceptor
|
||||
predicates []predicate.Proxy
|
||||
withAccounts *AccountQuery
|
||||
withPrimaryProxies *ProxyQuery
|
||||
withBackupProxy *ProxyQuery
|
||||
modifiers []func(*sql.Selector)
|
||||
// intermediate query (i.e. traversal path).
|
||||
sql *sql.Selector
|
||||
path func(context.Context) (*sql.Selector, error)
|
||||
@@ -86,6 +87,28 @@ func (_q *ProxyQuery) QueryAccounts() *AccountQuery {
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryPrimaryProxies chains the current query on the "primary_proxies" edge.
|
||||
func (_q *ProxyQuery) QueryPrimaryProxies() *ProxyQuery {
|
||||
query := (&ProxyClient{config: _q.config}).Query()
|
||||
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
|
||||
if err := _q.prepareQuery(ctx); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
selector := _q.sqlQuery(ctx)
|
||||
if err := selector.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
|
||||
sqlgraph.To(proxy.Table, proxy.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
|
||||
)
|
||||
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
|
||||
return fromU, nil
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
// QueryBackupProxy chains the current query on the "backup_proxy" edge.
|
||||
func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
|
||||
query := (&ProxyClient{config: _q.config}).Query()
|
||||
@@ -100,7 +123,7 @@ func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
|
||||
step := sqlgraph.NewStep(
|
||||
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
|
||||
sqlgraph.To(proxy.Table, proxy.FieldID),
|
||||
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
|
||||
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
|
||||
)
|
||||
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
|
||||
return fromU, nil
|
||||
@@ -295,13 +318,14 @@ func (_q *ProxyQuery) Clone() *ProxyQuery {
|
||||
return nil
|
||||
}
|
||||
return &ProxyQuery{
|
||||
config: _q.config,
|
||||
ctx: _q.ctx.Clone(),
|
||||
order: append([]proxy.OrderOption{}, _q.order...),
|
||||
inters: append([]Interceptor{}, _q.inters...),
|
||||
predicates: append([]predicate.Proxy{}, _q.predicates...),
|
||||
withAccounts: _q.withAccounts.Clone(),
|
||||
withBackupProxy: _q.withBackupProxy.Clone(),
|
||||
config: _q.config,
|
||||
ctx: _q.ctx.Clone(),
|
||||
order: append([]proxy.OrderOption{}, _q.order...),
|
||||
inters: append([]Interceptor{}, _q.inters...),
|
||||
predicates: append([]predicate.Proxy{}, _q.predicates...),
|
||||
withAccounts: _q.withAccounts.Clone(),
|
||||
withPrimaryProxies: _q.withPrimaryProxies.Clone(),
|
||||
withBackupProxy: _q.withBackupProxy.Clone(),
|
||||
// clone intermediate query.
|
||||
sql: _q.sql.Clone(),
|
||||
path: _q.path,
|
||||
@@ -319,6 +343,17 @@ func (_q *ProxyQuery) WithAccounts(opts ...func(*AccountQuery)) *ProxyQuery {
|
||||
return _q
|
||||
}
|
||||
|
||||
// WithPrimaryProxies tells the query-builder to eager-load the nodes that are connected to
|
||||
// the "primary_proxies" edge. The optional arguments are used to configure the query builder of the edge.
|
||||
func (_q *ProxyQuery) WithPrimaryProxies(opts ...func(*ProxyQuery)) *ProxyQuery {
|
||||
query := (&ProxyClient{config: _q.config}).Query()
|
||||
for _, opt := range opts {
|
||||
opt(query)
|
||||
}
|
||||
_q.withPrimaryProxies = query
|
||||
return _q
|
||||
}
|
||||
|
||||
// WithBackupProxy tells the query-builder to eager-load the nodes that are connected to
|
||||
// the "backup_proxy" edge. The optional arguments are used to configure the query builder of the edge.
|
||||
func (_q *ProxyQuery) WithBackupProxy(opts ...func(*ProxyQuery)) *ProxyQuery {
|
||||
@@ -408,8 +443,9 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
|
||||
var (
|
||||
nodes = []*Proxy{}
|
||||
_spec = _q.querySpec()
|
||||
loadedTypes = [2]bool{
|
||||
loadedTypes = [3]bool{
|
||||
_q.withAccounts != nil,
|
||||
_q.withPrimaryProxies != nil,
|
||||
_q.withBackupProxy != nil,
|
||||
}
|
||||
)
|
||||
@@ -441,6 +477,13 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if query := _q.withPrimaryProxies; query != nil {
|
||||
if err := _q.loadPrimaryProxies(ctx, query, nodes,
|
||||
func(n *Proxy) { n.Edges.PrimaryProxies = []*Proxy{} },
|
||||
func(n *Proxy, e *Proxy) { n.Edges.PrimaryProxies = append(n.Edges.PrimaryProxies, e) }); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
if query := _q.withBackupProxy; query != nil {
|
||||
if err := _q.loadBackupProxy(ctx, query, nodes, nil,
|
||||
func(n *Proxy, e *Proxy) { n.Edges.BackupProxy = e }); err != nil {
|
||||
@@ -483,6 +526,39 @@ func (_q *ProxyQuery) loadAccounts(ctx context.Context, query *AccountQuery, nod
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (_q *ProxyQuery) loadPrimaryProxies(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
|
||||
fks := make([]driver.Value, 0, len(nodes))
|
||||
nodeids := make(map[int64]*Proxy)
|
||||
for i := range nodes {
|
||||
fks = append(fks, nodes[i].ID)
|
||||
nodeids[nodes[i].ID] = nodes[i]
|
||||
if init != nil {
|
||||
init(nodes[i])
|
||||
}
|
||||
}
|
||||
if len(query.ctx.Fields) > 0 {
|
||||
query.ctx.AppendFieldOnce(proxy.FieldBackupProxyID)
|
||||
}
|
||||
query.Where(predicate.Proxy(func(s *sql.Selector) {
|
||||
s.Where(sql.InValues(s.C(proxy.PrimaryProxiesColumn), fks...))
|
||||
}))
|
||||
neighbors, err := query.All(ctx)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, n := range neighbors {
|
||||
fk := n.BackupProxyID
|
||||
if fk == nil {
|
||||
return fmt.Errorf(`foreign-key "backup_proxy_id" is nil for node %v`, n.ID)
|
||||
}
|
||||
node, ok := nodeids[*fk]
|
||||
if !ok {
|
||||
return fmt.Errorf(`unexpected referenced foreign-key "backup_proxy_id" returned %v for node %v`, *fk, n.ID)
|
||||
}
|
||||
assign(node, n)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
func (_q *ProxyQuery) loadBackupProxy(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
|
||||
ids := make([]int64, 0, len(nodes))
|
||||
nodeids := make(map[int64][]*Proxy)
|
||||
|
||||
+170
-8
@@ -262,6 +262,21 @@ func (_u *ProxyUpdate) AddAccounts(v ...*Account) *ProxyUpdate {
|
||||
return _u.AddAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
|
||||
func (_u *ProxyUpdate) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdate {
|
||||
_u.mutation.AddPrimaryProxyIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
|
||||
func (_u *ProxyUpdate) AddPrimaryProxies(v ...*Proxy) *ProxyUpdate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.AddPrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
|
||||
func (_u *ProxyUpdate) SetBackupProxy(v *Proxy) *ProxyUpdate {
|
||||
return _u.SetBackupProxyID(v.ID)
|
||||
@@ -293,6 +308,27 @@ func (_u *ProxyUpdate) RemoveAccounts(v ...*Account) *ProxyUpdate {
|
||||
return _u.RemoveAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
|
||||
func (_u *ProxyUpdate) ClearPrimaryProxies() *ProxyUpdate {
|
||||
_u.mutation.ClearPrimaryProxies()
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
|
||||
func (_u *ProxyUpdate) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdate {
|
||||
_u.mutation.RemovePrimaryProxyIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
|
||||
func (_u *ProxyUpdate) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdate {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.RemovePrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
|
||||
func (_u *ProxyUpdate) ClearBackupProxy() *ProxyUpdate {
|
||||
_u.mutation.ClearBackupProxy()
|
||||
@@ -492,13 +528,58 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.PrimaryProxiesCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.BackupProxyCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
@@ -507,11 +588,11 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
}
|
||||
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
@@ -774,6 +855,21 @@ func (_u *ProxyUpdateOne) AddAccounts(v ...*Account) *ProxyUpdateOne {
|
||||
return _u.AddAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
|
||||
func (_u *ProxyUpdateOne) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
|
||||
_u.mutation.AddPrimaryProxyIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
|
||||
func (_u *ProxyUpdateOne) AddPrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.AddPrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
|
||||
func (_u *ProxyUpdateOne) SetBackupProxy(v *Proxy) *ProxyUpdateOne {
|
||||
return _u.SetBackupProxyID(v.ID)
|
||||
@@ -805,6 +901,27 @@ func (_u *ProxyUpdateOne) RemoveAccounts(v ...*Account) *ProxyUpdateOne {
|
||||
return _u.RemoveAccountIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
|
||||
func (_u *ProxyUpdateOne) ClearPrimaryProxies() *ProxyUpdateOne {
|
||||
_u.mutation.ClearPrimaryProxies()
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
|
||||
func (_u *ProxyUpdateOne) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
|
||||
_u.mutation.RemovePrimaryProxyIDs(ids...)
|
||||
return _u
|
||||
}
|
||||
|
||||
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
|
||||
func (_u *ProxyUpdateOne) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
|
||||
ids := make([]int64, len(v))
|
||||
for i := range v {
|
||||
ids[i] = v[i].ID
|
||||
}
|
||||
return _u.RemovePrimaryProxyIDs(ids...)
|
||||
}
|
||||
|
||||
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
|
||||
func (_u *ProxyUpdateOne) ClearBackupProxy() *ProxyUpdateOne {
|
||||
_u.mutation.ClearBackupProxy()
|
||||
@@ -1034,13 +1151,58 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.PrimaryProxiesCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
|
||||
}
|
||||
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
Inverse: true,
|
||||
Table: proxy.PrimaryProxiesTable,
|
||||
Columns: []string{proxy.PrimaryProxiesColumn},
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
}
|
||||
for _, k := range nodes {
|
||||
edge.Target.Nodes = append(edge.Target.Nodes, k)
|
||||
}
|
||||
_spec.Edges.Add = append(_spec.Edges.Add, edge)
|
||||
}
|
||||
if _u.mutation.BackupProxyCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
@@ -1049,11 +1211,11 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
|
||||
}
|
||||
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2O,
|
||||
Rel: sqlgraph.M2O,
|
||||
Inverse: false,
|
||||
Table: proxy.BackupProxyTable,
|
||||
Columns: []string{proxy.BackupProxyColumn},
|
||||
Bidi: true,
|
||||
Bidi: false,
|
||||
Target: &sqlgraph.EdgeTarget{
|
||||
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
|
||||
},
|
||||
|
||||
@@ -1183,40 +1183,44 @@ func init() {
|
||||
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[54].Descriptor()
|
||||
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
|
||||
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
|
||||
// groupDescModelAllowlist is the schema descriptor for model_allowlist field.
|
||||
groupDescModelAllowlist := groupFields[54].Descriptor()
|
||||
// group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field.
|
||||
group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist)
|
||||
// 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[55].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[56].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[57].Descriptor()
|
||||
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[58].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[59].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[60].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[61].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()
|
||||
|
||||
@@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field {
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini", "grok",
|
||||
"antigravity", "kimi", "zhipu", "deepseek"),
|
||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
|
||||
// check_mode: 'probe' | 'quota' | 'quota_probe'
|
||||
// probe - LLM 探活(默认,原有行为)
|
||||
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
|
||||
|
||||
@@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
|
||||
MaxLen(100),
|
||||
field.Enum("provider").
|
||||
Values("openai", "anthropic", "gemini", "grok",
|
||||
"antigravity", "kimi", "zhipu", "deepseek"),
|
||||
"antigravity", "kimi", "zhipu", "deepseek", "minimax"),
|
||||
field.String("api_mode").
|
||||
Default("chat_completions").
|
||||
MaxLen(32).
|
||||
|
||||
@@ -262,10 +262,14 @@ func (Group) Fields() []ent.Field {
|
||||
Default(domain.OpenAIMessagesDispatchModelConfig{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
|
||||
field.JSON("models_list_config", domain.GroupModelsListConfig{}).
|
||||
Default(domain.GroupModelsListConfig{}).
|
||||
field.JSON("model_allowlist", domain.GroupModelAllowlist{}).
|
||||
Default(domain.GroupModelAllowlist{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
|
||||
Comment("分组模型白名单:同时约束模型列表接口与请求准入"),
|
||||
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").
|
||||
|
||||
@@ -73,6 +73,10 @@ func (Proxy) Edges() []ent.Edge {
|
||||
// accounts: 使用此代理的账户(反向边)
|
||||
edge.From("accounts", Account.Type).
|
||||
Ref("proxy"),
|
||||
// Directed many-to-one: a backup can serve multiple primary proxies.
|
||||
// The inverse edge prevents Ent from treating this self-reference as symmetric.
|
||||
edge.From("primary_proxies", Proxy.Type).
|
||||
Ref("backup_proxy"),
|
||||
edge.To("backup_proxy", Proxy.Type).
|
||||
Field("backup_proxy_id").
|
||||
Unique(),
|
||||
|
||||
@@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
|
||||
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
|
||||
switch s {
|
||||
case "anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek":
|
||||
"kimi", "zhipu", "deepseek", "minimax":
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("platform %q is not allowed", s)
|
||||
|
||||
+3
-4
@@ -31,7 +31,7 @@ require (
|
||||
github.com/lib/pq v1.10.9
|
||||
github.com/patrickmn/go-cache v2.1.0+incompatible
|
||||
github.com/pquerna/otp v1.5.0
|
||||
github.com/redis/go-redis/v9 v9.17.2
|
||||
github.com/redis/go-redis/v9 v9.22.0
|
||||
github.com/refraction-networking/utls v1.8.2
|
||||
github.com/robfig/cron/v3 v3.0.1
|
||||
github.com/shirou/gopsutil/v4 v4.25.6
|
||||
@@ -101,7 +101,6 @@ require (
|
||||
github.com/containerd/platforms v0.2.1 // indirect
|
||||
github.com/cpuguy83/dockercfg v0.3.2 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/distribution/reference v0.6.0 // indirect
|
||||
github.com/dlclark/regexp2/v2 v2.1.0 // indirect
|
||||
github.com/docker/docker v28.5.1+incompatible // indirect
|
||||
@@ -135,7 +134,7 @@ require (
|
||||
github.com/hashicorp/yamux v0.1.2 // indirect
|
||||
github.com/icholy/digest v1.1.0 // indirect
|
||||
github.com/json-iterator/go v1.1.12 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||
github.com/leodido/go-urn v1.2.4 // indirect
|
||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
||||
github.com/magiconair/properties v1.8.10 // indirect
|
||||
@@ -197,7 +196,7 @@ require (
|
||||
go.opentelemetry.io/otel v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/metric v1.43.0 // indirect
|
||||
go.opentelemetry.io/otel/trace v1.43.0 // indirect
|
||||
go.uber.org/atomic v1.10.0 // indirect
|
||||
go.uber.org/atomic v1.11.0 // indirect
|
||||
go.uber.org/automaxprocs v1.6.0 // indirect
|
||||
go.uber.org/multierr v1.9.0 // indirect
|
||||
golang.org/x/arch v0.3.0 // indirect
|
||||
|
||||
+16
-4
@@ -159,8 +159,6 @@ github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dr
|
||||
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
|
||||
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
|
||||
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
|
||||
github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY=
|
||||
@@ -255,6 +253,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
|
||||
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
|
||||
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
|
||||
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
|
||||
@@ -302,6 +302,8 @@ github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
|
||||
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
@@ -327,6 +329,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
|
||||
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
|
||||
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
|
||||
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
|
||||
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
|
||||
@@ -365,6 +369,8 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
|
||||
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
|
||||
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
|
||||
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
|
||||
@@ -393,12 +399,14 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
|
||||
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
|
||||
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
|
||||
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
|
||||
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
|
||||
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
|
||||
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
@@ -434,6 +442,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
|
||||
@@ -535,6 +545,8 @@ go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeX
|
||||
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
|
||||
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
|
||||
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
|
||||
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
|
||||
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
|
||||
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
|
||||
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
|
||||
|
||||
@@ -1613,10 +1613,10 @@ type OpsCleanupConfig struct {
|
||||
Enabled bool `mapstructure:"enabled"`
|
||||
Schedule string `mapstructure:"schedule"`
|
||||
|
||||
// Retention days (0 disables that cleanup target).
|
||||
//
|
||||
// vNext requirement: default 30 days across ops datasets.
|
||||
// Retention days. Error and metrics targets accept 0 as an explicit truncate;
|
||||
// system logs require a positive value because their runtime setting is bounded.
|
||||
ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"`
|
||||
SystemLogRetentionDays int `mapstructure:"system_log_retention_days"`
|
||||
MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"`
|
||||
HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"`
|
||||
}
|
||||
@@ -2046,7 +2046,8 @@ func setDefaults() {
|
||||
"api.moonshot.ai",
|
||||
"api.moonshot.cn",
|
||||
"open.bigmodel.cn",
|
||||
"api.minimaxi.com",
|
||||
"api.minimaxi.com", // MiniMax CN quota + inference
|
||||
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
|
||||
"generativelanguage.googleapis.com",
|
||||
"cloudcode-pa.googleapis.com",
|
||||
"*.openai.azure.com",
|
||||
@@ -2250,6 +2251,7 @@ func setDefaults() {
|
||||
viper.SetDefault("ops.cleanup.schedule", "0 2 * * *")
|
||||
// Retention days: vNext defaults to 30 days across ops datasets.
|
||||
viper.SetDefault("ops.cleanup.error_log_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.system_log_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30)
|
||||
viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30)
|
||||
viper.SetDefault("ops.aggregation.enabled", true)
|
||||
@@ -3673,6 +3675,9 @@ func (c *Config) Validate() error {
|
||||
if c.Ops.Cleanup.ErrorLogRetentionDays < 0 {
|
||||
return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative")
|
||||
}
|
||||
if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 {
|
||||
return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled")
|
||||
}
|
||||
if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 {
|
||||
return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative")
|
||||
}
|
||||
|
||||
@@ -1191,6 +1191,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadDefaultOpsCleanupConfig(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
cfg, err := Load()
|
||||
if err != nil {
|
||||
t.Fatalf("Load() error: %v", err)
|
||||
}
|
||||
if !cfg.Ops.Cleanup.Enabled {
|
||||
t.Fatal("Ops.Cleanup.Enabled = false, want true")
|
||||
}
|
||||
if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 {
|
||||
t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateUsageCleanupConfigEnabled(t *testing.T) {
|
||||
resetViperWithJWTSecret(t)
|
||||
|
||||
@@ -2137,6 +2152,11 @@ func TestValidateConfigErrors(t *testing.T) {
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 },
|
||||
wantErr: "ops.cleanup.error_log_retention_days",
|
||||
},
|
||||
{
|
||||
name: "ops cleanup system log retention",
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 },
|
||||
wantErr: "ops.cleanup.system_log_retention_days",
|
||||
},
|
||||
{
|
||||
name: "ops cleanup minute retention",
|
||||
mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 },
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -27,6 +27,7 @@ const (
|
||||
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
|
||||
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
|
||||
PlatformDeepseek = "deepseek" // DeepSeek
|
||||
PlatformMiniMax = "minimax" // MiniMax (M 系列)
|
||||
PlatformComposite = "composite"
|
||||
)
|
||||
|
||||
@@ -43,7 +44,7 @@ const (
|
||||
const (
|
||||
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
|
||||
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
|
||||
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi / minimax 原生端点,适配 Codex)
|
||||
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
|
||||
)
|
||||
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
package domain
|
||||
|
||||
// GroupModelAllowlist 是分组级模型白名单:开启后既过滤模型列表类接口的返回内容,
|
||||
// 也约束分组实际可调用的模型(网关准入在合成路由改写与调度之前完成)。
|
||||
type GroupModelAllowlist struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package domain
|
||||
|
||||
// GroupModelsListConfig controls the optional custom /v1/models response list.
|
||||
type GroupModelsListConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Models []string `json:"models,omitempty"`
|
||||
}
|
||||
@@ -7,7 +7,8 @@ const (
|
||||
)
|
||||
|
||||
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
|
||||
// value to another before the group ceiling is applied.
|
||||
// value to another before the group ceiling is applied. To may also be "deny"
|
||||
// to reject the request when the matching source value is present.
|
||||
//
|
||||
// Model and MatchType optionally scope the rewrite to a request model:
|
||||
// exact matches the full model id, prefix/suffix match a model-id affix.
|
||||
|
||||
@@ -309,9 +309,11 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
_, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{
|
||||
Status: normalizedStatus,
|
||||
ExpiresAt: existingExpiresAt,
|
||||
ClearExpiresAt: existingExpiresAt == nil,
|
||||
FallbackMode: existingFallbackMode,
|
||||
BackupProxyID: existingBackupProxyID,
|
||||
ExpiryWarnDays: item.ExpiryWarnDays,
|
||||
ClearBackupID: existingBackupProxyID == nil,
|
||||
ExpiryWarnDays: &item.ExpiryWarnDays,
|
||||
Name: proxy.Name,
|
||||
Protocol: proxy.Protocol,
|
||||
Host: proxy.Host,
|
||||
@@ -383,9 +385,11 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
|
||||
_, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
|
||||
Status: normalizedStatus,
|
||||
ExpiresAt: expiresAt,
|
||||
ClearExpiresAt: expiresAt == nil,
|
||||
FallbackMode: fallbackMode,
|
||||
BackupProxyID: backupProxyID,
|
||||
ExpiryWarnDays: item.ExpiryWarnDays,
|
||||
ClearBackupID: backupProxyID == nil,
|
||||
ExpiryWarnDays: &item.ExpiryWarnDays,
|
||||
Name: created.Name,
|
||||
Protocol: created.Protocol,
|
||||
Host: created.Host,
|
||||
|
||||
@@ -17,6 +17,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/domain"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
|
||||
@@ -64,6 +65,7 @@ type AccountHandler struct {
|
||||
grokImportProber grokImportProber
|
||||
upstreamBillingProbe *service.UpstreamBillingProbeService
|
||||
ollamaCloudUsage *service.OllamaCloudUsageService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
|
||||
@@ -190,6 +192,7 @@ type CheckMixedChannelRequest struct {
|
||||
// AccountWithConcurrency extends Account with real-time concurrency info
|
||||
type AccountWithConcurrency struct {
|
||||
*dto.Account
|
||||
simpleMode bool `json:"-"`
|
||||
CurrentConcurrency int `json:"current_concurrency"`
|
||||
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
|
||||
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
|
||||
@@ -199,6 +202,123 @@ 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 simpleModeGroupReference struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type simpleModeAccountGroupReference struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
GroupID int64 `json:"group_id"`
|
||||
Priority int `json:"priority"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Group *simpleModeGroupReference `json:"group,omitempty"`
|
||||
}
|
||||
|
||||
func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}
|
||||
}
|
||||
|
||||
func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} {
|
||||
hidden := make(map[int64]struct{})
|
||||
if account == nil {
|
||||
return hidden
|
||||
}
|
||||
for _, group := range account.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
hidden[group.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
hidden[accountGroup.GroupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 {
|
||||
visible := make([]int64, 0, len(groupIDs))
|
||||
for _, groupID := range groupIDs {
|
||||
if _, ok := hidden[groupID]; !ok {
|
||||
visible = append(visible, groupID)
|
||||
}
|
||||
}
|
||||
return visible
|
||||
}
|
||||
|
||||
func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} {
|
||||
hidden := make(map[int64]struct{})
|
||||
if account == nil {
|
||||
return hidden
|
||||
}
|
||||
for _, group := range account.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
hidden[group.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
for _, accountGroup := range account.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
hidden[accountGroup.GroupID] = struct{}{}
|
||||
}
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
|
||||
func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) {
|
||||
type alias AccountWithConcurrency
|
||||
if !a.simpleMode || a.Account == nil {
|
||||
return json.Marshal(alias(a))
|
||||
}
|
||||
groups := make([]simpleModeGroupReference, 0, len(a.Groups))
|
||||
compositeIDs := simpleModeCompositeGroupIDs(a.Account)
|
||||
for _, group := range a.Groups {
|
||||
if group != nil && group.Platform == service.PlatformComposite {
|
||||
continue
|
||||
}
|
||||
if ref := simpleModeGroupReferenceFromDTO(group); ref != nil {
|
||||
groups = append(groups, *ref)
|
||||
}
|
||||
}
|
||||
accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups))
|
||||
for _, accountGroup := range a.AccountGroups {
|
||||
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
|
||||
continue
|
||||
}
|
||||
if _, hidden := compositeIDs[accountGroup.GroupID]; hidden {
|
||||
continue
|
||||
}
|
||||
accountGroups = append(accountGroups, simpleModeAccountGroupReference{
|
||||
AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority,
|
||||
CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group),
|
||||
})
|
||||
}
|
||||
return json.Marshal(struct {
|
||||
alias
|
||||
GroupIDs []int64 `json:"group_ids,omitempty"`
|
||||
Groups []simpleModeGroupReference `json:"groups"`
|
||||
AccountGroups []simpleModeAccountGroupReference `json:"account_groups"`
|
||||
}{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups})
|
||||
}
|
||||
|
||||
type AccountSchedulerScore struct {
|
||||
BaseScore float64 `json:"base_score"`
|
||||
StickyScore float64 `json:"sticky_score"`
|
||||
@@ -223,9 +343,25 @@ 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) isSimpleMode() bool {
|
||||
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
|
||||
}
|
||||
|
||||
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
|
||||
item := AccountWithConcurrency{
|
||||
Account: h.accountResponseFromService(account),
|
||||
simpleMode: h.isSimpleMode(),
|
||||
CurrentConcurrency: 0,
|
||||
}
|
||||
if account == nil {
|
||||
@@ -650,8 +786,16 @@ 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)
|
||||
if h.isSimpleMode() {
|
||||
accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc))
|
||||
}
|
||||
}
|
||||
item := AccountWithConcurrency{
|
||||
Account: h.accountResponseFromService(acc),
|
||||
Account: accountResponse,
|
||||
simpleMode: h.isSimpleMode(),
|
||||
CurrentConcurrency: concurrencyCounts[acc.ID],
|
||||
SchedulerScore: schedulerScores[acc.ID],
|
||||
SchedulerScores: schedulerGroupScores[acc.ID],
|
||||
@@ -683,7 +827,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 +867,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 +1008,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 +1145,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 +1603,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)
|
||||
@@ -1875,6 +2058,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
groupIDs := make([]int64, 0)
|
||||
for _, item := range req.Accounts {
|
||||
groupIDs = append(groupIDs, item.GroupIDs...)
|
||||
}
|
||||
if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
|
||||
success := 0
|
||||
@@ -1897,6 +2088,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 +2291,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
|
||||
@@ -2576,6 +2780,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
|
||||
|
||||
// Handle OpenAI accounts
|
||||
if account.IsOpenAI() {
|
||||
// Prefer the shared, account-keyed upstream catalog. If discovery fails,
|
||||
// retain the legacy local catalog below so the test dialog remains usable.
|
||||
if h.accountTestService != nil {
|
||||
if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil {
|
||||
response.Success(c, models)
|
||||
return
|
||||
}
|
||||
}
|
||||
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
|
||||
if account.IsOpenAIPassthroughEnabled() {
|
||||
response.Success(c, openai.DefaultModels)
|
||||
|
||||
@@ -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,240 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type simpleModeAccountService struct {
|
||||
*stubAdminService
|
||||
account service.Account
|
||||
createCalls int
|
||||
updateCalls int
|
||||
bulkCalls int
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) {
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.createCalls++
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
if input.GroupIDs != nil {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.updateCalls++
|
||||
return &s.account, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
|
||||
if input.GroupIDs != nil {
|
||||
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
s.bulkCalls++
|
||||
return &service.BulkUpdateAccountsResult{}, nil
|
||||
}
|
||||
|
||||
func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error {
|
||||
for _, id := range groupIDs {
|
||||
for i := range s.groups {
|
||||
if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite {
|
||||
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99}
|
||||
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
|
||||
account := service.Account{
|
||||
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
GroupIDs: []int64{7, 9},
|
||||
Groups: []*service.Group{richGroup, historicalComposite},
|
||||
AccountGroups: []service.AccountGroup{
|
||||
{AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}},
|
||||
{AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite},
|
||||
},
|
||||
}
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
|
||||
svc.accounts = []service.Account{account}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.GET("/accounts", h.List)
|
||||
r.GET("/accounts/:id", h.GetByID)
|
||||
r.POST("/accounts", h.Create)
|
||||
r.PUT("/accounts/:id", h.Update)
|
||||
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodGet, "/accounts", ""},
|
||||
{http.MethodGet, "/accounts/3", ""},
|
||||
{http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`},
|
||||
{http.MethodPut, "/accounts/3", `{"name":"account"}`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+tt.path, func(t *testing.T) {
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
var payload map[string]any
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
data, ok := payload["data"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
if items, ok := data["items"].([]any); ok {
|
||||
require.Len(t, items, 1)
|
||||
data, ok = items[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
}
|
||||
groups, ok := data["groups"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, groups, 1)
|
||||
require.Equal(t, []any{float64(7)}, data["group_ids"])
|
||||
group, ok := groups[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group))
|
||||
accountGroups, ok := data["account_groups"].([]any)
|
||||
require.True(t, ok)
|
||||
require.Len(t, accountGroups, 1)
|
||||
accountGroup, ok := accountGroups[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup))
|
||||
nestedGroup, ok := accountGroup["group"].(map[string]any)
|
||||
require.True(t, ok)
|
||||
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) {
|
||||
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9}
|
||||
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
|
||||
account := service.Account{
|
||||
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
|
||||
GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite},
|
||||
AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}},
|
||||
}
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
|
||||
svc.accounts = []service.Account{account}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.GET("/accounts", h.List)
|
||||
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
require.NotEmpty(t, res.Header().Get("ETag"))
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 1)
|
||||
require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"])
|
||||
require.NotContains(t, payload.Data.Items[0], "groups")
|
||||
require.NotContains(t, payload.Data.Items[0], "account_groups")
|
||||
|
||||
notModified := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)
|
||||
req.Header.Set("If-None-Match", res.Header().Get("ETag"))
|
||||
r.ServeHTTP(notModified, req)
|
||||
require.Equal(t, http.StatusNotModified, notModified.Code)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tt := range []struct{ name, method, path, body string }{
|
||||
{"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`},
|
||||
{"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`},
|
||||
{"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts", h.Create)
|
||||
r.PUT("/accounts/:id", h.Update)
|
||||
r.POST("/accounts/bulk-update", h.BulkUpdate)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
|
||||
require.Zero(t, svc.createCalls)
|
||||
require.Zero(t, svc.updateCalls)
|
||||
require.Zero(t, svc.bulkCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts", h.Create)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
|
||||
require.Equal(t, 1, svc.createCalls)
|
||||
}
|
||||
|
||||
func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) {
|
||||
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
|
||||
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
|
||||
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
h.cfg = &config.Config{RunMode: config.RunModeSimple}
|
||||
r := gin.New()
|
||||
r.POST("/accounts/batch", h.BatchCreate)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
|
||||
require.Zero(t, svc.createCalls)
|
||||
}
|
||||
|
||||
func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) {
|
||||
group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription}
|
||||
h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}}))
|
||||
require.NoError(t, err)
|
||||
require.Contains(t, string(raw), `"rate_multiplier":9`)
|
||||
require.Contains(t, string(raw), `"subscription_type":"subscription"`)
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
|
||||
|
||||
router.GET("/api/v1/admin/groups", groupHandler.List)
|
||||
router.GET("/api/v1/admin/groups/all", groupHandler.GetAll)
|
||||
router.GET("/api/v1/admin/groups/:id/models-list-candidates", groupHandler.GetModelsListCandidates)
|
||||
router.GET("/api/v1/admin/groups/:id/model-allowlist-candidates", groupHandler.GetGroupModelAllowlistCandidates)
|
||||
router.GET("/api/v1/admin/groups/:id/composite-routes", groupHandler.ListCompositeRoutes)
|
||||
router.POST("/api/v1/admin/groups/:id/composite-routes", groupHandler.CreateCompositeRoute)
|
||||
router.POST("/api/v1/admin/groups/:id/composite-routes/preview", groupHandler.PreviewCompositeRoute)
|
||||
@@ -184,7 +184,7 @@ func TestGroupHandlerEndpoints(t *testing.T) {
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
rec = httptest.NewRecorder()
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/models-list-candidates?platform=openai", nil)
|
||||
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/model-allowlist-candidates?platform=openai", nil)
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "gpt-5.5")
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -24,6 +24,13 @@ type stubAdminService struct {
|
||||
boundAuthIdentity *service.AdminBindAuthIdentityInput
|
||||
boundAuthIdentityFor int64
|
||||
createdAccounts []*service.CreateAccountInput
|
||||
createdGroups []*service.CreateGroupInput
|
||||
updatedGroups []*service.UpdateGroupInput
|
||||
deletedGroupIDs []int64
|
||||
guardedDeletedGroupIDs []int64
|
||||
deleteGroupIfEmptyErr error
|
||||
advancedGroupOperationCalls int
|
||||
lastListGroupsIsExclusive *bool
|
||||
createdProxies []*service.CreateProxyInput
|
||||
updatedProxyIDs []int64
|
||||
updatedProxies []*service.UpdateProxyInput
|
||||
@@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
|
||||
s.lastListGroupsIsExclusive = isExclusive
|
||||
return s.groups, int64(len(s.groups)), nil
|
||||
}
|
||||
|
||||
@@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return []service.CompositeModelRoute{
|
||||
{
|
||||
ID: 1,
|
||||
@@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6
|
||||
}
|
||||
|
||||
func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return &service.CompositeModelRoute{
|
||||
ID: 1,
|
||||
GroupID: groupID,
|
||||
@@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
return &service.CompositeModelRoute{
|
||||
ID: routeID,
|
||||
GroupID: groupID,
|
||||
@@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in
|
||||
}
|
||||
|
||||
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
|
||||
s.createdGroups = append(s.createdGroups, input)
|
||||
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
|
||||
s.advancedGroupOperationCalls++
|
||||
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
|
||||
return &group, nil
|
||||
}
|
||||
@@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64,
|
||||
}
|
||||
|
||||
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
|
||||
s.updatedGroups = append(s.updatedGroups, input)
|
||||
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error {
|
||||
s.deletedGroupIDs = append(s.deletedGroupIDs, id)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error {
|
||||
s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id)
|
||||
return s.deleteGroupIfEmptyErr
|
||||
}
|
||||
|
||||
func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) {
|
||||
return s.apiKeys, int64(len(s.apiKeys)), nil
|
||||
}
|
||||
@@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) (
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er
|
||||
}
|
||||
|
||||
func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error {
|
||||
s.advancedGroupOperationCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
|
||||
return &account, nil
|
||||
}
|
||||
|
||||
func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil }
|
||||
|
||||
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
|
||||
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
|
||||
return &account, nil
|
||||
|
||||
@@ -57,21 +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"`
|
||||
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"`
|
||||
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 {
|
||||
@@ -129,22 +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"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_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 {
|
||||
@@ -258,22 +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,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -350,27 +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,
|
||||
CacheWrite1hPrice: r.CacheWrite1hPrice,
|
||||
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
|
||||
@@ -615,14 +620,15 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
|
||||
}
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"found": true,
|
||||
"input_price": pricing.InputPricePerToken,
|
||||
"output_price": pricing.OutputPricePerToken,
|
||||
"cache_write_price": cacheWritePrice,
|
||||
"cache_write_1h_price": cacheWrite1hPrice,
|
||||
"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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -637,6 +643,7 @@ var platformToLiteLLMProvider = map[string]string{
|
||||
service.PlatformKimi: "moonshot",
|
||||
service.PlatformZhipu: "zhipu",
|
||||
service.PlatformDeepseek: "deepseek",
|
||||
service.PlatformMiniMax: "minimax",
|
||||
}
|
||||
|
||||
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
|
||||
|
||||
@@ -453,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),
|
||||
@@ -469,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)
|
||||
@@ -477,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)
|
||||
@@ -546,7 +549,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
|
||||
svc := service.NewPricingService(nil, nil)
|
||||
router := setupSyncPricingModelsRouter(svc)
|
||||
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
|
||||
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} {
|
||||
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
|
||||
w := httptest.NewRecorder()
|
||||
router.ServeHTTP(w, req)
|
||||
@@ -580,9 +583,10 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
|
||||
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"`
|
||||
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))
|
||||
@@ -590,6 +594,8 @@ func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
|
||||
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) {
|
||||
|
||||
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
|
||||
|
||||
type channelMonitorCreateRequest struct {
|
||||
Name string `json:"name" binding:"required,max=100"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
|
||||
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
|
||||
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
|
||||
|
||||
type channelMonitorUpdateRequest struct {
|
||||
Name *string `json:"name" binding:"omitempty,max=100"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
|
||||
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax"`
|
||||
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
|
||||
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
|
||||
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
@@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
|
||||
// ProvideAccountHandler injects the Grok active prober for production while
|
||||
// keeping NewAccountHandler convenient for focused unit tests.
|
||||
func ProvideAccountHandler(
|
||||
cfg *config.Config,
|
||||
adminService service.AdminService,
|
||||
oauthService *service.OAuthService,
|
||||
openaiOAuthService *service.OpenAIOAuthService,
|
||||
@@ -227,5 +229,6 @@ func ProvideAccountHandler(
|
||||
tokenCacheInvalidator,
|
||||
)
|
||||
handler.grokImportProber = grokQuotaService
|
||||
handler.cfg = cfg
|
||||
return handler
|
||||
}
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const (
|
||||
grokMediaEligibilityModeAuto = "auto"
|
||||
grokMediaEligibilityModeEnabled = "enabled"
|
||||
grokMediaEligibilityModeDisabled = "disabled"
|
||||
)
|
||||
|
||||
type grokMediaEligibilityResponse struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Mode string `json:"mode"`
|
||||
Eligible bool `json:"eligible"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
|
||||
type updateGrokMediaEligibilityRequest struct {
|
||||
Mode string `json:"mode" binding:"required"`
|
||||
}
|
||||
|
||||
// GetGrokMediaEligibility returns the current manual override and evaluated
|
||||
// media-routing decision for a Grok OAuth account.
|
||||
// GET /api/v1/admin/accounts/:id/grok-media-eligibility
|
||||
func (h *AccountHandler) GetGrokMediaEligibility(c *gin.Context) {
|
||||
account, err := h.getGrokOAuthAccount(c)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, buildGrokMediaEligibilityResponse(account))
|
||||
}
|
||||
|
||||
// UpdateGrokMediaEligibility updates only the manual media eligibility
|
||||
// override. It deliberately uses UpdateAccountExtra so unrelated account
|
||||
// extra fields are preserved.
|
||||
// PUT /api/v1/admin/accounts/:id/grok-media-eligibility
|
||||
func (h *AccountHandler) UpdateGrokMediaEligibility(c *gin.Context) {
|
||||
account, err := h.getGrokOAuthAccount(c)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
var req updateGrokMediaEligibilityRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
var value any
|
||||
switch req.Mode {
|
||||
case grokMediaEligibilityModeAuto:
|
||||
value = nil
|
||||
case grokMediaEligibilityModeEnabled:
|
||||
value = true
|
||||
case grokMediaEligibilityModeDisabled:
|
||||
value = false
|
||||
default:
|
||||
response.ErrorFrom(c, infraerrors.BadRequest(
|
||||
"GROK_MEDIA_ELIGIBILITY_INVALID_MODE",
|
||||
"mode must be one of auto, enabled, disabled",
|
||||
))
|
||||
return
|
||||
}
|
||||
|
||||
updates := map[string]any{service.GrokMediaEligibleExtraKey: value}
|
||||
if err := service.ValidateGrokMediaEligibilityExtra(account.Platform, updates); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, updates); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
updated, err := h.adminService.GetAccount(c.Request.Context(), account.ID)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
if err := ensureGrokOAuthAccount(updated); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
response.Success(c, buildGrokMediaEligibilityResponse(updated))
|
||||
}
|
||||
|
||||
func (h *AccountHandler) getGrokOAuthAccount(c *gin.Context) (*service.Account, error) {
|
||||
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
return nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "Invalid account ID")
|
||||
}
|
||||
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := ensureGrokOAuthAccount(account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return account, nil
|
||||
}
|
||||
|
||||
func ensureGrokOAuthAccount(account *service.Account) error {
|
||||
if account == nil {
|
||||
return infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found")
|
||||
}
|
||||
if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth {
|
||||
return infraerrors.BadRequest(
|
||||
"GROK_MEDIA_ELIGIBILITY_UNSUPPORTED_ACCOUNT",
|
||||
"media eligibility is only supported for Grok OAuth accounts",
|
||||
)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func buildGrokMediaEligibilityResponse(account *service.Account) grokMediaEligibilityResponse {
|
||||
eligible, reason := account.GrokMediaGenerationEligibility()
|
||||
return grokMediaEligibilityResponse{
|
||||
AccountID: account.ID,
|
||||
Mode: grokMediaEligibilityModeFromExtra(account.Extra),
|
||||
Eligible: eligible,
|
||||
Reason: reason,
|
||||
}
|
||||
}
|
||||
|
||||
func grokMediaEligibilityModeFromExtra(extra map[string]any) string {
|
||||
if extra != nil {
|
||||
if enabled, ok := extra[service.GrokMediaEligibleExtraKey].(bool); ok {
|
||||
if enabled {
|
||||
return grokMediaEligibilityModeEnabled
|
||||
}
|
||||
return grokMediaEligibilityModeDisabled
|
||||
}
|
||||
}
|
||||
return grokMediaEligibilityModeAuto
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type grokMediaEligibilityAdminServiceStub struct {
|
||||
*stubAdminService
|
||||
account *service.Account
|
||||
}
|
||||
|
||||
func (s *grokMediaEligibilityAdminServiceStub) GetAccount(_ context.Context, _ int64) (*service.Account, error) {
|
||||
return s.account, nil
|
||||
}
|
||||
|
||||
func (s *grokMediaEligibilityAdminServiceStub) UpdateAccountExtra(_ context.Context, _ int64, updates map[string]any) error {
|
||||
if s.account.Extra == nil {
|
||||
s.account.Extra = map[string]any{}
|
||||
}
|
||||
for key, value := range updates {
|
||||
if value == nil {
|
||||
delete(s.account.Extra, key)
|
||||
} else {
|
||||
s.account.Extra[key] = value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func newGrokMediaEligibilityRouter(account *service.Account) *gin.Engine {
|
||||
stub := &grokMediaEligibilityAdminServiceStub{
|
||||
stubAdminService: newStubAdminService(),
|
||||
account: account,
|
||||
}
|
||||
h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router := gin.New()
|
||||
router.GET("/accounts/:id/grok-media-eligibility", h.GetGrokMediaEligibility)
|
||||
router.PUT("/accounts/:id/grok-media-eligibility", h.UpdateGrokMediaEligibility)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestGrokMediaEligibilityHandlerGetReturnsModeAndReason(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := newGrokMediaEligibilityRouter(&service.Account{
|
||||
ID: 12,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Extra: map[string]any{
|
||||
"grok_billing_snapshot": map[string]any{"status_code": 200, "partial": true},
|
||||
"unrelated_setting": "preserve",
|
||||
},
|
||||
})
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/accounts/12/grok-media-eligibility", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var envelope struct {
|
||||
Data grokMediaEligibilityResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
|
||||
require.Equal(t, int64(12), envelope.Data.AccountID)
|
||||
require.Equal(t, grokMediaEligibilityModeAuto, envelope.Data.Mode)
|
||||
require.Equal(t, "billing_inconclusive", envelope.Data.Reason)
|
||||
}
|
||||
|
||||
func TestGrokMediaEligibilityHandlerUpdateModesPreservesExtra(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
account := &service.Account{
|
||||
ID: 12,
|
||||
Platform: service.PlatformGrok,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Extra: map[string]any{"unrelated_setting": "preserve"},
|
||||
}
|
||||
router := newGrokMediaEligibilityRouter(account)
|
||||
|
||||
tests := []struct {
|
||||
mode string
|
||||
wantMode string
|
||||
wantExtra any
|
||||
wantEligible bool
|
||||
wantReason string
|
||||
}{
|
||||
{mode: "enabled", wantMode: grokMediaEligibilityModeEnabled, wantExtra: true, wantEligible: true, wantReason: "override_enabled"},
|
||||
{mode: "disabled", wantMode: grokMediaEligibilityModeDisabled, wantExtra: false, wantEligible: false, wantReason: "override_disabled"},
|
||||
{mode: "auto", wantMode: grokMediaEligibilityModeAuto, wantExtra: nil, wantEligible: false, wantReason: "billing_unobserved"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.mode, func(t *testing.T) {
|
||||
body, err := json.Marshal(map[string]string{"mode": tt.mode})
|
||||
require.NoError(t, err)
|
||||
req := httptest.NewRequest(http.MethodPut, "/accounts/12/grok-media-eligibility", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var envelope struct {
|
||||
Data grokMediaEligibilityResponse `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
|
||||
require.Equal(t, tt.wantMode, envelope.Data.Mode)
|
||||
require.Equal(t, tt.wantEligible, envelope.Data.Eligible)
|
||||
require.Equal(t, tt.wantReason, envelope.Data.Reason)
|
||||
require.Equal(t, "preserve", account.Extra["unrelated_setting"])
|
||||
if tt.wantExtra == nil {
|
||||
_, exists := account.Extra[service.GrokMediaEligibleExtraKey]
|
||||
require.False(t, exists)
|
||||
} else {
|
||||
require.Equal(t, tt.wantExtra, account.Extra[service.GrokMediaEligibleExtraKey])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokMediaEligibilityHandlerRejectsUnsupportedAndInvalidModes(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
account *service.Account
|
||||
body string
|
||||
}{
|
||||
{name: "unsupported account", account: &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, body: `{"mode":"enabled"}`},
|
||||
{name: "invalid mode", account: &service.Account{ID: 1, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}, body: `{"mode":"force"}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
router := newGrokMediaEligibilityRouter(tt.account)
|
||||
req := httptest.NewRequest(http.MethodPut, "/accounts/1/grok-media-eligibility", bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
rec := httptest.NewRecorder()
|
||||
router.ServeHTTP(rec, req)
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
|
||||
@@ -24,10 +25,14 @@ type GroupHandler struct {
|
||||
adminService service.AdminService
|
||||
dashboardService *service.DashboardService
|
||||
groupCapacityService *service.GroupCapacityService
|
||||
cfg *config.Config
|
||||
}
|
||||
|
||||
// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。
|
||||
func (h *GroupHandler) GetLiveCapability(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") {
|
||||
return
|
||||
}
|
||||
err := liveattestation.NewProvider().Check(c.Request.Context())
|
||||
result := gin.H{"supported": err == nil}
|
||||
if err != nil {
|
||||
@@ -87,18 +92,99 @@ func (f optionalLimitField) ToServiceInput() *float64 {
|
||||
|
||||
// NewGroupHandler creates a new admin group handler
|
||||
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
|
||||
return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil)
|
||||
}
|
||||
|
||||
func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler {
|
||||
return &GroupHandler{
|
||||
adminService: adminService,
|
||||
dashboardService: dashboardService,
|
||||
groupCapacityService: groupCapacityService,
|
||||
cfg: cfg,
|
||||
}
|
||||
}
|
||||
|
||||
func (h *GroupHandler) isSimpleMode() bool {
|
||||
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
|
||||
}
|
||||
|
||||
type simpleModeGroupOperation string
|
||||
|
||||
const (
|
||||
simpleModeGroupList simpleModeGroupOperation = "list"
|
||||
simpleModeGroupGetAll simpleModeGroupOperation = "get_all"
|
||||
simpleModeGroupGet simpleModeGroupOperation = "get"
|
||||
simpleModeGroupCreate simpleModeGroupOperation = "create"
|
||||
simpleModeGroupUpdate simpleModeGroupOperation = "update"
|
||||
simpleModeGroupDelete simpleModeGroupOperation = "delete"
|
||||
)
|
||||
|
||||
var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{
|
||||
simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {},
|
||||
simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {},
|
||||
}
|
||||
|
||||
func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool {
|
||||
if _, allowed := simpleModeGroupOperations[operation]; allowed {
|
||||
return false
|
||||
}
|
||||
if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type simpleModeGroupResponse struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform"`
|
||||
Status string `json:"status"`
|
||||
|
||||
AccountCount int64 `json:"account_count,omitempty"`
|
||||
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
|
||||
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
|
||||
if group == nil {
|
||||
return nil
|
||||
}
|
||||
return &simpleModeGroupResponse{
|
||||
ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
|
||||
Status: group.Status,
|
||||
AccountCount: group.AccountCount,
|
||||
ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
|
||||
SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
|
||||
allowed.RateMultiplier = 1
|
||||
allowed.SubscriptionType = service.SubscriptionTypeStandard
|
||||
*req = allowed
|
||||
}
|
||||
|
||||
func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
|
||||
if req == nil {
|
||||
return
|
||||
}
|
||||
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
|
||||
}
|
||||
|
||||
// CreateGroupRequest represents create group request
|
||||
type CreateGroupRequest struct {
|
||||
Name string `json:"name" binding:"required"`
|
||||
Description string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
IsExclusive bool `json:"is_exclusive"`
|
||||
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
|
||||
@@ -153,14 +239,16 @@ type CreateGroupRequest struct {
|
||||
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"`
|
||||
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
|
||||
// 固定账号 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"`
|
||||
// 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
// Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
// 从指定分组复制账号(创建后自动绑定)
|
||||
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
|
||||
@@ -170,7 +258,7 @@ type CreateGroupRequest struct {
|
||||
type UpdateGroupRequest struct {
|
||||
Name string `json:"name"`
|
||||
Description *string `json:"description"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
|
||||
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax composite"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
IsExclusive *bool `json:"is_exclusive"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
@@ -226,10 +314,12 @@ type UpdateGroupRequest struct {
|
||||
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"`
|
||||
ModelAllowlist *service.GroupModelAllowlist `json:"model_allowlist"`
|
||||
// 固定账号 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"`
|
||||
@@ -242,7 +332,7 @@ type UpdateGroupRequest struct {
|
||||
type CompositeRouteRequest struct {
|
||||
PublicModel string `json:"public_model" binding:"required"`
|
||||
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
|
||||
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax"`
|
||||
UpstreamModel string `json:"upstream_model"`
|
||||
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
|
||||
Priority int `json:"priority"`
|
||||
@@ -258,6 +348,9 @@ type CompositeRoutePreviewRequest struct {
|
||||
// List handles listing all groups with pagination
|
||||
// GET /api/v1/admin/groups
|
||||
func (h *GroupHandler) List(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) {
|
||||
return
|
||||
}
|
||||
page, pageSize := response.ParsePagination(c)
|
||||
platform := c.Query("platform")
|
||||
status := c.Query("status")
|
||||
@@ -272,7 +365,7 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
sortOrder := c.DefaultQuery("sort_order", "asc")
|
||||
|
||||
var isExclusive *bool
|
||||
if isExclusiveStr != "" {
|
||||
if !h.isSimpleMode() && isExclusiveStr != "" {
|
||||
val := isExclusiveStr == "true"
|
||||
isExclusive = &val
|
||||
}
|
||||
@@ -283,6 +376,16 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
|
||||
for i := range groups {
|
||||
if service.IsGroupBindableInSimpleMode(&groups[i]) {
|
||||
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
|
||||
}
|
||||
}
|
||||
response.Paginated(c, simpleGroups, total, page, pageSize)
|
||||
return
|
||||
}
|
||||
outGroups := make([]dto.AdminGroup, 0, len(groups))
|
||||
for i := range groups {
|
||||
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
|
||||
@@ -293,6 +396,9 @@ func (h *GroupHandler) List(c *gin.Context) {
|
||||
// ListCompositeRoutes handles listing composite model routes for one group.
|
||||
// GET /api/v1/admin/groups/:id/composite-routes
|
||||
func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -308,6 +414,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
|
||||
// CreateCompositeRoute handles creating one composite model route.
|
||||
// POST /api/v1/admin/groups/:id/composite-routes
|
||||
func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -328,6 +437,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
|
||||
// UpdateCompositeRoute handles replacing one composite model route.
|
||||
// PUT /api/v1/admin/groups/:id/composite-routes/:route_id
|
||||
func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -352,6 +464,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
|
||||
// DeleteCompositeRoute handles deleting one composite model route.
|
||||
// DELETE /api/v1/admin/groups/:id/composite-routes/:route_id
|
||||
func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -370,6 +485,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
|
||||
// PreviewCompositeRoute resolves a model without mutating routes.
|
||||
// POST /api/v1/admin/groups/:id/composite-routes/preview
|
||||
func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, ok := parsePositiveIDParam(c, "id")
|
||||
if !ok {
|
||||
return
|
||||
@@ -423,6 +541,9 @@ func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) {
|
||||
// bound to them even after the group is disabled).
|
||||
// GET /api/v1/admin/groups/all
|
||||
func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) {
|
||||
return
|
||||
}
|
||||
platform := c.Query("platform")
|
||||
includeInactive := c.Query("include_inactive") == "true"
|
||||
|
||||
@@ -442,6 +563,16 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
|
||||
for i := range groups {
|
||||
if service.IsGroupBindableInSimpleMode(&groups[i]) {
|
||||
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
|
||||
}
|
||||
}
|
||||
response.Success(c, simpleGroups)
|
||||
return
|
||||
}
|
||||
outGroups := make([]dto.AdminGroup, 0, len(groups))
|
||||
for i := range groups {
|
||||
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
|
||||
@@ -452,6 +583,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
|
||||
// GetByID handles getting a group by ID
|
||||
// GET /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) GetByID(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -464,12 +598,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// GetModelsListCandidates handles getting candidate model IDs for custom /v1/models list.
|
||||
// GET /api/v1/admin/groups/:id/models-list-candidates
|
||||
func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
|
||||
// GetGroupModelAllowlistCandidates handles getting candidate model IDs for the group model allowlist.
|
||||
// GET /api/v1/admin/groups/:id/model-allowlist-candidates
|
||||
func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || groupID < 0 {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -492,11 +633,21 @@ func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
|
||||
// Create handles creating a new group
|
||||
// POST /api/v1/admin/groups
|
||||
func (h *GroupHandler) Create(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) {
|
||||
return
|
||||
}
|
||||
var req CreateGroupRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() && req.Platform == service.PlatformComposite {
|
||||
response.BadRequest(c, "Platform is not supported in simple mode")
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() {
|
||||
sanitizeCreateGroupRequestForSimpleMode(&req)
|
||||
}
|
||||
|
||||
if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
@@ -564,7 +715,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
ModelAllowlist: req.ModelAllowlist,
|
||||
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
@@ -576,12 +728,19 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// Duplicate handles creating an inactive group copy with the source account bindings.
|
||||
// POST /api/v1/admin/groups/:id/duplicate
|
||||
func (h *GroupHandler) Duplicate(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil || groupID <= 0 {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -629,6 +788,9 @@ func (h *GroupHandler) Duplicate(c *gin.Context) {
|
||||
// Update handles updating a group
|
||||
// PUT /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) Update(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -640,6 +802,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
if h.isSimpleMode() {
|
||||
sanitizeUpdateGroupRequestForSimpleMode(&req)
|
||||
}
|
||||
|
||||
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
|
||||
Name: req.Name,
|
||||
@@ -696,7 +861,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
RequirePrivacySet: req.RequirePrivacySet,
|
||||
DefaultMappedModel: req.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: req.ModelsListConfig,
|
||||
ModelAllowlist: req.ModelAllowlist,
|
||||
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
|
||||
RPMLimit: req.RPMLimit,
|
||||
MaxReasoningEffort: req.MaxReasoningEffort,
|
||||
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
|
||||
@@ -708,19 +874,30 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
if h.isSimpleMode() {
|
||||
response.Success(c, groupForSimpleMode(group))
|
||||
return
|
||||
}
|
||||
response.Success(c, dto.GroupFromServiceAdmin(group))
|
||||
}
|
||||
|
||||
// Delete handles deleting a group
|
||||
// DELETE /api/v1/admin/groups/:id
|
||||
func (h *GroupHandler) Delete(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
return
|
||||
}
|
||||
|
||||
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
|
||||
if h.isSimpleMode() {
|
||||
err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID)
|
||||
} else {
|
||||
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
|
||||
}
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
@@ -732,6 +909,9 @@ func (h *GroupHandler) Delete(c *gin.Context) {
|
||||
// GetStats handles getting group statistics
|
||||
// GET /api/v1/admin/groups/:id/stats
|
||||
func (h *GroupHandler) GetStats(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "stats") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -751,6 +931,9 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
|
||||
// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
|
||||
// GET /api/v1/admin/groups/usage-summary
|
||||
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
todayStart := service.GroupUsageTodayStart(time.Now())
|
||||
|
||||
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
|
||||
@@ -765,6 +948,9 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
|
||||
// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
|
||||
// GET /api/v1/admin/groups/capacity-summary
|
||||
func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
|
||||
if err != nil {
|
||||
response.Error(c, 500, "Failed to get group capacity summary")
|
||||
@@ -776,6 +962,9 @@ func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
|
||||
// GetGroupAPIKeys handles getting API keys in a group
|
||||
// GET /api/v1/admin/groups/:id/api-keys
|
||||
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -800,6 +989,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
|
||||
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
|
||||
// GET /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -821,6 +1013,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
|
||||
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
|
||||
// DELETE /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -843,6 +1038,9 @@ type BatchSetGroupRateMultipliersRequest struct {
|
||||
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
|
||||
// PUT /api/v1/admin/groups/:id/rate-multipliers
|
||||
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -871,6 +1069,9 @@ type BatchSetGroupRPMOverridesRequest struct {
|
||||
// BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group
|
||||
// PUT /api/v1/admin/groups/:id/rpm-overrides
|
||||
func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -894,6 +1095,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
|
||||
// ClearGroupRPMOverrides handles clearing all rpm_override for a group
|
||||
// DELETE /api/v1/admin/groups/:id/rpm-overrides
|
||||
func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
|
||||
if err != nil {
|
||||
response.BadRequest(c, "Invalid group ID")
|
||||
@@ -919,6 +1123,9 @@ type UpdateSortOrderRequest struct {
|
||||
// UpdateSortOrder handles updating group sort orders
|
||||
// PUT /api/v1/admin/groups/sort-order
|
||||
func (h *GroupHandler) UpdateSortOrder(c *gin.Context) {
|
||||
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
|
||||
return
|
||||
}
|
||||
var req UpdateSortOrderRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
|
||||
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
|
||||
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
|
||||
allowed := []string{
|
||||
"anthropic", "openai", "gemini", "antigravity", "grok",
|
||||
"kimi", "zhipu", "deepseek", "composite",
|
||||
"kimi", "zhipu", "deepseek", "minimax", "composite",
|
||||
}
|
||||
for _, platform := range allowed {
|
||||
t.Run("create_"+platform, func(t *testing.T) {
|
||||
@@ -72,7 +72,7 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
|
||||
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax"} {
|
||||
var req CompositeRouteRequest
|
||||
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
|
||||
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
|
||||
|
||||
@@ -0,0 +1,309 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine {
|
||||
h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple})
|
||||
r := gin.New()
|
||||
r.GET("/groups", h.List)
|
||||
r.GET("/groups/all", h.GetAll)
|
||||
r.GET("/groups/live-capability", h.GetLiveCapability)
|
||||
r.GET("/groups/usage-summary", h.GetUsageSummary)
|
||||
r.GET("/groups/capacity-summary", h.GetCapacitySummary)
|
||||
r.PUT("/groups/sort-order", h.UpdateSortOrder)
|
||||
r.GET("/groups/:id", h.GetByID)
|
||||
r.GET("/groups/:id/stats", h.GetStats)
|
||||
r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys)
|
||||
r.GET("/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates)
|
||||
r.POST("/groups", h.Create)
|
||||
r.PUT("/groups/:id", h.Update)
|
||||
r.POST("/groups/:id/duplicate", h.Duplicate)
|
||||
r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes)
|
||||
r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute)
|
||||
r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute)
|
||||
r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute)
|
||||
r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute)
|
||||
r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers)
|
||||
r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers)
|
||||
r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers)
|
||||
r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides)
|
||||
r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides)
|
||||
r.DELETE("/groups/:id", h.Delete)
|
||||
return r
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
|
||||
create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}`
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Len(t, svc.createdGroups, 1)
|
||||
created := svc.createdGroups[0]
|
||||
require.Equal(t, "basic grouping", created.Description)
|
||||
require.Equal(t, 1.0, created.RateMultiplier)
|
||||
require.False(t, created.IsExclusive)
|
||||
require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType)
|
||||
require.Nil(t, created.DailyLimitUSD)
|
||||
require.False(t, created.AllowImageGeneration)
|
||||
require.False(t, created.LongContextPricingEnabled)
|
||||
require.Empty(t, created.ModelPricing)
|
||||
require.False(t, created.AllowBatchImageGeneration)
|
||||
require.Nil(t, created.VideoPrice720P)
|
||||
require.Nil(t, created.WebSearchPricePerCall)
|
||||
require.Nil(t, created.AudioRealtimePricePerMin)
|
||||
require.Zero(t, created.RPMLimit)
|
||||
|
||||
update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}`
|
||||
req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res = httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Len(t, svc.updatedGroups, 1)
|
||||
updated := svc.updatedGroups[0]
|
||||
require.NotNil(t, updated.Description)
|
||||
require.Equal(t, "still basic", *updated.Description)
|
||||
require.Nil(t, updated.RateMultiplier)
|
||||
require.Nil(t, updated.IsExclusive)
|
||||
require.Empty(t, updated.SubscriptionType)
|
||||
require.Nil(t, updated.DailyLimitUSD)
|
||||
require.Nil(t, updated.AllowImageGeneration)
|
||||
require.Nil(t, updated.LongContextPricingEnabled)
|
||||
require.Nil(t, updated.ModelPricing)
|
||||
require.Nil(t, updated.AllowBatchImageGeneration)
|
||||
require.Nil(t, updated.VideoPrice720P)
|
||||
require.Nil(t, updated.WebSearchPricePerCall)
|
||||
require.Nil(t, updated.AudioRealtimePricePerMin)
|
||||
require.Empty(t, updated.Status)
|
||||
require.Nil(t, updated.RPMLimit)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusBadRequest, res.Code)
|
||||
require.Empty(t, svc.createdGroups)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil))
|
||||
|
||||
require.Equal(t, http.StatusConflict, res.Code)
|
||||
require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs)
|
||||
require.Empty(t, svc.deletedGroupIDs)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil))
|
||||
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs)
|
||||
require.Empty(t, svc.deletedGroupIDs)
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Nil(t, svc.lastListGroupsIsExclusive)
|
||||
}
|
||||
|
||||
func float64PtrForSimpleModeTest(value float64) *float64 { return &value }
|
||||
|
||||
func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{
|
||||
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic,
|
||||
AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1,
|
||||
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
|
||||
LongContextPricingEnabled: true,
|
||||
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
|
||||
AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2),
|
||||
WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4),
|
||||
ModelRouting: map[string][]int64{"claude": {2}},
|
||||
}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Items []map[string]any `json:"items"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
require.Len(t, payload.Data.Items, 1)
|
||||
item := payload.Data.Items[0]
|
||||
require.Equal(t, "basic", item["name"])
|
||||
require.Equal(t, "allowed", item["description"])
|
||||
require.Equal(t, "anthropic", item["platform"])
|
||||
require.Equal(t, float64(3), item["account_count"])
|
||||
require.ElementsMatch(t, []string{
|
||||
"id", "name", "description", "platform", "status", "account_count",
|
||||
"active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at",
|
||||
}, mapKeys(item))
|
||||
for _, forbidden := range []string{
|
||||
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
|
||||
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
|
||||
"audio_realtime_price_per_min", "model_routing",
|
||||
} {
|
||||
_, exposed := item[forbidden]
|
||||
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
for _, path := range []string{"/groups", "/groups/all"} {
|
||||
res := httptest.NewRecorder()
|
||||
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil))
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
require.Contains(t, res.Body.String(), `"name":"basic"`)
|
||||
require.NotContains(t, res.Body.String(), `"name":"composite"`)
|
||||
}
|
||||
}
|
||||
|
||||
func mapKeys(values map[string]any) []string {
|
||||
keys := make([]string, 0, len(values))
|
||||
for key := range values {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
svc := newStubAdminService()
|
||||
svc.groups = []service.Group{{
|
||||
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3,
|
||||
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
|
||||
LongContextPricingEnabled: true,
|
||||
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
|
||||
}}
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
|
||||
assertNoAdvancedFields := func(t *testing.T, item map[string]any) {
|
||||
t.Helper()
|
||||
for _, forbidden := range []string{
|
||||
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
|
||||
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
|
||||
"audio_realtime_price_per_min", "model_routing",
|
||||
} {
|
||||
_, exposed := item[forbidden]
|
||||
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
|
||||
}
|
||||
}
|
||||
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
list bool
|
||||
}{
|
||||
{name: "get all", method: http.MethodGet, path: "/groups/all", list: true},
|
||||
{name: "get by id", method: http.MethodGet, path: "/groups/1"},
|
||||
{name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`},
|
||||
{name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusOK, res.Code)
|
||||
|
||||
var payload struct {
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
|
||||
if tt.list {
|
||||
var items []map[string]any
|
||||
require.NoError(t, json.Unmarshal(payload.Data, &items))
|
||||
require.Len(t, items, 1)
|
||||
assertNoAdvancedFields(t, items[0])
|
||||
return
|
||||
}
|
||||
var item map[string]any
|
||||
require.NoError(t, json.Unmarshal(payload.Data, &item))
|
||||
assertNoAdvancedFields(t, item)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
tests := []struct {
|
||||
method string
|
||||
path string
|
||||
body string
|
||||
}{
|
||||
{http.MethodPost, "/groups/1/duplicate", ""},
|
||||
{http.MethodGet, "/groups/1/model-allowlist-candidates", ""},
|
||||
{http.MethodGet, "/groups/1/composite-routes", ""},
|
||||
{http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`},
|
||||
{http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`},
|
||||
{http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`},
|
||||
{http.MethodDelete, "/groups/1/composite-routes/2", ""},
|
||||
{http.MethodGet, "/groups/1/rate-multipliers", ""},
|
||||
{http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`},
|
||||
{http.MethodDelete, "/groups/1/rate-multipliers", ""},
|
||||
{http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`},
|
||||
{http.MethodDelete, "/groups/1/rpm-overrides", ""},
|
||||
{http.MethodGet, "/groups/1/stats", ""},
|
||||
{http.MethodGet, "/groups/1/api-keys", ""},
|
||||
{http.MethodGet, "/groups/live-capability", ""},
|
||||
{http.MethodGet, "/groups/usage-summary", ""},
|
||||
{http.MethodGet, "/groups/capacity-summary", ""},
|
||||
{http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||
svc := newStubAdminService()
|
||||
r := newSimpleModeGroupRouter(svc)
|
||||
res := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
r.ServeHTTP(res, req)
|
||||
require.Equal(t, http.StatusForbidden, res.Code)
|
||||
require.Zero(t, svc.advancedGroupOperationCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -172,9 +172,11 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
|
||||
updateInput := &service.UpdateProxyInput{
|
||||
Status: normalizedStatus,
|
||||
ExpiresAt: existingExpiresAt,
|
||||
ClearExpiresAt: existingExpiresAt == nil,
|
||||
FallbackMode: existingFallbackMode,
|
||||
BackupProxyID: existingBackupProxyID,
|
||||
ExpiryWarnDays: item.ExpiryWarnDays,
|
||||
ClearBackupID: existingBackupProxyID == nil,
|
||||
ExpiryWarnDays: &item.ExpiryWarnDays,
|
||||
// 保留已存在代理的网络配置字段
|
||||
Name: existing.Name,
|
||||
Protocol: existing.Protocol,
|
||||
@@ -255,9 +257,11 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
|
||||
if _, err := h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
|
||||
Status: normalizedStatus,
|
||||
ExpiresAt: expiresAt,
|
||||
ClearExpiresAt: expiresAt == nil,
|
||||
FallbackMode: fallbackMode,
|
||||
BackupProxyID: backupProxyID,
|
||||
ExpiryWarnDays: item.ExpiryWarnDays,
|
||||
ClearBackupID: backupProxyID == nil,
|
||||
ExpiryWarnDays: &item.ExpiryWarnDays,
|
||||
Name: created.Name,
|
||||
Protocol: created.Protocol,
|
||||
Host: created.Host,
|
||||
|
||||
@@ -41,17 +41,17 @@ type CreateProxyRequest struct {
|
||||
|
||||
// UpdateProxyRequest represents update proxy request
|
||||
type UpdateProxyRequest struct {
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
|
||||
BackupProxyID *int64 `json:"backup_proxy_id"`
|
||||
ExpiryWarnDays int `json:"expiry_warn_days" binding:"omitempty,min=0"`
|
||||
Name string `json:"name"`
|
||||
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
|
||||
Username string `json:"username"`
|
||||
Password string `json:"password"`
|
||||
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
|
||||
ExpiresAt dto.NullableInt64Field `json:"expires_at"`
|
||||
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
|
||||
BackupProxyID dto.NullableInt64Field `json:"backup_proxy_id"`
|
||||
ExpiryWarnDays *int `json:"expiry_warn_days" binding:"omitempty,min=0"`
|
||||
}
|
||||
|
||||
// List handles listing all proxies with pagination
|
||||
@@ -183,8 +183,8 @@ func (h *ProxyHandler) Update(c *gin.Context) {
|
||||
}
|
||||
|
||||
var expiresAt *time.Time
|
||||
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
|
||||
t := time.Unix(*req.ExpiresAt, 0).UTC()
|
||||
if req.ExpiresAt.Value != nil && *req.ExpiresAt.Value > 0 {
|
||||
t := time.Unix(*req.ExpiresAt.Value, 0).UTC()
|
||||
expiresAt = &t
|
||||
}
|
||||
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
|
||||
@@ -196,8 +196,10 @@ func (h *ProxyHandler) Update(c *gin.Context) {
|
||||
Password: strings.TrimSpace(req.Password),
|
||||
Status: strings.TrimSpace(req.Status),
|
||||
ExpiresAt: expiresAt,
|
||||
ClearExpiresAt: req.ExpiresAt.Set && expiresAt == nil,
|
||||
FallbackMode: strings.TrimSpace(req.FallbackMode),
|
||||
BackupProxyID: req.BackupProxyID,
|
||||
BackupProxyID: req.BackupProxyID.Value,
|
||||
ClearBackupID: req.BackupProxyID.Set && req.BackupProxyID.Value == nil,
|
||||
ExpiryWarnDays: req.ExpiryWarnDays,
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build unit
|
||||
|
||||
package admin
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type proxyPartialUpdateService struct {
|
||||
service.AdminService
|
||||
input *service.UpdateProxyInput
|
||||
}
|
||||
|
||||
func (s *proxyPartialUpdateService) UpdateProxy(_ context.Context, _ int64, input *service.UpdateProxyInput) (*service.Proxy, error) {
|
||||
s.input = input
|
||||
return &service.Proxy{ID: 9}, nil
|
||||
}
|
||||
|
||||
func TestProxyHandlerUpdatePreservesFieldPresence(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
for _, tc := range []struct {
|
||||
name, body string
|
||||
set, clear, warn bool
|
||||
}{
|
||||
{name: "omitted", body: `{"status":"inactive"}`},
|
||||
{name: "null clears", body: `{"expires_at":null,"backup_proxy_id":null,"fallback_mode":"none","expiry_warn_days":0}`, set: true, clear: true, warn: true},
|
||||
{name: "zero clears expiry", body: `{"expires_at":0,"backup_proxy_id":null}`, set: true, clear: true},
|
||||
{name: "values", body: `{"expires_at":1800000000,"backup_proxy_id":10,"fallback_mode":"proxy","expiry_warn_days":7}`, set: true, warn: true},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
svc := &proxyPartialUpdateService{}
|
||||
router := gin.New()
|
||||
router.PUT("/proxies/:id", NewProxyHandler(svc).Update)
|
||||
w := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(tc.body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(w, req)
|
||||
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
|
||||
require.NotNil(t, svc.input)
|
||||
require.Equal(t, tc.set, svc.input.ExpiresAt != nil || svc.input.ClearExpiresAt)
|
||||
require.Equal(t, tc.set, svc.input.BackupProxyID != nil || svc.input.ClearBackupID)
|
||||
require.Equal(t, tc.warn, svc.input.ExpiryWarnDays != nil)
|
||||
if !tc.set || tc.clear {
|
||||
require.Nil(t, svc.input.ExpiresAt)
|
||||
require.Nil(t, svc.input.BackupProxyID)
|
||||
} else {
|
||||
require.Equal(t, int64(1800000000), svc.input.ExpiresAt.Unix())
|
||||
require.Equal(t, int64(10), *svc.input.BackupProxyID)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -228,7 +228,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
|
||||
return nil, createErr
|
||||
}
|
||||
|
||||
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
|
||||
redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code)
|
||||
if redeemErr != nil {
|
||||
return nil, redeemErr
|
||||
}
|
||||
@@ -246,7 +246,7 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
|
||||
return nil, service.ErrRedeemCodeExpired
|
||||
}
|
||||
if existing.CanUse() {
|
||||
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
|
||||
redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code)
|
||||
if err == nil {
|
||||
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
|
||||
}
|
||||
|
||||
@@ -376,6 +376,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
|
||||
|
||||
GrokDefaultTextModel: settings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
|
||||
|
||||
@@ -334,6 +334,7 @@ type UpdateSettingsRequest struct {
|
||||
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking *bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
// Grok model mapping policy
|
||||
GrokDefaultTextModel *string `json:"grok_default_text_model"`
|
||||
@@ -1906,6 +1907,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
}
|
||||
return previousSettings.ChannelMonitorShowQuota
|
||||
}(),
|
||||
ChannelMonitorHideUserRanking: func() bool {
|
||||
if req.ChannelMonitorHideUserRanking != nil {
|
||||
return *req.ChannelMonitorHideUserRanking
|
||||
}
|
||||
return previousSettings.ChannelMonitorHideUserRanking
|
||||
}(),
|
||||
GrokDefaultTextModel: func() string {
|
||||
if req.GrokDefaultTextModel != nil {
|
||||
return *req.GrokDefaultTextModel
|
||||
@@ -2366,6 +2373,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
|
||||
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
|
||||
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
|
||||
ChannelMonitorHideUserRanking: updatedSettings.ChannelMonitorHideUserRanking,
|
||||
|
||||
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
|
||||
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
|
||||
|
||||
@@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
|
||||
AvatarURL: "https://cdn.example.com/linuxdo.png",
|
||||
AvatarSource: "remote_url",
|
||||
},
|
||||
identities: []service.UserAuthIdentityRecord{
|
||||
{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo",
|
||||
ProviderSubject: "linuxdo-subject-31",
|
||||
VerifiedAt: &verifiedAt,
|
||||
Metadata: map[string]any{
|
||||
"username": "linuxdo-handle",
|
||||
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||
},
|
||||
identities: []service.UserAuthIdentityRecord{
|
||||
{
|
||||
ProviderType: "linuxdo",
|
||||
ProviderKey: "linuxdo",
|
||||
ProviderSubject: "linuxdo-subject-31",
|
||||
VerifiedAt: &verifiedAt,
|
||||
Metadata: map[string]any{
|
||||
"username": "linuxdo-handle",
|
||||
"avatar_url": "https://cdn.example.com/linuxdo.png",
|
||||
},
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
handler := &AuthHandler{
|
||||
userService: service.NewUserService(repo, nil, nil, nil),
|
||||
|
||||
@@ -66,29 +66,34 @@ 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"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
|
||||
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
|
||||
InputMultiplier *float64 `json:"input_multiplier"`
|
||||
OutputMultiplier *float64 `json:"output_multiplier"`
|
||||
CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
|
||||
CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
|
||||
PerRequestPrice *float64 `json:"per_request_price"`
|
||||
}
|
||||
|
||||
// userSupportedModel 用户可见的支持模型条目。
|
||||
@@ -294,15 +299,19 @@ 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,
|
||||
CacheWrite1hPrice: iv.CacheWrite1hPrice,
|
||||
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,
|
||||
InputMultiplier: iv.InputMultiplier,
|
||||
OutputMultiplier: iv.OutputMultiplier,
|
||||
CacheWriteMultiplier: iv.CacheWriteMultiplier,
|
||||
CacheReadMultiplier: iv.CacheReadMultiplier,
|
||||
PerRequestPrice: iv.PerRequestPrice,
|
||||
})
|
||||
}
|
||||
return intervals
|
||||
@@ -323,15 +332,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
|
||||
billingMode = string(service.BillingModeToken)
|
||||
}
|
||||
return &userSupportedModelPricing{
|
||||
BillingMode: billingMode,
|
||||
InputPrice: p.InputPrice,
|
||||
OutputPrice: p.OutputPrice,
|
||||
CacheWritePrice: p.CacheWritePrice,
|
||||
CacheWrite1hPrice: p.CacheWrite1hPrice,
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
||||
}
|
||||
|
||||
// pricing interval 白名单:不应暴露 id / sort_order。
|
||||
inputMultiplier := 2.0
|
||||
outputMultiplier := 1.5
|
||||
cacheWriteMultiplier := 2.0
|
||||
cacheReadMultiplier := 2.0
|
||||
pricing := toUserPricing(&service.ChannelModelPricing{
|
||||
BillingMode: service.BillingModeToken,
|
||||
Intervals: []service.PricingInterval{
|
||||
{ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3},
|
||||
{
|
||||
ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3,
|
||||
InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier,
|
||||
CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier,
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NotNil(t, pricing)
|
||||
@@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
|
||||
_, exists := ivDecoded[key]
|
||||
require.Falsef(t, exists, "user pricing interval must not expose %q", key)
|
||||
}
|
||||
for key, want := range map[string]float64{
|
||||
"input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier,
|
||||
"cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier,
|
||||
} {
|
||||
got, exists := ivDecoded[key]
|
||||
require.Truef(t, exists, "user pricing interval must expose %q", key)
|
||||
require.InDelta(t, want, got.(float64), 1e-12)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
|
||||
|
||||
@@ -139,9 +139,28 @@ func (h *BatchImageHandler) Models(c *gin.Context) {
|
||||
batchImageError(c, err)
|
||||
return
|
||||
}
|
||||
// 分组级模型白名单:ListModels 已按分组账号枚举,这里再按白名单过滤一次。
|
||||
if apiKey, ok := middleware.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil {
|
||||
got.Data = filterBatchImageModelsByAllowlist(got.Data, apiKey.Group.ModelAllowlist)
|
||||
}
|
||||
c.JSON(http.StatusOK, got)
|
||||
}
|
||||
|
||||
// filterBatchImageModelsByAllowlist 按分组模型白名单过滤批量生图模型列表。
|
||||
// 白名单未开启时原样返回。
|
||||
func filterBatchImageModelsByAllowlist(models []service.BatchImagePublicModel, allowlist service.GroupModelAllowlist) []service.BatchImagePublicModel {
|
||||
if !allowlist.Enabled {
|
||||
return models
|
||||
}
|
||||
filtered := make([]service.BatchImagePublicModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if allowlist.Allows(model.ID) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func (h *BatchImageHandler) Items(c *gin.Context) {
|
||||
owner, ok := batchImageOwnerFromContext(c)
|
||||
if !ok {
|
||||
|
||||
@@ -64,13 +64,45 @@ func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKe
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, "", false
|
||||
}
|
||||
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
|
||||
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 {
|
||||
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
|
||||
if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI {
|
||||
return "", nil, "", false
|
||||
}
|
||||
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, apiKey.Group.MaxReasoningEffortOverLimit, 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 anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
|
||||
if apiKey == nil || apiKey.Group == nil {
|
||||
return "", nil, "", false
|
||||
}
|
||||
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) {
|
||||
@@ -118,6 +150,14 @@ func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write fu
|
||||
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
|
||||
|
||||
@@ -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) {
|
||||
@@ -34,6 +35,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
|
||||
{model: "k3", platform: service.PlatformKimi},
|
||||
{model: "glm-5.2", platform: service.PlatformZhipu},
|
||||
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
|
||||
{model: "MiniMax-M3", platform: service.PlatformMiniMax},
|
||||
}
|
||||
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
|
||||
for _, provider := range providers {
|
||||
@@ -55,7 +57,7 @@ func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testi
|
||||
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
|
||||
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
|
||||
for _, platform := range []string{
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
|
||||
service.PlatformAnthropic, service.PlatformGemini,
|
||||
} {
|
||||
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
|
||||
@@ -151,6 +153,23 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
var overLimit *service.ReasoningEffortOverLimitError
|
||||
require.ErrorAs(t, err, &overLimit)
|
||||
|
||||
mappingDenyGroup := *group
|
||||
mappingDenyGroup.MaxReasoningEffort = ""
|
||||
mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{
|
||||
{From: "max", To: service.ReasoningEffortMappingDeny},
|
||||
}
|
||||
mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup}
|
||||
mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
|
||||
mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI))
|
||||
_, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body)
|
||||
require.Error(t, err)
|
||||
require.False(t, changed)
|
||||
var mappingDenied *service.ReasoningEffortMappingDeniedError
|
||||
require.ErrorAs(t, err, &mappingDenied)
|
||||
require.Equal(t, "max", mappingDenied.Requested)
|
||||
require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
|
||||
|
||||
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))
|
||||
@@ -160,6 +179,75 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
|
||||
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 配置")
|
||||
}
|
||||
@@ -159,7 +159,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
||||
MCPXMLInject: g.MCPXMLInject,
|
||||
DefaultMappedModel: g.DefaultMappedModel,
|
||||
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
|
||||
ModelsListConfig: g.ModelsListConfig,
|
||||
ModelAllowlist: g.ModelAllowlist,
|
||||
CodexModelsManifestConfig: g.CodexModelsManifestConfig,
|
||||
SupportedModelScopes: g.SupportedModelScopes,
|
||||
AccountCount: g.AccountCount,
|
||||
ActiveAccountCount: g.ActiveAccountCount,
|
||||
@@ -447,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
|
||||
@@ -723,6 +765,7 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
|
||||
UpstreamModelMismatch: l.UpstreamModelMismatch,
|
||||
ChannelID: l.ChannelID,
|
||||
ModelMappingChain: l.ModelMappingChain,
|
||||
UpstreamRequestID: l.UpstreamRequestID,
|
||||
BillingTier: l.BillingTier,
|
||||
AccountRateMultiplier: l.AccountRateMultiplier,
|
||||
AccountStatsCost: l.AccountStatsCost,
|
||||
|
||||
@@ -307,6 +307,7 @@ type SystemSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
|
||||
GrokDefaultTextModel string `json:"grok_default_text_model"`
|
||||
@@ -417,6 +418,7 @@ type PublicSettings struct {
|
||||
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
|
||||
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
|
||||
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
|
||||
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
|
||||
|
||||
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
|
||||
|
||||
|
||||
@@ -151,11 +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"`
|
||||
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
|
||||
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
// ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
|
||||
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -189,7 +189,9 @@ type AdminGroup struct {
|
||||
// OpenAI Messages 调度配置(仅 openai 平台使用)
|
||||
DefaultMappedModel string `json:"default_mapped_model"`
|
||||
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
|
||||
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config"`
|
||||
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
|
||||
// 固定账号获取 Codex Model Manifest 配置(仅 openai 平台使用)。
|
||||
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
|
||||
|
||||
// 支持的模型系列(仅 antigravity 平台使用)
|
||||
SupportedModelScopes []string `json:"supported_model_scopes"`
|
||||
@@ -322,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"`
|
||||
@@ -588,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"`
|
||||
|
||||
|
||||
@@ -171,6 +171,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
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))
|
||||
|
||||
// 解析渠道级模型映射
|
||||
@@ -395,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 {
|
||||
@@ -610,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
|
||||
@@ -719,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 {
|
||||
@@ -763,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 {
|
||||
@@ -983,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)
|
||||
@@ -998,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)
|
||||
@@ -1036,6 +1079,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
// 不会走到这里重复计费。
|
||||
if result != nil {
|
||||
submitForwardUsage(result)
|
||||
// 上游已接受并计量本次会话(流中断),会话槽保持既有语义
|
||||
upstreamServedSession = true
|
||||
}
|
||||
return
|
||||
}
|
||||
@@ -1061,6 +1106,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
|
||||
submitForwardUsage(result)
|
||||
// 转发成功,会话槽保持既有空闲超时语义
|
||||
upstreamServedSession = true
|
||||
return
|
||||
}
|
||||
if !retryWithFallback {
|
||||
@@ -1087,11 +1134,20 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
platform = forcedPlatform
|
||||
}
|
||||
|
||||
if platform == service.PlatformOpenAI && apiKey != nil && apiKey.Group != nil &&
|
||||
apiKey.Group.Platform == service.PlatformOpenAI && apiKey.Group.CodexModelsManifestConfig.Enabled {
|
||||
h.pinnedOpenAIModels(c, apiKey.Group)
|
||||
return
|
||||
}
|
||||
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
|
||||
availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(service.PlatformComposite), apiKey.Group.ModelsListConfig.Models)
|
||||
writeCustomModelsList(c, service.PlatformComposite, availableModels)
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
source := availableModels
|
||||
if len(source) == 0 {
|
||||
source = defaultModelIDsForPlatform(service.PlatformComposite)
|
||||
}
|
||||
writeAllowlistedModelsList(c, service.PlatformComposite, apiKey.Group.ModelAllowlist.FilterForListing(source))
|
||||
return
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
@@ -1104,10 +1160,9 @@ func (h *GatewayHandler) Models(c *gin.Context) {
|
||||
|
||||
// Get available models from account configurations for the selected group platform.
|
||||
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
|
||||
fallbackModels := defaultModelIDsForPlatform(platform)
|
||||
availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models)
|
||||
writeCustomModelsList(c, platform, availableModels)
|
||||
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
source := modelListingSource(platform, availableModels, defaultModelIDsForPlatform(platform))
|
||||
writeAllowlistedModelsList(c, platform, apiKey.Group.ModelAllowlist.FilterForListing(source))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1192,8 +1247,12 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
|
||||
if platform == service.PlatformComposite {
|
||||
availableModels := h.compositeAvailableModels(ctx, groupID)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models)
|
||||
if group.ModelAllowlistEnabled() {
|
||||
source := availableModels
|
||||
if len(source) == 0 {
|
||||
source = fallbackModels
|
||||
}
|
||||
return group.ModelAllowlist.FilterForListing(source)
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
@@ -1203,12 +1262,8 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
|
||||
|
||||
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
fallbackModels := defaultCodexModelIDsForPlatform(platform)
|
||||
if group.CustomModelsListEnabled() {
|
||||
return filterModelsByCustomList(
|
||||
customModelsListSource(platform, availableModels, fallbackModels),
|
||||
fallbackModels,
|
||||
group.ModelsListConfig.Models,
|
||||
)
|
||||
if group.ModelAllowlistEnabled() {
|
||||
return group.ModelAllowlist.FilterForListing(modelListingSource(platform, availableModels, fallbackModels))
|
||||
}
|
||||
if len(availableModels) > 0 {
|
||||
return availableModels
|
||||
@@ -1223,7 +1278,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
|
||||
seen := make(map[string]struct{})
|
||||
models := make([]string, 0)
|
||||
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
||||
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
|
||||
if len(platformModels) == 0 {
|
||||
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
|
||||
@@ -1248,6 +1303,10 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
|
||||
}
|
||||
|
||||
func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
if platform == service.PlatformOpenAI {
|
||||
writeOpenAIModelsList(c, modelIDs)
|
||||
return
|
||||
}
|
||||
if platform == service.PlatformGrok {
|
||||
writeGrokModelsList(c, modelIDs)
|
||||
return
|
||||
@@ -1267,7 +1326,7 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
})
|
||||
}
|
||||
|
||||
func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
func writeAllowlistedModelsList(c *gin.Context, platform string, modelIDs []string) {
|
||||
if platform == service.PlatformOpenAI {
|
||||
writeOpenAIModelsList(c, modelIDs)
|
||||
return
|
||||
@@ -1365,76 +1424,25 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
|
||||
})
|
||||
}
|
||||
|
||||
func customModelsListSource(platform string, availableModels, fallbackModels []string) []string {
|
||||
if platform == service.PlatformAnthropic && len(availableModels) > 0 {
|
||||
// modelListingSource 汇总模型列表过滤的候选来源:账号映射键(availableModels)
|
||||
// 与平台默认列表(fallbackModels)。账号映射为空时回落默认列表;Anthropic
|
||||
// 平台两者取并集,其余平台以账号映射键为准。
|
||||
func modelListingSource(platform string, availableModels, fallbackModels []string) []string {
|
||||
if len(availableModels) == 0 {
|
||||
return fallbackModels
|
||||
}
|
||||
if platform == service.PlatformAnthropic {
|
||||
return mergeModelIDs(availableModels, fallbackModels)
|
||||
}
|
||||
return availableModels
|
||||
}
|
||||
|
||||
func filterModelsByCustomList(availableModels, fallbackModels, selectedModels []string) []string {
|
||||
if len(selectedModels) == 0 {
|
||||
return availableModels
|
||||
}
|
||||
source := availableModels
|
||||
if len(source) == 0 {
|
||||
source = fallbackModels
|
||||
}
|
||||
if len(source) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
allowed := make([]string, 0, len(source))
|
||||
for _, model := range source {
|
||||
model = strings.TrimSpace(model)
|
||||
if model != "" {
|
||||
allowed = append(allowed, model)
|
||||
}
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(selectedModels))
|
||||
filtered := make([]string, 0, len(selectedModels))
|
||||
for _, model := range selectedModels {
|
||||
model = strings.TrimSpace(model)
|
||||
if model == "" {
|
||||
continue
|
||||
}
|
||||
if !customModelsListAllowsModel(allowed, model) {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[model]; ok {
|
||||
continue
|
||||
}
|
||||
seen[model] = struct{}{}
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
func customModelsListAllowsModel(availablePatterns []string, model string) bool {
|
||||
for _, pattern := range availablePatterns {
|
||||
if pattern == model {
|
||||
return true
|
||||
}
|
||||
if strings.HasSuffix(pattern, "*") && strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) {
|
||||
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"}
|
||||
case service.PlatformMiniMax:
|
||||
return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}
|
||||
default:
|
||||
return defaultModelIDsForPlatform(platform)
|
||||
}
|
||||
@@ -1464,7 +1472,7 @@ func defaultModelIDsForPlatform(platform string) []string {
|
||||
case service.PlatformComposite:
|
||||
ids := make([]string, 0)
|
||||
seen := make(map[string]struct{})
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
||||
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
|
||||
if _, ok := seen[id]; ok {
|
||||
continue
|
||||
@@ -1504,10 +1512,21 @@ func mergeModelIDs(primary, secondary []string) []string {
|
||||
|
||||
// AntigravityModels 返回 Antigravity 支持的全部模型
|
||||
// GET /antigravity/models
|
||||
// 分组级模型白名单开启时按白名单过滤。
|
||||
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
|
||||
models := antigravity.DefaultModels()
|
||||
if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
filtered := make([]antigravity.ClaudeModel, 0, len(models))
|
||||
for _, model := range models {
|
||||
if apiKey.Group.ModelAllowlist.Allows(model.ID) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
models = filtered
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"object": "list",
|
||||
"data": antigravity.DefaultModels(),
|
||||
"data": models,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1853,8 +1872,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) {
|
||||
@@ -1925,6 +1944,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 采集,
|
||||
@@ -1935,7 +1958,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
|
||||
}
|
||||
}
|
||||
@@ -1943,7 +1966,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)
|
||||
}
|
||||
@@ -1953,7 +1980,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 返回错误但尚未写响应时补写统一错误响应。
|
||||
@@ -2045,12 +2072,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,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2163,6 +2195,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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -394,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
|
||||
}
|
||||
|
||||
@@ -89,6 +89,10 @@ func claudeCodeBodyMapFromParsedRequest(parsedReq *service.ParsedRequest) map[st
|
||||
bodyMap := map[string]any{
|
||||
"model": parsedReq.Model,
|
||||
}
|
||||
// 探测识别(max_tokens=1)需要看到该字段,复用已解析请求时一并带上。
|
||||
if parsedReq.MaxTokens > 0 {
|
||||
bodyMap["max_tokens"] = parsedReq.MaxTokens
|
||||
}
|
||||
if parsedReq.HasSystem {
|
||||
if system, ok := parsedReq.SystemValue(); ok {
|
||||
bodyMap["system"] = system
|
||||
|
||||
@@ -500,3 +500,19 @@ type helperConcurrencyCacheStubWithError struct {
|
||||
func (s *helperConcurrencyCacheStubWithError) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
return false, s.err
|
||||
}
|
||||
|
||||
func TestSetClaudeCodeClientContext_ParsedRequestProbeWithoutSystemPrompt(t *testing.T) {
|
||||
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
|
||||
c.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
|
||||
|
||||
// The hot path reuses ParsedRequest instead of re-parsing the body; the probe
|
||||
// marker must survive that projection or the exemption only works cold.
|
||||
parsed := &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 1}
|
||||
SetClaudeCodeClientContext(c, nil, parsed)
|
||||
require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
|
||||
|
||||
c2, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
|
||||
c2.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
|
||||
SetClaudeCodeClientContext(c2, nil, &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 64})
|
||||
require.False(t, service.IsClaudeCodeClient(c2.Request.Context()))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 通配条目在 /v1/models 中展开为候选来源中所有匹配项,保持来源顺序。
|
||||
func TestGatewayModels_ModelAllowlistWildcardExpandsAgainstSource(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(31)
|
||||
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.4": "gpt-5.4",
|
||||
"gpt-5.5-codex": "gpt-5.5-codex",
|
||||
"gpt-5.5-mini": "gpt-5.5-mini",
|
||||
"other-foo": "other-foo",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
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.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5-*", "gpt-5.4"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.Models(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.5-mini", "gpt-5.4"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
// geminiAllowlistAccountRepoStub 在 gatewayModelsAccountRepoStub 之上补充
|
||||
// Gemini 兼容层用到的按平台过滤查询(分组内无任何账号,触发 fallback 列表)。
|
||||
type geminiAllowlistAccountRepoStub struct {
|
||||
gatewayModelsAccountRepoStub
|
||||
}
|
||||
|
||||
func (s *geminiAllowlistAccountRepoStub) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]service.Account, error) {
|
||||
allowed := make(map[string]struct{}, len(platforms))
|
||||
for _, platform := range platforms {
|
||||
allowed[platform] = struct{}{}
|
||||
}
|
||||
accounts := s.byGroup[groupID]
|
||||
filtered := make([]service.Account, 0, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if _, ok := allowed[account.Platform]; ok {
|
||||
filtered = append(filtered, account)
|
||||
}
|
||||
}
|
||||
return filtered, nil
|
||||
}
|
||||
|
||||
// Gemini 原生 /v1beta/models:白名单开启时过滤 fallback 列表的 models[].name。
|
||||
func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{
|
||||
// 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。
|
||||
41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}},
|
||||
},
|
||||
}}
|
||||
h := &GatewayHandler{
|
||||
geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil),
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
|
||||
geminiGroupID := int64(41)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
GroupID: &geminiGroupID,
|
||||
Group: &service.Group{
|
||||
ID: geminiGroupID,
|
||||
Platform: service.PlatformGemini,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-pro", "gemini-3-*"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.GeminiV1BetaListModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got struct {
|
||||
Models []struct {
|
||||
Name string `json:"name"`
|
||||
} `json:"models"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
names := make([]string, 0, len(got.Models))
|
||||
for _, model := range got.Models {
|
||||
names = append(names, model.Name)
|
||||
}
|
||||
// models/ 前缀的候选形式也应命中条目。
|
||||
require.Contains(t, names, "models/gemini-2.5-pro")
|
||||
require.Contains(t, names, "models/gemini-3-pro-preview")
|
||||
require.NotContains(t, names, "models/gemini-2.5-flash")
|
||||
require.NotContains(t, names, "models/gemini-2.0-flash")
|
||||
}
|
||||
|
||||
// Antigravity /antigravity/models:白名单开启时按条目过滤静态列表。
|
||||
func TestAntigravityModels_FiltersByAllowlist(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
h := &GatewayHandler{}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/models", nil)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
||||
Group: &service.Group{
|
||||
ID: 51,
|
||||
Platform: service.PlatformAntigravity,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-flash"},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
h.AntigravityModels(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var got struct {
|
||||
Object string `json:"object"`
|
||||
Data []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, "list", got.Object)
|
||||
// -thinking 后缀的宽容规则:gemini-2.5-flash-thinking 也命中 gemini-2.5-flash 条目。
|
||||
require.Len(t, got.Data, 2)
|
||||
require.Equal(t, "gemini-2.5-flash", got.Data[0].ID)
|
||||
require.Equal(t, "gemini-2.5-flash-thinking", got.Data[1].ID)
|
||||
}
|
||||
|
||||
func TestFilterUpstreamGeminiModelsBody(t *testing.T) {
|
||||
allowlist := service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}}
|
||||
|
||||
t.Run("full hit returns original body with dropped=false", func(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-pro"}]}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
|
||||
require.True(t, ok)
|
||||
require.False(t, dropped, "full hit must keep the original response so headers pass through")
|
||||
require.Equal(t, string(body), string(filtered))
|
||||
})
|
||||
|
||||
t.Run("partial hit filters models and preserves envelope fields", func(t *testing.T) {
|
||||
body := []byte(`{"nextPageToken":"tok","models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"other":"kept"}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
|
||||
require.True(t, ok)
|
||||
require.True(t, dropped)
|
||||
require.JSONEq(t, `{"nextPageToken":"tok","other":"kept","models":[{"name":"models/gemini-2.5-pro"}]}`, string(filtered))
|
||||
})
|
||||
|
||||
t.Run("models prefix stripped candidate form matches entry", func(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"gemini-2.5-pro"}]}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
|
||||
require.True(t, ok)
|
||||
require.False(t, dropped)
|
||||
require.Equal(t, string(body), string(filtered))
|
||||
})
|
||||
|
||||
t.Run("missing models field passes through", func(t *testing.T) {
|
||||
body := []byte(`{"error":"odd upstream"}`)
|
||||
_, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
|
||||
require.True(t, ok)
|
||||
require.False(t, dropped)
|
||||
})
|
||||
|
||||
t.Run("invalid JSON signals passthrough", func(t *testing.T) {
|
||||
_, dropped, ok := filterUpstreamGeminiModelsBody([]byte(`not-json`), allowlist)
|
||||
require.False(t, ok)
|
||||
require.False(t, dropped)
|
||||
})
|
||||
}
|
||||
|
||||
func TestFilterBatchImageModelsByAllowlist(t *testing.T) {
|
||||
models := []service.BatchImagePublicModel{
|
||||
{ID: "gemini-2.5-flash-image", Object: "image.batch.model", Provider: "gemini_api"},
|
||||
{ID: "gemini-3-pro-image", Object: "image.batch.model", Provider: "vertex"},
|
||||
{ID: "gpt-image-1", Object: "image.batch.model", Provider: "openai"},
|
||||
}
|
||||
|
||||
t.Run("disabled allowlist keeps everything", func(t *testing.T) {
|
||||
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{Enabled: false})
|
||||
require.Equal(t, models, got)
|
||||
})
|
||||
|
||||
t.Run("exact and wildcard entries filter by model id", func(t *testing.T) {
|
||||
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-3-*", "gpt-image-1"},
|
||||
})
|
||||
require.Equal(t, []string{"gemini-3-pro-image", "gpt-image-1"}, []string{got[0].ID, got[1].ID})
|
||||
require.Len(t, got, 2)
|
||||
})
|
||||
|
||||
t.Run("no match yields empty list", func(t *testing.T) {
|
||||
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-*"},
|
||||
})
|
||||
require.Empty(t, got)
|
||||
})
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -181,7 +182,7 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Scenario: Composite manifests aggregate only administrator-configured models.
|
||||
// Composite manifests include defaults from unmapped accounts and explicit mappings.
|
||||
func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 120
|
||||
@@ -225,7 +226,105 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T
|
||||
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))
|
||||
want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil)
|
||||
require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models))
|
||||
}
|
||||
|
||||
func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 28
|
||||
const sparkModel = "gpt-5.3-codex-spark"
|
||||
const alias = "team-coder"
|
||||
parentID := int64(1)
|
||||
accounts := []service.Account{
|
||||
{ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth},
|
||||
{
|
||||
ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
||||
ParentAccountID: &parentID, QuotaDimension: "spark",
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}},
|
||||
},
|
||||
{
|
||||
ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}},
|
||||
},
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
accounts []service.Account
|
||||
config service.GroupModelAllowlist
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "unmapped parent and Spark shadow retain defaults and aliases",
|
||||
accounts: accounts,
|
||||
want: append(openai.DefaultModelIDs(), alias),
|
||||
},
|
||||
{
|
||||
name: "unmapped API key account also contributes defaults",
|
||||
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...),
|
||||
want: append(openai.DefaultModelIDs(), alias),
|
||||
},
|
||||
{
|
||||
name: "unmapped accounts alone retain default response shape",
|
||||
accounts: accounts[:1],
|
||||
want: openai.DefaultModelIDs(),
|
||||
},
|
||||
{
|
||||
name: "custom list can select defaults and aliases",
|
||||
accounts: accounts,
|
||||
config: service.GroupModelAllowlist{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}},
|
||||
want: []string{alias, "gpt-5.6-sol", sparkModel},
|
||||
},
|
||||
{
|
||||
name: "unavailable custom selection remains empty",
|
||||
accounts: accounts,
|
||||
config: service.GroupModelAllowlist{Enabled: true, Models: []string{"unknown-model"}},
|
||||
want: []string{},
|
||||
},
|
||||
{
|
||||
name: "mapped accounts alone do not gain defaults",
|
||||
accounts: accounts[1:],
|
||||
want: []string{sparkModel, alias},
|
||||
},
|
||||
{
|
||||
name: "unmapped accounts from another platform do not add defaults",
|
||||
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...),
|
||||
want: []string{sparkModel, alias},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
||||
byGroup: map[int64][]service.Account{groupID: tt.accounts},
|
||||
})
|
||||
for range 2 {
|
||||
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.PlatformOpenAI, ModelAllowlist: tt.config},
|
||||
})
|
||||
h.Models(c)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, "list", got.Object)
|
||||
require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data))
|
||||
for _, model := range got.Data {
|
||||
require.Equal(t, "model", model.Object, model.ID)
|
||||
require.Positive(t, model.Created, model.ID)
|
||||
require.Equal(t, "openai", model.OwnedBy, model.ID)
|
||||
require.Empty(t, model.CreatedAt, model.ID)
|
||||
}
|
||||
if tt.config.Enabled {
|
||||
require.Equal(t, tt.want, modelIDsForTest(got.Data))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
require.Empty(t, accounts[0].GetModelMapping())
|
||||
require.True(t, accounts[0].IsModelSupported("gpt-future-model"))
|
||||
require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol"))
|
||||
}
|
||||
|
||||
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
|
||||
@@ -267,7 +366,7 @@ func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
|
||||
require.Equal(t, etag, second.Header().Get("ETag"))
|
||||
}
|
||||
|
||||
// Scenario: group models_list_config limits the generated Codex manifest.
|
||||
// Scenario: group model_allowlist limits the generated Codex manifest.
|
||||
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const groupID int64 = 121
|
||||
@@ -299,7 +398,7 @@ func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformComposite,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"grok-4.6"},
|
||||
},
|
||||
@@ -572,7 +671,7 @@ func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T)
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: false,
|
||||
Models: []string{"gpt-5.5"},
|
||||
},
|
||||
@@ -619,7 +718,7 @@ func TestGatewayModels_CustomModelsListFiltersAndOrdersMappedModels(t *testing.T
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5", "missing-model", "gpt-5.4"},
|
||||
},
|
||||
@@ -692,6 +791,13 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
|
||||
},
|
||||
},
|
||||
{
|
||||
ID: 7,
|
||||
Platform: service.PlatformMiniMax,
|
||||
Credentials: map[string]any{
|
||||
"model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -704,9 +810,9 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformComposite,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"},
|
||||
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"},
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -717,7 +823,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
|
||||
|
||||
var got gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
||||
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data))
|
||||
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data))
|
||||
}
|
||||
|
||||
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
|
||||
@@ -770,6 +876,7 @@ func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testin
|
||||
{ID: 2, Platform: service.PlatformKimi},
|
||||
{ID: 3, Platform: service.PlatformZhipu},
|
||||
{ID: 4, Platform: service.PlatformDeepseek},
|
||||
{ID: 5, Platform: service.PlatformMiniMax},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -801,13 +908,14 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
|
||||
for _, model := range claude.DefaultModels {
|
||||
want = append(want, model.ID)
|
||||
}
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
||||
require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
|
||||
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
|
||||
require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
|
||||
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
|
||||
}
|
||||
|
||||
@@ -918,7 +1026,7 @@ func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMappin
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-sonnet-4-6"},
|
||||
},
|
||||
@@ -969,7 +1077,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeep
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"},
|
||||
},
|
||||
@@ -1020,7 +1128,7 @@ func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: false,
|
||||
Models: []string{"claude-fable-5", "deepseek-v4-pro"},
|
||||
},
|
||||
@@ -1061,7 +1169,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappin
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformAnthropic,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"},
|
||||
},
|
||||
@@ -1106,7 +1214,7 @@ func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5"},
|
||||
},
|
||||
@@ -1143,7 +1251,7 @@ func TestGatewayModels_CustomModelsListFiltersDefaultFallbackModels(t *testing.T
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5", "legacy-gpt-2024", "gpt-5.4"},
|
||||
},
|
||||
@@ -1180,7 +1288,7 @@ func TestGatewayModels_OpenAICustomModelsListKeepsOpenAIResponseShapeForDefaultF
|
||||
Group: &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"gpt-5.5", "gpt-5.4"},
|
||||
},
|
||||
|
||||
@@ -46,8 +46,33 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// 分组级模型白名单开启时过滤 models[].name(名字形如 models/xxx)。
|
||||
filterGeminiModels := func(models []gemini.Model) []gemini.Model {
|
||||
if apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() {
|
||||
return models
|
||||
}
|
||||
filtered := make([]gemini.Model, 0, len(models))
|
||||
for _, model := range models {
|
||||
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
return filtered
|
||||
}
|
||||
|
||||
// 强制 antigravity 模式:返回 antigravity 支持的模型列表
|
||||
if forcePlatform == service.PlatformAntigravity {
|
||||
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
agModels := antigravity.DefaultGeminiModels()
|
||||
filtered := make([]antigravity.GeminiModel, 0, len(agModels))
|
||||
for _, model := range agModels {
|
||||
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
|
||||
filtered = append(filtered, model)
|
||||
}
|
||||
}
|
||||
c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered})
|
||||
return
|
||||
}
|
||||
c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList())
|
||||
return
|
||||
}
|
||||
@@ -58,7 +83,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
|
||||
if hasAntigravity {
|
||||
// antigravity 账户使用静态模型列表
|
||||
c.JSON(http.StatusOK, gemini.FallbackModelsList())
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
|
||||
return
|
||||
}
|
||||
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
|
||||
@@ -72,12 +97,66 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
if shouldFallbackGeminiModels(res) {
|
||||
c.JSON(http.StatusOK, gemini.FallbackModelsList())
|
||||
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
|
||||
return
|
||||
}
|
||||
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
|
||||
if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped {
|
||||
// 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应,
|
||||
// 统一经 writeUpstreamResponse 写出(保留全部上游响应头)。
|
||||
res.Body = filtered
|
||||
}
|
||||
}
|
||||
writeUpstreamResponse(c, res)
|
||||
}
|
||||
|
||||
// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的
|
||||
// models[].name,其余信封字段(如 nextPageToken)原样保留。
|
||||
// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时
|
||||
// 为 false,调用方应保持原始响应以完整透传上游头);ok=false 表示解析失败,
|
||||
// 调用方同样应透传原始响应。
|
||||
func filterUpstreamGeminiModelsBody(body []byte, allowlist service.GroupModelAllowlist) (filtered []byte, dropped bool, ok bool) {
|
||||
var envelope map[string]json.RawMessage
|
||||
if err := json.Unmarshal(body, &envelope); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
rawModels, hasModels := envelope["models"]
|
||||
if !hasModels {
|
||||
return body, false, true
|
||||
}
|
||||
type geminiModelName struct {
|
||||
Name string `json:"name"`
|
||||
}
|
||||
var models []json.RawMessage
|
||||
if err := json.Unmarshal(rawModels, &models); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
kept := make([]json.RawMessage, 0, len(models))
|
||||
for _, raw := range models {
|
||||
var model geminiModelName
|
||||
if err := json.Unmarshal(raw, &model); err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
if allowlist.Allows(model.Name) {
|
||||
kept = append(kept, raw)
|
||||
}
|
||||
}
|
||||
if len(kept) == len(models) {
|
||||
// 全部命中时直接透传原始响应体。
|
||||
return body, false, true
|
||||
}
|
||||
mergedModels, err := json.Marshal(kept)
|
||||
if err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
envelope["models"] = mergedModels
|
||||
merged, err := json.Marshal(envelope)
|
||||
if err != nil {
|
||||
return nil, false, false
|
||||
}
|
||||
return merged, true, true
|
||||
}
|
||||
|
||||
// GeminiV1BetaGetModel proxies:
|
||||
// GET /v1beta/models/{model}
|
||||
func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
|
||||
|
||||
@@ -3,13 +3,58 @@
|
||||
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/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGeminiV1BetaListModels_AllowlistFiltersNativeResponse(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"nextPageToken":"next"}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}})
|
||||
require.True(t, ok)
|
||||
require.True(t, dropped)
|
||||
require.JSONEq(t, `{"models":[{"name":"models/gemini-2.5-pro"}],"nextPageToken":"next"}`, string(filtered))
|
||||
}
|
||||
|
||||
func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(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,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
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.Empty(t, got.Models)
|
||||
}
|
||||
|
||||
func TestGeminiModelAllowlist_DisabledPreservesNativeResponse(t *testing.T) {
|
||||
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"}]}`)
|
||||
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: false, Models: []string{"other"}})
|
||||
require.True(t, ok)
|
||||
require.False(t, dropped)
|
||||
require.Equal(t, body, filtered)
|
||||
}
|
||||
|
||||
// TestGeminiV1BetaHandler_PlatformRoutingInvariant 文档化并验证 Handler 层的平台路由逻辑不变量
|
||||
// 该测试确保 gemini 和 antigravity 平台的路由逻辑符合预期
|
||||
func TestGeminiV1BetaHandler_PlatformRoutingInvariant(t *testing.T) {
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
|
||||
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
|
||||
// - 匿名:仅非专属分组(订阅型照常展示);
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性);
|
||||
// - 登录:非专属分组 + user_allowed_groups 授权或持有有效订阅的专属分组;
|
||||
// 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。
|
||||
type ModelPlazaHandler struct {
|
||||
plazaService *service.ModelPlazaService
|
||||
@@ -160,7 +160,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
|
||||
}
|
||||
|
||||
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
|
||||
// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
|
||||
// allowedGroups == nil 表示匿名(仅非专属);非 nil 包含普通授权及有效订阅分组。
|
||||
// restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会
|
||||
// 在广场看到自己实际绑定不了的分组。
|
||||
func filterPlazaVisibleGroups(
|
||||
|
||||
@@ -244,3 +244,17 @@ func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) {
|
||||
weekdaysTP := weekdaysModel["time_pricing"].(map[string]any)
|
||||
require.Equal(t, true, weekdaysTP["weekdays_only"])
|
||||
}
|
||||
|
||||
func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) {
|
||||
groups := []service.PlazaGroup{
|
||||
{ID: 42, IsExclusive: true, SubscriptionType: "subscription"},
|
||||
{ID: 43, IsExclusive: true, SubscriptionType: "subscription"},
|
||||
{ID: 44, IsExclusive: true, SubscriptionType: "standard"},
|
||||
}
|
||||
require.Empty(t, filterPlazaVisibleGroups(groups, nil, false))
|
||||
for _, restricted := range []bool{false, true} {
|
||||
visible := filterPlazaVisibleGroups(groups, map[int64]struct{}{42: {}}, restricted)
|
||||
require.Len(t, visible, 1)
|
||||
require.Equal(t, int64(42), visible[0].ID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,20 @@ func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
@@ -75,10 +89,6 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
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: "heartbeat output",
|
||||
body: codexAutomationBootstrapBody(t, `<heartbeat><automation_id>wiki</automation_id></heartbeat>`, ""),
|
||||
},
|
||||
{
|
||||
name: "mismatched memory id",
|
||||
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
|
||||
@@ -117,6 +127,31 @@ func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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"}]}`)
|
||||
|
||||
@@ -116,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)
|
||||
@@ -170,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
"",
|
||||
sessionHash,
|
||||
reqModel,
|
||||
forwardModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
service.OpenAIEndpointCapabilityChatCompletions,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestOpenAIEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil).WithContext(ctx)
|
||||
cancel()
|
||||
|
||||
h := &OpenAIGatewayHandler{}
|
||||
require.False(t, h.ensureForwardErrorResponse(c, true))
|
||||
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
|
||||
require.Empty(t, recorder.Body.String())
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -15,7 +16,8 @@ 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. Groups with explicit account model mappings are generated locally;
|
||||
// here. Pinned discovery takes precedence over local account model mappings;
|
||||
// when disabled, groups with explicit 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) {
|
||||
@@ -33,21 +35,60 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
}
|
||||
|
||||
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 {
|
||||
// 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器;
|
||||
// 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。
|
||||
if apiKey.Group.Platform == service.PlatformOpenAI &&
|
||||
apiKey.Group.CodexModelsManifestConfig.Enabled {
|
||||
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
|
||||
}
|
||||
writeOpenAIModelsResponse(c, pinnedManifest)
|
||||
return
|
||||
}
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
|
||||
return
|
||||
}
|
||||
if configured {
|
||||
writeCodexModelsManifestResponse(c, configuredManifest)
|
||||
return
|
||||
|
||||
if !apiKey.Group.CodexModelsManifestConfig.Enabled {
|
||||
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 {
|
||||
writeOpenAIModelsResponse(c, configuredManifest)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
@@ -94,6 +135,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
|
||||
return
|
||||
}
|
||||
if err := service.ApplyPinnedCodexModelsMapping(manifest, account, apiKey.Group); err != nil {
|
||||
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply model mappings")
|
||||
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
|
||||
@@ -102,19 +147,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
writeCodexModelsManifestResponse(c, manifest)
|
||||
writeOpenAIModelsResponse(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)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,11 @@ import (
|
||||
"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"
|
||||
@@ -161,7 +163,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"codex-auto-review", "gpt-5.6"},
|
||||
},
|
||||
@@ -179,7 +181,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
|
||||
t.Fatal("first response did not include an ETag")
|
||||
}
|
||||
|
||||
group.ModelsListConfig.Enabled = false
|
||||
group.ModelAllowlist.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())
|
||||
@@ -230,7 +232,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
|
||||
groupA := &service.Group{
|
||||
ID: 91,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"model-a"},
|
||||
},
|
||||
@@ -238,7 +240,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
|
||||
groupB := &service.Group{
|
||||
ID: 92,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelsListConfig: service.GroupModelsListConfig{
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: true,
|
||||
Models: []string{"model-b"},
|
||||
},
|
||||
@@ -290,8 +292,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
|
||||
require.True(t, sawGroupB)
|
||||
}
|
||||
|
||||
// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。
|
||||
func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
|
||||
func TestCodexModelsSupplementsConfiguredModelsWithUnmappedAccountDefaults(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(44)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
@@ -352,12 +353,59 @@ func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
|
||||
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)
|
||||
require.Contains(t, codexHandlerManifestSlugs(t, recorder), "gpt-5.6-sol")
|
||||
require.Contains(t, codexHandlerManifestSlugs(t, recorder), "glm-5.3")
|
||||
for _, model := range envelope.Models {
|
||||
require.Contains(t, model, "supported_reasoning_levels")
|
||||
}
|
||||
if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok {
|
||||
t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0])
|
||||
}
|
||||
|
||||
func TestCodexModelsUnmappedParentAndSparkShadowHonorCustomListAndETag(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
const sparkModel = "gpt-5.3-codex-spark"
|
||||
parentID := int64(1)
|
||||
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
|
||||
{
|
||||
ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive, Schedulable: true,
|
||||
},
|
||||
{
|
||||
ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
||||
Status: service.StatusActive, Schedulable: true,
|
||||
ParentAccountID: &parentID, QuotaDimension: "spark",
|
||||
Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}},
|
||||
},
|
||||
}}
|
||||
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}
|
||||
group := &service.Group{ID: 45, Platform: service.PlatformOpenAI}
|
||||
first := performCodexModelsRequestForGroup(t, handler, group, "")
|
||||
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||
slugs := codexHandlerManifestSlugs(t, first)
|
||||
require.Contains(t, slugs, "gpt-5.6-sol")
|
||||
require.Contains(t, slugs, sparkModel)
|
||||
require.NotContains(t, slugs, "gpt-image-2")
|
||||
require.NotContains(t, slugs, "codex-auto-review")
|
||||
firstETag := first.Header().Get("ETag")
|
||||
require.NotEmpty(t, firstETag)
|
||||
|
||||
group.ModelAllowlist = service.GroupModelAllowlist{
|
||||
Enabled: true, Models: []string{"gpt-5.6-sol", sparkModel, "unknown-model"},
|
||||
}
|
||||
second := performCodexModelsRequestForGroup(t, handler, group, firstETag)
|
||||
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
|
||||
require.ElementsMatch(t, []string{"gpt-5.6-sol", sparkModel}, codexHandlerManifestSlugs(t, second))
|
||||
require.NotEqual(t, firstETag, second.Header().Get("ETag"))
|
||||
third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusNotModified, third.Code)
|
||||
require.Empty(t, third.Body.Bytes())
|
||||
require.Empty(t, upstream.calls())
|
||||
}
|
||||
|
||||
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
|
||||
@@ -640,3 +688,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,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
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())
|
||||
}
|
||||
|
||||
@@ -43,11 +43,7 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
}{
|
||||
{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: "previous response", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`, extra: `,"previous_response_id":"resp-1"`},
|
||||
{name: "real call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"}`},
|
||||
{name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`},
|
||||
{name: "item reference context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"item_reference","id":"call-1"}`},
|
||||
{name: "built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call","call_id":"call-1"}`},
|
||||
{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"}`},
|
||||
@@ -67,6 +63,18 @@ func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
@@ -75,7 +83,7 @@ func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
|
||||
}{
|
||||
{name: "absent", changed: true},
|
||||
{name: "blank string", value: `,"previous_response_id":" "`, changed: true},
|
||||
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`},
|
||||
{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`},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -18,7 +18,7 @@ import (
|
||||
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
|
||||
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} {
|
||||
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformGrok} {
|
||||
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
|
||||
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
|
||||
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
|
||||
|
||||
@@ -128,6 +128,44 @@ func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testin
|
||||
})
|
||||
}
|
||||
|
||||
func TestOpenAIManagedSingleAccountModelNotFoundExhaustionPreservesStructured400(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"error":{"type":"invalid_request_error","code":"model_not_found","param":"model","message":"The requested model is unavailable on this account"}}`)
|
||||
|
||||
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
ResponseBody: body,
|
||||
}, false)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
|
||||
require.Equal(t, "model_not_found", gjson.Get(recorder.Body.String(), "error.code").String())
|
||||
require.Equal(t, "model", gjson.Get(recorder.Body.String(), "error.param").String())
|
||||
require.Equal(t, "The requested model is unavailable on this account", gjson.Get(recorder.Body.String(), "error.message").String())
|
||||
}
|
||||
|
||||
func TestOpenAIManagedModelNotFoundExhaustionSanitizesMessage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
body := []byte(`{"error":{"code":"model_not_found","message":"Model not found; inspect https://upstream.example/debug?access_token=super-secret-value&model=x"}}`)
|
||||
|
||||
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
ResponseBody: body,
|
||||
}, false)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, recorder.Code)
|
||||
require.NotContains(t, recorder.Body.String(), "super-secret-value")
|
||||
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "access_token=***")
|
||||
recorded, ok := c.Get(service.OpsUpstreamErrorMessageKey)
|
||||
require.True(t, ok)
|
||||
require.NotContains(t, recorded, "super-secret-value")
|
||||
require.Contains(t, recorded, "access_token=***")
|
||||
}
|
||||
|
||||
func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel"
|
||||
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
|
||||
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
@@ -53,6 +54,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 {
|
||||
@@ -140,6 +156,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)
|
||||
}
|
||||
@@ -294,7 +322,7 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
|
||||
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
|
||||
return compositeTargetPlatformAllowed(c, apiKey, model,
|
||||
service.PlatformOpenAI, service.PlatformGrok,
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek)
|
||||
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax)
|
||||
}
|
||||
|
||||
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
|
||||
@@ -533,10 +561,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,
|
||||
@@ -629,7 +654,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
previousResponseID,
|
||||
sessionHash,
|
||||
reqModel,
|
||||
forwardModel,
|
||||
failedAccountIDs,
|
||||
service.OpenAIUpstreamTransportAny,
|
||||
requiredCapability,
|
||||
@@ -810,6 +835,22 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
if result != nil && result.ClientDisconnect {
|
||||
reqLog.Info("openai.client_disconnected",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
submitResponsesUsage(result)
|
||||
return
|
||||
}
|
||||
if failoverClientGone(c) {
|
||||
reqLog.Info("openai.client_disconnected",
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.Error(err),
|
||||
)
|
||||
submitResponsesUsage(result)
|
||||
return
|
||||
}
|
||||
if result != nil && result.ImageCount > 0 {
|
||||
reqLog.Warn("openai.forward_partial_error_with_image_result",
|
||||
zap.Int64("account_id", account.ID),
|
||||
@@ -1600,14 +1641,17 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
|
||||
}
|
||||
|
||||
func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate)
|
||||
// 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id;
|
||||
// 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入,
|
||||
// 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true)
|
||||
}
|
||||
|
||||
func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate)
|
||||
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false)
|
||||
}
|
||||
|
||||
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool) ([]byte, bool) {
|
||||
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) {
|
||||
if !hasUniqueJSONMembers(body) {
|
||||
return body, false
|
||||
}
|
||||
@@ -1619,7 +1663,7 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]
|
||||
}
|
||||
if previousResponseID, exists := request["previous_response_id"]; exists {
|
||||
value, ok := previousResponseID.(string)
|
||||
if !ok || strings.TrimSpace(value) != "" {
|
||||
if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") {
|
||||
return body, false
|
||||
}
|
||||
}
|
||||
@@ -1628,27 +1672,35 @@ func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]
|
||||
return body, false
|
||||
}
|
||||
|
||||
// Any call/reference anchor makes a call-less output ambiguous. Responses
|
||||
// built-ins follow the *_call / *_call_output naming convention, so classify
|
||||
// by the wire type shape instead of maintaining an incomplete allowlist.
|
||||
// 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 typ == "item_reference" || strings.HasSuffix(typ, "_call") {
|
||||
return body, false
|
||||
}
|
||||
if isResponsesCallOutputType(typ) {
|
||||
if isCandidate(item) {
|
||||
callIDValue, exists := item["call_id"]
|
||||
callID, isString := callIDValue.(string)
|
||||
if exists && (!isString || strings.TrimSpace(callID) != "") {
|
||||
return body, false
|
||||
}
|
||||
if !isCandidate(item) {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1756,7 +1808,7 @@ func isCodexAutomationCandidate(item map[string]any) bool {
|
||||
return false
|
||||
}
|
||||
output, ok := item["output"].(string)
|
||||
return ok && validCodexAutomationBootstrap(output)
|
||||
return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output))
|
||||
}
|
||||
|
||||
func stringField(item map[string]any, key string) string {
|
||||
@@ -1834,6 +1886,56 @@ func validCodexAutomationLastRun(value string) bool {
|
||||
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
|
||||
@@ -1940,10 +2042,10 @@ const (
|
||||
// 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在
|
||||
// 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。
|
||||
//
|
||||
// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须
|
||||
// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。
|
||||
// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行
|
||||
// 准入门、没有 turn 级利润复核的既有行为。
|
||||
// 零值语义(重要):首轮准入由握手路径完成,不调用 BeforeTurn,因此首轮保持
|
||||
// 零值并回退到 TurnStarted 记录的首轮开始时刻。后续 turn 在 response.create
|
||||
// 写入上游前调用 BeforeTurn,按当时的利润门复核并冻结定价。绝不能用建连时刻
|
||||
// 初始化,否则会把长连接的所有 turn 钉死在建连时的峰谷因子。
|
||||
type openAIWSTurnPricing struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
@@ -2004,7 +2106,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
|
||||
@@ -2020,13 +2122,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
|
||||
}
|
||||
|
||||
@@ -2056,7 +2158,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
|
||||
}
|
||||
|
||||
@@ -2067,8 +2169,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 {
|
||||
@@ -2098,7 +2200,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
|
||||
}
|
||||
|
||||
@@ -2121,8 +2223,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
|
||||
}
|
||||
|
||||
@@ -2263,6 +2365,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model is required in first response.create payload")
|
||||
return
|
||||
}
|
||||
// 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。
|
||||
// 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。
|
||||
// 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定,
|
||||
// 全部候选值逐一校验,任一未命中即拒绝。
|
||||
if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked))
|
||||
return
|
||||
}
|
||||
ensureCompositeTargetPlatform(c, apiKey, reqModel)
|
||||
ctx = c.Request.Context()
|
||||
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
|
||||
@@ -2310,6 +2422,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) {
|
||||
@@ -2327,10 +2440,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()
|
||||
@@ -2493,7 +2603,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
apiKey.GroupID,
|
||||
previousResponseID,
|
||||
sessionHash,
|
||||
reqModel,
|
||||
wsForwardModel,
|
||||
failedAccountIDs,
|
||||
requiredTransport,
|
||||
requiredCapability,
|
||||
@@ -2645,8 +2755,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
// turn-tagged slot preserves the exact mapping used for the in-flight request.
|
||||
var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot]
|
||||
turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS})
|
||||
// turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号;
|
||||
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
|
||||
// turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由
|
||||
// BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
ClientLifecycleContext: clientLifecycleCtx,
|
||||
@@ -2660,10 +2770,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
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.
|
||||
// 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与
|
||||
// passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。
|
||||
// BeforeTurn 中保留同一检查作为防御式兜底。
|
||||
if cyberBlockedThisConn {
|
||||
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
|
||||
}
|
||||
@@ -2680,6 +2789,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
if model == "" {
|
||||
model = reqModel
|
||||
}
|
||||
// 分组级模型白名单:后续 turn 同样校验客户端模型(省略 model 时
|
||||
// 沿用会话实际生效模型,含 session.update 轮换后的模型),不通过
|
||||
// 则关闭整条连接,与推理强度 deny 一致。实际生效模型始终参与校验;
|
||||
// 帧内重复 model 键/大小写变体/嵌套 session.model 额外逐一校验,
|
||||
// 防止候选集非空时掩盖被轮换掉的禁用模型。
|
||||
candidates := append([]string{model}, requestmodel.FromBodyCandidates("", "application/json", payload)...)
|
||||
if blocked := blockedModelAllowlistCandidate(apiKey.Group, candidates); blocked != "" {
|
||||
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
|
||||
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
|
||||
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked), nil)
|
||||
}
|
||||
if decision := h.checkSecurityAuditStage(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload, "subsequent_turn"); decision != nil && !decision.AllowNextStage {
|
||||
writeSecurityAuditWSError(ctx, wsConn, decision)
|
||||
return service.NewOpenAIWSClientCloseError(securityAuditWSCloseStatus(decision), securityAuditWSCloseReason(decision), nil)
|
||||
@@ -2753,10 +2873,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 := ""
|
||||
@@ -2778,10 +2900,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
|
||||
@@ -3172,14 +3298,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) {
|
||||
@@ -3222,6 +3348,12 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE
|
||||
}
|
||||
statusCode := failoverErr.StatusCode
|
||||
responseBody := failoverErr.ResponseBody
|
||||
if statusCode == http.StatusBadRequest && service.IsOpenAICompatibleModelNotFound400(responseBody) && !streamStarted {
|
||||
upstreamMsg := service.SanitizeUpstreamErrorMessage(service.ExtractUpstreamErrorMessage(responseBody))
|
||||
service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "")
|
||||
service.WriteOpenAIUpstreamClientError(c, statusCode, responseBody, upstreamMsg)
|
||||
return
|
||||
}
|
||||
if service.IsOpenAISilentRefusalErrorBody(responseBody) {
|
||||
service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "")
|
||||
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage(), streamStarted)
|
||||
@@ -3359,7 +3491,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
|
||||
}
|
||||
}
|
||||
@@ -3412,6 +3544,10 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
|
||||
if c == nil || c.Writer == nil {
|
||||
return false
|
||||
}
|
||||
if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
|
||||
failoverClientGone(c)
|
||||
return false
|
||||
}
|
||||
// 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。
|
||||
compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c)
|
||||
if compactKeepaliveCommitted {
|
||||
@@ -3526,7 +3662,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
|
||||
}
|
||||
}
|
||||
@@ -3581,6 +3717,21 @@ func isOpenAIWSUpgradeRequest(r *http.Request) bool {
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade")
|
||||
}
|
||||
|
||||
// blockedModelAllowlistCandidate 对全部候选模型逐一校验分组白名单,返回第一个
|
||||
// 未命中的值(全部命中或白名单未开启返回空串)。WS 帧与 HTTP 请求体共用该
|
||||
// 规则:重复 model 键/大小写变体可能被上游按末值绑定,任一未命中即拒绝。
|
||||
func blockedModelAllowlistCandidate(group *service.Group, candidates []string) string {
|
||||
if group == nil || !group.ModelAllowlistEnabled() {
|
||||
return ""
|
||||
}
|
||||
for _, candidate := range candidates {
|
||||
if !group.ModelAllowlist.Allows(candidate) {
|
||||
return candidate
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason string) {
|
||||
if conn == nil {
|
||||
return
|
||||
@@ -3844,7 +3995,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
|
||||
}
|
||||
@@ -4088,15 +4239,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) {
|
||||
|
||||
@@ -1530,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}`,
|
||||
@@ -1538,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",
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1710,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"))
|
||||
@@ -1882,7 +1916,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject
|
||||
}
|
||||
|
||||
type openAIResponsesWSUsageLogCase struct {
|
||||
firstPayload string
|
||||
firstPayload string
|
||||
// midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它
|
||||
// 回一个 response.completed,客户端按普通事件读取。
|
||||
midPayload string
|
||||
secondPayload string
|
||||
userAgent *string
|
||||
ingressMode string
|
||||
@@ -1890,6 +1927,12 @@ type openAIResponsesWSUsageLogCase struct {
|
||||
billingModelSource string
|
||||
accountModelMapping map[string]any
|
||||
afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error
|
||||
// group 覆盖 apiKey.Group(分组级模型白名单测试用);nil 保持原有无分组行为。
|
||||
group *service.Group
|
||||
// firstFrameCloseExpected:首帧即被拒(连接被 1008 关闭),不期待任何响应帧。
|
||||
firstFrameCloseExpected bool
|
||||
// secondTurnCloseExpected:第二个 turn 被拒(连接被 1008 关闭)。
|
||||
secondTurnCloseExpected bool
|
||||
}
|
||||
|
||||
type openAIResponsesWSUsageLogResult struct {
|
||||
@@ -2809,8 +2852,11 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
turnCount := 1
|
||||
if strings.TrimSpace(tc.midPayload) != "" {
|
||||
turnCount++
|
||||
}
|
||||
if strings.TrimSpace(tc.secondPayload) != "" {
|
||||
turnCount = 2
|
||||
turnCount++
|
||||
}
|
||||
upstreamPayloadCh := make(chan []byte, turnCount)
|
||||
upstreamErrCh := make(chan error, 1)
|
||||
@@ -2914,7 +2960,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
BillingModelSource: tc.billingModelSource,
|
||||
}},
|
||||
groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI},
|
||||
}, nil, nil, nil)
|
||||
}, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
@@ -2963,6 +3009,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
GroupID: &groupID,
|
||||
User: &service.User{ID: 1701, Status: service.StatusActive},
|
||||
}
|
||||
if tc.group != nil {
|
||||
apiKey.Group = tc.group
|
||||
}
|
||||
router := gin.New()
|
||||
router.Use(func(c *gin.Context) {
|
||||
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
|
||||
@@ -2994,6 +3043,19 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
|
||||
if tc.firstFrameCloseExpected {
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, readErr := clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.Error(t, readErr, "first frame should have been rejected with a close")
|
||||
var closeErr coderws.CloseError
|
||||
require.ErrorAs(t, readErr, &closeErr)
|
||||
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
||||
require.Contains(t, closeErr.Reason, "not available for this group")
|
||||
_ = clientConn.CloseNow()
|
||||
return openAIResponsesWSUsageLogResult{}
|
||||
}
|
||||
|
||||
clientEvents := make([][]byte, 0, turnCount)
|
||||
readCompleted := func() {
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
@@ -3004,11 +3066,30 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
|
||||
clientEvents = append(clientEvents, append([]byte(nil), event...))
|
||||
}
|
||||
readCompleted()
|
||||
if turnCount == 2 {
|
||||
if tc.midPayload != "" {
|
||||
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.midPayload))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
readCompleted()
|
||||
}
|
||||
if strings.TrimSpace(tc.secondPayload) != "" && (turnCount >= 2) {
|
||||
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
|
||||
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.secondPayload))
|
||||
cancelWrite()
|
||||
require.NoError(t, err)
|
||||
if tc.secondTurnCloseExpected {
|
||||
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
|
||||
_, _, readErr := clientConn.Read(readCtx)
|
||||
cancelRead()
|
||||
require.Error(t, readErr, "second turn should have been rejected with a close")
|
||||
var closeErr coderws.CloseError
|
||||
require.ErrorAs(t, readErr, &closeErr)
|
||||
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
|
||||
require.Contains(t, closeErr.Reason, "not available for this group")
|
||||
_ = clientConn.CloseNow()
|
||||
return openAIResponsesWSUsageLogResult{}
|
||||
}
|
||||
readCompleted()
|
||||
}
|
||||
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
func wsAllowlistGroup(enabled bool, models ...string) *service.Group {
|
||||
return &service.Group{
|
||||
ID: 4201,
|
||||
Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{
|
||||
Enabled: enabled,
|
||||
Models: models,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 首帧拒绝:模型不在白名单时连接被 1008 关闭(passthrough relay 模式)。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_Passthrough(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModePassthrough,
|
||||
firstFrameCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 首帧拒绝:原生 ingress 模式。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_NativeIngress(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModeDedicated,
|
||||
firstFrameCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 首帧命中白名单:连接正常建立并收到 response.completed(passthrough)。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameAllowlistedModelProceeds(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) != 1 {
|
||||
t.Fatalf("expected one completed event, got %d", len(got.clientEvents))
|
||||
}
|
||||
}
|
||||
|
||||
// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(passthrough relay 模式)。
|
||||
func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_Passthrough(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModePassthrough,
|
||||
secondTurnCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(原生 ingress 模式)。
|
||||
func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_NativeIngress(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModeDedicated,
|
||||
secondTurnCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 后续 turn 省略 model:沿用会话初始模型,白名单校验通过。
|
||||
func TestOpenAIResponsesWebSocket_SubsequentTurnOmittedModelUsesSessionModel(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) != 2 {
|
||||
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
|
||||
}
|
||||
if len(got.upstreamPayloads) != 2 {
|
||||
t.Fatalf("expected two upstream frames, got %d", len(got.upstreamPayloads))
|
||||
}
|
||||
}
|
||||
|
||||
// 白名单关闭:不受任何影响。
|
||||
func TestOpenAIResponsesWebSocket_DisabledAllowlistDoesNotInterfere(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
group: wsAllowlistGroup(false, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) != 2 {
|
||||
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
|
||||
}
|
||||
}
|
||||
|
||||
// 首帧含重复 model 键(首个在白名单、末个不在):passthrough 会原样转发两个字段,
|
||||
// 上游按末值绑定时得到禁用模型,必须在入口拒绝。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameDuplicateModelKeysRejected(t *testing.T) {
|
||||
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: mode,
|
||||
firstFrameCloseExpected: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 首帧混用大小写变体键({"model":"allowed","Model":"blocked"}):绑定系上游
|
||||
// 大小写不敏感且末值生效会绑定 blocked,必须在入口拒绝。仅含变体键的帧则会在
|
||||
// 「model is required」处更早被关闭。
|
||||
func TestOpenAIResponsesWebSocket_FirstFrameCaseVariantModelKeyRejected(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","Model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModePassthrough,
|
||||
firstFrameCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 后续 turn 含重复 model 键(首个在白名单、末个不在):拒绝并关闭连接。
|
||||
func TestOpenAIResponsesWebSocket_SubsequentTurnDuplicateModelKeysRejected(t *testing.T) {
|
||||
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: mode,
|
||||
secondTurnCloseExpected: true,
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 重复但同值的 model 键不误伤,连接正常完成两个 turn。
|
||||
func TestOpenAIResponsesWebSocket_DuplicateIdenticalModelKeysAllowed(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-5.4","stream":false}`,
|
||||
secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) != 2 {
|
||||
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
|
||||
}
|
||||
}
|
||||
|
||||
// session.update 轮换绕过:首帧用白名单内模型建立会话,session.update 把会话
|
||||
// 模型改为白名单外的模型,随后 response.create 携带嵌套 session.model(白名单
|
||||
// 内)让帧内候选非空。实际生效模型(轮换后的会话模型)必须始终参与校验。
|
||||
func TestOpenAIResponsesWebSocket_SessionUpdateRotationBypassRejected(t *testing.T) {
|
||||
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
midPayload: `{"type":"session.update","session":{"model":"gpt-4.1"}}`,
|
||||
secondPayload: `{"type":"response.create","session":{"model":"gpt-5.4"},"stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
ingressMode: service.OpenAIWSIngressModePassthrough,
|
||||
secondTurnCloseExpected: true,
|
||||
})
|
||||
}
|
||||
|
||||
// 轮换后的会话模型本身在白名单内时,省略 model 的后续 turn 正常放行。
|
||||
func TestOpenAIResponsesWebSocket_SessionUpdateToAllowedModelStillWorks(t *testing.T) {
|
||||
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
|
||||
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
|
||||
midPayload: `{"type":"session.update","session":{"model":"gpt-5.4"}}`,
|
||||
secondPayload: `{"type":"response.create","stream":false}`,
|
||||
group: wsAllowlistGroup(true, "gpt-5.4"),
|
||||
})
|
||||
if len(got.clientEvents) < 2 {
|
||||
t.Fatalf("expected at least two completed events, got %d", len(got.clientEvents))
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group) {
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
if h.openAIGatewayService == nil {
|
||||
writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "OpenAI model discovery is not configured")
|
||||
return
|
||||
}
|
||||
response, account, err := h.openAIGatewayService.FetchPinnedOpenAIModelsList(
|
||||
c.Request.Context(), group, h.maxAccountSwitches, c.GetHeader("If-None-Match"),
|
||||
)
|
||||
if c.Request.Context().Err() != nil {
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, service.ErrNoPinnedCodexModelsAccounts) {
|
||||
writeOpenAIModelsError(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI model discovery accounts")
|
||||
return
|
||||
}
|
||||
writeOpenAIModelsError(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
|
||||
return
|
||||
}
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
writeOpenAIModelsResponse(c, response)
|
||||
}
|
||||
|
||||
func writeOpenAIModelsError(c *gin.Context, status int, errorType, message string) {
|
||||
c.JSON(status, gin.H{"error": gin.H{"type": errorType, "message": message}})
|
||||
}
|
||||
|
||||
func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) {
|
||||
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)
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
func performOrdinaryPinnedModelsRequest(t *testing.T, codex *OpenAIGatewayHandler, group *service.Group, path, etag string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
|
||||
c.Request.Header.Set("If-None-Match", etag)
|
||||
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
|
||||
h.Models(c)
|
||||
return recorder
|
||||
}
|
||||
|
||||
func ordinaryPinnedModelIDs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
|
||||
t.Helper()
|
||||
var response gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
|
||||
require.NotNil(t, response.Data, "an empty catalog must be an array, not null")
|
||||
return modelIDsForTest(response.Data)
|
||||
}
|
||||
|
||||
func TestOrdinaryPinnedModelsUsesSelectedAccountsAndFinalETag(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, true),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
|
||||
1: `{"data":[{"id":"unselected"}]}`,
|
||||
2: `{"data":[{"id":"special-model","owned_by":"first","created":123},{"id":"gpt-image-1"}]}`,
|
||||
3: `{"data":[{"id":"special-model","owned_by":"second"},{"id":"text-embedding-3-large"}]}`,
|
||||
}}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 91, Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 3}}}
|
||||
first := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
|
||||
require.Equal(t, []string{"special-model", "gpt-image-1", "text-embedding-3-large"}, ordinaryPinnedModelIDs(t, first))
|
||||
var response gatewayModelsResponseForTest
|
||||
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &response))
|
||||
require.Equal(t, "model", response.Data[0].Object)
|
||||
require.Equal(t, "first", response.Data[0].OwnedBy)
|
||||
require.EqualValues(t, 123, response.Data[0].Created)
|
||||
require.Equal(t, []int64{2, 3}, upstream.accountIDs())
|
||||
alias := performOrdinaryPinnedModelsRequest(t, h, group, "/models", first.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusNotModified, alias.Code)
|
||||
require.Empty(t, alias.Body.String())
|
||||
require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "fresh requests must not hit upstream")
|
||||
|
||||
otherGroup := *group
|
||||
otherGroup.ID++
|
||||
otherGroup.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"text-embedding-3-large", "special-model"}}
|
||||
filtered := performOrdinaryPinnedModelsRequest(t, h, &otherGroup, "/v1/models", first.Header().Get("ETag"))
|
||||
require.Equal(t, http.StatusOK, filtered.Code)
|
||||
require.Equal(t, []string{"text-embedding-3-large", "special-model"}, ordinaryPinnedModelIDs(t, filtered))
|
||||
require.NotEqual(t, first.Header().Get("ETag"), filtered.Header().Get("ETag"))
|
||||
again := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, first.Body.String(), again.Body.String(), "group filters must not modify account cache")
|
||||
require.Equal(t, []int64{2, 3}, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestOrdinaryPinnedModelsFailureAndEmptyPolicies(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
accountIDs []int64
|
||||
bodies map[int64]string
|
||||
statuses map[int64]int
|
||||
fallback bool
|
||||
selected []string
|
||||
wantStatus int
|
||||
wantIDs []string
|
||||
}{
|
||||
{name: "partial failure", accountIDs: []int64{2, 3}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, statuses: map[int64]int{3: 503}, wantStatus: 200, wantIDs: []string{"ok"}},
|
||||
{name: "all failed", accountIDs: []int64{2, 3}, statuses: map[int64]int{2: 429, 3: 504}, wantStatus: 502},
|
||||
{name: "missing members", accountIDs: []int64{99}, wantStatus: 503},
|
||||
{name: "empty config", wantStatus: 503},
|
||||
{name: "fallback from missing members", accountIDs: []int64{99}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
|
||||
{name: "fallback from failure", accountIDs: []int64{2}, statuses: map[int64]int{2: 503}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
|
||||
{name: "valid empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[]}`}, fallback: true, wantStatus: 200},
|
||||
{name: "filtered empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, selected: []string{"absent"}, fallback: true, wantStatus: 200},
|
||||
{name: "invalid envelope", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"error":"denied"}`}, wantStatus: 502},
|
||||
{name: "null data", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":null}`}, wantStatus: 502},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(3, service.StatusActive, true, false),
|
||||
}
|
||||
bodies := map[int64]string{1: `{"data":[{"id":"from-scheduler"}]}`}
|
||||
for id, body := range tc.bodies {
|
||||
bodies[id] = body
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: bodies, statuses: tc.statuses}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 92, Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: tc.accountIDs, FallbackToScheduler: tc.fallback}}
|
||||
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String())
|
||||
if tc.wantStatus == 200 {
|
||||
require.Equal(t, len(tc.wantIDs), len(ordinaryPinnedModelIDs(t, recorder)))
|
||||
if len(tc.wantIDs) > 0 {
|
||||
require.Equal(t, tc.wantIDs, ordinaryPinnedModelIDs(t, recorder))
|
||||
} else {
|
||||
require.NotContains(t, upstream.accountIDs(), int64(1), "empty success must not trigger fallback")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestOrdinaryPinnedModelsSkipsPersistentlyUnavailableAccounts(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, true),
|
||||
newPinnedCodexAccount(2, service.StatusActive, false, false),
|
||||
newPinnedCodexAccount(3, service.StatusDisabled, true, false),
|
||||
newPinnedCodexAccount(4, service.StatusActive, true, false),
|
||||
}
|
||||
expired := time.Now().Add(-time.Hour)
|
||||
accounts[3].ExpiresAt = &expired
|
||||
accounts[3].AutoPauseOnExpired = true
|
||||
overloaded := time.Now().Add(time.Hour)
|
||||
accounts[0].OverloadUntil = &overloaded
|
||||
accounts[0].TempUnschedulableUntil = &overloaded
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: `{"data":[{"id":"available"}]}`}}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 93, Platform: service.PlatformOpenAI,
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1, 2, 3, 4, 99}}}
|
||||
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []int64{1}, upstream.accountIDs())
|
||||
}
|
||||
|
||||
func TestPinnedModelsAllowlistExpandsWildcardsForBothRepresentations(t *testing.T) {
|
||||
for _, codex := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
|
||||
accounts := []service.Account{newPinnedCodexAccount(1, service.StatusActive, true, false)}
|
||||
accounts[0].Credentials["model_mapping"] = map[string]any{
|
||||
"public-a": "gpt-5.5", "public-b": "gpt-5.5", "blocked": "gpt-5.5",
|
||||
}
|
||||
body := `{"data":[{"id":"gpt-5.5","owned_by":"provider"}]}`
|
||||
if codex {
|
||||
body = `{"models":[{"slug":"gpt-5.5","context_window":424242}]}`
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: body}}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 95, Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"public-b", "public-*"}},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}}}
|
||||
if codex {
|
||||
recorder := performPinnedCodexModelsRequest(t, h, group, "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"public-b", "public-a"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Contains(t, recorder.Body.String(), `"context_window":424242`)
|
||||
} else {
|
||||
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []string{"public-b", "public-a"}, ordinaryPinnedModelIDs(t, recorder))
|
||||
require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedModelsMappingFollowsUpstreamDiscoveryForBothRepresentations(t *testing.T) {
|
||||
for _, codex := range []bool{false, true} {
|
||||
t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
|
||||
accounts := []service.Account{
|
||||
newPinnedCodexAccount(1, service.StatusActive, true, false),
|
||||
newPinnedCodexAccount(2, service.StatusActive, true, false),
|
||||
}
|
||||
accounts[0].Credentials["model_mapping"] = map[string]any{"unselected-alias": "gpt-5.5"}
|
||||
accounts[1].Credentials["model_mapping"] = map[string]any{
|
||||
"public-alias": "gpt-5.5", "missing-alias": "missing-upstream", "custom-*": "gpt-5.5",
|
||||
}
|
||||
body := `{"data":[{"id":"gpt-5.5","owned_by":"provider","created":123},{"id":"unmapped-upstream"}]}`
|
||||
if codex {
|
||||
body = `{"models":[{"slug":"gpt-5.5","context_window":424242},{"slug":"unmapped-upstream"}]}`
|
||||
}
|
||||
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{2: body}}
|
||||
h := newPinnedCodexTestHandler(accounts, upstream, 3)
|
||||
group := &service.Group{ID: 94, Platform: service.PlatformOpenAI,
|
||||
ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"custom-concrete", "public-alias", "unselected-alias", "missing-alias"}},
|
||||
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}}
|
||||
var recorder *httptest.ResponseRecorder
|
||||
if codex {
|
||||
recorder = performPinnedCodexModelsRequest(t, h, group, "")
|
||||
} else {
|
||||
recorder = performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
|
||||
}
|
||||
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
|
||||
require.Equal(t, []int64{2}, upstream.accountIDs(), "mappings must not bypass pinned discovery")
|
||||
if codex {
|
||||
require.Equal(t, []string{"custom-concrete", "public-alias"}, codexHandlerManifestSlugs(t, recorder))
|
||||
require.Contains(t, recorder.Body.String(), `"context_window":424242`)
|
||||
} else {
|
||||
require.Equal(t, []string{"custom-concrete", "public-alias"}, ordinaryPinnedModelIDs(t, recorder))
|
||||
require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user