mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-14 12:02:27 +08:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1b76e2245 | ||
|
|
7e2e9ba050 | ||
|
|
c043c24774 | ||
|
|
11c1e944b9 | ||
|
|
d99ee72911 | ||
|
|
dec47e8fae | ||
|
|
fad2f215e8 | ||
|
|
20ad5ec506 | ||
|
|
0b6b4ea956 | ||
|
|
b0f5007f04 | ||
|
|
56f3d3c9b0 | ||
|
|
b74024c786 | ||
|
|
21aacde0b3 | ||
|
|
f3a3d86845 | ||
|
|
b22f73e725 | ||
|
|
d6d53052f8 | ||
|
|
bd52e5d770 | ||
|
|
d4cada3b6b | ||
|
|
8f5caef78e | ||
|
|
2ef1246295 | ||
|
|
dd9a177a62 | ||
|
|
85a27fae39 | ||
|
|
eb1c5c7ee8 | ||
|
|
d9fba8fe78 | ||
|
|
15b3c0c5aa | ||
|
|
2e338af822 | ||
|
|
682c4fe0e6 | ||
|
|
948b63c9ca | ||
|
|
4c80d160dd | ||
|
|
570ea74d12 | ||
|
|
2980ff3850 | ||
|
|
04c96a2015 | ||
|
|
07f980b99f | ||
|
|
fe21725865 | ||
|
|
698547418f | ||
|
|
f54e9827a0 | ||
|
|
d29acc29a5 | ||
|
|
66998918b6 | ||
|
|
da6194c1c3 | ||
|
|
132d446ca9 | ||
|
|
0eac363e67 | ||
|
|
796313e993 | ||
|
|
c772d18666 | ||
|
|
2bf9c6d56b | ||
|
|
94df1fffc2 | ||
|
|
30967d5d9a | ||
|
|
dfdbc27709 | ||
|
|
beeb2f989b | ||
|
|
77d4df9544 | ||
|
|
7ceabb3fd5 | ||
|
|
26d894ef4f | ||
|
|
d6467f6eb0 | ||
|
|
8ed9f754cf | ||
|
|
3deb2f17d8 | ||
|
|
baaae8e121 | ||
|
|
5c9629ddb7 | ||
|
|
4d13925c9e | ||
|
|
d74e669a23 | ||
|
|
7d3bf86e55 | ||
|
|
2a871ec852 | ||
|
|
a35ff9613e | ||
|
|
739c0ff9c5 | ||
|
|
3d99acb0ad | ||
|
|
7b6111f2f7 | ||
|
|
272735b0a7 |
@@ -59,8 +59,8 @@ Please read the following carefully before using this project:
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -84,12 +84,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>Thanks to Unity2 for sponsoring this project! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> is a high-performance AI model API relay for individuals, teams, and enterprises, handling 30B+ tokens/day with 5000 RPM concurrency. One API Key works across Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows, with balance billing, bundled subscriptions, enterprise invoicing, and 1-on-1 support. <a href="https://unity2.ai/register?source=sub2api">Register</a> to claim $2 in balance, plus $10 more by joining the official group — up to $12 in free credit.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
|
||||
<td>Thanks to Veilx for sponsoring this project! <a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN is purpose-built for large-scale AI API traffic, deeply optimized for relay services and call chains across OpenAI, Claude, Gemini, and scenarios like chat, image generation, embeddings, and streaming — delivering lower latency and higher stability under heavy concurrency. It also offers China three-network optimized return lines, making it ideal for global AI relay platforms, overseas AI SaaS, and cross-border high-concurrency deployments.
|
||||
@@ -102,16 +96,6 @@ Please read the following carefully before using this project:
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>Thanks to 666api for sponsoring this project! <a href="https://666api.work/sub2api">666api</a> is an all-in-one platform offering:<br>
|
||||
⚡ API Relay — Pay-as-you-go access to global models sourced 100% from official providers, up to 75% off official pricing<br>
|
||||
Exclusive: Zhipu GLM 50% off · DeepSeek V4-pro 50% off · Seedance 2.0 8% off (whitelisted) · HappyHorse Overseas 30% off (whitelisted)<br>
|
||||
🔑 GPT Subscription Accounts (same-origin IP included) · Global Residential IP <br>
|
||||
💰 Invoices supported
|
||||
</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.
|
||||
@@ -149,8 +133,8 @@ Model authenticity: no content intervention or secondary filtering — experienc
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -165,6 +149,26 @@ Model authenticity: no content intervention or secondary filtering — experienc
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/s/dC4k"><img src="assets/partners/logos/fennoai.jpg" alt="FennoAI" width="150"></a></td>
|
||||
<td>Thanks to FennoAI for sponsoring this project! FennoAI is a high-stability, high-performance API relay provider for enterprise R&D teams and developers, compatible with the OpenAI and Anthropic protocols and seamlessly integrating with mainstream AI coding tools such as Codex, Claude Code, and OpenCode. The platform delivers enterprise-grade stability, supporting call volumes of 100 billion tokens per day, and supports business-to-business settlement and invoicing for both domestic and overseas entities to meet enterprise R&D and procurement needs. As an exclusive benefit for Sub2API users, purchase a subscription via the <a href="https://api.fenno.ai/s/dC4k">exclusive link</a> to get $50 worth of Coding Plan credit for only $1.99. Referral rewards are also available: invite friends to purchase and earn up to 20% commission — the more you invite, the more you earn.</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://lanox.ai/?c=6"><img src="assets/partners/logos/lanox.jpg" alt="LanoX AI" width="150"></a></td>
|
||||
<td>Thank you to LanoX AI for sponsoring this project! <a href="https://lanox.ai/?c=6">LanoX AI</a> provides stable, cost-effective global model access services for developers, teams, and enterprises. 🎁 New User Benefits — Claim millions of free tokens, plus 500+ free models for easier low-cost testing, validation, and deployment 🧠 Global Leading Models — GPT · Claude · Gemini · Qwen · Grok... 🎬 Multimodal Creation — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ Enterprise-Grade Reliability — High availability 💎 native capability output 💎 no intelligence degradation 💎 no model mixing 💎 transparent usage and billing 💎 💰 Lower API Costs — Top-tier models from as low as 10% of official pricing, with clear documentation, simple integration, invoicing support, and enterprise-scale batch usage 🏢 Enterprise Choice — Ideal for AI products, Agents, content platforms, and R&D teams with high-volume model usage</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## Overview
|
||||
|
||||
+24
-20
@@ -60,8 +60,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -87,12 +87,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>感谢 Unity2 赞助本项目! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。同时支持余额计费、组合订阅、首充优惠、企业开票、专属 1v1 对接,适合个人高频使用和企业长期接入。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度,适合先体验后长期使用。<a href="https://unity2.ai/register?source=sub2api">注册链接</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
|
||||
<td>感谢 Veilx 赞助本项目! <a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN 专为超大规模 API 请求场景打造,针对 AI 中转站业务与 AI API 调用链路进行了深度优化,轻松应对高并发、高频请求与大流量传输,为开发者与企业提供更快、更稳、更低延迟的加速体验。无论是 OpenAI、Claude、Gemini 等 AI 接口中转,还是聊天、绘图、Embedding、流式输出等复杂场景,Veilx 都能显著提升响应速度与连接稳定性,有效降低网络波动带来的超时与失败问题。同时,Veilx 提供中国三网优化回国极速线路,大幅提升中国大陆地区访问海外 AI 服务的速度与稳定性,特别适合全球 AI 中转平台、海外 AI SaaS、跨境业务与高并发 API 系统部署。专为 AI API 而生,让你的 AI 中转服务更快、更稳、更省心。<a href="https://veilx.io/#/hello/SJRBRVDV">购买地址</a>
|
||||
@@ -105,16 +99,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>感谢 666api 赞助本项目!<a href="https://666api.work/sub2api">666api</a> 是一站式综合服务平台,提供:<br>
|
||||
⚡ API 中转 — 全球模型按量计费接入,100% 官方源直供,最高 75 折优惠<br>
|
||||
独家特惠:智谱 GLM 5 折 · DeepSeek V4-pro 5 折 · Seedance 2.0 0.8 折(白名单)· HappyHorse 海外版 3 折(白名单)<br>
|
||||
🔑 GPT 订阅账号(含同源 IP)· 全球住宅 IP<br>
|
||||
💰 支持开票
|
||||
</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% 可用性,关键调用从不掉队。
|
||||
@@ -152,8 +136,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -168,6 +152,26 @@
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/s/dC4k"><img src="assets/partners/logos/fennoai.jpg" alt="FennoAI" width="150"></a></td>
|
||||
<td>感谢 FennoAI 赞助本项目!FennoAI 是一家面向企业研发团队和开发者的高稳定、高性能 API 中转服务商,兼容 OpenAI 与 Anthropic 协议,可无缝接入 Codex、Claude Code、OpenCode 等主流 AI 编程工具。平台具备企业级稳定性,可支撑千亿 Token/日的调用规模,并支持境内外主体公对公结算及开票,满足企业级研发与采购需求。作为 Sub2API 用户专属福利,通过<a href="https://api.fenno.ai/s/dC4k">专属链接</a>购买订阅,仅需 1.99 美元即可获得价值 50 美元的 Coding Plan 额度。同时支持邀请奖励,邀请好友购买最高可获得 20% 返佣,邀请越多,奖励越高。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://lanox.ai/?c=6"><img src="assets/partners/logos/lanox.jpg" alt="LanoX AI" width="150"></a></td>
|
||||
<td>感谢 LanoX AI 对本项目的赞助!<a href="https://lanox.ai/?c=6">LanoX AI</a> 为开发者、团队与企业提供稳定、高性价比的全球模型接入服务。 🎁 新用户福利 — 免费领取 百万 Token ,更有500+ 免费模型 — 低成本测试、验证、部署更轻松 🧠 全球主流模型 — GPT · Claude · Gemini · Qwen · Grok... 🎬 多模态创作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ 企业级稳定服务 — 高可用💎原生能力输出💎不降智💎不混模💎调用与计费透明💎 💰 更低调用成本 — 顶级模型低至官方价 1 折起,文档清晰、接入简单、支持开票与企业批量调用 🏢 企业优选 — 适用于 AI 产品、Agent、内容平台、研发团队批量调用</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 项目概述
|
||||
|
||||
+24
-16
@@ -59,8 +59,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
|
||||
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
@@ -86,12 +86,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
|
||||
<td>Unity2 のご支援に感謝します!<a href="https://unity2.ai/register?source=sub2api">Unity2</a> は個人開発者、チーム、企業向けの高性能 AI モデル API 中継プラットフォームです。中国の大手企業に長期にわたりサービスを提供しており、1日あたり 300 億以上のトークン呼び出しを処理し、5000 RPM 級の高並列性をサポートします。1つの API キーで Claude Code、Codex、OpenAI モデル、IDE プラグイン、Agent ワークフローなど様々なシナリオに対応できます。エンタープライズグレードの安定供給能力を備え、高並列・継続的な呼び出し・チームの集中購入シーンでも低レイテンシと高可用性を維持します。残高課金、組み合わせサブスクリプション、初回チャージ特典、企業向け請求書発行、専属 1v1 サポートにも対応しており、個人の頻繁な利用にも企業の長期導入にも適しています。今 Unity2.ai に登録すると $2 の残高、公式グループに参加するとさらに $10 の残高がもらえ、合計最大 $12 の無料クレジットを獲得できます — 試用後に長期利用したい方に最適です。<a href="https://unity2.ai/register?source=sub2api">登録リンク</a>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
|
||||
<td>Veilx のご支援に感謝します!<a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN は超大規模 API リクエストシナリオ向けに設計されており、AI 中継サービスと AI API 呼び出しチェーンに対して深く最適化されています。高並列・高頻度リクエスト・大容量トラフィックに容易に対応し、開発者と企業により高速で安定した、低レイテンシの加速体験を提供します。OpenAI、Claude、Gemini などの AI インターフェース中継はもちろん、チャット、画像生成、Embedding、ストリーミング出力などの複雑なシナリオでも、Veilx は応答速度と接続安定性を大幅に向上させ、ネットワーク変動によるタイムアウトや失敗を効果的に削減します。さらに、Veilx は中国三大ネットワーク最適化の高速回線を提供しており、中国本土から海外 AI サービスへのアクセス速度と安定性を大幅に向上させます。グローバル AI 中継プラットフォーム、海外 AI SaaS、越境ビジネス、高並列 API システム展開に特に適しています。AI API のために生まれ、あなたの AI 中継サービスをより速く、より安定して、より安心に。<a href="https://veilx.io/#/hello/SJRBRVDV">購入リンク</a>
|
||||
@@ -104,12 +98,6 @@
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
|
||||
<td>666api のご支援に感謝します!<a href="https://666api.work/sub2api">666api</a> はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応
|
||||
</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% の可用性、重要な呼び出しは決して遅れません。
|
||||
@@ -147,8 +135,8 @@
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
|
||||
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
|
||||
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@@ -163,6 +151,26 @@
|
||||
<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>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://api.fenno.ai/s/dC4k"><img src="assets/partners/logos/fennoai.jpg" alt="FennoAI" width="150"></a></td>
|
||||
<td>FennoAI のご支援に感謝します!FennoAI は、企業の研究開発チームと開発者向けの高安定・高性能 API 中継サービスプロバイダーです。OpenAI と Anthropic のプロトコルに対応し、Codex、Claude Code、OpenCode などの主要 AI コーディングツールにシームレスに接続できます。エンタープライズ級の安定性を備え、1 日あたり千億規模の Token 呼び出しに対応。国内外法人間の企業間決済と請求書発行もサポートし、企業の研究開発・調達ニーズに応えます。Sub2API ユーザー限定特典として、<a href="https://api.fenno.ai/s/dC4k">専用リンク</a>からサブスクリプションを購入すると、わずか 1.99 ドルで 50 ドル相当の Coding Plan クレジットを獲得できます。さらに招待報酬にも対応しており、友達の購入で最大 20% の還元を獲得可能。招待が多いほど、報酬も増えます。</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<td width="180"><a href="https://lanox.ai/?c=6"><img src="assets/partners/logos/lanox.jpg" alt="LanoX AI" width="150"></a></td>
|
||||
<td>本プロジェクトをご支援いただいている <a href="https://lanox.ai/?c=6">LanoX AI</a> に感謝します!LanoX AI は、開発者、チーム、企業向けに、安定性とコストパフォーマンスに優れたグローバルモデル接続サービスを提供しています。 🎁 新規ユーザー特典 — 数百万 Token を無料で獲得可能。さらに 500+ の無料モデルで、低コストなテスト、検証、デプロイをより簡単に 🧠 世界の主要モデル — GPT · Claude · Gemini · Qwen · Grok... 🎬 マルチモーダル制作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ エンタープライズ級の安定性 — 高可用性 💎 ネイティブ能力の出力 💎 性能劣化なし 💎 モデル混在なし 💎 利用量と課金が透明 💎 💰 より低い利用コスト — トップモデルを公式価格の 1 割から利用可能。明確なドキュメント、簡単な接続、請求書発行、企業向け一括利用に対応 🏢 企業に最適 — AI プロダクト、Agent、コンテンツプラットフォーム、大量利用する開発チームに最適</td>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
## 概要
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 204 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 102 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
@@ -0,0 +1,237 @@
|
||||
// profit-preview 读取生产只读导出的 JSON(分组利润配置、账号倍率与探测状态、
|
||||
// 用户覆盖倍率、主力模型清单),复用线上 U/D/阈值判定做五平台离线预演。
|
||||
//
|
||||
// 用法:
|
||||
//
|
||||
// go run ./cmd/profit-preview -input dump.json [-assume-enabled] [-json]
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type inputGroup struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
RateMultiplier float64 `json:"rate_multiplier"`
|
||||
SubscriptionType string `json:"subscription_type"`
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
|
||||
PeakRateEnabled bool `json:"peak_rate_enabled"`
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
}
|
||||
|
||||
type inputAccount struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Platform string `json:"platform"`
|
||||
Type string `json:"type"`
|
||||
RateMultiplier *float64 `json:"rate_multiplier"`
|
||||
Extra map[string]any `json:"extra"`
|
||||
ModelMapping map[string]string `json:"model_mapping"`
|
||||
}
|
||||
|
||||
type inputEntry struct {
|
||||
Group inputGroup `json:"group"`
|
||||
Accounts []inputAccount `json:"accounts"`
|
||||
UserOverrides map[string]*float64 `json:"user_overrides"`
|
||||
Models []string `json:"models"`
|
||||
}
|
||||
|
||||
type inputDoc struct {
|
||||
Groups []inputEntry `json:"groups"`
|
||||
}
|
||||
|
||||
func main() {
|
||||
inputPath := flag.String("input", "", "生产只读导出 JSON 路径")
|
||||
assumeEnabled := flag.Bool("assume-enabled", false, "把当前关闭的支持平台分组按保存配置视为已启用")
|
||||
jsonOut := flag.Bool("json", false, "以 JSON 输出完整报告(默认输出可读表格)")
|
||||
flag.Parse()
|
||||
if *inputPath == "" {
|
||||
fmt.Fprintln(os.Stderr, "usage: profit-preview -input dump.json [-assume-enabled] [-json]")
|
||||
os.Exit(2)
|
||||
}
|
||||
raw, err := os.ReadFile(*inputPath)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "read input: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
inputs, err := parsePreviewInputs(raw, *assumeEnabled)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "parse input: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
evalAt := time.Now()
|
||||
reports := service.PreviewProfitAdmission(inputs, evalAt)
|
||||
if len(reports) == 0 {
|
||||
fmt.Fprintln(os.Stderr, "input produced no preview reports")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
if *jsonOut {
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
if err := enc.Encode(map[string]any{"evaluated_at": evalAt, "reports": reports}); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "write output: %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
fmt.Printf("利润门预演 @ %s(U=账号倍率;探测状态仅告警)\n", evalAt.Format(time.RFC3339))
|
||||
for _, report := range reports {
|
||||
fmt.Printf("\n== 分组 %d %s [%s] ==\n", report.GroupID, report.GroupName, report.Platform)
|
||||
fmt.Printf(" 利润门生效=%v 假定启用=%v | 默认 D=%.4f 阈值=%.4f | 最低有效 D=%.4f 阈值=%.4f\n",
|
||||
report.EffectiveGate, report.AssumedEnabled,
|
||||
report.DefaultD, report.ThresholdDefault, report.MinEffectiveD, report.ThresholdMinD)
|
||||
counts := map[string]int{}
|
||||
for _, v := range report.Verdicts {
|
||||
counts[v.Class]++
|
||||
rate := "-"
|
||||
if v.AccountRate != nil {
|
||||
rate = fmt.Sprintf("%.4f", *v.AccountRate)
|
||||
}
|
||||
flags := make([]string, 0, 2)
|
||||
if v.RejectedUnderMinD {
|
||||
flags = append(flags, "最低有效D下拒绝")
|
||||
}
|
||||
if len(v.Warnings) > 0 {
|
||||
flags = append(flags, strings.Join(v.Warnings, ","))
|
||||
}
|
||||
suffix := ""
|
||||
if len(flags) > 0 {
|
||||
suffix = " [" + strings.Join(flags, "; ") + "]"
|
||||
}
|
||||
fmt.Printf(" 账号 %-4d %-24s 平台=%-12s U=%-8s 来源=%-19s %s%s\n",
|
||||
v.AccountID, v.Name, v.Platform, rate, v.RateSource, v.Class, suffix)
|
||||
}
|
||||
fmt.Printf(" 分类合计: 准入=%d 利润不足=%d 倍率非法=%d\n",
|
||||
counts[service.ProfitPreviewClassAdmitted],
|
||||
counts[service.ProfitPreviewClassRejectedThreshold],
|
||||
counts[service.ProfitPreviewClassRejectedInvalidRate])
|
||||
models := make([]string, 0, len(report.RemainingByModel))
|
||||
for model := range report.RemainingByModel {
|
||||
models = append(models, model)
|
||||
}
|
||||
sort.Strings(models)
|
||||
for _, model := range models {
|
||||
fmt.Printf(" 模型 %-20s 利润门准入账号: 默认D=%d 最低有效D=%d\n",
|
||||
model, report.RemainingByModel[model], report.RemainingByModelMinD[model])
|
||||
}
|
||||
for _, model := range modelsWithZeroRemaining(report) {
|
||||
fmt.Printf(" 警告: 模型 %s 启用后利润门准入账号为 0\n", model)
|
||||
}
|
||||
for _, model := range modelsWithZeroRemainingUnderMinD(report) {
|
||||
fmt.Printf(" 警告: 模型 %s 在最低有效D(存在低倍率用户覆盖)下利润门准入账号为 0\n", model)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func parsePreviewInputs(raw []byte, assumeEnabled bool) ([]service.ProfitPreviewGroupInput, error) {
|
||||
var doc inputDoc
|
||||
if err := json.Unmarshal(raw, &doc); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(doc.Groups) == 0 {
|
||||
return nil, fmt.Errorf("input contains no groups; check the export query and target configuration")
|
||||
}
|
||||
|
||||
inputs := make([]service.ProfitPreviewGroupInput, 0, len(doc.Groups))
|
||||
for i, entry := range doc.Groups {
|
||||
if entry.Group.ID <= 0 || strings.TrimSpace(entry.Group.Platform) == "" {
|
||||
return nil, fmt.Errorf("invalid group at index %d: id and platform are required", i)
|
||||
}
|
||||
group := &service.Group{
|
||||
ID: entry.Group.ID,
|
||||
Name: entry.Group.Name,
|
||||
Platform: entry.Group.Platform,
|
||||
Status: service.StatusActive,
|
||||
Hydrated: true,
|
||||
RateMultiplier: entry.Group.RateMultiplier,
|
||||
SubscriptionType: entry.Group.SubscriptionType,
|
||||
ProfitControlEnabled: entry.Group.ProfitControlEnabled,
|
||||
ProfitMinMargin: entry.Group.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: entry.Group.ProfitSafetyBuffer,
|
||||
PeakRateEnabled: entry.Group.PeakRateEnabled,
|
||||
PeakStart: entry.Group.PeakStart,
|
||||
PeakEnd: entry.Group.PeakEnd,
|
||||
PeakRateMultiplier: entry.Group.PeakRateMultiplier,
|
||||
}
|
||||
accounts := make([]*service.Account, 0, len(entry.Accounts))
|
||||
for _, a := range entry.Accounts {
|
||||
account := &service.Account{
|
||||
ID: a.ID,
|
||||
Name: a.Name,
|
||||
Platform: a.Platform,
|
||||
Type: a.Type,
|
||||
RateMultiplier: a.RateMultiplier,
|
||||
Extra: a.Extra,
|
||||
}
|
||||
if len(a.ModelMapping) > 0 {
|
||||
mapping := make(map[string]any, len(a.ModelMapping))
|
||||
for k, v := range a.ModelMapping {
|
||||
mapping[k] = v
|
||||
}
|
||||
account.Credentials = map[string]any{"model_mapping": mapping}
|
||||
}
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
overrides := make(map[int64]float64, len(entry.UserOverrides))
|
||||
for userID, rate := range entry.UserOverrides {
|
||||
if rate == nil {
|
||||
continue
|
||||
}
|
||||
var id int64
|
||||
if _, err := fmt.Sscan(userID, &id); err == nil && id > 0 {
|
||||
overrides[id] = *rate
|
||||
}
|
||||
}
|
||||
inputs = append(inputs, service.ProfitPreviewGroupInput{
|
||||
Group: group,
|
||||
Accounts: accounts,
|
||||
UserOverrides: overrides,
|
||||
Models: entry.Models,
|
||||
AssumeEnabled: assumeEnabled,
|
||||
})
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func modelsWithZeroRemaining(report service.ProfitPreviewGroupReport) []string {
|
||||
var out []string
|
||||
for model, count := range report.RemainingByModel {
|
||||
if count == 0 {
|
||||
out = append(out, model)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
// modelsWithZeroRemainingUnderMinD 返回默认 D 下仍有准入账号、但在最低有效 D
|
||||
// 下会归零的模型。最低有效 D 来自分组内最低的用户级倍率覆盖:这些模型对那部分
|
||||
// 用户是全黑的,而只看默认 D 的告警完全看不出来。
|
||||
// 两档都为 0 的模型由 modelsWithZeroRemaining 报告,这里不重复。
|
||||
func modelsWithZeroRemainingUnderMinD(report service.ProfitPreviewGroupReport) []string {
|
||||
var out []string
|
||||
for model, count := range report.RemainingByModelMinD {
|
||||
if count == 0 && report.RemainingByModel[model] > 0 {
|
||||
out = append(out, model)
|
||||
}
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestParsePreviewInputsIgnoresNullUserOverride(t *testing.T) {
|
||||
raw := []byte(`{
|
||||
"groups": [{
|
||||
"group": {
|
||||
"id": 50,
|
||||
"name": "preview",
|
||||
"platform": "openai",
|
||||
"rate_multiplier": 0.5,
|
||||
"subscription_type": "standard",
|
||||
"profit_control_enabled": false,
|
||||
"profit_min_margin": 0.1,
|
||||
"profit_safety_buffer": 0
|
||||
},
|
||||
"accounts": [{
|
||||
"id": 1,
|
||||
"name": "cheap",
|
||||
"platform": "openai",
|
||||
"type": "apikey",
|
||||
"rate_multiplier": 0.2
|
||||
}],
|
||||
"user_overrides": {"40": null, "41": 0.4},
|
||||
"models": ["gpt-test"]
|
||||
}]
|
||||
}`)
|
||||
|
||||
inputs, err := parsePreviewInputs(raw, true)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, inputs, 1)
|
||||
require.Equal(t, map[int64]float64{41: 0.4}, inputs[0].UserOverrides)
|
||||
require.True(t, inputs[0].AssumeEnabled)
|
||||
|
||||
report := service.PreviewProfitAdmission(inputs, time.Date(2026, 1, 15, 8, 30, 0, 0, time.UTC))[0]
|
||||
require.InDelta(t, 0.4, report.MinEffectiveD, 1e-12, "null 覆盖不能被解码成 0 倍率")
|
||||
require.InDelta(t, 0.36, report.ThresholdMinD, 1e-12)
|
||||
}
|
||||
|
||||
func TestParsePreviewInputsRejectsEmptyGroups(t *testing.T) {
|
||||
for _, raw := range [][]byte{
|
||||
[]byte(`{"groups":null}`),
|
||||
[]byte(`{"groups":[]}`),
|
||||
} {
|
||||
inputs, err := parsePreviewInputs(raw, false)
|
||||
require.ErrorContains(t, err, "input contains no groups")
|
||||
require.Nil(t, inputs)
|
||||
}
|
||||
}
|
||||
|
||||
// TestModelsWithZeroRemainingWarnings 钉死两档 D 的归零告警分工:
|
||||
// 默认 D 归零由 modelsWithZeroRemaining 报告;默认 D 仍有账号但最低有效 D
|
||||
// (分组内存在更低的用户级倍率覆盖)归零的模型必须单独告警——那些用户的该
|
||||
// 模型会全黑,只看默认 D 完全看不出来。两档都为 0 时不重复告警。
|
||||
func TestModelsWithZeroRemainingWarnings(t *testing.T) {
|
||||
report := service.ProfitPreviewGroupReport{
|
||||
RemainingByModel: map[string]int{
|
||||
"both-zero": 0,
|
||||
"min-d-zero": 2,
|
||||
"healthy": 3,
|
||||
"min-d-zero-alt": 1,
|
||||
},
|
||||
RemainingByModelMinD: map[string]int{
|
||||
"both-zero": 0,
|
||||
"min-d-zero": 0,
|
||||
"healthy": 3,
|
||||
"min-d-zero-alt": 0,
|
||||
},
|
||||
}
|
||||
|
||||
if got := modelsWithZeroRemaining(report); len(got) != 1 || got[0] != "both-zero" {
|
||||
t.Fatalf("默认D归零告警应只覆盖 both-zero,got %v", got)
|
||||
}
|
||||
|
||||
got := modelsWithZeroRemainingUnderMinD(report)
|
||||
want := []string{"min-d-zero", "min-d-zero-alt"}
|
||||
if len(got) != len(want) {
|
||||
t.Fatalf("最低有效D归零告警不符: got %v want %v", got, want)
|
||||
}
|
||||
for i := range want {
|
||||
if got[i] != want[i] {
|
||||
t.Fatalf("最低有效D归零告警不符(应按模型名排序): got %v want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1 +1 @@
|
||||
0.1.168
|
||||
0.1.170
|
||||
|
||||
@@ -46,10 +46,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
return nil, err
|
||||
}
|
||||
userRepository := repository.NewUserRepository(client, db)
|
||||
passkeyRepository := repository.NewPasskeyRepository(db)
|
||||
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
|
||||
redisClient := repository.ProvideRedis(configConfig)
|
||||
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
|
||||
refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
|
||||
settingRepository := repository.NewSettingRepository(client)
|
||||
groupRepository := repository.NewGroupRepository(client, db)
|
||||
@@ -81,10 +79,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
affiliateRepository := repository.NewAffiliateRepository(client, db)
|
||||
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
|
||||
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
|
||||
redeemCache := repository.NewRedeemCache(redisClient)
|
||||
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
|
||||
@@ -98,7 +92,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
|
||||
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
|
||||
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService)
|
||||
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
|
||||
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository)
|
||||
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
|
||||
usageLogRepository := repository.NewUsageLogRepository(client, db)
|
||||
@@ -260,7 +253,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
|
||||
contentModerationRepository := repository.NewContentModerationRepository(db)
|
||||
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
|
||||
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
|
||||
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, proxyRepository, apiKeyAuthCacheInvalidator, emailService)
|
||||
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
|
||||
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor, configConfig)
|
||||
postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db)
|
||||
@@ -287,6 +280,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
|
||||
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
|
||||
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
|
||||
totpHandler := handler.NewTotpHandler(totpService)
|
||||
passkeyRepository := repository.NewPasskeyRepository(db)
|
||||
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
|
||||
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
|
||||
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
|
||||
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
|
||||
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
|
||||
|
||||
+14
-14
@@ -6828,25 +6828,25 @@ type (
|
||||
APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity,
|
||||
AuthIdentityChannel, BatchImageEvent, BatchImageItem, BatchImageJob,
|
||||
ChannelMonitor, ChannelMonitorDailyRollup, ChannelMonitorHistory,
|
||||
ChannelMonitorRequestTemplate, CompositeModelRoute,
|
||||
ErrorPassthroughRule, Group, IdempotencyRecord, IdentityAdoptionDecision,
|
||||
PaymentAuditLog, PaymentOrder, PaymentProviderInstance, PendingAuthSession,
|
||||
PromoCode, PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting,
|
||||
SubscriptionPlan, TLSFingerprintProfile, UsageCleanupTask, UsageLog, User,
|
||||
UserAllowedGroup, UserAttributeDefinition, UserAttributeValue,
|
||||
UserPlatformQuota, UserSubscription []ent.Hook
|
||||
ChannelMonitorRequestTemplate, CompositeModelRoute, ErrorPassthroughRule,
|
||||
Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog,
|
||||
PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode,
|
||||
PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
|
||||
TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
|
||||
UserAttributeDefinition, UserAttributeValue, UserPlatformQuota,
|
||||
UserSubscription []ent.Hook
|
||||
}
|
||||
inters struct {
|
||||
APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity,
|
||||
AuthIdentityChannel, BatchImageEvent, BatchImageItem, BatchImageJob,
|
||||
ChannelMonitor, ChannelMonitorDailyRollup, ChannelMonitorHistory,
|
||||
ChannelMonitorRequestTemplate, CompositeModelRoute,
|
||||
ErrorPassthroughRule, Group, IdempotencyRecord, IdentityAdoptionDecision,
|
||||
PaymentAuditLog, PaymentOrder, PaymentProviderInstance, PendingAuthSession,
|
||||
PromoCode, PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting,
|
||||
SubscriptionPlan, TLSFingerprintProfile, UsageCleanupTask, UsageLog, User,
|
||||
UserAllowedGroup, UserAttributeDefinition, UserAttributeValue,
|
||||
UserPlatformQuota, UserSubscription []ent.Interceptor
|
||||
ChannelMonitorRequestTemplate, CompositeModelRoute, ErrorPassthroughRule,
|
||||
Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog,
|
||||
PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode,
|
||||
PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
|
||||
TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
|
||||
UserAttributeDefinition, UserAttributeValue, UserPlatformQuota,
|
||||
UserSubscription []ent.Interceptor
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
+35
-2
@@ -123,6 +123,12 @@ type Group struct {
|
||||
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
|
||||
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
|
||||
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
|
||||
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"`
|
||||
// 最低毛利率,小数(0.30=30%);账号准入条件为 U <= D*(1-margin-buffer)
|
||||
ProfitMinMargin float64 `json:"profit_min_margin,omitempty"`
|
||||
// 安全缓冲,小数;与 margin 相加后从下游倍率中扣除,默认 0
|
||||
ProfitSafetyBuffer float64 `json:"profit_safety_buffer,omitempty"`
|
||||
// Edges holds the relations/edges for other nodes in the graph.
|
||||
// The values are being populated by the GroupQuery when eager-loading is set.
|
||||
Edges GroupEdges `json:"edges"`
|
||||
@@ -231,9 +237,9 @@ func (*Group) scanValues(columns []string) ([]any, error) {
|
||||
switch columns[i] {
|
||||
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
|
||||
values[i] = new([]byte)
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
|
||||
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
|
||||
values[i] = new(sql.NullBool)
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall:
|
||||
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
|
||||
values[i] = new(sql.NullFloat64)
|
||||
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
|
||||
values[i] = new(sql.NullInt64)
|
||||
@@ -599,6 +605,24 @@ func (_m *Group) assignValues(columns []string, values []any) error {
|
||||
return fmt.Errorf("unmarshal field reasoning_effort_mappings: %w", err)
|
||||
}
|
||||
}
|
||||
case group.FieldProfitControlEnabled:
|
||||
if value, ok := values[i].(*sql.NullBool); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field profit_control_enabled", values[i])
|
||||
} else if value.Valid {
|
||||
_m.ProfitControlEnabled = value.Bool
|
||||
}
|
||||
case group.FieldProfitMinMargin:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field profit_min_margin", values[i])
|
||||
} else if value.Valid {
|
||||
_m.ProfitMinMargin = value.Float64
|
||||
}
|
||||
case group.FieldProfitSafetyBuffer:
|
||||
if value, ok := values[i].(*sql.NullFloat64); !ok {
|
||||
return fmt.Errorf("unexpected type %T for field profit_safety_buffer", values[i])
|
||||
} else if value.Valid {
|
||||
_m.ProfitSafetyBuffer = value.Float64
|
||||
}
|
||||
default:
|
||||
_m.selectValues.Set(columns[i], values[i])
|
||||
}
|
||||
@@ -860,6 +884,15 @@ func (_m *Group) String() string {
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("reasoning_effort_mappings=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("profit_control_enabled=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ProfitControlEnabled))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("profit_min_margin=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ProfitMinMargin))
|
||||
builder.WriteString(", ")
|
||||
builder.WriteString("profit_safety_buffer=")
|
||||
builder.WriteString(fmt.Sprintf("%v", _m.ProfitSafetyBuffer))
|
||||
builder.WriteByte(')')
|
||||
return builder.String()
|
||||
}
|
||||
|
||||
@@ -120,6 +120,12 @@ const (
|
||||
FieldMaxReasoningEffort = "max_reasoning_effort"
|
||||
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
|
||||
FieldReasoningEffortMappings = "reasoning_effort_mappings"
|
||||
// FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database.
|
||||
FieldProfitControlEnabled = "profit_control_enabled"
|
||||
// FieldProfitMinMargin holds the string denoting the profit_min_margin field in the database.
|
||||
FieldProfitMinMargin = "profit_min_margin"
|
||||
// FieldProfitSafetyBuffer holds the string denoting the profit_safety_buffer field in the database.
|
||||
FieldProfitSafetyBuffer = "profit_safety_buffer"
|
||||
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
|
||||
EdgeAPIKeys = "api_keys"
|
||||
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
|
||||
@@ -247,6 +253,9 @@ var Columns = []string{
|
||||
FieldRpmLimit,
|
||||
FieldMaxReasoningEffort,
|
||||
FieldReasoningEffortMappings,
|
||||
FieldProfitControlEnabled,
|
||||
FieldProfitMinMargin,
|
||||
FieldProfitSafetyBuffer,
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -366,6 +375,12 @@ var (
|
||||
MaxReasoningEffortValidator func(string) error
|
||||
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
|
||||
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
|
||||
// DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field.
|
||||
DefaultProfitControlEnabled bool
|
||||
// DefaultProfitMinMargin holds the default value on creation for the "profit_min_margin" field.
|
||||
DefaultProfitMinMargin float64
|
||||
// DefaultProfitSafetyBuffer holds the default value on creation for the "profit_safety_buffer" field.
|
||||
DefaultProfitSafetyBuffer float64
|
||||
)
|
||||
|
||||
// OrderOption defines the ordering options for the Group queries.
|
||||
@@ -611,6 +626,21 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByProfitControlEnabled orders the results by the profit_control_enabled field.
|
||||
func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByProfitMinMargin orders the results by the profit_min_margin field.
|
||||
func ByProfitMinMargin(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldProfitMinMargin, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByProfitSafetyBuffer orders the results by the profit_safety_buffer field.
|
||||
func ByProfitSafetyBuffer(opts ...sql.OrderTermOption) OrderOption {
|
||||
return sql.OrderByField(FieldProfitSafetyBuffer, opts...).ToFunc()
|
||||
}
|
||||
|
||||
// ByAPIKeysCount orders the results by api_keys count.
|
||||
func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption {
|
||||
return func(s *sql.Selector) {
|
||||
|
||||
@@ -290,6 +290,21 @@ func MaxReasoningEffort(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ.
|
||||
func ProfitControlEnabled(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
}
|
||||
|
||||
// ProfitMinMargin applies equality check predicate on the "profit_min_margin" field. It's identical to ProfitMinMarginEQ.
|
||||
func ProfitMinMargin(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitMinMargin, v))
|
||||
}
|
||||
|
||||
// ProfitSafetyBuffer applies equality check predicate on the "profit_safety_buffer" field. It's identical to ProfitSafetyBufferEQ.
|
||||
func ProfitSafetyBuffer(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitSafetyBuffer, v))
|
||||
}
|
||||
|
||||
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
|
||||
func CreatedAtEQ(v time.Time) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldCreatedAt, v))
|
||||
@@ -2190,6 +2205,96 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group {
|
||||
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field.
|
||||
func ProfitControlEnabledEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
|
||||
}
|
||||
|
||||
// ProfitControlEnabledNEQ applies the NEQ predicate on the "profit_control_enabled" field.
|
||||
func ProfitControlEnabledNEQ(v bool) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldProfitControlEnabled, v))
|
||||
}
|
||||
|
||||
// ProfitMinMarginEQ applies the EQ predicate on the "profit_min_margin" field.
|
||||
func ProfitMinMarginEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitMinMargin, v))
|
||||
}
|
||||
|
||||
// ProfitMinMarginNEQ applies the NEQ predicate on the "profit_min_margin" field.
|
||||
func ProfitMinMarginNEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldProfitMinMargin, v))
|
||||
}
|
||||
|
||||
// ProfitMinMarginIn applies the In predicate on the "profit_min_margin" field.
|
||||
func ProfitMinMarginIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldProfitMinMargin, vs...))
|
||||
}
|
||||
|
||||
// ProfitMinMarginNotIn applies the NotIn predicate on the "profit_min_margin" field.
|
||||
func ProfitMinMarginNotIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldProfitMinMargin, vs...))
|
||||
}
|
||||
|
||||
// ProfitMinMarginGT applies the GT predicate on the "profit_min_margin" field.
|
||||
func ProfitMinMarginGT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldProfitMinMargin, v))
|
||||
}
|
||||
|
||||
// ProfitMinMarginGTE applies the GTE predicate on the "profit_min_margin" field.
|
||||
func ProfitMinMarginGTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldProfitMinMargin, v))
|
||||
}
|
||||
|
||||
// ProfitMinMarginLT applies the LT predicate on the "profit_min_margin" field.
|
||||
func ProfitMinMarginLT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldProfitMinMargin, v))
|
||||
}
|
||||
|
||||
// ProfitMinMarginLTE applies the LTE predicate on the "profit_min_margin" field.
|
||||
func ProfitMinMarginLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldProfitMinMargin, v))
|
||||
}
|
||||
|
||||
// ProfitSafetyBufferEQ applies the EQ predicate on the "profit_safety_buffer" field.
|
||||
func ProfitSafetyBufferEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldEQ(FieldProfitSafetyBuffer, v))
|
||||
}
|
||||
|
||||
// ProfitSafetyBufferNEQ applies the NEQ predicate on the "profit_safety_buffer" field.
|
||||
func ProfitSafetyBufferNEQ(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNEQ(FieldProfitSafetyBuffer, v))
|
||||
}
|
||||
|
||||
// ProfitSafetyBufferIn applies the In predicate on the "profit_safety_buffer" field.
|
||||
func ProfitSafetyBufferIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldIn(FieldProfitSafetyBuffer, vs...))
|
||||
}
|
||||
|
||||
// ProfitSafetyBufferNotIn applies the NotIn predicate on the "profit_safety_buffer" field.
|
||||
func ProfitSafetyBufferNotIn(vs ...float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldNotIn(FieldProfitSafetyBuffer, vs...))
|
||||
}
|
||||
|
||||
// ProfitSafetyBufferGT applies the GT predicate on the "profit_safety_buffer" field.
|
||||
func ProfitSafetyBufferGT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGT(FieldProfitSafetyBuffer, v))
|
||||
}
|
||||
|
||||
// ProfitSafetyBufferGTE applies the GTE predicate on the "profit_safety_buffer" field.
|
||||
func ProfitSafetyBufferGTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldGTE(FieldProfitSafetyBuffer, v))
|
||||
}
|
||||
|
||||
// ProfitSafetyBufferLT applies the LT predicate on the "profit_safety_buffer" field.
|
||||
func ProfitSafetyBufferLT(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLT(FieldProfitSafetyBuffer, v))
|
||||
}
|
||||
|
||||
// ProfitSafetyBufferLTE applies the LTE predicate on the "profit_safety_buffer" field.
|
||||
func ProfitSafetyBufferLTE(v float64) predicate.Group {
|
||||
return predicate.Group(sql.FieldLTE(FieldProfitSafetyBuffer, v))
|
||||
}
|
||||
|
||||
// HasAPIKeys applies the HasEdge predicate on the "api_keys" edge.
|
||||
func HasAPIKeys() predicate.Group {
|
||||
return predicate.Group(func(s *sql.Selector) {
|
||||
|
||||
@@ -725,6 +725,48 @@ func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapp
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetProfitControlEnabled sets the "profit_control_enabled" field.
|
||||
func (_c *GroupCreate) SetProfitControlEnabled(v bool) *GroupCreate {
|
||||
_c.mutation.SetProfitControlEnabled(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableProfitControlEnabled(v *bool) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetProfitControlEnabled(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetProfitMinMargin sets the "profit_min_margin" field.
|
||||
func (_c *GroupCreate) SetProfitMinMargin(v float64) *GroupCreate {
|
||||
_c.mutation.SetProfitMinMargin(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableProfitMinMargin(v *float64) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetProfitMinMargin(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
|
||||
func (_c *GroupCreate) SetProfitSafetyBuffer(v float64) *GroupCreate {
|
||||
_c.mutation.SetProfitSafetyBuffer(v)
|
||||
return _c
|
||||
}
|
||||
|
||||
// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil.
|
||||
func (_c *GroupCreate) SetNillableProfitSafetyBuffer(v *float64) *GroupCreate {
|
||||
if v != nil {
|
||||
_c.SetProfitSafetyBuffer(*v)
|
||||
}
|
||||
return _c
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate {
|
||||
_c.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -998,6 +1040,18 @@ func (_c *GroupCreate) defaults() error {
|
||||
v := group.DefaultReasoningEffortMappings
|
||||
_c.mutation.SetReasoningEffortMappings(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ProfitControlEnabled(); !ok {
|
||||
v := group.DefaultProfitControlEnabled
|
||||
_c.mutation.SetProfitControlEnabled(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ProfitMinMargin(); !ok {
|
||||
v := group.DefaultProfitMinMargin
|
||||
_c.mutation.SetProfitMinMargin(v)
|
||||
}
|
||||
if _, ok := _c.mutation.ProfitSafetyBuffer(); !ok {
|
||||
v := group.DefaultProfitSafetyBuffer
|
||||
_c.mutation.SetProfitSafetyBuffer(v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1156,6 +1210,15 @@ func (_c *GroupCreate) check() error {
|
||||
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
|
||||
return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.ProfitControlEnabled(); !ok {
|
||||
return &ValidationError{Name: "profit_control_enabled", err: errors.New(`ent: missing required field "Group.profit_control_enabled"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.ProfitMinMargin(); !ok {
|
||||
return &ValidationError{Name: "profit_min_margin", err: errors.New(`ent: missing required field "Group.profit_min_margin"`)}
|
||||
}
|
||||
if _, ok := _c.mutation.ProfitSafetyBuffer(); !ok {
|
||||
return &ValidationError{Name: "profit_safety_buffer", err: errors.New(`ent: missing required field "Group.profit_safety_buffer"`)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -1391,6 +1454,18 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
|
||||
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
|
||||
_node.ReasoningEffortMappings = value
|
||||
}
|
||||
if value, ok := _c.mutation.ProfitControlEnabled(); ok {
|
||||
_spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value)
|
||||
_node.ProfitControlEnabled = value
|
||||
}
|
||||
if value, ok := _c.mutation.ProfitMinMargin(); ok {
|
||||
_spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value)
|
||||
_node.ProfitMinMargin = value
|
||||
}
|
||||
if value, ok := _c.mutation.ProfitSafetyBuffer(); ok {
|
||||
_spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
|
||||
_node.ProfitSafetyBuffer = value
|
||||
}
|
||||
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@@ -2363,6 +2438,54 @@ func (u *GroupUpsert) UpdateReasoningEffortMappings() *GroupUpsert {
|
||||
return u
|
||||
}
|
||||
|
||||
// SetProfitControlEnabled sets the "profit_control_enabled" field.
|
||||
func (u *GroupUpsert) SetProfitControlEnabled(v bool) *GroupUpsert {
|
||||
u.Set(group.FieldProfitControlEnabled, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateProfitControlEnabled() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldProfitControlEnabled)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetProfitMinMargin sets the "profit_min_margin" field.
|
||||
func (u *GroupUpsert) SetProfitMinMargin(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldProfitMinMargin, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateProfitMinMargin() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldProfitMinMargin)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddProfitMinMargin adds v to the "profit_min_margin" field.
|
||||
func (u *GroupUpsert) AddProfitMinMargin(v float64) *GroupUpsert {
|
||||
u.Add(group.FieldProfitMinMargin, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
|
||||
func (u *GroupUpsert) SetProfitSafetyBuffer(v float64) *GroupUpsert {
|
||||
u.Set(group.FieldProfitSafetyBuffer, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create.
|
||||
func (u *GroupUpsert) UpdateProfitSafetyBuffer() *GroupUpsert {
|
||||
u.SetExcluded(group.FieldProfitSafetyBuffer)
|
||||
return u
|
||||
}
|
||||
|
||||
// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field.
|
||||
func (u *GroupUpsert) AddProfitSafetyBuffer(v float64) *GroupUpsert {
|
||||
u.Add(group.FieldProfitSafetyBuffer, v)
|
||||
return u
|
||||
}
|
||||
|
||||
// UpdateNewValues updates the mutable fields using the new values that were set on create.
|
||||
// Using this option is equivalent to using:
|
||||
//
|
||||
@@ -3363,6 +3486,62 @@ func (u *GroupUpsertOne) UpdateReasoningEffortMappings() *GroupUpsertOne {
|
||||
})
|
||||
}
|
||||
|
||||
// SetProfitControlEnabled sets the "profit_control_enabled" field.
|
||||
func (u *GroupUpsertOne) SetProfitControlEnabled(v bool) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetProfitControlEnabled(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateProfitControlEnabled() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateProfitControlEnabled()
|
||||
})
|
||||
}
|
||||
|
||||
// SetProfitMinMargin sets the "profit_min_margin" field.
|
||||
func (u *GroupUpsertOne) SetProfitMinMargin(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetProfitMinMargin(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddProfitMinMargin adds v to the "profit_min_margin" field.
|
||||
func (u *GroupUpsertOne) AddProfitMinMargin(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddProfitMinMargin(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateProfitMinMargin() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateProfitMinMargin()
|
||||
})
|
||||
}
|
||||
|
||||
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
|
||||
func (u *GroupUpsertOne) SetProfitSafetyBuffer(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetProfitSafetyBuffer(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field.
|
||||
func (u *GroupUpsertOne) AddProfitSafetyBuffer(v float64) *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddProfitSafetyBuffer(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create.
|
||||
func (u *GroupUpsertOne) UpdateProfitSafetyBuffer() *GroupUpsertOne {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateProfitSafetyBuffer()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *GroupUpsertOne) Exec(ctx context.Context) error {
|
||||
if len(u.create.conflict) == 0 {
|
||||
@@ -4529,6 +4708,62 @@ func (u *GroupUpsertBulk) UpdateReasoningEffortMappings() *GroupUpsertBulk {
|
||||
})
|
||||
}
|
||||
|
||||
// SetProfitControlEnabled sets the "profit_control_enabled" field.
|
||||
func (u *GroupUpsertBulk) SetProfitControlEnabled(v bool) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetProfitControlEnabled(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateProfitControlEnabled() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateProfitControlEnabled()
|
||||
})
|
||||
}
|
||||
|
||||
// SetProfitMinMargin sets the "profit_min_margin" field.
|
||||
func (u *GroupUpsertBulk) SetProfitMinMargin(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetProfitMinMargin(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddProfitMinMargin adds v to the "profit_min_margin" field.
|
||||
func (u *GroupUpsertBulk) AddProfitMinMargin(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddProfitMinMargin(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateProfitMinMargin() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateProfitMinMargin()
|
||||
})
|
||||
}
|
||||
|
||||
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
|
||||
func (u *GroupUpsertBulk) SetProfitSafetyBuffer(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.SetProfitSafetyBuffer(v)
|
||||
})
|
||||
}
|
||||
|
||||
// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field.
|
||||
func (u *GroupUpsertBulk) AddProfitSafetyBuffer(v float64) *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.AddProfitSafetyBuffer(v)
|
||||
})
|
||||
}
|
||||
|
||||
// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create.
|
||||
func (u *GroupUpsertBulk) UpdateProfitSafetyBuffer() *GroupUpsertBulk {
|
||||
return u.Update(func(s *GroupUpsert) {
|
||||
s.UpdateProfitSafetyBuffer()
|
||||
})
|
||||
}
|
||||
|
||||
// Exec executes the query.
|
||||
func (u *GroupUpsertBulk) Exec(ctx context.Context) error {
|
||||
if u.create.err != nil {
|
||||
|
||||
@@ -953,6 +953,62 @@ func (_u *GroupUpdate) AppendReasoningEffortMappings(v []domain.ReasoningEffortM
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetProfitControlEnabled sets the "profit_control_enabled" field.
|
||||
func (_u *GroupUpdate) SetProfitControlEnabled(v bool) *GroupUpdate {
|
||||
_u.mutation.SetProfitControlEnabled(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableProfitControlEnabled(v *bool) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetProfitControlEnabled(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetProfitMinMargin sets the "profit_min_margin" field.
|
||||
func (_u *GroupUpdate) SetProfitMinMargin(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetProfitMinMargin()
|
||||
_u.mutation.SetProfitMinMargin(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableProfitMinMargin(v *float64) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetProfitMinMargin(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddProfitMinMargin adds value to the "profit_min_margin" field.
|
||||
func (_u *GroupUpdate) AddProfitMinMargin(v float64) *GroupUpdate {
|
||||
_u.mutation.AddProfitMinMargin(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
|
||||
func (_u *GroupUpdate) SetProfitSafetyBuffer(v float64) *GroupUpdate {
|
||||
_u.mutation.ResetProfitSafetyBuffer()
|
||||
_u.mutation.SetProfitSafetyBuffer(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil.
|
||||
func (_u *GroupUpdate) SetNillableProfitSafetyBuffer(v *float64) *GroupUpdate {
|
||||
if v != nil {
|
||||
_u.SetProfitSafetyBuffer(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddProfitSafetyBuffer adds value to the "profit_safety_buffer" field.
|
||||
func (_u *GroupUpdate) AddProfitSafetyBuffer(v float64) *GroupUpdate {
|
||||
_u.mutation.AddProfitSafetyBuffer(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate {
|
||||
_u.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -1544,6 +1600,21 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
|
||||
sqljson.Append(u, group.FieldReasoningEffortMappings, value)
|
||||
})
|
||||
}
|
||||
if value, ok := _u.mutation.ProfitControlEnabled(); ok {
|
||||
_spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ProfitMinMargin(); ok {
|
||||
_spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedProfitMinMargin(); ok {
|
||||
_spec.AddField(group.FieldProfitMinMargin, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ProfitSafetyBuffer(); ok {
|
||||
_spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedProfitSafetyBuffer(); ok {
|
||||
_spec.AddField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.APIKeysCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
@@ -2775,6 +2846,62 @@ func (_u *GroupUpdateOne) AppendReasoningEffortMappings(v []domain.ReasoningEffo
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetProfitControlEnabled sets the "profit_control_enabled" field.
|
||||
func (_u *GroupUpdateOne) SetProfitControlEnabled(v bool) *GroupUpdateOne {
|
||||
_u.mutation.SetProfitControlEnabled(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableProfitControlEnabled(v *bool) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetProfitControlEnabled(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetProfitMinMargin sets the "profit_min_margin" field.
|
||||
func (_u *GroupUpdateOne) SetProfitMinMargin(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetProfitMinMargin()
|
||||
_u.mutation.SetProfitMinMargin(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableProfitMinMargin(v *float64) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetProfitMinMargin(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddProfitMinMargin adds value to the "profit_min_margin" field.
|
||||
func (_u *GroupUpdateOne) AddProfitMinMargin(v float64) *GroupUpdateOne {
|
||||
_u.mutation.AddProfitMinMargin(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
|
||||
func (_u *GroupUpdateOne) SetProfitSafetyBuffer(v float64) *GroupUpdateOne {
|
||||
_u.mutation.ResetProfitSafetyBuffer()
|
||||
_u.mutation.SetProfitSafetyBuffer(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil.
|
||||
func (_u *GroupUpdateOne) SetNillableProfitSafetyBuffer(v *float64) *GroupUpdateOne {
|
||||
if v != nil {
|
||||
_u.SetProfitSafetyBuffer(*v)
|
||||
}
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddProfitSafetyBuffer adds value to the "profit_safety_buffer" field.
|
||||
func (_u *GroupUpdateOne) AddProfitSafetyBuffer(v float64) *GroupUpdateOne {
|
||||
_u.mutation.AddProfitSafetyBuffer(v)
|
||||
return _u
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
|
||||
func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne {
|
||||
_u.mutation.AddAPIKeyIDs(ids...)
|
||||
@@ -3396,6 +3523,21 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
|
||||
sqljson.Append(u, group.FieldReasoningEffortMappings, value)
|
||||
})
|
||||
}
|
||||
if value, ok := _u.mutation.ProfitControlEnabled(); ok {
|
||||
_spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ProfitMinMargin(); ok {
|
||||
_spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedProfitMinMargin(); ok {
|
||||
_spec.AddField(group.FieldProfitMinMargin, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.ProfitSafetyBuffer(); ok {
|
||||
_spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
|
||||
}
|
||||
if value, ok := _u.mutation.AddedProfitSafetyBuffer(); ok {
|
||||
_spec.AddField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
|
||||
}
|
||||
if _u.mutation.APIKeysCleared() {
|
||||
edge := &sqlgraph.EdgeSpec{
|
||||
Rel: sqlgraph.O2M,
|
||||
|
||||
@@ -947,6 +947,9 @@ var (
|
||||
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
|
||||
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
|
||||
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
|
||||
{Name: "profit_control_enabled", Type: field.TypeBool, Default: false},
|
||||
{Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
{Name: "profit_safety_buffer", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
|
||||
}
|
||||
// GroupsTable holds the schema information for the "groups" table.
|
||||
GroupsTable = &schema.Table{
|
||||
|
||||
+229
-1
@@ -21922,6 +21922,11 @@ type GroupMutation struct {
|
||||
max_reasoning_effort *string
|
||||
reasoning_effort_mappings *[]domain.ReasoningEffortMapping
|
||||
appendreasoning_effort_mappings []domain.ReasoningEffortMapping
|
||||
profit_control_enabled *bool
|
||||
profit_min_margin *float64
|
||||
addprofit_min_margin *float64
|
||||
profit_safety_buffer *float64
|
||||
addprofit_safety_buffer *float64
|
||||
clearedFields map[string]struct{}
|
||||
api_keys map[int64]struct{}
|
||||
removedapi_keys map[int64]struct{}
|
||||
@@ -24586,6 +24591,154 @@ func (m *GroupMutation) ResetReasoningEffortMappings() {
|
||||
m.appendreasoning_effort_mappings = nil
|
||||
}
|
||||
|
||||
// SetProfitControlEnabled sets the "profit_control_enabled" field.
|
||||
func (m *GroupMutation) SetProfitControlEnabled(b bool) {
|
||||
m.profit_control_enabled = &b
|
||||
}
|
||||
|
||||
// ProfitControlEnabled returns the value of the "profit_control_enabled" field in the mutation.
|
||||
func (m *GroupMutation) ProfitControlEnabled() (r bool, exists bool) {
|
||||
v := m.profit_control_enabled
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldProfitControlEnabled returns the old "profit_control_enabled" 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) OldProfitControlEnabled(ctx context.Context) (v bool, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldProfitControlEnabled is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldProfitControlEnabled requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldProfitControlEnabled: %w", err)
|
||||
}
|
||||
return oldValue.ProfitControlEnabled, nil
|
||||
}
|
||||
|
||||
// ResetProfitControlEnabled resets all changes to the "profit_control_enabled" field.
|
||||
func (m *GroupMutation) ResetProfitControlEnabled() {
|
||||
m.profit_control_enabled = nil
|
||||
}
|
||||
|
||||
// SetProfitMinMargin sets the "profit_min_margin" field.
|
||||
func (m *GroupMutation) SetProfitMinMargin(f float64) {
|
||||
m.profit_min_margin = &f
|
||||
m.addprofit_min_margin = nil
|
||||
}
|
||||
|
||||
// ProfitMinMargin returns the value of the "profit_min_margin" field in the mutation.
|
||||
func (m *GroupMutation) ProfitMinMargin() (r float64, exists bool) {
|
||||
v := m.profit_min_margin
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldProfitMinMargin returns the old "profit_min_margin" 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) OldProfitMinMargin(ctx context.Context) (v float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldProfitMinMargin is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldProfitMinMargin requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldProfitMinMargin: %w", err)
|
||||
}
|
||||
return oldValue.ProfitMinMargin, nil
|
||||
}
|
||||
|
||||
// AddProfitMinMargin adds f to the "profit_min_margin" field.
|
||||
func (m *GroupMutation) AddProfitMinMargin(f float64) {
|
||||
if m.addprofit_min_margin != nil {
|
||||
*m.addprofit_min_margin += f
|
||||
} else {
|
||||
m.addprofit_min_margin = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedProfitMinMargin returns the value that was added to the "profit_min_margin" field in this mutation.
|
||||
func (m *GroupMutation) AddedProfitMinMargin() (r float64, exists bool) {
|
||||
v := m.addprofit_min_margin
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ResetProfitMinMargin resets all changes to the "profit_min_margin" field.
|
||||
func (m *GroupMutation) ResetProfitMinMargin() {
|
||||
m.profit_min_margin = nil
|
||||
m.addprofit_min_margin = nil
|
||||
}
|
||||
|
||||
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
|
||||
func (m *GroupMutation) SetProfitSafetyBuffer(f float64) {
|
||||
m.profit_safety_buffer = &f
|
||||
m.addprofit_safety_buffer = nil
|
||||
}
|
||||
|
||||
// ProfitSafetyBuffer returns the value of the "profit_safety_buffer" field in the mutation.
|
||||
func (m *GroupMutation) ProfitSafetyBuffer() (r float64, exists bool) {
|
||||
v := m.profit_safety_buffer
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// OldProfitSafetyBuffer returns the old "profit_safety_buffer" 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) OldProfitSafetyBuffer(ctx context.Context) (v float64, err error) {
|
||||
if !m.op.Is(OpUpdateOne) {
|
||||
return v, errors.New("OldProfitSafetyBuffer is only allowed on UpdateOne operations")
|
||||
}
|
||||
if m.id == nil || m.oldValue == nil {
|
||||
return v, errors.New("OldProfitSafetyBuffer requires an ID field in the mutation")
|
||||
}
|
||||
oldValue, err := m.oldValue(ctx)
|
||||
if err != nil {
|
||||
return v, fmt.Errorf("querying old value for OldProfitSafetyBuffer: %w", err)
|
||||
}
|
||||
return oldValue.ProfitSafetyBuffer, nil
|
||||
}
|
||||
|
||||
// AddProfitSafetyBuffer adds f to the "profit_safety_buffer" field.
|
||||
func (m *GroupMutation) AddProfitSafetyBuffer(f float64) {
|
||||
if m.addprofit_safety_buffer != nil {
|
||||
*m.addprofit_safety_buffer += f
|
||||
} else {
|
||||
m.addprofit_safety_buffer = &f
|
||||
}
|
||||
}
|
||||
|
||||
// AddedProfitSafetyBuffer returns the value that was added to the "profit_safety_buffer" field in this mutation.
|
||||
func (m *GroupMutation) AddedProfitSafetyBuffer() (r float64, exists bool) {
|
||||
v := m.addprofit_safety_buffer
|
||||
if v == nil {
|
||||
return
|
||||
}
|
||||
return *v, true
|
||||
}
|
||||
|
||||
// ResetProfitSafetyBuffer resets all changes to the "profit_safety_buffer" field.
|
||||
func (m *GroupMutation) ResetProfitSafetyBuffer() {
|
||||
m.profit_safety_buffer = nil
|
||||
m.addprofit_safety_buffer = nil
|
||||
}
|
||||
|
||||
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by ids.
|
||||
func (m *GroupMutation) AddAPIKeyIDs(ids ...int64) {
|
||||
if m.api_keys == nil {
|
||||
@@ -24944,7 +25097,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, 52)
|
||||
fields := make([]string, 0, 55)
|
||||
if m.created_at != nil {
|
||||
fields = append(fields, group.FieldCreatedAt)
|
||||
}
|
||||
@@ -25101,6 +25254,15 @@ func (m *GroupMutation) Fields() []string {
|
||||
if m.reasoning_effort_mappings != nil {
|
||||
fields = append(fields, group.FieldReasoningEffortMappings)
|
||||
}
|
||||
if m.profit_control_enabled != nil {
|
||||
fields = append(fields, group.FieldProfitControlEnabled)
|
||||
}
|
||||
if m.profit_min_margin != nil {
|
||||
fields = append(fields, group.FieldProfitMinMargin)
|
||||
}
|
||||
if m.profit_safety_buffer != nil {
|
||||
fields = append(fields, group.FieldProfitSafetyBuffer)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -25213,6 +25375,12 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
|
||||
return m.MaxReasoningEffort()
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.ReasoningEffortMappings()
|
||||
case group.FieldProfitControlEnabled:
|
||||
return m.ProfitControlEnabled()
|
||||
case group.FieldProfitMinMargin:
|
||||
return m.ProfitMinMargin()
|
||||
case group.FieldProfitSafetyBuffer:
|
||||
return m.ProfitSafetyBuffer()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -25326,6 +25494,12 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
|
||||
return m.OldMaxReasoningEffort(ctx)
|
||||
case group.FieldReasoningEffortMappings:
|
||||
return m.OldReasoningEffortMappings(ctx)
|
||||
case group.FieldProfitControlEnabled:
|
||||
return m.OldProfitControlEnabled(ctx)
|
||||
case group.FieldProfitMinMargin:
|
||||
return m.OldProfitMinMargin(ctx)
|
||||
case group.FieldProfitSafetyBuffer:
|
||||
return m.OldProfitSafetyBuffer(ctx)
|
||||
}
|
||||
return nil, fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
@@ -25699,6 +25873,27 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
|
||||
}
|
||||
m.SetReasoningEffortMappings(v)
|
||||
return nil
|
||||
case group.FieldProfitControlEnabled:
|
||||
v, ok := value.(bool)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetProfitControlEnabled(v)
|
||||
return nil
|
||||
case group.FieldProfitMinMargin:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetProfitMinMargin(v)
|
||||
return nil
|
||||
case group.FieldProfitSafetyBuffer:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.SetProfitSafetyBuffer(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
@@ -25770,6 +25965,12 @@ func (m *GroupMutation) AddedFields() []string {
|
||||
if m.addrpm_limit != nil {
|
||||
fields = append(fields, group.FieldRpmLimit)
|
||||
}
|
||||
if m.addprofit_min_margin != nil {
|
||||
fields = append(fields, group.FieldProfitMinMargin)
|
||||
}
|
||||
if m.addprofit_safety_buffer != nil {
|
||||
fields = append(fields, group.FieldProfitSafetyBuffer)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
@@ -25820,6 +26021,10 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
|
||||
return m.AddedSortOrder()
|
||||
case group.FieldRpmLimit:
|
||||
return m.AddedRpmLimit()
|
||||
case group.FieldProfitMinMargin:
|
||||
return m.AddedProfitMinMargin()
|
||||
case group.FieldProfitSafetyBuffer:
|
||||
return m.AddedProfitSafetyBuffer()
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
@@ -25976,6 +26181,20 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
|
||||
}
|
||||
m.AddRpmLimit(v)
|
||||
return nil
|
||||
case group.FieldProfitMinMargin:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddProfitMinMargin(v)
|
||||
return nil
|
||||
case group.FieldProfitSafetyBuffer:
|
||||
v, ok := value.(float64)
|
||||
if !ok {
|
||||
return fmt.Errorf("unexpected type %T for field %s", value, name)
|
||||
}
|
||||
m.AddProfitSafetyBuffer(v)
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Group numeric field %s", name)
|
||||
}
|
||||
@@ -26258,6 +26477,15 @@ func (m *GroupMutation) ResetField(name string) error {
|
||||
case group.FieldReasoningEffortMappings:
|
||||
m.ResetReasoningEffortMappings()
|
||||
return nil
|
||||
case group.FieldProfitControlEnabled:
|
||||
m.ResetProfitControlEnabled()
|
||||
return nil
|
||||
case group.FieldProfitMinMargin:
|
||||
m.ResetProfitMinMargin()
|
||||
return nil
|
||||
case group.FieldProfitSafetyBuffer:
|
||||
m.ResetProfitSafetyBuffer()
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unknown Group field %s", name)
|
||||
}
|
||||
|
||||
@@ -1181,6 +1181,18 @@ func init() {
|
||||
groupDescReasoningEffortMappings := groupFields[48].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[49].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[50].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[51].Descriptor()
|
||||
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
|
||||
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
|
||||
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
|
||||
idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields()
|
||||
_ = idempotencyrecordMixinFields0
|
||||
|
||||
@@ -234,6 +234,20 @@ func (Group) Fields() []ent.Field {
|
||||
Default([]domain.ReasoningEffortMapping{}).
|
||||
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
|
||||
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
|
||||
|
||||
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
|
||||
// 的 token 分组可启用,composite 分组不能直接启用。
|
||||
field.Bool("profit_control_enabled").
|
||||
Default(false).
|
||||
Comment("是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池"),
|
||||
field.Float("profit_min_margin").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(0).
|
||||
Comment("最低毛利率,小数(0.30=30%);账号准入条件为 U <= D*(1-margin-buffer)"),
|
||||
field.Float("profit_safety_buffer").
|
||||
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
|
||||
Default(0).
|
||||
Comment("安全缓冲,小数;与 margin 相加后从下游倍率中扣除,默认 0"),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -907,6 +907,14 @@ type GatewayConfig struct {
|
||||
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
|
||||
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
|
||||
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
|
||||
// DisableCodexOriginatorNormalization: 关闭「把落在上游降载桶的 Codex originator 改写为
|
||||
// 官方 CLI 身份」。上游 /backend-api/codex 按 originator 分桶调度容量,命中降载桶的请求会被回
|
||||
// server_is_overloaded,网关据此冷却账号,表现为账号频繁过载不可用。
|
||||
//
|
||||
// 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的
|
||||
// Config(测试、工具)其零值必须落在「归一化开启」这一侧,否则会静默丢掉这层保护。
|
||||
// 仅当上游调整分桶、使归一化反而落入降载桶时才置 true。
|
||||
DisableCodexOriginatorNormalization bool `mapstructure:"disable_codex_originator_normalization"`
|
||||
// CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
|
||||
// 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
|
||||
CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"`
|
||||
@@ -2217,6 +2225,7 @@ func setDefaults() {
|
||||
viper.SetDefault("gateway.max_account_switches", 10)
|
||||
viper.SetDefault("gateway.max_account_switches_gemini", 3)
|
||||
viper.SetDefault("gateway.force_codex_cli", false)
|
||||
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
|
||||
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
|
||||
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
|
||||
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
|
||||
|
||||
@@ -147,6 +147,8 @@ type UpdateAccountRequest struct {
|
||||
GroupIDs *[]int64 `json:"group_ids"`
|
||||
ExpiresAt *int64 `json:"expires_at"`
|
||||
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
|
||||
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
|
||||
RateSyncEnabled *bool `json:"upstream_billing_rate_sync_enabled"`
|
||||
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
|
||||
}
|
||||
|
||||
@@ -987,6 +989,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
|
||||
GroupIDs: req.GroupIDs,
|
||||
ExpiresAt: req.ExpiresAt,
|
||||
AutoPauseOnExpired: req.AutoPauseOnExpired,
|
||||
ProbeEnabled: req.ProbeEnabled,
|
||||
RateSyncEnabled: req.RateSyncEnabled,
|
||||
SkipMixedChannelCheck: skipCheck,
|
||||
})
|
||||
if err != nil {
|
||||
@@ -1531,6 +1535,141 @@ func (h *AccountHandler) RevertProxyFallback(c *gin.Context) {
|
||||
response.Success(c, gin.H{"message": "reverted"})
|
||||
}
|
||||
|
||||
// BatchDelete handles deleting multiple accounts with bounded concurrency.
|
||||
// POST /api/v1/admin/accounts/batch-delete
|
||||
func (h *AccountHandler) BatchDelete(c *gin.Context) {
|
||||
var req struct {
|
||||
AccountIDs []int64 `json:"account_ids"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
response.BadRequest(c, "Invalid request: "+err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
accountIDs := normalizeInt64IDList(req.AccountIDs)
|
||||
if len(accountIDs) == 0 {
|
||||
response.BadRequest(c, "account_ids is required")
|
||||
return
|
||||
}
|
||||
|
||||
accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), accountIDs)
|
||||
if err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
type deleteError struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
requestedIDs := make(map[int64]struct{}, len(accountIDs))
|
||||
for _, accountID := range accountIDs {
|
||||
requestedIDs[accountID] = struct{}{}
|
||||
}
|
||||
accountsByID := make(map[int64]*service.Account, len(accounts))
|
||||
for _, account := range accounts {
|
||||
if account != nil {
|
||||
accountsByID[account.ID] = account
|
||||
}
|
||||
}
|
||||
|
||||
rootIDs := make([]int64, 0, len(accountIDs))
|
||||
dependentIDs := make(map[int64][]int64)
|
||||
failedIDs := make([]int64, 0)
|
||||
errorsByAccount := make([]deleteError, 0)
|
||||
for _, accountID := range accountIDs {
|
||||
account := accountsByID[accountID]
|
||||
if account == nil {
|
||||
failedIDs = append(failedIDs, accountID)
|
||||
errorsByAccount = append(errorsByAccount, deleteError{
|
||||
AccountID: accountID,
|
||||
Error: "account not found",
|
||||
})
|
||||
continue
|
||||
}
|
||||
|
||||
rootID := accountID
|
||||
visited := map[int64]struct{}{accountID: {}}
|
||||
for {
|
||||
current := accountsByID[rootID]
|
||||
if current == nil || current.ParentAccountID == nil {
|
||||
break
|
||||
}
|
||||
parentID := *current.ParentAccountID
|
||||
if _, selected := requestedIDs[parentID]; !selected {
|
||||
break
|
||||
}
|
||||
if _, exists := accountsByID[parentID]; !exists {
|
||||
break
|
||||
}
|
||||
if _, cyclic := visited[parentID]; cyclic {
|
||||
rootID = accountID
|
||||
break
|
||||
}
|
||||
visited[parentID] = struct{}{}
|
||||
rootID = parentID
|
||||
}
|
||||
|
||||
if rootID != accountID {
|
||||
dependentIDs[rootID] = append(dependentIDs[rootID], accountID)
|
||||
continue
|
||||
}
|
||||
rootIDs = append(rootIDs, accountID)
|
||||
}
|
||||
|
||||
const maxConcurrency = 5
|
||||
g, gctx := errgroup.WithContext(c.Request.Context())
|
||||
g.SetLimit(maxConcurrency)
|
||||
|
||||
var mu sync.Mutex
|
||||
successIDs := make([]int64, 0, len(accountIDs))
|
||||
|
||||
// Every worker returns nil so one account failure does not cancel the remaining deletions.
|
||||
for _, id := range rootIDs {
|
||||
accountID := id
|
||||
g.Go(func() error {
|
||||
err := h.adminService.DeleteAccount(gctx, accountID)
|
||||
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
affectedIDs := append([]int64{accountID}, dependentIDs[accountID]...)
|
||||
if err != nil {
|
||||
for _, affectedID := range affectedIDs {
|
||||
failedIDs = append(failedIDs, affectedID)
|
||||
errorsByAccount = append(errorsByAccount, deleteError{
|
||||
AccountID: affectedID,
|
||||
Error: err.Error(),
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
successIDs = append(successIDs, affectedIDs...)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
if err := g.Wait(); err != nil {
|
||||
response.ErrorFrom(c, err)
|
||||
return
|
||||
}
|
||||
|
||||
sort.Slice(successIDs, func(i, j int) bool { return successIDs[i] < successIDs[j] })
|
||||
sort.Slice(failedIDs, func(i, j int) bool { return failedIDs[i] < failedIDs[j] })
|
||||
sort.Slice(errorsByAccount, func(i, j int) bool {
|
||||
return errorsByAccount[i].AccountID < errorsByAccount[j].AccountID
|
||||
})
|
||||
|
||||
response.Success(c, gin.H{
|
||||
"total": len(accountIDs),
|
||||
"success": len(successIDs),
|
||||
"failed": len(failedIDs),
|
||||
"success_ids": successIDs,
|
||||
"failed_ids": failedIDs,
|
||||
"errors": errorsByAccount,
|
||||
})
|
||||
}
|
||||
|
||||
// BatchClearError handles batch clearing account errors
|
||||
// POST /api/v1/admin/accounts/batch-clear-error
|
||||
func (h *AccountHandler) BatchClearError(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
type batchDeleteAdminService struct {
|
||||
*stubAdminService
|
||||
|
||||
mu sync.Mutex
|
||||
active int
|
||||
maxActive int
|
||||
deletedIDs []int64
|
||||
deleteErrorsByID map[int64]error
|
||||
accountsByID map[int64]*service.Account
|
||||
}
|
||||
|
||||
func (s *batchDeleteAdminService) GetAccountsByIDs(_ context.Context, ids []int64) ([]*service.Account, error) {
|
||||
accounts := make([]*service.Account, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
if s.accountsByID != nil {
|
||||
if account, ok := s.accountsByID[id]; ok {
|
||||
accounts = append(accounts, account)
|
||||
}
|
||||
continue
|
||||
}
|
||||
accounts = append(accounts, &service.Account{ID: id})
|
||||
}
|
||||
return accounts, nil
|
||||
}
|
||||
|
||||
func (s *batchDeleteAdminService) DeleteAccount(ctx context.Context, id int64) error {
|
||||
s.mu.Lock()
|
||||
s.active++
|
||||
if s.active > s.maxActive {
|
||||
s.maxActive = s.active
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
s.mu.Lock()
|
||||
s.active--
|
||||
s.mu.Unlock()
|
||||
return ctx.Err()
|
||||
case <-time.After(10 * time.Millisecond):
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.active--
|
||||
s.deletedIDs = append(s.deletedIDs, id)
|
||||
return s.deleteErrorsByID[id]
|
||||
}
|
||||
|
||||
func setupAccountBatchDeleteRouter(adminSvc *batchDeleteAdminService) *gin.Engine {
|
||||
gin.SetMode(gin.TestMode)
|
||||
router := gin.New()
|
||||
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
router.POST("/api/v1/admin/accounts/batch-delete", handler.BatchDelete)
|
||||
return router
|
||||
}
|
||||
|
||||
func TestAccountHandlerBatchDeleteReturnsStablePerAccountResults(t *testing.T) {
|
||||
adminSvc := &batchDeleteAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
deleteErrorsByID: map[int64]error{
|
||||
3: errors.New("delete failed"),
|
||||
},
|
||||
}
|
||||
router := setupAccountBatchDeleteRouter(adminSvc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/batch-delete",
|
||||
bytes.NewBufferString(`{"account_ids":[5,4,3,2,1,2,0,-1]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
Total int `json:"total"`
|
||||
Success int `json:"success"`
|
||||
Failed int `json:"failed"`
|
||||
SuccessIDs []int64 `json:"success_ids"`
|
||||
FailedIDs []int64 `json:"failed_ids"`
|
||||
Errors []struct {
|
||||
AccountID int64 `json:"account_id"`
|
||||
Error string `json:"error"`
|
||||
} `json:"errors"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, 5, payload.Data.Total)
|
||||
require.Equal(t, 4, payload.Data.Success)
|
||||
require.Equal(t, 1, payload.Data.Failed)
|
||||
require.Equal(t, []int64{1, 2, 4, 5}, payload.Data.SuccessIDs)
|
||||
require.Equal(t, []int64{3}, payload.Data.FailedIDs)
|
||||
require.Equal(t, int64(3), payload.Data.Errors[0].AccountID)
|
||||
require.Equal(t, "delete failed", payload.Data.Errors[0].Error)
|
||||
require.LessOrEqual(t, adminSvc.maxActive, 5)
|
||||
require.Greater(t, adminSvc.maxActive, 1)
|
||||
}
|
||||
|
||||
func TestAccountHandlerBatchDeleteDoesNotRaceSelectedShadowWithParent(t *testing.T) {
|
||||
parentID := int64(1)
|
||||
adminSvc := &batchDeleteAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
accountsByID: map[int64]*service.Account{
|
||||
1: {ID: 1},
|
||||
2: {ID: 2, ParentAccountID: &parentID},
|
||||
3: {ID: 3},
|
||||
},
|
||||
}
|
||||
router := setupAccountBatchDeleteRouter(adminSvc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/batch-delete",
|
||||
bytes.NewBufferString(`{"account_ids":[1,2,3]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
var payload struct {
|
||||
Data struct {
|
||||
SuccessIDs []int64 `json:"success_ids"`
|
||||
FailedIDs []int64 `json:"failed_ids"`
|
||||
} `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
|
||||
require.Equal(t, []int64{1, 2, 3}, payload.Data.SuccessIDs)
|
||||
require.Empty(t, payload.Data.FailedIDs)
|
||||
require.ElementsMatch(t, []int64{1, 3}, adminSvc.deletedIDs)
|
||||
}
|
||||
|
||||
func TestAccountHandlerBatchDeleteRejectsEmptyNormalizedIDs(t *testing.T) {
|
||||
adminSvc := &batchDeleteAdminService{
|
||||
stubAdminService: newStubAdminService(),
|
||||
}
|
||||
router := setupAccountBatchDeleteRouter(adminSvc)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/admin/accounts/batch-delete",
|
||||
bytes.NewBufferString(`{"account_ids":[0,-1]}`),
|
||||
)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
}
|
||||
@@ -147,6 +147,28 @@ func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T
|
||||
require.False(t, hasRequireConfirmation)
|
||||
}
|
||||
|
||||
func TestAccountHandlerUpdateMapsUpstreamBillingRateSyncSettings(t *testing.T) {
|
||||
adminSvc := newStubAdminService()
|
||||
router := setupAccountMixedChannelRouter(adminSvc)
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"name": "gemini-key",
|
||||
"upstream_billing_probe_enabled": true,
|
||||
"upstream_billing_rate_sync_enabled": true,
|
||||
})
|
||||
rec := httptest.NewRecorder()
|
||||
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/42", bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
router.ServeHTTP(rec, req)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.NotNil(t, adminSvc.lastUpdateAccountInput)
|
||||
require.NotNil(t, adminSvc.lastUpdateAccountInput.ProbeEnabled)
|
||||
require.True(t, *adminSvc.lastUpdateAccountInput.ProbeEnabled)
|
||||
require.NotNil(t, adminSvc.lastUpdateAccountInput.RateSyncEnabled)
|
||||
require.True(t, *adminSvc.lastUpdateAccountInput.RateSyncEnabled)
|
||||
}
|
||||
|
||||
func TestAccountHandlerBulkUpdateMixedChannelConflict(t *testing.T) {
|
||||
adminSvc := newStubAdminService()
|
||||
adminSvc.bulkUpdateAccountErr = &service.MixedChannelError{
|
||||
|
||||
@@ -32,6 +32,7 @@ type stubAdminService struct {
|
||||
createAccountErr error
|
||||
createSparkShadowErr error
|
||||
updateAccountErr error
|
||||
lastUpdateAccountInput *service.UpdateAccountInput
|
||||
bulkUpdateAccountErr error
|
||||
lastBulkUpdateAccountInput *service.BulkUpdateAccountsInput
|
||||
getAccountResult *service.Account
|
||||
@@ -501,6 +502,7 @@ func (s *stubAdminService) RecoverDuplicateAccount(ctx context.Context, id int64
|
||||
|
||||
func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
|
||||
s.updateAccountCalls++
|
||||
s.lastUpdateAccountInput = input
|
||||
if s.updateAccountErr != nil {
|
||||
return nil, s.updateAccountErr
|
||||
}
|
||||
|
||||
@@ -20,10 +20,12 @@ func NewContentModerationHandler(svc *service.ContentModerationService) *Content
|
||||
}
|
||||
|
||||
type contentModerationConfigRequest struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
Enabled *bool `json:"enabled"`
|
||||
Mode *string `json:"mode"`
|
||||
BaseURL *string `json:"base_url"`
|
||||
Model *string `json:"model"`
|
||||
// 审计请求使用的代理服务器:null 不修改;0 清除(直连);>0 指定代理。
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
APIKey *string `json:"api_key"`
|
||||
APIKeys *[]string `json:"api_keys"`
|
||||
APIKeysMode string `json:"api_keys_mode"`
|
||||
@@ -60,6 +62,7 @@ type contentModerationAPIKeyTestRequest struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
@@ -88,6 +91,7 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
|
||||
Mode: req.Mode,
|
||||
BaseURL: req.BaseURL,
|
||||
Model: req.Model,
|
||||
ProxyID: req.ProxyID,
|
||||
APIKey: req.APIKey,
|
||||
APIKeys: req.APIKeys,
|
||||
APIKeysMode: req.APIKeysMode,
|
||||
@@ -134,6 +138,7 @@ func (h *ContentModerationHandler) TestAPIKeys(c *gin.Context) {
|
||||
BaseURL: req.BaseURL,
|
||||
Model: req.Model,
|
||||
TimeoutMS: req.TimeoutMS,
|
||||
ProxyID: req.ProxyID,
|
||||
Prompt: req.Prompt,
|
||||
Images: req.Images,
|
||||
})
|
||||
|
||||
@@ -118,6 +118,9 @@ type CreateGroupRequest struct {
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin *float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
@@ -177,6 +180,9 @@ type UpdateGroupRequest struct {
|
||||
PeakStart *string `json:"peak_start"`
|
||||
PeakEnd *string `json:"peak_end"`
|
||||
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
|
||||
ProfitControlEnabled *bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin *float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
|
||||
ImagePrice1K *float64 `json:"image_price_1k"`
|
||||
ImagePrice2K *float64 `json:"image_price_2k"`
|
||||
ImagePrice4K *float64 `json:"image_price_4k"`
|
||||
@@ -475,6 +481,13 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// platform 是 omitempty:预校验必须用与 CreateGroup 落库一致的归一化平台,
|
||||
// 否则省略 platform 的请求会被误判成「平台不支持利润控制」。
|
||||
if err := service.ValidateProfitControlConfig(service.NormalizeGroupPlatform(req.Platform), req.ProfitControlEnabled, float64ValueOrDefault(req.ProfitMinMargin, 0), float64ValueOrDefault(req.ProfitSafetyBuffer, 0)); err != nil {
|
||||
response.BadRequest(c, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{
|
||||
Name: req.Name,
|
||||
Description: req.Description,
|
||||
@@ -497,6 +510,9 @@ func (h *GroupHandler) Create(c *gin.Context) {
|
||||
PeakStart: req.PeakStart,
|
||||
PeakEnd: req.PeakEnd,
|
||||
PeakRateMultiplier: req.PeakRateMultiplier,
|
||||
ProfitControlEnabled: req.ProfitControlEnabled,
|
||||
ProfitMinMargin: req.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: req.ProfitSafetyBuffer,
|
||||
ImagePrice1K: req.ImagePrice1K,
|
||||
ImagePrice2K: req.ImagePrice2K,
|
||||
ImagePrice4K: req.ImagePrice4K,
|
||||
@@ -616,6 +632,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
|
||||
PeakStart: req.PeakStart,
|
||||
PeakEnd: req.PeakEnd,
|
||||
PeakRateMultiplier: req.PeakRateMultiplier,
|
||||
ProfitControlEnabled: req.ProfitControlEnabled,
|
||||
ProfitMinMargin: req.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: req.ProfitSafetyBuffer,
|
||||
ImagePrice1K: req.ImagePrice1K,
|
||||
ImagePrice2K: req.ImagePrice2K,
|
||||
ImagePrice4K: req.ImagePrice4K,
|
||||
|
||||
@@ -233,6 +233,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
CompactHomeEnabled: settings.CompactHomeEnabled,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
|
||||
@@ -299,6 +299,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
|
||||
if before.HomeContent != after.HomeContent {
|
||||
changed = append(changed, "home_content")
|
||||
}
|
||||
if before.CompactHomeEnabled != after.CompactHomeEnabled {
|
||||
changed = append(changed, "compact_home_enabled")
|
||||
}
|
||||
if before.HideCcsImportButton != after.HideCcsImportButton {
|
||||
changed = append(changed, "hide_ccs_import_button")
|
||||
}
|
||||
|
||||
@@ -64,6 +64,18 @@ func TestDiffSettings_NoChangeWhenEqual(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDiffSettings_DetectsCompactHomeChange(t *testing.T) {
|
||||
changed := diffSettings(
|
||||
&service.SystemSettings{},
|
||||
&service.SystemSettings{CompactHomeEnabled: true},
|
||||
nil,
|
||||
nil,
|
||||
UpdateSettingsRequest{},
|
||||
)
|
||||
|
||||
require.Contains(t, changed, service.SettingKeyCompactHomeEnabled)
|
||||
}
|
||||
|
||||
func TestEqualNullableFloat(t *testing.T) {
|
||||
five := 5.0
|
||||
five2 := 5.0
|
||||
|
||||
@@ -142,6 +142,7 @@ type UpdateSettingsRequest struct {
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
CompactHomeEnabled bool `json:"compact_home_enabled"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
|
||||
@@ -1430,6 +1431,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ContactInfo: req.ContactInfo,
|
||||
DocURL: req.DocURL,
|
||||
HomeContent: req.HomeContent,
|
||||
CompactHomeEnabled: req.CompactHomeEnabled,
|
||||
HideCcsImportButton: req.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: purchaseEnabled,
|
||||
PurchaseSubscriptionURL: purchaseURL,
|
||||
@@ -1982,6 +1984,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
|
||||
ContactInfo: updatedSettings.ContactInfo,
|
||||
DocURL: updatedSettings.DocURL,
|
||||
HomeContent: updatedSettings.HomeContent,
|
||||
CompactHomeEnabled: updatedSettings.CompactHomeEnabled,
|
||||
HideCcsImportButton: updatedSettings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package dto
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
)
|
||||
|
||||
// profitControlJSONFields 是分组利润控制的三个 JSON 字段。它们与同响应中的
|
||||
// rate_multiplier 相乘即可反推出运营方的上游采购成本上限,属于内部经营信息,
|
||||
// 只能出现在管理员 DTO 中。
|
||||
var profitControlJSONFields = []string{
|
||||
"profit_control_enabled",
|
||||
"profit_min_margin",
|
||||
"profit_safety_buffer",
|
||||
}
|
||||
|
||||
func profitControlServiceGroup() *service.Group {
|
||||
return &service.Group{
|
||||
ID: 7,
|
||||
Name: "profit-gated",
|
||||
Platform: service.PlatformAnthropic,
|
||||
RateMultiplier: 2.0,
|
||||
Status: service.StatusActive,
|
||||
ProfitControlEnabled: true,
|
||||
ProfitMinMargin: 0.3,
|
||||
ProfitSafetyBuffer: 0.05,
|
||||
}
|
||||
}
|
||||
|
||||
func marshalToMap(t *testing.T, v any) map[string]any {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(v)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal: %v", err)
|
||||
}
|
||||
var out map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
t.Fatalf("unmarshal: %v", err)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestGroupFromServiceOmitsProfitControl 钉死普通用户侧的分组 DTO 不泄露利润控制配置。
|
||||
func TestGroupFromServiceOmitsProfitControl(t *testing.T) {
|
||||
for name, got := range map[string]any{
|
||||
"GroupFromService": GroupFromService(profitControlServiceGroup()),
|
||||
"GroupFromServiceShallow": GroupFromServiceShallow(profitControlServiceGroup()),
|
||||
} {
|
||||
fields := marshalToMap(t, got)
|
||||
for _, f := range profitControlJSONFields {
|
||||
if _, ok := fields[f]; ok {
|
||||
t.Errorf("%s: 普通用户 DTO 不得包含 %q", name, f)
|
||||
}
|
||||
}
|
||||
if _, ok := fields["rate_multiplier"]; !ok {
|
||||
t.Errorf("%s: 应仍返回 rate_multiplier", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestGroupFromServiceAdminIncludesProfitControl 钉死管理端仍能读写利润控制配置。
|
||||
func TestGroupFromServiceAdminIncludesProfitControl(t *testing.T) {
|
||||
admin := GroupFromServiceAdmin(profitControlServiceGroup())
|
||||
if admin.ProfitControlEnabled != true || admin.ProfitMinMargin != 0.3 || admin.ProfitSafetyBuffer != 0.05 {
|
||||
t.Fatalf("管理员 DTO 未透传利润控制配置: %+v", admin)
|
||||
}
|
||||
fields := marshalToMap(t, admin)
|
||||
for _, f := range profitControlJSONFields {
|
||||
if _, ok := fields[f]; !ok {
|
||||
t.Errorf("管理员 DTO 应包含 %q", f)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -146,6 +146,9 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
|
||||
}
|
||||
out := &AdminGroup{
|
||||
Group: groupFromServiceBase(g),
|
||||
ProfitControlEnabled: g.ProfitControlEnabled,
|
||||
ProfitMinMargin: g.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
|
||||
ModelRouting: g.ModelRouting,
|
||||
ModelRoutingEnabled: g.ModelRoutingEnabled,
|
||||
MCPXMLInject: g.MCPXMLInject,
|
||||
|
||||
@@ -142,6 +142,7 @@ type SystemSettings struct {
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
CompactHomeEnabled bool `json:"compact_home_enabled"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
@@ -344,6 +345,7 @@ type PublicSettings struct {
|
||||
ContactInfo string `json:"contact_info"`
|
||||
DocURL string `json:"doc_url"`
|
||||
HomeContent string `json:"home_content"`
|
||||
CompactHomeEnabled bool `json:"compact_home_enabled"`
|
||||
HideCcsImportButton bool `json:"hide_ccs_import_button"`
|
||||
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
|
||||
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
|
||||
|
||||
@@ -155,6 +155,13 @@ type Group struct {
|
||||
type AdminGroup struct {
|
||||
Group
|
||||
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。
|
||||
// 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出
|
||||
// 运营方的上游成本上限,属于内部经营信息,不得下放到 dto.Group。
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
|
||||
|
||||
// 模型路由配置(仅 anthropic 平台使用)
|
||||
ModelRouting map[string][]int64 `json:"model_routing"`
|
||||
ModelRoutingEnabled bool `json:"model_routing_enabled"`
|
||||
|
||||
@@ -40,8 +40,18 @@ const (
|
||||
// Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s),
|
||||
// Handler 层只需短暂间隔后重新进入 Service 层即可。
|
||||
singleAccountBackoffDelay = 2 * time.Second
|
||||
// maxProfitVetoAttempts 单次请求内允许的分组利润门终检否决次数上限。
|
||||
// 利润否决不产生上游请求,因此不会推进 SwitchCount;没有独立上限的话,
|
||||
// 「选号 → 终检否决 → 重选」在候选池与账号快照短暂不一致时可以空转很久。
|
||||
// 取值与 maxAccountSwitches 默认值一致:混合定价的大分组仍有充分重选机会,
|
||||
// 同时把整池越线时的无谓选号开销限制在常数级。
|
||||
maxProfitVetoAttempts = 10
|
||||
)
|
||||
|
||||
// profitVetoExhaustedMessage 是利润否决次数耗尽时返回给客户端的文案。
|
||||
// 语义上等同于「无可用账号」:候选账号都不满足分组的利润约束。
|
||||
const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control"
|
||||
|
||||
// FailoverState 跨循环迭代共享的 failover 状态
|
||||
type FailoverState struct {
|
||||
SwitchCount int
|
||||
@@ -51,18 +61,64 @@ type FailoverState struct {
|
||||
LastFailoverErr *service.UpstreamFailoverError
|
||||
ForceCacheBilling bool
|
||||
hasBoundSession bool
|
||||
|
||||
// profitVetoedAccountIDs 记录被分组利润门终检否决的账号,是 FailedAccountIDs
|
||||
// 的子集。之所以单独维护:HandleSelectionExhausted 的 503 退避分支会清空
|
||||
// FailedAccountIDs,而利润否决在同一请求内的判定不会改变(下游倍率 D 已在
|
||||
// 请求开始冻结),被清空的账号会被立即重选并再次否决,形成没有任何上游请求、
|
||||
// SwitchCount 也不前进的活锁。清空后必须把它们放回排除集。
|
||||
profitVetoedAccountIDs map[int64]struct{}
|
||||
// profitVetoCount 本次请求累计的利润否决次数,用于 maxProfitVetoAttempts 上限。
|
||||
profitVetoCount int
|
||||
}
|
||||
|
||||
// NewFailoverState 创建 failover 状态
|
||||
func NewFailoverState(maxSwitches int, hasBoundSession bool) *FailoverState {
|
||||
return &FailoverState{
|
||||
MaxSwitches: maxSwitches,
|
||||
FailedAccountIDs: make(map[int64]struct{}),
|
||||
SameAccountRetryCount: make(map[int64]int),
|
||||
hasBoundSession: hasBoundSession,
|
||||
MaxSwitches: maxSwitches,
|
||||
FailedAccountIDs: make(map[int64]struct{}),
|
||||
SameAccountRetryCount: make(map[int64]int),
|
||||
hasBoundSession: hasBoundSession,
|
||||
profitVetoedAccountIDs: make(map[int64]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// RecordProfitVeto 记录一次分组利润门终检否决:把账号加入排除列表(同时登记到
|
||||
// 利润否决集,使其不被 503 退避分支清掉)并递增否决计数。
|
||||
//
|
||||
// 返回 FailoverContinue 表示调用方可以继续重选下一个账号;返回 FailoverExhausted
|
||||
// 表示本次请求的利润否决次数已达上限,调用方应按「无可用账号」终止,
|
||||
// 不得继续 continue。
|
||||
func (s *FailoverState) RecordProfitVeto(accountID int64) FailoverAction {
|
||||
s.FailedAccountIDs[accountID] = struct{}{}
|
||||
if s.profitVetoedAccountIDs == nil {
|
||||
s.profitVetoedAccountIDs = make(map[int64]struct{})
|
||||
}
|
||||
s.profitVetoedAccountIDs[accountID] = struct{}{}
|
||||
s.profitVetoCount++
|
||||
if s.profitVetoCount >= maxProfitVetoAttempts {
|
||||
return FailoverExhausted
|
||||
}
|
||||
return FailoverContinue
|
||||
}
|
||||
|
||||
// ProfitVetoCount 返回本次请求累计的利润否决次数(供日志使用)。
|
||||
func (s *FailoverState) ProfitVetoCount() int { return s.profitVetoCount }
|
||||
|
||||
// allExclusionsAreProfitVetoed 判断排除列表是否已全部由利润门否决贡献。
|
||||
// 此时清空 FailedAccountIDs 会被原样恢复,退避重试不会带来任何新候选。
|
||||
func (s *FailoverState) allExclusionsAreProfitVetoed() bool {
|
||||
if len(s.profitVetoedAccountIDs) == 0 || len(s.FailedAccountIDs) == 0 {
|
||||
return false
|
||||
}
|
||||
for id := range s.FailedAccountIDs {
|
||||
if _, ok := s.profitVetoedAccountIDs[id]; !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// HandleFailoverError 处理 UpstreamFailoverError,返回下一步动作。
|
||||
// 包含:缓存计费判断、同账号重试、临时封禁、切换计数、Antigravity 延时。
|
||||
func (s *FailoverState) HandleFailoverError(
|
||||
@@ -156,6 +212,17 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc
|
||||
s.LastFailoverErr.StatusCode == http.StatusServiceUnavailable &&
|
||||
s.SwitchCount <= s.MaxSwitches {
|
||||
|
||||
// 排除列表全由利润门否决贡献时,清空后会被原样恢复:退避重试拿不到
|
||||
// 任何新候选,而利润否决不推进 SwitchCount,退避条件将永远成立。
|
||||
// 这里直接判定耗尽,避免每 2s 空转一轮的活锁。
|
||||
if s.allExclusionsAreProfitVetoed() {
|
||||
logger.FromContext(ctx).Warn("gateway.failover_selection_exhausted_by_profit_veto",
|
||||
zap.Int("profit_veto_count", s.profitVetoCount),
|
||||
zap.Int("excluded_accounts", len(s.FailedAccountIDs)),
|
||||
)
|
||||
return FailoverExhausted
|
||||
}
|
||||
|
||||
logger.FromContext(ctx).Warn("gateway.failover_single_account_backoff",
|
||||
zap.Duration("backoff_delay", singleAccountBackoffDelay),
|
||||
zap.Int("switch_count", s.SwitchCount),
|
||||
@@ -169,6 +236,11 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc
|
||||
zap.Int("max_switches", s.MaxSwitches),
|
||||
)
|
||||
s.FailedAccountIDs = make(map[int64]struct{})
|
||||
// 利润门否决的账号不参与退避重试的解除:判定依据(冻结的下游倍率)在
|
||||
// 同一请求内不变,放它们回池只会被再次否决。
|
||||
for id := range s.profitVetoedAccountIDs {
|
||||
s.FailedAccountIDs[id] = struct{}{}
|
||||
}
|
||||
return FailoverContinue
|
||||
}
|
||||
return FailoverExhausted
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// profitVetoLoopResult 记录一次模拟选号循环的终止方式与步数。
|
||||
type profitVetoLoopResult struct {
|
||||
outcome string // "forwarded" | "exhausted" | "budget_exceeded"
|
||||
forwardedID int64
|
||||
iterations int
|
||||
backoffRetries int
|
||||
}
|
||||
|
||||
// runProfitVetoLoop 模拟 handler 的「选号 → 利润终检 → 重选」循环,只保留与
|
||||
// 活锁相关的状态机(FailoverState + 排除列表 + HandleSelectionExhausted),
|
||||
// 不涉及真实调度器与上游转发。
|
||||
//
|
||||
// pool 按顺序给出候选账号;vetoed 中的账号每次终检都被利润门否决——这对应
|
||||
// 「候选池快照与 per-account 快照短暂不一致」时门的确定性判定。
|
||||
// maxIterations 是测试自身的预算:循环超过它即视为活锁。
|
||||
func runProfitVetoLoop(t *testing.T, fs *FailoverState, pool []int64, vetoed map[int64]bool, maxIterations int) profitVetoLoopResult {
|
||||
t.Helper()
|
||||
res := profitVetoLoopResult{}
|
||||
for res.iterations = 1; res.iterations <= maxIterations; res.iterations++ {
|
||||
// 选号:返回第一个不在排除列表中的账号。
|
||||
var picked int64
|
||||
for _, id := range pool {
|
||||
if _, excluded := fs.FailedAccountIDs[id]; !excluded {
|
||||
picked = id
|
||||
break
|
||||
}
|
||||
}
|
||||
if picked == 0 {
|
||||
// 选号耗尽,交给退避决策。
|
||||
switch fs.HandleSelectionExhausted(context.Background()) {
|
||||
case FailoverContinue:
|
||||
res.backoffRetries++
|
||||
continue
|
||||
default:
|
||||
res.outcome = "exhausted"
|
||||
return res
|
||||
}
|
||||
}
|
||||
if vetoed[picked] {
|
||||
if fs.RecordProfitVeto(picked) == FailoverExhausted {
|
||||
res.outcome = "exhausted"
|
||||
return res
|
||||
}
|
||||
continue
|
||||
}
|
||||
res.outcome = "forwarded"
|
||||
res.forwardedID = picked
|
||||
return res
|
||||
}
|
||||
res.outcome = "budget_exceeded"
|
||||
return res
|
||||
}
|
||||
|
||||
// TestProfitVetoAfter503DoesNotLivelock 钉死 #4925 引入的活锁回归:
|
||||
// 一次真实 503 之后,若调度器持续返回会被利润门否决的账号,
|
||||
// 「选号 → 否决 → 排除 → 选号耗尽 → 清空排除 → 睡 2s → 选号」会无限循环,
|
||||
// 因为利润否决不推进 SwitchCount,退避条件永远成立。
|
||||
func TestProfitVetoAfter503DoesNotLivelock(t *testing.T) {
|
||||
fs := NewFailoverState(10, false)
|
||||
// 已经历一次真实 503(Antigravity 单账号分组 MODEL_CAPACITY_EXHAUSTED 是设计内路径)。
|
||||
fs.LastFailoverErr = newTestFailoverErr(503, false, false)
|
||||
fs.SwitchCount = 1
|
||||
fs.FailedAccountIDs[1] = struct{}{}
|
||||
|
||||
start := time.Now()
|
||||
res := runProfitVetoLoop(t, fs, []int64{1}, map[int64]bool{1: true}, 50)
|
||||
elapsed := time.Since(start)
|
||||
|
||||
require.Equal(t, "exhausted", res.outcome, "整池被利润门否决时必须有限步终止")
|
||||
require.LessOrEqual(t, res.backoffRetries, 1, "被否决的账号不得被退避分支复活并反复重选")
|
||||
require.Less(t, elapsed, 10*time.Second, "不得每 2s 空转一轮")
|
||||
}
|
||||
|
||||
// TestProfitVetoKeepsBackoffUsefulForHealthyAccount 钉死修复没有削弱既有的
|
||||
// 503 退避语义:排除列表里仍有非利润否决的账号时,退避照常清空并重试,
|
||||
// 只是被利润门否决的账号不再复活。
|
||||
func TestProfitVetoKeepsBackoffUsefulForHealthyAccount(t *testing.T) {
|
||||
fs := NewFailoverState(10, false)
|
||||
fs.LastFailoverErr = newTestFailoverErr(503, false, false)
|
||||
fs.SwitchCount = 1
|
||||
// 账号 1 因真实 503 被排除;账号 2 会被利润门否决。
|
||||
fs.FailedAccountIDs[1] = struct{}{}
|
||||
|
||||
res := runProfitVetoLoop(t, fs, []int64{2, 1}, map[int64]bool{2: true}, 50)
|
||||
|
||||
require.Equal(t, "forwarded", res.outcome)
|
||||
require.Equal(t, int64(1), res.forwardedID, "退避清空后应重新可选账号 1")
|
||||
require.Equal(t, 1, res.backoffRetries, "应发生且只发生一次退避重试")
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(2), "利润否决的账号在退避清空后必须被放回排除集")
|
||||
}
|
||||
|
||||
// TestProfitVetoAttemptsCapped 钉死没有 503 参与时,大分组整池越线也会在
|
||||
// 常数步内终止,而不是把整池逐个选一遍。
|
||||
func TestProfitVetoAttemptsCapped(t *testing.T) {
|
||||
fs := NewFailoverState(10, false)
|
||||
pool := make([]int64, 0, 64)
|
||||
vetoed := make(map[int64]bool, 64)
|
||||
for id := int64(1); id <= 64; id++ {
|
||||
pool = append(pool, id)
|
||||
vetoed[id] = true
|
||||
}
|
||||
|
||||
res := runProfitVetoLoop(t, fs, pool, vetoed, 200)
|
||||
|
||||
require.Equal(t, "exhausted", res.outcome)
|
||||
require.Equal(t, maxProfitVetoAttempts, fs.ProfitVetoCount())
|
||||
require.Equal(t, maxProfitVetoAttempts, res.iterations, "达到上限即终止,不应继续遍历候选池")
|
||||
}
|
||||
|
||||
// TestRecordProfitVetoExcludesAccount 钉死 RecordProfitVeto 仍然把账号加入
|
||||
// 调度排除列表(选号入参用的就是 FailedAccountIDs)。
|
||||
func TestRecordProfitVetoExcludesAccount(t *testing.T) {
|
||||
fs := NewFailoverState(10, false)
|
||||
require.Equal(t, FailoverContinue, fs.RecordProfitVeto(42))
|
||||
require.Contains(t, fs.FailedAccountIDs, int64(42))
|
||||
require.Equal(t, 1, fs.ProfitVetoCount())
|
||||
}
|
||||
|
||||
// TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto 钉死未启用利润控制的
|
||||
// 请求(无任何利润否决)走的仍是原有退避语义:清空排除列表并重试。
|
||||
func TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto(t *testing.T) {
|
||||
fs := NewFailoverState(3, false)
|
||||
fs.LastFailoverErr = newTestFailoverErr(503, false, false)
|
||||
fs.SwitchCount = 1
|
||||
fs.FailedAccountIDs[100] = struct{}{}
|
||||
|
||||
require.Equal(t, FailoverContinue, fs.HandleSelectionExhausted(context.Background()))
|
||||
require.Empty(t, fs.FailedAccountIDs, "无利润否决时排除列表应被完全清空")
|
||||
}
|
||||
@@ -196,6 +196,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
|
||||
c.Request = c.Request.WithContext(pricingCtx)
|
||||
|
||||
// 验证 model 必填
|
||||
if reqModel == "" {
|
||||
@@ -421,8 +423,30 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
// Slot acquired: no longer waiting in queue.
|
||||
releaseWait()
|
||||
if err := h.gatewayService.BindStickySession(c.Request.Context(), apiKey.GroupID, sessionKey, account.ID); err != nil {
|
||||
reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
// 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。
|
||||
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
|
||||
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("gateway.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
|
||||
reqLog.Warn("gateway.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
|
||||
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
|
||||
if selection.ProfitGateActive() || !selection.Acquired {
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionKey, account.ID); err != nil {
|
||||
reqLog.Warn("gateway.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// 账号槽位/等待计数需要在超时或断开时安全回收
|
||||
@@ -544,6 +568,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
@@ -721,12 +746,30 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
// Slot acquired: no longer waiting in queue.
|
||||
releaseWait()
|
||||
reqLog.Info("sticky.bind_after_wait",
|
||||
zap.String("session_key", sessionKey),
|
||||
zap.Int64("account_id", account.ID),
|
||||
)
|
||||
if err := h.gatewayService.BindStickySession(c.Request.Context(), currentAPIKey.GroupID, sessionKey, account.ID); err != nil {
|
||||
reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
// 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。
|
||||
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
|
||||
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("gateway.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
|
||||
reqLog.Warn("gateway.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
|
||||
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
|
||||
if selection.ProfitGateActive() || !selection.Acquired {
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, currentAPIKey.GroupID, sessionKey, account.ID); err != nil {
|
||||
reqLog.Warn("gateway.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// 账号槽位/等待计数需要在超时或断开时安全回收
|
||||
@@ -827,6 +870,66 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
|
||||
// 提交 usage 记录。成功路径与"流中断但 Forward 已观测到 usage 的部分结果"
|
||||
// 错误路径共用:后者若不入账,上游已计量的请求会完全漏记漏计费(#5148)。
|
||||
submitForwardUsage := func(result *service.ForwardResult) {
|
||||
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
// Forward 内部可能继续改写 body,usage 去重指纹必须使用最终上游接受的当前 body。
|
||||
requestPayloadHash := service.HashUsageRequestPayload(attemptParsedReq.Body.Bytes())
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
|
||||
if result.ReasoningEffort == nil {
|
||||
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)
|
||||
}
|
||||
// 同上(重试路径中的对称填充)。详见非重试路径同名注释。
|
||||
if result.ReasoningEffort == nil && attemptParsedReq.ThinkingEnabled {
|
||||
protocolModel := result.UpstreamModel
|
||||
if protocolModel == "" {
|
||||
protocolModel = result.Model
|
||||
}
|
||||
result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel)
|
||||
}
|
||||
|
||||
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
|
||||
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
|
||||
forceCacheBilling := fs.ForceCacheBilling
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
APIKey: currentAPIKey,
|
||||
User: currentAPIKey.User,
|
||||
Account: account,
|
||||
Subscription: currentSubscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
SessionID: sessionID,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gateway.messages"),
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", currentAPIKey.ID),
|
||||
zap.Any("group_id", currentAPIKey.GroupID),
|
||||
zap.String("model", reqModel),
|
||||
zap.Int64("account_id", account.ID),
|
||||
).Error("gateway.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
// Beta policy block: return 400 immediately, no failover
|
||||
var betaBlockedErr *service.BetaBlockedError
|
||||
@@ -925,6 +1028,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID))
|
||||
}
|
||||
reqLog.Error("gateway.forward_failed", forwardFailedFields...)
|
||||
// Forward 与错误一起返回的部分结果:流中断前上游已计量的 usage 照常入账,
|
||||
// 避免上游已产生消耗的请求完全漏记(#5148)。failover 错误恒定 result=nil,
|
||||
// 不会走到这里重复计费。
|
||||
if result != nil {
|
||||
submitForwardUsage(result)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
@@ -948,59 +1057,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
|
||||
}
|
||||
}
|
||||
|
||||
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
|
||||
userAgent := c.GetHeader("User-Agent")
|
||||
clientIP := ip.GetClientIP(c)
|
||||
// Forward 内部可能继续改写 body,usage 去重指纹必须使用最终上游接受的当前 body。
|
||||
requestPayloadHash := service.HashUsageRequestPayload(attemptParsedReq.Body.Bytes())
|
||||
inboundEndpoint := GetInboundEndpoint(c)
|
||||
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
|
||||
|
||||
if result.ReasoningEffort == nil {
|
||||
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)
|
||||
}
|
||||
// 同上(重试路径中的对称填充)。详见非重试路径同名注释。
|
||||
if result.ReasoningEffort == nil && attemptParsedReq.ThinkingEnabled {
|
||||
protocolModel := result.UpstreamModel
|
||||
if protocolModel == "" {
|
||||
protocolModel = result.Model
|
||||
}
|
||||
result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel)
|
||||
}
|
||||
|
||||
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
|
||||
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
|
||||
forceCacheBilling := fs.ForceCacheBilling
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
|
||||
Result: result,
|
||||
QuotaPlatform: quotaPlatform,
|
||||
APIKey: currentAPIKey,
|
||||
User: currentAPIKey.User,
|
||||
Account: account,
|
||||
Subscription: currentSubscription,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
IPAddress: clientIP,
|
||||
SessionID: sessionID,
|
||||
RequestPayloadHash: requestPayloadHash,
|
||||
ForceCacheBilling: forceCacheBilling,
|
||||
APIKeyService: h.apiKeyService,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gateway.messages"),
|
||||
zap.Int64("user_id", subject.UserID),
|
||||
zap.Int64("api_key_id", currentAPIKey.ID),
|
||||
zap.Any("group_id", currentAPIKey.GroupID),
|
||||
zap.String("model", reqModel),
|
||||
zap.Int64("account_id", account.ID),
|
||||
).Error("gateway.record_usage_failed", zap.Error(err))
|
||||
}
|
||||
})
|
||||
submitForwardUsage(result)
|
||||
return
|
||||
}
|
||||
if !retryWithFallback {
|
||||
@@ -2336,10 +2393,16 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv
|
||||
}
|
||||
task = wrapUsageRecordTaskContext(parent, task)
|
||||
if h.usageRecordWorkerPool != nil {
|
||||
h.usageRecordWorkerPool.Submit(task)
|
||||
return
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
|
||||
return
|
||||
}
|
||||
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
|
||||
// 显式配置的 drop/sample 溢出丢弃仍按配置语义保留。
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.gateway.messages"),
|
||||
).Warn("gateway.usage_record_task_stopped_sync_fallback")
|
||||
}
|
||||
// 回退路径:worker 池未注入时同步执行,避免退回到无界 goroutine 模式。
|
||||
// 回退路径:worker 池未注入或已停止时同步执行,避免退回到无界 goroutine 模式。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
defer func() {
|
||||
|
||||
@@ -93,6 +93,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
|
||||
c.Request = c.Request.WithContext(pricingCtx)
|
||||
|
||||
// 解析渠道级模型映射
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
|
||||
@@ -157,7 +159,6 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
if groupPlatform == service.PlatformGemini && selectionSessionHash != "" {
|
||||
selectionSessionHash = "gemini:" + selectionSessionHash
|
||||
}
|
||||
|
||||
// 3. Account selection + failover loop
|
||||
fs := NewFailoverState(h.maxAccountSwitches, false)
|
||||
if groupPlatform == service.PlatformGemini {
|
||||
@@ -223,6 +224,28 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。
|
||||
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
|
||||
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("gateway.cc.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
|
||||
reqLog.Warn("gateway.cc.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
|
||||
h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
if selection.ProfitGateActive() {
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, selectionSessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("gateway.cc.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc)
|
||||
|
||||
if groupPlatform == service.PlatformGemini && account.Platform != service.PlatformGemini {
|
||||
@@ -318,6 +341,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
|
||||
@@ -90,9 +90,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
setOpsRequestContext(c, reqModel, reqStream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
|
||||
requestCtx := c.Request.Context()
|
||||
// 定价上下文无条件装配:/v1/responses 是 token 计费端点,声明生图工具的
|
||||
// 混合请求同样按 token 计费(外加图片部分),其 token 利润保护不因请求体
|
||||
// 里的任何工具声明(含 Codex 被动 image_gen namespace)而关闭。生图意图
|
||||
// 仅用于能力路由与图片计费;独立图片/视频端点才在利润门范围之外。
|
||||
requestCtx, pricingAt := service.WithGatewayTokenRequestPricing(requestCtx)
|
||||
if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(c.Request.Context(), apiKey)) {
|
||||
requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx)
|
||||
}
|
||||
c.Request = c.Request.WithContext(requestCtx)
|
||||
|
||||
// 解析渠道级模型映射
|
||||
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(requestCtx, apiKey.GroupID, reqModel)
|
||||
@@ -220,6 +226,30 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
}
|
||||
// 终检与准入后绑定必须使用选号结果携带的门:门安装在调度栈的局部
|
||||
// ctx 上(composite/fallback 还可能解析出与入口分组不同的门),直接用
|
||||
// requestCtx 会退化为空操作。
|
||||
admissionCtx := service.ContextWithSelectionProfitGate(requestCtx, selection)
|
||||
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("gateway.responses.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
|
||||
reqLog.Warn("gateway.responses.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
|
||||
h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
if selection.ProfitGateActive() {
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("gateway.responses.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc)
|
||||
|
||||
// 5. Forward request
|
||||
@@ -299,6 +329,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
|
||||
@@ -204,6 +204,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
|
||||
setOpsRequestContext(c, modelName, stream)
|
||||
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(stream, false)))
|
||||
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
|
||||
c.Request = c.Request.WithContext(pricingCtx)
|
||||
|
||||
if decision := h.checkSecurityAudit(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && !decision.AllowNextStage {
|
||||
googleSecurityAuditError(c, decision)
|
||||
@@ -466,8 +468,30 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
geminiConcurrency.DecrementAccountWaitCount(c.Request.Context(), account.ID)
|
||||
accountWaitCounted = false
|
||||
}
|
||||
if err := h.gatewayService.BindStickySession(c.Request.Context(), apiKey.GroupID, sessionKey, account.ID); err != nil {
|
||||
reqLog.Warn("gemini.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
// 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。
|
||||
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
|
||||
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("gemini.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
|
||||
reqLog.Warn("gemini.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
googleError(c, http.StatusServiceUnavailable, profitVetoExhaustedMessage)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已抢槽
|
||||
// 的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
|
||||
if selection.ProfitGateActive() || !selection.Acquired {
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionKey, account.ID); err != nil {
|
||||
reqLog.Warn("gemini.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
// 账号槽位/等待计数需要在超时或断开时安全回收
|
||||
@@ -553,6 +577,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
|
||||
User: apiKey.User,
|
||||
Account: account,
|
||||
Subscription: subscription,
|
||||
PricingAt: pricingAt,
|
||||
InboundEndpoint: inboundEndpoint,
|
||||
UpstreamEndpoint: upstreamEndpoint,
|
||||
UserAgent: userAgent,
|
||||
|
||||
@@ -177,7 +177,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
return
|
||||
}
|
||||
}
|
||||
requestCtx := c.Request.Context()
|
||||
// Grok 媒体(图片/视频生成与视频查询)按媒体倍率计费,不在 token 利润门
|
||||
// 范围内:显式豁免,防止 service 层防御性装门按文本 D 误过滤媒体请求,
|
||||
// 也防止已计费的在途视频任务因绑定账号被门排除而查询返回伪 404。
|
||||
requestCtx := service.WithOpenAIProfitControlSuppressed(c.Request.Context())
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
sameAccountRetryCount := make(map[int64]int)
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
@@ -293,8 +297,17 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
|
||||
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
|
||||
if !accountAcquired {
|
||||
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
|
||||
if slotResult == openAISlotAcquireProfitVetoed {
|
||||
// 媒体路径已显式豁免利润门(suppress 标记),此分支仅防御性兜底,
|
||||
// 同样受否决上限约束。
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slotResult != openAISlotAcquireOK {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -107,12 +107,18 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
|
||||
searchID := strings.TrimSpace(gjson.GetBytes(body, "id").String())
|
||||
sessionHash := h.gatewayService.GenerateSessionHashWithFallback(c, nil, searchID)
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
switchCount := 0
|
||||
var oauth429FailoverState service.OpenAIOAuth429FailoverState
|
||||
routingStart := time.Now()
|
||||
|
||||
// 分组利润控制:alpha search 文本入口请求级装门并固定 pricingAt
|
||||
//(记录路径经 service.OpenAIPricingAtFromContext 从请求 ctx 回读)。
|
||||
asPricingCtx, _ := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
|
||||
c.Request = c.Request.WithContext(asPricingCtx)
|
||||
|
||||
for {
|
||||
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
c.Request.Context(),
|
||||
@@ -151,8 +157,16 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
|
||||
|
||||
account := selection.Account
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
accountRelease, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
|
||||
if !acquired {
|
||||
accountRelease, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
|
||||
if slotResult == openAISlotAcquireProfitVetoed {
|
||||
// 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slotResult != openAISlotAcquireOK {
|
||||
return
|
||||
}
|
||||
service.SetOpsLatencyMs(c, service.OpsRoutingLatencyMsKey, time.Since(routingStart).Milliseconds())
|
||||
@@ -255,6 +269,7 @@ func (h *OpenAIGatewayHandler) recordAlphaSearchUsage(
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: channelMapping.ToUsageFields(requestedModel, result.UpstreamModel),
|
||||
PricingAt: service.OpenAIPricingAtFromContext(c.Request.Context()),
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.alpha_search"),
|
||||
|
||||
@@ -144,11 +144,16 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
switchCount := 0
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
sameAccountRetryCount := make(map[int64]int)
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
var oauth429FailoverState service.OpenAIOAuth429FailoverState
|
||||
|
||||
// 分组利润控制:chat completions 文本入口请求级装门并固定 pricingAt。
|
||||
ccPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
|
||||
c.Request = c.Request.WithContext(ccPricingCtx)
|
||||
|
||||
for {
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
@@ -207,8 +212,16 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
_ = scheduleDecision
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
accountReleaseFunc, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog)
|
||||
if !acquired {
|
||||
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog)
|
||||
if slotResult == openAISlotAcquireProfitVetoed {
|
||||
// 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slotResult != openAISlotAcquireOK {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -358,6 +371,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
PricingAt: pricingAt,
|
||||
CyberBlocked: cyberBlocked,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
|
||||
@@ -107,6 +107,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
switchCount := 0
|
||||
@@ -116,6 +117,10 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
}
|
||||
routingStart := time.Now()
|
||||
|
||||
// 分组利润控制:embeddings 文本入口请求级装门并固定 pricingAt。
|
||||
embPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
|
||||
c.Request = c.Request.WithContext(embPricingCtx)
|
||||
|
||||
for {
|
||||
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
|
||||
c.Request.Context(),
|
||||
@@ -165,8 +170,16 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
account := selection.Account
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
accountReleaseFunc, accountAcquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &streamStarted, reqLog)
|
||||
if !accountAcquired {
|
||||
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &streamStarted, reqLog)
|
||||
if slotResult == openAISlotAcquireProfitVetoed {
|
||||
// 利润终检否决:排除该账号重新选号;否决次数达上限则按无可用账号终止。
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slotResult != openAISlotAcquireOK {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -260,6 +273,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
PricingAt: pricingAt,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.embeddings"),
|
||||
|
||||
@@ -143,6 +143,9 @@ func (h *OpenAIGatewayHandler) CountTokens(c *gin.Context) {
|
||||
}
|
||||
|
||||
requestStart := time.Now()
|
||||
// count_tokens 不计费:显式豁免利润门,避免高倍率账号池被门排除后连
|
||||
// token 计数都返回 no available accounts。
|
||||
c.Request = c.Request.WithContext(service.WithOpenAIProfitControlSuppressed(c.Request.Context()))
|
||||
sessionHash := h.gatewayService.GenerateSessionHash(c, body)
|
||||
currentRoutingModel := routingModel
|
||||
if preferredMappedModel != "" {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"runtime/debug"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
@@ -426,6 +427,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
switchCount := 0
|
||||
firstOutputTimeoutSwitchCount := 0
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
sameAccountRetryCount := make(map[int64]int)
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
@@ -439,6 +441,13 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
// 该判断已排除 Codex 被动 image_gen namespace,避免 CC-only 账号被误过滤(#4476)。
|
||||
requiredCapability := openAIResponsesRequiredCapability(imageIntent, requestPlatform)
|
||||
|
||||
// 分组利润控制:请求级装配定价上下文——pricingAt 固定本请求的
|
||||
// D 与计费高峰因子,选号、槽位终检与全部 failover 重入共用同一门与阈值。
|
||||
// 生图意图只影响能力路由与图片计费,不关门:混合 /v1/responses 请求的
|
||||
// token 计费部分仍受利润门保护,独立图片/视频端点才在门外。
|
||||
pricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
|
||||
c.Request = c.Request.WithContext(pricingCtx)
|
||||
|
||||
for {
|
||||
// Streaming Forward intentionally detaches the upstream request so usage can
|
||||
// be drained after a disconnect. Re-check the client context before every
|
||||
@@ -516,8 +525,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
reqLog.Debug("openai.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name))
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
accountReleaseFunc, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog)
|
||||
if !acquired {
|
||||
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog)
|
||||
if slotResult == openAISlotAcquireProfitVetoed {
|
||||
// 利润终检否决:排除该账号重新选号,全池耗尽由下一轮选号报错;
|
||||
// 否决次数达上限则直接终止,避免排队抢槽后才终检的延迟放大。
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slotResult != openAISlotAcquireOK {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -696,6 +714,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
|
||||
PricingAt: pricingAt,
|
||||
CyberBlocked: cyberBlocked,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
@@ -988,12 +1007,17 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
switchCount := 0
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
sameAccountRetryCount := make(map[int64]int)
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
var oauth429FailoverState service.OpenAIOAuth429FailoverState
|
||||
effectiveMappedModel := preferredMappedModel
|
||||
|
||||
// 分组利润控制:Messages 文本入口同样请求级装门并固定 pricingAt。
|
||||
msgPricingCtx, pricingAt := h.gatewayService.WithOpenAIRequestPricingContext(c.Request.Context(), apiKey.GroupID)
|
||||
c.Request = c.Request.WithContext(msgPricingCtx)
|
||||
|
||||
for {
|
||||
if failoverClientGone(c) {
|
||||
return
|
||||
@@ -1058,8 +1082,17 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
_ = scheduleDecision
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
accountReleaseFunc, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog)
|
||||
if !acquired {
|
||||
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, reqStream, &streamStarted, reqLog)
|
||||
if slotResult == openAISlotAcquireProfitVetoed {
|
||||
// 利润终检否决:排除该账号重新选号,全池耗尽由下一轮选号报错;
|
||||
// 否决次数达上限则直接终止,避免排队抢槽后才终检的延迟放大。
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slotResult != openAISlotAcquireOK {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1208,6 +1241,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, result.UpstreamModel),
|
||||
PricingAt: pricingAt,
|
||||
CyberBlocked: cyberBlocked,
|
||||
}); err != nil {
|
||||
logger.L().With(
|
||||
@@ -1348,6 +1382,74 @@ func (h *OpenAIGatewayHandler) acquireResponsesUserSlot(
|
||||
return wrapReleaseOnDone(ctx, userReleaseFunc), true
|
||||
}
|
||||
|
||||
// openAISlotAcquireResult 是账号槽位获取的三态结果。
|
||||
type openAISlotAcquireResult int
|
||||
|
||||
const (
|
||||
openAISlotAcquireOK openAISlotAcquireResult = iota
|
||||
// openAISlotAcquireFailed:错误响应已写出,调用方直接 return。
|
||||
openAISlotAcquireFailed
|
||||
// openAISlotAcquireProfitVetoed:槽位获取成功后利润终检否决。槽位已释放、
|
||||
// 未写任何响应;调用方应经 recordOpenAIProfitVeto 把该账号加入本请求排除集
|
||||
// 后重新选号,全池耗尽由下一轮选号返回标准 no available accounts。
|
||||
openAISlotAcquireProfitVetoed
|
||||
)
|
||||
|
||||
// openAIWSTurnPricing 持有 WebSocket 连接内「当前 turn」的计费定价时刻。
|
||||
// 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在
|
||||
// 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。
|
||||
//
|
||||
// 零值语义(重要):ws_v2 passthrough ingress 只实现了 AfterTurn,没有任何
|
||||
// turn 起始回调,BeforeTurn 永远不会被调用。此时本值保持零,RecordUsage 经
|
||||
// openAIUsagePricingAt 回退到记录时刻——与引入分组利润控制前的基线一致。
|
||||
// 绝不能用建连时刻初始化:那会把透传连接的所有 turn 钉死在建连时的高峰因子,
|
||||
// 客户端只要峰前一分钟建连并保活,整条连接就能全程按谷价结算,正是利润控制
|
||||
// 想堵的漏洞。透传 ingress 目前不做 turn 级利润复核,只有建连时的准入门。
|
||||
type openAIWSTurnPricing struct {
|
||||
mu sync.Mutex
|
||||
at time.Time
|
||||
}
|
||||
|
||||
func (p *openAIWSTurnPricing) freeze(at time.Time) {
|
||||
p.mu.Lock()
|
||||
p.at = at
|
||||
p.mu.Unlock()
|
||||
}
|
||||
|
||||
func (p *openAIWSTurnPricing) current() time.Time {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.at
|
||||
}
|
||||
|
||||
// recordOpenAIProfitVeto 记录 OpenAI 侧选号循环的一次利润门终检否决:把账号
|
||||
// 加入本请求排除集并递增否决计数。返回 false 表示否决次数已达
|
||||
// maxProfitVetoAttempts,调用方必须停止重选并按「无可用账号」终止。
|
||||
//
|
||||
// OpenAI 路径用的是各自的 failedAccountIDs map + for 循环(不是 FailoverState),
|
||||
// 这里用一个独立计数器复用同一上限语义。上限是必需的:WaitPlan 分支先阻塞
|
||||
// 排队(sticky 45s / fallback 30s)拿到槽位才终检,无上限重选会把单次请求的
|
||||
// 延迟放大到 N × WaitPlan.Timeout。
|
||||
func recordOpenAIProfitVeto(failedAccountIDs map[int64]struct{}, accountID int64, vetoCount *int) bool {
|
||||
failedAccountIDs[accountID] = struct{}{}
|
||||
*vetoCount++
|
||||
return *vetoCount < maxProfitVetoAttempts
|
||||
}
|
||||
|
||||
// handleOpenAIProfitVetoExhausted 在利润否决预算耗尽时写出错误响应。
|
||||
// 与 acquireResponsesAccountSlot 内部的 no-available-accounts 失败分支同形,
|
||||
// 保证同一调用方在两条路径上拿到一致的响应格式。
|
||||
func (h *OpenAIGatewayHandler) handleOpenAIProfitVetoExhausted(
|
||||
c *gin.Context,
|
||||
streamStarted bool,
|
||||
reqLog *zap.Logger,
|
||||
vetoCount int,
|
||||
) {
|
||||
reqLog.Warn("openai.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", vetoCount))
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
|
||||
}
|
||||
|
||||
func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
|
||||
c *gin.Context,
|
||||
groupID *int64,
|
||||
@@ -1356,22 +1458,41 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
|
||||
reqStream bool,
|
||||
streamStarted *bool,
|
||||
reqLog *zap.Logger,
|
||||
) (func(), bool) {
|
||||
) (func(), openAISlotAcquireResult) {
|
||||
if selection == nil || selection.Account == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted)
|
||||
return nil, false
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
ctx := c.Request.Context()
|
||||
// 终检与准入后绑定使用选号结果携带的门:composite 等跨分组调度解析出的
|
||||
// 门只存在于调度栈的局部 ctx,必须经选号结果重放到本函数的 ctx 上。
|
||||
ctx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
|
||||
account := selection.Account
|
||||
if selection.Acquired {
|
||||
return wrapReleaseOnDone(ctx, selection.ReleaseFunc), true
|
||||
latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account)
|
||||
if vetoed {
|
||||
if selection.ReleaseFunc != nil {
|
||||
selection.ReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
return nil, openAISlotAcquireProfitVetoed
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
// 调度器已抢槽路径无门时由选号内部完成 eager 绑定;门下选号内部
|
||||
// 推迟绑定,这里在终检通过后补准入后绑定。
|
||||
if selection.ProfitGateActive() {
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
}
|
||||
return wrapReleaseOnDone(ctx, selection.ReleaseFunc), openAISlotAcquireOK
|
||||
}
|
||||
if selection.WaitPlan == nil {
|
||||
markOpsRoutingCapacityLimited(c)
|
||||
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", "No available accounts", *streamStarted)
|
||||
return nil, false
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
fastReleaseFunc, fastAcquired, err := h.concurrencyHelper.TryAcquireAccountSlot(
|
||||
@@ -1382,13 +1503,25 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
|
||||
if err != nil {
|
||||
reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
h.handleConcurrencyError(c, err, "account", *streamStarted)
|
||||
return nil, false
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
if fastAcquired {
|
||||
if err := h.gatewayService.BindStickySession(ctx, groupID, sessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("openai.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
// 分组利润控制:快速抢槽成功后终检。选号与抢槽之间账号
|
||||
// 倍率可能刷新,越线则释放槽位交由调用方排除重选,不绑定粘连。
|
||||
latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account)
|
||||
if vetoed {
|
||||
if fastReleaseFunc != nil {
|
||||
fastReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
return nil, openAISlotAcquireProfitVetoed
|
||||
}
|
||||
return wrapReleaseOnDone(ctx, fastReleaseFunc), true
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
return wrapReleaseOnDone(ctx, fastReleaseFunc), openAISlotAcquireOK
|
||||
}
|
||||
|
||||
canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(ctx, account.ID, selection.WaitPlan.MaxWaiting)
|
||||
@@ -1400,7 +1533,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
|
||||
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
|
||||
)
|
||||
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", *streamStarted)
|
||||
return nil, false
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
accountWaitCounted := waitErr == nil && canWait
|
||||
@@ -1423,15 +1556,27 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
|
||||
if err != nil {
|
||||
reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
h.handleConcurrencyError(c, err, "account", *streamStarted)
|
||||
return nil, false
|
||||
return nil, openAISlotAcquireFailed
|
||||
}
|
||||
|
||||
// Slot acquired: no longer waiting in queue.
|
||||
releaseWait()
|
||||
if err := h.gatewayService.BindStickySession(ctx, groupID, sessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("openai.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
// 分组利润控制:WaitPlan 排队成功后终检。排队期间账号倍率
|
||||
// 可能上调,越线则释放槽位交由调用方排除重选,不绑定粘连。
|
||||
latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(ctx, account)
|
||||
if vetoed {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("openai.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
return nil, openAISlotAcquireProfitVetoed
|
||||
}
|
||||
return wrapReleaseOnDone(ctx, accountReleaseFunc), true
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, groupID, sessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("openai.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
return wrapReleaseOnDone(ctx, accountReleaseFunc), openAISlotAcquireOK
|
||||
}
|
||||
|
||||
// ResponsesWebSocket handles OpenAI Responses API WebSocket ingress endpoint
|
||||
@@ -1667,6 +1812,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
)
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
switchCount := 0
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
var oauth429FailoverState service.OpenAIOAuth429FailoverState
|
||||
@@ -1717,6 +1863,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
requiredCapability = service.OpenAIEndpointCapabilityResponses
|
||||
}
|
||||
|
||||
// 分组利润控制:WS 桥按连接装配定价上下文并装门(选号与抢槽共用该
|
||||
// ctx)。连接内不重选号,但每个 turn 开始经 BeforeTurn 重新冻结 pricingAt
|
||||
// 并按最新门复核当前账号(准入与计费同源),峰前建连保活不能让后续 turn
|
||||
// 继续按建连时刻的谷价计费。生图意图只影响能力路由与图片计费,不关门。
|
||||
// 建连时刻只用于选号/准入,不作为任何 turn 的计费定价时刻。
|
||||
wsPricingCtx, _ := h.gatewayService.WithOpenAIRequestPricingContext(ctx, apiKey.GroupID)
|
||||
ctx = wsPricingCtx
|
||||
|
||||
for {
|
||||
if ctx.Err() != nil {
|
||||
return
|
||||
@@ -1762,7 +1916,28 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
if selection.WaitPlan != nil && selection.WaitPlan.MaxConcurrency > 0 {
|
||||
accountMaxConcurrency = selection.WaitPlan.MaxConcurrency
|
||||
}
|
||||
// 终检、准入后绑定与后续 turn 级复核都使用选号结果携带的门(composite
|
||||
// 等跨分组调度的门只存在于调度栈局部 ctx);准入成功后并入连接 ctx。
|
||||
admissionCtx := service.ContextWithSelectionProfitGate(ctx, selection)
|
||||
accountReleaseFunc := selection.ReleaseFunc
|
||||
if selection.Acquired {
|
||||
// 调度器已抢槽路径同样终检:选号与抢槽之间账号倍率可能刷新。
|
||||
latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
if accountReleaseFunc != nil {
|
||||
accountReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("openai.websocket_account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
reqLog.Warn("openai.websocket_profit_veto_attempts_exhausted", zap.Int("profit_veto_count", profitVetoCount))
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account")
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
}
|
||||
if !selection.Acquired {
|
||||
if selection.WaitPlan == nil {
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "account is busy, please retry later")
|
||||
@@ -1782,11 +1957,30 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "account is busy, please retry later")
|
||||
return
|
||||
}
|
||||
// 分组利润控制:WS 快速抢槽成功后终检,越线则释放
|
||||
// 槽位、排除该账号重新选号,全池耗尽由下一轮选号关闭连接。
|
||||
latest, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(admissionCtx, account)
|
||||
if vetoed {
|
||||
if fastReleaseFunc != nil {
|
||||
fastReleaseFunc()
|
||||
}
|
||||
reqLog.Debug("openai.websocket_account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
reqLog.Warn("openai.websocket_profit_veto_attempts_exhausted", zap.Int("profit_veto_count", profitVetoCount))
|
||||
closeOpenAIClientWS(wsConn, coderws.StatusTryAgainLater, "no available account")
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
account = latest
|
||||
selection.Account = latest
|
||||
accountReleaseFunc = fastReleaseFunc
|
||||
}
|
||||
// 准入完成:门并入连接 ctx,turn 级复核与 failover 重选共用。
|
||||
ctx = admissionCtx
|
||||
currentAccountRelease = wrapReleaseOnDone(ctx, accountReleaseFunc)
|
||||
if err := h.gatewayService.BindStickySession(ctx, apiKey.GroupID, sessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("openai.websocket_bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(ctx, apiKey.GroupID, sessionHash, account.ID); err != nil {
|
||||
reqLog.Warn("openai.websocket_bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
|
||||
}
|
||||
|
||||
token, _, err := h.gatewayService.GetRequestCredential(ctx, c, account)
|
||||
@@ -1824,6 +2018,10 @@ 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 并按最新门复核当前账号,
|
||||
// AfterTurn 的计费读取所属 turn 的时刻。零值起步的语义见
|
||||
// openAIWSTurnPricing 的注释——绝不能用建连时刻初始化。
|
||||
var turnPricing openAIWSTurnPricing
|
||||
hooks := &service.OpenAIWSIngressHooks{
|
||||
InitialRequestModel: reqModel,
|
||||
MaxReasoningEffort: maxReasoningEffort,
|
||||
@@ -1869,6 +2067,18 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
if cyberBlockedThisConn {
|
||||
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
|
||||
}
|
||||
// 长连接跨峰谷/倍率刷新防护:每个 turn 按当前时刻重装门并复核
|
||||
// 当前账号,越线即要求客户端重连重选(连接绑定单一上游账号,
|
||||
// 无法中途换号)。本 turn 的准入与计费共用同一 pricingAt。
|
||||
turnCtx, turnAt := h.gatewayService.WithOpenAITurnPricingContext(ctx, apiKey.GroupID)
|
||||
if _, vetoed, reason := h.gatewayService.ProfitControlVetoLatest(turnCtx, account); vetoed {
|
||||
reqLog.Info("openai.websocket_turn_profit_vetoed",
|
||||
zap.Int("turn", turn),
|
||||
zap.Int64("account_id", account.ID),
|
||||
zap.String("reason", reason))
|
||||
return service.NewOpenAIWSClientCloseError(coderws.StatusTryAgainLater, "account is no longer eligible for this connection, please reconnect", nil)
|
||||
}
|
||||
turnPricing.freeze(turnAt)
|
||||
if turn == 1 {
|
||||
return nil
|
||||
}
|
||||
@@ -1967,6 +2177,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
|
||||
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
|
||||
sessionID := service.ExtractClientSessionID(c)
|
||||
turnRecordPricingAt := turnPricing.current()
|
||||
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
|
||||
h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
|
||||
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
|
||||
@@ -1984,6 +2195,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
|
||||
QuotaPlatform: quotaPlatform,
|
||||
SessionID: sessionID,
|
||||
ChannelUsageFields: turnUsageFields,
|
||||
PricingAt: turnRecordPricingAt,
|
||||
CyberBlocked: cyberBlocked,
|
||||
}); err != nil {
|
||||
reqLog.Error("openai.websocket_record_usage_failed",
|
||||
@@ -2183,10 +2395,16 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, tas
|
||||
}
|
||||
task = wrapUsageRecordTaskContext(parent, task)
|
||||
if h.usageRecordWorkerPool != nil {
|
||||
h.usageRecordWorkerPool.Submit(task)
|
||||
return
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
|
||||
return
|
||||
}
|
||||
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
|
||||
// 显式配置的 drop/sample 溢出丢弃仍按配置语义保留。
|
||||
logger.L().With(
|
||||
zap.String("component", "handler.openai_gateway.responses"),
|
||||
).Warn("openai.usage_record_task_stopped_sync_fallback")
|
||||
}
|
||||
// 回退路径:worker 池未注入时同步执行,避免退回到无界 goroutine 模式。
|
||||
// 回退路径:worker 池未注入或已停止时同步执行,避免退回到无界 goroutine 模式。
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
defer func() {
|
||||
@@ -2214,7 +2432,7 @@ func (h *OpenAIGatewayHandler) submitMandatoryUsageRecordTask(parent context.Con
|
||||
}
|
||||
task = wrapUsageRecordTaskContext(parent, task)
|
||||
if h.usageRecordWorkerPool != nil {
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDropped {
|
||||
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
|
||||
return
|
||||
}
|
||||
logger.L().With(
|
||||
|
||||
@@ -1266,6 +1266,7 @@ func TestOpenAIResponsesWebSocket_ContentModerationBlocksFirstFrame(t *testing.T
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
decision, err := moderationSvc.Check(context.Background(), service.ContentModerationCheckInput{
|
||||
UserID: 1,
|
||||
@@ -1811,6 +1812,40 @@ func (u *openAIHTTPPassthroughFailoverUpstream) calls() []int64 {
|
||||
return append([]int64(nil), u.accountIDs...)
|
||||
}
|
||||
|
||||
type openAIHTTPPassthroughSSERateLimitUpstream struct {
|
||||
service.HTTPUpstream
|
||||
mu sync.Mutex
|
||||
accountIDs []int64
|
||||
}
|
||||
|
||||
func (u *openAIHTTPPassthroughSSERateLimitUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
|
||||
u.mu.Lock()
|
||||
u.accountIDs = append(u.accountIDs, accountID)
|
||||
u.mu.Unlock()
|
||||
body := strings.Join([]string{
|
||||
"event: response.created",
|
||||
`data: {"type":"response.created","response":{"id":"resp_rate_limited"}}`,
|
||||
"",
|
||||
"event: response.failed",
|
||||
`data: {"type":"response.failed","response":{"id":"resp_rate_limited","status":"failed","error":{"code":"rate_limit_exceeded","message":"Concurrency limit exceeded for account, please retry later"}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
return &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"Retry-After": []string{"1"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *openAIHTTPPassthroughSSERateLimitUpstream) calls() []int64 {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return append([]int64(nil), u.accountIDs...)
|
||||
}
|
||||
|
||||
func (s *openAIWSFailoverHandlerAccountRepoStub) ListSchedulableByPlatform(ctx context.Context, platform string) ([]service.Account, error) {
|
||||
out := make([]service.Account, 0, len(s.accounts))
|
||||
for _, account := range s.accounts {
|
||||
@@ -2011,6 +2046,88 @@ func TestOpenAIResponses_APIKeyPassthroughPool5xxRetriesThenExhaustsMaxSwitches(
|
||||
require.Equal(t, "Upstream service temporarily unavailable", gjson.GetBytes(rec.Body.Bytes(), "error.message").String())
|
||||
}
|
||||
|
||||
func TestOpenAIResponses_APIKeyPassthroughSSERateLimitUsesConfiguredPoolRetry(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
groupID := int64(4204)
|
||||
accounts := []service.Account{
|
||||
{
|
||||
ID: 9912, Name: "pool-sse-rate-limit", Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Priority: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-pool",
|
||||
"base_url": "https://api.example.test",
|
||||
"pool_mode": true,
|
||||
"pool_mode_retry_count": float64(1),
|
||||
"pool_mode_retry_status_codes": []any{float64(http.StatusTooManyRequests)},
|
||||
},
|
||||
Extra: map[string]any{"openai_passthrough": true},
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
cfg.Default.RateMultiplier = 1
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
cfg.Gateway.MaxAccountSwitches = 1
|
||||
|
||||
accountRepo := &openAIWSFailoverHandlerAccountRepoStub{accounts: accounts}
|
||||
upstream := &openAIHTTPPassthroughSSERateLimitUpstream{}
|
||||
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
t.Cleanup(billingCacheSvc.Stop)
|
||||
gatewaySvc := service.NewOpenAIGatewayService(
|
||||
accountRepo,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
cfg,
|
||||
nil,
|
||||
nil,
|
||||
service.NewBillingService(cfg, nil),
|
||||
nil,
|
||||
billingCacheSvc,
|
||||
upstream,
|
||||
&service.DeferredService{},
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
h := NewOpenAIGatewayHandler(
|
||||
gatewaySvc,
|
||||
service.NewConcurrencyService(nil),
|
||||
billingCacheSvc,
|
||||
service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg),
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
cfg,
|
||||
)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(`{"model":"gpt-5.6-sol","input":"hello","stream":true}`))
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
|
||||
ID: 1804, GroupID: &groupID,
|
||||
User: &service.User{ID: 1704, Status: service.StatusActive},
|
||||
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, Status: service.StatusActive},
|
||||
})
|
||||
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1704, Concurrency: 0})
|
||||
|
||||
h.Responses(c)
|
||||
|
||||
require.Equal(t, []int64{9912, 9912}, upstream.calls())
|
||||
require.Equal(t, http.StatusTooManyRequests, rec.Code)
|
||||
require.Equal(t, "1", rec.Header().Get("Retry-After"))
|
||||
require.Equal(t, "rate_limit_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
|
||||
require.Equal(t, "Upstream rate limit exceeded, please retry later", gjson.GetBytes(rec.Body.Bytes(), "error.message").String())
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesWebSocket_FailoverOnUpstreamUsageLimitEvent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
|
||||
@@ -148,6 +148,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
|
||||
maxAccountSwitches := h.maxAccountSwitches
|
||||
switchCount := 0
|
||||
profitVetoCount := 0
|
||||
failedAccountIDs := make(map[int64]struct{})
|
||||
sameAccountRetryCount := make(map[int64]int)
|
||||
var lastFailoverErr *service.UpstreamFailoverError
|
||||
@@ -221,8 +222,16 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
|
||||
reqLog.Debug("openai.images.account_selected", zap.Int64("account_id", account.ID), zap.String("account_name", account.Name))
|
||||
setOpsSelectedAccount(c, account.ID, account.Platform)
|
||||
|
||||
accountReleaseFunc, acquired := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, parsed.Stream, &streamStarted, reqLog)
|
||||
if !acquired {
|
||||
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, parsed.Stream, &streamStarted, reqLog)
|
||||
if slotResult == openAISlotAcquireProfitVetoed {
|
||||
// Images 调度不装利润门,此分支实际不可达;防御性排除重选并受同一否决上限约束。
|
||||
if !recordOpenAIProfitVeto(failedAccountIDs, account.ID, &profitVetoCount) {
|
||||
h.handleOpenAIProfitVetoExhausted(c, streamStarted, reqLog, profitVetoCount)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if slotResult != openAISlotAcquireOK {
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
//go:build unit
|
||||
|
||||
package handler
|
||||
|
||||
// 槽位终检与生图跳门回归(handler 半程):
|
||||
// - 槽位获取成功后的利润终检:越线账号释放槽位并要求调用方排除重选,
|
||||
// 不写响应、不绑定粘连;
|
||||
// - openAIResponsesRequiredCapability 的生图意图映射钉死(scheduler 的
|
||||
// 跳门条件依赖 CapabilityResponses ⇔ 显式生图意图这一耦合)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type profitCountingConcurrencyCache struct {
|
||||
fakeConcurrencyCache
|
||||
accountReleases atomic.Int64
|
||||
}
|
||||
|
||||
func (c *profitCountingConcurrencyCache) ReleaseAccountSlot(context.Context, int64, string) error {
|
||||
c.accountReleases.Add(1)
|
||||
return nil
|
||||
}
|
||||
|
||||
func profitSlotTestAccount(id int64, rate float64) *service.Account {
|
||||
now := time.Now()
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 2,
|
||||
RateMultiplier: &rate,
|
||||
Extra: map[string]any{
|
||||
"upstream_billing_probe": map[string]any{
|
||||
"status": service.UpstreamBillingProbeStatusOK,
|
||||
"received_at": now.Add(-time.Minute),
|
||||
"fresh_until": now.Add(30 * time.Minute),
|
||||
"data": map[string]any{
|
||||
"billing_scope": "token",
|
||||
"resolved_rate_multiplier": rate,
|
||||
"peak_rate_enabled": false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func profitSlotTestContext(t *testing.T, gw *service.OpenAIGatewayService, groupID int64, suppress bool) context.Context {
|
||||
t.Helper()
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Status: service.StatusActive,
|
||||
Hydrated: true,
|
||||
RateMultiplier: 1.0,
|
||||
SubscriptionType: service.SubscriptionTypeStandard,
|
||||
ProfitControlEnabled: true,
|
||||
ProfitMinMargin: 0.5,
|
||||
}
|
||||
base := context.WithValue(context.Background(), ctxkey.Group, group)
|
||||
if suppress {
|
||||
base = service.WithOpenAIProfitControlSuppressed(base)
|
||||
}
|
||||
ctx, pricingAt := gw.WithOpenAIRequestPricingContext(base, &groupID)
|
||||
require.False(t, pricingAt.IsZero())
|
||||
return ctx
|
||||
}
|
||||
|
||||
func TestAcquireResponsesAccountSlotProfitRecheck(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
gw := &service.OpenAIGatewayService{}
|
||||
groupID := int64(50)
|
||||
|
||||
newHandler := func(cache *profitCountingConcurrencyCache) *OpenAIGatewayHandler {
|
||||
return &OpenAIGatewayHandler{
|
||||
gatewayService: gw,
|
||||
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(cache), SSEPingFormatClaude, 0),
|
||||
}
|
||||
}
|
||||
newSelection := func(account *service.Account) *service.AccountSelectionResult {
|
||||
return &service.AccountSelectionResult{
|
||||
Account: account,
|
||||
Acquired: false,
|
||||
WaitPlan: &service.AccountWaitPlan{AccountID: account.ID, MaxConcurrency: 2, Timeout: time.Second, MaxWaiting: 2},
|
||||
}
|
||||
}
|
||||
|
||||
t.Run("veto releases slot and requests reschedule without writing response", func(t *testing.T) {
|
||||
cache := &profitCountingConcurrencyCache{}
|
||||
h := newHandler(cache)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, false))
|
||||
streamStarted := false
|
||||
|
||||
release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(1, 0.8)), false, &streamStarted, zap.NewNop())
|
||||
require.Equal(t, openAISlotAcquireProfitVetoed, result)
|
||||
require.Nil(t, release)
|
||||
require.Zero(t, w.Body.Len(), "利润终检否决不得写出任何响应")
|
||||
require.Equal(t, int64(1), cache.accountReleases.Load(), "否决后必须立即释放已获取的槽位")
|
||||
})
|
||||
|
||||
t.Run("qualifying account acquires normally", func(t *testing.T) {
|
||||
cache := &profitCountingConcurrencyCache{}
|
||||
h := newHandler(cache)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, false))
|
||||
streamStarted := false
|
||||
|
||||
release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(2, 0.3)), false, &streamStarted, zap.NewNop())
|
||||
require.Equal(t, openAISlotAcquireOK, result)
|
||||
require.NotNil(t, release)
|
||||
release()
|
||||
})
|
||||
|
||||
t.Run("image intent suppression keeps official behavior", func(t *testing.T) {
|
||||
cache := &profitCountingConcurrencyCache{}
|
||||
h := newHandler(cache)
|
||||
w := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(w)
|
||||
c.Request = httptest.NewRequest("POST", "/v1/responses", nil).WithContext(profitSlotTestContext(t, gw, groupID, true))
|
||||
streamStarted := false
|
||||
|
||||
release, result := h.acquireResponsesAccountSlot(c, &groupID, "", newSelection(profitSlotTestAccount(3, 0.8)), false, &streamStarted, zap.NewNop())
|
||||
require.Equal(t, openAISlotAcquireOK, result, "生图意图跳门:过贵账号照常获取(图片边界不装门)")
|
||||
require.NotNil(t, release)
|
||||
release()
|
||||
})
|
||||
}
|
||||
|
||||
// scheduler 跳门条件依赖"CapabilityResponses 仅在显式生图意图时被要求"这一
|
||||
// 映射;后续若扩展该 capability 的用途,本测试失败提示同步收窄跳门条件。
|
||||
func TestOpenAIResponsesRequiredCapabilityPinsImageIntentMapping(t *testing.T) {
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityResponses, openAIResponsesRequiredCapability(true, service.PlatformOpenAI))
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapability(false, service.PlatformOpenAI))
|
||||
require.Equal(t, service.OpenAIEndpointCapabilityChatCompletions, openAIResponsesRequiredCapability(true, service.PlatformGrok))
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestRecordOpenAIProfitVetoBounded 钉死 OpenAI 侧选号循环的利润否决预算:
|
||||
// 账号被加入排除集,且第 maxProfitVetoAttempts 次否决返回 false 要求终止。
|
||||
//
|
||||
// 上限是必需的:acquireResponsesAccountSlot 的 WaitPlan 分支先阻塞排队
|
||||
// (sticky 45s / fallback 30s)拿到槽位才做利润终检,无上限重选会把单次请求
|
||||
// 的延迟放大到 N × WaitPlan.Timeout。
|
||||
func TestRecordOpenAIProfitVetoBounded(t *testing.T) {
|
||||
failed := make(map[int64]struct{})
|
||||
count := 0
|
||||
|
||||
for i := int64(1); i < int64(maxProfitVetoAttempts); i++ {
|
||||
require.True(t, recordOpenAIProfitVeto(failed, i, &count), "第 %d 次否决应允许继续重选", i)
|
||||
require.Contains(t, failed, i, "否决的账号必须进入本请求排除集")
|
||||
}
|
||||
|
||||
require.False(t,
|
||||
recordOpenAIProfitVeto(failed, int64(maxProfitVetoAttempts), &count),
|
||||
"第 %d 次否决应耗尽预算并要求终止", maxProfitVetoAttempts)
|
||||
require.Equal(t, maxProfitVetoAttempts, count)
|
||||
require.Len(t, failed, maxProfitVetoAttempts)
|
||||
}
|
||||
|
||||
// TestOpenAIProfitVetoLoopTerminates 模拟「选号 → 抢槽 → 利润终检否决 → 重选」
|
||||
// 循环:整池越线时必须在常数步内终止,而不是把候选池逐个排队一遍。
|
||||
func TestOpenAIProfitVetoLoopTerminates(t *testing.T) {
|
||||
failed := make(map[int64]struct{})
|
||||
count := 0
|
||||
terminated := false
|
||||
iterations := 0
|
||||
|
||||
// 候选池远大于否决上限:无上限时会对每个账号各排队一次。
|
||||
for accountID := int64(1); accountID <= 500; accountID++ {
|
||||
iterations++
|
||||
if _, excluded := failed[accountID]; excluded {
|
||||
continue
|
||||
}
|
||||
if !recordOpenAIProfitVeto(failed, accountID, &count) {
|
||||
terminated = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
require.True(t, terminated, "整池越线时循环必须由否决预算终止")
|
||||
require.Equal(t, maxProfitVetoAttempts, iterations)
|
||||
}
|
||||
|
||||
// TestProfitVetoBudgetSharedWithFailoverState 钉死两条路径(FailoverState 与
|
||||
// OpenAI 独立计数器)使用同一上限语义,避免日后单边漂移。
|
||||
func TestProfitVetoBudgetSharedWithFailoverState(t *testing.T) {
|
||||
fs := NewFailoverState(10, false)
|
||||
failed := make(map[int64]struct{})
|
||||
count := 0
|
||||
|
||||
var fsStoppedAt, openAIStoppedAt int
|
||||
for i := int64(1); i <= int64(maxProfitVetoAttempts)+5; i++ {
|
||||
if fsStoppedAt == 0 && fs.RecordProfitVeto(i) == FailoverExhausted {
|
||||
fsStoppedAt = fs.ProfitVetoCount()
|
||||
}
|
||||
if openAIStoppedAt == 0 && !recordOpenAIProfitVeto(failed, i, &count) {
|
||||
openAIStoppedAt = count
|
||||
}
|
||||
}
|
||||
|
||||
require.Equal(t, maxProfitVetoAttempts, fsStoppedAt)
|
||||
require.Equal(t, fsStoppedAt, openAIStoppedAt, "两条路径的利润否决上限必须一致")
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestOpenAIWSTurnPricingZeroValue 钉死 WS turn 定价的零值语义:
|
||||
// 没有 turn 起始回调的 ingress 模式(ws_v2 passthrough 只实现 AfterTurn)
|
||||
// 必须让 pricingAt 保持零,由 RecordUsage 回退到记录时刻。
|
||||
//
|
||||
// 反例(本 PR 引入的回归):用建连时刻初始化,会把透传连接的所有 turn 钉死在
|
||||
// 建连时的高峰因子——客户端峰前一分钟建连并保活,整条连接就按谷价结算。
|
||||
func TestOpenAIWSTurnPricingZeroValue(t *testing.T) {
|
||||
var p openAIWSTurnPricing
|
||||
require.True(t, p.current().IsZero(),
|
||||
"未经 turn 起始回调冻结时必须保持零值,交由 RecordUsage 回退记录时刻")
|
||||
}
|
||||
|
||||
// TestOpenAIWSTurnPricingFreezePerTurn 钉死每个 turn 的 BeforeTurn 都会覆盖
|
||||
// 上一个 turn 的定价时刻:长连接跨峰谷时后续 turn 不得沿用旧时刻。
|
||||
func TestOpenAIWSTurnPricingFreezePerTurn(t *testing.T) {
|
||||
var p openAIWSTurnPricing
|
||||
turn1 := time.Now().Add(-time.Hour)
|
||||
turn2 := time.Now()
|
||||
|
||||
p.freeze(turn1)
|
||||
require.Equal(t, turn1, p.current())
|
||||
|
||||
p.freeze(turn2)
|
||||
require.Equal(t, turn2, p.current(), "后续 turn 必须使用自己的定价时刻")
|
||||
}
|
||||
@@ -67,6 +67,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
CompactHomeEnabled: settings.CompactHomeEnabled,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 本文件覆盖:worker 池已停止(进程关停窗口)时,计费任务不得静默丢失,
|
||||
// 必须降级为内联同步执行;显式配置的 drop/sample 溢出丢弃仍按配置语义保留。
|
||||
|
||||
func newStoppedUsageRecordPoolForTest() *service.UsageRecordWorkerPool {
|
||||
pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{
|
||||
WorkerCount: 1,
|
||||
QueueSize: 1,
|
||||
TaskTimeout: time.Second,
|
||||
OverflowPolicy: config.UsageRecordOverflowPolicySync,
|
||||
})
|
||||
pool.Stop()
|
||||
return pool
|
||||
}
|
||||
|
||||
func TestGatewayHandlerSubmitUsageRecordTask_StoppedPoolFallsBackToSync(t *testing.T) {
|
||||
h := &GatewayHandler{usageRecordWorkerPool: newStoppedUsageRecordPoolForTest()}
|
||||
|
||||
executed := false
|
||||
h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
|
||||
executed = true
|
||||
})
|
||||
require.True(t, executed, "池已停止时计费任务必须内联同步执行")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayHandlerSubmitUsageRecordTask_StoppedPoolFallsBackToSync(t *testing.T) {
|
||||
h := &OpenAIGatewayHandler{usageRecordWorkerPool: newStoppedUsageRecordPoolForTest()}
|
||||
|
||||
executed := false
|
||||
h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
|
||||
executed = true
|
||||
})
|
||||
require.True(t, executed, "池已停止时计费任务必须内联同步执行")
|
||||
}
|
||||
|
||||
func TestGatewayHandlerSubmitUsageRecordTask_DropPolicyOverflowStillDrops(t *testing.T) {
|
||||
pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{
|
||||
WorkerCount: 1,
|
||||
QueueSize: 1,
|
||||
TaskTimeout: time.Minute,
|
||||
OverflowPolicy: config.UsageRecordOverflowPolicyDrop,
|
||||
})
|
||||
t.Cleanup(pool.Stop)
|
||||
h := &GatewayHandler{usageRecordWorkerPool: pool}
|
||||
|
||||
started := make(chan struct{})
|
||||
block := make(chan struct{})
|
||||
t.Cleanup(func() { close(block) })
|
||||
// 占满 worker 槽位后再填满队列,保证第三个任务触发溢出。
|
||||
require.Equal(t, service.UsageRecordSubmitModeEnqueued, pool.Submit(func(ctx context.Context) {
|
||||
close(started)
|
||||
<-block
|
||||
}))
|
||||
<-started
|
||||
require.Equal(t, service.UsageRecordSubmitModeEnqueued, pool.Submit(func(ctx context.Context) {
|
||||
<-block
|
||||
}))
|
||||
|
||||
var executed atomic.Bool
|
||||
h.submitUsageRecordTask(context.Background(), func(ctx context.Context) {
|
||||
executed.Store(true)
|
||||
})
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
require.False(t, executed.Load(), "drop 溢出策略是运维显式配置的取舍,不应被同步兜底覆盖")
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
@@ -9,6 +10,13 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
toolOutputMediaMarker = "[Tool output media moved to the following user message]"
|
||||
toolOutputMediaAttribution = "[Tool output media for call %s]"
|
||||
)
|
||||
|
||||
type toolOutputMediaByCallID map[string][]ChatContentPart
|
||||
|
||||
// ResponsesToChatCompletionsRequest converts a Responses API request into a
|
||||
// Chat Completions request for upstreams that only implement
|
||||
// /v1/chat/completions.
|
||||
@@ -215,16 +223,16 @@ func responsesInputToChatMessages(instructions string, inputRaw json.RawMessage)
|
||||
return nil, fmt.Errorf("parse responses input: %w", err)
|
||||
}
|
||||
|
||||
built, err := buildChatMessagesFromItems(messages, rawItems)
|
||||
built, mediaByCallID, err := buildChatMessagesFromItems(messages, rawItems)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return normalizeChatMessages(built), nil
|
||||
return normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID), nil
|
||||
}
|
||||
|
||||
// buildChatMessagesFromItems walks the Responses input items and appends the
|
||||
// corresponding Chat messages.
|
||||
func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage) ([]ChatMessage, error) {
|
||||
func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessage) ([]ChatMessage, toolOutputMediaByCallID, error) {
|
||||
// pendingReasoning holds the reasoning text from a reasoning item until the
|
||||
// assistant message it belongs to is emitted. DeepSeek's thinking mode
|
||||
// requires the reasoning_content that produced a tool call to be passed back
|
||||
@@ -232,6 +240,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
// across an assistant message (so a following tool call in the same turn
|
||||
// still receives it); any other role ends the thinking span.
|
||||
var pendingReasoning string
|
||||
mediaByCallID := make(toolOutputMediaByCallID)
|
||||
|
||||
for _, raw := range rawItems {
|
||||
raw = bytesTrimSpace(raw)
|
||||
@@ -248,7 +257,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
pendingReasoning = ""
|
||||
continue
|
||||
}
|
||||
return nil, fmt.Errorf("parse responses input item: %w", err)
|
||||
return nil, nil, fmt.Errorf("parse responses input item: %w", err)
|
||||
}
|
||||
|
||||
role := chatCompletionsBridgeRole(rawString(item["role"]))
|
||||
@@ -320,15 +329,25 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
continue
|
||||
case "function_call_output", "custom_tool_call_output", "tool_search_output":
|
||||
outputRaw := bytesTrimSpace(item["output"])
|
||||
outputText := rawString(outputRaw)
|
||||
if outputText == "" && len(outputRaw) > 0 && string(outputRaw) != "null" && string(outputRaw) != `""` {
|
||||
// 对象/数组形式的输出(如 tool_search 的结果列表)整体字符串化。
|
||||
outputText = string(outputRaw)
|
||||
callID := rawString(item["call_id"])
|
||||
delete(mediaByCallID, callID)
|
||||
|
||||
outputText, media, rewritten := extractToolOutputMedia(outputRaw)
|
||||
if rewritten {
|
||||
if callID != "" {
|
||||
mediaByCallID[callID] = media
|
||||
}
|
||||
} else {
|
||||
outputText = rawString(outputRaw)
|
||||
if outputText == "" && len(outputRaw) > 0 && string(outputRaw) != "null" && string(outputRaw) != `""` {
|
||||
// 对象/数组形式的输出(如 tool_search 的结果列表)整体字符串化。
|
||||
outputText = string(outputRaw)
|
||||
}
|
||||
}
|
||||
content, _ := json.Marshal(outputText)
|
||||
messages = append(messages, ChatMessage{
|
||||
Role: "tool",
|
||||
ToolCallID: rawString(item["call_id"]),
|
||||
ToolCallID: callID,
|
||||
Content: content,
|
||||
})
|
||||
pendingReasoning = ""
|
||||
@@ -341,7 +360,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
case "input_image":
|
||||
content, err := chatContentFromSingleResponsesPart(itemType, item)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
messages = append(messages, ChatMessage{Role: "user", Content: content})
|
||||
pendingReasoning = ""
|
||||
@@ -367,7 +386,7 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
}
|
||||
chatContent, err := responsesContentToChatContent(content, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, nil, err
|
||||
}
|
||||
messages = append(messages, ChatMessage{Role: role, Content: chatContent})
|
||||
// Reasoning only survives across an assistant text message.
|
||||
@@ -376,7 +395,141 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
|
||||
}
|
||||
}
|
||||
|
||||
return messages, nil
|
||||
return messages, mediaByCallID, nil
|
||||
}
|
||||
|
||||
// extractToolOutputMedia rewrites only recognized image nodes. Media-free
|
||||
// outputs return rewritten=false so the caller can preserve their original
|
||||
// bytes and prompt-cache prefix.
|
||||
func extractToolOutputMedia(outputRaw json.RawMessage) (string, []ChatContentPart, bool) {
|
||||
outputRaw = bytesTrimSpace(outputRaw)
|
||||
if len(outputRaw) == 0 || string(outputRaw) == "null" {
|
||||
return "", nil, false
|
||||
}
|
||||
|
||||
var outputString string
|
||||
if err := json.Unmarshal(outputRaw, &outputString); err == nil {
|
||||
if isToolOutputImageDataURL(outputString) {
|
||||
return toolOutputMediaMarker, []ChatContentPart{toolOutputImagePart(outputString)}, true
|
||||
}
|
||||
|
||||
nested, ok := decodeToolOutputJSON([]byte(outputString))
|
||||
if !ok {
|
||||
return "", nil, false
|
||||
}
|
||||
rewritten, media, changed := rewriteToolOutputMediaValue(nested)
|
||||
if !changed {
|
||||
return "", nil, false
|
||||
}
|
||||
encoded, err := json.Marshal(rewritten)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
return string(encoded), media, true
|
||||
}
|
||||
|
||||
value, ok := decodeToolOutputJSON(outputRaw)
|
||||
if !ok {
|
||||
return "", nil, false
|
||||
}
|
||||
rewritten, media, changed := rewriteToolOutputMediaValue(value)
|
||||
if !changed {
|
||||
return "", nil, false
|
||||
}
|
||||
encoded, err := json.Marshal(rewritten)
|
||||
if err != nil {
|
||||
return "", nil, false
|
||||
}
|
||||
return string(encoded), media, true
|
||||
}
|
||||
|
||||
func decodeToolOutputJSON(raw []byte) (any, bool) {
|
||||
if !json.Valid(raw) {
|
||||
return nil, false
|
||||
}
|
||||
decoder := json.NewDecoder(bytes.NewReader(raw))
|
||||
decoder.UseNumber()
|
||||
var value any
|
||||
if err := decoder.Decode(&value); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
return value, true
|
||||
}
|
||||
|
||||
func rewriteToolOutputMediaValue(value any) (any, []ChatContentPart, bool) {
|
||||
switch typed := value.(type) {
|
||||
case []any:
|
||||
var media []ChatContentPart
|
||||
changed := false
|
||||
for i, item := range typed {
|
||||
rewritten, itemMedia, itemChanged := rewriteToolOutputMediaValue(item)
|
||||
if !itemChanged {
|
||||
continue
|
||||
}
|
||||
typed[i] = rewritten
|
||||
media = append(media, itemMedia...)
|
||||
changed = true
|
||||
}
|
||||
return typed, media, changed
|
||||
case map[string]any:
|
||||
if imageURL, ok := recognizedToolOutputImageURL(typed); ok {
|
||||
return map[string]any{
|
||||
"type": "input_text",
|
||||
"text": toolOutputMediaMarker,
|
||||
}, []ChatContentPart{toolOutputImagePart(imageURL)}, true
|
||||
}
|
||||
|
||||
content, ok := typed["content"]
|
||||
if !ok {
|
||||
return typed, nil, false
|
||||
}
|
||||
rewritten, media, changed := rewriteToolOutputMediaValue(content)
|
||||
if !changed {
|
||||
return typed, nil, false
|
||||
}
|
||||
typed["content"] = rewritten
|
||||
return typed, media, true
|
||||
default:
|
||||
return value, nil, false
|
||||
}
|
||||
}
|
||||
|
||||
func recognizedToolOutputImageURL(value map[string]any) (string, bool) {
|
||||
partType, _ := value["type"].(string)
|
||||
if partType != "input_image" && partType != "image_url" {
|
||||
return "", false
|
||||
}
|
||||
|
||||
switch imageURL := value["image_url"].(type) {
|
||||
case string:
|
||||
return imageURL, strings.TrimSpace(imageURL) != ""
|
||||
case map[string]any:
|
||||
url, _ := imageURL["url"].(string)
|
||||
return url, strings.TrimSpace(url) != ""
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func isToolOutputImageDataURL(value string) bool {
|
||||
const prefix = "data:image/"
|
||||
const separator = ";base64,"
|
||||
if !strings.HasPrefix(value, prefix) {
|
||||
return false
|
||||
}
|
||||
separatorIndex := strings.Index(value[len(prefix):], separator)
|
||||
if separatorIndex <= 0 {
|
||||
return false
|
||||
}
|
||||
payloadIndex := len(prefix) + separatorIndex + len(separator)
|
||||
return payloadIndex < len(value)
|
||||
}
|
||||
|
||||
func toolOutputImagePart(imageURL string) ChatContentPart {
|
||||
return ChatContentPart{
|
||||
Type: "image_url",
|
||||
ImageURL: &ChatImageURL{URL: imageURL},
|
||||
}
|
||||
}
|
||||
|
||||
// appendAssistantToolCall merges a tool call into the chat message list.
|
||||
@@ -417,6 +570,10 @@ func appendAssistantToolCall(messages []ChatMessage, toolCall ChatToolCall, pend
|
||||
// tool replies and intervening messages are emitted in their natural position
|
||||
// but never between an assistant tool_calls message and its replies.
|
||||
func normalizeChatMessages(messages []ChatMessage) []ChatMessage {
|
||||
return normalizeChatMessagesWithToolOutputMedia(messages, nil)
|
||||
}
|
||||
|
||||
func normalizeChatMessagesWithToolOutputMedia(messages []ChatMessage, mediaByCallID toolOutputMediaByCallID) []ChatMessage {
|
||||
// Index every tool reply by its tool_call_id (last wins on duplicates).
|
||||
replies := make(map[string]ChatMessage)
|
||||
for _, m := range messages {
|
||||
@@ -463,6 +620,23 @@ func normalizeChatMessages(messages []ChatMessage) []ChatMessage {
|
||||
for _, tc := range kept {
|
||||
out = append(out, replies[tc.ID])
|
||||
}
|
||||
|
||||
var mediaParts []ChatContentPart
|
||||
for _, tc := range kept {
|
||||
media := mediaByCallID[tc.ID]
|
||||
if len(media) == 0 {
|
||||
continue
|
||||
}
|
||||
mediaParts = append(mediaParts, ChatContentPart{
|
||||
Type: "text",
|
||||
Text: fmt.Sprintf(toolOutputMediaAttribution, tc.ID),
|
||||
})
|
||||
mediaParts = append(mediaParts, media...)
|
||||
}
|
||||
if len(mediaParts) > 0 {
|
||||
content, _ := json.Marshal(mediaParts)
|
||||
out = append(out, ChatMessage{Role: "user", Content: content})
|
||||
}
|
||||
default:
|
||||
out = append(out, m)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
package apicompat
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testToolImageDataURL = "data:image/png;base64,AQID"
|
||||
testToolImageRemote = "https://example.com/tool-output.png"
|
||||
)
|
||||
|
||||
func TestResponsesToolOutputMedia_ExtractsSupportedShapes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
call string
|
||||
outputType string
|
||||
output string
|
||||
imageURL string
|
||||
toolText string
|
||||
}{
|
||||
{
|
||||
name: "image-only array",
|
||||
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
|
||||
outputType: "function_call_output",
|
||||
output: `[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]`,
|
||||
imageURL: testToolImageDataURL,
|
||||
},
|
||||
{
|
||||
name: "text and nested image URL",
|
||||
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
|
||||
outputType: "function_call_output",
|
||||
output: `[{"type":"input_text","text":"render complete"},{"type":"image_url","image_url":{"url":"https://example.com/tool-output.png"}}]`,
|
||||
imageURL: testToolImageRemote,
|
||||
toolText: "render complete",
|
||||
},
|
||||
{
|
||||
name: "top-level image object",
|
||||
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
|
||||
outputType: "function_call_output",
|
||||
output: `{"type":"input_image","image_url":"data:image/png;base64,AQID"}`,
|
||||
imageURL: testToolImageDataURL,
|
||||
},
|
||||
{
|
||||
name: "content wrapper",
|
||||
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
|
||||
outputType: "function_call_output",
|
||||
output: `{"status":"ok","content":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}],"unknown":{"score":0.9,"large":9007199254740993}}`,
|
||||
imageURL: testToolImageDataURL,
|
||||
toolText: `"large":9007199254740993`,
|
||||
},
|
||||
{
|
||||
name: "JSON string output",
|
||||
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
|
||||
outputType: "function_call_output",
|
||||
output: `"[{\"type\":\"input_image\",\"image_url\":\"data:image/png;base64,AQID\"}]"`,
|
||||
imageURL: testToolImageDataURL,
|
||||
},
|
||||
{
|
||||
name: "bare data URL",
|
||||
call: `{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"}`,
|
||||
outputType: "function_call_output",
|
||||
output: `"data:image/png;base64,AQID"`,
|
||||
imageURL: testToolImageDataURL,
|
||||
},
|
||||
{
|
||||
name: "custom tool output",
|
||||
call: `{"type":"custom_tool_call","call_id":"call_image","name":"view_image","input":"{}"}`,
|
||||
outputType: "custom_tool_call_output",
|
||||
output: `[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]`,
|
||||
imageURL: testToolImageDataURL,
|
||||
},
|
||||
{
|
||||
name: "tool search output",
|
||||
call: `{"type":"tool_search_call","call_id":"call_image","arguments":{"query":"image"}}`,
|
||||
outputType: "tool_search_output",
|
||||
output: `[{"type":"image_url","image_url":{"url":"https://example.com/tool-output.png"}}]`,
|
||||
imageURL: testToolImageRemote,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := fmt.Sprintf(`[%s,{"type":%q,"call_id":"call_image","output":%s}]`, tt.call, tt.outputType, tt.output)
|
||||
messages := convertToolOutputMedia(t, input)
|
||||
|
||||
require.Len(t, messages, 3)
|
||||
require.Equal(t, []string{"assistant", "tool", "user"}, chatMessageRoles(messages))
|
||||
require.Equal(t, "call_image", messages[1].ToolCallID)
|
||||
|
||||
toolText := chatToolContentString(t, messages[1])
|
||||
require.Contains(t, toolText, "[Tool output media moved to the following user message]")
|
||||
require.NotContains(t, toolText, tt.imageURL)
|
||||
if tt.toolText != "" {
|
||||
require.Contains(t, toolText, tt.toolText)
|
||||
}
|
||||
|
||||
parts := chatContentParts(t, messages[2])
|
||||
require.Len(t, parts, 2)
|
||||
require.Equal(t, "text", parts[0].Type)
|
||||
require.Equal(t, "[Tool output media for call call_image]", parts[0].Text)
|
||||
require.Equal(t, "image_url", parts[1].Type)
|
||||
require.NotNil(t, parts[1].ImageURL)
|
||||
require.Equal(t, tt.imageURL, parts[1].ImageURL.URL)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToolOutputMedia_ParallelBatchUsesCallOrder(t *testing.T) {
|
||||
messages := convertToolOutputMedia(t, `[
|
||||
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_B","output":[{"type":"input_image","image_url":{"url":"https://example.com/b.png"}}]},
|
||||
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":{"url":"https://example.com/a.png"}}]}
|
||||
]`)
|
||||
|
||||
require.Len(t, messages, 4)
|
||||
require.Equal(t, []string{"assistant", "tool", "tool", "user"}, chatMessageRoles(messages))
|
||||
require.Equal(t, []string{"call_A", "call_B"}, []string{messages[1].ToolCallID, messages[2].ToolCallID})
|
||||
|
||||
parts := chatContentParts(t, messages[3])
|
||||
require.Len(t, parts, 4)
|
||||
require.Equal(t, "[Tool output media for call call_A]", parts[0].Text)
|
||||
require.Equal(t, "https://example.com/a.png", parts[1].ImageURL.URL)
|
||||
require.Equal(t, "[Tool output media for call call_B]", parts[2].Text)
|
||||
require.Equal(t, "https://example.com/b.png", parts[3].ImageURL.URL)
|
||||
}
|
||||
|
||||
func TestResponsesToolOutputMedia_PreservesRichSiblingWhenRewriting(t *testing.T) {
|
||||
messages := convertToolOutputMedia(t, `[
|
||||
{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_image","output":[
|
||||
{"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}},
|
||||
{"type":"input_image","image_url":"data:image/png;base64,AQID"}
|
||||
]}
|
||||
]`)
|
||||
|
||||
toolText := chatToolContentString(t, messages[1])
|
||||
require.JSONEq(t, `[
|
||||
{"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}},
|
||||
{"type":"input_text","text":"[Tool output media moved to the following user message]"}
|
||||
]`, toolText)
|
||||
}
|
||||
|
||||
func TestResponsesToolOutputMedia_InterleavedMessagesFollowMediaBatch(t *testing.T) {
|
||||
messages := convertToolOutputMedia(t, `[
|
||||
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
|
||||
{"type":"message","role":"developer","content":[{"type":"input_text","text":"approval saved"}]},
|
||||
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]},
|
||||
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
|
||||
]`)
|
||||
|
||||
require.Equal(t, []string{"assistant", "tool", "user", "system", "user"}, chatMessageRoles(messages))
|
||||
require.Equal(t, "[Tool output media for call call_A]", chatContentParts(t, messages[2])[0].Text)
|
||||
require.JSONEq(t, `"approval saved"`, string(messages[3].Content))
|
||||
require.JSONEq(t, `"continue"`, string(messages[4].Content))
|
||||
}
|
||||
|
||||
func TestResponsesToolOutputMedia_DropsOrphanAndUnansweredCallMedia(t *testing.T) {
|
||||
t.Run("orphan", func(t *testing.T) {
|
||||
messages := convertToolOutputMedia(t, `[
|
||||
{"type":"function_call_output","call_id":"call_ghost","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
|
||||
]`)
|
||||
require.Empty(t, messages)
|
||||
})
|
||||
|
||||
t.Run("unanswered parallel call", func(t *testing.T) {
|
||||
messages := convertToolOutputMedia(t, `[
|
||||
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
|
||||
]`)
|
||||
|
||||
require.Len(t, messages, 3)
|
||||
require.Len(t, messages[0].ToolCalls, 1)
|
||||
require.Equal(t, "call_A", messages[0].ToolCalls[0].ID)
|
||||
parts := chatContentParts(t, messages[2])
|
||||
require.Len(t, parts, 2)
|
||||
require.NotContains(t, string(messages[2].Content), "call_B")
|
||||
})
|
||||
}
|
||||
|
||||
func TestResponsesToolOutputMedia_PreservesMediaFreeOutputBytes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
output string
|
||||
}{
|
||||
{
|
||||
name: "rich unknown object",
|
||||
output: `{"type":"result","url":"https://example.com/result","score":0.9,"text":"complete","extra":{"count":2}}`,
|
||||
},
|
||||
{
|
||||
name: "no-image array",
|
||||
output: `[ { "type": "input_text", "text": "ok" }, {"unknown":true} ]`,
|
||||
},
|
||||
{
|
||||
name: "plain string",
|
||||
output: `"plain output"`,
|
||||
},
|
||||
{
|
||||
name: "JSON string without image",
|
||||
output: `"{ \"ok\": true }"`,
|
||||
},
|
||||
{
|
||||
name: "embedded data URL text",
|
||||
output: `"prefix data:image/png;base64,AQID suffix"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := fmt.Sprintf(`[
|
||||
{"type":"function_call","call_id":"call_text","name":"exec","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_text","output":%s}
|
||||
]`, tt.output)
|
||||
messages := convertToolOutputMedia(t, input)
|
||||
require.Len(t, messages, 2)
|
||||
|
||||
var expected string
|
||||
if err := json.Unmarshal([]byte(tt.output), &expected); err != nil {
|
||||
expected = tt.output
|
||||
}
|
||||
expectedContent, err := json.Marshal(expected)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, string(expectedContent), string(messages[1].Content))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResponsesToolOutputMedia_DuplicateCallIDIsLastWins(t *testing.T) {
|
||||
t.Run("later media replaces earlier media", func(t *testing.T) {
|
||||
messages := convertToolOutputMedia(t, `[
|
||||
{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":{"url":"https://example.com/first.png"}}]},
|
||||
{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":{"url":"https://example.com/last.png"}}]}
|
||||
]`)
|
||||
|
||||
require.Len(t, messages, 3)
|
||||
require.NotContains(t, string(messages[1].Content), "first.png")
|
||||
require.NotContains(t, string(messages[2].Content), "first.png")
|
||||
require.Contains(t, string(messages[2].Content), "last.png")
|
||||
})
|
||||
|
||||
t.Run("later text clears earlier media", func(t *testing.T) {
|
||||
messages := convertToolOutputMedia(t, `[
|
||||
{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
|
||||
{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]},
|
||||
{"type":"function_call_output","call_id":"call_image","output":"latest text"}
|
||||
]`)
|
||||
|
||||
require.Len(t, messages, 2)
|
||||
require.Equal(t, "latest text", chatToolContentString(t, messages[1]))
|
||||
})
|
||||
}
|
||||
|
||||
func TestResponsesToChatCompletionsRequest_ToolContentNeverContainsExtractedMedia(t *testing.T) {
|
||||
req := &ResponsesRequest{
|
||||
Model: "vision-model",
|
||||
Input: json.RawMessage(`[
|
||||
{"type":"function_call","call_id":"call_function","name":"view_image","arguments":"{}"},
|
||||
{"type":"custom_tool_call","call_id":"call_custom","name":"custom_image","input":"{}"},
|
||||
{"type":"tool_search_call","call_id":"call_search","arguments":{"query":"image"}},
|
||||
{"type":"function_call_output","call_id":"call_function","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]},
|
||||
{"type":"custom_tool_call_output","call_id":"call_custom","output":{"content":[{"type":"image_url","image_url":{"url":"https://example.com/custom.png"}}]}},
|
||||
{"type":"tool_search_output","call_id":"call_search","output":"data:image/jpeg;base64,BAUG"}
|
||||
]`),
|
||||
}
|
||||
|
||||
out, err := ResponsesToChatCompletionsRequest(req)
|
||||
require.NoError(t, err)
|
||||
assertChatInvariants(t, out.Messages)
|
||||
|
||||
var toolCount int
|
||||
for _, message := range out.Messages {
|
||||
if message.Role != "tool" {
|
||||
continue
|
||||
}
|
||||
toolCount++
|
||||
content := string(message.Content)
|
||||
require.NotContains(t, content, "data:image/")
|
||||
require.NotContains(t, content, "https://example.com/custom.png")
|
||||
}
|
||||
require.Equal(t, 3, toolCount)
|
||||
require.Equal(t, []string{"assistant", "tool", "tool", "tool", "user"}, chatMessageRoles(out.Messages))
|
||||
}
|
||||
|
||||
func convertToolOutputMedia(t *testing.T, input string) []ChatMessage {
|
||||
t.Helper()
|
||||
messages, err := responsesInputToChatMessages("", json.RawMessage(input))
|
||||
require.NoError(t, err)
|
||||
assertChatInvariants(t, messages)
|
||||
return messages
|
||||
}
|
||||
|
||||
func chatToolContentString(t *testing.T, message ChatMessage) string {
|
||||
t.Helper()
|
||||
require.Equal(t, "tool", message.Role)
|
||||
var content string
|
||||
require.NoError(t, json.Unmarshal(message.Content, &content))
|
||||
return content
|
||||
}
|
||||
|
||||
func chatContentParts(t *testing.T, message ChatMessage) []ChatContentPart {
|
||||
t.Helper()
|
||||
var parts []ChatContentPart
|
||||
require.NoError(t, json.Unmarshal(message.Content, &parts))
|
||||
for _, part := range parts {
|
||||
if part.Type == "image_url" {
|
||||
require.NotNil(t, part.ImageURL)
|
||||
require.False(t, strings.TrimSpace(part.ImageURL.URL) == "")
|
||||
}
|
||||
}
|
||||
return parts
|
||||
}
|
||||
@@ -255,6 +255,52 @@ func canonicalizeCodexOriginator(name string) string {
|
||||
return name
|
||||
}
|
||||
|
||||
// CodexCLIOriginator 官方 Codex CLI 默认 originator(codex-rs DEFAULT_ORIGINATOR),
|
||||
// 也是身份归一化的目标身份。
|
||||
const CodexCLIOriginator = "codex_cli_rs"
|
||||
|
||||
// codexLoadShedOriginators:上游 /backend-api/codex 按 originator 分桶调度容量,命中降载桶的
|
||||
// 请求即使 HTTP 200 也会立刻推 SSE `event: error`(code=server_is_overloaded)并以
|
||||
// response.failed 收尾。2026-07-29 起 codex-tui 被观测到落入降载桶:同账号、同请求体、同 UA,
|
||||
// 仅把 originator 换成 codex_cli_rs 即恢复正常(换言之 UA 不是判定因子,originator 才是)。
|
||||
// 网关会把该错误判定为瞬时上游故障并让账号进入冷却,对外表现为「账号过载不可用」,
|
||||
// 因此出站前需要把命中的身份改写为 CLI 身份。
|
||||
//
|
||||
// 该集合是上游容量策略的快照而非协议常量,上游调整分桶后需同步修订。
|
||||
var codexLoadShedOriginators = map[string]bool{
|
||||
"codex-tui": true,
|
||||
}
|
||||
|
||||
// IsCodexLoadShedOriginator 判断 originator 是否落在上游降载桶。
|
||||
func IsCodexLoadShedOriginator(originator string) bool {
|
||||
return codexLoadShedOriginators[normalizeCodexClientHeader(originator)]
|
||||
}
|
||||
|
||||
// NormalizeCodexClientIdentityToCLI 把落在降载桶的官方身份改写为 Codex CLI 身份:
|
||||
// UA 首段替换为 codex_cli_rs,并裁掉尾部 `(name; version)` 客户端标识组(真实 CLI UA 无该组),
|
||||
// 版本 / OS / 架构 / 终端指纹原样保留。返回配套的 originator 与 UA,未命中降载桶时 changed=false。
|
||||
//
|
||||
// 入参应为 PairCodexClientIdentity 输出的已配对身份;改写后 UA 首段与 originator 仍然配套,
|
||||
// 不破坏上游的配对校验。
|
||||
func NormalizeCodexClientIdentityToCLI(originator, userAgent string) (string, string, bool) {
|
||||
if !IsCodexLoadShedOriginator(originator) {
|
||||
return originator, userAgent, false
|
||||
}
|
||||
ua := strings.TrimSpace(userAgent)
|
||||
slash := strings.IndexByte(ua, '/')
|
||||
if slash <= 0 {
|
||||
return CodexCLIOriginator, ua, true
|
||||
}
|
||||
rest := ua[slash:]
|
||||
// 仅当尾部括号组确为官方客户端标识时才裁剪,避免误截合法 UA 尾巴(如 `(Ubuntu 22.4.0; x86_64)`)。
|
||||
if trailer := codexUATrailerName(ua); trailer != "" && IsCodexOfficialClientOriginator(trailer) {
|
||||
if open := strings.LastIndex(rest, "("); open > 0 {
|
||||
rest = strings.TrimRight(rest[:open], " ")
|
||||
}
|
||||
}
|
||||
return CodexCLIOriginator, CodexCLIOriginator + rest, true
|
||||
}
|
||||
|
||||
// codexEngineVersionPattern 提取版本段开头的三段数字 X.Y.Z(忽略 -alpha 等后缀)。
|
||||
var codexEngineVersionPattern = regexp.MustCompile(`^(\d+\.\d+\.\d+)`)
|
||||
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
package openai
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestIsCodexLoadShedOriginator(t *testing.T) {
|
||||
require.True(t, IsCodexLoadShedOriginator("codex-tui"))
|
||||
require.True(t, IsCodexLoadShedOriginator(" CODEX-TUI "))
|
||||
require.False(t, IsCodexLoadShedOriginator("codex_cli_rs"))
|
||||
require.False(t, IsCodexLoadShedOriginator("codex_vscode"))
|
||||
require.False(t, IsCodexLoadShedOriginator("Codex Desktop"))
|
||||
require.False(t, IsCodexLoadShedOriginator(""))
|
||||
}
|
||||
|
||||
func TestNormalizeCodexClientIdentityToCLI(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
originator string
|
||||
ua string
|
||||
wantOriginator string
|
||||
wantUA string
|
||||
wantChanged bool
|
||||
}{
|
||||
{
|
||||
name: "tui 完整 UA 改写首段并裁掉客户端标识组",
|
||||
originator: "codex-tui",
|
||||
ua: "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
|
||||
wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "无客户端标识组时仅改写首段",
|
||||
originator: "codex-tui",
|
||||
ua: "codex-tui/0.144.1 (Mac OS X 14.0; arm64)",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: "codex_cli_rs/0.144.1 (Mac OS X 14.0; arm64)",
|
||||
wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "OS 括号组不是客户端标识不得被裁剪",
|
||||
originator: "codex-tui",
|
||||
ua: "codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64)",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64)",
|
||||
wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "缺少版本段时只替换 originator",
|
||||
originator: "codex-tui",
|
||||
ua: "codex-tui",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: "codex-tui",
|
||||
wantChanged: true,
|
||||
},
|
||||
{
|
||||
name: "健康身份原样返回",
|
||||
originator: "codex_cli_rs",
|
||||
ua: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
|
||||
wantOriginator: "codex_cli_rs",
|
||||
wantUA: "codex_cli_rs/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color",
|
||||
wantChanged: false,
|
||||
},
|
||||
{
|
||||
name: "其他官方身份不受影响",
|
||||
originator: "codex_vscode",
|
||||
ua: "codex_vscode/1.0.0 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.0.0)",
|
||||
wantOriginator: "codex_vscode",
|
||||
wantUA: "codex_vscode/1.0.0 (Ubuntu 22.4.0; x86_64) vscode (codex_vscode; 1.0.0)",
|
||||
wantChanged: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
gotOriginator, gotUA, changed := NormalizeCodexClientIdentityToCLI(tt.originator, tt.ua)
|
||||
|
||||
require.Equal(t, tt.wantOriginator, gotOriginator)
|
||||
require.Equal(t, tt.wantUA, gotUA)
|
||||
require.Equal(t, tt.wantChanged, changed)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 归一化后的身份必须仍然通过上游的 originator ↔ UA 首段配对校验(issue #3901),
|
||||
// 且再次归一化保持幂等。
|
||||
func TestNormalizeCodexClientIdentityToCLIStaysPaired(t *testing.T) {
|
||||
originator, ua, changed := NormalizeCodexClientIdentityToCLI(
|
||||
"codex-tui",
|
||||
"codex-tui/0.144.1 (Ubuntu 22.4.0; x86_64) xterm-256color (codex-tui; 0.144.1)",
|
||||
)
|
||||
require.True(t, changed)
|
||||
|
||||
pairedOriginator, pairedUA, ok := PairCodexClientIdentity(ua)
|
||||
require.True(t, ok)
|
||||
require.Equal(t, originator, pairedOriginator)
|
||||
require.Equal(t, ua, pairedUA)
|
||||
|
||||
againOriginator, againUA, againChanged := NormalizeCodexClientIdentityToCLI(originator, ua)
|
||||
require.False(t, againChanged)
|
||||
require.Equal(t, originator, againOriginator)
|
||||
require.Equal(t, ua, againUA)
|
||||
}
|
||||
@@ -58,6 +58,7 @@ var schedulerNeutralExtraKeyPrefixes = []string{
|
||||
"codex_7d_",
|
||||
"passive_usage_",
|
||||
"upstream_billing_probe",
|
||||
"upstream_billing_rate_sync",
|
||||
"ollama_cloud_usage",
|
||||
}
|
||||
|
||||
@@ -397,16 +398,29 @@ func (r *accountRepository) ListCRSAccountIDs(ctx context.Context) (map[string]i
|
||||
}
|
||||
|
||||
func (r *accountRepository) Update(ctx context.Context, account *service.Account) error {
|
||||
return r.updateAccount(ctx, account, nil)
|
||||
return r.updateAccount(ctx, account, nil, nil, account.RateMultiplier)
|
||||
}
|
||||
|
||||
// UpdateWithUpstreamBillingProbeEnabled applies an explicit probe switch in the
|
||||
// same row-lock transaction as the rest of an admin account edit.
|
||||
func (r *accountRepository) UpdateWithUpstreamBillingProbeEnabled(ctx context.Context, account *service.Account, enabled bool) error {
|
||||
return r.updateAccount(ctx, account, &enabled)
|
||||
// UpdateWithAccountBillingSettings applies an admin account edit while
|
||||
// preserving a concurrently probe-synchronized rate unless the request
|
||||
// explicitly includes a manual rate.
|
||||
func (r *accountRepository) UpdateWithAccountBillingSettings(
|
||||
ctx context.Context,
|
||||
account *service.Account,
|
||||
probeEnabled *bool,
|
||||
rateSyncEnabled *bool,
|
||||
rateMultiplier *float64,
|
||||
) error {
|
||||
return r.updateAccount(ctx, account, probeEnabled, rateSyncEnabled, rateMultiplier)
|
||||
}
|
||||
|
||||
func (r *accountRepository) updateAccount(ctx context.Context, account *service.Account, explicitProbeEnabled *bool) error {
|
||||
func (r *accountRepository) updateAccount(
|
||||
ctx context.Context,
|
||||
account *service.Account,
|
||||
explicitProbeEnabled *bool,
|
||||
explicitRateSyncEnabled *bool,
|
||||
explicitRateMultiplier *float64,
|
||||
) error {
|
||||
if account == nil {
|
||||
return nil
|
||||
}
|
||||
@@ -430,7 +444,14 @@ func (r *accountRepository) updateAccount(ctx context.Context, account *service.
|
||||
}
|
||||
}
|
||||
|
||||
updated, err := r.updateLockedAccount(ctx, client, account, explicitProbeEnabled)
|
||||
updated, err := r.updateLockedAccount(
|
||||
ctx,
|
||||
client,
|
||||
account,
|
||||
explicitProbeEnabled,
|
||||
explicitRateSyncEnabled,
|
||||
explicitRateMultiplier,
|
||||
)
|
||||
if err != nil {
|
||||
return translatePersistenceError(err, service.ErrAccountNotFound, nil)
|
||||
}
|
||||
@@ -452,8 +473,15 @@ func (r *accountRepository) updateAccount(ctx context.Context, account *service.
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *accountRepository) updateLockedAccount(ctx context.Context, client *dbent.Client, account *service.Account, explicitProbeEnabled *bool) (*dbent.Account, error) {
|
||||
extra, err := lockAndMergeAccountProbeExtra(ctx, client, account, explicitProbeEnabled)
|
||||
func (r *accountRepository) updateLockedAccount(
|
||||
ctx context.Context,
|
||||
client *dbent.Client,
|
||||
account *service.Account,
|
||||
explicitProbeEnabled *bool,
|
||||
explicitRateSyncEnabled *bool,
|
||||
explicitRateMultiplier *float64,
|
||||
) (*dbent.Account, error) {
|
||||
extra, err := lockAndMergeAccountProbeExtra(ctx, client, account, explicitProbeEnabled, explicitRateSyncEnabled)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -478,8 +506,8 @@ func (r *accountRepository) updateLockedAccount(ctx context.Context, client *dbe
|
||||
SetSchedulable(schedulable).
|
||||
SetAutoPauseOnExpired(account.AutoPauseOnExpired)
|
||||
|
||||
if account.RateMultiplier != nil {
|
||||
builder.SetRateMultiplier(*account.RateMultiplier)
|
||||
if explicitRateMultiplier != nil {
|
||||
builder.SetRateMultiplier(*explicitRateMultiplier)
|
||||
}
|
||||
if account.LoadFactor != nil {
|
||||
builder.SetLoadFactor(*account.LoadFactor)
|
||||
@@ -542,7 +570,13 @@ func (r *accountRepository) updateLockedAccount(ctx context.Context, client *dbe
|
||||
return builder.Save(ctx)
|
||||
}
|
||||
|
||||
func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, account *service.Account, explicitProbeEnabled *bool) (map[string]any, error) {
|
||||
func lockAndMergeAccountProbeExtra(
|
||||
ctx context.Context,
|
||||
client *dbent.Client,
|
||||
account *service.Account,
|
||||
explicitProbeEnabled *bool,
|
||||
explicitRateSyncEnabled *bool,
|
||||
) (map[string]any, error) {
|
||||
credentials, err := json.Marshal(normalizeJSONMap(account.Credentials))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -569,6 +603,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac
|
||||
),
|
||||
proxy_id IS NOT DISTINCT FROM $5,
|
||||
extra -> 'upstream_billing_probe_enabled',
|
||||
extra -> 'upstream_billing_rate_sync_enabled',
|
||||
extra -> 'upstream_billing_probe',
|
||||
extra -> 'ollama_cloud_usage_session',
|
||||
extra -> 'ollama_cloud_usage_auto_refresh',
|
||||
@@ -593,6 +628,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac
|
||||
ollamaGroupIdentityUnchanged bool
|
||||
ollamaProxyIdentityUnchanged bool
|
||||
currentEnabled []byte
|
||||
currentRateSyncEnabled []byte
|
||||
currentSnapshot []byte
|
||||
currentOllamaSession []byte
|
||||
currentOllamaAutoRefresh []byte
|
||||
@@ -603,6 +639,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac
|
||||
&ollamaGroupIdentityUnchanged,
|
||||
&ollamaProxyIdentityUnchanged,
|
||||
¤tEnabled,
|
||||
¤tRateSyncEnabled,
|
||||
¤tSnapshot,
|
||||
¤tOllamaSession,
|
||||
¤tOllamaAutoRefresh,
|
||||
@@ -617,6 +654,7 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac
|
||||
extra := copyJSONMap(normalizeJSONMap(account.Extra))
|
||||
for _, key := range []string{
|
||||
service.UpstreamBillingProbeEnabledExtraKey,
|
||||
service.UpstreamBillingRateSyncEnabledExtraKey,
|
||||
service.UpstreamBillingProbeExtraKey,
|
||||
service.OllamaCloudUsageSessionExtraKey,
|
||||
service.OllamaCloudUsageAutoRefreshExtraKey,
|
||||
@@ -624,21 +662,53 @@ func lockAndMergeAccountProbeExtra(ctx context.Context, client *dbent.Client, ac
|
||||
} {
|
||||
delete(extra, key)
|
||||
}
|
||||
probeExplicitlyDisabled := false
|
||||
probeAccount := account.Platform == service.PlatformOpenAI && account.Type == service.AccountTypeAPIKey
|
||||
if probeAccount && explicitProbeEnabled != nil {
|
||||
extra[service.UpstreamBillingProbeEnabledExtraKey] = *explicitProbeEnabled
|
||||
probeExplicitlyDisabled = !*explicitProbeEnabled
|
||||
} else if probeAccount {
|
||||
probeAccount := service.IsUpstreamBillingProbeIdentity(account.Platform, account.Type)
|
||||
probeEnabled := false
|
||||
probeEnabledPresent := false
|
||||
if probeAccount {
|
||||
if enabled, ok, err := decodeAccountExtraJSON(currentEnabled); err != nil {
|
||||
return nil, err
|
||||
} else if ok {
|
||||
extra[service.UpstreamBillingProbeEnabledExtraKey] = enabled
|
||||
if value, isBool := enabled.(bool); isBool && !value {
|
||||
probeExplicitlyDisabled = true
|
||||
}
|
||||
} else if value, isBool := enabled.(bool); ok && isBool {
|
||||
probeEnabled = value
|
||||
probeEnabledPresent = true
|
||||
}
|
||||
if explicitProbeEnabled != nil {
|
||||
probeEnabled = *explicitProbeEnabled
|
||||
probeEnabledPresent = true
|
||||
}
|
||||
}
|
||||
rateSyncEnabled := false
|
||||
rateSyncEnabledPresent := false
|
||||
if probeAccount {
|
||||
if enabled, ok, err := decodeAccountExtraJSON(currentRateSyncEnabled); err != nil {
|
||||
return nil, err
|
||||
} else if value, isBool := enabled.(bool); ok && isBool {
|
||||
rateSyncEnabled = value
|
||||
rateSyncEnabledPresent = true
|
||||
}
|
||||
if explicitRateSyncEnabled != nil {
|
||||
rateSyncEnabled = *explicitRateSyncEnabled
|
||||
rateSyncEnabledPresent = true
|
||||
}
|
||||
if explicitProbeEnabled != nil && !*explicitProbeEnabled {
|
||||
rateSyncEnabled = false
|
||||
rateSyncEnabledPresent = true
|
||||
}
|
||||
// 同步依赖探测,方向是单向的:探测关闭(或探测键缺失)一律把同步归零。
|
||||
// 不做反向推导——由 rate_sync=true 推出 probe=true 会让一条"同步开、探测键
|
||||
// 缺失"的僵尸记录在任意一次无关编辑时静默打开周期性外呼。需要同时打开两个
|
||||
// 开关的调用方(管理端编辑)自己显式传 explicitProbeEnabled=true。
|
||||
if !probeEnabled {
|
||||
rateSyncEnabled = false
|
||||
}
|
||||
if probeEnabledPresent {
|
||||
extra[service.UpstreamBillingProbeEnabledExtraKey] = probeEnabled
|
||||
}
|
||||
if rateSyncEnabledPresent {
|
||||
extra[service.UpstreamBillingRateSyncEnabledExtraKey] = rateSyncEnabled
|
||||
}
|
||||
}
|
||||
probeExplicitlyDisabled := probeEnabledPresent && !probeEnabled
|
||||
if identityUnchanged && !probeExplicitlyDisabled {
|
||||
if snapshot, ok, err := decodeAccountExtraJSON(currentSnapshot); err != nil {
|
||||
return nil, err
|
||||
@@ -709,7 +779,7 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre
|
||||
credentials = $1::jsonb,
|
||||
extra = CASE
|
||||
-- 凭证整体未变化 ⇒ Ollama 组身份必然未变化;顶层 DISTINCT 守卫防止
|
||||
-- 非 Ollama 账号的无变化持久化误清 openai 探测快照或重写 NULL extra。
|
||||
-- 非 Ollama 账号的无变化持久化误清探测快照或重写 NULL extra。
|
||||
WHEN platform IN ('openai', 'anthropic')
|
||||
AND type = 'apikey'
|
||||
AND credentials IS DISTINCT FROM $1::jsonb
|
||||
@@ -720,15 +790,14 @@ func (r *accountRepository) UpdateCredentials(ctx context.Context, id int64, cre
|
||||
AND `+ollamaCloudBaseURLMatchesSQL("$1::jsonb ->> 'base_url'")+`
|
||||
)
|
||||
)
|
||||
THEN (CASE
|
||||
WHEN platform = 'openai' THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe'
|
||||
ELSE COALESCE(extra, '{}'::jsonb)
|
||||
END)
|
||||
THEN COALESCE(extra, '{}'::jsonb)
|
||||
- 'upstream_billing_probe'
|
||||
- 'ollama_cloud_usage_session'
|
||||
- 'ollama_cloud_usage_auto_refresh'
|
||||
- 'ollama_cloud_usage_snapshot'
|
||||
WHEN platform = 'openai'
|
||||
AND type = 'apikey'
|
||||
-- 上游倍率探测已放宽到全部 API-key 平台:凭证变化即视为探测
|
||||
-- 身份变化,丢弃 stale 快照。
|
||||
WHEN type = 'apikey'
|
||||
AND credentials IS DISTINCT FROM $1::jsonb
|
||||
THEN COALESCE(extra, '{}'::jsonb) - 'upstream_billing_probe'
|
||||
ELSE extra
|
||||
@@ -2528,21 +2597,25 @@ func (r *accountRepository) UpdateUpstreamBillingProbeSnapshot(
|
||||
ctx context.Context,
|
||||
account *service.Account,
|
||||
snapshot *service.UpstreamBillingProbeSnapshot,
|
||||
rateMultiplier *float64,
|
||||
) error {
|
||||
if account == nil || snapshot == nil {
|
||||
return service.ErrAccountNilInput
|
||||
}
|
||||
if snapshot.Status != service.UpstreamBillingProbeStatusOK {
|
||||
rateMultiplier = nil
|
||||
}
|
||||
if dbent.TxFromContext(ctx) == nil {
|
||||
tx, err := r.client.Tx(ctx)
|
||||
if errors.Is(err, dbent.ErrTxStarted) {
|
||||
return r.updateUpstreamBillingProbeSnapshotInTx(ctx, account, snapshot)
|
||||
return r.updateUpstreamBillingProbeSnapshotInTx(ctx, account, snapshot, rateMultiplier)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func() { _ = tx.Rollback() }()
|
||||
|
||||
if err := r.updateUpstreamBillingProbeSnapshotInTx(dbent.NewTxContext(ctx, tx), account, snapshot); err != nil {
|
||||
if err := r.updateUpstreamBillingProbeSnapshotInTx(dbent.NewTxContext(ctx, tx), account, snapshot, rateMultiplier); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
@@ -2553,13 +2626,14 @@ func (r *accountRepository) UpdateUpstreamBillingProbeSnapshot(
|
||||
r.syncSchedulerAccountSnapshot(ctx, account.ID)
|
||||
return nil
|
||||
}
|
||||
return r.updateUpstreamBillingProbeSnapshotInTx(ctx, account, snapshot)
|
||||
return r.updateUpstreamBillingProbeSnapshotInTx(ctx, account, snapshot, rateMultiplier)
|
||||
}
|
||||
|
||||
func (r *accountRepository) updateUpstreamBillingProbeSnapshotInTx(
|
||||
ctx context.Context,
|
||||
account *service.Account,
|
||||
snapshot *service.UpstreamBillingProbeSnapshot,
|
||||
rateMultiplier *float64,
|
||||
) error {
|
||||
payload, err := json.Marshal(map[string]any{service.UpstreamBillingProbeExtraKey: snapshot})
|
||||
if err != nil {
|
||||
@@ -2585,6 +2659,14 @@ func (r *accountRepository) updateUpstreamBillingProbeSnapshotInTx(
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var expectedRateSyncEnabled any
|
||||
if account.Extra != nil {
|
||||
expectedRateSyncEnabled = account.Extra[service.UpstreamBillingRateSyncEnabledExtraKey]
|
||||
}
|
||||
expectedRateSyncEnabledJSON, err := json.Marshal(expectedRateSyncEnabled)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
client := clientFromContext(ctx, r.client)
|
||||
proxyMatches, err := lockAndMatchProbeProxyIdentity(ctx, client, account)
|
||||
if err != nil {
|
||||
@@ -2599,7 +2681,16 @@ func (r *accountRepository) updateUpstreamBillingProbeSnapshotInTx(
|
||||
}
|
||||
result, err := client.ExecContext(ctx, `
|
||||
UPDATE accounts
|
||||
SET extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb, updated_at = NOW()
|
||||
SET
|
||||
extra = COALESCE(extra, '{}'::jsonb) || $1::jsonb,
|
||||
rate_multiplier = CASE
|
||||
WHEN $10::numeric IS NOT NULL
|
||||
AND extra @> '{"upstream_billing_probe_enabled": true}'::jsonb
|
||||
AND extra @> '{"upstream_billing_rate_sync_enabled": true}'::jsonb
|
||||
THEN $10::numeric
|
||||
ELSE rate_multiplier
|
||||
END,
|
||||
updated_at = NOW()
|
||||
WHERE id = $2
|
||||
AND platform = $3
|
||||
AND type = $4
|
||||
@@ -2607,8 +2698,9 @@ func (r *accountRepository) updateUpstreamBillingProbeSnapshotInTx(
|
||||
AND proxy_id IS NOT DISTINCT FROM $6
|
||||
AND COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb
|
||||
AND COALESCE(extra -> 'upstream_billing_probe_enabled', 'null'::jsonb) = $8::jsonb
|
||||
AND COALESCE(extra -> 'upstream_billing_rate_sync_enabled', 'null'::jsonb) = $9::jsonb
|
||||
AND deleted_at IS NULL
|
||||
`, string(payload), account.ID, account.Platform, account.Type, string(credentials), proxyID, string(expectedSnapshotJSON), string(expectedEnabledJSON))
|
||||
`, string(payload), account.ID, account.Platform, account.Type, string(credentials), proxyID, string(expectedSnapshotJSON), string(expectedEnabledJSON), string(expectedRateSyncEnabledJSON), rateMultiplier)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -2839,8 +2931,8 @@ func (r *accountRepository) BulkUpdate(ctx context.Context, ids []int64, updates
|
||||
args = append(args, pq.Array(ids))
|
||||
idx++
|
||||
if updates.ProbeEnabled != nil {
|
||||
whereClause += " AND platform = $" + itoa(idx) + " AND type = $" + itoa(idx+1)
|
||||
args = append(args, service.PlatformOpenAI, service.AccountTypeAPIKey)
|
||||
whereClause += " AND type = $" + itoa(idx)
|
||||
args = append(args, service.AccountTypeAPIKey)
|
||||
}
|
||||
query := "UPDATE accounts SET " + joinClauses(setClauses, ", ") + whereClause
|
||||
|
||||
@@ -3382,7 +3474,6 @@ func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Cont
|
||||
FROM accounts
|
||||
WHERE deleted_at IS NULL
|
||||
AND status = 'active'
|
||||
AND platform = 'openai'
|
||||
AND type = 'apikey'
|
||||
AND extra @> '{"upstream_billing_probe_enabled": true}'::jsonb
|
||||
), parsed AS MATERIALIZED (
|
||||
|
||||
@@ -229,7 +229,7 @@ func TestLockAndMergeAccountProbeExtraCoalescesNullableOllamaGroupIdentity(t *te
|
||||
loaded, err := newAccountRepositoryWithSQL(tx.Client(), tx, nil).GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
merged, err := lockAndMergeAccountProbeExtra(ctx, tx.Client(), loaded, nil)
|
||||
merged, err := lockAndMergeAccountProbeExtra(ctx, tx.Client(), loaded, nil, nil)
|
||||
|
||||
require.NoError(t, err, "a NULL Ollama eligibility expression must scan as false")
|
||||
require.NotContains(t, merged, service.OllamaCloudUsageSessionExtraKey)
|
||||
|
||||
@@ -41,8 +41,8 @@ func TestUpdateUpstreamBillingProbeSnapshotRequiresSameIdentityAndSnapshot(t *te
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"protocol", "host", "port", "username", "password", "status"}).
|
||||
AddRow("http", "127.0.0.1", 3128, "user", "pass", service.StatusActive))
|
||||
mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("WHERE id = $2")+`.*`+regexp.QuoteMeta("AND platform = $3")+`.*`+regexp.QuoteMeta("AND type = $4")+`.*`+regexp.QuoteMeta("AND credentials = $5::jsonb")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe_enabled', 'null'::jsonb) = $8::jsonb")).
|
||||
WithArgs(sqlmock.AnyArg(), int64(17), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test","base_url":"http://127.0.0.1:8080"}`, int64(9), `{"status":"stale"}`, "null").
|
||||
mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("WHERE id = $2")+`.*`+regexp.QuoteMeta("AND platform = $3")+`.*`+regexp.QuoteMeta("AND type = $4")+`.*`+regexp.QuoteMeta("AND credentials = $5::jsonb")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe_enabled', 'null'::jsonb) = $8::jsonb")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_rate_sync_enabled', 'null'::jsonb) = $9::jsonb")).
|
||||
WithArgs(sqlmock.AnyArg(), int64(17), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test","base_url":"http://127.0.0.1:8080"}`, int64(9), `{"status":"stale"}`, "null", "null", nil).
|
||||
WillReturnResult(sqlmock.NewResult(0, tt.affected))
|
||||
if tt.affected > 0 {
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
|
||||
@@ -75,7 +75,7 @@ func TestUpdateUpstreamBillingProbeSnapshotRequiresSameIdentityAndSnapshot(t *te
|
||||
}
|
||||
|
||||
txCtx := dbent.NewTxContext(context.Background(), tx)
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(txCtx, account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK})
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(txCtx, account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}, nil)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
@@ -98,8 +98,8 @@ func TestUpdateUpstreamBillingProbeSnapshotCommitsSnapshotAndOutboxAtomically(t
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("AND credentials = $5::jsonb")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")).
|
||||
WithArgs(sqlmock.AnyArg(), int64(17), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil, "null", "null").
|
||||
mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("rate_multiplier = CASE")+`.*`+regexp.QuoteMeta("THEN $10::numeric")+`.*`+regexp.QuoteMeta("AND credentials = $5::jsonb")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")).
|
||||
WithArgs(sqlmock.AnyArg(), int64(17), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil, "null", "true", "true", 0.065).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).
|
||||
WithArgs(service.SchedulerOutboxEventAccountChanged, int64(17), nil, nil, sqlmock.AnyArg()).
|
||||
@@ -112,9 +112,19 @@ func TestUpdateUpstreamBillingProbeSnapshotCommitsSnapshotAndOutboxAtomically(t
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: map[string]any{
|
||||
service.UpstreamBillingProbeEnabledExtraKey: true,
|
||||
service.UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
},
|
||||
}
|
||||
rateMultiplier := 0.065
|
||||
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(context.Background(), account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK})
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(
|
||||
context.Background(),
|
||||
account,
|
||||
&service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK},
|
||||
&rateMultiplier,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
@@ -148,7 +158,7 @@ func TestUpdateUpstreamBillingProbeSnapshotRejectsChangedProxyIdentity(t *testin
|
||||
},
|
||||
}
|
||||
repo := newAccountRepositoryWithSQL(client, db, nil)
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(dbent.NewTxContext(context.Background(), tx), account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK})
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(dbent.NewTxContext(context.Background(), tx), account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK}, nil)
|
||||
|
||||
require.ErrorIs(t, err, service.ErrUpstreamBillingProbeIdentityChanged)
|
||||
mock.ExpectRollback()
|
||||
@@ -166,7 +176,7 @@ func TestUpdateUpstreamBillingProbeSnapshotRollsBackWhenOutboxFails(t *testing.T
|
||||
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`(?s)`+regexp.QuoteMeta("UPDATE accounts")+`.*`+regexp.QuoteMeta("AND proxy_id IS NOT DISTINCT FROM $6")+`.*`+regexp.QuoteMeta("COALESCE(extra -> 'upstream_billing_probe', 'null'::jsonb) = $7::jsonb")).
|
||||
WithArgs(sqlmock.AnyArg(), int64(18), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil, "null", "null").
|
||||
WithArgs(sqlmock.AnyArg(), int64(18), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil, "null", "true", "true", 0.7).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).WillReturnError(errors.New("outbox failed"))
|
||||
mock.ExpectRollback()
|
||||
@@ -177,9 +187,19 @@ func TestUpdateUpstreamBillingProbeSnapshotRollsBackWhenOutboxFails(t *testing.T
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: map[string]any{
|
||||
service.UpstreamBillingProbeEnabledExtraKey: true,
|
||||
service.UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
},
|
||||
}
|
||||
rateMultiplier := 0.7
|
||||
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(context.Background(), account, &service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK})
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(
|
||||
context.Background(),
|
||||
account,
|
||||
&service.UpstreamBillingProbeSnapshot{Status: service.UpstreamBillingProbeStatusOK},
|
||||
&rateMultiplier,
|
||||
)
|
||||
|
||||
require.EqualError(t, err, "outbox failed")
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
|
||||
+51
@@ -153,3 +153,54 @@ func TestListDueUpstreamBillingProbeAccountsSelectsEarliestDueAcrossIDs(t *testi
|
||||
}
|
||||
require.Equal(t, want, got)
|
||||
}
|
||||
|
||||
// 探测资格放宽回归:任何 API-key 平台的启用账号都进入定时探测候选并按
|
||||
// 到期时间排序;OAuth 与未启用账号仍被排除。
|
||||
func TestListDueUpstreamBillingProbeAccountsIncludesAllAPIKeyPlatforms(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
now := time.Date(2026, time.July, 26, 3, 0, 0, 0, time.UTC)
|
||||
_, err := tx.ExecContext(ctx, `
|
||||
UPDATE accounts
|
||||
SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe'
|
||||
`)
|
||||
require.NoError(t, err)
|
||||
|
||||
insert := func(name, platform, accountType, nextProbeAt string) int64 {
|
||||
t.Helper()
|
||||
var id int64
|
||||
extra := fmt.Sprintf(`{
|
||||
"upstream_billing_probe_enabled": true,
|
||||
"upstream_billing_probe": {"status": "ok", "next_probe_at": %q}
|
||||
}`, nextProbeAt)
|
||||
err := scanSingleRow(ctx, tx, `
|
||||
INSERT INTO accounts (name, platform, type, status, extra)
|
||||
VALUES ($1, $2, $3, 'active', $4::jsonb)
|
||||
RETURNING id
|
||||
`, []any{name, platform, accountType, extra}, &id)
|
||||
require.NoError(t, err)
|
||||
return id
|
||||
}
|
||||
|
||||
openaiDue := insert("probe-openai-due", "openai", service.AccountTypeAPIKey, "2026-07-26T02:57:00Z")
|
||||
anthropicDue := insert("probe-anthropic-due", "anthropic", service.AccountTypeAPIKey, "2026-07-26T02:58:00Z")
|
||||
grokDue := insert("probe-grok-due", "grok", service.AccountTypeAPIKey, "2026-07-26T02:59:00Z")
|
||||
// OAuth 账号即便误持有启用标记也不得入选。
|
||||
_ = insert("probe-grok-oauth-excluded", "grok", service.AccountTypeOAuth, "2026-07-26T02:59:00Z")
|
||||
// 未启用探测的 API-key 账号不入选。
|
||||
var disabledID int64
|
||||
err = scanSingleRow(ctx, tx, `
|
||||
INSERT INTO accounts (name, platform, type, status, extra)
|
||||
VALUES ('probe-grok-disabled', 'grok', $1, 'active', '{}'::jsonb)
|
||||
RETURNING id
|
||||
`, []any{service.AccountTypeAPIKey}, &disabledID)
|
||||
require.NoError(t, err)
|
||||
|
||||
accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, accounts, 3)
|
||||
require.Equal(t, openaiDue, accounts[0].ID)
|
||||
require.Equal(t, anthropicDue, accounts[1].ID)
|
||||
require.Equal(t, grokDue, accounts[2].ID)
|
||||
}
|
||||
|
||||
@@ -28,7 +28,8 @@ func TestAccountRepositoryListDueUpstreamBillingProbeAccountsBoundsQuery(t *test
|
||||
normalized := normalizeSQLWhitespace(capturedSQL)
|
||||
require.Contains(t, normalized, "deleted_at IS NULL")
|
||||
require.Contains(t, normalized, "status = 'active'")
|
||||
require.Contains(t, normalized, "platform = 'openai'")
|
||||
// 探测资格已放宽到全部 API-key 平台:候选 SQL 不得再按 platform 过滤。
|
||||
require.NotContains(t, normalized, "platform")
|
||||
require.Contains(t, normalized, "type = 'apikey'")
|
||||
require.Contains(t, normalized, `extra @> '{"upstream_billing_probe_enabled": true}'::jsonb`)
|
||||
require.Contains(t, normalized, "jsonb_path_query_first_tz")
|
||||
|
||||
@@ -81,8 +81,8 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T)
|
||||
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, tt.databaseSnapshot, nil, nil, nil))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(tt.identityUnchanged, false, true, tt.databaseEnabled, nil, tt.databaseSnapshot, nil, nil, nil))
|
||||
|
||||
account := &service.Account{
|
||||
ID: 27,
|
||||
@@ -91,7 +91,7 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T)
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: tt.inputExtra,
|
||||
}
|
||||
got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil)
|
||||
got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil)
|
||||
require.NoError(t, err)
|
||||
if tt.wantSnapshot == nil {
|
||||
require.NotContains(t, got, service.UpstreamBillingProbeExtraKey)
|
||||
@@ -104,6 +104,102 @@ func TestLockAndMergeAccountProbeExtraUsesCurrentDatabaseSnapshot(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func probeBoolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
|
||||
// The probe switch drives the rate-sync switch and never the other way round:
|
||||
// syncing depends on probing, so a row where sync is on but the probe key is
|
||||
// missing must lose the sync flag instead of silently gaining periodic
|
||||
// outbound calls on the next unrelated edit.
|
||||
func TestLockAndMergeAccountProbeExtraNeverInfersProbeFromRateSync(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
databaseEnabled any
|
||||
databaseRateSync any
|
||||
explicitProbeEnabled *bool
|
||||
explicitRateSync *bool
|
||||
wantEnabled any
|
||||
wantRateSync any
|
||||
}{
|
||||
{
|
||||
name: "sync on with missing probe key zeroes sync and keeps probing off",
|
||||
databaseEnabled: nil,
|
||||
databaseRateSync: []byte(`true`),
|
||||
wantEnabled: nil,
|
||||
wantRateSync: false,
|
||||
},
|
||||
{
|
||||
name: "sync on with probe off zeroes sync",
|
||||
databaseEnabled: []byte(`false`),
|
||||
databaseRateSync: []byte(`true`),
|
||||
wantEnabled: false,
|
||||
wantRateSync: false,
|
||||
},
|
||||
{
|
||||
name: "both on in database stay on",
|
||||
databaseEnabled: []byte(`true`),
|
||||
databaseRateSync: []byte(`true`),
|
||||
wantEnabled: true,
|
||||
wantRateSync: true,
|
||||
},
|
||||
{
|
||||
name: "admin enabling both explicitly still turns probing on",
|
||||
databaseEnabled: nil,
|
||||
databaseRateSync: nil,
|
||||
explicitProbeEnabled: probeBoolPtr(true),
|
||||
explicitRateSync: probeBoolPtr(true),
|
||||
wantEnabled: true,
|
||||
wantRateSync: true,
|
||||
},
|
||||
{
|
||||
name: "explicit probe disable clears sync",
|
||||
databaseEnabled: []byte(`true`),
|
||||
databaseRateSync: []byte(`true`),
|
||||
explicitProbeEnabled: probeBoolPtr(false),
|
||||
wantEnabled: false,
|
||||
wantRateSync: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
client := dbent.NewClient(dbent.Driver(entsql.OpenDB(dialect.Postgres, db)))
|
||||
t.Cleanup(func() { _ = client.Close() })
|
||||
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(int64(31), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(true, false, true, tt.databaseEnabled, tt.databaseRateSync, nil, nil, nil, nil))
|
||||
|
||||
account := &service.Account{
|
||||
ID: 31,
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
}
|
||||
got, err := lockAndMergeAccountProbeExtra(
|
||||
context.Background(), client, account, tt.explicitProbeEnabled, tt.explicitRateSync,
|
||||
)
|
||||
require.NoError(t, err)
|
||||
if tt.wantEnabled == nil {
|
||||
require.NotContains(t, got, service.UpstreamBillingProbeEnabledExtraKey)
|
||||
} else {
|
||||
require.Equal(t, tt.wantEnabled, got[service.UpstreamBillingProbeEnabledExtraKey])
|
||||
}
|
||||
if tt.wantRateSync == nil {
|
||||
require.NotContains(t, got, service.UpstreamBillingRateSyncEnabledExtraKey)
|
||||
} else {
|
||||
require.Equal(t, tt.wantRateSync, got[service.UpstreamBillingRateSyncEnabledExtraKey])
|
||||
}
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T) {
|
||||
for _, identityUnchanged := range []bool{true, false} {
|
||||
t.Run(map[bool]string{true: "same identity keeps snapshot", false: "changed identity clears snapshot"}[identityUnchanged], func(t *testing.T) {
|
||||
@@ -115,8 +211,8 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T)
|
||||
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(int64(29), service.PlatformAnthropic, service.AccountTypeAPIKey, `{"api_key":"key","base_url":"https://ollama.com"}`, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(identityUnchanged, identityUnchanged, true, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`)))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(identityUnchanged, identityUnchanged, true, nil, nil, nil, []byte(`"local-ciphertext"`), []byte(`true`), []byte(`{"status":"ok"}`)))
|
||||
|
||||
account := &service.Account{
|
||||
ID: 29, Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
|
||||
@@ -127,7 +223,7 @@ func TestLockAndMergeAccountProbeExtraProtectsOllamaManagedFields(t *testing.T)
|
||||
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{"status": "forged"},
|
||||
},
|
||||
}
|
||||
got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil)
|
||||
got, err := lockAndMergeAccountProbeExtra(context.Background(), client, account, nil, nil)
|
||||
require.NoError(t, err)
|
||||
if identityUnchanged {
|
||||
require.Equal(t, "local-ciphertext", got[service.OllamaCloudUsageSessionExtraKey])
|
||||
@@ -227,8 +323,8 @@ func TestBulkUpdateProbeEligibilityMismatchRollsBack(t *testing.T) {
|
||||
|
||||
enabled := true
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectExec(`(?s)UPDATE accounts SET extra = .* WHERE id = ANY\(\$2\) AND deleted_at IS NULL AND platform = \$3 AND type = \$4`).
|
||||
WithArgs(sqlmock.AnyArg(), `{27,28}`, service.PlatformOpenAI, service.AccountTypeAPIKey).
|
||||
mock.ExpectExec(`(?s)UPDATE accounts SET extra = .* WHERE id = ANY\(\$2\) AND deleted_at IS NULL AND type = \$3`).
|
||||
WithArgs(sqlmock.AnyArg(), `{27,28}`, service.AccountTypeAPIKey).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectRollback()
|
||||
|
||||
@@ -265,7 +361,7 @@ func TestUpdateCredentialsAtomicallyClearsProbeForOpenAIAPIKeyIdentityChange(t *
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
func TestUpdateWithUpstreamBillingProbeEnabledRollsBackWhenOutboxFails(t *testing.T) {
|
||||
func TestUpdateWithAccountBillingSettingsRollsBackWhenOutboxFails(t *testing.T) {
|
||||
db, mock, err := sqlmock.New()
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
@@ -275,13 +371,13 @@ func TestUpdateWithUpstreamBillingProbeEnabledRollsBackWhenOutboxFails(t *testin
|
||||
mock.ExpectBegin()
|
||||
mock.ExpectQuery(`(?s)`+regexp.QuoteMeta("SELECT")+`.*`+regexp.QuoteMeta("FOR NO KEY UPDATE")).
|
||||
WithArgs(int64(27), service.PlatformOpenAI, service.AccountTypeAPIKey, `{"api_key":"sk-test"}`, nil).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(true, false, true, []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil))
|
||||
WillReturnRows(sqlmock.NewRows([]string{"identity_unchanged", "ollama_group_unchanged", "ollama_proxy_unchanged", "enabled", "rate_sync_enabled", "snapshot", "ollama_session", "ollama_auto", "ollama_snapshot"}).
|
||||
AddRow(true, false, true, []byte(`true`), []byte(`true`), []byte(`{"status":"ok"}`), nil, nil, nil))
|
||||
mock.ExpectExec(`(?s)UPDATE .*accounts.*SET.*WHERE .*id.*`).
|
||||
WillReturnResult(sqlmock.NewResult(0, 1))
|
||||
mock.ExpectQuery(`(?s)SELECT .* FROM "accounts" WHERE "id" = \$1`).
|
||||
WithArgs(int64(27)).
|
||||
WillReturnRows(updatedAccountRows(27, `{"upstream_billing_probe_enabled":false}`))
|
||||
WillReturnRows(updatedAccountRows(27, `{"upstream_billing_probe_enabled":false,"upstream_billing_rate_sync_enabled":false}`))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox")).WillReturnError(errors.New("outbox failed"))
|
||||
mock.ExpectRollback()
|
||||
|
||||
@@ -301,10 +397,12 @@ func TestUpdateWithUpstreamBillingProbeEnabledRollsBackWhenOutboxFails(t *testin
|
||||
Schedulable: true,
|
||||
}
|
||||
|
||||
err = repo.UpdateWithUpstreamBillingProbeEnabled(context.Background(), account, false)
|
||||
probeDisabled := false
|
||||
err = repo.UpdateWithAccountBillingSettings(context.Background(), account, &probeDisabled, nil, nil)
|
||||
|
||||
require.EqualError(t, err, "outbox failed")
|
||||
require.Equal(t, false, account.Extra[service.UpstreamBillingProbeEnabledExtraKey])
|
||||
require.Equal(t, false, account.Extra[service.UpstreamBillingRateSyncEnabledExtraKey])
|
||||
require.NotContains(t, account.Extra, service.UpstreamBillingProbeExtraKey)
|
||||
require.NoError(t, mock.ExpectationsWereMet())
|
||||
}
|
||||
|
||||
@@ -210,6 +210,12 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
|
||||
group.FieldPeakStart,
|
||||
group.FieldPeakEnd,
|
||||
group.FieldPeakRateMultiplier,
|
||||
// 分组利润控制:认证快照是调度门 enable 判定的直接来源,
|
||||
// 漏选会让门静默失效;新增快照分组字段时必须同步本投影,
|
||||
// 集成测试对账兜底。
|
||||
group.FieldProfitControlEnabled,
|
||||
group.FieldProfitMinMargin,
|
||||
group.FieldProfitSafetyBuffer,
|
||||
)
|
||||
}).
|
||||
Only(ctx)
|
||||
@@ -987,6 +993,9 @@ func groupEntityToService(g *dbent.Group) *service.Group {
|
||||
PeakStart: g.PeakStart,
|
||||
PeakEnd: g.PeakEnd,
|
||||
PeakRateMultiplier: g.PeakRateMultiplier,
|
||||
ProfitControlEnabled: g.ProfitControlEnabled,
|
||||
ProfitMinMargin: g.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
|
||||
CreatedAt: g.CreatedAt,
|
||||
UpdatedAt: g.UpdatedAt,
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
// 投影漏列回归(repository 半程):真实 PostgreSQL 上认证专用
|
||||
// 查询 GetByKeyForAuth 的分组显式投影必须携带利润控制与计费字段。该查询是
|
||||
// 认证快照(进而是利润门 enable 判定)的唯一数据来源,漏选任何快照分组字段
|
||||
// 都会让对应功能在真实流量上静默失效。新增快照分组字段时必须同步扩展
|
||||
// GetByKeyForAuth 的 WithGroup Select 并在本测试补断言。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGetByKeyForAuthCarriesProfitControlProjection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
suffix := time.Now().UnixNano()
|
||||
group := mustCreateGroup(t, integrationEntClient, &service.Group{
|
||||
Name: fmt.Sprintf("profit-proj-group-%d", suffix),
|
||||
Platform: service.PlatformOpenAI,
|
||||
RateMultiplier: 0.06,
|
||||
ProfitControlEnabled: true,
|
||||
ProfitMinMargin: 0.2,
|
||||
ProfitSafetyBuffer: 0.05,
|
||||
})
|
||||
user := mustCreateUser(t, integrationEntClient, &service.User{
|
||||
Email: fmt.Sprintf("profit-proj-%d@example.com", suffix), Concurrency: 5,
|
||||
})
|
||||
groupID := group.ID
|
||||
keyValue := fmt.Sprintf("sk-profit-proj-%d", suffix)
|
||||
apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB)
|
||||
key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "profit-proj", Status: service.StatusActive}
|
||||
require.NoError(t, apiKeyRepo.Create(ctx, key))
|
||||
t.Cleanup(func() {
|
||||
_, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = encode(sha256(convert_to($1, 'UTF8')), 'hex')", keyValue)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
got, err := apiKeyRepo.GetByKeyForAuth(ctx, keyValue)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.Group, "认证查询必须带出分组")
|
||||
|
||||
require.Equal(t, service.PlatformOpenAI, got.Group.Platform)
|
||||
require.InDelta(t, 0.06, got.Group.RateMultiplier, 1e-9)
|
||||
require.True(t, got.Group.ProfitControlEnabled, "profit_control_enabled 必须进入认证投影(投影漏列会让门静默失效)")
|
||||
require.InDelta(t, 0.2, got.Group.ProfitMinMargin, 1e-9)
|
||||
require.InDelta(t, 0.05, got.Group.ProfitSafetyBuffer, 1e-9)
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
//go:build integration
|
||||
|
||||
package repository
|
||||
|
||||
// migration 193 回归:groups 触发器的 durable 失效监视清单必须覆盖利润控制
|
||||
// 配置及 D 依赖的分组计价字段(正常后台保存走 InvalidateAuthCacheByGroupID 即时失效,触发器兜底
|
||||
// 直改 SQL / 更新与失效之间崩溃等 out-of-band 场景)。
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestAuthCacheInvalidationTrigger_ProfitControlColumns(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
suffix := time.Now().UnixNano()
|
||||
group := mustCreateGroup(t, integrationEntClient, &service.Group{
|
||||
Name: fmt.Sprintf("profit-trigger-group-%d", suffix), Platform: service.PlatformOpenAI, RateMultiplier: 1,
|
||||
})
|
||||
user := mustCreateUser(t, integrationEntClient, &service.User{
|
||||
Email: fmt.Sprintf("profit-trigger-%d@example.com", suffix), Concurrency: 5,
|
||||
})
|
||||
groupID := group.ID
|
||||
keyValue := fmt.Sprintf("sk-profit-trigger-%d", suffix)
|
||||
apiKeyRepo := NewAPIKeyRepository(integrationEntClient, integrationDB)
|
||||
key := &service.APIKey{UserID: user.ID, GroupID: &groupID, Key: keyValue, Name: "profit-trigger", Status: service.StatusActive}
|
||||
require.NoError(t, apiKeyRepo.Create(ctx, key))
|
||||
|
||||
sum := sha256.Sum256([]byte(keyValue))
|
||||
cacheKey := hex.EncodeToString(sum[:])
|
||||
clear := func() {
|
||||
_, err := integrationDB.ExecContext(ctx, "DELETE FROM auth_cache_invalidation_outbox WHERE cache_key = $1", cacheKey)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
count := func() int {
|
||||
var value int
|
||||
require.NoError(t, integrationDB.QueryRowContext(ctx,
|
||||
"SELECT COUNT(*) FROM auth_cache_invalidation_outbox WHERE cache_key = $1", cacheKey).Scan(&value))
|
||||
return value
|
||||
}
|
||||
clear()
|
||||
t.Cleanup(clear)
|
||||
t.Cleanup(func() {
|
||||
_, err := integrationDB.ExecContext(ctx, "DELETE FROM api_keys WHERE id = $1", key.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM users WHERE id = $1", user.ID)
|
||||
require.NoError(t, err)
|
||||
_, err = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
_, err := integrationDB.ExecContext(ctx, "UPDATE groups SET name = name || '-cosmetic' WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count(), "cosmetic 更新不得入队(既有语义回归)")
|
||||
|
||||
_, err = integrationDB.ExecContext(ctx, "UPDATE groups SET profit_control_enabled = NOT profit_control_enabled WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count(), "profit_control_enabled 变更必须入队")
|
||||
clear()
|
||||
|
||||
_, err = integrationDB.ExecContext(ctx, "UPDATE groups SET profit_min_margin = 0.3 WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count(), "profit_min_margin 变更必须入队")
|
||||
clear()
|
||||
|
||||
_, err = integrationDB.ExecContext(ctx, "UPDATE groups SET profit_safety_buffer = 0.02 WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count(), "profit_safety_buffer 变更必须入队")
|
||||
clear()
|
||||
|
||||
_, err = integrationDB.ExecContext(ctx, "UPDATE groups SET profit_min_margin = profit_min_margin WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, count(), "利润字段无实际变化的 UPDATE 不得入队")
|
||||
|
||||
for name, update := range map[string]string{
|
||||
"platform": "platform = 'anthropic'",
|
||||
"subscription_type": "subscription_type = 'subscription'",
|
||||
"rate_multiplier": "rate_multiplier = 0.9",
|
||||
"peak_rate_enabled": "peak_rate_enabled = true",
|
||||
"peak_start": "peak_start = '08:00'",
|
||||
"peak_end": "peak_end = '09:00'",
|
||||
"peak_rate_multiplier": "peak_rate_multiplier = 1.2",
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
clear()
|
||||
_, err := integrationDB.ExecContext(ctx, "UPDATE groups SET "+update+" WHERE id = $1", group.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, count(), name+" 变更必须入队")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -89,7 +89,10 @@ func mustCreateGroup(t *testing.T, client *dbent.Client, g *service.Group) *serv
|
||||
SetStatus(g.Status).
|
||||
SetSubscriptionType(g.SubscriptionType).
|
||||
SetRateMultiplier(g.RateMultiplier).
|
||||
SetIsExclusive(g.IsExclusive)
|
||||
SetIsExclusive(g.IsExclusive).
|
||||
SetProfitControlEnabled(g.ProfitControlEnabled).
|
||||
SetProfitMinMargin(g.ProfitMinMargin).
|
||||
SetProfitSafetyBuffer(g.ProfitSafetyBuffer)
|
||||
if g.Description != "" {
|
||||
create.SetDescription(g.Description)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
@@ -31,7 +32,14 @@ func buildSessionKey(groupID int64, sessionHash string) string {
|
||||
|
||||
func (c *gatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
key := buildSessionKey(groupID, sessionHash)
|
||||
return c.rdb.Get(ctx, key).Int64()
|
||||
accountID, err := c.rdb.Get(ctx, key).Int64()
|
||||
if err != nil {
|
||||
if errors.Is(err, redis.Nil) {
|
||||
return 0, service.ErrStickySessionNotFound
|
||||
}
|
||||
return 0, err
|
||||
}
|
||||
return accountID, nil
|
||||
}
|
||||
|
||||
func (c *gatewayCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||
|
||||
@@ -8,7 +8,6 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/stretchr/testify/suite"
|
||||
)
|
||||
@@ -25,7 +24,7 @@ func (s *GatewayCacheSuite) SetupTest() {
|
||||
|
||||
func (s *GatewayCacheSuite) TestGetSessionAccountID_Missing() {
|
||||
_, err := s.cache.GetSessionAccountID(s.ctx, 1, "nonexistent")
|
||||
require.True(s.T(), errors.Is(err, redis.Nil), "expected redis.Nil for missing session")
|
||||
require.True(s.T(), errors.Is(err, service.ErrStickySessionNotFound), "expected ErrStickySessionNotFound for missing session")
|
||||
}
|
||||
|
||||
func (s *GatewayCacheSuite) TestSetAndGetSessionAccountID() {
|
||||
@@ -88,7 +87,7 @@ func (s *GatewayCacheSuite) TestDeleteSessionAccountID() {
|
||||
require.NoError(s.T(), s.cache.DeleteSessionAccountID(s.ctx, groupID, sessionID), "DeleteSessionAccountID")
|
||||
|
||||
_, err := s.cache.GetSessionAccountID(s.ctx, groupID, sessionID)
|
||||
require.True(s.T(), errors.Is(err, redis.Nil), "expected redis.Nil after delete")
|
||||
require.True(s.T(), errors.Is(err, service.ErrStickySessionNotFound), "expected ErrStickySessionNotFound after delete")
|
||||
}
|
||||
|
||||
func (s *GatewayCacheSuite) TestGetSessionAccountID_CorruptedValue() {
|
||||
@@ -101,7 +100,7 @@ func (s *GatewayCacheSuite) TestGetSessionAccountID_CorruptedValue() {
|
||||
|
||||
_, err := s.cache.GetSessionAccountID(s.ctx, groupID, sessionID)
|
||||
require.Error(s.T(), err, "expected error for corrupted value")
|
||||
require.False(s.T(), errors.Is(err, redis.Nil), "expected parsing error, not redis.Nil")
|
||||
require.False(s.T(), errors.Is(err, service.ErrStickySessionNotFound), "expected parsing error, not a miss")
|
||||
}
|
||||
|
||||
func TestGatewayCacheSuite(t *testing.T) {
|
||||
|
||||
@@ -102,7 +102,10 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
|
||||
SetPeakRateEnabled(groupIn.PeakRateEnabled).
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
SetPeakEnd(groupIn.PeakEnd).
|
||||
SetPeakRateMultiplier(groupIn.PeakRateMultiplier)
|
||||
SetPeakRateMultiplier(groupIn.PeakRateMultiplier).
|
||||
SetProfitControlEnabled(groupIn.ProfitControlEnabled).
|
||||
SetProfitMinMargin(groupIn.ProfitMinMargin).
|
||||
SetProfitSafetyBuffer(groupIn.ProfitSafetyBuffer)
|
||||
if groupIn.DuplicateOperationID != "" {
|
||||
builder = builder.SetDuplicateOperationID(groupIn.DuplicateOperationID)
|
||||
}
|
||||
@@ -268,7 +271,10 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
|
||||
SetPeakRateEnabled(groupIn.PeakRateEnabled).
|
||||
SetPeakStart(groupIn.PeakStart).
|
||||
SetPeakEnd(groupIn.PeakEnd).
|
||||
SetPeakRateMultiplier(groupIn.PeakRateMultiplier)
|
||||
SetPeakRateMultiplier(groupIn.PeakRateMultiplier).
|
||||
SetProfitControlEnabled(groupIn.ProfitControlEnabled).
|
||||
SetProfitMinMargin(groupIn.ProfitMinMargin).
|
||||
SetProfitSafetyBuffer(groupIn.ProfitSafetyBuffer)
|
||||
|
||||
// 显式处理可空字段:nil 需要 clear,非 nil 需要 set。
|
||||
if groupIn.DailyLimitUSD != nil {
|
||||
|
||||
@@ -231,8 +231,7 @@ func invalidateProxyProbeSnapshots(ctx context.Context, exec sqlExecutor, proxyI
|
||||
WHERE proxy_id = $1
|
||||
AND type = 'apikey'
|
||||
AND (
|
||||
(platform = 'openai'
|
||||
AND extra ? 'upstream_billing_probe'
|
||||
(extra ? 'upstream_billing_probe'
|
||||
AND extra -> 'upstream_billing_probe' <> 'null'::jsonb)
|
||||
OR (platform IN ('openai', 'anthropic')
|
||||
AND extra ? 'ollama_cloud_usage_snapshot'
|
||||
@@ -747,7 +746,7 @@ func (r *proxyRepository) sweepOneExpiredProxyOnExec(ctx context.Context, exec s
|
||||
rows, err = exec.QueryContext(ctx, `
|
||||
UPDATE accounts SET proxy_id=NULL, proxy_fallback_origin_id=$1,
|
||||
extra=CASE
|
||||
WHEN platform='openai' AND type='apikey' AND extra ? 'upstream_billing_probe'
|
||||
WHEN type='apikey' AND extra ? 'upstream_billing_probe'
|
||||
THEN extra - 'upstream_billing_probe'
|
||||
ELSE extra
|
||||
END,
|
||||
@@ -758,7 +757,7 @@ func (r *proxyRepository) sweepOneExpiredProxyOnExec(ctx context.Context, exec s
|
||||
rows, err = exec.QueryContext(ctx, `
|
||||
UPDATE accounts SET proxy_id=$2, proxy_fallback_origin_id=$1,
|
||||
extra=CASE
|
||||
WHEN platform='openai' AND type='apikey' AND extra ? 'upstream_billing_probe'
|
||||
WHEN type='apikey' AND extra ? 'upstream_billing_probe'
|
||||
THEN extra - 'upstream_billing_probe'
|
||||
ELSE extra
|
||||
END,
|
||||
|
||||
@@ -33,7 +33,7 @@ func TestProxyUpdateInvalidatesBoundProbeSnapshotsAndEnqueuesOutboxAtomically(t
|
||||
WithArgs(int64(9)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
expectProxyUpdateReload(mock, 9, "new.example", "user", "pass")
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*platform = 'openai'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)).AddRow(int64(18)))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
|
||||
@@ -76,7 +76,7 @@ func TestProxyUpdateRollsBackWhenProbeInvalidationOutboxFails(t *testing.T) {
|
||||
WithArgs(int64(9)).
|
||||
WillReturnResult(sqlmock.NewResult(0, 0))
|
||||
expectProxyUpdateReload(mock, 9, "new.example", "", "")
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*platform = 'openai'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
|
||||
mock.ExpectQuery(`(?s)UPDATE accounts.*- 'upstream_billing_probe'.*- 'ollama_cloud_usage_snapshot'.*type = 'apikey'.*extra \? 'upstream_billing_probe'.*platform IN \('openai', 'anthropic'\).*extra \? 'ollama_cloud_usage_snapshot'.*RETURNING id`).
|
||||
WithArgs(int64(9)).
|
||||
WillReturnRows(sqlmock.NewRows([]string{"id"}).AddRow(int64(17)))
|
||||
mock.ExpectExec(regexp.QuoteMeta("INSERT INTO scheduler_outbox (event_type, account_id, group_id, payload)")).
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
//go:build unit
|
||||
|
||||
package repository
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestSchedulerCachePreservesRateMultiplier 钉死账号调度快照的两份 payload
|
||||
// (full + metadata)都必须保留 RateMultiplier。
|
||||
//
|
||||
// 为什么值得一条专门的护栏:分组利润门对 nil RateMultiplier 是 fail-closed
|
||||
// (保守拒绝),与全系统其他地方不同——Account.BillingRateMultiplier() 对同样
|
||||
// 的 nil 返回 1.0,字段注释也写着「nil 表示按 1.0 处理」。DB 列有 Default(1.0)
|
||||
// 且非 nillable,因此 nil 只可能来自缓存反序列化缺字段。
|
||||
// buildSchedulerMetadataAccount 是显式字段清单,将来任何一次快照结构调整漏掉
|
||||
// 这一项,对启用利润控制的分组就是全组 no available accounts——而且是静默的。
|
||||
// 这条测试让那种漏列在 CI 就变红。
|
||||
func TestSchedulerCachePreservesRateMultiplier(t *testing.T) {
|
||||
rate := 0.75
|
||||
account := service.Account{
|
||||
ID: 9001,
|
||||
Name: "profit-gate-rate",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
Concurrency: 2,
|
||||
RateMultiplier: &rate,
|
||||
}
|
||||
|
||||
t.Run("metadata payload keeps the field", func(t *testing.T) {
|
||||
meta := buildSchedulerMetadataAccount(account)
|
||||
require.NotNil(t, meta.RateMultiplier, "metadata 快照漏掉 rate_multiplier 会让利润门 fail-closed 全组拒绝")
|
||||
require.Equal(t, rate, *meta.RateMultiplier)
|
||||
})
|
||||
|
||||
t.Run("both payloads survive a decode round-trip", func(t *testing.T) {
|
||||
full, meta, err := marshalSchedulerCacheAccount(account)
|
||||
require.NoError(t, err)
|
||||
|
||||
for name, payload := range map[string][]byte{"full": full, "metadata": meta} {
|
||||
decoded, decodeErr := decodeCachedAccount(payload)
|
||||
require.NoError(t, decodeErr, name)
|
||||
require.NotNil(t, decoded.RateMultiplier, "%s payload 反序列化后 rate_multiplier 不得为 nil", name)
|
||||
require.Equal(t, rate, *decoded.RateMultiplier, name)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("zero rate survives as zero rather than nil", func(t *testing.T) {
|
||||
// 0 是合法值(该账号上游成本为 0),绝不能被当成缺字段丢掉:
|
||||
// 丢成 nil 后利润门会把一个本该必然放行的账号判成越线。
|
||||
zero := 0.0
|
||||
zeroAccount := account
|
||||
zeroAccount.ID = 9002
|
||||
zeroAccount.RateMultiplier = &zero
|
||||
|
||||
_, meta, err := marshalSchedulerCacheAccount(zeroAccount)
|
||||
require.NoError(t, err)
|
||||
decoded, err := decodeCachedAccount(meta)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, decoded.RateMultiplier)
|
||||
require.Equal(t, 0.0, *decoded.RateMultiplier)
|
||||
})
|
||||
|
||||
t.Run("SetAccount then GetAccount preserves the field", func(t *testing.T) {
|
||||
cache := newSchedulerCacheUnit(t)
|
||||
ctx := context.Background()
|
||||
require.NoError(t, cache.SetAccount(ctx, &account))
|
||||
|
||||
got, err := cache.GetAccount(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got)
|
||||
require.NotNil(t, got.RateMultiplier, "端到端缓存读写后 rate_multiplier 不得丢失")
|
||||
require.Equal(t, rate, *got.RateMultiplier)
|
||||
})
|
||||
}
|
||||
+152
-3
@@ -30,7 +30,7 @@ func TestAccountUpdatePreservesConcurrentProbeSnapshot(t *testing.T) {
|
||||
require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, stale, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusOK,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
}))
|
||||
}, nil))
|
||||
|
||||
stale.Name = "ordinary-edit"
|
||||
require.NoError(t, repo.Update(ctx, stale))
|
||||
@@ -46,6 +46,115 @@ func TestAccountUpdatePreservesConcurrentProbeSnapshot(t *testing.T) {
|
||||
require.NotContains(t, disabled.Extra, service.UpstreamBillingProbeExtraKey)
|
||||
}
|
||||
|
||||
func TestAdminAccountEditPreservesRateSynchronizedAfterLoad(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
initialRate := 0.1
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "probe-rate-concurrent-edit",
|
||||
Platform: service.PlatformOpenAI,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
RateMultiplier: &initialRate,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: map[string]any{
|
||||
service.UpstreamBillingProbeEnabledExtraKey: true,
|
||||
service.UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
},
|
||||
})
|
||||
|
||||
staleAdminEdit, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
probeAccount, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
synchronizedRate := 0.2
|
||||
require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, probeAccount, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusOK,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
}, &synchronizedRate))
|
||||
|
||||
staleAdminEdit.Name = "name-only-edit"
|
||||
require.NoError(t, repo.UpdateWithAccountBillingSettings(ctx, staleAdminEdit, nil, nil, nil))
|
||||
|
||||
got, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "name-only-edit", got.Name)
|
||||
require.NotNil(t, got.RateMultiplier)
|
||||
require.Equal(t, synchronizedRate, *got.RateMultiplier)
|
||||
}
|
||||
|
||||
func TestProbeSnapshotSyncsRateOnlyForSuccessfulEnabledAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
initialRate := 0.25
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "probe-rate-sync",
|
||||
Platform: service.PlatformGemini,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
RateMultiplier: &initialRate,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: map[string]any{
|
||||
service.UpstreamBillingProbeEnabledExtraKey: true,
|
||||
service.UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
},
|
||||
})
|
||||
|
||||
loaded, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
syncedRate := 0.065
|
||||
require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, loaded, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusOK,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
}, &syncedRate))
|
||||
|
||||
got, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.RateMultiplier)
|
||||
require.Equal(t, syncedRate, *got.RateMultiplier)
|
||||
|
||||
failedRate := 0.9
|
||||
require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, got, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusFailed,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
}, &failedRate))
|
||||
got, err = repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.RateMultiplier)
|
||||
require.Equal(t, syncedRate, *got.RateMultiplier)
|
||||
|
||||
require.NoError(t, repo.UpdateExtra(ctx, account.ID, map[string]any{
|
||||
service.UpstreamBillingRateSyncEnabledExtraKey: false,
|
||||
}))
|
||||
manual, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
manualProbeRate := 0.4
|
||||
require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, manual, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusOK,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
}, &manualProbeRate))
|
||||
got, err = repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.RateMultiplier)
|
||||
require.Equal(t, syncedRate, *got.RateMultiplier)
|
||||
|
||||
require.NoError(t, repo.UpdateExtra(ctx, account.ID, map[string]any{
|
||||
service.UpstreamBillingProbeEnabledExtraKey: false,
|
||||
service.UpstreamBillingRateSyncEnabledExtraKey: false,
|
||||
}))
|
||||
disabled, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, repo.UpdateUpstreamBillingProbeSnapshot(ctx, disabled, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusOK,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
}, &manualProbeRate))
|
||||
got, err = repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.RateMultiplier)
|
||||
require.Equal(t, syncedRate, *got.RateMultiplier)
|
||||
}
|
||||
|
||||
func TestAccountUpdatePreservesConcurrentProbeEnableFlag(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
@@ -166,7 +275,7 @@ func TestProbeSnapshotCASIncludesLoadedEnabledState(t *testing.T) {
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(ctx, inFlight, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusOK,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
})
|
||||
}, nil)
|
||||
if tt.wantConflict {
|
||||
require.ErrorIs(t, err, service.ErrUpstreamBillingProbeIdentityChanged)
|
||||
} else {
|
||||
@@ -183,6 +292,46 @@ func TestProbeSnapshotCASIncludesLoadedEnabledState(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestProbeSnapshotCASProtectsManualRateAfterSyncDisabled(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tx := testEntTx(t)
|
||||
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
|
||||
initialRate := 0.25
|
||||
account := mustCreateAccount(t, tx.Client(), &service.Account{
|
||||
Name: "probe-sync-cas",
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
RateMultiplier: &initialRate,
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
Extra: map[string]any{
|
||||
service.UpstreamBillingProbeEnabledExtraKey: true,
|
||||
service.UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
},
|
||||
})
|
||||
|
||||
inFlight, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
manual, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
manualRate := 0.8
|
||||
syncDisabled := false
|
||||
require.NoError(t, repo.UpdateWithAccountBillingSettings(ctx, manual, nil, &syncDisabled, &manualRate))
|
||||
|
||||
probedRate := 0.1
|
||||
err = repo.UpdateUpstreamBillingProbeSnapshot(ctx, inFlight, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusOK,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
}, &probedRate)
|
||||
require.ErrorIs(t, err, service.ErrUpstreamBillingProbeIdentityChanged)
|
||||
|
||||
got, err := repo.GetByID(ctx, account.ID)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, got.RateMultiplier)
|
||||
require.Equal(t, manualRate, *got.RateMultiplier)
|
||||
require.Equal(t, false, got.Extra[service.UpstreamBillingRateSyncEnabledExtraKey])
|
||||
require.NotContains(t, got.Extra, service.UpstreamBillingProbeExtraKey)
|
||||
}
|
||||
|
||||
func boolPtr(value bool) *bool {
|
||||
return &value
|
||||
}
|
||||
@@ -249,7 +398,7 @@ func TestProxyIdentityUpdateInvalidatesProbeAndRejectsInFlightSnapshot(t *testin
|
||||
err = accountRepo.UpdateUpstreamBillingProbeSnapshot(ctx, inFlight, &service.UpstreamBillingProbeSnapshot{
|
||||
Status: service.UpstreamBillingProbeStatusOK,
|
||||
LastAttemptAt: time.Now().UTC(),
|
||||
})
|
||||
}, nil)
|
||||
require.ErrorIs(t, err, service.ErrUpstreamBillingProbeIdentityChanged)
|
||||
|
||||
rows, err := tx.QueryContext(ctx, `
|
||||
|
||||
@@ -358,12 +358,18 @@ func (r *userSubscriptionRepository) UpdateNotes(ctx context.Context, subscripti
|
||||
|
||||
func (r *userSubscriptionRepository) ActivateWindows(ctx context.Context, id int64, start time.Time) error {
|
||||
client := clientFromContext(ctx, r.client)
|
||||
_, err := client.UserSubscription.UpdateOneID(id).
|
||||
n, err := client.UserSubscription.Update().
|
||||
Where(
|
||||
usersubscription.IDEQ(id),
|
||||
usersubscription.DailyWindowStartIsNil(),
|
||||
usersubscription.WeeklyWindowStartIsNil(),
|
||||
usersubscription.MonthlyWindowStartIsNil(),
|
||||
).
|
||||
SetDailyWindowStart(start).
|
||||
SetWeeklyWindowStart(start).
|
||||
SetMonthlyWindowStart(start).
|
||||
Save(ctx)
|
||||
return translatePersistenceError(err, service.ErrSubscriptionNotFound, nil)
|
||||
return r.translateConditionalWindowReset(ctx, client, id, n, err)
|
||||
}
|
||||
|
||||
func (r *userSubscriptionRepository) ResetUsageWindows(ctx context.Context, id int64, resetDaily, resetWeekly, resetMonthly bool, newWindowStart time.Time) error {
|
||||
|
||||
@@ -463,6 +463,25 @@ func (s *UserSubscriptionRepoSuite) TestActivateWindows() {
|
||||
s.Require().WithinDuration(activateAt, *got.DailyWindowStart, time.Microsecond)
|
||||
}
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestActivateWindows_StaleActivationPreservesExistingWindows() {
|
||||
user := s.mustCreateUser("activate-cas@test.com", service.RoleUser)
|
||||
group := s.mustCreateGroup("g-activate-cas")
|
||||
sub := s.mustCreateSubscription(user.ID, group.ID, nil)
|
||||
activatedAt := time.Date(2025, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
manualResetAt := activatedAt.Add(2 * time.Hour)
|
||||
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt))
|
||||
s.Require().NoError(s.repo.ResetUsageWindows(s.ctx, sub.ID, true, true, true, manualResetAt))
|
||||
// Simulate a concurrent request carrying the original unactivated snapshot.
|
||||
s.Require().NoError(s.repo.ActivateWindows(s.ctx, sub.ID, activatedAt.Add(time.Hour)))
|
||||
|
||||
got, err := s.repo.GetByID(s.ctx, sub.ID)
|
||||
s.Require().NoError(err)
|
||||
s.Require().WithinDuration(manualResetAt, *got.DailyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualResetAt, *got.WeeklyWindowStart, time.Microsecond)
|
||||
s.Require().WithinDuration(manualResetAt, *got.MonthlyWindowStart, time.Microsecond)
|
||||
}
|
||||
|
||||
func (s *UserSubscriptionRepoSuite) TestResetDailyUsage() {
|
||||
user := s.mustCreateUser("resetd@test.com", service.RoleUser)
|
||||
group := s.mustCreateGroup("g-resetd")
|
||||
|
||||
@@ -64,20 +64,21 @@ type StorageEndpoint struct {
|
||||
}
|
||||
|
||||
type storageConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []StorageEndpoint `json:"endpoints"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy int64 `json:"updated_by"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []StorageEndpoint `json:"endpoints"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy int64 `json:"updated_by"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
}
|
||||
|
||||
type ActiveEndpoint struct {
|
||||
@@ -98,21 +99,22 @@ type ActiveEndpoint struct {
|
||||
}
|
||||
|
||||
type ActiveConfig struct {
|
||||
RiskControlEnabled bool
|
||||
Enabled bool
|
||||
BlockingEnabled bool
|
||||
StorePassEvents bool
|
||||
Strategy string
|
||||
WorkerCount int
|
||||
QueueCapacity int
|
||||
Scanners []string
|
||||
AllGroups bool
|
||||
GroupIDs []int64
|
||||
Endpoints []ActiveEndpoint
|
||||
ConfigVersion int64
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy int64
|
||||
ChangeSummary string
|
||||
RiskControlEnabled bool
|
||||
Enabled bool
|
||||
BlockingEnabled bool
|
||||
BlockingLatestTurnOnly bool
|
||||
StorePassEvents bool
|
||||
Strategy string
|
||||
WorkerCount int
|
||||
QueueCapacity int
|
||||
Scanners []string
|
||||
AllGroups bool
|
||||
GroupIDs []int64
|
||||
Endpoints []ActiveEndpoint
|
||||
ConfigVersion int64
|
||||
UpdatedAt time.Time
|
||||
UpdatedBy int64
|
||||
ChangeSummary string
|
||||
}
|
||||
|
||||
type PublicEndpoint struct {
|
||||
@@ -129,21 +131,22 @@ type PublicEndpoint struct {
|
||||
}
|
||||
|
||||
type PublicConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
EffectiveMode Mode `json:"effective_mode"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []PublicEndpoint `json:"endpoints"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy int64 `json:"updated_by"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
EffectiveMode Mode `json:"effective_mode"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []PublicEndpoint `json:"endpoints"`
|
||||
ConfigVersion int64 `json:"config_version"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
UpdatedBy int64 `json:"updated_by"`
|
||||
ChangeSummary string `json:"change_summary"`
|
||||
}
|
||||
|
||||
type UpdateEndpoint struct {
|
||||
@@ -160,32 +163,34 @@ type UpdateEndpoint struct {
|
||||
}
|
||||
|
||||
type UpdateConfigRequest struct {
|
||||
ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []UpdateEndpoint `json:"endpoints"`
|
||||
ExpectedConfigVersion int64 `json:"expected_config_version" binding:"required"`
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
Strategy string `json:"strategy"`
|
||||
WorkerCount int `json:"worker_count"`
|
||||
QueueCapacity int `json:"queue_capacity"`
|
||||
Scanners []string `json:"scanners"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupIDs []int64 `json:"group_ids"`
|
||||
Endpoints []UpdateEndpoint `json:"endpoints"`
|
||||
}
|
||||
|
||||
func DefaultStorageConfig() storageConfig {
|
||||
return storageConfig{
|
||||
Enabled: false,
|
||||
BlockingEnabled: false,
|
||||
StorePassEvents: false,
|
||||
Strategy: "priority",
|
||||
WorkerCount: DefaultWorkerCount,
|
||||
QueueCapacity: DefaultQueueCapacity,
|
||||
Scanners: append([]string(nil), AllScannerIDs...),
|
||||
AllGroups: true,
|
||||
GroupIDs: []int64{},
|
||||
Endpoints: []StorageEndpoint{},
|
||||
ConfigVersion: 1,
|
||||
Enabled: false,
|
||||
BlockingEnabled: false,
|
||||
BlockingLatestTurnOnly: false,
|
||||
StorePassEvents: false,
|
||||
Strategy: "priority",
|
||||
WorkerCount: DefaultWorkerCount,
|
||||
QueueCapacity: DefaultQueueCapacity,
|
||||
Scanners: append([]string(nil), AllScannerIDs...),
|
||||
AllGroups: true,
|
||||
GroupIDs: []int64{},
|
||||
Endpoints: []StorageEndpoint{},
|
||||
ConfigVersion: 1,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -407,7 +412,7 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenE
|
||||
}
|
||||
active := ActiveConfig{RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled}
|
||||
return PublicConfig{
|
||||
Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, StorePassEvents: cfg.StorePassEvents,
|
||||
Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled, BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly, StorePassEvents: cfg.StorePassEvents,
|
||||
EffectiveMode: active.EffectiveMode(), Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
|
||||
QueueCapacity: cfg.QueueCapacity, Scanners: scanners, AllGroups: cfg.AllGroups,
|
||||
GroupIDs: groupIDs, Endpoints: endpoints, ConfigVersion: cfg.ConfigVersion,
|
||||
@@ -418,7 +423,8 @@ func PublicFromStorage(cfg storageConfig, riskControlEnabled bool, invalidTokenE
|
||||
func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor SecretEncryptor) (ActiveConfig, error) {
|
||||
active := ActiveConfig{
|
||||
RiskControlEnabled: riskControlEnabled, Enabled: cfg.Enabled, BlockingEnabled: cfg.BlockingEnabled,
|
||||
StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
|
||||
BlockingLatestTurnOnly: cfg.BlockingLatestTurnOnly,
|
||||
StorePassEvents: cfg.StorePassEvents, Strategy: cfg.Strategy, WorkerCount: cfg.WorkerCount,
|
||||
QueueCapacity: cfg.QueueCapacity, Scanners: append([]string(nil), cfg.Scanners...), AllGroups: cfg.AllGroups,
|
||||
GroupIDs: append([]int64(nil), cfg.GroupIDs...), ConfigVersion: cfg.ConfigVersion,
|
||||
UpdatedAt: cfg.UpdatedAt, UpdatedBy: cfg.UpdatedBy, ChangeSummary: cfg.ChangeSummary,
|
||||
@@ -454,15 +460,16 @@ func ActiveFromStorage(cfg storageConfig, riskControlEnabled bool, encryptor Sec
|
||||
|
||||
func changeSummary(cfg storageConfig) string {
|
||||
summary := struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
EndpointCount int `json:"endpoint_count"`
|
||||
ScannerCount int `json:"scanner_count"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupCount int `json:"group_count"`
|
||||
GroupHash string `json:"group_hash"`
|
||||
}{cfg.Enabled, cfg.BlockingEnabled, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""}
|
||||
Enabled bool `json:"enabled"`
|
||||
BlockingEnabled bool `json:"blocking_enabled"`
|
||||
BlockingLatestTurnOnly bool `json:"blocking_latest_turn_only"`
|
||||
StorePassEvents bool `json:"store_pass_events"`
|
||||
EndpointCount int `json:"endpoint_count"`
|
||||
ScannerCount int `json:"scanner_count"`
|
||||
AllGroups bool `json:"all_groups"`
|
||||
GroupCount int `json:"group_count"`
|
||||
GroupHash string `json:"group_hash"`
|
||||
}{cfg.Enabled, cfg.BlockingEnabled, cfg.BlockingLatestTurnOnly, cfg.StorePassEvents, len(cfg.Endpoints), len(cfg.Scanners), cfg.AllGroups, len(cfg.GroupIDs), ""}
|
||||
rawGroups, _ := json.Marshal(cfg.GroupIDs)
|
||||
digest := sha256.Sum256(rawGroups)
|
||||
summary.GroupHash = hex.EncodeToString(digest[:])
|
||||
|
||||
@@ -335,7 +335,7 @@ func (m *ConfigManager) buildNextStorage(current storageConfig, req UpdateConfig
|
||||
currentByID[endpoint.ID] = endpoint
|
||||
}
|
||||
next := storageConfig{
|
||||
Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, StorePassEvents: req.StorePassEvents,
|
||||
Enabled: req.Enabled, BlockingEnabled: req.BlockingEnabled, BlockingLatestTurnOnly: req.BlockingLatestTurnOnly, StorePassEvents: req.StorePassEvents,
|
||||
Strategy: strings.TrimSpace(req.Strategy), WorkerCount: req.WorkerCount,
|
||||
QueueCapacity: req.QueueCapacity, Scanners: append([]string(nil), req.Scanners...),
|
||||
AllGroups: req.AllGroups, GroupIDs: append([]int64(nil), req.GroupIDs...),
|
||||
|
||||
@@ -32,6 +32,7 @@ func TestDefaultConfigIsOff(t *testing.T) {
|
||||
storage, err := ParseStorageConfig("")
|
||||
require.NoError(t, err)
|
||||
require.False(t, storage.Enabled)
|
||||
require.False(t, storage.BlockingLatestTurnOnly)
|
||||
active, err := ActiveFromStorage(storage, true, prefixEncryptor{})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, ModeOff, active.EffectiveMode())
|
||||
@@ -42,6 +43,28 @@ func TestDefaultConfigIsOff(t *testing.T) {
|
||||
require.Contains(t, string(publicJSON), `"endpoints":[]`)
|
||||
}
|
||||
|
||||
func TestBlockingLatestTurnOnlyConfigRoundTrip(t *testing.T) {
|
||||
manager := &ConfigManager{encryptor: prefixEncryptor{}, encryptionKeyConfigured: true}
|
||||
request := UpdateConfigRequest{
|
||||
ExpectedConfigVersion: 1, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true,
|
||||
Strategy: "priority", WorkerCount: 1, QueueCapacity: 10, Scanners: []string{"pii"}, AllGroups: true,
|
||||
Endpoints: []UpdateEndpoint{{
|
||||
ID: "guard-1", Name: "Guard", Protocol: "openai_compatible", BaseURL: "http://127.0.0.1:8080",
|
||||
Model: DefaultGuardModel, TimeoutMS: 1000, InputLimit: 1000, Enabled: true,
|
||||
}},
|
||||
}
|
||||
next, err := manager.buildNextStorage(DefaultStorageConfig(), request, 9)
|
||||
require.NoError(t, err)
|
||||
require.True(t, next.BlockingLatestTurnOnly)
|
||||
require.Contains(t, changeSummary(next), `"blocking_latest_turn_only":true`)
|
||||
|
||||
active, err := ActiveFromStorage(next, true, prefixEncryptor{})
|
||||
require.NoError(t, err)
|
||||
require.True(t, active.BlockingLatestTurnOnly)
|
||||
public := PublicFromStorage(next, true, nil)
|
||||
require.True(t, public.BlockingLatestTurnOnly)
|
||||
}
|
||||
|
||||
func TestConfigRejectsBlockingWithoutAudit(t *testing.T) {
|
||||
storage := DefaultStorageConfig()
|
||||
storage.BlockingEnabled = true
|
||||
|
||||
@@ -228,7 +228,8 @@ func configAuditFields(request UpdateConfigRequest, saved *PublicConfig) map[str
|
||||
}
|
||||
return map[string]any{
|
||||
"enabled": request.Enabled, "blocking_enabled": request.BlockingEnabled,
|
||||
"config_version": version, "endpoint_count": len(request.Endpoints),
|
||||
"blocking_latest_turn_only": request.BlockingLatestTurnOnly,
|
||||
"config_version": version, "endpoint_count": len(request.Endpoints),
|
||||
"scanner_count": len(request.Scanners), "all_groups": request.AllGroups,
|
||||
"group_count": len(request.GroupIDs),
|
||||
}
|
||||
|
||||
@@ -156,7 +156,7 @@ func (s *PromptService) Evaluate(ctx context.Context, req Request) (*PromptDecis
|
||||
if cfg.EffectiveMode() != ModeBlocking || !cfg.IncludesGroup(req.GroupID) {
|
||||
return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
|
||||
}
|
||||
snapshot, err := ExtractPromptSnapshot(req)
|
||||
snapshot, err := ExtractBlockingPromptSnapshot(req, cfg.BlockingLatestTurnOnly)
|
||||
if errors.Is(err, ErrNoPromptText) {
|
||||
return &PromptDecision{Kind: DecisionAllow, AllowNextStage: true}, nil
|
||||
}
|
||||
|
||||
@@ -68,6 +68,25 @@ func TestPromptServiceStartReportsDependencyFailureWithoutPanic(t *testing.T) {
|
||||
require.NoError(t, service.Shutdown(ctx))
|
||||
}
|
||||
|
||||
func TestPromptServiceBlockingLatestTurnOnlyUsesNarrowSnapshot(t *testing.T) {
|
||||
seen := make([]string, 0, 2)
|
||||
evaluator := newGuardEvaluator(PromptScannerFunc(func(_ context.Context, _ ActiveEndpoint, chunk string, _ []string) (*NormalizedResult, error) {
|
||||
seen = append(seen, chunk)
|
||||
return &NormalizedResult{Decision: EventPass, RiskLevel: RiskLow, Action: ActionAllow, ScannerScores: map[string]float64{}, ScannerEvidence: map[string]string{}}, nil
|
||||
}), nil, NewAtomicMetrics(), 2, 2)
|
||||
service := &PromptService{
|
||||
config: &fakeConfigStore{active: true, cfg: ActiveConfig{
|
||||
RiskControlEnabled: true, Enabled: true, BlockingEnabled: true, BlockingLatestTurnOnly: true, AllGroups: true,
|
||||
Scanners: AllScannerIDs, Endpoints: []ActiveEndpoint{{ID: "guard-1", Enabled: true, TimeoutMS: 1000, InputLimit: 4096}},
|
||||
}},
|
||||
evaluator: evaluator,
|
||||
}
|
||||
decision, err := service.Evaluate(context.Background(), Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, DecisionAllow, decision.Kind)
|
||||
require.Equal(t, []string{"latest user input", "previous output"}, seen)
|
||||
}
|
||||
|
||||
func TestPromptServiceRejectsInvalidDeleteConfirmationClaims(t *testing.T) {
|
||||
now := time.Date(2026, 7, 16, 10, 0, 0, 0, time.UTC)
|
||||
start, end := now.Add(-time.Hour), now.Add(time.Hour)
|
||||
|
||||
@@ -26,15 +26,30 @@ const promptAuditPrioritySeparator = "\x00SUB2API_PROMPT_AUDIT_PRIORITY_END\x00"
|
||||
type promptSegment struct {
|
||||
text string
|
||||
user bool
|
||||
role string
|
||||
}
|
||||
|
||||
func ExtractPromptSnapshot(req Request) (PromptSnapshot, error) {
|
||||
return extractPromptSnapshot(req, false)
|
||||
}
|
||||
|
||||
// ExtractBlockingPromptSnapshot builds the narrow, low-latency blocking input
|
||||
// when configured. Asynchronous auditing always uses ExtractPromptSnapshot so
|
||||
// the complete client-controlled transcript is retained for review.
|
||||
func ExtractBlockingPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) {
|
||||
return extractPromptSnapshot(req, latestTurnOnly)
|
||||
}
|
||||
|
||||
func extractPromptSnapshot(req Request, latestTurnOnly bool) (PromptSnapshot, error) {
|
||||
var document any
|
||||
if err := json.Unmarshal(req.Body, &document); err != nil {
|
||||
return PromptSnapshot{}, errors.New("prompt audit request JSON is invalid")
|
||||
}
|
||||
extracted := extractProtocolSegments(req.Protocol, document)
|
||||
segments := normalizeSegmentsLatestUserFirst(extracted)
|
||||
if latestTurnOnly {
|
||||
segments = blockingSegmentsLatestUserAndPreviousOutput(extracted)
|
||||
}
|
||||
if len(segments) == 0 {
|
||||
return PromptSnapshot{}, ErrNoPromptText
|
||||
}
|
||||
@@ -138,7 +153,7 @@ func extractMessages(value any, wantedRoles ...string) []promptSegment {
|
||||
}
|
||||
texts := contentTexts(message["content"])
|
||||
for _, text := range texts {
|
||||
result = append(result, promptSegment{text: text, user: role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "user", role: role})
|
||||
}
|
||||
}
|
||||
return result
|
||||
@@ -148,7 +163,7 @@ func extractInstructions(value any) []promptSegment {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return []promptSegment{{text: text}}
|
||||
return []promptSegment{{text: text, role: "system"}}
|
||||
}
|
||||
case []any:
|
||||
return systemPromptSegments(contentTexts(typed))
|
||||
@@ -162,7 +177,7 @@ func extractAnthropicSystem(value any) []promptSegment {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return []promptSegment{{text: text}}
|
||||
return []promptSegment{{text: text, role: "system"}}
|
||||
}
|
||||
case []any:
|
||||
return systemPromptSegments(contentTexts(typed))
|
||||
@@ -175,13 +190,13 @@ func extractAnthropicSystem(value any) []promptSegment {
|
||||
func extractResponses(value any) []promptSegment {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
return []promptSegment{{text: typed, user: true}}
|
||||
return []promptSegment{{text: typed, user: true, role: "user"}}
|
||||
case []any:
|
||||
result := make([]promptSegment, 0, len(typed))
|
||||
for _, item := range typed {
|
||||
switch entry := item.(type) {
|
||||
case string:
|
||||
result = append(result, promptSegment{text: entry, user: true})
|
||||
result = append(result, promptSegment{text: entry, user: true, role: "user"})
|
||||
case map[string]any:
|
||||
role := strings.ToLower(stringValue(entry["role"]))
|
||||
if role != "" && !isClientInstructionRole(role) {
|
||||
@@ -189,10 +204,10 @@ func extractResponses(value any) []promptSegment {
|
||||
}
|
||||
if content, exists := entry["content"]; exists {
|
||||
for _, text := range contentTexts(content) {
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
|
||||
}
|
||||
} else if text := stringValue(entry["text"]); text != "" {
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -241,7 +256,7 @@ func extractGemini(value any) []promptSegment {
|
||||
for _, part := range parts {
|
||||
if object, ok := part.(map[string]any); ok {
|
||||
if text := stringValue(object["text"]); text != "" {
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -278,7 +293,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment {
|
||||
switch typed := value.(type) {
|
||||
case string:
|
||||
if text := strings.TrimSpace(typed); text != "" {
|
||||
return []promptSegment{{text: text}}
|
||||
return []promptSegment{{text: text, role: "system"}}
|
||||
}
|
||||
case map[string]any:
|
||||
if parts, ok := typed["parts"].([]any); ok {
|
||||
@@ -286,7 +301,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment {
|
||||
for _, part := range parts {
|
||||
if object, ok := part.(map[string]any); ok {
|
||||
if text := stringValue(object["text"]); text != "" {
|
||||
result = append(result, promptSegment{text: text})
|
||||
result = append(result, promptSegment{text: text, role: "system"})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -297,6 +312,7 @@ func extractGeminiSystemInstruction(value any) []promptSegment {
|
||||
segments := extractGemini(typed)
|
||||
for index := range segments {
|
||||
segments[index].user = false
|
||||
segments[index].role = "system"
|
||||
}
|
||||
return segments
|
||||
}
|
||||
@@ -312,7 +328,7 @@ func extractGeminiInstances(value any) []promptSegment {
|
||||
for _, item := range instances {
|
||||
if instance, ok := item.(map[string]any); ok {
|
||||
if prompt := stringValue(instance["prompt"]); prompt != "" {
|
||||
result = append(result, promptSegment{text: prompt, user: true})
|
||||
result = append(result, promptSegment{text: prompt, user: true, role: "user"})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -420,19 +436,13 @@ func contentTexts(value any) []string {
|
||||
}
|
||||
|
||||
func normalizeSegmentsLatestUserFirst(values []promptSegment) []string {
|
||||
normalized := make([]promptSegment, 0, len(values))
|
||||
for _, value := range values {
|
||||
value.text = strings.TrimSpace(value.text)
|
||||
if value.text != "" {
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
}
|
||||
normalized := normalizedPromptSegments(values)
|
||||
if len(normalized) == 0 {
|
||||
return nil
|
||||
}
|
||||
priorityIndex := len(normalized) - 1
|
||||
for index := len(normalized) - 1; index >= 0; index-- {
|
||||
if normalized[index].user {
|
||||
if isUserSegment(normalized[index]) {
|
||||
priorityIndex = index
|
||||
break
|
||||
}
|
||||
@@ -447,6 +457,85 @@ func normalizeSegmentsLatestUserFirst(values []promptSegment) []string {
|
||||
return result
|
||||
}
|
||||
|
||||
// blockingSegmentsLatestUserAndPreviousOutput limits synchronous guard input to
|
||||
// the current user turn and the nearest preceding assistant/model turn. It is
|
||||
// deliberately opt-in because full transcript scanning remains stronger at
|
||||
// finding client-controlled content placed in older or non-user messages.
|
||||
func blockingSegmentsLatestUserAndPreviousOutput(values []promptSegment) []string {
|
||||
normalized := normalizedPromptSegments(values)
|
||||
latestUserStart := latestUserSegmentStart(normalized)
|
||||
if latestUserStart < 0 {
|
||||
// A request without user content cannot be narrowed safely. Preserve the
|
||||
// established full-snapshot behavior for unusual protocol payloads.
|
||||
return normalizeSegmentsLatestUserFirst(values)
|
||||
}
|
||||
latestUserEnd := latestUserStart
|
||||
for latestUserEnd < len(normalized) && isUserSegment(normalized[latestUserEnd]) {
|
||||
latestUserEnd++
|
||||
}
|
||||
currentUserText := make([]string, 0, latestUserEnd-latestUserStart)
|
||||
for _, segment := range normalized[latestUserStart:latestUserEnd] {
|
||||
currentUserText = append(currentUserText, segment.text)
|
||||
}
|
||||
// A single client turn may have several text content parts. Keep it in one
|
||||
// priority segment so every part of the latest input is scanned before the
|
||||
// prior output begins.
|
||||
selected := []promptSegment{{text: strings.Join(currentUserText, "\n\n"), user: true, role: "user"}}
|
||||
for index := latestUserStart - 1; index >= 0; index-- {
|
||||
if !isAssistantOutputSegment(normalized[index]) {
|
||||
continue
|
||||
}
|
||||
start := index
|
||||
for start > 0 && isAssistantOutputSegment(normalized[start-1]) {
|
||||
start--
|
||||
}
|
||||
selected = append(selected, normalized[start:index+1]...)
|
||||
break
|
||||
}
|
||||
return promptSegmentTexts(selected)
|
||||
}
|
||||
|
||||
func normalizedPromptSegments(values []promptSegment) []promptSegment {
|
||||
normalized := make([]promptSegment, 0, len(values))
|
||||
for _, value := range values {
|
||||
value.text = strings.TrimSpace(value.text)
|
||||
if value.text != "" {
|
||||
normalized = append(normalized, value)
|
||||
}
|
||||
}
|
||||
return normalized
|
||||
}
|
||||
|
||||
func latestUserSegmentStart(values []promptSegment) int {
|
||||
latest := -1
|
||||
for index := len(values) - 1; index >= 0; index-- {
|
||||
if isUserSegment(values[index]) {
|
||||
latest = index
|
||||
break
|
||||
}
|
||||
}
|
||||
for latest > 0 && isUserSegment(values[latest-1]) {
|
||||
latest--
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func isUserSegment(segment promptSegment) bool {
|
||||
return segment.user || segment.role == "user"
|
||||
}
|
||||
|
||||
func isAssistantOutputSegment(segment promptSegment) bool {
|
||||
return segment.role == "assistant" || segment.role == "model"
|
||||
}
|
||||
|
||||
func promptSegmentTexts(values []promptSegment) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
for _, value := range values {
|
||||
result = append(result, value.text)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func buildPrioritizedScanText(segments []string) (scanText string, metadataText string) {
|
||||
metadataText = strings.Join(segments, "\n\n")
|
||||
if len(segments) <= 1 {
|
||||
@@ -458,7 +547,7 @@ func buildPrioritizedScanText(segments []string) (scanText string, metadataText
|
||||
func promptSegmentsForRole(texts []string, role string) []promptSegment {
|
||||
result := make([]promptSegment, 0, len(texts))
|
||||
for _, text := range texts {
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user"})
|
||||
result = append(result, promptSegment{text: text, user: role == "" || role == "user", role: role})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
@@ -293,6 +293,65 @@ func TestPromptSnapshotIncludesClientControlledInstructions(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingPromptSnapshotLimitsInputToLatestUserAndPreviousOutput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name, protocol, body, want string
|
||||
omitted []string
|
||||
}{
|
||||
{
|
||||
name: "chat keeps multipart latest user and prior assistant",
|
||||
protocol: "openai_chat_completions",
|
||||
body: `{"messages":[
|
||||
{"role":"system","content":"system instruction"},
|
||||
{"role":"user","content":"older user input"},
|
||||
{"role":"assistant","content":"older assistant output"},
|
||||
{"role":"tool","content":"tool payload"},
|
||||
{"role":"assistant","content":"previous assistant output"},
|
||||
{"role":"user","content":[{"type":"text","text":"latest user first part"},{"type":"text","text":"latest user second part"}]}
|
||||
]}`,
|
||||
want: "latest user first part\n\nlatest user second part" + promptAuditPrioritySeparator + "previous assistant output",
|
||||
omitted: []string{"system instruction", "older user input", "older assistant output", "tool payload"},
|
||||
},
|
||||
{
|
||||
name: "gemini keeps prior model output",
|
||||
protocol: "gemini",
|
||||
body: `{"systemInstruction":{"parts":[{"text":"system instruction"}]},"contents":[
|
||||
{"role":"user","parts":[{"text":"older user input"}]},
|
||||
{"role":"model","parts":[{"text":"previous model output"}]},
|
||||
{"role":"user","parts":[{"text":"latest user input"}]}
|
||||
]}`,
|
||||
want: "latest user input" + promptAuditPrioritySeparator + "previous model output",
|
||||
omitted: []string{"system instruction", "older user input"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
snapshot, err := ExtractBlockingPromptSnapshot(Request{Protocol: tt.protocol, Body: []byte(tt.body)}, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.want, snapshot.ScanText)
|
||||
for _, omitted := range tt.omitted {
|
||||
require.NotContains(t, snapshot.ScanText, omitted)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockingPromptSnapshotPreservesFullScopeByDefaultAndWithoutUserInput(t *testing.T) {
|
||||
req := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"user","content":"older user input"},{"role":"assistant","content":"previous output"},{"role":"user","content":"latest user input"}]}`)}
|
||||
full, err := ExtractPromptSnapshot(req)
|
||||
require.NoError(t, err)
|
||||
defaultBlocking, err := ExtractBlockingPromptSnapshot(req, false)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, full, defaultBlocking)
|
||||
|
||||
noUser := Request{Protocol: "openai_chat_completions", Body: []byte(`{"messages":[{"role":"system","content":"system instruction"},{"role":"assistant","content":"assistant output"}]}`)}
|
||||
fullWithoutUser, err := ExtractPromptSnapshot(noUser)
|
||||
require.NoError(t, err)
|
||||
narrowWithoutUser, err := ExtractBlockingPromptSnapshot(noUser, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, fullWithoutUser, narrowWithoutUser)
|
||||
}
|
||||
|
||||
func TestBuildPromptPreviewWithholdsMajorityOfOrdinaryText(t *testing.T) {
|
||||
prompt := strings.Repeat("机密业务提示词内容", 40)
|
||||
preview := BuildPromptPreview(prompt, DefaultPromptPreviewMaxRunes)
|
||||
|
||||
@@ -314,19 +314,23 @@ func TestAPIContracts(t *testing.T) {
|
||||
name: "GET /api/v1/groups/available",
|
||||
setup: func(t *testing.T, deps *contractDeps) {
|
||||
t.Helper()
|
||||
// 普通用户可见的分组列表不应包含内部字段(如 model_routing/account_count)。
|
||||
// 普通用户可见的分组列表不应包含内部字段(如 model_routing/account_count),
|
||||
// 也不得包含利润控制配置——它与同响应的 rate_multiplier 相乘即可反推上游成本上限。
|
||||
deps.groupRepo.SetActive([]service.Group{
|
||||
{
|
||||
ID: 10,
|
||||
Name: "Group One",
|
||||
Description: "desc",
|
||||
Platform: service.PlatformAnthropic,
|
||||
RateMultiplier: 1.5,
|
||||
PeakRateMultiplier: 1.0,
|
||||
IsExclusive: false,
|
||||
Status: service.StatusActive,
|
||||
SubscriptionType: service.SubscriptionTypeStandard,
|
||||
ModelRoutingEnabled: true,
|
||||
ID: 10,
|
||||
Name: "Group One",
|
||||
Description: "desc",
|
||||
Platform: service.PlatformAnthropic,
|
||||
RateMultiplier: 1.5,
|
||||
PeakRateMultiplier: 1.0,
|
||||
IsExclusive: false,
|
||||
Status: service.StatusActive,
|
||||
SubscriptionType: service.SubscriptionTypeStandard,
|
||||
ProfitControlEnabled: true,
|
||||
ProfitMinMargin: 0.3,
|
||||
ProfitSafetyBuffer: 0.05,
|
||||
ModelRoutingEnabled: true,
|
||||
ModelRouting: map[string][]int64{
|
||||
"claude-3-*": []int64{101, 102},
|
||||
},
|
||||
@@ -874,6 +878,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"max_codex_version": "",
|
||||
"codex_cli_only_blacklist": "",
|
||||
"codex_cli_only_whitelist": "",
|
||||
"compact_home_enabled": false,
|
||||
"codex_cli_only_allow_app_server_clients": false,
|
||||
"codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]",
|
||||
"allow_ungrouped_key_scheduling": false,
|
||||
@@ -1175,6 +1180,7 @@ func TestAPIContracts(t *testing.T) {
|
||||
"max_codex_version": "",
|
||||
"codex_cli_only_blacklist": "",
|
||||
"codex_cli_only_whitelist": "",
|
||||
"compact_home_enabled": false,
|
||||
"codex_cli_only_allow_app_server_clients": false,
|
||||
"codex_cli_only_engine_fingerprint_signals": "[{\"type\":\"header_prefix\",\"match\":[\"x-codex-\"],\"required\":true},{\"type\":\"header_exact\",\"match\":[\"session-id\",\"session_id\"],\"required\":false},{\"type\":\"header_exact\",\"match\":[\"thread-id\",\"thread_id\"],\"required\":false},{\"type\":\"body_path\",\"match\":[\"client_metadata.x-codex-window-id\",\"client_metadata.x-codex-installation-id\"],\"required\":false}]",
|
||||
"web_search_emulation_enabled": false,
|
||||
@@ -1817,6 +1823,16 @@ func (s *stubAccountRepo) Update(ctx context.Context, account *service.Account)
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *stubAccountRepo) UpdateWithAccountBillingSettings(
|
||||
ctx context.Context,
|
||||
account *service.Account,
|
||||
probeEnabled *bool,
|
||||
rateSyncEnabled *bool,
|
||||
rateMultiplier *float64,
|
||||
) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
func (s *stubAccountRepo) Delete(ctx context.Context, id int64) error {
|
||||
return errors.New("not implemented")
|
||||
}
|
||||
|
||||
@@ -395,6 +395,7 @@ func registerAccountRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAu
|
||||
accounts.POST("/batch-update-credentials", h.Admin.Account.BatchUpdateCredentials)
|
||||
accounts.POST("/batch-refresh-tier", h.Admin.Account.BatchRefreshTier)
|
||||
accounts.POST("/bulk-update", h.Admin.Account.BulkUpdate)
|
||||
accounts.POST("/batch-delete", h.Admin.Account.BatchDelete)
|
||||
accounts.POST("/batch-clear-error", h.Admin.Account.BatchClearError)
|
||||
accounts.POST("/batch-refresh", h.Admin.Account.BatchRefresh)
|
||||
|
||||
|
||||
@@ -1844,6 +1844,22 @@ func (a *Account) IsOpenAIWSForceHTTPEnabled() bool {
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsOpenAIResponsesFlattenNamespacesEnabled 返回账号级"摊平 Codex namespace 工具"开关。
|
||||
// 字段:accounts.extra.openai_responses_flatten_namespaces,缺省 false(原样保留)。
|
||||
//
|
||||
// namespace 是 Codex 后端定义的私有扩展,OAuth 出口恒为 chatgpt.com/backend-api/codex
|
||||
// (buildUpstreamRequest 只对 API Key 账号取 base_url),即定义方本身,因此默认保留。
|
||||
// 该开关只为把流量转发到不认识 namespace 的兼容上游的部署保留退路:打开后恢复
|
||||
// 0.1.166 及更早版本的摊平行为。仅对 OpenAI OAuth 账号有效——API Key 走 chat
|
||||
// completions 回退桥时由桥自行摊平,Grok/Anthropic 出口有各自的适配链路。
|
||||
func (a *Account) IsOpenAIResponsesFlattenNamespacesEnabled() bool {
|
||||
if a == nil || !a.IsOpenAI() || a.Extra == nil {
|
||||
return false
|
||||
}
|
||||
enabled, ok := a.Extra["openai_responses_flatten_namespaces"].(bool)
|
||||
return ok && enabled
|
||||
}
|
||||
|
||||
// IsOpenAIWSAllowStoreRecoveryEnabled 返回账号级 store 恢复开关。
|
||||
// 字段:accounts.extra.openai_ws_allow_store_recovery。
|
||||
func (a *Account) IsOpenAIWSAllowStoreRecoveryEnabled() bool {
|
||||
|
||||
@@ -131,11 +131,25 @@ type AccountDuplicateRepository interface {
|
||||
CreateWithAccountGroups(ctx context.Context, account *Account, groups []AccountGroup) error
|
||||
}
|
||||
|
||||
// AccountBillingSettingsRepository applies an admin edit without overwriting a
|
||||
// rate_multiplier that a successful upstream probe synchronized after the edit
|
||||
// form was loaded. A nil rateMultiplier means the request did not edit it.
|
||||
type AccountBillingSettingsRepository interface {
|
||||
UpdateWithAccountBillingSettings(
|
||||
ctx context.Context,
|
||||
account *Account,
|
||||
probeEnabled *bool,
|
||||
rateSyncEnabled *bool,
|
||||
rateMultiplier *float64,
|
||||
) error
|
||||
}
|
||||
|
||||
// AdminAccountRepository makes the account-duplication write capability an explicit
|
||||
// construction dependency without forcing read-only gateway test doubles to implement it.
|
||||
type AdminAccountRepository interface {
|
||||
AccountRepository
|
||||
AccountDuplicateRepository
|
||||
AccountBillingSettingsRepository
|
||||
}
|
||||
|
||||
// AccountBulkUpdate describes the fields that can be updated in a bulk operation.
|
||||
|
||||
@@ -767,8 +767,8 @@ func (s *AccountUsageService) probeOpenAICodexSnapshot(ctx context.Context, acco
|
||||
req.Header.Set("User-Agent", strings.TrimSpace(fp.UserAgent))
|
||||
}
|
||||
}
|
||||
// 与真实转发一致:originator 与最终 User-Agent(可能来自指纹缓存,如 codex-tui)首段配套,
|
||||
// 否则探针被上游 404(issue #3901)。
|
||||
// 与真实转发一致:originator 与最终 User-Agent(可能来自指纹缓存)首段配套,否则探针被上游
|
||||
// 404(issue #3901);缓存里的降载桶身份同样在此归一化,避免探针被回 server_is_overloaded。
|
||||
enforceCodexIdentityHeaders(req.Header)
|
||||
setOpenAIChatGPTAccountHeaders(req.Header, account)
|
||||
|
||||
|
||||
@@ -455,6 +455,7 @@ func normalizeGrokMediaEligibilityUpdateExtra(account *Account, input *UpdateAcc
|
||||
func buildAccountForCreate(input *CreateAccountInput, accountExtra map[string]any) (*Account, error) {
|
||||
// Probe/session state is system-managed. New accounts always start with automatic refresh disabled.
|
||||
delete(accountExtra, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(accountExtra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
delete(accountExtra, UpstreamBillingProbeExtraKey)
|
||||
delete(accountExtra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(accountExtra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
@@ -594,10 +595,6 @@ func (s *adminServiceImpl) CreateAccount(ctx context.Context, input *CreateAccou
|
||||
return account, nil
|
||||
}
|
||||
|
||||
type accountProbeEnabledAtomicUpdater interface {
|
||||
UpdateWithUpstreamBillingProbeEnabled(context.Context, *Account, bool) error
|
||||
}
|
||||
|
||||
func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *UpdateAccountInput) (*Account, error) {
|
||||
account, err := s.accountRepo.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
@@ -667,7 +664,8 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
}
|
||||
// Extra 使用 map:需要区分“未提供(nil)”与“显式清空({})”。
|
||||
// 关闭配额限制时前端会删除 quota_* 键并提交 extra:{},此时也必须落库。
|
||||
var requestedProbeEnabledUpdate *bool
|
||||
requestedProbeEnabledUpdate := input.ProbeEnabled
|
||||
requestedRateSyncEnabledUpdate := input.RateSyncEnabled
|
||||
if input.Extra != nil {
|
||||
requestedProbeEnabled, hasRequestedProbeEnabled := normalizedExtra[UpstreamBillingProbeEnabledExtraKey]
|
||||
if hasRequestedProbeEnabled {
|
||||
@@ -675,9 +673,13 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
if !ok {
|
||||
return nil, infraerrors.BadRequest("INVALID_UPSTREAM_BILLING_PROBE_ENABLED", "upstream_billing_probe_enabled must be a boolean")
|
||||
}
|
||||
if requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate != enabled {
|
||||
return nil, infraerrors.BadRequest("CONFLICTING_UPSTREAM_BILLING_PROBE_ENABLED", "conflicting upstream_billing_probe_enabled values")
|
||||
}
|
||||
requestedProbeEnabledUpdate = &enabled
|
||||
}
|
||||
delete(normalizedExtra, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(normalizedExtra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
delete(normalizedExtra, UpstreamBillingProbeExtraKey)
|
||||
delete(normalizedExtra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(normalizedExtra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
@@ -691,6 +693,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
"quota_weekly_start",
|
||||
grokBillingExtraKey,
|
||||
UpstreamBillingProbeEnabledExtraKey,
|
||||
UpstreamBillingRateSyncEnabledExtraKey,
|
||||
UpstreamBillingProbeExtraKey,
|
||||
OllamaCloudUsageSessionExtraKey,
|
||||
OllamaCloudUsageAutoRefreshExtraKey,
|
||||
@@ -700,13 +703,6 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
normalizedExtra[key] = v
|
||||
}
|
||||
}
|
||||
if hasRequestedProbeEnabled {
|
||||
if isUpstreamBillingProbeAccount(account) {
|
||||
normalizedExtra[UpstreamBillingProbeEnabledExtraKey] = requestedProbeEnabled
|
||||
} else {
|
||||
delete(normalizedExtra, UpstreamBillingProbeEnabledExtraKey)
|
||||
}
|
||||
}
|
||||
account.Extra = normalizedExtra
|
||||
if account.Platform == PlatformAntigravity && wasOveragesEnabled && !account.IsOveragesEnabled() {
|
||||
delete(account.Extra, "antigravity_credits_overages") // 清理旧版 overages 运行态
|
||||
@@ -726,6 +722,35 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
ComputeQuotaResetAt(account.Extra)
|
||||
NormalizeFixedQuotaWindows(account.Extra)
|
||||
}
|
||||
if requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate {
|
||||
if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
|
||||
return nil, infraerrors.BadRequest(
|
||||
"UPSTREAM_BILLING_RATE_SYNC_REQUIRES_PROBE",
|
||||
"upstream billing rate sync requires upstream billing probe",
|
||||
)
|
||||
}
|
||||
enabled := true
|
||||
requestedProbeEnabledUpdate = &enabled
|
||||
}
|
||||
if requestedProbeEnabledUpdate != nil && !*requestedProbeEnabledUpdate {
|
||||
disabled := false
|
||||
requestedRateSyncEnabledUpdate = &disabled
|
||||
}
|
||||
if (requestedProbeEnabledUpdate != nil && *requestedProbeEnabledUpdate) ||
|
||||
(requestedRateSyncEnabledUpdate != nil && *requestedRateSyncEnabledUpdate) {
|
||||
if !isUpstreamBillingProbeAccount(account) {
|
||||
return nil, ErrUpstreamBillingProbeAccountInvalid
|
||||
}
|
||||
}
|
||||
if account.Extra == nil && (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) {
|
||||
account.Extra = make(map[string]any)
|
||||
}
|
||||
if requestedProbeEnabledUpdate != nil {
|
||||
account.Extra[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate
|
||||
}
|
||||
if requestedRateSyncEnabledUpdate != nil {
|
||||
account.Extra[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate
|
||||
}
|
||||
// 影子代理恒继承母账号(由 propagateProxyToShadows 同步),不接受独立编辑——外审 B/P1;
|
||||
// 否则要等母账号下次改 proxy 才被覆盖,期间影子会出现"有时继承、有时独立"的漂移。
|
||||
if input.ProxyID != nil && !account.IsCredentialShadow() {
|
||||
@@ -741,6 +766,7 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
delete(account.Extra, UpstreamBillingProbeExtraKey)
|
||||
if !isUpstreamBillingProbeAccount(account) {
|
||||
delete(account.Extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(account.Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
}
|
||||
}
|
||||
if account.Extra != nil {
|
||||
@@ -766,6 +792,13 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
if *input.RateMultiplier < 0 {
|
||||
return nil, errors.New("rate_multiplier must be >= 0")
|
||||
}
|
||||
// 同步开启时倍率归上游所有,手工值活不过下一次成功探测(表现为"改了又自己
|
||||
// 变回去"),与批量路径一样直接拒绝。判断的是本次请求生效后的状态:上面
|
||||
// 已把请求携带的两个开关落进 account.Extra,所以"同一请求关闭同步 + 改倍率"
|
||||
// (用户显式收回所有权)会走到这里时读到 false,正常放行。
|
||||
if upstreamBillingRateSyncEnabled(account) {
|
||||
return nil, ErrUpstreamBillingRateSyncConflict
|
||||
}
|
||||
account.RateMultiplier = input.RateMultiplier
|
||||
}
|
||||
if input.LoadFactor != nil {
|
||||
@@ -806,23 +839,40 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
}
|
||||
}
|
||||
|
||||
probeEnabledAppliedAtomically := false
|
||||
if requestedProbeEnabledUpdate != nil && isUpstreamBillingProbeAccount(account) {
|
||||
if updater, ok := s.accountRepo.(accountProbeEnabledAtomicUpdater); ok {
|
||||
if err := updater.UpdateWithUpstreamBillingProbeEnabled(ctx, account, *requestedProbeEnabledUpdate); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
probeEnabledAppliedAtomically = true
|
||||
}
|
||||
billingSettingsAppliedAtomically := false
|
||||
updater := s.accountBillingRepo
|
||||
if updater == nil {
|
||||
// Unit tests and narrow internal callers may construct adminServiceImpl
|
||||
// directly; production wiring requires this capability through
|
||||
// AdminAccountRepository.
|
||||
updater, _ = s.accountRepo.(AccountBillingSettingsRepository)
|
||||
}
|
||||
if !probeEnabledAppliedAtomically {
|
||||
if updater != nil {
|
||||
if err := updater.UpdateWithAccountBillingSettings(
|
||||
ctx,
|
||||
account,
|
||||
requestedProbeEnabledUpdate,
|
||||
requestedRateSyncEnabledUpdate,
|
||||
input.RateMultiplier,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
billingSettingsAppliedAtomically = true
|
||||
}
|
||||
if !billingSettingsAppliedAtomically {
|
||||
if err := s.accountRepo.Update(ctx, account); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if requestedProbeEnabledUpdate != nil && isUpstreamBillingProbeAccount(account) {
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, map[string]any{
|
||||
UpstreamBillingProbeEnabledExtraKey: *requestedProbeEnabledUpdate,
|
||||
}); err != nil {
|
||||
if (requestedProbeEnabledUpdate != nil || requestedRateSyncEnabledUpdate != nil) &&
|
||||
isUpstreamBillingProbeAccount(account) {
|
||||
settings := make(map[string]any, 2)
|
||||
if requestedProbeEnabledUpdate != nil {
|
||||
settings[UpstreamBillingProbeEnabledExtraKey] = *requestedProbeEnabledUpdate
|
||||
}
|
||||
if requestedRateSyncEnabledUpdate != nil {
|
||||
settings[UpstreamBillingRateSyncEnabledExtraKey] = *requestedRateSyncEnabledUpdate
|
||||
}
|
||||
if err := s.accountRepo.UpdateExtra(ctx, account.ID, settings); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
@@ -854,6 +904,9 @@ func (s *adminServiceImpl) UpdateAccount(ctx context.Context, id int64, input *U
|
||||
// UpdateAccountExtra 仅对 Extra JSONB 做 key 级合并,避免覆盖其它运行态键
|
||||
// (如 model_rate_limits / passive_usage_* 等)。
|
||||
func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, updates map[string]any) error {
|
||||
delete(updates, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(updates, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
delete(updates, UpstreamBillingProbeExtraKey)
|
||||
delete(updates, OllamaCloudUsageSessionExtraKey)
|
||||
delete(updates, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
delete(updates, OllamaCloudUsageSnapshotExtraKey)
|
||||
@@ -877,6 +930,7 @@ func (s *adminServiceImpl) UpdateAccountExtra(ctx context.Context, id int64, upd
|
||||
func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUpdateAccountsInput) (*BulkUpdateAccountsResult, error) {
|
||||
// Managed probe/session state may only enter through dedicated typed endpoints.
|
||||
delete(input.Extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
delete(input.Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
delete(input.Extra, UpstreamBillingProbeExtraKey)
|
||||
delete(input.Extra, OllamaCloudUsageSessionExtraKey)
|
||||
delete(input.Extra, OllamaCloudUsageAutoRefreshExtraKey)
|
||||
@@ -910,7 +964,7 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
||||
|
||||
// 预取所有目标账号,供凭据守卫/代理守卫/混合渠道检查共用,避免多次 DB 查询。
|
||||
var cachedTargets []*Account
|
||||
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate || input.ProbeEnabled != nil {
|
||||
if len(input.Credentials) > 0 || input.ProxyID != nil || needMixedChannelCheck || hasLongContextBillingUpdate || input.ProbeEnabled != nil || input.RateMultiplier != nil {
|
||||
loaded, err := s.accountRepo.GetByIDs(ctx, input.AccountIDs)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -996,6 +1050,21 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
||||
if *input.RateMultiplier < 0 {
|
||||
return nil, errors.New("rate_multiplier must be >= 0")
|
||||
}
|
||||
syncEnabledCount := 0
|
||||
for _, account := range cachedTargets {
|
||||
if account == nil || account.Extra == nil {
|
||||
continue
|
||||
}
|
||||
enabled, _ := account.Extra[UpstreamBillingRateSyncEnabledExtraKey].(bool)
|
||||
if enabled {
|
||||
syncEnabledCount++
|
||||
}
|
||||
}
|
||||
if syncEnabledCount > 0 {
|
||||
return nil, ErrUpstreamBillingRateSyncBulkConflict.WithMetadata(map[string]string{
|
||||
"count": strconv.Itoa(syncEnabledCount),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 校验并规范化请求头覆写配置(批量路径为 JSONB 顶层 key 合并,直接校验增量即可)
|
||||
@@ -1014,6 +1083,9 @@ func (s *adminServiceImpl) BulkUpdateAccounts(ctx context.Context, input *BulkUp
|
||||
repoUpdates.Extra = make(map[string]any)
|
||||
}
|
||||
repoUpdates.Extra[UpstreamBillingProbeEnabledExtraKey] = *input.ProbeEnabled
|
||||
if !*input.ProbeEnabled {
|
||||
repoUpdates.Extra[UpstreamBillingRateSyncEnabledExtraKey] = false
|
||||
}
|
||||
}
|
||||
if updatesUpstreamBillingProbeIdentity(input.Credentials) || input.ProxyID != nil {
|
||||
if repoUpdates.Extra == nil {
|
||||
|
||||
@@ -17,6 +17,101 @@ func (r *upstreamBillingProbeAdminRepo) ListShadowsByParent(context.Context, int
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
type accountBillingSettingsAdminRepo struct {
|
||||
*upstreamBillingProbeAccountRepo
|
||||
concurrentRate *float64
|
||||
lastExplicitRate *float64
|
||||
updateCalls int
|
||||
}
|
||||
|
||||
func (r *accountBillingSettingsAdminRepo) UpdateWithAccountBillingSettings(
|
||||
_ context.Context,
|
||||
account *Account,
|
||||
probeEnabled *bool,
|
||||
rateSyncEnabled *bool,
|
||||
rateMultiplier *float64,
|
||||
) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
current := r.accounts[account.ID]
|
||||
if current == nil {
|
||||
return ErrAccountNotFound
|
||||
}
|
||||
updated := *account
|
||||
updated.Credentials = mergeMap(nil, account.Credentials)
|
||||
updated.Extra = mergeMap(nil, account.Extra)
|
||||
if updated.Extra == nil {
|
||||
updated.Extra = make(map[string]any)
|
||||
}
|
||||
if probeEnabled != nil {
|
||||
updated.Extra[UpstreamBillingProbeEnabledExtraKey] = *probeEnabled
|
||||
}
|
||||
if rateSyncEnabled != nil {
|
||||
updated.Extra[UpstreamBillingRateSyncEnabledExtraKey] = *rateSyncEnabled
|
||||
}
|
||||
switch {
|
||||
case rateMultiplier != nil:
|
||||
value := *rateMultiplier
|
||||
updated.RateMultiplier = &value
|
||||
r.lastExplicitRate = &value
|
||||
case r.concurrentRate != nil:
|
||||
value := *r.concurrentRate
|
||||
updated.RateMultiplier = &value
|
||||
r.lastExplicitRate = nil
|
||||
default:
|
||||
updated.RateMultiplier = cloneAccountValuePointer(current.RateMultiplier)
|
||||
r.lastExplicitRate = nil
|
||||
}
|
||||
r.accounts[account.ID] = &updated
|
||||
r.updateCalls++
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestUpdateAccountRoutesRateIntentThroughAtomicBillingUpdater(t *testing.T) {
|
||||
accountID := int64(109)
|
||||
initialRate := 0.1
|
||||
concurrentRate := 0.2
|
||||
repo := &accountBillingSettingsAdminRepo{
|
||||
upstreamBillingProbeAccountRepo: &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: {
|
||||
ID: accountID,
|
||||
Name: "before",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
RateMultiplier: &initialRate,
|
||||
Extra: map[string]any{
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
},
|
||||
},
|
||||
}},
|
||||
concurrentRate: &concurrentRate,
|
||||
}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{Name: "after"})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, repo.updateCalls)
|
||||
require.Nil(t, repo.lastExplicitRate)
|
||||
require.Equal(t, concurrentRate, *updated.RateMultiplier)
|
||||
|
||||
// 手工倍率只有在同步不再开启时才被接受,所以同一请求先关闭同步再设值
|
||||
// (同步仍开启时的手工倍率由 TestUpdateAccountRejectsManualRateWhileRateSyncEnabled 覆盖)。
|
||||
zero := 0.0
|
||||
syncDisabled := false
|
||||
updated, err = svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
RateSyncEnabled: &syncDisabled,
|
||||
RateMultiplier: &zero,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, repo.updateCalls)
|
||||
require.NotNil(t, repo.lastExplicitRate)
|
||||
require.Zero(t, *repo.lastExplicitRate)
|
||||
require.Zero(t, *updated.RateMultiplier)
|
||||
}
|
||||
|
||||
func TestCreateAccountDropsManagedUpstreamBillingProbeState(t *testing.T) {
|
||||
repo := &upstreamBillingProbeAccountRepo{}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
@@ -28,13 +123,15 @@ func TestCreateAccountDropsManagedUpstreamBillingProbeState(t *testing.T) {
|
||||
Credentials: map[string]any{"api_key": "sk-test"},
|
||||
SkipDefaultGroupBind: true,
|
||||
Extra: map[string]any{
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotContains(t, created.Extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
require.NotContains(t, created.Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
require.NotContains(t, created.Extra, UpstreamBillingProbeExtraKey)
|
||||
}
|
||||
|
||||
@@ -73,8 +170,9 @@ func TestUpdateAccountPreservesManagedUpstreamBillingProbeStateForUnrelatedEdit(
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Extra: map[string]any{
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -86,6 +184,7 @@ func TestUpdateAccountPreservesManagedUpstreamBillingProbeStateForUnrelatedEdit(
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
|
||||
require.Equal(t, true, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey])
|
||||
require.Contains(t, updated.Extra, UpstreamBillingProbeExtraKey)
|
||||
require.Equal(t, "value", updated.Extra["custom"])
|
||||
}
|
||||
@@ -196,8 +295,9 @@ func TestUpdateAccountInvalidatesProbeSnapshotWhenUpstreamIdentityChanges(t *tes
|
||||
"base_url": "https://old.example",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
},
|
||||
},
|
||||
}}
|
||||
@@ -210,6 +310,7 @@ func TestUpdateAccountInvalidatesProbeSnapshotWhenUpstreamIdentityChanges(t *tes
|
||||
require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
|
||||
} else {
|
||||
require.NotContains(t, updated.Extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
require.NotContains(t, updated.Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -289,16 +390,149 @@ func TestUpdateAccountAcceptsProbeEnabledAndRejectsInjectedSnapshot(t *testing.T
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
Extra: map[string]any{
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
|
||||
require.NotContains(t, updated.Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
require.NotContains(t, updated.Extra, UpstreamBillingProbeExtraKey)
|
||||
}
|
||||
|
||||
func TestUpdateAccountRateSyncControlsProbeAndManualMode(t *testing.T) {
|
||||
accountID := int64(151)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: {
|
||||
ID: accountID,
|
||||
Platform: PlatformGemini,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
Extra: map[string]any{},
|
||||
},
|
||||
}}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
|
||||
syncEnabled := true
|
||||
updated, err := svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
RateSyncEnabled: &syncEnabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
|
||||
require.Equal(t, true, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey])
|
||||
|
||||
syncEnabled = false
|
||||
updated, err = svc.UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
RateSyncEnabled: &syncEnabled,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, true, updated.Extra[UpstreamBillingProbeEnabledExtraKey])
|
||||
require.Equal(t, false, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey])
|
||||
}
|
||||
|
||||
// 单账号编辑必须和批量路径语义一致:同步开启时倍率归上游所有,手工值会在下一次
|
||||
// 成功探测时被覆盖,因此直接拒绝而不是静默接受。
|
||||
func TestUpdateAccountRejectsManualRateWhileRateSyncEnabled(t *testing.T) {
|
||||
newRepo := func(accountID int64, extra map[string]any) *upstreamBillingProbeAccountRepo {
|
||||
initialRate := 0.25
|
||||
return &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: {
|
||||
ID: accountID,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
RateMultiplier: &initialRate,
|
||||
Extra: extra,
|
||||
},
|
||||
}}
|
||||
}
|
||||
manualRate := 3.5
|
||||
syncEnabled := map[string]any{
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
}
|
||||
|
||||
t.Run("sync enabled rejects manual rate", func(t *testing.T) {
|
||||
accountID := int64(153)
|
||||
repo := newRepo(accountID, mergeMap(nil, syncEnabled))
|
||||
|
||||
_, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
RateMultiplier: &manualRate,
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, ErrUpstreamBillingRateSyncConflict)
|
||||
require.Equal(t, 0.25, *repo.accounts[accountID].RateMultiplier)
|
||||
})
|
||||
|
||||
t.Run("enabling sync in the same request rejects manual rate", func(t *testing.T) {
|
||||
accountID := int64(154)
|
||||
repo := newRepo(accountID, map[string]any{})
|
||||
enable := true
|
||||
|
||||
_, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
RateSyncEnabled: &enable,
|
||||
RateMultiplier: &manualRate,
|
||||
})
|
||||
|
||||
require.ErrorIs(t, err, ErrUpstreamBillingRateSyncConflict)
|
||||
require.Equal(t, 0.25, *repo.accounts[accountID].RateMultiplier)
|
||||
})
|
||||
|
||||
// 用户显式收回所有权:同一请求关闭同步并改倍率必须放行。
|
||||
t.Run("disabling sync in the same request allows manual rate", func(t *testing.T) {
|
||||
accountID := int64(155)
|
||||
repo := newRepo(accountID, mergeMap(nil, syncEnabled))
|
||||
disable := false
|
||||
|
||||
updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
RateSyncEnabled: &disable,
|
||||
RateMultiplier: &manualRate,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, false, updated.Extra[UpstreamBillingRateSyncEnabledExtraKey])
|
||||
require.NotNil(t, updated.RateMultiplier)
|
||||
require.Equal(t, manualRate, *updated.RateMultiplier)
|
||||
})
|
||||
|
||||
t.Run("sync disabled allows manual rate", func(t *testing.T) {
|
||||
accountID := int64(156)
|
||||
repo := newRepo(accountID, map[string]any{UpstreamBillingProbeEnabledExtraKey: true})
|
||||
|
||||
updated, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
RateMultiplier: &manualRate,
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, updated.RateMultiplier)
|
||||
require.Equal(t, manualRate, *updated.RateMultiplier)
|
||||
})
|
||||
}
|
||||
|
||||
func TestUpdateAccountRejectsSyncWithExplicitlyDisabledProbe(t *testing.T) {
|
||||
accountID := int64(152)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: {
|
||||
ID: accountID,
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Status: StatusActive,
|
||||
},
|
||||
}}
|
||||
probeEnabled := false
|
||||
syncEnabled := true
|
||||
|
||||
_, err := (&adminServiceImpl{accountRepo: repo}).UpdateAccount(context.Background(), accountID, &UpdateAccountInput{
|
||||
ProbeEnabled: &probeEnabled,
|
||||
RateSyncEnabled: &syncEnabled,
|
||||
})
|
||||
|
||||
require.Error(t, err)
|
||||
require.Empty(t, repo.updates[accountID])
|
||||
}
|
||||
|
||||
func TestUpdateAccountExplicitProbeDisableUsesDedicatedExtraUpdate(t *testing.T) {
|
||||
accountID := int64(113)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
@@ -321,6 +555,7 @@ func TestUpdateAccountExplicitProbeDisableUsesDedicatedExtraUpdate(t *testing.T)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, repo.updates[accountID], 1)
|
||||
require.Equal(t, false, repo.updates[accountID][0][UpstreamBillingProbeEnabledExtraKey])
|
||||
require.Equal(t, false, repo.updates[accountID][0][UpstreamBillingRateSyncEnabledExtraKey])
|
||||
}
|
||||
|
||||
func TestUpdateAccountExplicitUnchangedProbeEnabledStillUsesDedicatedExtraUpdate(t *testing.T) {
|
||||
@@ -364,15 +599,36 @@ func TestUpdateAccountRejectsInvalidProbeEnabled(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
func TestUpdateAccountExtraDropsManagedBillingProbeFields(t *testing.T) {
|
||||
accountID := int64(153)
|
||||
repo := &upstreamBillingProbeAccountRepo{accounts: map[int64]*Account{
|
||||
accountID: {ID: accountID, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
|
||||
}}
|
||||
|
||||
err := (&adminServiceImpl{accountRepo: repo}).UpdateAccountExtra(context.Background(), accountID, map[string]any{
|
||||
"custom": "value",
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "value", repo.accounts[accountID].Extra["custom"])
|
||||
require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
require.NotContains(t, repo.accounts[accountID].Extra, UpstreamBillingProbeExtraKey)
|
||||
}
|
||||
|
||||
func TestBulkUpdateAccountsDropsManagedUpstreamBillingProbeState(t *testing.T) {
|
||||
repo := &upstreamBillingProbeAccountRepo{}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
input := &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1},
|
||||
Extra: map[string]any{
|
||||
"custom": "value",
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
"custom": "value",
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
UpstreamBillingProbeExtraKey: map[string]any{"status": "ok"},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -383,6 +639,7 @@ func TestBulkUpdateAccountsDropsManagedUpstreamBillingProbeState(t *testing.T) {
|
||||
require.Len(t, repo.bulkUpdates, 1)
|
||||
require.Equal(t, "value", repo.bulkUpdates[0].Extra["custom"])
|
||||
require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeEnabledExtraKey)
|
||||
require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
require.NotContains(t, repo.bulkUpdates[0].Extra, UpstreamBillingProbeExtraKey)
|
||||
}
|
||||
|
||||
@@ -403,6 +660,9 @@ func TestBulkUpdateAccountsAcceptsDedicatedUpstreamBillingProbeSetting(t *testin
|
||||
require.Equal(t, 2, result.Success)
|
||||
require.Len(t, repo.bulkUpdates, 1)
|
||||
require.Equal(t, enabled, repo.bulkUpdates[0].Extra[UpstreamBillingProbeEnabledExtraKey])
|
||||
if !enabled {
|
||||
require.Equal(t, false, repo.bulkUpdates[0].Extra[UpstreamBillingRateSyncEnabledExtraKey])
|
||||
}
|
||||
require.NotNil(t, repo.bulkUpdates[0].ProbeEnabled)
|
||||
require.Equal(t, enabled, *repo.bulkUpdates[0].ProbeEnabled)
|
||||
})
|
||||
|
||||
@@ -300,10 +300,7 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
return nil, errors.New("rate_multiplier must be > 0")
|
||||
}
|
||||
|
||||
platform := input.Platform
|
||||
if platform == "" {
|
||||
platform = PlatformAnthropic
|
||||
}
|
||||
platform := NormalizeGroupPlatform(input.Platform)
|
||||
maxReasoningEffort, err := normalizeMaxReasoningEffortForPlatform(platform, input.MaxReasoningEffort)
|
||||
if err != nil {
|
||||
return nil, infraerrors.Newf(http.StatusBadRequest, "INVALID_MAX_REASONING_EFFORT", "%v", err)
|
||||
@@ -375,6 +372,20 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
return nil, err
|
||||
}
|
||||
|
||||
profitMinMargin := 0.0
|
||||
if input.ProfitMinMargin != nil {
|
||||
profitMinMargin = *input.ProfitMinMargin
|
||||
}
|
||||
profitSafetyBuffer := 0.0
|
||||
if input.ProfitSafetyBuffer != nil {
|
||||
profitSafetyBuffer = *input.ProfitSafetyBuffer
|
||||
}
|
||||
// 利润控制与高峰倍率同一收口顺序:先按平台归一化(不支持的平台重置),再校验。
|
||||
profitControlEnabled, profitMinMargin, profitSafetyBuffer := NormalizeProfitControlConfig(platform, input.ProfitControlEnabled, profitMinMargin, profitSafetyBuffer)
|
||||
if err := ValidateProfitControlConfig(platform, profitControlEnabled, profitMinMargin, profitSafetyBuffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 校验降级分组
|
||||
if input.FallbackGroupID != nil {
|
||||
if err := s.validateFallbackGroup(ctx, 0, *input.FallbackGroupID); err != nil {
|
||||
@@ -456,6 +467,9 @@ func (s *adminServiceImpl) CreateGroup(ctx context.Context, input *CreateGroupIn
|
||||
PeakStart: peakStart,
|
||||
PeakEnd: peakEnd,
|
||||
PeakRateMultiplier: peakRateMultiplier,
|
||||
ProfitControlEnabled: profitControlEnabled,
|
||||
ProfitMinMargin: profitMinMargin,
|
||||
ProfitSafetyBuffer: profitSafetyBuffer,
|
||||
ImagePrice1K: imagePrice1K,
|
||||
ImagePrice2K: imagePrice2K,
|
||||
ImagePrice4K: imagePrice4K,
|
||||
@@ -708,6 +722,21 @@ func (s *adminServiceImpl) UpdateGroup(ctx context.Context, id int64, input *Upd
|
||||
if err := ValidatePeakRateConfig(group.SubscriptionType, group.PeakRateEnabled, group.PeakStart, group.PeakEnd, group.PeakRateMultiplier); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.ProfitControlEnabled != nil {
|
||||
group.ProfitControlEnabled = *input.ProfitControlEnabled
|
||||
}
|
||||
if input.ProfitMinMargin != nil {
|
||||
group.ProfitMinMargin = *input.ProfitMinMargin
|
||||
}
|
||||
if input.ProfitSafetyBuffer != nil {
|
||||
group.ProfitSafetyBuffer = *input.ProfitSafetyBuffer
|
||||
}
|
||||
// 利润控制与高峰同一收口:按合并后的最终平台归一化(转到不支持平台时静默重置),
|
||||
// 再对合并后的最终配置统一校验,防止部分字段更新拼出非法组合入库。
|
||||
group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer = NormalizeProfitControlConfig(group.Platform, group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer)
|
||||
if err := ValidateProfitControlConfig(group.Platform, group.ProfitControlEnabled, group.ProfitMinMargin, group.ProfitSafetyBuffer); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if input.ImagePrice1K != nil {
|
||||
group.ImagePrice1K = normalizePrice(input.ImagePrice1K)
|
||||
}
|
||||
|
||||
@@ -88,6 +88,9 @@ func cloneGroupForDuplicate(source *Group, operationID string) *Group {
|
||||
PeakStart: source.PeakStart,
|
||||
PeakEnd: source.PeakEnd,
|
||||
PeakRateMultiplier: source.PeakRateMultiplier,
|
||||
ProfitControlEnabled: source.ProfitControlEnabled,
|
||||
ProfitMinMargin: source.ProfitMinMargin,
|
||||
ProfitSafetyBuffer: source.ProfitSafetyBuffer,
|
||||
IsExclusive: source.IsExclusive,
|
||||
Status: duplicateGroupInactiveStatus,
|
||||
DuplicateOperationID: operationID,
|
||||
|
||||
@@ -264,6 +264,10 @@ type CreateGroupInput struct {
|
||||
MaxReasoningEffort string
|
||||
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
|
||||
ReasoningEffortMappings []ReasoningEffortMapping
|
||||
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数,nil 按 0 处理)
|
||||
ProfitControlEnabled bool
|
||||
ProfitMinMargin *float64
|
||||
ProfitSafetyBuffer *float64
|
||||
// 从指定分组复制账号(创建分组后在同一事务内绑定)
|
||||
CopyAccountsFromGroupIDs []int64
|
||||
}
|
||||
@@ -325,6 +329,10 @@ type UpdateGroupInput struct {
|
||||
MaxReasoningEffort *string
|
||||
// ReasoningEffortMappings nil 表示不修改,空数组表示清空,非空数组表示替换。
|
||||
ReasoningEffortMappings *[]ReasoningEffortMapping
|
||||
// 分组利润控制(nil 表示不修改;margin/buffer 为小数)
|
||||
ProfitControlEnabled *bool
|
||||
ProfitMinMargin *float64
|
||||
ProfitSafetyBuffer *float64
|
||||
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
|
||||
CopyAccountsFromGroupIDs []int64
|
||||
}
|
||||
@@ -376,6 +384,8 @@ type UpdateAccountInput struct {
|
||||
GroupIDs *[]int64
|
||||
ExpiresAt *int64
|
||||
AutoPauseOnExpired *bool
|
||||
ProbeEnabled *bool
|
||||
RateSyncEnabled *bool
|
||||
SkipMixedChannelCheck bool // 跳过混合渠道检查(用户已确认风险)
|
||||
}
|
||||
|
||||
@@ -617,6 +627,7 @@ type adminServiceImpl struct {
|
||||
groupDuplicateRepo GroupDuplicateRepository
|
||||
accountRepo AccountRepository
|
||||
accountDuplicateRepo AccountDuplicateRepository
|
||||
accountBillingRepo AccountBillingSettingsRepository
|
||||
proxyRepo ProxyRepository
|
||||
apiKeyRepo APIKeyRepository
|
||||
redeemCodeRepo RedeemCodeRepository
|
||||
@@ -675,6 +686,7 @@ func NewAdminService(
|
||||
groupDuplicateRepo: groupRepo,
|
||||
accountRepo: accountRepo,
|
||||
accountDuplicateRepo: accountRepo,
|
||||
accountBillingRepo: accountRepo,
|
||||
proxyRepo: proxyRepo,
|
||||
apiKeyRepo: apiKeyRepo,
|
||||
redeemCodeRepo: redeemCodeRepo,
|
||||
|
||||
@@ -5,9 +5,11 @@ package service
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
@@ -153,6 +155,38 @@ func TestAdminService_BulkUpdateAccounts_AllSuccessIDs(t *testing.T) {
|
||||
require.Len(t, result.Results, 3)
|
||||
}
|
||||
|
||||
func TestAdminService_BulkUpdateAccounts_RejectsRateChangeForSyncedAccounts(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{
|
||||
getByIDsAccounts: []*Account{
|
||||
{
|
||||
ID: 1,
|
||||
Extra: map[string]any{
|
||||
UpstreamBillingProbeEnabledExtraKey: true,
|
||||
UpstreamBillingRateSyncEnabledExtraKey: true,
|
||||
},
|
||||
},
|
||||
{ID: 2, Extra: map[string]any{}},
|
||||
},
|
||||
}
|
||||
svc := &adminServiceImpl{accountRepo: repo}
|
||||
rateMultiplier := 0.5
|
||||
|
||||
result, err := svc.BulkUpdateAccounts(context.Background(), &BulkUpdateAccountsInput{
|
||||
AccountIDs: []int64{1, 2},
|
||||
RateMultiplier: &rateMultiplier,
|
||||
})
|
||||
|
||||
require.Nil(t, result)
|
||||
require.Error(t, err)
|
||||
var appErr *infraerrors.ApplicationError
|
||||
require.ErrorAs(t, err, &appErr)
|
||||
require.Equal(t, int32(http.StatusConflict), appErr.Code)
|
||||
require.Equal(t, "UPSTREAM_BILLING_RATE_SYNC_BULK_CONFLICT", appErr.Reason)
|
||||
require.Equal(t, "1", appErr.Metadata["count"])
|
||||
require.True(t, repo.getByIDsCalled)
|
||||
require.Empty(t, repo.bulkUpdateIDs, "rate conflict must be rejected before any write")
|
||||
}
|
||||
|
||||
// TestAdminService_BulkUpdateAccounts_PartialFailureIDs 验证部分失败时 success_ids/failed_ids 正确。
|
||||
func TestAdminService_BulkUpdateAccounts_PartialFailureIDs(t *testing.T) {
|
||||
repo := &accountRepoStubForBulkUpdate{
|
||||
|
||||
@@ -141,6 +141,7 @@ func TestDuplicateAccountCopiesConfigurationAndResetsRuntimeState(t *testing.T)
|
||||
SessionWindowStatus: "active",
|
||||
}
|
||||
source.Extra[UpstreamBillingProbeEnabledExtraKey] = true
|
||||
source.Extra[UpstreamBillingRateSyncEnabledExtraKey] = true
|
||||
source.Extra[UpstreamBillingProbeExtraKey] = map[string]any{"status": "ok"}
|
||||
require.NoError(t, repo.Create(ctx, source))
|
||||
|
||||
@@ -162,6 +163,7 @@ func TestDuplicateAccountCopiesConfigurationAndResetsRuntimeState(t *testing.T)
|
||||
"quota_limit": float64(1000),
|
||||
"codex_cli_only": true,
|
||||
}, duplicate.Extra)
|
||||
require.NotContains(t, duplicate.Extra, UpstreamBillingRateSyncEnabledExtraKey)
|
||||
require.NotNil(t, duplicate.ExpiresAt)
|
||||
require.True(t, source.ExpiresAt.Equal(*duplicate.ExpiresAt))
|
||||
require.Equal(t, source.Notes, duplicate.Notes)
|
||||
|
||||
@@ -114,6 +114,17 @@ type APIKeyAuthGroupSnapshot struct {
|
||||
PeakStart string `json:"peak_start"`
|
||||
PeakEnd string `json:"peak_end"`
|
||||
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
|
||||
|
||||
// 分组利润控制:调度准入门在直连热路径上读的就是这份快照——门解析
|
||||
// (resolveOpenAIProfitControlGate / resolveProfitControlGroup)优先取
|
||||
// 认证中间件放入 ctx 的 Group,而它正是本快照物化出来的对象,生产绝大
|
||||
// 多数流量走的都是这条路;只有 composite/模型路由等被调度分组与认证分组
|
||||
// 不一致时才回源 schedulerSnapshot。
|
||||
// 因此这三个字段与 GetByKeyForAuth 的投影都不得删减:漏掉任何一个,
|
||||
// 门会拿到零值 ProfitControlEnabled=false 而静默失效(有集成测试兜底)。
|
||||
ProfitControlEnabled bool `json:"profit_control_enabled"`
|
||||
ProfitMinMargin float64 `json:"profit_min_margin"`
|
||||
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
|
||||
}
|
||||
|
||||
// APIKeyAuthCacheEntry 缓存条目,支持负缓存
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user