mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-15 23:22:59 +08:00
Compare commits
51
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
21aacde0b3 | ||
|
|
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 | ||
|
|
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 |
@@ -1 +1 @@
|
||||
0.1.168
|
||||
0.1.169
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1531,6 +1531,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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -827,6 +827,65 @@ 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,
|
||||
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 +984,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 +1013,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 +2349,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() {
|
||||
|
||||
@@ -2183,10 +2183,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 +2220,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)
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -874,6 +874,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 +1176,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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -205,43 +205,54 @@ func (v *ClaudeCodeValidator) hasClaudeCodeSystemPrompt(body map[string]any) boo
|
||||
return false
|
||||
}
|
||||
|
||||
// claudeCodeSecurityMonitorMarkers 与固定前缀、长度下限共同构成分类器提示词的
|
||||
// 判别条件,须全部命中。
|
||||
var claudeCodeSecurityMonitorMarkers = []string{
|
||||
"## Threat Model",
|
||||
"- `<transcript>`:",
|
||||
"## HARD BLOCK",
|
||||
"## SOFT BLOCK",
|
||||
"## Classification Process",
|
||||
"## Output Format",
|
||||
"<block>yes</block>",
|
||||
"<block>no</block>",
|
||||
}
|
||||
|
||||
// isClaudeCodeSecurityMonitorPrompt 识别 Claude Code auto 模式安全监视器分类器请求。
|
||||
// 真实 CLI(实测 2.1.220)会在监视器提示词之外追加独立的会话上下文 system 块,
|
||||
// entry 数量不受服务端控制,故逐 entry 查找匹配项而非限定恰好一个 entry。
|
||||
func isClaudeCodeSecurityMonitorPrompt(systemEntries []any) bool {
|
||||
if len(systemEntries) != 1 {
|
||||
return false
|
||||
for _, raw := range systemEntries {
|
||||
entry, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
entryType, ok := entry["type"].(string)
|
||||
if !ok || entryType != "text" {
|
||||
continue
|
||||
}
|
||||
|
||||
text, ok := entry["text"].(string)
|
||||
if !ok || len(text) < claudeCodeSecurityMonitorPromptMinLen ||
|
||||
!strings.HasPrefix(text, claudeCodeSecurityMonitorPromptPrefix) {
|
||||
continue
|
||||
}
|
||||
|
||||
if hasAllClaudeCodeSecurityMonitorMarkers(text) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
entry, ok := systemEntries[0].(map[string]any)
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
entryType, ok := entry["type"].(string)
|
||||
if !ok || entryType != "text" {
|
||||
return false
|
||||
}
|
||||
|
||||
text, ok := entry["text"].(string)
|
||||
if !ok || len(text) < claudeCodeSecurityMonitorPromptMinLen ||
|
||||
!strings.HasPrefix(text, claudeCodeSecurityMonitorPromptPrefix) {
|
||||
return false
|
||||
}
|
||||
|
||||
markers := []string{
|
||||
"## Threat Model",
|
||||
"- `<transcript>`:",
|
||||
"## HARD BLOCK",
|
||||
"## SOFT BLOCK",
|
||||
"## Classification Process",
|
||||
"## Output Format",
|
||||
"<block>yes</block>",
|
||||
"<block>no</block>",
|
||||
}
|
||||
for _, marker := range markers {
|
||||
func hasAllClaudeCodeSecurityMonitorMarkers(text string) bool {
|
||||
for _, marker := range claudeCodeSecurityMonitorMarkers {
|
||||
if !strings.Contains(text, marker) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
|
||||
@@ -156,6 +156,11 @@ func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// 真实 CLI(2.1.220)在监视器提示词之后追加的独立会话上下文块(脱敏),
|
||||
// 随会话/环境变化,服务端不可控(见 issue #5152 抓包)。
|
||||
sessionContext := "\n\n## Session Context\n\n- **User identity**: testuser\n" +
|
||||
"- **Working directory**: /home/testuser/project\n- **Platform**: linux"
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
headers map[string]string
|
||||
@@ -253,7 +258,9 @@ func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) {
|
||||
wantAccept: false,
|
||||
},
|
||||
{
|
||||
name: "multiple system entries without billing block",
|
||||
// 回归 issue #5152:真实分类器请求携带 2 个 system entry
|
||||
//(监视器提示词 + 追加的会话上下文块),不得因 entry 数量拒识。
|
||||
name: "classifier with trailing session context entry",
|
||||
headers: validHeaders,
|
||||
body: func() map[string]any {
|
||||
body := validBody(string(monitorPrompt))
|
||||
@@ -261,7 +268,45 @@ func TestClaudeCodeValidator_SecurityMonitorWithoutBillingBlock(t *testing.T) {
|
||||
require.True(t, ok)
|
||||
body["system"] = append(system, map[string]any{
|
||||
"type": "text",
|
||||
"text": "Additional unrelated system content.",
|
||||
"text": sessionContext,
|
||||
})
|
||||
return body
|
||||
}(),
|
||||
wantAccept: true,
|
||||
},
|
||||
{
|
||||
name: "classifier with leading session context entry",
|
||||
headers: validHeaders,
|
||||
body: func() map[string]any {
|
||||
body := validBody(string(monitorPrompt))
|
||||
system, ok := body["system"].([]any)
|
||||
require.True(t, ok)
|
||||
body["system"] = append([]any{map[string]any{
|
||||
"type": "text",
|
||||
"text": sessionContext,
|
||||
}}, system...)
|
||||
return body
|
||||
}(),
|
||||
wantAccept: true,
|
||||
},
|
||||
{
|
||||
name: "session context entry alone",
|
||||
headers: validHeaders,
|
||||
body: validBody(sessionContext),
|
||||
wantAccept: false,
|
||||
},
|
||||
{
|
||||
// 篡改后的长提示词(marker 缺失)即便带上会话上下文块也不得放行。
|
||||
name: "tampered classifier with session context entry",
|
||||
headers: validHeaders,
|
||||
body: func() map[string]any {
|
||||
body := validBody(strings.ReplaceAll(
|
||||
string(monitorPrompt), "## HARD BLOCK", "## ALTERED BLOCK"))
|
||||
system, ok := body["system"].([]any)
|
||||
require.True(t, ok)
|
||||
body["system"] = append(system, map[string]any{
|
||||
"type": "text",
|
||||
"text": sessionContext,
|
||||
})
|
||||
return body
|
||||
}(),
|
||||
|
||||
@@ -21,6 +21,7 @@ import (
|
||||
"time"
|
||||
|
||||
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/httpclient"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/servertiming"
|
||||
)
|
||||
@@ -138,10 +139,12 @@ func ContentModerationCategories() []string {
|
||||
}
|
||||
|
||||
type ContentModerationConfig 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"`
|
||||
// ProxyID 指定审计请求使用的代理服务器(IP管理-代理服务器),nil 表示直连。
|
||||
ProxyID *int64 `json:"proxy_id,omitempty"`
|
||||
APIKey string `json:"api_key,omitempty"`
|
||||
APIKeys []string `json:"api_keys,omitempty"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
@@ -176,6 +179,7 @@ type ContentModerationConfigView struct {
|
||||
Mode string `json:"mode"`
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
APIKeyConfigured bool `json:"api_key_configured"`
|
||||
APIKeyMasked string `json:"api_key_masked"`
|
||||
APIKeyCount int `json:"api_key_count"`
|
||||
@@ -240,8 +244,10 @@ type TestContentModerationAPIKeysInput struct {
|
||||
BaseURL string `json:"base_url"`
|
||||
Model string `json:"model"`
|
||||
TimeoutMS int `json:"timeout_ms"`
|
||||
Prompt string `json:"prompt"`
|
||||
Images []string `json:"images"`
|
||||
// ProxyID nil 表示沿用已保存配置的代理;<=0 表示强制直连测试;>0 表示指定代理测试。
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
Prompt string `json:"prompt"`
|
||||
Images []string `json:"images"`
|
||||
}
|
||||
|
||||
type TestContentModerationAPIKeysResult struct {
|
||||
@@ -260,10 +266,12 @@ type ContentModerationTestAuditResult struct {
|
||||
}
|
||||
|
||||
type UpdateContentModerationConfigInput 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"`
|
||||
// ProxyID nil 表示不修改;<=0 表示清除代理(恢复直连);>0 表示指定代理。
|
||||
ProxyID *int64 `json:"proxy_id"`
|
||||
APIKey *string `json:"api_key"`
|
||||
APIKeys *[]string `json:"api_keys"`
|
||||
APIKeysMode string `json:"api_keys_mode"`
|
||||
@@ -495,9 +503,11 @@ type ContentModerationService struct {
|
||||
hashCache ContentModerationHashCache
|
||||
groupRepo GroupRepository
|
||||
userRepo UserRepository
|
||||
proxyRepo ProxyRepository
|
||||
authCacheInvalidator APIKeyAuthCacheInvalidator
|
||||
emailService *EmailService
|
||||
httpClient *http.Client
|
||||
moderationProxyCache atomic.Pointer[moderationProxyURLCacheEntry]
|
||||
asyncQueue chan contentModerationTask
|
||||
workerCount int
|
||||
apiKeyCursor atomic.Uint64
|
||||
@@ -566,6 +576,7 @@ func NewContentModerationService(
|
||||
hashCache ContentModerationHashCache,
|
||||
groupRepo GroupRepository,
|
||||
userRepo UserRepository,
|
||||
proxyRepo ProxyRepository,
|
||||
authCacheInvalidator APIKeyAuthCacheInvalidator,
|
||||
emailService *EmailService,
|
||||
) *ContentModerationService {
|
||||
@@ -575,6 +586,7 @@ func NewContentModerationService(
|
||||
hashCache: hashCache,
|
||||
groupRepo: groupRepo,
|
||||
userRepo: userRepo,
|
||||
proxyRepo: proxyRepo,
|
||||
authCacheInvalidator: authCacheInvalidator,
|
||||
emailService: emailService,
|
||||
httpClient: servertiming.InstrumentClient(nil),
|
||||
@@ -616,6 +628,14 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat
|
||||
if input.Model != nil {
|
||||
cfg.Model = strings.TrimSpace(*input.Model)
|
||||
}
|
||||
if input.ProxyID != nil {
|
||||
if *input.ProxyID > 0 {
|
||||
id := *input.ProxyID
|
||||
cfg.ProxyID = &id
|
||||
} else {
|
||||
cfg.ProxyID = nil
|
||||
}
|
||||
}
|
||||
if input.TimeoutMS != nil {
|
||||
cfg.TimeoutMS = *input.TimeoutMS
|
||||
}
|
||||
@@ -716,6 +736,8 @@ func (s *ContentModerationService) UpdateConfig(ctx context.Context, input Updat
|
||||
return nil, fmt.Errorf("save content moderation config: %w", err)
|
||||
}
|
||||
s.replaceRuntimeConfig(cfg, raw)
|
||||
// 代理选择可能已变化,丢弃已解析的代理 URL 缓存,下次调用即时生效。
|
||||
s.moderationProxyCache.Store(nil)
|
||||
return s.configView(cfg), nil
|
||||
}
|
||||
|
||||
@@ -739,6 +761,14 @@ func (s *ContentModerationService) TestAPIKeys(ctx context.Context, input TestCo
|
||||
if input.TimeoutMS > 0 {
|
||||
cfg.TimeoutMS = input.TimeoutMS
|
||||
}
|
||||
if input.ProxyID != nil {
|
||||
if *input.ProxyID > 0 {
|
||||
id := *input.ProxyID
|
||||
cfg.ProxyID = &id
|
||||
} else {
|
||||
cfg.ProxyID = nil
|
||||
}
|
||||
}
|
||||
cfg.normalize()
|
||||
testInput, imageCount, err := buildModerationTestInput(input.Prompt, input.Images)
|
||||
if err != nil {
|
||||
@@ -1631,6 +1661,11 @@ func (s *ContentModerationService) validateConfig(ctx context.Context, cfg *Cont
|
||||
if _, err := url.ParseRequestURI(cfg.BaseURL); err != nil {
|
||||
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BASE_URL", "OpenAI Base URL 无效")
|
||||
}
|
||||
if cfg.ProxyID != nil && s.proxyRepo != nil {
|
||||
if _, err := s.proxyRepo.GetByID(ctx, *cfg.ProxyID); err != nil {
|
||||
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_PROXY", fmt.Sprintf("代理服务器不存在: %d", *cfg.ProxyID))
|
||||
}
|
||||
}
|
||||
if cfg.BlockStatus < 400 || cfg.BlockStatus > 599 {
|
||||
return infraerrors.BadRequest("INVALID_CONTENT_MODERATION_BLOCK_STATUS", "拦截 HTTP 状态码必须在 400-599 之间")
|
||||
}
|
||||
@@ -1723,9 +1758,9 @@ func (s *ContentModerationService) callModerationOnceWithInput(ctx context.Conte
|
||||
req.Header.Set("Authorization", "Bearer "+apiKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
client := s.httpClient
|
||||
if client == nil {
|
||||
client = http.DefaultClient
|
||||
client, err := s.moderationHTTPClient(ctx, cfg)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
@@ -1750,6 +1785,73 @@ func (s *ContentModerationService) callModerationOnceWithInput(ctx context.Conte
|
||||
return &out.Results[0], nil
|
||||
}
|
||||
|
||||
// moderationProxyURLCacheEntry 缓存 proxy_id 到代理 URL 的解析结果,
|
||||
// 避免审计热路径上每次调用都查询数据库。
|
||||
type moderationProxyURLCacheEntry struct {
|
||||
proxyID int64
|
||||
url string
|
||||
expiresAt time.Time
|
||||
}
|
||||
|
||||
const contentModerationProxyURLCacheTTL = time.Minute
|
||||
|
||||
// moderationHTTPClient 返回本次审计调用应使用的 HTTP 客户端。
|
||||
// 未配置代理时沿用默认客户端;配置了代理时通过共享客户端池构建,
|
||||
// 代理解析/构建失败直接返回错误,绝不回退直连(避免 IP 关联风险)。
|
||||
func (s *ContentModerationService) moderationHTTPClient(ctx context.Context, cfg *ContentModerationConfig) (*http.Client, error) {
|
||||
if cfg == nil || cfg.ProxyID == nil {
|
||||
if s.httpClient == nil {
|
||||
return http.DefaultClient, nil
|
||||
}
|
||||
return s.httpClient, nil
|
||||
}
|
||||
proxyURL, err := s.resolveModerationProxyURL(ctx, *cfg.ProxyID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
client, err := httpclient.GetClient(httpclient.Options{ProxyURL: proxyURL})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("build moderation proxy client: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func (s *ContentModerationService) resolveModerationProxyURL(ctx context.Context, proxyID int64) (string, error) {
|
||||
now := time.Now()
|
||||
prev := s.moderationProxyCache.Load()
|
||||
if prev != nil && prev.proxyID == proxyID && now.Before(prev.expiresAt) {
|
||||
return prev.url, nil
|
||||
}
|
||||
if s.proxyRepo == nil {
|
||||
return "", errors.New("moderation proxy repository unavailable")
|
||||
}
|
||||
px, err := s.proxyRepo.GetByID(ctx, proxyID)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("resolve moderation proxy %d: %w", proxyID, err)
|
||||
}
|
||||
if !px.IsActive() || px.IsExpired(now) {
|
||||
slog.Warn("content_moderation.proxy_not_active",
|
||||
"proxy_id", proxyID,
|
||||
"proxy_name", px.Name,
|
||||
"status", px.Status,
|
||||
"expired", px.IsExpired(now))
|
||||
}
|
||||
proxyURL := px.URL()
|
||||
if prev == nil || prev.proxyID != proxyID || prev.url != proxyURL {
|
||||
// 不打印完整 URL(可能含认证信息),仅记录可定位的地址。
|
||||
slog.Info("content_moderation.proxy_enabled",
|
||||
"proxy_id", proxyID,
|
||||
"proxy_name", px.Name,
|
||||
"proxy_addr", fmt.Sprintf("%s://%s:%d", px.Protocol, px.Host, px.Port))
|
||||
}
|
||||
s.moderationProxyCache.Store(&moderationProxyURLCacheEntry{
|
||||
proxyID: proxyID,
|
||||
url: proxyURL,
|
||||
expiresAt: now.Add(contentModerationProxyURLCacheTTL),
|
||||
})
|
||||
return proxyURL, nil
|
||||
}
|
||||
|
||||
func (s *ContentModerationService) buildLog(input ContentModerationCheckInput, cfg *ContentModerationConfig, action string, flagged bool, highestCategory string, highestScore float64, scores map[string]float64, text string, latency *int, queueDelay *int, errText string) *ContentModerationLog {
|
||||
var userID *int64
|
||||
if input.UserID > 0 {
|
||||
@@ -2013,6 +2115,7 @@ func cloneContentModerationConfig(cfg *ContentModerationConfig) *ContentModerati
|
||||
return nil
|
||||
}
|
||||
clone := *cfg
|
||||
clone.ProxyID = cloneInt64Ptr(cfg.ProxyID)
|
||||
clone.APIKeys = append([]string(nil), cfg.APIKeys...)
|
||||
clone.GroupIDs = append([]int64(nil), cfg.GroupIDs...)
|
||||
clone.BlockedKeywords = append([]string(nil), cfg.BlockedKeywords...)
|
||||
@@ -2042,6 +2145,9 @@ func (cfg *ContentModerationConfig) normalize() {
|
||||
cfg.Model = defaultContentModerationModel
|
||||
}
|
||||
cfg.Model = strings.TrimSpace(cfg.Model)
|
||||
if cfg.ProxyID != nil && *cfg.ProxyID <= 0 {
|
||||
cfg.ProxyID = nil
|
||||
}
|
||||
if cfg.TimeoutMS <= 0 {
|
||||
cfg.TimeoutMS = defaultContentModerationTimeoutMS
|
||||
}
|
||||
@@ -2305,6 +2411,7 @@ func (s *ContentModerationService) configView(cfg *ContentModerationConfig) *Con
|
||||
Mode: cfg.Mode,
|
||||
BaseURL: cfg.BaseURL,
|
||||
Model: cfg.Model,
|
||||
ProxyID: cloneInt64Ptr(cfg.ProxyID),
|
||||
APIKeyConfigured: len(keys) > 0,
|
||||
APIKeyMasked: apiKeyMasked,
|
||||
APIKeyCount: len(keys),
|
||||
|
||||
@@ -76,6 +76,7 @@ func TestRecordCyberPolicyEvent_DisabledWhenRiskControlOff(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
||||
@@ -102,6 +103,7 @@ func TestRecordCyberPolicyEvent_WritesLogWhenEnabled(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil, // emailService=nil: email path safely skipped
|
||||
)
|
||||
|
||||
@@ -179,6 +181,7 @@ func TestRecordCyberPolicyEvent_CreateLogBeforeEmail(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil, // emailService=nil: email path safely skipped; see doc comment above
|
||||
)
|
||||
|
||||
@@ -233,7 +236,7 @@ func TestApplyFlaggedAccountSideEffects_PassesExcludeCyberFlag(t *testing.T) {
|
||||
repo := &banCountArgsTestRepo{}
|
||||
svc := NewContentModerationService(
|
||||
&contentModerationTestSettingRepo{values: map[string]string{}},
|
||||
repo, nil, nil, nil, nil, nil,
|
||||
repo, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
userID := int64(42)
|
||||
|
||||
@@ -255,7 +258,7 @@ func TestRecordCyberPolicyEvent_ExcludeFromBanCount_SkipsBanJudgment(t *testing.
|
||||
SettingKeyRiskControlEnabled: "true",
|
||||
SettingKeyContentModerationConfig: `{"cyber_policy_exclude_from_ban_count":true}`,
|
||||
}},
|
||||
repo, nil, nil, nil, nil, nil,
|
||||
repo, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
||||
@@ -282,7 +285,7 @@ func TestRecordCyberPolicyEvent_DefaultCountsTowardBan(t *testing.T) {
|
||||
&contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyRiskControlEnabled: "true",
|
||||
}},
|
||||
repo, nil, nil, nil, nil, nil,
|
||||
repo, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
svc.RecordCyberPolicyEvent(context.Background(), CyberPolicyRecordInput{
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
)
|
||||
|
||||
// contentModerationTestProxyRepo 仅实现审计代理路径用到的 GetByID,其余方法不应被调用。
|
||||
type contentModerationTestProxyRepo struct {
|
||||
proxies map[int64]*Proxy
|
||||
getByIDErr error
|
||||
getCalls atomic.Int64
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) GetByID(ctx context.Context, id int64) (*Proxy, error) {
|
||||
r.getCalls.Add(1)
|
||||
if r.getByIDErr != nil {
|
||||
return nil, r.getByIDErr
|
||||
}
|
||||
if px, ok := r.proxies[id]; ok {
|
||||
return px, nil
|
||||
}
|
||||
return nil, errors.New("proxy not found")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) Create(ctx context.Context, proxy *Proxy) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListByIDs(ctx context.Context, ids []int64) ([]Proxy, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) Update(ctx context.Context, proxy *Proxy) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) Delete(ctx context.Context, id int64) error {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) List(ctx context.Context, params pagination.PaginationParams) ([]Proxy, *pagination.PaginationResult, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListWithFilters(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]Proxy, *pagination.PaginationResult, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListWithFiltersAndAccountCount(ctx context.Context, params pagination.PaginationParams, protocol, status, search string) ([]ProxyWithAccountCount, *pagination.PaginationResult, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListActive(ctx context.Context) ([]Proxy, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListActiveWithAccountCount(ctx context.Context) ([]ProxyWithAccountCount, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ExistsByHostPortAuth(ctx context.Context, host string, port int, username, password string) (bool, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) CountAccountsByProxyID(ctx context.Context, proxyID int64) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListAccountSummariesByProxyID(ctx context.Context, proxyID int64) ([]ProxyAccountSummary, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) SweepExpiredProxies(ctx context.Context, now time.Time) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) ListAllForFallback(ctx context.Context) ([]Proxy, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) CountExpired(ctx context.Context) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func (r *contentModerationTestProxyRepo) CountExpiringSoon(ctx context.Context, now time.Time) (int64, error) {
|
||||
panic("not implemented")
|
||||
}
|
||||
|
||||
func moderationProxyIDPtr(v int64) *int64 { return &v }
|
||||
|
||||
// 审计请求必须真正经过配置的代理发出(#2646 核心行为)。
|
||||
// 通过一个本地 HTTP 正向代理验证:BaseURL 指向不可直连的假域名,
|
||||
// 请求只有走代理才能得到响应。
|
||||
func TestContentModerationCallRoutesThroughProxy(t *testing.T) {
|
||||
var proxied atomic.Int64
|
||||
proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
// HTTP 目标经正向代理时,代理收到的是绝对 URI 请求。
|
||||
if !strings.HasPrefix(r.RequestURI, "http://moderation-proxy-test.invalid") {
|
||||
t.Errorf("expected absolute-URI proxy request, got %q", r.RequestURI)
|
||||
}
|
||||
proxied.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
|
||||
}))
|
||||
defer proxySrv.Close()
|
||||
|
||||
proxyAddr := strings.TrimPrefix(proxySrv.URL, "http://")
|
||||
host, portStr, ok := strings.Cut(proxyAddr, ":")
|
||||
if !ok {
|
||||
t.Fatalf("unexpected proxy addr: %s", proxyAddr)
|
||||
}
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
t.Fatalf("parse proxy port: %v", err)
|
||||
}
|
||||
|
||||
proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
|
||||
7: {ID: 7, Name: "audit-proxy", Protocol: "http", Host: host, Port: port, Status: StatusActive},
|
||||
}}
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
|
||||
cfg := defaultContentModerationConfig()
|
||||
cfg.BaseURL = "http://moderation-proxy-test.invalid"
|
||||
cfg.ProxyID = moderationProxyIDPtr(7)
|
||||
cfg.normalize()
|
||||
|
||||
httpStatus := 0
|
||||
if _, err := svc.callModerationOnceWithInput(context.Background(), cfg, "sk-test", "hello", &httpStatus); err != nil {
|
||||
t.Fatalf("expected moderation call via proxy to succeed, got: %v", err)
|
||||
}
|
||||
if proxied.Load() == 0 {
|
||||
t.Fatal("expected request to be routed through the proxy server")
|
||||
}
|
||||
}
|
||||
|
||||
// 代理解析失败必须报错,而不是静默回退直连。
|
||||
func TestContentModerationProxyResolveFailureDoesNotFallBackToDirect(t *testing.T) {
|
||||
var direct atomic.Int64
|
||||
directSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
direct.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
|
||||
}))
|
||||
defer directSrv.Close()
|
||||
|
||||
proxyRepo := &contentModerationTestProxyRepo{getByIDErr: errors.New("proxy deleted")}
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
|
||||
cfg := defaultContentModerationConfig()
|
||||
cfg.BaseURL = directSrv.URL
|
||||
cfg.ProxyID = moderationProxyIDPtr(9)
|
||||
cfg.normalize()
|
||||
|
||||
httpStatus := 0
|
||||
_, err := svc.callModerationOnceWithInput(context.Background(), cfg, "sk-test", "hello", &httpStatus)
|
||||
if err == nil || !strings.Contains(err.Error(), "resolve moderation proxy") {
|
||||
t.Fatalf("expected proxy resolve error, got: %v", err)
|
||||
}
|
||||
if direct.Load() != 0 {
|
||||
t.Fatal("must not fall back to direct connection when proxy resolution fails")
|
||||
}
|
||||
}
|
||||
|
||||
// 代理 URL 解析结果按 TTL 缓存,热路径不应每次调用都查库。
|
||||
func TestContentModerationProxyURLResolutionCached(t *testing.T) {
|
||||
proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
|
||||
3: {ID: 3, Name: "p", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: StatusActive},
|
||||
}}
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
|
||||
for i := 0; i < 5; i++ {
|
||||
if _, err := svc.resolveModerationProxyURL(context.Background(), 3); err != nil {
|
||||
t.Fatalf("resolve attempt %d failed: %v", i, err)
|
||||
}
|
||||
}
|
||||
if got := proxyRepo.getCalls.Load(); got != 1 {
|
||||
t.Fatalf("expected exactly 1 repository lookup thanks to caching, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// UpdateConfig 的 proxy_id 语义:>0 设置、nil 保持、<=0 清除;配置视图回显。
|
||||
func TestContentModerationUpdateConfigProxyIDSemantics(t *testing.T) {
|
||||
settingRepo := &contentModerationTestSettingRepo{values: map[string]string{}}
|
||||
proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
|
||||
5: {ID: 5, Name: "p", Protocol: "http", Host: "127.0.0.1", Port: 8080, Status: StatusActive},
|
||||
}}
|
||||
svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
ctx := context.Background()
|
||||
|
||||
view, err := svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(5)})
|
||||
if err != nil {
|
||||
t.Fatalf("set proxy_id=5: %v", err)
|
||||
}
|
||||
if view.ProxyID == nil || *view.ProxyID != 5 {
|
||||
t.Fatalf("expected proxy_id=5 in view, got %v", view.ProxyID)
|
||||
}
|
||||
|
||||
// nil 表示不修改,代理保持不变。
|
||||
enabled := true
|
||||
view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{Enabled: &enabled})
|
||||
if err != nil {
|
||||
t.Fatalf("update unrelated field: %v", err)
|
||||
}
|
||||
if view.ProxyID == nil || *view.ProxyID != 5 {
|
||||
t.Fatalf("expected proxy_id to stay 5 when omitted, got %v", view.ProxyID)
|
||||
}
|
||||
|
||||
// 0 表示清除,恢复直连。
|
||||
view, err = svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(0)})
|
||||
if err != nil {
|
||||
t.Fatalf("clear proxy_id: %v", err)
|
||||
}
|
||||
if view.ProxyID != nil {
|
||||
t.Fatalf("expected proxy_id cleared, got %v", *view.ProxyID)
|
||||
}
|
||||
|
||||
// 不存在的代理必须被校验拒绝。
|
||||
if _, err := svc.UpdateConfig(ctx, UpdateContentModerationConfigInput{ProxyID: moderationProxyIDPtr(404)}); err == nil {
|
||||
t.Fatal("expected validation error for nonexistent proxy")
|
||||
}
|
||||
}
|
||||
|
||||
// TestAPIKeys 的 proxy_id 语义:nil 沿用已保存配置的代理;0 强制直连;>0 指定代理。
|
||||
func TestContentModerationTestAPIKeysProxySemantics(t *testing.T) {
|
||||
var proxied atomic.Int64
|
||||
proxySrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
proxied.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
|
||||
}))
|
||||
defer proxySrv.Close()
|
||||
proxyAddr := strings.TrimPrefix(proxySrv.URL, "http://")
|
||||
host, portStr, _ := strings.Cut(proxyAddr, ":")
|
||||
port, _ := strconv.Atoi(portStr)
|
||||
|
||||
savedCfg := defaultContentModerationConfig()
|
||||
savedCfg.BaseURL = "http://moderation-proxy-test.invalid"
|
||||
savedCfg.ProxyID = moderationProxyIDPtr(7)
|
||||
savedCfg.APIKeys = []string{"sk-saved"}
|
||||
rawCfg, err := json.Marshal(savedCfg)
|
||||
if err != nil {
|
||||
t.Fatalf("marshal cfg: %v", err)
|
||||
}
|
||||
|
||||
settingRepo := &contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyContentModerationConfig: string(rawCfg),
|
||||
}}
|
||||
proxyRepo := &contentModerationTestProxyRepo{proxies: map[int64]*Proxy{
|
||||
7: {ID: 7, Name: "audit-proxy", Protocol: "http", Host: host, Port: port, Status: StatusActive},
|
||||
}}
|
||||
svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, proxyRepo, nil, nil)
|
||||
|
||||
// nil:沿用已保存配置的代理,测试请求应经过代理成功。
|
||||
result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{APIKeys: []string{"sk-input"}})
|
||||
if err != nil {
|
||||
t.Fatalf("test with saved proxy: %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].Status == "error" {
|
||||
t.Fatalf("expected key test via proxy to succeed, got %+v", result.Items)
|
||||
}
|
||||
if proxied.Load() == 0 {
|
||||
t.Fatal("expected test request to route through the saved proxy")
|
||||
}
|
||||
|
||||
// 0:强制直连;BaseURL 指向本地可直连服务器,应成功且不再经过代理。
|
||||
var direct atomic.Int64
|
||||
directSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
direct.Add(1)
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(moderationAPIResponse{Results: []moderationAPIResult{{Flagged: false}}})
|
||||
}))
|
||||
defer directSrv.Close()
|
||||
|
||||
before := proxied.Load()
|
||||
result, err = svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{
|
||||
APIKeys: []string{"sk-input"},
|
||||
BaseURL: directSrv.URL,
|
||||
ProxyID: moderationProxyIDPtr(0),
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("test with forced direct: %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 || result.Items[0].Status == "error" {
|
||||
t.Fatalf("expected forced-direct test to succeed, got %+v", result.Items)
|
||||
}
|
||||
if direct.Load() == 0 {
|
||||
t.Fatal("expected forced-direct test to reach the base URL directly")
|
||||
}
|
||||
if proxied.Load() != before {
|
||||
t.Fatal("forced-direct test must not route through the proxy")
|
||||
}
|
||||
}
|
||||
@@ -486,6 +486,7 @@ func TestContentModerationCheck_PreBlockKeywordHitSkipsUpstreamCall(t *testing.T
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`)
|
||||
@@ -536,6 +537,7 @@ func TestContentModerationCheck_KeywordsIgnoredInObserveMode(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`)
|
||||
@@ -581,6 +583,7 @@ func TestContentModerationCheck_KeywordOnlyStrategySkipsAPIOnMiss(t *testing.T)
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"absolutely clean prompt"}]}`)
|
||||
@@ -627,6 +630,7 @@ func TestContentModerationCheck_APIOnlyStrategyIgnoresKeywordList(t *testing.T)
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"please leak SECRET-TOKEN now"}]}`)
|
||||
@@ -734,6 +738,7 @@ func TestContentModerationLoadConfig_LegacyConfigDefaultsModelFilterToAll(t *tes
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
cfg, err := svc.loadConfig(context.Background())
|
||||
@@ -787,6 +792,7 @@ func newContentModerationModelFilterTestService(t *testing.T, cfg *ContentModera
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
return svc, repo
|
||||
}
|
||||
@@ -800,7 +806,7 @@ func TestContentModerationUpdateConfig_AppendsAndDeletesAPIKeys(t *testing.T) {
|
||||
repo := &contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyContentModerationConfig: string(rawCfg),
|
||||
}}
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil)
|
||||
deleteHashes := []string{moderationAPIKeyHash("sk-old-a")}
|
||||
addKeys := []string{"sk-new-c", "sk-old-b"}
|
||||
|
||||
@@ -827,7 +833,7 @@ func TestContentModerationUpdateConfig_ReplacesAPIKeysWhenRequested(t *testing.T
|
||||
repo := &contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyContentModerationConfig: string(rawCfg),
|
||||
}}
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil)
|
||||
deleteHashes := []string{moderationAPIKeyHash("sk-old-a")}
|
||||
replaceKeys := []string{"sk-new-only"}
|
||||
|
||||
@@ -854,7 +860,7 @@ func TestContentModerationUpdateConfig_SavesCustomThresholds(t *testing.T) {
|
||||
repo := &contentModerationTestSettingRepo{values: map[string]string{
|
||||
SettingKeyContentModerationConfig: string(rawCfg),
|
||||
}}
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(repo, nil, nil, nil, nil, nil, nil, nil)
|
||||
thresholds := map[string]float64{
|
||||
"sexual": 0.72,
|
||||
"harassment": 1.25,
|
||||
@@ -1038,6 +1044,7 @@ func TestContentModerationCheck_OpenAIResponsesRecordsNonHitForCodexPayload(t *t
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -1102,6 +1109,7 @@ func TestContentModerationCheck_PreBlockBlocksCodexResponsesLatestUserInput(t *t
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{
|
||||
@@ -1171,6 +1179,7 @@ func TestContentModerationStatusTracksPreBlockSyncMetrics(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
for _, prompt := range []string{"blocked prompt", "clean prompt"} {
|
||||
@@ -1221,6 +1230,7 @@ func TestContentModerationStatusTracksPreBlockAPIKeyLoad(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
for idx := 0; idx < 4; idx++ {
|
||||
@@ -1266,6 +1276,7 @@ func TestContentModerationStatusTracksPreBlockLocalBlocks(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
for _, prompt := range []string{"blocked prompt", "clean prompt"} {
|
||||
@@ -1314,7 +1325,7 @@ func TestContentModerationCallModeration_400DoesNotFreezeAPIKey(t *testing.T) {
|
||||
cfg.BaseURL = server.URL
|
||||
cfg.APIKeys = []string{"sk-test"}
|
||||
cfg.RetryCount = 5
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
_, err := svc.callModeration(context.Background(), cfg, "hello")
|
||||
|
||||
@@ -1353,7 +1364,7 @@ func TestContentModerationCallModeration_FreezesByHTTPStatus(t *testing.T) {
|
||||
cfg.BaseURL = server.URL
|
||||
cfg.APIKeys = []string{"sk-test"}
|
||||
cfg.RetryCount = 0
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
_, err := svc.callModeration(context.Background(), cfg, "hello")
|
||||
|
||||
@@ -1385,6 +1396,7 @@ func TestContentModerationTestAPIKeys_400DoesNotFreezeAPIKey(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
result, err := svc.TestAPIKeys(context.Background(), TestContentModerationAPIKeysInput{
|
||||
APIKeys: []string{"sk-test"},
|
||||
@@ -1430,6 +1442,7 @@ func TestContentModerationCheck_PreHashUsesRedisHashCache(t *testing.T) {
|
||||
userRepo,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||
@@ -1497,6 +1510,7 @@ func TestContentModerationCheck_HashBlockLogsDoNotIncreaseNextViolationCount(t *
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
decision, err := svc.Check(context.Background(), ContentModerationCheckInput{
|
||||
@@ -1530,7 +1544,7 @@ func TestContentModerationAutoBanSkipsAdminAccount(t *testing.T) {
|
||||
require.NoError(t, repo.CreateLog(context.Background(), newContentModerationFlaggedLog(userID)))
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleAdmin, Status: StatusActive}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true)
|
||||
|
||||
@@ -1557,7 +1571,7 @@ func TestContentModerationAutoBanDisablesRegularUserAtThreshold(t *testing.T) {
|
||||
require.NoError(t, repo.CreateLog(context.Background(), newContentModerationFlaggedLog(userID)))
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleUser, Status: StatusActive}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true)
|
||||
|
||||
@@ -1578,7 +1592,7 @@ func TestContentModerationAdminBelowBanThresholdRecordsViolationOnly(t *testing.
|
||||
repo := &contentModerationTestRepo{}
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: userID, Role: RoleAdmin, Status: StatusActive}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
svc.persistContentModerationLog(context.Background(), cfg, newContentModerationFlaggedLog(userID), "", false, true)
|
||||
|
||||
@@ -1637,6 +1651,7 @@ func TestContentModerationCheck_PreBlockFlaggedWritesRedisHashCache(t *testing.T
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
body := []byte(`{"messages":[{"role":"user","content":"repeat blocked prompt"}]}`)
|
||||
@@ -1750,6 +1765,7 @@ func TestContentModerationCheck_AsyncFlaggedWritesRedisHashCache(t *testing.T) {
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
|
||||
decision := svc.checkSync(context.Background(), ContentModerationCheckInput{
|
||||
@@ -1787,7 +1803,7 @@ func TestContentModerationUnbanUser_ActivatesUserAndInvalidatesAuthCache(t *test
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusDisabled}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
repo := &contentModerationTestRepo{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
result, err := svc.UnbanUser(context.Background(), 1001)
|
||||
|
||||
@@ -1803,7 +1819,7 @@ func TestContentModerationUnbanUser_ActiveUserOnlyInvalidatesAuthCache(t *testin
|
||||
userRepo := &contentModerationTestUserRepo{user: &User{ID: 1001, Email: "user@example.com", Status: StatusActive}}
|
||||
invalidator := &contentModerationTestAuthCacheInvalidator{}
|
||||
repo := &contentModerationTestRepo{}
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, invalidator, nil)
|
||||
svc := NewContentModerationService(nil, repo, nil, nil, userRepo, nil, invalidator, nil)
|
||||
|
||||
result, err := svc.UnbanUser(context.Background(), 1001)
|
||||
|
||||
@@ -1819,7 +1835,7 @@ func contentModerationIntPtr(v int) *int {
|
||||
|
||||
func TestContentModerationUpdateConfig_CyberPolicyExcludeFromBanCount(t *testing.T) {
|
||||
settingRepo := &contentModerationTestSettingRepo{values: map[string]string{}}
|
||||
svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, nil, nil)
|
||||
svc := NewContentModerationService(settingRepo, nil, nil, nil, nil, nil, nil, nil)
|
||||
|
||||
// 默认值必须是 false(计入,保持现状)
|
||||
view, err := svc.GetConfig(context.Background())
|
||||
|
||||
@@ -271,6 +271,7 @@ const (
|
||||
SettingKeyContactInfo = "contact_info" // 客服联系方式
|
||||
SettingKeyDocURL = "doc_url" // 文档链接
|
||||
SettingKeyHomeContent = "home_content" // 首页内容(支持 Markdown/HTML,或 URL 作为 iframe src)
|
||||
SettingKeyCompactHomeEnabled = "compact_home_enabled" // 是否启用内置简洁首页
|
||||
SettingKeyHideCcsImportButton = "hide_ccs_import_button" // 是否隐藏 API Keys 页面的导入 CCS 按钮
|
||||
SettingKeyPurchaseSubscriptionEnabled = "purchase_subscription_enabled" // 是否展示"购买订阅"页面入口
|
||||
SettingKeyPurchaseSubscriptionURL = "purchase_subscription_url" // "购买订阅"页面 URL(作为 iframe src)
|
||||
|
||||
@@ -5,7 +5,9 @@ import (
|
||||
"crypto/rand"
|
||||
"crypto/subtle"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"html"
|
||||
"log/slog"
|
||||
@@ -191,118 +193,114 @@ func (s *EmailService) SendEmailWithConfig(config *SMTPConfig, to, subject, body
|
||||
return err
|
||||
}
|
||||
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
|
||||
if config.UseTLS {
|
||||
return s.sendMailTLS(addr, auth, message.envelopeFrom, message.envelopeTo, message.data, config.Host)
|
||||
}
|
||||
|
||||
return s.sendMailPlain(addr, auth, message.envelopeFrom, message.envelopeTo, message.data, config.Host)
|
||||
}
|
||||
|
||||
// sendMailPlain sends mail without TLS using a dialer with timeout.
|
||||
func (s *EmailService) sendMailPlain(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error {
|
||||
dialer := &net.Dialer{Timeout: smtpDialTimeout}
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
client, err := s.connectSMTP(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpIOTimeout))
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new smtp client: %w", err)
|
||||
return err
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
// Opportunistic STARTTLS: upgrade to encrypted connection if the server supports it.
|
||||
// This mirrors the behavior of smtp.SendMail which we replaced for timeout support.
|
||||
if ok, _ := client.Extension("STARTTLS"); ok {
|
||||
if err = client.StartTLS(&tls.Config{ServerName: host, MinVersion: tls.VersionTLS12}); err != nil {
|
||||
return fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
if err = client.Mail(from); err != nil {
|
||||
if err = client.Mail(message.envelopeFrom); err != nil {
|
||||
return fmt.Errorf("smtp mail: %w", err)
|
||||
}
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
if err = client.Rcpt(message.envelopeTo); err != nil {
|
||||
return fmt.Errorf("smtp rcpt: %w", err)
|
||||
}
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp data: %w", err)
|
||||
}
|
||||
if _, err = w.Write(msg); err != nil {
|
||||
if _, err = w.Write(message.data); err != nil {
|
||||
return fmt.Errorf("write msg: %w", err)
|
||||
}
|
||||
if err = w.Close(); err != nil {
|
||||
return fmt.Errorf("close writer: %w", err)
|
||||
}
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// sendMailTLS 使用TLS发送邮件
|
||||
func (s *EmailService) sendMailTLS(addr string, auth smtp.Auth, from, to string, msg []byte, host string) error {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: host,
|
||||
// 强制 TLS 1.2+,避免协议降级导致的弱加密风险。
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
|
||||
dialer := &net.Dialer{Timeout: smtpDialTimeout}
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls dial: %w", err)
|
||||
}
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpIOTimeout))
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("new smtp client: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp auth: %w", err)
|
||||
}
|
||||
|
||||
if err = client.Mail(from); err != nil {
|
||||
return fmt.Errorf("smtp mail: %w", err)
|
||||
}
|
||||
|
||||
if err = client.Rcpt(to); err != nil {
|
||||
return fmt.Errorf("smtp rcpt: %w", err)
|
||||
}
|
||||
|
||||
w, err := client.Data()
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp data: %w", err)
|
||||
}
|
||||
|
||||
_, err = w.Write(msg)
|
||||
if err != nil {
|
||||
return fmt.Errorf("write msg: %w", err)
|
||||
}
|
||||
|
||||
err = w.Close()
|
||||
if err != nil {
|
||||
return fmt.Errorf("close writer: %w", err)
|
||||
}
|
||||
|
||||
// Email is sent successfully after w.Close(), ignore Quit errors
|
||||
// Some SMTP servers return non-standard responses on QUIT
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// smtpTestRootCAs 仅供单元测试注入自签 CA,生产环境始终为 nil(走系统信任链)。
|
||||
var smtpTestRootCAs *x509.CertPool
|
||||
|
||||
func smtpTLSConfig(host string) *tls.Config {
|
||||
return &tls.Config{
|
||||
ServerName: host,
|
||||
// 强制 TLS 1.2+,避免协议降级导致的弱加密风险。
|
||||
MinVersion: tls.VersionTLS12,
|
||||
RootCAs: smtpTestRootCAs,
|
||||
}
|
||||
}
|
||||
|
||||
// connectSMTP 按配置建立 SMTP 会话,发送与测试连接共用此路径,
|
||||
// 保证"测试连接成功 ⇔ 实际发信可用":
|
||||
// - UseTLS=true:先尝试隐式 TLS(465 语义);若服务器以明文应答
|
||||
// (587/25 等提交端口的 STARTTLS 语义),自动改走"明文连接 + 强制 STARTTLS"。
|
||||
// 两种方式都无法建立加密连接时报错,绝不明文继续。
|
||||
// - UseTLS=false:明文连接后若服务器支持 STARTTLS 则机会式升级,
|
||||
// 与 smtp.SendMail 的默认行为一致。
|
||||
func (s *EmailService) connectSMTP(config *SMTPConfig) (*smtp.Client, error) {
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
dialer := &net.Dialer{Timeout: smtpDialTimeout}
|
||||
tlsConfig := smtpTLSConfig(config.Host)
|
||||
|
||||
if config.UseTLS {
|
||||
conn, err := tls.DialWithDialer(dialer, "tcp", addr, tlsConfig)
|
||||
if err == nil {
|
||||
return newSMTPClient(conn, config.Host)
|
||||
}
|
||||
var recordErr tls.RecordHeaderError
|
||||
if !errors.As(err, &recordErr) {
|
||||
return nil, fmt.Errorf("tls dial: %w", err)
|
||||
}
|
||||
// SMTP 服务器先发问候语:明文问候会让 TLS 握手立刻返回
|
||||
// RecordHeaderError,据此可靠判定对端期望 STARTTLS。
|
||||
return s.connectSMTPStartTLS(dialer, addr, config.Host, tlsConfig, true)
|
||||
}
|
||||
|
||||
return s.connectSMTPStartTLS(dialer, addr, config.Host, tlsConfig, false)
|
||||
}
|
||||
|
||||
// connectSMTPStartTLS 建立明文连接并按需升级 STARTTLS。
|
||||
// mandatory 为 true 时服务器必须支持 STARTTLS,否则报错。
|
||||
func (s *EmailService) connectSMTPStartTLS(dialer *net.Dialer, addr, host string, tlsConfig *tls.Config, mandatory bool) (*smtp.Client, error) {
|
||||
conn, err := dialer.Dial("tcp", addr)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("smtp dial: %w", err)
|
||||
}
|
||||
client, err := newSMTPClient(conn, host)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok, _ := client.Extension("STARTTLS"); !ok {
|
||||
if mandatory {
|
||||
_ = client.Close()
|
||||
return nil, errors.New("smtp server does not support STARTTLS")
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
if err := client.StartTLS(tlsConfig); err != nil {
|
||||
_ = client.Close()
|
||||
return nil, fmt.Errorf("starttls: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
func newSMTPClient(conn net.Conn, host string) (*smtp.Client, error) {
|
||||
_ = conn.SetDeadline(time.Now().Add(smtpIOTimeout))
|
||||
client, err := smtp.NewClient(conn, host)
|
||||
if err != nil {
|
||||
_ = conn.Close()
|
||||
return nil, fmt.Errorf("new smtp client: %w", err)
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
|
||||
// GenerateVerifyCode 生成6位数字验证码
|
||||
func (s *EmailService) GenerateVerifyCode() (string, error) {
|
||||
const digits = "0123456789"
|
||||
@@ -451,49 +449,24 @@ func (s *EmailService) buildVerifyCodeEmailBody(code, siteName string) string {
|
||||
`, html.EscapeString(siteName), code)
|
||||
}
|
||||
|
||||
// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接
|
||||
// TestSMTPConnectionWithConfig 使用指定配置测试SMTP连接。
|
||||
// 与 SendEmailWithConfig 共用 connectSMTP 建连(含 STARTTLS 升级逻辑),
|
||||
// 避免出现"测试连接失败但实际发信成功"的不一致。
|
||||
func (s *EmailService) TestSMTPConnectionWithConfig(config *SMTPConfig) error {
|
||||
addr := fmt.Sprintf("%s:%d", config.Host, config.Port)
|
||||
|
||||
if config.UseTLS {
|
||||
tlsConfig := &tls.Config{
|
||||
ServerName: config.Host,
|
||||
// 与发送逻辑一致,显式要求 TLS 1.2+。
|
||||
MinVersion: tls.VersionTLS12,
|
||||
}
|
||||
conn, err := tls.Dial("tcp", addr, tlsConfig)
|
||||
if err != nil {
|
||||
return fmt.Errorf("tls connection failed: %w", err)
|
||||
}
|
||||
defer func() { _ = conn.Close() }()
|
||||
|
||||
client, err := smtp.NewClient(conn, config.Host)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp client creation failed: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp authentication failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
}
|
||||
|
||||
// 非TLS连接测试
|
||||
client, err := smtp.Dial(addr)
|
||||
client, err := s.connectSMTP(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("smtp connection failed: %w", err)
|
||||
}
|
||||
defer func() { _ = client.Close() }()
|
||||
|
||||
auth := smtp.PlainAuth("", config.Username, config.Password, config.Host)
|
||||
if err = client.Auth(auth); err != nil {
|
||||
if err := client.Auth(auth); err != nil {
|
||||
return fmt.Errorf("smtp authentication failed: %w", err)
|
||||
}
|
||||
|
||||
return client.Quit()
|
||||
// 认证成功即视为连接可用;与发送路径一致,忽略 QUIT 的非标准响应。
|
||||
_ = client.Quit()
|
||||
return nil
|
||||
}
|
||||
|
||||
// GeneratePasswordResetToken generates a secure 32-byte random token (64 hex characters)
|
||||
|
||||
@@ -0,0 +1,383 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/ecdsa"
|
||||
"crypto/elliptic"
|
||||
"crypto/rand"
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"math/big"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// newSMTPTestCert 生成 127.0.0.1/localhost 的自签证书及其信任池。
|
||||
func newSMTPTestCert(t *testing.T) (tls.Certificate, *x509.CertPool) {
|
||||
t.Helper()
|
||||
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||
if err != nil {
|
||||
t.Fatalf("generate key: %v", err)
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: big.NewInt(1),
|
||||
Subject: pkix.Name{CommonName: "127.0.0.1"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().Add(time.Hour),
|
||||
KeyUsage: x509.KeyUsageDigitalSignature | x509.KeyUsageCertSign,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
BasicConstraintsValid: true,
|
||||
IsCA: true,
|
||||
IPAddresses: []net.IP{net.ParseIP("127.0.0.1")},
|
||||
DNSNames: []string{"localhost"},
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
|
||||
if err != nil {
|
||||
t.Fatalf("create certificate: %v", err)
|
||||
}
|
||||
leaf, err := x509.ParseCertificate(der)
|
||||
if err != nil {
|
||||
t.Fatalf("parse certificate: %v", err)
|
||||
}
|
||||
pool := x509.NewCertPool()
|
||||
pool.AddCert(leaf)
|
||||
return tls.Certificate{Certificate: [][]byte{der}, PrivateKey: priv}, pool
|
||||
}
|
||||
|
||||
// fakeSMTPServer 是覆盖三种连接形态的最小 SMTP 服务器:
|
||||
// 隐式 TLS(465 语义)、明文+STARTTLS(587 语义)、纯明文。
|
||||
type fakeSMTPServer struct {
|
||||
listener net.Listener
|
||||
tlsConfig *tls.Config
|
||||
advertiseStartTLS bool
|
||||
|
||||
mu sync.Mutex
|
||||
commands []string
|
||||
conns atomic.Int64
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
func startFakeSMTPServer(t *testing.T, implicitTLS, advertiseStartTLS bool) (*fakeSMTPServer, int) {
|
||||
t.Helper()
|
||||
cert, pool := newSMTPTestCert(t)
|
||||
prevPool := smtpTestRootCAs
|
||||
smtpTestRootCAs = pool
|
||||
t.Cleanup(func() { smtpTestRootCAs = prevPool })
|
||||
|
||||
listener, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatalf("listen: %v", err)
|
||||
}
|
||||
srv := &fakeSMTPServer{
|
||||
listener: listener,
|
||||
tlsConfig: &tls.Config{Certificates: []tls.Certificate{cert}, MinVersion: tls.VersionTLS12},
|
||||
advertiseStartTLS: advertiseStartTLS,
|
||||
}
|
||||
if implicitTLS {
|
||||
srv.listener = tls.NewListener(listener, srv.tlsConfig)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
_ = srv.listener.Close()
|
||||
srv.wg.Wait()
|
||||
})
|
||||
|
||||
srv.wg.Add(1)
|
||||
go func() {
|
||||
defer srv.wg.Done()
|
||||
for {
|
||||
conn, err := srv.listener.Accept()
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
srv.conns.Add(1)
|
||||
srv.wg.Add(1)
|
||||
go func() {
|
||||
defer srv.wg.Done()
|
||||
defer func() { _ = conn.Close() }()
|
||||
_ = conn.SetDeadline(time.Now().Add(10 * time.Second))
|
||||
srv.serve(conn, srv.advertiseStartTLS)
|
||||
}()
|
||||
}
|
||||
}()
|
||||
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
return srv, port
|
||||
}
|
||||
|
||||
func (srv *fakeSMTPServer) record(cmd string) {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
srv.commands = append(srv.commands, cmd)
|
||||
}
|
||||
|
||||
func (srv *fakeSMTPServer) sawCommand(prefix string) bool {
|
||||
srv.mu.Lock()
|
||||
defer srv.mu.Unlock()
|
||||
for _, cmd := range srv.commands {
|
||||
if strings.HasPrefix(strings.ToUpper(cmd), prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (srv *fakeSMTPServer) serve(conn net.Conn, allowStartTLS bool) {
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
writeLine := func(line string) bool {
|
||||
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
||||
return false
|
||||
}
|
||||
return writer.Flush() == nil
|
||||
}
|
||||
if !writeLine("220 fake.test ESMTP ready") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(line)
|
||||
srv.record(cmd)
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
|
||||
ok := writeLine("250-fake.test")
|
||||
if allowStartTLS {
|
||||
ok = ok && writeLine("250-STARTTLS")
|
||||
}
|
||||
if !(ok && writeLine("250-AUTH PLAIN LOGIN") && writeLine("250 8BITMIME")) {
|
||||
return
|
||||
}
|
||||
case upper == "STARTTLS" && allowStartTLS:
|
||||
if !writeLine("220 2.0.0 ready to start TLS") {
|
||||
return
|
||||
}
|
||||
tlsConn := tls.Server(conn, srv.tlsConfig)
|
||||
if err := tlsConn.Handshake(); err != nil {
|
||||
return
|
||||
}
|
||||
srv.serveUpgraded(tlsConn)
|
||||
return
|
||||
case strings.HasPrefix(upper, "AUTH"):
|
||||
if !writeLine("235 2.7.0 authentication successful") {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "MAIL"), strings.HasPrefix(upper, "RCPT"):
|
||||
if !writeLine("250 ok") {
|
||||
return
|
||||
}
|
||||
case upper == "DATA":
|
||||
if !writeLine("354 go ahead") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
dataLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimRight(dataLine, "\r\n") == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !writeLine("250 message accepted") {
|
||||
return
|
||||
}
|
||||
case upper == "QUIT":
|
||||
_ = writeLine("221 bye")
|
||||
return
|
||||
default:
|
||||
if !writeLine("250 ok") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// serveUpgraded 复用命令循环处理 STARTTLS 升级后的会话(升级后不再提供 STARTTLS)。
|
||||
func (srv *fakeSMTPServer) serveUpgraded(conn net.Conn) {
|
||||
reader := bufio.NewReader(conn)
|
||||
writer := bufio.NewWriter(conn)
|
||||
// net/smtp 在 StartTLS 成功后会重新发送 EHLO,直接进入命令循环即可。
|
||||
srv.serveCommands(reader, writer)
|
||||
}
|
||||
|
||||
func (srv *fakeSMTPServer) serveCommands(reader *bufio.Reader, writer *bufio.Writer) {
|
||||
writeLine := func(line string) bool {
|
||||
if _, err := writer.WriteString(line + "\r\n"); err != nil {
|
||||
return false
|
||||
}
|
||||
return writer.Flush() == nil
|
||||
}
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
cmd := strings.TrimSpace(line)
|
||||
srv.record(cmd)
|
||||
upper := strings.ToUpper(cmd)
|
||||
switch {
|
||||
case strings.HasPrefix(upper, "EHLO"), strings.HasPrefix(upper, "HELO"):
|
||||
if !(writeLine("250-fake.test") && writeLine("250-AUTH PLAIN LOGIN") && writeLine("250 8BITMIME")) {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "AUTH"):
|
||||
if !writeLine("235 2.7.0 authentication successful") {
|
||||
return
|
||||
}
|
||||
case strings.HasPrefix(upper, "MAIL"), strings.HasPrefix(upper, "RCPT"):
|
||||
if !writeLine("250 ok") {
|
||||
return
|
||||
}
|
||||
case upper == "DATA":
|
||||
if !writeLine("354 go ahead") {
|
||||
return
|
||||
}
|
||||
for {
|
||||
dataLine, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
if strings.TrimRight(dataLine, "\r\n") == "." {
|
||||
break
|
||||
}
|
||||
}
|
||||
if !writeLine("250 message accepted") {
|
||||
return
|
||||
}
|
||||
case upper == "QUIT":
|
||||
_ = writeLine("221 bye")
|
||||
return
|
||||
default:
|
||||
if !writeLine("250 ok") {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func smtpTestConfig(port int, useTLS bool) *SMTPConfig {
|
||||
return &SMTPConfig{
|
||||
Host: "127.0.0.1",
|
||||
Port: port,
|
||||
Username: "user",
|
||||
Password: "pass",
|
||||
From: "noreply@example.com",
|
||||
FromName: "Test",
|
||||
UseTLS: useTLS,
|
||||
}
|
||||
}
|
||||
|
||||
// 465 语义:UseTLS=true + 隐式 TLS 服务器,原有路径保持可用。
|
||||
func TestSMTPConnectionImplicitTLS(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, true, false)
|
||||
svc := &EmailService{}
|
||||
|
||||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)); err != nil {
|
||||
t.Fatalf("expected implicit TLS connection to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("EHLO") {
|
||||
t.Fatal("expected server to receive EHLO")
|
||||
}
|
||||
}
|
||||
|
||||
// 587 语义(#1470/#1488 核心场景):UseTLS=true + 明文问候的 STARTTLS 服务器,
|
||||
// 隐式 TLS 失败后必须自动降级为强制 STARTTLS 并成功。
|
||||
func TestSMTPConnectionStartTLSFallbackWhenTLSEnabled(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, true)
|
||||
svc := &EmailService{}
|
||||
|
||||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true)); err != nil {
|
||||
t.Fatalf("expected STARTTLS fallback to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("STARTTLS") {
|
||||
t.Fatal("expected server to receive STARTTLS command")
|
||||
}
|
||||
if got := srv.conns.Load(); got < 2 {
|
||||
t.Fatalf("expected implicit TLS attempt before STARTTLS fallback (>=2 connections), got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// UseTLS=true 但服务器不支持 STARTTLS:必须报错,且绝不能把凭据发到明文连接上。
|
||||
func TestSMTPConnectionMandatoryStartTLSRefusesPlaintext(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, false)
|
||||
svc := &EmailService{}
|
||||
|
||||
err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, true))
|
||||
if err == nil {
|
||||
t.Fatal("expected error when server does not support STARTTLS")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "STARTTLS") {
|
||||
t.Fatalf("expected STARTTLS-related error, got: %v", err)
|
||||
}
|
||||
if srv.sawCommand("AUTH") {
|
||||
t.Fatal("credentials must not be sent over plaintext when TLS is required")
|
||||
}
|
||||
}
|
||||
|
||||
// UseTLS=false + 服务器支持 STARTTLS:测试连接与发送路径一致,机会式升级后认证成功。
|
||||
// 这是 #1488 评论"测试连接不成功,发送测试邮件实际上能发"的回归用例。
|
||||
func TestSMTPConnectionOpportunisticStartTLSWhenTLSDisabled(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, true)
|
||||
svc := &EmailService{}
|
||||
|
||||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, false)); err != nil {
|
||||
t.Fatalf("expected opportunistic STARTTLS test connection to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("STARTTLS") {
|
||||
t.Fatal("expected test connection to upgrade via STARTTLS like the send path")
|
||||
}
|
||||
}
|
||||
|
||||
// UseTLS=false + 服务器不支持 STARTTLS:保持明文直连(既有行为不回归)。
|
||||
func TestSMTPConnectionPlainWhenNoStartTLS(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, false)
|
||||
svc := &EmailService{}
|
||||
|
||||
if err := svc.TestSMTPConnectionWithConfig(smtpTestConfig(port, false)); err != nil {
|
||||
t.Fatalf("expected plain connection to succeed, got: %v", err)
|
||||
}
|
||||
if srv.sawCommand("STARTTLS") {
|
||||
t.Fatal("did not expect STARTTLS command when server does not advertise it")
|
||||
}
|
||||
}
|
||||
|
||||
// 发送路径全流程:UseTLS=true + STARTTLS 服务器(587 语义)完整走完 MAIL/RCPT/DATA。
|
||||
func TestSendEmailWithConfigStartTLSFallback(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, false, true)
|
||||
svc := &EmailService{}
|
||||
|
||||
err := svc.SendEmailWithConfig(smtpTestConfig(port, true), "rcpt@example.com", "subject", "<p>body</p>")
|
||||
if err != nil {
|
||||
t.Fatalf("expected send via STARTTLS fallback to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("STARTTLS") {
|
||||
t.Fatal("expected send path to upgrade via STARTTLS")
|
||||
}
|
||||
if !srv.sawCommand("DATA") {
|
||||
t.Fatal("expected send path to reach DATA")
|
||||
}
|
||||
}
|
||||
|
||||
// 发送路径全流程:UseTLS=true + 隐式 TLS 服务器(465 语义)保持既有行为。
|
||||
func TestSendEmailWithConfigImplicitTLS(t *testing.T) {
|
||||
srv, port := startFakeSMTPServer(t, true, false)
|
||||
svc := &EmailService{}
|
||||
|
||||
err := svc.SendEmailWithConfig(smtpTestConfig(port, true), "rcpt@example.com", "subject", "<p>body</p>")
|
||||
if err != nil {
|
||||
t.Fatalf("expected send via implicit TLS to succeed, got: %v", err)
|
||||
}
|
||||
if !srv.sawCommand("DATA") {
|
||||
t.Fatal("expected send path to reach DATA")
|
||||
}
|
||||
}
|
||||
@@ -269,6 +269,11 @@ func (s *GatewayService) forwardAnthropicAPIKeyPassthroughWithInput(
|
||||
if input.RequestStream {
|
||||
streamResult, err := s.handleStreamingResponseAnthropicAPIKeyPassthrough(ctx, resp, c, account, input.StartTime, input.RequestModel)
|
||||
if err != nil {
|
||||
// 流中断时保留已观测到的 usage 与错误一起返回,避免上游已计量的请求
|
||||
// 完全漏记漏计费(issue #5148)。
|
||||
if partial := partialStreamUsageResult(resp, streamResult, input.OriginalModel, input.RequestModel, input.StartTime, err); partial != nil {
|
||||
return partial, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
usage = streamResult.usage
|
||||
|
||||
@@ -843,6 +843,11 @@ func (s *GatewayService) Forward(ctx context.Context, c *gin.Context, account *A
|
||||
ResponseBody: body,
|
||||
}
|
||||
}
|
||||
// 流中断(缺失 terminal 事件、读错误、数据间隔超时等)时保留已观测到的
|
||||
// usage 与错误一起返回,handler 在错误处理完成后照常提交 usage 记录。
|
||||
if partial := partialStreamUsageResult(resp, streamResult, originalModel, mappedModel, startTime, err); partial != nil {
|
||||
return partial, err
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
usage = streamResult.usage
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// 本文件覆盖 issue #5148:流式转发中途出错(缺失 terminal 事件、读错误等)时,
|
||||
// 已观测到的上游 usage 不得随错误一起被丢弃,Forward 必须把部分结果与错误一同
|
||||
// 返回,供 handler 照常提交 usage 记录。
|
||||
|
||||
func newForwardPartialUsageServiceForTest(upstream *anthropicHTTPUpstreamRecorder) *GatewayService {
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
}
|
||||
return &GatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: &RateLimitService{},
|
||||
deferredService: &DeferredService{},
|
||||
}
|
||||
}
|
||||
|
||||
func newAnthropicOAuthAccountForPartialUsageTest() *Account {
|
||||
return &Account{
|
||||
ID: 501,
|
||||
Name: "anthropic-oauth-partial-usage",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "oauth-token",
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func TestGatewayService_Forward_StreamMissingTerminalPreservesPartialUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
|
||||
// newapi 等聚合上游的典型失败形态:message_start/message_delta 携带 usage,
|
||||
// 但流在 message_stop 前直接结束。
|
||||
upstreamSSE := strings.Join([]string{
|
||||
`event: message_start`,
|
||||
`data: {"type":"message_start","message":{"id":"msg_1","type":"message","role":"assistant","model":"claude-3-5-sonnet-latest","content":[],"usage":{"input_tokens":11,"cache_read_input_tokens":7}}}`,
|
||||
"",
|
||||
`event: content_block_delta`,
|
||||
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hi"}}`,
|
||||
"",
|
||||
`event: message_delta`,
|
||||
`data: {"type":"message_delta","delta":{"stop_reason":null},"usage":{"output_tokens":5}}`,
|
||||
"",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"rid-partial"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := newForwardPartialUsageServiceForTest(upstream)
|
||||
account := newAnthropicOAuthAccountForPartialUsageTest()
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, parsed)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "missing terminal event")
|
||||
require.NotNil(t, result, "流中断但已观测到 usage 时必须返回部分结果用于计费")
|
||||
require.True(t, result.Stream)
|
||||
require.Equal(t, 11, result.Usage.InputTokens)
|
||||
require.Equal(t, 7, result.Usage.CacheReadInputTokens)
|
||||
require.Equal(t, 5, result.Usage.OutputTokens)
|
||||
require.Equal(t, "rid-partial", result.RequestID)
|
||||
require.NotNil(t, result.FirstTokenMs)
|
||||
}
|
||||
|
||||
func TestGatewayService_Forward_StreamReadErrorAfterOutputPreservesPartialUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
|
||||
// message_start 已写出(含 usage),随后上游连接异常中断。
|
||||
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: &streamReadCloser{
|
||||
payload: []byte("data: {\"type\":\"message_start\",\"message\":{\"usage\":{\"input_tokens\":9,\"cache_creation_input_tokens\":4}}}\n\n"),
|
||||
err: io.ErrUnexpectedEOF,
|
||||
},
|
||||
}}
|
||||
svc := newForwardPartialUsageServiceForTest(upstream)
|
||||
account := newAnthropicOAuthAccountForPartialUsageTest()
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, parsed)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "stream read error")
|
||||
require.NotNil(t, result, "已写出内容后的读错误必须保留部分 usage")
|
||||
require.Equal(t, 9, result.Usage.InputTokens)
|
||||
require.Equal(t, 4, result.Usage.CacheCreationInputTokens)
|
||||
}
|
||||
|
||||
func TestGatewayService_Forward_StreamErrorWithoutUsageReturnsNilResult(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 只有 ping、没有任何 usage 的流中断:不应产生零 usage 的幽灵账单记录。
|
||||
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: io.NopCloser(strings.NewReader("event: ping\ndata: {\"type\": \"ping\"}\n\n")),
|
||||
}}
|
||||
svc := newForwardPartialUsageServiceForTest(upstream)
|
||||
account := newAnthropicOAuthAccountForPartialUsageTest()
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, parsed)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "missing terminal event")
|
||||
require.Nil(t, result, "无已观测 usage 时不应返回部分结果")
|
||||
}
|
||||
|
||||
func TestGatewayService_Forward_FailoverErrorKeepsNilResult(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
body := []byte(`{"model":"claude-3-5-sonnet-latest","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
parsed, err := ParseGatewayRequest(NewRequestBodyRef(body), PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
|
||||
// 未向客户端写出任何字节前的读错误会包成 UpstreamFailoverError 走换号重试。
|
||||
// 该路径必须保持 result=nil:failover 成功后按成功请求计费,双份结果会重复计费。
|
||||
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
Body: &streamReadCloser{
|
||||
err: errors.New("connection reset by peer"),
|
||||
},
|
||||
}}
|
||||
svc := newForwardPartialUsageServiceForTest(upstream)
|
||||
account := newAnthropicOAuthAccountForPartialUsageTest()
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, parsed)
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.True(t, errors.As(err, &failoverErr))
|
||||
require.Nil(t, result, "failover 错误必须保持 result=nil,防止重试成功后双重计费")
|
||||
}
|
||||
|
||||
func TestGatewayService_AnthropicAPIKeyPassthrough_ForwardStreamMissingTerminalPreservesPartialUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
body := []byte(`{"model":"claude-3-7-sonnet-20250219","stream":true,"messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef(body),
|
||||
Model: "claude-3-7-sonnet-20250219",
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
upstreamSSE := strings.Join([]string{
|
||||
`data: {"type":"message_start","message":{"usage":{"input_tokens":9,"cache_read_input_tokens":2}}}`,
|
||||
"",
|
||||
`data: {"type":"message_delta","usage":{"output_tokens":3}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &anthropicHTTPUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"rid-pass-partial"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := newForwardPartialUsageServiceForTest(upstream)
|
||||
account := newAnthropicAPIKeyAccountForTest()
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, parsed)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "missing terminal event")
|
||||
require.NotNil(t, result, "透传流中断但已观测到 usage 时必须返回部分结果用于计费")
|
||||
require.True(t, result.Stream)
|
||||
require.Equal(t, 9, result.Usage.InputTokens)
|
||||
require.Equal(t, 2, result.Usage.CacheReadInputTokens)
|
||||
require.Equal(t, 3, result.Usage.OutputTokens)
|
||||
require.Equal(t, "claude-3-7-sonnet-20250219", result.Model)
|
||||
}
|
||||
@@ -647,6 +647,44 @@ type streamingResult struct {
|
||||
clientDisconnect bool // 客户端是否在流式传输过程中断开
|
||||
}
|
||||
|
||||
// hasObservedTokens 报告流式过程中是否已观测到任何上游计量的 token。
|
||||
func (u *ClaudeUsage) hasObservedTokens() bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
return u.InputTokens > 0 || u.OutputTokens > 0 ||
|
||||
u.CacheCreationInputTokens > 0 || u.CacheReadInputTokens > 0 ||
|
||||
u.CacheCreation5mTokens > 0 || u.CacheCreation1hTokens > 0 ||
|
||||
u.ImageOutputTokens > 0
|
||||
}
|
||||
|
||||
// partialStreamUsageResult 在流式转发中途出错时,把已观测到 usage 的部分结果包装为
|
||||
// ForwardResult(与错误一起返回给 handler 记录)。上游一旦下发过 message_start,
|
||||
// input/cache token 就已计量,直接丢弃会让请求完全漏记漏计费(issue #5148)。
|
||||
// 无已观测 usage 时返回 nil。
|
||||
//
|
||||
// 不变式:UpstreamFailoverError 必须保持 result=nil——failover 重试成功后按成功请求
|
||||
// 计费,若同时返回部分 usage 会造成双重计费,此处显式拦截兜底。
|
||||
func partialStreamUsageResult(resp *http.Response, streamResult *streamingResult, model, upstreamModel string, startTime time.Time, err error) *ForwardResult {
|
||||
if streamResult == nil || !streamResult.usage.hasObservedTokens() {
|
||||
return nil
|
||||
}
|
||||
var failoverErr *UpstreamFailoverError
|
||||
if errors.As(err, &failoverErr) {
|
||||
return nil
|
||||
}
|
||||
return &ForwardResult{
|
||||
RequestID: resp.Header.Get("x-request-id"),
|
||||
Usage: *streamResult.usage,
|
||||
Model: model,
|
||||
UpstreamModel: upstreamModel,
|
||||
Stream: true,
|
||||
Duration: time.Since(startTime),
|
||||
FirstTokenMs: streamResult.firstTokenMs,
|
||||
ClientDisconnect: streamResult.clientDisconnect,
|
||||
}
|
||||
}
|
||||
|
||||
func (s *GatewayService) handleStreamingResponse(ctx context.Context, resp *http.Response, c *gin.Context, account *Account, startTime time.Time, originalModel, mappedModel string, mimicClaudeCode bool) (*streamingResult, error) {
|
||||
// 更新5h窗口状态
|
||||
s.rateLimitService.UpdateSessionWindow(ctx, account, resp.Header)
|
||||
|
||||
@@ -259,6 +259,74 @@ func TestHandleGrokAccountUpstreamErrorEntitlement403KeepsDefaultCooldown(t *tes
|
||||
require.Less(t, repo.lastTempUnschedUntil, before.Add(31*time.Minute))
|
||||
}
|
||||
|
||||
func TestHandleGrokAccountUpstreamErrorDefaultCooldownsRespectPoolMode(t *testing.T) {
|
||||
for _, statusCode := range []int{
|
||||
http.StatusUnauthorized,
|
||||
http.StatusPaymentRequired,
|
||||
http.StatusForbidden,
|
||||
http.StatusInternalServerError,
|
||||
} {
|
||||
t.Run(http.StatusText(statusCode), func(t *testing.T) {
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
account := &Account{
|
||||
ID: int64(4800 + statusCode),
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
},
|
||||
}
|
||||
body := []byte(`{"error":{"message":"grok access or entitlement denied"}}`)
|
||||
|
||||
svc.handleGrokAccountUpstreamError(
|
||||
context.Background(), account, statusCode, nil, body,
|
||||
)
|
||||
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.Nil(t, account.TempUnschedulableUntil)
|
||||
require.Empty(t, account.TempUnschedulableReason)
|
||||
require.True(t, svc.shouldFailoverGrokUpstreamError(statusCode, body))
|
||||
})
|
||||
}
|
||||
|
||||
account := &Account{Type: AccountTypeAPIKey, Credentials: map[string]any{"pool_mode": true}}
|
||||
require.True(t, account.IsPoolModeRetryableStatus(http.StatusForbidden))
|
||||
|
||||
t.Run("explicit temporary rule still applies", func(t *testing.T) {
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
account := &Account{
|
||||
ID: 4723,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
"temp_unschedulable_enabled": true,
|
||||
"temp_unschedulable_rules": []any{
|
||||
map[string]any{
|
||||
"error_code": float64(http.StatusForbidden),
|
||||
"keywords": []any{"entitlement denied"},
|
||||
"duration_minutes": float64(7),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
before := time.Now()
|
||||
|
||||
svc.handleGrokAccountUpstreamError(
|
||||
context.Background(), account, http.StatusForbidden, nil,
|
||||
[]byte(`{"error":{"message":"grok access or entitlement denied"}}`),
|
||||
)
|
||||
|
||||
require.Equal(t, 1, repo.tempUnschedCalls)
|
||||
require.Equal(t, "grok configured forbidden rule", repo.lastTempUnschedReason)
|
||||
require.WithinDuration(t, before.Add(7*time.Minute), repo.lastTempUnschedUntil, time.Second)
|
||||
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
})
|
||||
}
|
||||
|
||||
func TestHandleGrokAccountUpstreamError403UsesConfiguredRule(t *testing.T) {
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
@@ -126,6 +127,9 @@ func (u *ImageResultUploader) fetchImageBytes(ctx context.Context, item map[stri
|
||||
var rawURL string
|
||||
if err := json.Unmarshal(raw, &rawURL); err == nil {
|
||||
if rawURL = strings.TrimSpace(rawURL); rawURL != "" {
|
||||
if len(rawURL) >= len("data:") && strings.EqualFold(rawURL[:len("data:")], "data:") {
|
||||
return u.decodeImageDataURL(rawURL)
|
||||
}
|
||||
return u.download(ctx, rawURL)
|
||||
}
|
||||
}
|
||||
@@ -133,6 +137,60 @@ func (u *ImageResultUploader) fetchImageBytes(ctx context.Context, item map[stri
|
||||
return nil, "", errors.New("image item has neither b64_json nor url")
|
||||
}
|
||||
|
||||
func (u *ImageResultUploader) decodeImageDataURL(rawURL string) ([]byte, string, error) {
|
||||
header, payload, ok := strings.Cut(rawURL[len("data:"):], ",")
|
||||
if !ok {
|
||||
return nil, "", errors.New("decode image data URL: missing comma separator")
|
||||
}
|
||||
|
||||
parts := strings.Split(header, ";")
|
||||
if strings.TrimSpace(parts[0]) == "" {
|
||||
return nil, "", errors.New("decode image data URL: missing media type")
|
||||
}
|
||||
base64Index := len(parts) - 1
|
||||
if base64Index < 1 || !strings.EqualFold(strings.TrimSpace(parts[base64Index]), "base64") {
|
||||
for i := 1; i < base64Index; i++ {
|
||||
if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") {
|
||||
return nil, "", errors.New("decode image data URL: base64 marker must be the final header token")
|
||||
}
|
||||
}
|
||||
return nil, "", errors.New("decode image data URL: payload is not base64 encoded")
|
||||
}
|
||||
for i := 1; i < base64Index; i++ {
|
||||
if strings.EqualFold(strings.TrimSpace(parts[i]), "base64") {
|
||||
return nil, "", errors.New("decode image data URL: duplicate base64 marker")
|
||||
}
|
||||
}
|
||||
mediaTypeHeader := strings.Join(parts[:base64Index], ";")
|
||||
declaredType, _, err := mime.ParseMediaType(mediaTypeHeader)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image data URL: invalid media type: %w", err)
|
||||
}
|
||||
declaredType = strings.ToLower(declaredType)
|
||||
if !strings.HasPrefix(declaredType, "image/") {
|
||||
return nil, "", fmt.Errorf("decode image data URL: media type %q is not an image", declaredType)
|
||||
}
|
||||
|
||||
limit := u.maxDownloadBytes
|
||||
if limit <= 0 {
|
||||
limit = defaultImageMaxDownloadBytes
|
||||
}
|
||||
decoder := base64.NewDecoder(base64.StdEncoding, strings.NewReader(payload))
|
||||
data, err := io.ReadAll(io.LimitReader(decoder, limit+1))
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("decode image data URL base64 payload: %w", err)
|
||||
}
|
||||
if int64(len(data)) > limit {
|
||||
return nil, "", fmt.Errorf("decoded image data URL exceeds %d bytes", limit)
|
||||
}
|
||||
|
||||
contentType := detectedImageContentType(data)
|
||||
if contentType == "" {
|
||||
contentType = declaredType
|
||||
}
|
||||
return data, contentType, nil
|
||||
}
|
||||
|
||||
func (u *ImageResultUploader) download(ctx context.Context, rawURL string) ([]byte, string, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
@@ -169,11 +227,18 @@ func (u *ImageResultUploader) buildKey(taskID string, index int, contentType str
|
||||
}
|
||||
|
||||
func detectImageContentType(data []byte) string {
|
||||
if ct := detectedImageContentType(data); ct != "" {
|
||||
return ct
|
||||
}
|
||||
return "image/png"
|
||||
}
|
||||
|
||||
func detectedImageContentType(data []byte) string {
|
||||
ct := strings.TrimSpace(strings.Split(http.DetectContentType(data), ";")[0])
|
||||
if strings.HasPrefix(ct, "image/") {
|
||||
return ct
|
||||
}
|
||||
return "image/png"
|
||||
return ""
|
||||
}
|
||||
|
||||
func extensionForContentType(ct string) string {
|
||||
|
||||
@@ -29,6 +29,12 @@ type fakeImageStorage struct {
|
||||
err error
|
||||
}
|
||||
|
||||
type roundTripFunc func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (f roundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return f(req)
|
||||
}
|
||||
|
||||
func (f *fakeImageStorage) Save(_ context.Context, key, contentType string, data []byte) (string, error) {
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
@@ -91,6 +97,89 @@ func TestImageResultUploaderRewritesURL(t *testing.T) {
|
||||
require.JSONEq(t, `"https://cdn.test/images/imgtask_xyz-0.png"`, string(parsed.Data[0]["url"]))
|
||||
}
|
||||
|
||||
func TestImageResultUploaderRewritesImageDataURLWithoutHTTP(t *testing.T) {
|
||||
httpCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
httpCalls++
|
||||
return nil, errors.New("HTTP must not be called for data URLs")
|
||||
})}
|
||||
storage := &fakeImageStorage{}
|
||||
uploader := NewImageResultUploader(storage, "images/", 0, client)
|
||||
b64 := base64.StdEncoding.EncodeToString(pngBytes)
|
||||
result := json.RawMessage(`{"data":[{"url":"DATA:image/jpeg;name=photo.jpg;BaSe64,` + b64 + `","revised_prompt":"kept"}]}`)
|
||||
|
||||
out, err := uploader.Rewrite(context.Background(), "imgtask_data", result)
|
||||
require.NoError(t, err)
|
||||
require.Zero(t, httpCalls)
|
||||
require.Len(t, storage.saved, 1)
|
||||
require.Equal(t, pngBytes, storage.saved[0].data)
|
||||
require.Equal(t, "image/png", storage.saved[0].contentType, "detected bytes take precedence over a conflicting declaration")
|
||||
require.Equal(t, "images/imgtask_data-0.png", storage.saved[0].key)
|
||||
|
||||
var parsed struct {
|
||||
Data []map[string]json.RawMessage `json:"data"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(out, &parsed))
|
||||
require.JSONEq(t, `"https://cdn.test/images/imgtask_data-0.png"`, string(parsed.Data[0]["url"]))
|
||||
require.JSONEq(t, `"kept"`, string(parsed.Data[0]["revised_prompt"]))
|
||||
}
|
||||
|
||||
func TestImageResultUploaderDataURLValidation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
url string
|
||||
wantErr string
|
||||
}{
|
||||
{name: "missing comma", url: "data:image/png;base64", wantErr: "missing comma"},
|
||||
{name: "non image", url: "data:text/plain;base64,aGVsbG8=", wantErr: "is not an image"},
|
||||
{name: "non base64", url: "data:image/png,raw", wantErr: "not base64"},
|
||||
{name: "invalid base64", url: "data:image/png;base64,%%%", wantErr: "base64 payload"},
|
||||
{name: "invalid media type", url: "data:image/png;bad parameter;base64,aGVsbG8=", wantErr: "invalid media type"},
|
||||
{name: "parameter after base64", url: "data:image/png;base64;name=photo.png,aGVsbG8=", wantErr: "base64 marker must be the final header token"},
|
||||
{name: "duplicate base64 marker", url: "data:image/png;base64;base64,aGVsbG8=", wantErr: "duplicate base64 marker"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
httpCalls := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(*http.Request) (*http.Response, error) {
|
||||
httpCalls++
|
||||
return nil, errors.New("HTTP must not be called for data URLs")
|
||||
})}
|
||||
uploader := NewImageResultUploader(&fakeImageStorage{}, "images/", 0, client)
|
||||
result, err := json.Marshal(map[string]any{"data": []map[string]string{{"url": tt.url}}})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = uploader.Rewrite(context.Background(), "imgtask_bad", result)
|
||||
require.ErrorContains(t, err, tt.wantErr)
|
||||
require.Zero(t, httpCalls)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImageResultUploaderRejectsOversizedImageDataURL(t *testing.T) {
|
||||
storage := &fakeImageStorage{}
|
||||
uploader := NewImageResultUploader(storage, "images/", 3, nil)
|
||||
payload := base64.StdEncoding.EncodeToString([]byte("four"))
|
||||
result := json.RawMessage(`{"data":[{"url":"data:image/png;base64,` + payload + `"}]}`)
|
||||
|
||||
_, err := uploader.Rewrite(context.Background(), "imgtask_large", result)
|
||||
require.ErrorContains(t, err, "decoded image data URL exceeds 3 bytes")
|
||||
require.Empty(t, storage.saved)
|
||||
}
|
||||
|
||||
func TestImageResultUploaderB64JSONTakesPrecedenceOverDataURL(t *testing.T) {
|
||||
storage := &fakeImageStorage{}
|
||||
uploader := NewImageResultUploader(storage, "images/", 0, nil)
|
||||
b64 := base64.StdEncoding.EncodeToString(pngBytes)
|
||||
result := json.RawMessage(`{"data":[{"b64_json":"` + b64 + `","url":"data:text/plain,not-an-image"}]}`)
|
||||
|
||||
_, err := uploader.Rewrite(context.Background(), "imgtask_precedence", result)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, storage.saved, 1)
|
||||
require.Equal(t, pngBytes, storage.saved[0].data)
|
||||
}
|
||||
|
||||
func TestImageResultUploaderPropagatesStorageError(t *testing.T) {
|
||||
storage := &fakeImageStorage{err: errors.New("bucket unreachable")}
|
||||
uploader := NewImageResultUploader(storage, "images/", 0, nil)
|
||||
|
||||
@@ -284,6 +284,20 @@ func TestOpenAICompactKeepaliveAdjustedWrittenSize_ExcludesHeartbeatBytes(t *tes
|
||||
require.Contains(t, rec.Body.String(), ": keepalive\n\n")
|
||||
}
|
||||
|
||||
func TestOpenAIStreamClientOutputStarted_IgnoresCompactKeepaliveBytes(t *testing.T) {
|
||||
c, _ := newCompactBridgeTestContext(t, true)
|
||||
stop := StartOpenAICompactSSEKeepalive(c, keepaliveTestInterval)
|
||||
defer stop()
|
||||
waitForKeepaliveBeats()
|
||||
|
||||
require.True(t, c.Writer.Written())
|
||||
require.False(t, openAIStreamClientOutputStarted(c, false), "keepalive comments are not semantic output")
|
||||
|
||||
_, err := c.Writer.Write([]byte("real-output"))
|
||||
require.NoError(t, err)
|
||||
require.True(t, openAIStreamClientOutputStarted(c, false))
|
||||
}
|
||||
|
||||
// fast policy block 在心跳未提交时保持 403 JSON 原语义。
|
||||
func TestWriteOpenAIFastPolicyBlockedResponse_BeforeKeepaliveCommit(t *testing.T) {
|
||||
c, rec := newCompactBridgeTestContext(t, true)
|
||||
|
||||
@@ -441,7 +441,7 @@ func (s *OpenAIGatewayService) handleChatBufferedStreamingResponse(
|
||||
}
|
||||
message := openAICompatFailedResponseMessage(finalResponse)
|
||||
if openAIStreamFailedEventShouldFailover(payload, message) {
|
||||
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message)
|
||||
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message, resp.Header)
|
||||
}
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message)
|
||||
// response.failed 到达在 HTTP 200 SSE 流上,无真实 HTTP 错误码;统一走语义
|
||||
@@ -608,7 +608,7 @@ func (s *OpenAIGatewayService) handleChatStreamingResponse(
|
||||
return true
|
||||
}
|
||||
if openAIStreamFailedEventShouldFailover(payloadBytes, message) {
|
||||
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message)
|
||||
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header)
|
||||
return true
|
||||
}
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message)
|
||||
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
// Forward forwards request to OpenAI API
|
||||
func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, account *Account, body []byte) (*OpenAIForwardResult, error) {
|
||||
clearGrokResponsesClientToolMapping(c)
|
||||
clearOpenAIResponsesNamespaceNames(c)
|
||||
startTime := time.Now()
|
||||
// 固定渠道映射后的请求级 canonical body;账号 normalize/strip 不得改写跨 failover hint。
|
||||
canonicalImageIntentBody := body
|
||||
@@ -62,7 +63,8 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
// 仅允许 WS 入站请求走 WS 上游,避免出现 HTTP -> WS 协议混用。
|
||||
wsDecision = resolveOpenAIWSDecisionByClientTransport(wsDecision, GetOpenAIClientTransport(c))
|
||||
passthroughEnabled := account.IsOpenAIPassthroughEnabled()
|
||||
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
||||
compactPath := isOpenAIResponsesCompactPath(c)
|
||||
if shouldFlattenOpenAIResponsesNamespaces(account, wsDecision.Transport, passthroughEnabled, compactPath) {
|
||||
body, err = flattenOpenAIResponsesNamespaces(c, body)
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
||||
@@ -73,7 +75,10 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
}
|
||||
}
|
||||
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
|
||||
body, err = stripOpenAIResponsesInputNamespaces(body)
|
||||
keepToolCallNamespaces := shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
account, wsDecision.Transport, passthroughEnabled, compactPath,
|
||||
)
|
||||
body, err = stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
|
||||
if err != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
|
||||
@@ -273,7 +278,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
|
||||
markPatchSet("model", billingModel)
|
||||
}
|
||||
upstreamModel := billingModel
|
||||
isCompactRequest := isOpenAIResponsesCompactPath(c)
|
||||
isCompactRequest := compactPath
|
||||
compactMapped := false
|
||||
if isCompactRequest {
|
||||
compactMappedModel := resolveOpenAICompactForwardModel(account, billingModel)
|
||||
|
||||
@@ -179,11 +179,12 @@ func (s *OpenAIGatewayService) forwardGrokResponses(
|
||||
var firstTokenMs *int
|
||||
responseID := ""
|
||||
if reqStream {
|
||||
maxLineSize := defaultMaxLineSize
|
||||
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
resp.Body = newGrokResponsesBillingPingFilterBody(resp.Body, account, maxLineSize)
|
||||
if hasGrokResponsesClientToolMapping(clientToolMapping) {
|
||||
maxLineSize := defaultMaxLineSize
|
||||
if s.cfg != nil && s.cfg.Gateway.MaxLineSize > 0 {
|
||||
maxLineSize = s.cfg.Gateway.MaxLineSize
|
||||
}
|
||||
resp.Body = newGrokResponsesClientToolStreamBody(resp.Body, clientToolMapping, maxLineSize)
|
||||
}
|
||||
streamResult, err := s.handleStreamingResponse(ctx, resp, c, account, startTime, originalModel, upstreamModel)
|
||||
@@ -1129,12 +1130,11 @@ func (s *OpenAIGatewayService) updateGrokUsageSnapshot(ctx context.Context, acco
|
||||
grokQuotaSnapshotExtraKey: snapshot,
|
||||
})
|
||||
}
|
||||
// Error responses are reconciled by handleGrokAccountUpstreamError, which
|
||||
// also installs the immediate in-memory scheduling block. Successful
|
||||
// responses can still consume the last available request/token, so persist
|
||||
// that exhausted window here as a real rate limit rather than relying only
|
||||
// on the passive snapshot scheduler check.
|
||||
if hasActiveLimit {
|
||||
// Error responses are reconciled by handleGrokAccountUpstreamError. Pool-mode
|
||||
// API keys retain the snapshot for observability but leave account health to
|
||||
// the upstream pool. Other accounts install the immediate runtime and durable
|
||||
// rate-limit state when the observed window is exhausted.
|
||||
if hasActiveLimit && !account.IsPoolMode() {
|
||||
s.rateLimitGrok(stateCtx, account, resetAt)
|
||||
} else if recovery {
|
||||
clearGrokRateLimitAfterRecovery(stateCtx, s.accountRepo, account)
|
||||
@@ -1356,20 +1356,24 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex
|
||||
}
|
||||
now := time.Now()
|
||||
s.updateGrokUsageSnapshot(ctx, account, parseGrokQuotaSnapshot(headers, statusCode, now))
|
||||
if statusCode == http.StatusForbidden && s.applyGrokForbiddenPolicy(ctx, account, responseBody) {
|
||||
return
|
||||
}
|
||||
if account.IsPoolMode() {
|
||||
slog.Info("grok_pool_mode_error_state_skipped", "account_id", account.ID, "status_code", statusCode)
|
||||
return
|
||||
}
|
||||
switch statusCode {
|
||||
case http.StatusUnauthorized:
|
||||
s.tempUnscheduleGrok(ctx, account, 10*time.Minute, "grok credentials unauthorized")
|
||||
case http.StatusPaymentRequired:
|
||||
s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok payment required")
|
||||
case http.StatusForbidden:
|
||||
if s.applyGrokForbiddenPolicy(ctx, account, responseBody) {
|
||||
return
|
||||
}
|
||||
s.tempUnscheduleGrok(ctx, account, 30*time.Minute, "grok access or entitlement denied")
|
||||
case http.StatusTooManyRequests:
|
||||
// updateGrokUsageSnapshot installs both runtime and durable rate-limit state.
|
||||
// updateGrokUsageSnapshot installs rate-limit state for non-pool accounts.
|
||||
default:
|
||||
if statusCode >= 500 && !account.IsPoolMode() {
|
||||
if statusCode >= 500 {
|
||||
s.tempUnscheduleGrok(ctx, account, 2*time.Minute, "grok upstream temporary error")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,248 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// OpenAI Responses SSE event types are a closed enum for strict clients
|
||||
// (grok CLI, Codex CLI): an unknown `event: ping` frame aborts the whole
|
||||
// turn. Vendor gateways behind Grok subscriptions inject such frames for
|
||||
// billing/keepalive, so ping frames are rewritten into an SSE comment that
|
||||
// every parser ignores while the connection still looks alive downstream.
|
||||
var grokResponsesPingComment = []byte(": ping\n\n")
|
||||
|
||||
// A vendor ping frame is one event line plus one small data line. Cap what is
|
||||
// buffered while deciding, so an upstream streaming a frame that never ends
|
||||
// cannot grow gateway memory; frames over the cap are passed through as-is.
|
||||
const (
|
||||
grokResponsesPingFrameMaxLines = 16
|
||||
grokResponsesPingFrameMaxBytes = 16 * 1024
|
||||
)
|
||||
|
||||
type grokResponsesBillingPingFilterBody struct {
|
||||
*io.PipeReader
|
||||
source io.Closer
|
||||
closeOnce sync.Once
|
||||
closeErr error
|
||||
}
|
||||
|
||||
func (b *grokResponsesBillingPingFilterBody) Close() error {
|
||||
readerErr := b.PipeReader.Close()
|
||||
sourceErr := b.closeSource()
|
||||
if readerErr != nil {
|
||||
return readerErr
|
||||
}
|
||||
return sourceErr
|
||||
}
|
||||
|
||||
func (b *grokResponsesBillingPingFilterBody) closeSource() error {
|
||||
b.closeOnce.Do(func() { b.closeErr = b.source.Close() })
|
||||
return b.closeErr
|
||||
}
|
||||
|
||||
func newGrokResponsesBillingPingFilterBody(source io.ReadCloser, account *Account, maxLineSize int) io.ReadCloser {
|
||||
if account == nil || account.Platform != PlatformGrok {
|
||||
return source
|
||||
}
|
||||
reader, writer := io.Pipe()
|
||||
body := &grokResponsesBillingPingFilterBody{PipeReader: reader, source: source}
|
||||
go filterGrokResponsesBillingPings(source, writer, body.closeSource, maxLineSize)
|
||||
return body
|
||||
}
|
||||
|
||||
func filterGrokResponsesBillingPings(
|
||||
source io.Reader,
|
||||
destination *io.PipeWriter,
|
||||
closeSource func() error,
|
||||
maxLineSize int,
|
||||
) {
|
||||
defer func() { _ = closeSource() }()
|
||||
if maxLineSize <= 0 {
|
||||
maxLineSize = defaultMaxLineSize
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(source)
|
||||
scanBuf := getSSEScannerBuf64K()
|
||||
defer putSSEScannerBuf64K(scanBuf)
|
||||
initialBufferSize := len(scanBuf)
|
||||
if maxLineSize < initialBufferSize {
|
||||
initialBufferSize = maxLineSize
|
||||
}
|
||||
scanner.Buffer(scanBuf[:0:initialBufferSize], maxLineSize)
|
||||
scanner.Split(scanSSELinesPreservingEndings)
|
||||
|
||||
// Only frames opened by an `event: ping` line are buffered (pingFrame);
|
||||
// every other frame streams through line by line without copying.
|
||||
pingFrame := make([][]byte, 0, 3)
|
||||
pingFrameBytes := 0
|
||||
inPassthroughFrame := false
|
||||
|
||||
replayPingFrame := func() error {
|
||||
for _, line := range pingFrame {
|
||||
if _, err := destination.Write(line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
pingFrame = pingFrame[:0]
|
||||
pingFrameBytes = 0
|
||||
return nil
|
||||
}
|
||||
// endPingFrame decides a complete buffered candidate: vendor ping frames
|
||||
// become an SSE comment, everything else is replayed verbatim. blankLine
|
||||
// is nil when the stream ends inside the frame.
|
||||
endPingFrame := func(blankLine []byte) error {
|
||||
if isGrokResponsesPingEventFrame(pingFrame) {
|
||||
pingFrame = pingFrame[:0]
|
||||
pingFrameBytes = 0
|
||||
_, err := destination.Write(grokResponsesPingComment)
|
||||
return err
|
||||
}
|
||||
if err := replayPingFrame(); err != nil {
|
||||
return err
|
||||
}
|
||||
if blankLine == nil {
|
||||
return nil
|
||||
}
|
||||
_, err := destination.Write(blankLine)
|
||||
return err
|
||||
}
|
||||
abort := func(err error) { _ = destination.CloseWithError(err) }
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
isBlank := len(trimSSELineEnding(line)) == 0
|
||||
|
||||
if inPassthroughFrame {
|
||||
if _, err := destination.Write(line); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
if isBlank {
|
||||
inPassthroughFrame = false
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if len(pingFrame) > 0 {
|
||||
if isBlank {
|
||||
if err := endPingFrame(line); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
continue
|
||||
}
|
||||
if canExtendGrokResponsesPingFrame(line) &&
|
||||
len(pingFrame) < grokResponsesPingFrameMaxLines &&
|
||||
pingFrameBytes+len(line) <= grokResponsesPingFrameMaxBytes {
|
||||
pingFrame = append(pingFrame, append([]byte(nil), line...))
|
||||
pingFrameBytes += len(line)
|
||||
continue
|
||||
}
|
||||
// Not a filterable ping frame after all (unexpected field line,
|
||||
// or past the buffering caps): replay it and stream the rest of
|
||||
// the frame through unchanged.
|
||||
if err := replayPingFrame(); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
if _, err := destination.Write(line); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
inPassthroughFrame = true
|
||||
continue
|
||||
}
|
||||
|
||||
// Frame start: only `event: ping` opens a buffered candidate.
|
||||
if !isBlank {
|
||||
if value, ok := extractOpenAISSEEventLine(string(trimSSELineEnding(line))); ok && value == "ping" {
|
||||
pingFrame = append(pingFrame, append([]byte(nil), line...))
|
||||
pingFrameBytes = len(line)
|
||||
continue
|
||||
}
|
||||
}
|
||||
if _, err := destination.Write(line); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
inPassthroughFrame = !isBlank
|
||||
}
|
||||
if len(pingFrame) > 0 {
|
||||
if err := endPingFrame(nil); err != nil {
|
||||
abort(err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := scanner.Err(); err != nil {
|
||||
abort(fmt.Errorf("filter Grok Responses billing ping: %w", err))
|
||||
return
|
||||
}
|
||||
_ = destination.Close()
|
||||
}
|
||||
|
||||
func scanSSELinesPreservingEndings(data []byte, atEOF bool) (advance int, token []byte, err error) {
|
||||
for index, value := range data {
|
||||
switch value {
|
||||
case '\n':
|
||||
return index + 1, data[:index+1], nil
|
||||
case '\r':
|
||||
if index+1 == len(data) && !atEOF {
|
||||
return 0, nil, nil
|
||||
}
|
||||
if index+1 < len(data) && data[index+1] == '\n' {
|
||||
return index + 2, data[:index+2], nil
|
||||
}
|
||||
return index + 1, data[:index+1], nil
|
||||
}
|
||||
}
|
||||
if atEOF && len(data) > 0 {
|
||||
return len(data), data, nil
|
||||
}
|
||||
return 0, nil, nil
|
||||
}
|
||||
|
||||
func trimSSELineEnding(line []byte) []byte {
|
||||
return bytes.TrimSuffix(bytes.TrimSuffix(line, []byte("\n")), []byte("\r"))
|
||||
}
|
||||
|
||||
// canExtendGrokResponsesPingFrame reports whether a line may still belong to a
|
||||
// vendor ping frame: only data lines and SSE comments. Any other field (a
|
||||
// second event line, id, retry, ...) means the frame is not a plain ping.
|
||||
func canExtendGrokResponsesPingFrame(rawLine []byte) bool {
|
||||
line := trimSSELineEnding(rawLine)
|
||||
if len(line) > 0 && line[0] == ':' {
|
||||
return true
|
||||
}
|
||||
_, ok := extractOpenAISSEDataLine(string(line))
|
||||
return ok
|
||||
}
|
||||
|
||||
// isGrokResponsesPingEventFrame decides a buffered candidate whose first line
|
||||
// is already `event: ping`. The only candidates replayed verbatim are frames
|
||||
// whose data payload declares a different event type than the SSE event line;
|
||||
// every other shape (billing cost, keepalive, no data, malformed JSON) would
|
||||
// break strict Responses clients and is rewritten into a comment.
|
||||
func isGrokResponsesPingEventFrame(rawLines [][]byte) bool {
|
||||
dataParts := make([]string, 0, 1)
|
||||
for _, rawLine := range rawLines[1:] {
|
||||
if value, ok := extractOpenAISSEDataLine(string(trimSSELineEnding(rawLine))); ok {
|
||||
dataParts = append(dataParts, value)
|
||||
}
|
||||
}
|
||||
if len(dataParts) == 0 {
|
||||
return true
|
||||
}
|
||||
var payload struct {
|
||||
Type *string `json:"type"`
|
||||
}
|
||||
if err := json.Unmarshal([]byte(strings.Join(dataParts, "\n")), &payload); err != nil || payload.Type == nil {
|
||||
return true
|
||||
}
|
||||
return *payload.Type == "ping"
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func filterGrokPingTestInput(t *testing.T, input string) string {
|
||||
t.Helper()
|
||||
body := newGrokResponsesBillingPingFilterBody(
|
||||
io.NopCloser(strings.NewReader(input)),
|
||||
&Account{Platform: PlatformGrok},
|
||||
defaultMaxLineSize,
|
||||
)
|
||||
output, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, body.Close())
|
||||
return string(output)
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilter(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
": upstream keepalive",
|
||||
"",
|
||||
"event: response.output_text.delta",
|
||||
`data: {"type":"response.output_text.delta","delta":"hello"}`,
|
||||
"",
|
||||
"event: future.vendor_event",
|
||||
`data: {"type":"future.vendor_event","value":1}`,
|
||||
"",
|
||||
"event: ping",
|
||||
`data: {"type":"ping","x-opencode-type":"inference-cost","cost":2.75,"input-tokens":42}`,
|
||||
"",
|
||||
"event: ping",
|
||||
`data: {"type":"ping","cost":"0"}`,
|
||||
"",
|
||||
"event: response.completed",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
result := filterGrokPingTestInput(t, input)
|
||||
require.NotContains(t, result, "event: ping")
|
||||
require.NotContains(t, result, `"x-opencode-type":"inference-cost"`)
|
||||
require.NotContains(t, result, `{"type":"ping","cost":"0"}`)
|
||||
require.Equal(t, 2, strings.Count(result, ": ping\n\n"))
|
||||
require.Contains(t, result, ": upstream keepalive\n\n")
|
||||
require.Contains(t, result, "event: response.output_text.delta")
|
||||
require.Contains(t, result, `{"type":"response.output_text.delta","delta":"hello"}`)
|
||||
require.Contains(t, result, "event: future.vendor_event")
|
||||
require.Contains(t, result, `{"type":"future.vendor_event","value":1}`)
|
||||
require.Contains(t, result, "event: response.completed")
|
||||
require.Contains(t, result, `"usage":{"input_tokens":3,"output_tokens":5}`)
|
||||
}
|
||||
|
||||
// Every `event: ping` frame is outside the Responses closed event enum and
|
||||
// breaks strict clients regardless of its payload shape, so all variants are
|
||||
// rewritten into an SSE comment (issue #5105).
|
||||
func TestGrokResponsesBillingPingFilterConvertsPingVariants(t *testing.T) {
|
||||
frames := []string{
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":\"inference-cost\",\"cost\":\"0.06029240\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0.06029240\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"kind\":\"keepalive\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":2}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":0.0001}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"cost\":\" 0 \"}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":\"keepalive\",\"cost\":0}\n\n",
|
||||
"event: ping\ndata: {\"type\":\"ping\",\"x-opencode-type\":null,\"cost\":0}\n\n",
|
||||
"event: ping\ndata: {\"cost\":\"0\"}\n\n",
|
||||
"event: ping\ndata: {not-json}\n\n",
|
||||
"event: ping\n\n",
|
||||
"event: ping\n: vendor note\ndata: {\"type\":\"ping\"}\n\n",
|
||||
}
|
||||
result := filterGrokPingTestInput(t, strings.Join(frames, ""))
|
||||
require.Equal(t, strings.Repeat(": ping\n\n", len(frames)), result)
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterPreservesNonPingFrames(t *testing.T) {
|
||||
input := strings.Join([]string{
|
||||
"event: ping",
|
||||
`data: {"type":" ping ","cost":0}`,
|
||||
"",
|
||||
"event: ping",
|
||||
`data: {"type":"response.completed"}`,
|
||||
"",
|
||||
"event: custom",
|
||||
`data: {"type":"ping","x-opencode-type":"inference-cost"}`,
|
||||
"",
|
||||
`data: {"type":"ping","cost":"0"}`,
|
||||
"",
|
||||
": keepalive comment",
|
||||
"",
|
||||
"retry: 1000",
|
||||
"",
|
||||
}, "\n")
|
||||
|
||||
require.Equal(t, input, filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
// A ping candidate that turns out to carry an unexpected SSE field is not a
|
||||
// vendor billing/keepalive frame; it must be replayed byte for byte.
|
||||
func TestGrokResponsesBillingPingFilterPassesThroughPingFrameWithUnknownField(t *testing.T) {
|
||||
input := "event: ping\nid: 7\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n"
|
||||
require.Equal(t, input, filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
// Buffering caps: a ping candidate that grows past the line or byte limit is
|
||||
// streamed through unchanged instead of accumulating unbounded memory.
|
||||
func TestGrokResponsesBillingPingFilterPassesThroughOversizedPingFrame(t *testing.T) {
|
||||
lines := []string{"event: ping"}
|
||||
for i := 0; i < grokResponsesPingFrameMaxLines; i++ {
|
||||
lines = append(lines, ": filler comment")
|
||||
}
|
||||
lines = append(lines, `data: {"type":"ping","cost":"0"}`, "")
|
||||
byLines := strings.Join(lines, "\n")
|
||||
require.Equal(t, byLines, filterGrokPingTestInput(t, byLines))
|
||||
|
||||
byBytes := "event: ping\ndata: {\"type\":\"ping\",\"pad\":\"" +
|
||||
strings.Repeat("x", grokResponsesPingFrameMaxBytes) + "\"}\n\n"
|
||||
require.Equal(t, byBytes, filterGrokPingTestInput(t, byBytes))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterConvertsMalformedPingFrames(t *testing.T) {
|
||||
input := "event: ping\r\ndata: {not-json}\r\n\r\n" +
|
||||
"event: ping\r\ndata: {\"type\":\"ping\",\"cost\":\"0\"} trailing\r\n\r\n" +
|
||||
"event: future.response.event\r\ndata: {\"type\":\"future.response.event\"}"
|
||||
want := ": ping\n\n" + ": ping\n\n" +
|
||||
"event: future.response.event\r\ndata: {\"type\":\"future.response.event\"}"
|
||||
require.Equal(t, want, filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterHandlesBareCRFrames(t *testing.T) {
|
||||
input := "event: ping\rdata: {\"type\":\"ping\",\"cost\":\"0\"}\r\r" +
|
||||
"event: future.event\rdata: {\"type\":\"future.event\"}\r\r"
|
||||
want := ": ping\n\n" + "event: future.event\rdata: {\"type\":\"future.event\"}\r\r"
|
||||
require.Equal(t, want, filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterConvertsPartialPingFrameAtEOF(t *testing.T) {
|
||||
input := "event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}"
|
||||
require.Equal(t, ": ping\n\n", filterGrokPingTestInput(t, input))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterDoesNotFilterNonGrokAccounts(t *testing.T) {
|
||||
input := "event: ping\ndata: {\"type\":\"ping\",\"cost\":\"0\"}\n\n"
|
||||
source := io.NopCloser(strings.NewReader(input))
|
||||
body := newGrokResponsesBillingPingFilterBody(source, &Account{Platform: PlatformOpenAI}, defaultMaxLineSize)
|
||||
|
||||
output, err := io.ReadAll(body)
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, body.Close())
|
||||
require.Equal(t, input, string(output))
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterPreservesUsageAndTerminalEvent(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
input := strings.Join([]string{
|
||||
"event: ping",
|
||||
`data: {"type":"ping","x-opencode-type":"inference-cost","cost":"0"}`,
|
||||
"",
|
||||
"event: response.completed",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","usage":{"input_tokens":3,"output_tokens":5}}}`,
|
||||
"",
|
||||
}, "\n")
|
||||
account := &Account{ID: 1, Platform: PlatformGrok}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{},
|
||||
Body: newGrokResponsesBillingPingFilterBody(
|
||||
io.NopCloser(strings.NewReader(input)), account, defaultMaxLineSize,
|
||||
),
|
||||
}
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}},
|
||||
toolCorrector: NewCodexToolCorrector(),
|
||||
}
|
||||
|
||||
result, err := svc.handleStreamingResponse(context.Background(), resp, c, account, time.Now(), "grok-4.5", "grok-4.5")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 3, result.usage.InputTokens)
|
||||
require.Equal(t, 5, result.usage.OutputTokens)
|
||||
require.Equal(t, "resp_1", result.responseID)
|
||||
require.Contains(t, recorder.Body.String(), "response.completed")
|
||||
require.NotContains(t, recorder.Body.String(), "inference-cost")
|
||||
require.NotContains(t, recorder.Body.String(), "event: ping")
|
||||
}
|
||||
|
||||
type grokPingFilterTestReadCloser struct {
|
||||
reader io.ReadCloser
|
||||
closeCount atomic.Int32
|
||||
}
|
||||
|
||||
func (r *grokPingFilterTestReadCloser) Read(p []byte) (int, error) { return r.reader.Read(p) }
|
||||
func (r *grokPingFilterTestReadCloser) Close() error {
|
||||
r.closeCount.Add(1)
|
||||
return r.reader.Close()
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterCloseCancelsSourceOnce(t *testing.T) {
|
||||
upstreamReader, upstreamWriter := io.Pipe()
|
||||
source := &grokPingFilterTestReadCloser{reader: upstreamReader}
|
||||
body := newGrokResponsesBillingPingFilterBody(source, &Account{Platform: PlatformGrok}, defaultMaxLineSize)
|
||||
|
||||
require.NoError(t, body.Close())
|
||||
require.Eventually(t, func() bool { return source.closeCount.Load() == 1 }, time.Second, time.Millisecond)
|
||||
_, err := upstreamWriter.Write([]byte("blocked"))
|
||||
require.Error(t, err)
|
||||
require.NoError(t, upstreamWriter.Close())
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterFlushesCompletedFrames(t *testing.T) {
|
||||
upstreamReader, upstreamWriter := io.Pipe()
|
||||
body := newGrokResponsesBillingPingFilterBody(upstreamReader, &Account{Platform: PlatformGrok}, defaultMaxLineSize)
|
||||
t.Cleanup(func() { require.NoError(t, body.Close()) })
|
||||
|
||||
go func() {
|
||||
_, _ = io.WriteString(upstreamWriter, "event: future.event\ndata: {\"type\":\"future.event\"}\n\n")
|
||||
}()
|
||||
|
||||
result := make(chan error, 1)
|
||||
go func() {
|
||||
buffer := make([]byte, 64)
|
||||
n, err := body.Read(buffer)
|
||||
if err == nil && !strings.Contains(string(buffer[:n]), "future.event") {
|
||||
err = errors.New("completed frame was not forwarded")
|
||||
}
|
||||
result <- err
|
||||
}()
|
||||
select {
|
||||
case err := <-result:
|
||||
require.NoError(t, err)
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("completed frame was buffered until upstream EOF")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGrokResponsesBillingPingFilterReportsOversizedLine(t *testing.T) {
|
||||
body := newGrokResponsesBillingPingFilterBody(
|
||||
io.NopCloser(strings.NewReader("data: 123456789\n\n")),
|
||||
&Account{Platform: PlatformGrok},
|
||||
8,
|
||||
)
|
||||
_, err := io.ReadAll(body)
|
||||
require.ErrorContains(t, err, "filter Grok Responses billing ping")
|
||||
require.NoError(t, body.Close())
|
||||
}
|
||||
@@ -2612,6 +2612,30 @@ func TestHandleGrokAccountUpstreamError429SetsRateLimitedFromRetryAfter(t *testi
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
}
|
||||
|
||||
func TestHandleGrokAccountUpstreamError429PoolModeKeepsSchedulingState(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 613,
|
||||
Platform: PlatformGrok,
|
||||
Type: AccountTypeAPIKey,
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
},
|
||||
}
|
||||
repo := &grokQuotaAccountRepo{}
|
||||
svc := &OpenAIGatewayService{accountRepo: repo}
|
||||
|
||||
svc.handleGrokAccountUpstreamError(
|
||||
context.Background(), account, http.StatusTooManyRequests,
|
||||
http.Header{"Retry-After": []string{"45"}}, nil,
|
||||
)
|
||||
|
||||
require.Equal(t, 1, repo.updateCalls, "pool mode should retain the quota snapshot for observability")
|
||||
require.Zero(t, repo.rateLimitedCalls)
|
||||
require.Zero(t, repo.tempUnschedCalls)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
require.Nil(t, account.RateLimitResetAt)
|
||||
}
|
||||
|
||||
func TestHandleGrokAccountUpstreamError402RecoversAfterCooldownExpiry(t *testing.T) {
|
||||
account := &Account{
|
||||
ID: 610, Platform: PlatformGrok, Type: AccountTypeOAuth,
|
||||
|
||||
@@ -561,7 +561,7 @@ func (s *OpenAIGatewayService) handleAnthropicBufferedStreamingResponse(
|
||||
}
|
||||
message := openAICompatFailedResponseMessage(finalResponse)
|
||||
if openAIStreamFailedEventShouldFailover(payload, message) {
|
||||
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message)
|
||||
return nil, s.newOpenAIStreamFailoverError(c, account, false, requestID, payload, message, resp.Header)
|
||||
}
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payload, message)
|
||||
// 统一走语义状态推断 + body 归一化(与 /v1/responses 路径一致),
|
||||
@@ -917,7 +917,7 @@ func (s *OpenAIGatewayService) handleAnthropicStreamingResponse(
|
||||
// two model streams together. Surface a proper Anthropic error event
|
||||
// instead of returning a failover error that the handler cannot retry.
|
||||
if !clientOutputStarted && openAIStreamFailedEventShouldFailover(payloadBytes, message) {
|
||||
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message)
|
||||
streamFailoverErr = s.newOpenAIStreamFailoverError(c, account, false, requestID, payloadBytes, message, resp.Header)
|
||||
return true
|
||||
}
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, false, requestID, "http_error", payloadBytes, message)
|
||||
|
||||
@@ -63,6 +63,13 @@ func (s *OpenAIGatewayService) forwardOpenAIPassthrough(
|
||||
})
|
||||
return nil, fmt.Errorf("openai passthrough rejected before upstream: %s", rejectReason)
|
||||
}
|
||||
if isOpenAICodexModel(reqModel) && !gjson.GetBytes(body, "instructions").Exists() {
|
||||
nextBody, setErr := sjson.SetBytes(body, "instructions", defaultCodexSynthInstructions(reqModel))
|
||||
if setErr != nil {
|
||||
return nil, fmt.Errorf("set passthrough codex instructions: %w", setErr)
|
||||
}
|
||||
body = nextBody
|
||||
}
|
||||
|
||||
normalizedBody, normalized, err := normalizeOpenAIPassthroughOAuthBody(body, isOpenAIResponsesCompactPath(c))
|
||||
if err != nil {
|
||||
@@ -731,7 +738,13 @@ func openAIStreamClientOutputStarted(c *gin.Context, localStarted bool) bool {
|
||||
if localStarted {
|
||||
return true
|
||||
}
|
||||
return c != nil && c.Writer != nil && c.Writer.Written()
|
||||
if c == nil || c.Writer == nil {
|
||||
return false
|
||||
}
|
||||
// compact keepalive comments commit the HTTP response as 200, but they are
|
||||
// not semantic model output and therefore must not block a safe retry.
|
||||
// Without a compact keepalive this is equivalent to checking Writer.Size().
|
||||
return OpenAICompactKeepaliveAdjustedWrittenSize(c) >= 0
|
||||
}
|
||||
|
||||
func openAIStreamEventIsPreamble(eventType string) bool {
|
||||
@@ -769,10 +782,10 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
|
||||
}
|
||||
combined := strings.TrimSpace(errType + " " + code + " " + strings.ToLower(strings.TrimSpace(message)))
|
||||
switch {
|
||||
case strings.Contains(errType, "invalid_request"):
|
||||
return http.StatusBadRequest
|
||||
case strings.Contains(combined, "rate_limit"):
|
||||
return http.StatusTooManyRequests
|
||||
case strings.Contains(errType, "invalid_request"):
|
||||
return http.StatusBadRequest
|
||||
case strings.Contains(combined, "authentication") || strings.Contains(combined, "unauthorized") || strings.Contains(combined, "invalid_api_key"):
|
||||
return http.StatusUnauthorized
|
||||
case strings.Contains(combined, "permission") || strings.Contains(combined, "forbidden") || strings.Contains(combined, "access denied"):
|
||||
@@ -784,6 +797,19 @@ func openAIStreamFailedEventSemanticStatus(payload []byte, message string) int {
|
||||
}
|
||||
}
|
||||
|
||||
func openAIStreamFailureStatus(payload []byte, message string) int {
|
||||
if len(bytes.TrimSpace(payload)) == 0 || !gjson.ValidBytes(payload) {
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
// Keep the existing 502 failover behavior for other response.failed events.
|
||||
// Only rate limits need promotion because they participate in the account's
|
||||
// configurable 429 same-account retry policy.
|
||||
if openAIStreamFailedEventSemanticStatus(payload, message) == http.StatusTooManyRequests {
|
||||
return http.StatusTooManyRequests
|
||||
}
|
||||
return http.StatusBadGateway
|
||||
}
|
||||
|
||||
func openAIStreamFailedEventPassthroughBody(payload []byte, failedMessage string) []byte {
|
||||
if len(payload) == 0 || !gjson.ValidBytes(payload) {
|
||||
return payload
|
||||
@@ -860,6 +886,12 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool
|
||||
if isOpenAIContextWindowError(message, payload) {
|
||||
return false
|
||||
}
|
||||
// A response.failed event is transported over HTTP 200. Prefer its semantic
|
||||
// rate-limit status over a generic/invalid_request error type so it can enter
|
||||
// the same 429 retry policy as a regular upstream HTTP response.
|
||||
if openAIStreamFailureStatus(payload, message) == http.StatusTooManyRequests {
|
||||
return true
|
||||
}
|
||||
if isOpenAITransientProcessingError(http.StatusBadRequest, message, payload) {
|
||||
return true
|
||||
}
|
||||
@@ -892,6 +924,15 @@ func openAIStreamFailedEventShouldFailover(payload []byte, message string) bool
|
||||
return true
|
||||
}
|
||||
|
||||
func openAIStreamFailedEventRetryableOnSameAccount(account *Account, payload []byte, message string) bool {
|
||||
if account == nil || !account.IsPoolMode() {
|
||||
return false
|
||||
}
|
||||
semanticStatus := openAIStreamFailedEventSemanticStatus(payload, message)
|
||||
return account.IsPoolModeRetryableStatus(semanticStatus) ||
|
||||
isOpenAITransientProcessingError(http.StatusBadRequest, message, payload)
|
||||
}
|
||||
|
||||
func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError(
|
||||
c *gin.Context,
|
||||
account *Account,
|
||||
@@ -905,6 +946,7 @@ func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError(
|
||||
if message == "" {
|
||||
message = "OpenAI upstream response failed"
|
||||
}
|
||||
statusCode := openAIStreamFailureStatus(payload, message)
|
||||
detail := ""
|
||||
if len(payload) > 0 && s != nil && s.cfg != nil && s.cfg.Gateway.LogUpstreamErrorBody {
|
||||
maxBytes := s.cfg.Gateway.LogUpstreamErrorBodyMaxBytes
|
||||
@@ -914,10 +956,10 @@ func (s *OpenAIGatewayService) recordOpenAIStreamUpstreamError(
|
||||
detail = truncateString(string(payload), maxBytes)
|
||||
}
|
||||
if c != nil {
|
||||
setOpsUpstreamError(c, http.StatusBadGateway, message, detail)
|
||||
setOpsUpstreamError(c, statusCode, message, detail)
|
||||
event := OpsUpstreamErrorEvent{
|
||||
Platform: PlatformOpenAI,
|
||||
UpstreamStatusCode: http.StatusBadGateway,
|
||||
UpstreamStatusCode: statusCode,
|
||||
UpstreamRequestID: strings.TrimSpace(upstreamRequestID),
|
||||
Passthrough: passthrough,
|
||||
Kind: kind,
|
||||
@@ -941,21 +983,36 @@ func (s *OpenAIGatewayService) newOpenAIStreamFailoverError(
|
||||
upstreamRequestID string,
|
||||
payload []byte,
|
||||
message string,
|
||||
responseHeaders ...http.Header,
|
||||
) *UpstreamFailoverError {
|
||||
message = sanitizeUpstreamErrorMessage(strings.TrimSpace(message))
|
||||
if message == "" {
|
||||
message = "OpenAI stream disconnected before completion"
|
||||
}
|
||||
statusCode := openAIStreamFailureStatus(payload, message)
|
||||
var headers http.Header
|
||||
if len(responseHeaders) > 0 && responseHeaders[0] != nil {
|
||||
headers = responseHeaders[0].Clone()
|
||||
}
|
||||
// 流内 failed 事件承载于 HTTP 200,响应头是正常配额快照而非限流信号,
|
||||
// 不写账号级限流/封禁状态;重试与切号由 failover 引擎按
|
||||
// StatusCode/RetryableOnSameAccount 决定。
|
||||
message = s.recordOpenAIStreamUpstreamError(c, account, passthrough, upstreamRequestID, "failover", payload, message)
|
||||
errType := "upstream_error"
|
||||
if statusCode == http.StatusTooManyRequests {
|
||||
errType = "rate_limit_error"
|
||||
}
|
||||
body, _ := json.Marshal(gin.H{
|
||||
"error": gin.H{
|
||||
"type": "upstream_error",
|
||||
"type": errType,
|
||||
"message": message,
|
||||
},
|
||||
})
|
||||
return &UpstreamFailoverError{
|
||||
StatusCode: http.StatusBadGateway,
|
||||
ResponseBody: body,
|
||||
StatusCode: statusCode,
|
||||
ResponseBody: body,
|
||||
ResponseHeaders: headers,
|
||||
RetryableOnSameAccount: openAIStreamFailedEventRetryableOnSameAccount(account, payload, message),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1110,7 +1167,7 @@ func (s *OpenAIGatewayService) handleStreamingResponsePassthrough(
|
||||
}
|
||||
if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) {
|
||||
return resultWithUsage(),
|
||||
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage)
|
||||
s.newOpenAIStreamFailoverError(c, account, true, upstreamRequestID, dataBytes, failedMessage, resp.Header)
|
||||
}
|
||||
}
|
||||
forceFlushFailedEvent = true
|
||||
|
||||
@@ -132,7 +132,14 @@ func sanitizeEncryptedReasoningInputItem(item any) (next any, changed bool, keep
|
||||
}
|
||||
|
||||
itemType, _ := inputItem["type"].(string)
|
||||
if strings.TrimSpace(itemType) != "reasoning" {
|
||||
switch strings.TrimSpace(itemType) {
|
||||
case "compaction", "compaction_summary":
|
||||
if _, encrypted := inputItem["encrypted_content"]; encrypted {
|
||||
return nil, true, false
|
||||
}
|
||||
return item, false, true
|
||||
case "reasoning":
|
||||
default:
|
||||
return item, false, true
|
||||
}
|
||||
|
||||
@@ -777,14 +784,13 @@ func normalizeOpenAIPassthroughOAuthBody(body []byte, compact bool) ([]byte, boo
|
||||
}
|
||||
|
||||
func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byte) string {
|
||||
model := strings.ToLower(strings.TrimSpace(reqModel))
|
||||
if !strings.Contains(model, "codex") {
|
||||
if !isOpenAICodexModel(reqModel) {
|
||||
return ""
|
||||
}
|
||||
|
||||
instructions := gjson.GetBytes(body, "instructions")
|
||||
if !instructions.Exists() {
|
||||
return "instructions_missing"
|
||||
return ""
|
||||
}
|
||||
if instructions.Type != gjson.String {
|
||||
return "instructions_not_string"
|
||||
@@ -795,6 +801,10 @@ func detectOpenAIPassthroughInstructionsRejectReason(reqModel string, body []byt
|
||||
return ""
|
||||
}
|
||||
|
||||
func isOpenAICodexModel(model string) bool {
|
||||
return strings.Contains(strings.ToLower(strings.TrimSpace(model)), "codex")
|
||||
}
|
||||
|
||||
// extractOpenAIReasoningEffortFromBody 按优先级传入模型候选(如 upstreamModel,
|
||||
// billingModel, originalModel):显式 effort 的模型归一化(max 保留判定)用第一个
|
||||
// 非空候选;body 未携带 effort 时的模型后缀推导依次尝试每个候选——OAuth 的
|
||||
|
||||
@@ -99,6 +99,47 @@ func TestTrimOpenAIEncryptedReasoningItems_NoReasoningItems(t *testing.T) {
|
||||
assert.False(t, changed)
|
||||
}
|
||||
|
||||
func TestTrimOpenAIEncryptedReasoningItems_Compaction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
itemType string
|
||||
encrypted bool
|
||||
changed bool
|
||||
}{
|
||||
{name: "compaction", itemType: "compaction", encrypted: true, changed: true},
|
||||
{name: "compaction summary", itemType: "compaction_summary", encrypted: true, changed: true},
|
||||
{name: "unencrypted compaction", itemType: "compaction", encrypted: false, changed: false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
item := map[string]any{"type": tt.itemType, "id": "cmp_stale"}
|
||||
if tt.encrypted {
|
||||
item["encrypted_content"] = "gAAA"
|
||||
}
|
||||
reqBody := map[string]any{"input": []any{
|
||||
item,
|
||||
map[string]any{"type": "message", "content": "hi"},
|
||||
}}
|
||||
|
||||
changed := trimOpenAIEncryptedReasoningItems(reqBody)
|
||||
assert.Equal(t, tt.changed, changed)
|
||||
input, ok := reqBody["input"].([]any)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, input)
|
||||
first, ok := input[0].(map[string]any)
|
||||
require.True(t, ok)
|
||||
if tt.changed {
|
||||
require.Len(t, input, 1)
|
||||
assert.Equal(t, "message", first["type"])
|
||||
return
|
||||
}
|
||||
require.Len(t, input, 2)
|
||||
assert.Equal(t, tt.itemType, first["type"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSanitizeOpenAICrossModeFailoverReasoning_DropsWholeEncryptedItem(t *testing.T) {
|
||||
body := []byte(`{"model":"gpt-5.1","input":[` +
|
||||
`{"type":"message","role":"user","content":"hi"},` +
|
||||
|
||||
@@ -449,7 +449,7 @@ func (s *OpenAIGatewayService) handleStreamingResponseWithReasoning(ctx context.
|
||||
}
|
||||
if openAIStreamFailedEventShouldFailover(dataBytes, failedMessage) {
|
||||
sawFailedEvent = true
|
||||
streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage)
|
||||
streamEarlyErr = s.newOpenAIStreamFailoverError(c, account, false, upstreamRequestID, dataBytes, failedMessage, resp.Header)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -593,6 +593,52 @@ func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDoesNotDecodeBeforeError(
|
||||
require.Equal(t, "summary_text", gjson.GetBytes(upstream.bodies[1], "input.0.summary.0.type").String())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_HTTPRetryRecoveryDropsCompaction(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := &httpUpstreamRecorder{
|
||||
responses: []*http.Response{
|
||||
{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"error":{"code":"invalid_encrypted_content","type":"invalid_request_error","message":"bad encrypted content"}}`)),
|
||||
},
|
||||
{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"usage":{"input_tokens":1,"output_tokens":2}}`)),
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{}
|
||||
cfg.Security.URLAllowlist.Enabled = false
|
||||
svc := &OpenAIGatewayService{cfg: cfg, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 10,
|
||||
Name: "openai-apikey",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-test",
|
||||
"base_url": "https://example.com",
|
||||
},
|
||||
Extra: map[string]any{"use_responses_api": true},
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
|
||||
|
||||
body := []byte(`{"model":"gpt-5.6-sol","stream":false,"input":[{"id":"cmp_stale","type":"compaction","encrypted_content":"gAAA"},{"type":"message","content":[{"type":"input_text","text":"hi"}]}]}`)
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 2)
|
||||
require.Equal(t, "compaction", gjson.GetBytes(upstream.bodies[0], "input.0.type").String())
|
||||
require.Equal(t, "message", gjson.GetBytes(upstream.bodies[1], "input.0.type").String())
|
||||
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1").Exists())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_Forward_CodexSparkRejectsEscapedInputImage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
upstream := &httpUpstreamRecorder{
|
||||
|
||||
@@ -1482,6 +1482,7 @@ func TestOpenAIStreamingResponseFailedBeforeOutputReturnsFailover(t *testing.T)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Contains(t, string(failoverErr.ResponseBody), "An error occurred while processing your request")
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, rec.Body.String())
|
||||
@@ -1518,11 +1519,21 @@ func TestOpenAIStreamingResponseFailedBeforeOutputCapacityErrorReturnsFailover(t
|
||||
Header: http.Header{"X-Request-Id": []string{"rid-capacity-failed"}},
|
||||
}
|
||||
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, &Account{ID: 1, Platform: PlatformOpenAI, Name: "acc"}, time.Now(), "model", "model")
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Name: "pool-account",
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
},
|
||||
}
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model")
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusBadGateway, failoverErr.StatusCode)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Contains(t, string(failoverErr.ResponseBody), "Selected model is at capacity")
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, rec.Body.String())
|
||||
@@ -1566,6 +1577,117 @@ func TestOpenAIStreamingResponseFailedBeforeOutputServerOverloadedCodeReturnsFai
|
||||
require.Empty(t, rec.Body.String())
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingResponseFailedBeforeOutputRateLimitUsesPoolRetryPolicy(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 0,
|
||||
StreamKeepaliveInterval: 0,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{cfg: cfg}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
|
||||
"event: response.created",
|
||||
`data: {"type":"response.created","response":{"id":"resp_1"}}`,
|
||||
"",
|
||||
"event: response.failed",
|
||||
`data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"rate_limit_exceeded","message":"Concurrency limit exceeded for account, please retry later"}}}`,
|
||||
"",
|
||||
}, "\n"))),
|
||||
Header: http.Header{
|
||||
"X-Request-Id": []string{"rid-rate-limit-failed"},
|
||||
"Retry-After": []string{"1"},
|
||||
},
|
||||
}
|
||||
account := &Account{
|
||||
ID: 1,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Name: "pool-account",
|
||||
Credentials: map[string]any{
|
||||
"pool_mode": true,
|
||||
"pool_mode_retry_count": float64(1),
|
||||
"pool_mode_retry_status_codes": []any{float64(http.StatusTooManyRequests)},
|
||||
},
|
||||
}
|
||||
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model")
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
|
||||
require.True(t, failoverErr.RetryableOnSameAccount)
|
||||
require.Equal(t, "1", failoverErr.ResponseHeaders.Get("Retry-After"))
|
||||
require.Equal(t, "rate_limit_error", gjson.GetBytes(failoverErr.ResponseBody, "error.type").String())
|
||||
require.Contains(t, string(failoverErr.ResponseBody), "Concurrency limit exceeded")
|
||||
require.False(t, c.Writer.Written())
|
||||
require.Empty(t, rec.Body.String())
|
||||
|
||||
opsVal, ok := c.Get(OpsUpstreamErrorsKey)
|
||||
require.True(t, ok)
|
||||
opsEvents, ok := opsVal.([]*OpsUpstreamErrorEvent)
|
||||
require.True(t, ok)
|
||||
require.NotEmpty(t, opsEvents)
|
||||
require.Equal(t, http.StatusTooManyRequests, opsEvents[len(opsEvents)-1].UpstreamStatusCode)
|
||||
}
|
||||
|
||||
// 流内 rate limit 只产生 failover 错误,不写账号级限流/封禁状态:
|
||||
// HTTP 200 流的 x-codex-* 头是正常配额快照,不能按 429 头驱动账号冷却。
|
||||
func TestOpenAIStreamingResponseFailedRateLimitDoesNotBlockAccountScheduling(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
Gateway: config.GatewayConfig{
|
||||
StreamDataIntervalTimeout: 0,
|
||||
StreamKeepaliveInterval: 0,
|
||||
MaxLineSize: defaultMaxLineSize,
|
||||
},
|
||||
}
|
||||
svc := &OpenAIGatewayService{cfg: cfg}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Body: io.NopCloser(strings.NewReader(strings.Join([]string{
|
||||
"event: response.created",
|
||||
`data: {"type":"response.created","response":{"id":"resp_1"}}`,
|
||||
"",
|
||||
"event: response.failed",
|
||||
`data: {"type":"response.failed","response":{"id":"resp_1","status":"failed","error":{"code":"rate_limit_exceeded","message":"Concurrency limit exceeded for account, please retry later"}}}`,
|
||||
"",
|
||||
}, "\n"))),
|
||||
Header: http.Header{
|
||||
"X-Codex-Primary-Used-Percent": []string{"12"},
|
||||
"X-Codex-Primary-Reset-After-Seconds": []string{"604800"},
|
||||
"Retry-After": []string{"1"},
|
||||
},
|
||||
}
|
||||
account := &Account{
|
||||
ID: 11,
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Name: "oauth-account",
|
||||
}
|
||||
|
||||
_, err := svc.handleStreamingResponse(c.Request.Context(), resp, c, account, time.Now(), "model", "model")
|
||||
require.Error(t, err)
|
||||
var failoverErr *UpstreamFailoverError
|
||||
require.ErrorAs(t, err, &failoverErr)
|
||||
require.Equal(t, http.StatusTooManyRequests, failoverErr.StatusCode)
|
||||
require.False(t, failoverErr.RetryableOnSameAccount)
|
||||
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
|
||||
}
|
||||
|
||||
func TestOpenAIStreamingResponseFailedAfterOutputSanitizesVerboseResponseForClient(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
cfg := &config.Config{
|
||||
|
||||
@@ -433,7 +433,77 @@ func TestOpenAIGatewayService_OAuthPassthrough_StreamKeepsToolNameAndBodyNormali
|
||||
require.NotContains(t, body, "\"name\":\"edit\"")
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
// 「自动透传(仅替换认证)」的默认行为必须真的只替换认证:namespace 声明、
|
||||
// namespace 形态的 tool_choice、历史调用项上的 namespace 都原样转发,只清掉
|
||||
// 非调用项上的残留 namespace(Codex 协议里只有调用项会带该字段)。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.144.1")
|
||||
|
||||
originalBody := []byte(`{
|
||||
"model":"gpt-5.5",
|
||||
"stream":true,
|
||||
"instructions":"local-test-instructions",
|
||||
"tools":[
|
||||
{"type":"function","name":"plain","description":"keep","parameters":{"type":"object"}},
|
||||
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]}
|
||||
],
|
||||
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"},
|
||||
"input":[
|
||||
{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"},
|
||||
{"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
upstreamSSE := strings.Join([]string{
|
||||
`data: {"type":"response.output_item.done","output_index":0,"item":{"type":"function_call","id":"fc_1","call_id":"call_1","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}}`,
|
||||
"",
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":2,"output_tokens":1,"total_tokens":3}}}`,
|
||||
"",
|
||||
"data: [DONE]",
|
||||
"",
|
||||
}, "\n")
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"text/event-stream"}, "x-request-id": []string{"rid_preserve"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 125, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Len(t, gjson.GetBytes(upstream.lastBody, "tools").Array(), 2)
|
||||
require.Equal(t, "namespace", gjson.GetBytes(upstream.lastBody, "tools.1.type").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tools.1.name").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tools.1.tools.0.name").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "tool_choice.name").String())
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(upstream.lastBody, "input.0.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists())
|
||||
require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String())
|
||||
require.NotContains(t, string(upstream.lastBody), "collaboration__spawn_agent")
|
||||
|
||||
// 未摊平即无需回程还原,上游事件原样下发。
|
||||
downstream := rec.Body.String()
|
||||
require.Contains(t, downstream, `"name":"spawn_agent"`)
|
||||
require.Contains(t, downstream, `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
// 兼容开关打开时的旧行为:摊平请求、回程还原。默认路径见
|
||||
// TestOpenAIGatewayService_OAuthPassthrough_PreservesNamespaceRequest。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
@@ -475,7 +545,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
|
||||
account := &Account{
|
||||
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
"openai_responses_flatten_namespaces": true,
|
||||
},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
@@ -501,7 +575,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
|
||||
require.Contains(t, downstream, `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
// 兼容开关打开时的旧行为;默认保留路径见
|
||||
// TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools。
|
||||
func TestOpenAIGatewayService_NativeOAuth_FlattenEnabledNamespaceRequestAndStreamResponse(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
@@ -529,6 +605,7 @@ func TestOpenAIGatewayService_NativeOAuth_NamespaceRequestAndStreamResponse(t *t
|
||||
account := &Account{
|
||||
ID: 124, Name: "native", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
@@ -596,7 +673,8 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceNonStreamingResponse(t *
|
||||
require.Contains(t, rec.Body.String(), `"namespace":"collaboration"`)
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadRequest(t *testing.T) {
|
||||
// 摊平名冲突只在兼容开关打开时才可能发生:默认保留 namespace,不存在平名冲突。
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_FlattenEnabledNamespaceCollisionReturnsBadRequest(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
@@ -614,7 +692,11 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceCollisionReturnsBadReque
|
||||
account := &Account{
|
||||
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true}, Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
Extra: map[string]any{
|
||||
"openai_passthrough": true,
|
||||
"openai_responses_flatten_namespaces": true,
|
||||
},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
@@ -727,57 +809,53 @@ func TestOpenAIGatewayService_OAuthPassthrough_UpstreamRequestIgnoresClientCance
|
||||
require.NoError(t, upstream.lastReq.Context().Err())
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsRejectedBeforeUpstream(t *testing.T) {
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_CodexMissingInstructionsGetsDefault(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
logSink, restore := captureStructuredLog(t)
|
||||
defer restore()
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses?trace=1", bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown")
|
||||
c.Request.Header.Set("Content-Type", "application/json")
|
||||
c.Request.Header.Set("OpenAI-Beta", "responses=experimental")
|
||||
for _, stream := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("stream=%t", stream), func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
path := "/v1/responses"
|
||||
responseBody := strings.Join([]string{
|
||||
`data: {"type":"response.completed","response":{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}}`,
|
||||
"", "data: [DONE]", "",
|
||||
}, "\n")
|
||||
responseContentType := "text/event-stream"
|
||||
if !stream {
|
||||
path = "/v1/responses/compact"
|
||||
responseBody = `{"id":"resp_1","status":"completed","output":[],"usage":{"input_tokens":1,"output_tokens":1}}`
|
||||
responseContentType = "application/json"
|
||||
}
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(nil))
|
||||
c.Request.Header.Set("User-Agent", "codex_cli_rs/0.98.0")
|
||||
|
||||
// Codex 模型且缺少 instructions,应在本地直接 403 拒绝,不触达上游。
|
||||
originalBody := []byte(`{"model":"gpt-5.1-codex-max","stream":false,"store":true,"input":[{"type":"text","text":"hi"}]}`)
|
||||
originalBody := []byte(fmt.Sprintf(`{"model":"gpt-5.1-codex-max","stream":%t,"store":true,"input":[{"type":"text","text":"hi"}]}`, stream))
|
||||
upstream := &httpUpstreamRecorder{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{responseContentType}, "x-request-id": []string{"rid"}},
|
||||
Body: io.NopCloser(strings.NewReader(responseBody)),
|
||||
}}
|
||||
svc := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
|
||||
account := &Account{
|
||||
ID: 123, Name: "acc", Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true, "openai_oauth_responses_websockets_v2_mode": OpenAIWSIngressModeOff},
|
||||
Status: StatusActive, Schedulable: true, RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{"rid"}},
|
||||
Body: io.NopCloser(strings.NewReader(`{"output":[],"usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
},
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
if stream {
|
||||
require.True(t, gjson.GetBytes(upstream.lastBody, "stream").Bool())
|
||||
} else {
|
||||
require.False(t, gjson.GetBytes(upstream.lastBody, "stream").Exists())
|
||||
}
|
||||
require.Equal(t, strings.TrimSpace(defaultCodexSynthInstructions("gpt-5.1-codex-max")), strings.TrimSpace(gjson.GetBytes(upstream.lastBody, "instructions").String()))
|
||||
})
|
||||
}
|
||||
|
||||
svc := &OpenAIGatewayService{
|
||||
cfg: &config.Config{Gateway: config.GatewayConfig{ForceCodexCLI: false}},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
account := &Account{
|
||||
ID: 123,
|
||||
Name: "acc",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{"access_token": "oauth-token", "chatgpt_account_id": "chatgpt-acc"},
|
||||
Extra: map[string]any{"openai_passthrough": true},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
RateMultiplier: f64p(1),
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, account, originalBody)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
require.Equal(t, http.StatusForbidden, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "requires a non-empty instructions field")
|
||||
require.Nil(t, upstream.lastReq)
|
||||
|
||||
require.True(t, logSink.ContainsMessage("OpenAI passthrough 本地拦截:Codex 请求缺少有效 instructions"))
|
||||
require.True(t, logSink.ContainsFieldValue("request_user_agent", "codex_cli_rs/0.98.0 (Windows 10.0.19045; x86_64) unknown"))
|
||||
require.True(t, logSink.ContainsFieldValue("reject_reason", "instructions_missing"))
|
||||
}
|
||||
|
||||
func TestOpenAIGatewayService_OAuthPassthrough_DisabledUsesLegacyTransform(t *testing.T) {
|
||||
|
||||
@@ -85,3 +85,20 @@ func TestNormalizeOpenAIPassthroughOAuthBody_ArrayInputUnchanged(t *testing.T) {
|
||||
require.Len(t, input.Array(), 1)
|
||||
require.Equal(t, "message", input.Array()[0].Get("type").String())
|
||||
}
|
||||
|
||||
func TestDetectOpenAIPassthroughInstructionsRejectReason(t *testing.T) {
|
||||
for _, tt := range []struct {
|
||||
name string
|
||||
body string
|
||||
want string
|
||||
}{
|
||||
{name: "missing is optional", body: `{"model":"gpt-5.1-codex"}`, want: ""},
|
||||
{name: "non string remains rejected", body: `{"instructions":{"text":"invalid"}}`, want: "instructions_not_string"},
|
||||
{name: "empty remains rejected", body: `{"instructions":" "}`, want: "instructions_empty"},
|
||||
{name: "non empty remains accepted", body: `{"instructions":"client guidance"}`, want: ""},
|
||||
} {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, detectOpenAIPassthroughInstructionsRejectReason("gpt-5.1-codex", []byte(tt.body)))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -14,14 +15,39 @@ import (
|
||||
const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names"
|
||||
|
||||
// shouldFlattenOpenAIResponsesNamespaces 判定原生 Responses 转发前是否摊平
|
||||
// Codex namespace 工具。WSv2 上游原生支持 namespace,且 WS 出口
|
||||
// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的
|
||||
// 平名无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求保持 namespace
|
||||
// 原样。透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
|
||||
func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
|
||||
// Codex namespace 工具。
|
||||
//
|
||||
// 默认不摊平:OAuth 账号的 HTTP 出口恒为 chatgpt.com/backend-api/codex/responses
|
||||
// (buildUpstreamRequest 只在 API Key 分支读 base_url),也就是 namespace 扩展的
|
||||
// 定义方本身;Codex 客户端对 WS 与 HTTP 两条传输发送同一份 tools(codex-rs
|
||||
// client.rs build_responses_request 无传输分支,WS 失败后会 session 级回落 HTTP
|
||||
// 继续发同样的声明)。摊平只改写工具名,改不掉客户端在 tools 描述与 developer
|
||||
// 消息里写死的 `to=functions.<namespace>.<tool>` 寻址约定,模型据此寻址必然落空
|
||||
// (issue #4978);命名空间名还可由 features.multi_agent_v2.tool_namespace 自定义、
|
||||
// 或由 MCP/connector 动态生成(mcp__codex_apps__gmail),保留名单枚举不完。
|
||||
//
|
||||
// compact 端点例外:已知它的 schema 比 /responses 窄(连 input[].namespace 都会报
|
||||
// Unknown parameter,见 issue #4761),而是否接受 namespace 工具声明没有任何实测
|
||||
// 证据;compact 只做历史摘要、不需要模型寻址工具,回程也没有工具调用可还原,因此
|
||||
// 保持 0.1.166 起就在跑的摊平行为,不随本次默认值翻转扩大风险面。
|
||||
//
|
||||
// 账号开关 openai_responses_flatten_namespaces 为不认识 namespace 的兼容上游保留
|
||||
// 退路,打开后恢复旧行为:WSv2 上游原生支持 namespace,且 WS 出口
|
||||
// (openai_ws_forwarder_v2)原样转发上游事件、不经 HTTP 回程还原,摊平后的平名
|
||||
// 无法还原会破坏客户端工具匹配,因此实际走 WSv2 分支的请求仍保持 namespace 原样;
|
||||
// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
|
||||
func shouldFlattenOpenAIResponsesNamespaces(
|
||||
account *Account,
|
||||
transport OpenAIUpstreamTransport,
|
||||
passthroughEnabled bool,
|
||||
compactPath bool,
|
||||
) bool {
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
return false
|
||||
}
|
||||
if !compactPath && !account.IsOpenAIResponsesFlattenNamespacesEnabled() {
|
||||
return false
|
||||
}
|
||||
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
|
||||
return false
|
||||
}
|
||||
@@ -41,6 +67,50 @@ func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenA
|
||||
return true
|
||||
}
|
||||
|
||||
// shouldKeepOpenAIResponsesToolCallNamespaces 判定清理 input 残留 namespace 时是否
|
||||
// 保留工具调用项上的 namespace。
|
||||
//
|
||||
// 上游对这个字段有两套互斥要求,判定按「出口 + 端点」而非工具声明内容:
|
||||
// - /backend-api/codex/responses 会按 namespace 解析历史调用,缺字段直接 400
|
||||
// `Missing namespace for function_call '...'. Round-trip the model's
|
||||
// function_call item with its namespace field included.`(issue #4761 回帖),
|
||||
// 故 OAuth 非 compact 请求必须保留。
|
||||
// - compact 端点的 schema 不含该字段,携带即 400 `Unknown parameter:
|
||||
// input[N].namespace`(issue #4761 正文),故 compact 一律清理。
|
||||
// - API Key 出口是标准 Responses API(api.openai.com 或自定义 base_url),同样
|
||||
// 不认识该字段,维持全量清理;否则只能退化成
|
||||
// openai_responses_rejected_field_retry 的逐项删除,6 次上限根本盖不住长历史。
|
||||
// - 摊平模式下调用项已被改写成平名,残留 namespace 指向的声明已不存在,一律清理。
|
||||
func shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
account *Account,
|
||||
transport OpenAIUpstreamTransport,
|
||||
passthroughEnabled bool,
|
||||
compactPath bool,
|
||||
) bool {
|
||||
if account == nil || !account.IsOpenAIOAuth() {
|
||||
return false
|
||||
}
|
||||
if compactPath {
|
||||
return false
|
||||
}
|
||||
return !shouldFlattenOpenAIResponsesNamespaces(account, transport, passthroughEnabled, compactPath)
|
||||
}
|
||||
|
||||
// openAIResponsesToolCallItemTypes 是携带 namespace 的调用项类型集合。与
|
||||
// removeOpenAIResponsesRejectedNamespaceAtIndex 的反应式白名单保持一致;codex-rs
|
||||
// protocol/src/models.rs 中只有 FunctionCall 与 CustomToolCall 序列化 namespace,
|
||||
// 其余类型带该字段一定是非 Codex 客户端或历史残留,清掉才安全。
|
||||
var openAIResponsesToolCallItemTypes = map[string]bool{
|
||||
"function_call": true,
|
||||
"tool_call": true,
|
||||
"custom_tool_call": true,
|
||||
"mcp_tool_call": true,
|
||||
}
|
||||
|
||||
func isOpenAIResponsesToolCallItemType(itemType string) bool {
|
||||
return openAIResponsesToolCallItemTypes[strings.ToLower(strings.TrimSpace(itemType))]
|
||||
}
|
||||
|
||||
func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) {
|
||||
if !bytes.Contains(body, []byte(`"namespace"`)) {
|
||||
return body, nil
|
||||
@@ -68,7 +138,11 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro
|
||||
// array items. Namespace declarations and nested namespace fields are left
|
||||
// untouched. Rebuilding the input array once keeps this linear for long
|
||||
// histories and avoids decoding JSON numbers through float64.
|
||||
func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
//
|
||||
// keepToolCallNamespaces 保留工具调用项(function_call / custom_tool_call 等)上的
|
||||
// namespace,让 Codex 调用能按上游要求原样回传;判定见
|
||||
// shouldKeepOpenAIResponsesToolCallNamespaces。
|
||||
func stripOpenAIResponsesInputNamespaces(body []byte, keepToolCallNamespaces bool) ([]byte, error) {
|
||||
if !bytes.Contains(body, []byte(`"namespace"`)) {
|
||||
return body, nil
|
||||
}
|
||||
@@ -89,7 +163,10 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
}
|
||||
first = false
|
||||
itemBody := []byte(item.Raw)
|
||||
if item.IsObject() && item.Get("namespace").Exists() {
|
||||
// 先判存在再判类型:长历史里绝大多数是 message/reasoning 等不带 namespace
|
||||
// 的项,这样它们无需再扫一次 type。
|
||||
if item.IsObject() && item.Get("namespace").Exists() &&
|
||||
(!keepToolCallNamespaces || !isOpenAIResponsesToolCallItemType(item.Get("type").String())) {
|
||||
itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace")
|
||||
if stripErr != nil {
|
||||
return false
|
||||
@@ -99,13 +176,13 @@ func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
|
||||
_, _ = rebuilt.Write(itemBody)
|
||||
return true
|
||||
})
|
||||
_ = rebuilt.WriteByte(']')
|
||||
if stripErr != nil {
|
||||
return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr)
|
||||
}
|
||||
if !changed {
|
||||
return body, nil
|
||||
}
|
||||
_ = rebuilt.WriteByte(']')
|
||||
stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes())
|
||||
if err != nil {
|
||||
return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err)
|
||||
@@ -119,6 +196,18 @@ func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat
|
||||
}
|
||||
}
|
||||
|
||||
// clearOpenAIResponsesNamespaceNames 清除上一次尝试登记的摊平名映射。handler 的
|
||||
// failover 会在同一个 *gin.Context 上重试下一个账号,映射不清会让保留 namespace 的
|
||||
// 账号拿着上一个账号的摊平名做回程还原。
|
||||
func clearOpenAIResponsesNamespaceNames(c *gin.Context) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
if _, exists := c.Get(openAIResponsesNamespaceNamesContextKey); exists {
|
||||
c.Set(openAIResponsesNamespaceNamesContextKey, map[string]apicompat.ResponsesNamespaceName(nil))
|
||||
}
|
||||
}
|
||||
|
||||
func openAIResponsesNamespaceNames(c *gin.Context) map[string]apicompat.ResponsesNamespaceName {
|
||||
if c == nil {
|
||||
return nil
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
// codexNamespaceRequestBody 模拟 Codex 多智能体请求:collaboration 命名空间声明 +
|
||||
// 历史里的命名空间调用项 + 带残留 namespace 的普通消息项。
|
||||
const codexNamespaceRequestBody = `{
|
||||
"model":"gpt-5.6-terra",
|
||||
"stream":false,
|
||||
"instructions":"test",
|
||||
"tools":[
|
||||
{"type":"namespace","name":"collaboration","description":"Tools for spawning and managing sub-agents.","tools":[
|
||||
{"type":"function","name":"spawn_agent","description":"Call as to=functions.collaboration.spawn_agent","parameters":{"type":"object"}},
|
||||
{"type":"function","name":"wait_agent","parameters":{"type":"object"}}
|
||||
]},
|
||||
{"type":"function","name":"exec","parameters":{"type":"object"}}
|
||||
],
|
||||
"input":[
|
||||
{"type":"function_call","namespace":"collaboration","name":"spawn_agent","call_id":"call_1","arguments":"{}"},
|
||||
{"type":"message","role":"user","namespace":"leftover","content":[{"type":"input_text","text":"hello"}]}
|
||||
]
|
||||
}`
|
||||
|
||||
const namespaceForwardOKResponse = `{"id":"resp_ns","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`
|
||||
|
||||
// OAuth 出口即 namespace 扩展的定义方:声明必须原样送达,历史调用项必须保留
|
||||
// namespace(缺字段上游会 400 "Missing namespace for function_call"),而非调用项上的
|
||||
// 残留 namespace 仍要清掉。回归 issue #4978。
|
||||
func TestOpenAIGatewayService_OAuthPreservesCodexNamespaceTools(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
namespaceTool := gjson.GetBytes(forwarded, `tools.#(type=="namespace")`)
|
||||
require.True(t, namespaceTool.Exists(), "namespace 声明必须原样转发")
|
||||
require.Equal(t, "collaboration", namespaceTool.Get("name").String())
|
||||
require.Equal(t, "spawn_agent", namespaceTool.Get("tools.0.name").String())
|
||||
require.Equal(t, "wait_agent", namespaceTool.Get("tools.1.name").String())
|
||||
// 摊平名一旦出现,模型就无法按工具描述里的 to=functions.collaboration.spawn_agent 寻址。
|
||||
require.NotContains(t, string(forwarded), "collaboration__spawn_agent")
|
||||
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(forwarded, "input.0.namespace").String())
|
||||
require.Equal(t, "spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
|
||||
// 未摊平即无需回程还原,不得登记映射。
|
||||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||||
}
|
||||
|
||||
// compact 端点 schema 更窄:input[].namespace 会 400 Unknown parameter(issue #4761),
|
||||
// 且没有证据表明它接受 namespace 工具声明。compact 只做历史摘要、不需要模型寻址工具,
|
||||
// 因此保持既有的摊平 + 全量清理行为,不随默认值翻转扩大风险面。
|
||||
func TestOpenAIGatewayService_OAuthCompactKeepsFlattening(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
c.Request.URL.Path = "/v1/responses/compact"
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||||
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
}
|
||||
|
||||
// 账号开关为不认识 namespace 的兼容上游保留退路:打开后恢复 0.1.166 的摊平行为。
|
||||
func TestOpenAIGatewayService_OAuthFlattenFlagRestoresLegacyBehavior(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
account := newOpenAIOAuthNamespaceTestAccount()
|
||||
account.Extra = map[string]any{"openai_responses_flatten_namespaces": true}
|
||||
|
||||
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, account, body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.Len(t, upstream.bodies, 1)
|
||||
forwarded := upstream.bodies[0]
|
||||
|
||||
require.False(t, gjson.GetBytes(forwarded, `tools.#(type=="namespace")`).Exists())
|
||||
require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__spawn_agent")`).Exists())
|
||||
require.True(t, gjson.GetBytes(forwarded, `tools.#(name=="collaboration__wait_agent")`).Exists())
|
||||
// 摊平后调用项已改写成平名,不得再带 namespace。
|
||||
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(forwarded, "input.0.name").String())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.0.namespace").Exists())
|
||||
require.False(t, gjson.GetBytes(forwarded, "input.1.namespace").Exists())
|
||||
|
||||
names := openAIResponsesNamespaceNames(c)
|
||||
require.Equal(t,
|
||||
apicompat.ResponsesNamespaceName{Namespace: "collaboration", Name: "spawn_agent"},
|
||||
names["collaboration__spawn_agent"],
|
||||
)
|
||||
}
|
||||
|
||||
// handler 的 failover 在同一个 *gin.Context 上重试下一个账号;保留 namespace 的账号
|
||||
// 不得沿用上一个账号登记的摊平名映射做回程还原。
|
||||
func TestOpenAIGatewayService_ForwardClearsStaleNamespaceNames(t *testing.T) {
|
||||
body := []byte(codexNamespaceRequestBody)
|
||||
upstream := &httpUpstreamRecorder{responses: []*http.Response{
|
||||
newOpenAIRejectedFieldTestResponse(http.StatusOK, namespaceForwardOKResponse),
|
||||
}}
|
||||
c := newOpenAIRejectedFieldTestContext(body)
|
||||
setOpenAIResponsesNamespaceNames(c, map[string]apicompat.ResponsesNamespaceName{
|
||||
"stale__tool": {Namespace: "stale", Name: "tool"},
|
||||
})
|
||||
|
||||
_, err := newOpenAIRejectedFieldTestService(upstream).Forward(
|
||||
context.Background(), c, newOpenAIOAuthNamespaceTestAccount(), body,
|
||||
)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Empty(t, openAIResponsesNamespaceNames(c))
|
||||
}
|
||||
@@ -12,27 +12,96 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}
|
||||
// 账号级兼容开关:为不认识 namespace 的兼容上游恢复旧的摊平行为。
|
||||
flattenOAuth := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
flattenAPIKey := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
transport OpenAIUpstreamTransport
|
||||
passthroughEnabled bool
|
||||
compactPath bool
|
||||
want bool
|
||||
}{
|
||||
{name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_http_passthrough", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// 默认保留:OAuth 出口是 namespace 扩展的定义方,摊平会让模型无法按
|
||||
// `to=functions.<namespace>.<tool>` 寻址(issue #4978)。
|
||||
{name: "oauth_http_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "oauth_http_passthrough_default_preserves", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: false},
|
||||
{name: "oauth_wsv2_default_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// compact 端点 schema 更窄且无实测证据,保持既有摊平行为。
|
||||
{name: "oauth_compact_flattens", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: true},
|
||||
{name: "oauth_compact_wsv2_preserves", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
|
||||
{name: "apikey_compact", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
|
||||
{name: "oauth_flatten_enabled_http", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_flatten_enabled_http_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// WSv2 出口原样转发上游事件、不做回程还原,摊平会让客户端收到无法匹配的平名。
|
||||
{name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// 透传账号先于 WSv2 分支经 HTTP 转发返回,仍需摊平。
|
||||
{name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
|
||||
{name: "oauth_flatten_enabled_wsv2", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
|
||||
// 透传账号先于 WSv2 分支经 HTTP 转发返回,开关打开时仍需摊平。
|
||||
{name: "oauth_flatten_enabled_wsv2_passthrough", account: flattenOAuth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
|
||||
// 开关仅对 OAuth 生效:API Key 走 chat completions 回退桥时由桥自行摊平。
|
||||
{name: "apikey_flatten_enabled_http", account: flattenAPIKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(tt.account, tt.transport, tt.passthroughEnabled))
|
||||
require.Equal(t, tt.want, shouldFlattenOpenAIResponsesNamespaces(
|
||||
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestShouldKeepOpenAIResponsesToolCallNamespaces(t *testing.T) {
|
||||
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
|
||||
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
|
||||
setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
|
||||
flattenOAuth := &Account{
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"openai_responses_flatten_namespaces": true},
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
account *Account
|
||||
transport OpenAIUpstreamTransport
|
||||
passthroughEnabled bool
|
||||
compactPath bool
|
||||
want bool
|
||||
}{
|
||||
// 上游按 namespace 解析历史调用,缺字段会 400 "Missing namespace for function_call"。
|
||||
{name: "oauth_http_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
|
||||
{name: "oauth_http_passthrough_keeps", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, passthroughEnabled: true, want: true},
|
||||
// compact 端点 schema 不含该字段,携带即 400 "Unknown parameter: input[N].namespace"。
|
||||
{name: "oauth_compact_strips", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, compactPath: true, want: false},
|
||||
// 摊平后调用项已是平名,残留 namespace 指向的声明不存在。
|
||||
{name: "oauth_flatten_enabled_strips", account: flattenOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
// WSv2 实际由 shouldStrip 提前短路,此处只钉住策略本身的取值。
|
||||
{name: "oauth_wsv2_keeps", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: true},
|
||||
// WSv2 + compact 是唯一「不摊平但仍必须清理」的组合,钉住 compact 判定本身,
|
||||
// 使其不会被误当成可由 shouldFlatten 推导出的冗余分支。
|
||||
{name: "oauth_compact_wsv2_strips", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, compactPath: true, want: false},
|
||||
// API Key 出口是标准 Responses API,不认识该字段。
|
||||
{name: "apikey_strips", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "setup_token_strips", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, shouldKeepOpenAIResponsesToolCallNamespaces(
|
||||
tt.account, tt.transport, tt.passthroughEnabled, tt.compactPath,
|
||||
))
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -85,7 +154,7 @@ func TestStripOpenAIResponsesInputNamespaces(t *testing.T) {
|
||||
]
|
||||
}`)
|
||||
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body)
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, false)
|
||||
require.NoError(t, err)
|
||||
for index := 0; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
@@ -106,8 +175,61 @@ func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testin
|
||||
[]byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`),
|
||||
}
|
||||
for _, body := range tests {
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, stripped)
|
||||
for _, keepToolCallNamespaces := range []bool{false, true} {
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, keepToolCallNamespaces)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, body, stripped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 保留模式下只有工具调用项留住 namespace:上游按 namespace 解析历史调用,
|
||||
// 而 message / reasoning / 输出项带该字段会被 schema 拒绝。
|
||||
func TestStripOpenAIResponsesInputNamespacesKeepsToolCallNamespaces(t *testing.T) {
|
||||
body := []byte(`{
|
||||
"meta":9007199254740993,
|
||||
"input":[
|
||||
{"type":"function_call","namespace":"collaboration","name":"spawn_agent","arguments":"{}","large":9007199254740993},
|
||||
{"type":"custom_tool_call","namespace":"codex_app","name":"exec","input":"{}"},
|
||||
{"type":"tool_call","namespace":"mcp__codex_apps__gmail","name":"send"},
|
||||
{"type":"mcp_tool_call","namespace":"mcp__codex_apps__gmail","name":"list"},
|
||||
{"type":"message","namespace":"leftover","role":"assistant","content":[{"type":"output_text","text":"hi"}]},
|
||||
{"type":"function_call_output","namespace":"leftover","output":"ok"},
|
||||
{"type":"reasoning","namespace":"leftover"},
|
||||
{"type":"item","namespace":"leftover"}
|
||||
]
|
||||
}`)
|
||||
|
||||
stripped, err := stripOpenAIResponsesInputNamespaces(body, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "collaboration", gjson.GetBytes(stripped, "input.0.namespace").String())
|
||||
require.Equal(t, "codex_app", gjson.GetBytes(stripped, "input.1.namespace").String())
|
||||
require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.2.namespace").String())
|
||||
require.Equal(t, "mcp__codex_apps__gmail", gjson.GetBytes(stripped, "input.3.namespace").String())
|
||||
for index := 4; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
}
|
||||
// 大整数不得经 float64 往返。
|
||||
require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw)
|
||||
require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw)
|
||||
|
||||
// 类型比对不区分大小写与首尾空白。
|
||||
mixedCase := []byte(`{"input":[{"type":" Function_Call ","namespace":"collaboration","name":"spawn_agent"}]}`)
|
||||
keptMixedCase, err := stripOpenAIResponsesInputNamespaces(mixedCase, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, mixedCase, keptMixedCase)
|
||||
|
||||
// 全部为调用项时无改动,应原样返回。
|
||||
callsOnly := []byte(`{"input":[{"type":"function_call","namespace":"collaboration","name":"spawn_agent"}]}`)
|
||||
unchanged, err := stripOpenAIResponsesInputNamespaces(callsOnly, true)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, callsOnly, unchanged)
|
||||
|
||||
// 关闭保留时回到全量清理。
|
||||
strippedAll, err := stripOpenAIResponsesInputNamespaces(body, false)
|
||||
require.NoError(t, err)
|
||||
for index := 0; index < 8; index++ {
|
||||
require.False(t, gjson.GetBytes(strippedAll, "input."+strconv.Itoa(index)+".namespace").Exists())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1537,7 +1537,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t
|
||||
},
|
||||
}
|
||||
|
||||
body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"input_text","text":"hello"}]}`)
|
||||
body := []byte(`{"model":"gpt-5.3-codex","stream":false,"previous_response_id":"resp_prev_encrypted","input":[{"type":"reasoning","encrypted_content":"gAAA"},{"type":"compaction","encrypted_content":"cAAA"},{"type":"input_text","text":"hello"}]}`)
|
||||
result, err := svc.Forward(context.Background(), c, account, body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
@@ -1553,6 +1553,7 @@ func TestOpenAIGatewayService_Forward_WSv2InvalidEncryptedContentRecoversOnce(t
|
||||
require.Len(t, requests, 2)
|
||||
require.True(t, gjson.GetBytes(requests[0], "previous_response_id").Exists(), "首轮请求应保留 previous_response_id")
|
||||
require.True(t, gjson.GetBytes(requests[0], `input.0.encrypted_content`).Exists(), "首轮请求应保留 encrypted reasoning")
|
||||
require.True(t, gjson.GetBytes(requests[0], `input.1.encrypted_content`).Exists(), "首轮请求应保留 encrypted compaction")
|
||||
require.False(t, gjson.GetBytes(requests[1], "previous_response_id").Exists(), "恢复重试应移除 previous_response_id")
|
||||
require.False(t, gjson.GetBytes(requests[1], `input.0.encrypted_content`).Exists(), "恢复重试应移除 encrypted reasoning item")
|
||||
require.Equal(t, "input_text", gjson.GetBytes(requests[1], `input.0.type`).String())
|
||||
|
||||
@@ -173,7 +173,13 @@ func Relay(
|
||||
return writeUpstream(msgType, payload)
|
||||
}
|
||||
writeClient := func(msgType coderws.MessageType, payload []byte) error {
|
||||
writeCtx, cancel := context.WithTimeout(relayCtx, writeTimeout)
|
||||
// 下行写超时故意不挂在 relayCtx 上:coder/websocket 在已武装的 write
|
||||
// ctx 被取消时会直接硬关连接(context.AfterFunc 的 stop 不等待执行中
|
||||
// 的回调),外部取消若落在一次已成功写入的解除武装窗口内,会连同尚未
|
||||
// 发出的 close 帧一起冲掉,客户端只能看到裸 EOF 而收不到关闭码。与读
|
||||
// 侧 conn.Read(context.Background()) 同理,取消路径的连接回收由各退出
|
||||
// 分支的显式 Close/CloseNow 兜底。
|
||||
writeCtx, cancel := context.WithTimeout(context.Background(), writeTimeout)
|
||||
defer cancel()
|
||||
return clientConn.WriteFrame(writeCtx, msgType, payload)
|
||||
}
|
||||
|
||||
@@ -343,38 +343,87 @@ func (s *PaymentConfigService) UpdatePaymentConfig(ctx context.Context, req Upda
|
||||
return infraerrors.BadRequest("INVALID_RECHARGE_FEE_RATE", "recharge fee rate allows at most 2 decimal places")
|
||||
}
|
||||
}
|
||||
m := map[string]string{
|
||||
SettingPaymentEnabled: formatBoolOrEmpty(req.Enabled),
|
||||
SettingMinRechargeAmount: formatPositiveFloat(req.MinAmount),
|
||||
SettingMaxRechargeAmount: formatPositiveFloat(req.MaxAmount),
|
||||
SettingDailyRechargeLimit: formatPositiveFloat(req.DailyLimit),
|
||||
SettingOrderTimeoutMinutes: formatPositiveInt(req.OrderTimeoutMin),
|
||||
SettingMaxPendingOrders: formatPositiveInt(req.MaxPendingOrders),
|
||||
SettingBalancePayDisabled: formatBoolOrEmpty(req.BalanceDisabled),
|
||||
SettingBalanceRechargeMult: formatPositiveFloat(req.BalanceRechargeMultiplier),
|
||||
SettingSubscriptionUSDToCNYRate: formatPositiveFloatExact(req.SubscriptionUSDToCNYRate),
|
||||
SettingRechargeFeeRate: formatNonNegativeFloat(req.RechargeFeeRate),
|
||||
SettingLoadBalanceStrategy: derefStr(req.LoadBalanceStrategy),
|
||||
SettingProductNamePrefix: derefStr(req.ProductNamePrefix),
|
||||
SettingProductNameSuffix: derefStr(req.ProductNameSuffix),
|
||||
SettingHelpImageURL: derefStr(req.HelpImageURL),
|
||||
SettingHelpText: derefStr(req.HelpText),
|
||||
SettingCancelRateLimitOn: formatBoolOrEmpty(req.CancelRateLimitEnabled),
|
||||
SettingCancelRateLimitMax: formatPositiveInt(req.CancelRateLimitMax),
|
||||
SettingCancelWindowSize: formatPositiveInt(req.CancelRateLimitWindow),
|
||||
SettingCancelWindowUnit: derefStr(req.CancelRateLimitUnit),
|
||||
SettingCancelWindowMode: derefStr(req.CancelRateLimitMode),
|
||||
SettingAlipayForceQRCode: formatBoolOrEmpty(req.AlipayForceQRCode),
|
||||
SettingAlipayMobilePrecreateDeepLink: formatBoolOrEmpty(req.AlipayMobilePrecreateDeepLink),
|
||||
SettingPaymentVisibleMethodAlipaySource: derefStr(req.VisibleMethodAlipaySource),
|
||||
SettingPaymentVisibleMethodWxpaySource: derefStr(req.VisibleMethodWxpaySource),
|
||||
SettingPaymentVisibleMethodAlipayEnabled: formatBoolOrEmpty(req.VisibleMethodAlipayEnabled),
|
||||
SettingPaymentVisibleMethodWxpayEnabled: formatBoolOrEmpty(req.VisibleMethodWxpayEnabled),
|
||||
m := make(map[string]string)
|
||||
if req.Enabled != nil {
|
||||
m[SettingPaymentEnabled] = formatBoolOrEmpty(req.Enabled)
|
||||
}
|
||||
if req.MinAmount != nil {
|
||||
m[SettingMinRechargeAmount] = formatPositiveFloat(req.MinAmount)
|
||||
}
|
||||
if req.MaxAmount != nil {
|
||||
m[SettingMaxRechargeAmount] = formatPositiveFloat(req.MaxAmount)
|
||||
}
|
||||
if req.DailyLimit != nil {
|
||||
m[SettingDailyRechargeLimit] = formatPositiveFloat(req.DailyLimit)
|
||||
}
|
||||
if req.OrderTimeoutMin != nil {
|
||||
m[SettingOrderTimeoutMinutes] = formatPositiveInt(req.OrderTimeoutMin)
|
||||
}
|
||||
if req.MaxPendingOrders != nil {
|
||||
m[SettingMaxPendingOrders] = formatPositiveInt(req.MaxPendingOrders)
|
||||
}
|
||||
if req.EnabledTypes != nil {
|
||||
m[SettingEnabledPaymentTypes] = strings.Join(req.EnabledTypes, ",")
|
||||
} else {
|
||||
m[SettingEnabledPaymentTypes] = ""
|
||||
}
|
||||
if req.BalanceDisabled != nil {
|
||||
m[SettingBalancePayDisabled] = formatBoolOrEmpty(req.BalanceDisabled)
|
||||
}
|
||||
if req.BalanceRechargeMultiplier != nil {
|
||||
m[SettingBalanceRechargeMult] = formatPositiveFloat(req.BalanceRechargeMultiplier)
|
||||
}
|
||||
if req.SubscriptionUSDToCNYRate != nil {
|
||||
m[SettingSubscriptionUSDToCNYRate] = formatPositiveFloatExact(req.SubscriptionUSDToCNYRate)
|
||||
}
|
||||
if req.RechargeFeeRate != nil {
|
||||
m[SettingRechargeFeeRate] = formatNonNegativeFloat(req.RechargeFeeRate)
|
||||
}
|
||||
if req.LoadBalanceStrategy != nil {
|
||||
m[SettingLoadBalanceStrategy] = derefStr(req.LoadBalanceStrategy)
|
||||
}
|
||||
if req.ProductNamePrefix != nil {
|
||||
m[SettingProductNamePrefix] = derefStr(req.ProductNamePrefix)
|
||||
}
|
||||
if req.ProductNameSuffix != nil {
|
||||
m[SettingProductNameSuffix] = derefStr(req.ProductNameSuffix)
|
||||
}
|
||||
if req.HelpImageURL != nil {
|
||||
m[SettingHelpImageURL] = derefStr(req.HelpImageURL)
|
||||
}
|
||||
if req.HelpText != nil {
|
||||
m[SettingHelpText] = derefStr(req.HelpText)
|
||||
}
|
||||
if req.CancelRateLimitEnabled != nil {
|
||||
m[SettingCancelRateLimitOn] = formatBoolOrEmpty(req.CancelRateLimitEnabled)
|
||||
}
|
||||
if req.CancelRateLimitMax != nil {
|
||||
m[SettingCancelRateLimitMax] = formatPositiveInt(req.CancelRateLimitMax)
|
||||
}
|
||||
if req.CancelRateLimitWindow != nil {
|
||||
m[SettingCancelWindowSize] = formatPositiveInt(req.CancelRateLimitWindow)
|
||||
}
|
||||
if req.CancelRateLimitUnit != nil {
|
||||
m[SettingCancelWindowUnit] = derefStr(req.CancelRateLimitUnit)
|
||||
}
|
||||
if req.CancelRateLimitMode != nil {
|
||||
m[SettingCancelWindowMode] = derefStr(req.CancelRateLimitMode)
|
||||
}
|
||||
if req.AlipayForceQRCode != nil {
|
||||
m[SettingAlipayForceQRCode] = formatBoolOrEmpty(req.AlipayForceQRCode)
|
||||
}
|
||||
if req.AlipayMobilePrecreateDeepLink != nil {
|
||||
m[SettingAlipayMobilePrecreateDeepLink] = formatBoolOrEmpty(req.AlipayMobilePrecreateDeepLink)
|
||||
}
|
||||
if req.VisibleMethodAlipaySource != nil {
|
||||
m[SettingPaymentVisibleMethodAlipaySource] = derefStr(req.VisibleMethodAlipaySource)
|
||||
}
|
||||
if req.VisibleMethodWxpaySource != nil {
|
||||
m[SettingPaymentVisibleMethodWxpaySource] = derefStr(req.VisibleMethodWxpaySource)
|
||||
}
|
||||
if req.VisibleMethodAlipayEnabled != nil {
|
||||
m[SettingPaymentVisibleMethodAlipayEnabled] = formatBoolOrEmpty(req.VisibleMethodAlipayEnabled)
|
||||
}
|
||||
if req.VisibleMethodWxpayEnabled != nil {
|
||||
m[SettingPaymentVisibleMethodWxpayEnabled] = formatBoolOrEmpty(req.VisibleMethodWxpayEnabled)
|
||||
}
|
||||
return s.settingRepo.SetMultiple(ctx, m)
|
||||
}
|
||||
|
||||
@@ -441,7 +441,12 @@ func (s *paymentConfigSettingRepoStub) GetAll(context.Context) (map[string]strin
|
||||
func (s *paymentConfigSettingRepoStub) Delete(context.Context, string) error { return nil }
|
||||
|
||||
func TestUpdatePaymentConfig_PersistsVisibleMethodRouting(t *testing.T) {
|
||||
repo := &paymentConfigSettingRepoStub{values: map[string]string{}}
|
||||
repo := &paymentConfigSettingRepoStub{values: map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "false",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceOfficialAlipay,
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "true",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceEasyPayWechat,
|
||||
}}
|
||||
svc := &PaymentConfigService{settingRepo: repo}
|
||||
|
||||
alipayEnabled := true
|
||||
@@ -470,6 +475,82 @@ func TestUpdatePaymentConfig_PersistsVisibleMethodRouting(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePaymentConfig_OmittedVisibleMethodRoutingIsPreserved(t *testing.T) {
|
||||
wantVisibleMethods := map[string]string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled: "true",
|
||||
SettingPaymentVisibleMethodAlipaySource: VisibleMethodSourceEasyPayAlipay,
|
||||
SettingPaymentVisibleMethodWxpayEnabled: "false",
|
||||
SettingPaymentVisibleMethodWxpaySource: VisibleMethodSourceOfficialWechat,
|
||||
}
|
||||
initial := make(map[string]string, len(wantVisibleMethods))
|
||||
for key, value := range wantVisibleMethods {
|
||||
initial[key] = value
|
||||
}
|
||||
repo := &paymentConfigSettingRepoStub{values: initial}
|
||||
svc := &PaymentConfigService{settingRepo: repo}
|
||||
|
||||
enabled := true
|
||||
err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{Enabled: &enabled})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePaymentConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
visibleMethodKeys := []string{
|
||||
SettingPaymentVisibleMethodAlipayEnabled,
|
||||
SettingPaymentVisibleMethodAlipaySource,
|
||||
SettingPaymentVisibleMethodWxpayEnabled,
|
||||
SettingPaymentVisibleMethodWxpaySource,
|
||||
}
|
||||
for _, key := range visibleMethodKeys {
|
||||
if _, ok := repo.updates[key]; ok {
|
||||
t.Fatalf("omitted visible method setting %q was written", key)
|
||||
}
|
||||
if repo.values[key] != wantVisibleMethods[key] {
|
||||
t.Fatalf("visible method setting %q = %q, want preserved value %q", key, repo.values[key], wantVisibleMethods[key])
|
||||
}
|
||||
}
|
||||
if repo.updates[SettingPaymentEnabled] != "true" {
|
||||
t.Fatalf("payment enabled update = %q, want true", repo.updates[SettingPaymentEnabled])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdatePaymentConfig_PersistsExplicitEmptyAndFalseValues(t *testing.T) {
|
||||
repo := &paymentConfigSettingRepoStub{values: map[string]string{
|
||||
SettingEnabledPaymentTypes: "alipay,wxpay",
|
||||
SettingBalancePayDisabled: "true",
|
||||
SettingProductNamePrefix: "existing",
|
||||
}}
|
||||
svc := &PaymentConfigService{settingRepo: repo}
|
||||
|
||||
falseValue := false
|
||||
emptyString := ""
|
||||
err := svc.UpdatePaymentConfig(context.Background(), UpdatePaymentConfigRequest{
|
||||
EnabledTypes: []string{},
|
||||
BalanceDisabled: &falseValue,
|
||||
ProductNamePrefix: &emptyString,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("UpdatePaymentConfig returned error: %v", err)
|
||||
}
|
||||
|
||||
want := map[string]string{
|
||||
SettingEnabledPaymentTypes: "",
|
||||
SettingBalancePayDisabled: "false",
|
||||
SettingProductNamePrefix: "",
|
||||
}
|
||||
if len(repo.updates) != len(want) {
|
||||
t.Fatalf("updates = %v, want exactly %v", repo.updates, want)
|
||||
}
|
||||
for key, value := range want {
|
||||
if repo.updates[key] != value {
|
||||
t.Fatalf("update %q = %q, want %q", key, repo.updates[key], value)
|
||||
}
|
||||
if repo.values[key] != value {
|
||||
t.Fatalf("stored %q = %q, want %q", key, repo.values[key], value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func paymentConfigStrPtr(value string) *string {
|
||||
return &value
|
||||
}
|
||||
|
||||
@@ -531,7 +531,7 @@ func TestDefaultPricingIncludesGemini36FlashRates(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) {
|
||||
func TestDefaultPricingUsesCurrentCodexAutoReviewBaseRates(t *testing.T) {
|
||||
data, err := os.ReadFile(filepath.Join("..", "..", "resources", "model-pricing", "model_prices_and_context_window.json"))
|
||||
require.NoError(t, err)
|
||||
|
||||
@@ -542,9 +542,19 @@ func TestDefaultPricingIncludesCodexAutoReview(t *testing.T) {
|
||||
|
||||
got := svc.GetModelPricing("codex-auto-review")
|
||||
require.NotNil(t, got)
|
||||
require.InDelta(t, 5e-6, got.InputCostPerToken, 1e-12)
|
||||
require.InDelta(t, 3e-5, got.OutputCostPerToken, 1e-12)
|
||||
require.InDelta(t, 5e-7, got.CacheReadInputTokenCost, 1e-12)
|
||||
require.InDelta(t, 0.2e-6, got.InputCostPerToken, 1e-12)
|
||||
require.InDelta(t, 1.2e-6, got.OutputCostPerToken, 1e-12)
|
||||
require.InDelta(t, 0.02e-6, got.CacheReadInputTokenCost, 1e-12)
|
||||
|
||||
// Auto-review is an internal Codex model. Do not infer public GPT-5.6 API
|
||||
// service-tier, cache-write, or long-context pricing without an upstream
|
||||
// usage contract for this dedicated model.
|
||||
require.Zero(t, got.InputCostPerTokenPriority)
|
||||
require.Zero(t, got.OutputCostPerTokenPriority)
|
||||
require.Zero(t, got.CacheReadInputTokenCostPriority)
|
||||
require.Zero(t, got.CacheCreationInputTokenCost)
|
||||
require.Zero(t, got.CacheCreationInputTokenCostPriority)
|
||||
require.Zero(t, got.LongContextInputTokenThreshold)
|
||||
}
|
||||
|
||||
func TestGetModelPricing_Gpt54MiniUsesDedicatedStaticFallbackWhenRemoteMissing(t *testing.T) {
|
||||
|
||||
@@ -330,6 +330,7 @@ func (s *SettingService) parseSettings(settings map[string]string) *SystemSettin
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true",
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
|
||||
@@ -179,6 +179,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
SettingKeyContactInfo,
|
||||
SettingKeyDocURL,
|
||||
SettingKeyHomeContent,
|
||||
SettingKeyCompactHomeEnabled,
|
||||
SettingKeyHideCcsImportButton,
|
||||
SettingKeyPurchaseSubscriptionEnabled,
|
||||
SettingKeyPurchaseSubscriptionURL,
|
||||
@@ -307,6 +308,7 @@ func (s *SettingService) GetPublicSettings(ctx context.Context) (*PublicSettings
|
||||
ContactInfo: settings[SettingKeyContactInfo],
|
||||
DocURL: settings[SettingKeyDocURL],
|
||||
HomeContent: settings[SettingKeyHomeContent],
|
||||
CompactHomeEnabled: settings[SettingKeyCompactHomeEnabled] == "true",
|
||||
HideCcsImportButton: settings[SettingKeyHideCcsImportButton] == "true",
|
||||
PurchaseSubscriptionEnabled: settings[SettingKeyPurchaseSubscriptionEnabled] == "true",
|
||||
PurchaseSubscriptionURL: strings.TrimSpace(settings[SettingKeyPurchaseSubscriptionURL]),
|
||||
@@ -495,6 +497,7 @@ type PublicSettingsInjectionPayload 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"`
|
||||
@@ -567,6 +570,7 @@ func (s *SettingService) GetPublicSettingsForInjection(ctx context.Context) (any
|
||||
ContactInfo: settings.ContactInfo,
|
||||
DocURL: settings.DocURL,
|
||||
HomeContent: settings.HomeContent,
|
||||
CompactHomeEnabled: settings.CompactHomeEnabled,
|
||||
HideCcsImportButton: settings.HideCcsImportButton,
|
||||
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
|
||||
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
|
||||
|
||||
@@ -78,6 +78,25 @@ func TestSettingService_GetPublicSettings_ExposesTablePreferences(t *testing.T)
|
||||
require.Equal(t, []int{20, 50, 100}, settings.TablePageSizeOptions)
|
||||
}
|
||||
|
||||
func TestSettingService_GetPublicSettings_ExposesCompactHomeEnabled(t *testing.T) {
|
||||
repo := &settingPublicRepoStub{
|
||||
values: map[string]string{
|
||||
SettingKeyCompactHomeEnabled: "true",
|
||||
},
|
||||
}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
settings, err := svc.GetPublicSettings(context.Background())
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, settings.CompactHomeEnabled)
|
||||
|
||||
missingSettings, err := NewSettingService(&settingPublicRepoStub{values: map[string]string{}}, &config.Config{}).
|
||||
GetPublicSettings(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.False(t, missingSettings.CompactHomeEnabled)
|
||||
}
|
||||
|
||||
func TestSettingService_GetPublicSettings_ExposesForceEmailOnThirdPartySignup(t *testing.T) {
|
||||
repo := &settingPublicRepoStub{
|
||||
values: map[string]string{
|
||||
|
||||
@@ -228,6 +228,16 @@ func (s *defaultSubGroupReaderStub) GetByID(ctx context.Context, id int64) (*Gro
|
||||
return nil, ErrGroupNotFound
|
||||
}
|
||||
|
||||
func TestSettingService_UpdateSettings_PersistsCompactHomeEnabled(t *testing.T) {
|
||||
repo := &settingUpdateRepoStub{}
|
||||
svc := NewSettingService(repo, &config.Config{})
|
||||
|
||||
err := svc.UpdateSettings(context.Background(), &SystemSettings{CompactHomeEnabled: true})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, "true", repo.updates[SettingKeyCompactHomeEnabled])
|
||||
}
|
||||
|
||||
func TestSettingService_UpdateSettings_DefaultSubscriptions_ValidGroup(t *testing.T) {
|
||||
repo := &settingUpdateRepoStub{}
|
||||
groupReader := &defaultSubGroupReaderStub{
|
||||
|
||||
@@ -320,6 +320,7 @@ func (s *SettingService) buildSystemSettingsUpdates(ctx context.Context, setting
|
||||
updates[SettingKeyContactInfo] = settings.ContactInfo
|
||||
updates[SettingKeyDocURL] = settings.DocURL
|
||||
updates[SettingKeyHomeContent] = settings.HomeContent
|
||||
updates[SettingKeyCompactHomeEnabled] = strconv.FormatBool(settings.CompactHomeEnabled)
|
||||
updates[SettingKeyHideCcsImportButton] = strconv.FormatBool(settings.HideCcsImportButton)
|
||||
updates[SettingKeyPurchaseSubscriptionEnabled] = strconv.FormatBool(settings.PurchaseSubscriptionEnabled)
|
||||
updates[SettingKeyPurchaseSubscriptionURL] = strings.TrimSpace(settings.PurchaseSubscriptionURL)
|
||||
|
||||
@@ -139,6 +139,7 @@ type SystemSettings struct {
|
||||
ContactInfo string
|
||||
DocURL string
|
||||
HomeContent string
|
||||
CompactHomeEnabled bool
|
||||
HideCcsImportButton bool
|
||||
PurchaseSubscriptionEnabled bool
|
||||
PurchaseSubscriptionURL string
|
||||
@@ -305,6 +306,7 @@ type PublicSettings struct {
|
||||
ContactInfo string
|
||||
DocURL string
|
||||
HomeContent string
|
||||
CompactHomeEnabled bool
|
||||
HideCcsImportButton bool
|
||||
|
||||
PurchaseSubscriptionEnabled bool
|
||||
|
||||
@@ -417,9 +417,9 @@ func TestAssignSubscriptionRenewsExpiredSemanticMatch(t *testing.T) {
|
||||
require.False(t, sub.StartsAt.Before(before))
|
||||
require.False(t, sub.StartsAt.After(after))
|
||||
require.Equal(t, sub.StartsAt.AddDate(0, 0, 30), sub.ExpiresAt)
|
||||
require.Equal(t, startOfDay(sub.StartsAt), *sub.DailyWindowStart)
|
||||
require.Equal(t, startOfDay(sub.StartsAt), *sub.WeeklyWindowStart)
|
||||
require.Equal(t, startOfDay(sub.StartsAt), *sub.MonthlyWindowStart)
|
||||
require.Equal(t, sub.StartsAt, *sub.DailyWindowStart)
|
||||
require.Equal(t, sub.StartsAt, *sub.WeeklyWindowStart)
|
||||
require.Equal(t, sub.StartsAt, *sub.MonthlyWindowStart)
|
||||
require.Zero(t, sub.DailyUsageUSD)
|
||||
require.Zero(t, sub.WeeklyUsageUSD)
|
||||
require.Zero(t, sub.MonthlyUsageUSD)
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
//go:build unit
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type activateWindowUserSubRepo struct {
|
||||
userSubRepoNoop
|
||||
windowStart time.Time
|
||||
}
|
||||
|
||||
type monthlyResetUserSubRepo struct {
|
||||
userSubRepoNoop
|
||||
resetCalled bool
|
||||
resetAt time.Time
|
||||
}
|
||||
|
||||
func (r *monthlyResetUserSubRepo) ResetMonthlyUsage(_ context.Context, _ int64, _ *time.Time, resetAt time.Time) error {
|
||||
r.resetCalled = true
|
||||
r.resetAt = resetAt
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *activateWindowUserSubRepo) ActivateWindows(_ context.Context, _ int64, start time.Time) error {
|
||||
r.windowStart = start
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDelayedFirstUseAnchorsMonthlyWindowAtActivation(t *testing.T) {
|
||||
repo := &activateWindowUserSubRepo{}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||
startsAt := time.Date(2026, 7, 1, 9, 0, 0, 0, time.UTC)
|
||||
activatedAt := time.Date(2026, 7, 10, 23, 30, 0, 0, time.UTC)
|
||||
svc.now = func() time.Time { return activatedAt }
|
||||
sub := &UserSubscription{
|
||||
ID: 1,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(45 * 24 * time.Hour),
|
||||
}
|
||||
|
||||
require.NoError(t, svc.CheckAndActivateWindow(context.Background(), sub))
|
||||
|
||||
require.Equal(t, activatedAt, repo.windowStart)
|
||||
monthlyWindowStart := repo.windowStart
|
||||
resetAt, ok := sub.automaticWindowStartAt(&monthlyWindowStart, 30*24*time.Hour, activatedAt.Add(30*24*time.Hour))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, activatedAt.Add(30*24*time.Hour), resetAt)
|
||||
require.NotEqual(t, startsAt.Add(30*24*time.Hour), resetAt)
|
||||
}
|
||||
|
||||
func TestThirtyDaySubscriptionDoesNotResetMonthlyQuotaBeforeExpiry(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
expiresAt := startsAt.Add(30 * 24 * time.Hour)
|
||||
renewed := renewedSubscriptionTerm(&UserSubscription{}, "", startsAt, expiresAt)
|
||||
|
||||
require.Equal(t, startsAt, *renewed.MonthlyWindowStart)
|
||||
require.False(t, renewed.NeedsMonthlyResetAt(expiresAt.Add(-time.Second)))
|
||||
require.True(t, renewed.NeedsMonthlyResetAt(expiresAt))
|
||||
require.False(t, renewed.canAutomaticallyResetMonthlyAt(expiresAt))
|
||||
require.Equal(t, expiresAt, *renewed.MonthlyResetTime())
|
||||
}
|
||||
|
||||
func TestCheckAndResetWindowsDoesNotResetExactThirtyDayLegacyMonthlyWindow(t *testing.T) {
|
||||
windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
now := startsAt.Add(30 * 24 * time.Hour)
|
||||
repo := &monthlyResetUserSubRepo{}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
sub := &UserSubscription{
|
||||
ID: 1,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(30 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}
|
||||
|
||||
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
|
||||
require.False(t, repo.resetCalled)
|
||||
require.Equal(t, 12.0, sub.MonthlyUsageUSD)
|
||||
require.Equal(t, windowStart, *sub.MonthlyWindowStart)
|
||||
}
|
||||
|
||||
func TestCheckAndResetWindowsResetsPartialFinalMonthlySubscriptions(t *testing.T) {
|
||||
for _, durationDays := range []int{31, 45} {
|
||||
t.Run(strconv.Itoa(durationDays)+"_days", func(t *testing.T) {
|
||||
legacyWindowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
now := startsAt.Add(30 * 24 * time.Hour)
|
||||
repo := &monthlyResetUserSubRepo{}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
sub := &UserSubscription{
|
||||
ID: 2,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(time.Duration(durationDays) * 24 * time.Hour),
|
||||
MonthlyWindowStart: &legacyWindowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}
|
||||
|
||||
require.NoError(t, svc.CheckAndResetWindows(context.Background(), sub))
|
||||
require.True(t, repo.resetCalled)
|
||||
require.Equal(t, startsAt.Add(30*24*time.Hour), repo.resetAt)
|
||||
require.Zero(t, sub.MonthlyUsageUSD)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeExpiredWindowsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) {
|
||||
windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
startsAt := windowStart.Add(23*time.Hour + 30*time.Minute)
|
||||
now := windowStart.Add(30 * 24 * time.Hour)
|
||||
subs := []UserSubscription{{
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(30 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}}
|
||||
|
||||
normalizeExpiredWindowsAt(subs, now)
|
||||
|
||||
require.Equal(t, 12.0, subs[0].MonthlyUsageUSD)
|
||||
require.Equal(t, windowStart, *subs[0].MonthlyWindowStart)
|
||||
}
|
||||
|
||||
func TestNormalizeExpiredWindowsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute)
|
||||
now := startsAt.Add(30 * 24 * time.Hour)
|
||||
subs := []UserSubscription{{
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(31 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}}
|
||||
|
||||
normalizeExpiredWindowsAt(subs, now)
|
||||
|
||||
require.Zero(t, subs[0].MonthlyUsageUSD)
|
||||
require.Nil(t, subs[0].MonthlyWindowStart)
|
||||
}
|
||||
|
||||
func TestValidateAndCheckLimitsKeepsLegacyMonthlyUsageBeforeExpiry(t *testing.T) {
|
||||
windowStart := time.Date(2026, 7, 1, 0, 0, 0, 0, time.UTC)
|
||||
startsAt := windowStart.Add(23*time.Hour + 30*time.Minute)
|
||||
now := windowStart.Add(30 * 24 * time.Hour)
|
||||
limit := 10.0
|
||||
sub := &UserSubscription{
|
||||
Status: SubscriptionStatusActive,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(30 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
|
||||
needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit})
|
||||
|
||||
require.ErrorIs(t, err, ErrMonthlyLimitExceeded)
|
||||
require.False(t, needsMaintenance)
|
||||
require.Equal(t, 12.0, sub.MonthlyUsageUSD)
|
||||
}
|
||||
|
||||
func TestValidateAndCheckLimitsResetsMonthlyUsageWithPartialFinalPeriod(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 23, 30, 0, 0, time.UTC)
|
||||
windowStart := startsAt.Add(-23*time.Hour - 30*time.Minute)
|
||||
now := startsAt.Add(30 * 24 * time.Hour)
|
||||
limit := 10.0
|
||||
sub := &UserSubscription{
|
||||
Status: SubscriptionStatusActive,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(45 * 24 * time.Hour),
|
||||
MonthlyWindowStart: &windowStart,
|
||||
MonthlyUsageUSD: 12,
|
||||
}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
|
||||
needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{MonthlyLimitUSD: &limit})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.True(t, needsMaintenance)
|
||||
require.Zero(t, sub.MonthlyUsageUSD)
|
||||
}
|
||||
|
||||
func TestValidateAndCheckLimitsRejectsExactExpiry(t *testing.T) {
|
||||
now := time.Date(2026, 7, 31, 12, 0, 0, 0, time.UTC)
|
||||
sub := &UserSubscription{Status: SubscriptionStatusActive, ExpiresAt: now}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, userSubRepoNoop{}, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
|
||||
needsMaintenance, err := svc.ValidateAndCheckLimits(sub, &Group{})
|
||||
|
||||
require.ErrorIs(t, err, ErrSubscriptionExpired)
|
||||
require.False(t, needsMaintenance)
|
||||
}
|
||||
|
||||
func TestAutomaticWindowsAllowPartialFinalDailyAndWeeklyPeriods(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 15, 45, 0, 0, time.UTC)
|
||||
legacyWindowStart := startOfDay(startsAt)
|
||||
tests := []struct {
|
||||
name string
|
||||
period time.Duration
|
||||
expiresAt time.Time
|
||||
}{
|
||||
{name: "daily", period: 24 * time.Hour, expiresAt: startsAt.Add(36 * time.Hour)},
|
||||
{name: "weekly", period: 7 * 24 * time.Hour, expiresAt: startsAt.Add(10 * 24 * time.Hour)},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: tt.expiresAt}
|
||||
resetAt, ok := sub.automaticWindowStartAt(&legacyWindowStart, tt.period, startsAt.Add(tt.period))
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, startsAt.Add(tt.period), resetAt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAutomaticWindowPreservesPersistedManualAnchorAndAdvancesWholePeriods(t *testing.T) {
|
||||
startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
manualAnchor := startsAt.Add(5 * 24 * time.Hour)
|
||||
sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)}
|
||||
|
||||
resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(65*24*time.Hour))
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, manualAnchor.Add(60*24*time.Hour), resetAt)
|
||||
}
|
||||
|
||||
func TestAutomaticWindowPreservesPeriodAlignedLaterMidnightManualAnchor(t *testing.T) {
|
||||
startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
manualAnchor := startOfDay(startsAt).Add(30 * 24 * time.Hour)
|
||||
sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)}
|
||||
|
||||
resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, startsAt.Add(60*24*time.Hour))
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt)
|
||||
}
|
||||
|
||||
func TestAutomaticWindowPreservesExactMidnightManualAnchor(t *testing.T) {
|
||||
startsAt := time.Date(2026, 1, 1, 10, 0, 0, 0, time.UTC)
|
||||
manualAnchor := time.Date(2026, 1, 20, 0, 0, 0, 0, time.UTC)
|
||||
sub := &UserSubscription{StartsAt: startsAt, ExpiresAt: startsAt.Add(100 * 24 * time.Hour)}
|
||||
|
||||
resetAt, ok := sub.automaticWindowStartAt(&manualAnchor, 30*24*time.Hour, manualAnchor.Add(30*24*time.Hour))
|
||||
|
||||
require.True(t, ok)
|
||||
require.Equal(t, manualAnchor.Add(30*24*time.Hour), resetAt)
|
||||
}
|
||||
@@ -24,6 +24,7 @@ type resetQuotaUserSubRepoStub struct {
|
||||
resetDailyErr error
|
||||
resetWeeklyErr error
|
||||
resetMonthlyErr error
|
||||
windowStart time.Time
|
||||
}
|
||||
|
||||
func (r *resetQuotaUserSubRepoStub) GetByID(_ context.Context, id int64) (*UserSubscription, error) {
|
||||
@@ -38,6 +39,7 @@ func (r *resetQuotaUserSubRepoStub) ResetUsageWindows(_ context.Context, _ int64
|
||||
r.resetDailyCalled = resetDaily
|
||||
r.resetWeeklyCalled = resetWeekly
|
||||
r.resetMonthlyCalled = resetMonthly
|
||||
r.windowStart = windowStart
|
||||
if resetDaily && r.resetDailyErr != nil {
|
||||
return r.resetDailyErr
|
||||
}
|
||||
@@ -93,6 +95,8 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) {
|
||||
sub: &UserSubscription{ID: 1, UserID: 10, GroupID: 20},
|
||||
}
|
||||
svc := newResetQuotaSvc(stub)
|
||||
resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC)
|
||||
svc.now = func() time.Time { return resetAt }
|
||||
|
||||
result, err := svc.AdminResetQuota(context.Background(), 1, true, true, false)
|
||||
|
||||
@@ -101,6 +105,9 @@ func TestAdminResetQuota_ResetBoth(t *testing.T) {
|
||||
require.True(t, stub.resetDailyCalled, "应调用 ResetDailyUsage")
|
||||
require.True(t, stub.resetWeeklyCalled, "应调用 ResetWeeklyUsage")
|
||||
require.False(t, stub.resetMonthlyCalled, "不应调用 ResetMonthlyUsage")
|
||||
require.Equal(t, resetAt, stub.windowStart)
|
||||
require.Equal(t, resetAt, *result.DailyWindowStart)
|
||||
require.Equal(t, resetAt, *result.WeeklyWindowStart)
|
||||
}
|
||||
|
||||
func TestAdminResetQuota_ResetDailyOnly(t *testing.T) {
|
||||
@@ -203,6 +210,30 @@ func TestAdminResetQuota_ResetMonthlyOnly(t *testing.T) {
|
||||
require.True(t, stub.resetMonthlyCalled, "应调用 ResetMonthlyUsage")
|
||||
}
|
||||
|
||||
func TestAdminResetQuota_BeforeStartsAtSameDayPreservesAutomaticBoundary(t *testing.T) {
|
||||
startsAt := time.Date(2026, 7, 1, 15, 0, 0, 0, time.UTC)
|
||||
resetAt := time.Date(2026, 7, 1, 10, 37, 42, 123, time.UTC)
|
||||
stub := &resetQuotaUserSubRepoStub{
|
||||
sub: &UserSubscription{
|
||||
ID: 10,
|
||||
UserID: 10,
|
||||
GroupID: 20,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.Add(45 * 24 * time.Hour),
|
||||
},
|
||||
}
|
||||
svc := newResetQuotaSvc(stub)
|
||||
svc.now = func() time.Time { return resetAt }
|
||||
|
||||
result, err := svc.AdminResetQuota(context.Background(), 10, false, false, true)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, resetAt, *result.MonthlyWindowStart)
|
||||
boundary, ok := result.automaticWindowStartAt(result.MonthlyWindowStart, 30*24*time.Hour, resetAt.Add(30*24*time.Hour))
|
||||
require.True(t, ok)
|
||||
require.Equal(t, resetAt.Add(30*24*time.Hour), boundary)
|
||||
}
|
||||
|
||||
func TestAdminResetQuota_ResetMonthlyUsageError(t *testing.T) {
|
||||
dbErr := errors.New("db error")
|
||||
stub := &resetQuotaUserSubRepoStub{
|
||||
|
||||
@@ -55,6 +55,7 @@ type SubscriptionService struct {
|
||||
subCacheJitter int // 抖动百分比
|
||||
|
||||
maintenanceQueue *SubscriptionMaintenanceQueue
|
||||
now func() time.Time
|
||||
}
|
||||
|
||||
// NewSubscriptionService 创建订阅服务
|
||||
@@ -64,6 +65,7 @@ func NewSubscriptionService(groupRepo GroupRepository, userSubRepo UserSubscript
|
||||
userSubRepo: userSubRepo,
|
||||
billingCacheService: billingCacheService,
|
||||
entClient: entClient,
|
||||
now: time.Now,
|
||||
}
|
||||
svc.initSubCache(cfg)
|
||||
svc.initMaintenanceQueue(cfg)
|
||||
@@ -368,7 +370,7 @@ func (s *SubscriptionService) withSubscriptionUpdateTx(ctx context.Context, fn f
|
||||
|
||||
func renewedSubscriptionTerm(existingSub *UserSubscription, notes string, startsAt, expiresAt time.Time) *UserSubscription {
|
||||
renewed := *existingSub
|
||||
windowStart := startOfDay(startsAt)
|
||||
windowStart := startsAt
|
||||
renewed.StartsAt = startsAt
|
||||
renewed.ExpiresAt = expiresAt
|
||||
renewed.Status = SubscriptionStatusActive
|
||||
@@ -803,20 +805,24 @@ func (s *SubscriptionService) List(ctx context.Context, page, pageSize int, user
|
||||
// normalizeExpiredWindows 将已过期窗口的数据清零(仅影响返回数据,不影响数据库)
|
||||
// 这确保前端显示正确的当前窗口状态,而不是过期窗口的历史数据
|
||||
func normalizeExpiredWindows(subs []UserSubscription) {
|
||||
normalizeExpiredWindowsAt(subs, time.Now())
|
||||
}
|
||||
|
||||
func normalizeExpiredWindowsAt(subs []UserSubscription, now time.Time) {
|
||||
for i := range subs {
|
||||
sub := &subs[i]
|
||||
// 日窗口过期:清零展示数据
|
||||
if sub.NeedsDailyReset() {
|
||||
if sub.canAutomaticallyResetDailyAt(now) {
|
||||
sub.DailyWindowStart = nil
|
||||
sub.DailyUsageUSD = 0
|
||||
}
|
||||
// 周窗口过期:清零展示数据
|
||||
if sub.NeedsWeeklyReset() {
|
||||
if sub.canAutomaticallyResetWeeklyAt(now) {
|
||||
sub.WeeklyWindowStart = nil
|
||||
sub.WeeklyUsageUSD = 0
|
||||
}
|
||||
// 月窗口过期:清零展示数据
|
||||
if sub.NeedsMonthlyReset() {
|
||||
if sub.canAutomaticallyResetMonthlyAt(now) {
|
||||
sub.MonthlyWindowStart = nil
|
||||
sub.MonthlyUsageUSD = 0
|
||||
}
|
||||
@@ -842,17 +848,18 @@ func startOfDay(t time.Time) time.Time {
|
||||
|
||||
// CheckAndActivateWindow 检查并激活窗口(首次使用时)
|
||||
func (s *SubscriptionService) CheckAndActivateWindow(ctx context.Context, sub *UserSubscription) error {
|
||||
return s.checkAndActivateWindowAt(ctx, sub, s.now())
|
||||
}
|
||||
|
||||
func (s *SubscriptionService) checkAndActivateWindowAt(ctx context.Context, sub *UserSubscription, now time.Time) error {
|
||||
if sub.IsWindowActivated() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 使用当天零点作为窗口起始时间
|
||||
windowStart := startOfDay(time.Now())
|
||||
return s.userSubRepo.ActivateWindows(ctx, sub.ID, windowStart)
|
||||
return s.userSubRepo.ActivateWindows(ctx, sub.ID, now)
|
||||
}
|
||||
|
||||
// AdminResetQuota manually resets the daily, weekly, and/or monthly usage windows.
|
||||
// Uses startOfDay(now) as the new window start, matching automatic resets.
|
||||
func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionID int64, resetDaily, resetWeekly, resetMonthly bool) (*UserSubscription, error) {
|
||||
if !resetDaily && !resetWeekly && !resetMonthly {
|
||||
return nil, ErrInvalidInput
|
||||
@@ -861,7 +868,7 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
windowStart := startOfDay(time.Now())
|
||||
windowStart := s.now()
|
||||
if err := s.userSubRepo.ResetUsageWindows(ctx, sub.ID, resetDaily, resetWeekly, resetMonthly, windowStart); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -878,12 +885,11 @@ func (s *SubscriptionService) AdminResetQuota(ctx context.Context, subscriptionI
|
||||
|
||||
// CheckAndResetWindows 检查并重置过期的窗口
|
||||
func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *UserSubscription) error {
|
||||
// 使用当天零点作为新窗口起始时间
|
||||
windowStart := startOfDay(time.Now())
|
||||
now := s.now()
|
||||
needsInvalidateCache := false
|
||||
|
||||
// 日窗口重置(24小时)
|
||||
if sub.NeedsDailyReset() {
|
||||
if windowStart, ok := sub.automaticWindowStartAt(sub.DailyWindowStart, 24*time.Hour, now); !sub.HasOneTimeDailyQuota() && ok {
|
||||
expectedWindowStart := sub.DailyWindowStart
|
||||
if err := s.userSubRepo.ResetDailyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
|
||||
return err
|
||||
@@ -894,7 +900,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use
|
||||
}
|
||||
|
||||
// 周窗口重置(7天)
|
||||
if sub.NeedsWeeklyReset() {
|
||||
if windowStart, ok := sub.automaticWindowStartAt(sub.WeeklyWindowStart, 7*24*time.Hour, now); ok {
|
||||
expectedWindowStart := sub.WeeklyWindowStart
|
||||
if err := s.userSubRepo.ResetWeeklyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
|
||||
return err
|
||||
@@ -905,7 +911,7 @@ func (s *SubscriptionService) CheckAndResetWindows(ctx context.Context, sub *Use
|
||||
}
|
||||
|
||||
// 月窗口重置(30天)
|
||||
if sub.NeedsMonthlyReset() {
|
||||
if windowStart, ok := sub.automaticWindowStartAt(sub.MonthlyWindowStart, 30*24*time.Hour, now); ok {
|
||||
expectedWindowStart := sub.MonthlyWindowStart
|
||||
if err := s.userSubRepo.ResetMonthlyUsage(ctx, sub.ID, expectedWindowStart, windowStart); err != nil {
|
||||
return err
|
||||
@@ -971,6 +977,7 @@ func (s *SubscriptionService) CheckUsageLimits(ctx context.Context, sub *UserSub
|
||||
// 仅做内存检查,不触发 DB 写入。调用方必须在放行请求前同步完成窗口维护。
|
||||
// 返回 needsMaintenance 表示是否需要执行窗口维护并回读数据库快照。
|
||||
func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, group *Group) (needsMaintenance bool, err error) {
|
||||
now := s.now()
|
||||
// 1. 验证订阅状态
|
||||
if sub.Status == SubscriptionStatusExpired {
|
||||
return false, ErrSubscriptionExpired
|
||||
@@ -978,21 +985,21 @@ func (s *SubscriptionService) ValidateAndCheckLimits(sub *UserSubscription, grou
|
||||
if sub.Status == SubscriptionStatusSuspended {
|
||||
return false, ErrSubscriptionSuspended
|
||||
}
|
||||
if sub.IsExpired() {
|
||||
if !sub.ExpiresAt.After(now) {
|
||||
return false, ErrSubscriptionExpired
|
||||
}
|
||||
|
||||
// 2. 内存中修正过期窗口的用量,确保预检查不会误拒绝用户。
|
||||
// 调用方随后同步推进 DB 窗口,并用回读快照重新校验。
|
||||
if sub.NeedsDailyReset() {
|
||||
if sub.canAutomaticallyResetDailyAt(now) {
|
||||
sub.DailyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
if sub.NeedsWeeklyReset() {
|
||||
if sub.canAutomaticallyResetWeeklyAt(now) {
|
||||
sub.WeeklyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
if sub.NeedsMonthlyReset() {
|
||||
if sub.canAutomaticallyResetMonthlyAt(now) {
|
||||
sub.MonthlyUsageUSD = 0
|
||||
needsMaintenance = true
|
||||
}
|
||||
|
||||
@@ -43,9 +43,18 @@ type UsageRecordSubmitMode string
|
||||
const (
|
||||
UsageRecordSubmitModeEnqueued UsageRecordSubmitMode = "enqueued"
|
||||
UsageRecordSubmitModeDropped UsageRecordSubmitMode = "dropped"
|
||||
UsageRecordSubmitModeSync UsageRecordSubmitMode = "sync_fallback"
|
||||
// UsageRecordSubmitModeDroppedStopped 表示任务因池已停止(进程关停窗口)被丢弃。
|
||||
// 与显式 drop/sample 溢出策略的丢弃区分开:溢出丢弃是运维显式配置的取舍,
|
||||
// 而关停窗口丢弃不是,计费关键任务应在调用侧降级为同步执行兜底。
|
||||
UsageRecordSubmitModeDroppedStopped UsageRecordSubmitMode = "dropped_stopped"
|
||||
UsageRecordSubmitModeSync UsageRecordSubmitMode = "sync_fallback"
|
||||
)
|
||||
|
||||
// Dropped 报告任务是否未被执行(入队失败且未同步执行)。
|
||||
func (m UsageRecordSubmitMode) Dropped() bool {
|
||||
return m == UsageRecordSubmitModeDropped || m == UsageRecordSubmitModeDroppedStopped
|
||||
}
|
||||
|
||||
// UsageRecordWorkerPoolOptions 使用量记录池配置。
|
||||
type UsageRecordWorkerPoolOptions struct {
|
||||
WorkerCount int
|
||||
@@ -150,7 +159,7 @@ func (p *UsageRecordWorkerPool) Submit(task UsageRecordTask) UsageRecordSubmitMo
|
||||
if p.pool == nil || p.pool.Stopped() {
|
||||
p.droppedPoolStopped.Add(1)
|
||||
p.logDrop("stopped")
|
||||
return UsageRecordSubmitModeDropped
|
||||
return UsageRecordSubmitModeDroppedStopped
|
||||
}
|
||||
|
||||
_, ok := p.pool.TrySubmit(func() {
|
||||
@@ -163,7 +172,7 @@ func (p *UsageRecordWorkerPool) Submit(task UsageRecordTask) UsageRecordSubmitMo
|
||||
if p.pool.Stopped() {
|
||||
p.droppedPoolStopped.Add(1)
|
||||
p.logDrop("stopped")
|
||||
return UsageRecordSubmitModeDropped
|
||||
return UsageRecordSubmitModeDroppedStopped
|
||||
}
|
||||
|
||||
switch p.overflowPolicy {
|
||||
|
||||
@@ -176,7 +176,11 @@ func TestUsageRecordWorkerPool_SubmitAfterStop(t *testing.T) {
|
||||
|
||||
pool.Stop()
|
||||
mode := pool.Submit(func(ctx context.Context) {})
|
||||
require.Equal(t, UsageRecordSubmitModeDropped, mode)
|
||||
require.Equal(t, UsageRecordSubmitModeDroppedStopped, mode)
|
||||
require.True(t, mode.Dropped())
|
||||
require.True(t, UsageRecordSubmitModeDropped.Dropped())
|
||||
require.False(t, UsageRecordSubmitModeEnqueued.Dropped())
|
||||
require.False(t, UsageRecordSubmitModeSync.Dropped())
|
||||
require.GreaterOrEqual(t, pool.Stats().DroppedPoolStopped, uint64(1))
|
||||
}
|
||||
|
||||
|
||||
@@ -85,17 +85,66 @@ func (s *UserSubscription) NeedsDailyResetAt(now time.Time) bool {
|
||||
}
|
||||
|
||||
func (s *UserSubscription) NeedsWeeklyReset() bool {
|
||||
return s.NeedsWeeklyResetAt(time.Now())
|
||||
}
|
||||
|
||||
func (s *UserSubscription) NeedsWeeklyResetAt(now time.Time) bool {
|
||||
if s.WeeklyWindowStart == nil {
|
||||
return false
|
||||
}
|
||||
return time.Since(*s.WeeklyWindowStart) >= 7*24*time.Hour
|
||||
return !now.Before(s.WeeklyWindowStart.Add(7 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
func (s *UserSubscription) NeedsMonthlyReset() bool {
|
||||
return s.NeedsMonthlyResetAt(time.Now())
|
||||
}
|
||||
|
||||
func (s *UserSubscription) NeedsMonthlyResetAt(now time.Time) bool {
|
||||
if s.MonthlyWindowStart == nil {
|
||||
return false
|
||||
}
|
||||
return time.Since(*s.MonthlyWindowStart) >= 30*24*time.Hour
|
||||
return !now.Before(s.MonthlyWindowStart.Add(30 * 24 * time.Hour))
|
||||
}
|
||||
|
||||
func (s *UserSubscription) canAutomaticallyResetDailyAt(now time.Time) bool {
|
||||
_, ok := s.automaticWindowStartAt(s.DailyWindowStart, 24*time.Hour, now)
|
||||
return !s.HasOneTimeDailyQuota() && ok
|
||||
}
|
||||
|
||||
func (s *UserSubscription) canAutomaticallyResetWeeklyAt(now time.Time) bool {
|
||||
_, ok := s.automaticWindowStartAt(s.WeeklyWindowStart, 7*24*time.Hour, now)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *UserSubscription) canAutomaticallyResetMonthlyAt(now time.Time) bool {
|
||||
_, ok := s.automaticWindowStartAt(s.MonthlyWindowStart, 30*24*time.Hour, now)
|
||||
return ok
|
||||
}
|
||||
|
||||
func (s *UserSubscription) automaticWindowStartAt(previous *time.Time, period time.Duration, now time.Time) (time.Time, bool) {
|
||||
if previous == nil {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
anchor := *previous
|
||||
// Older subscriptions initialized their first windows at midnight on their
|
||||
// start date. Only that initial value is unambiguous; later midnight anchors
|
||||
// may be manual resets and must remain authoritative.
|
||||
legacyAnchor := startOfDay(s.StartsAt)
|
||||
if legacyAnchor.Before(s.StartsAt) && anchor.Equal(legacyAnchor) {
|
||||
anchor = s.StartsAt
|
||||
}
|
||||
next := anchor.Add(period)
|
||||
if now.Before(next) || !next.Before(s.ExpiresAt) {
|
||||
return time.Time{}, false
|
||||
}
|
||||
|
||||
periods := now.Sub(anchor) / period
|
||||
lastPeriodBeforeExpiry := (s.ExpiresAt.Sub(anchor) - 1) / period
|
||||
if periods > lastPeriodBeforeExpiry {
|
||||
periods = lastPeriodBeforeExpiry
|
||||
}
|
||||
return anchor.Add(periods * period), true
|
||||
}
|
||||
|
||||
func (s *UserSubscription) DailyResetTime() *time.Time {
|
||||
|
||||
@@ -58,7 +58,7 @@ func TestAssignOrExtendSubscription_ExpiredDailyCardStartsNewOneTimeQuota(t *tes
|
||||
require.True(t, renewed.StartsAt.After(oldStart), "重新购买过期订阅时应重置当前周期 StartsAt")
|
||||
require.False(t, renewed.ExpiresAt.After(renewed.StartsAt.AddDate(0, 0, 1)))
|
||||
require.NotNil(t, renewed.DailyWindowStart)
|
||||
require.Equal(t, startOfDay(renewed.StartsAt), *renewed.DailyWindowStart)
|
||||
require.Equal(t, renewed.StartsAt, *renewed.DailyWindowStart)
|
||||
require.Equal(t, 0.0, renewed.DailyUsageUSD)
|
||||
require.Equal(t, 0.0, renewed.WeeklyUsageUSD)
|
||||
require.Equal(t, 0.0, renewed.MonthlyUsageUSD)
|
||||
@@ -160,17 +160,18 @@ func TestCheckAndResetWindows_DailyCardDoesNotResetDailyUsage(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckAndResetWindows_MultiDaySubscriptionStillResetsDailyUsage(t *testing.T) {
|
||||
now := time.Now()
|
||||
now := time.Date(2026, 7, 29, 12, 0, 0, 0, time.UTC)
|
||||
startsAt := now.Add(-48 * time.Hour)
|
||||
dailyWindowStart := now.Add(-25 * time.Hour)
|
||||
repo := &dailyResetTrackingUserSubRepo{}
|
||||
svc := NewSubscriptionService(groupRepoNoop{}, repo, nil, nil, nil)
|
||||
svc.now = func() time.Time { return now }
|
||||
sub := &UserSubscription{
|
||||
ID: 1,
|
||||
UserID: 10,
|
||||
GroupID: 20,
|
||||
StartsAt: startsAt,
|
||||
ExpiresAt: startsAt.AddDate(0, 0, 2),
|
||||
ExpiresAt: startsAt.AddDate(0, 0, 4),
|
||||
DailyUsageUSD: 10,
|
||||
DailyWindowStart: &dailyWindowStart,
|
||||
}
|
||||
|
||||
@@ -709,25 +709,14 @@
|
||||
"tool_use_system_prompt_tokens": 346
|
||||
},
|
||||
"codex-auto-review": {
|
||||
"cache_read_input_token_cost": 5e-07,
|
||||
"cache_read_input_token_cost_above_272k_tokens": 1e-06,
|
||||
"cache_read_input_token_cost_flex": 2.5e-07,
|
||||
"cache_read_input_token_cost_priority": 1e-06,
|
||||
"input_cost_per_token": 5e-06,
|
||||
"input_cost_per_token_above_272k_tokens": 1e-05,
|
||||
"input_cost_per_token_batches": 2.5e-06,
|
||||
"input_cost_per_token_flex": 2.5e-06,
|
||||
"input_cost_per_token_priority": 1e-05,
|
||||
"cache_read_input_token_cost": 2e-08,
|
||||
"input_cost_per_token": 2e-07,
|
||||
"litellm_provider": "openai",
|
||||
"max_input_tokens": 1050000,
|
||||
"max_output_tokens": 128000,
|
||||
"max_tokens": 128000,
|
||||
"mode": "chat",
|
||||
"output_cost_per_token": 3e-05,
|
||||
"output_cost_per_token_above_272k_tokens": 4.5e-05,
|
||||
"output_cost_per_token_batches": 1.5e-05,
|
||||
"output_cost_per_token_flex": 1.5e-05,
|
||||
"output_cost_per_token_priority": 6e-05,
|
||||
"output_cost_per_token": 1.2e-06,
|
||||
"supported_endpoints": [
|
||||
"/v1/chat/completions",
|
||||
"/v1/batch",
|
||||
|
||||
@@ -715,6 +715,8 @@ export interface BatchOperationResult {
|
||||
total: number
|
||||
success: number
|
||||
failed: number
|
||||
success_ids?: number[]
|
||||
failed_ids?: number[]
|
||||
errors?: Array<{ account_id: number; error: string }>
|
||||
warnings?: Array<{ account_id: number; warning: string }>
|
||||
}
|
||||
@@ -729,6 +731,16 @@ export async function revertProxyFallback(id: number): Promise<{ message: string
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete multiple accounts with bounded server-side concurrency.
|
||||
*/
|
||||
export async function batchDelete(accountIds: number[]): Promise<BatchOperationResult> {
|
||||
const { data } = await apiClient.post<BatchOperationResult>('/admin/accounts/batch-delete', {
|
||||
account_ids: accountIds
|
||||
})
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch clear account errors
|
||||
* @param accountIds - Array of account IDs
|
||||
@@ -968,6 +980,7 @@ export const accountsAPI = {
|
||||
importCodexSession,
|
||||
createOpenAICodexPAT,
|
||||
getAntigravityDefaultModelMapping,
|
||||
batchDelete,
|
||||
batchClearError,
|
||||
batchRefresh,
|
||||
setPrivacy,
|
||||
|
||||
@@ -14,6 +14,7 @@ export interface ContentModerationConfig {
|
||||
mode: ModerationMode
|
||||
base_url: string
|
||||
model: string
|
||||
proxy_id: number | null
|
||||
api_key_configured: boolean
|
||||
api_key_masked: string
|
||||
api_key_count: number
|
||||
@@ -66,6 +67,8 @@ export interface TestContentModerationAPIKeysPayload {
|
||||
base_url?: string
|
||||
model?: string
|
||||
timeout_ms?: number
|
||||
// null/undefined 沿用已保存配置的代理;0 强制直连;>0 指定代理
|
||||
proxy_id?: number
|
||||
prompt?: string
|
||||
images?: string[]
|
||||
}
|
||||
@@ -90,6 +93,8 @@ export interface UpdateContentModerationConfig {
|
||||
mode?: ModerationMode
|
||||
base_url?: string
|
||||
model?: string
|
||||
// undefined 不修改;0 清除(直连);>0 指定代理
|
||||
proxy_id?: number
|
||||
api_key?: string
|
||||
api_keys?: string[]
|
||||
api_keys_mode?: 'append' | 'replace'
|
||||
|
||||
@@ -440,6 +440,7 @@ export interface SystemSettings {
|
||||
contact_info: string;
|
||||
doc_url: string;
|
||||
home_content: string;
|
||||
compact_home_enabled: boolean;
|
||||
hide_ccs_import_button: boolean;
|
||||
table_default_page_size: number;
|
||||
table_page_size_options: number[];
|
||||
@@ -752,6 +753,7 @@ export interface UpdateSettingsRequest {
|
||||
contact_info?: string;
|
||||
doc_url?: string;
|
||||
home_content?: string;
|
||||
compact_home_enabled?: boolean;
|
||||
hide_ccs_import_button?: boolean;
|
||||
table_default_page_size?: number;
|
||||
table_page_size_options?: number[];
|
||||
|
||||
@@ -82,6 +82,57 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
|
||||
<div
|
||||
v-if="allOpenAIOAuthOnly"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<div class="flex-1 pr-4">
|
||||
<label
|
||||
id="bulk-edit-openai-flatten-namespaces-label"
|
||||
class="input-label mb-0"
|
||||
for="bulk-edit-openai-flatten-namespaces-enabled"
|
||||
>
|
||||
{{ t('admin.accounts.openai.flattenNamespaces') }}
|
||||
</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="enableOpenAIFlattenNamespaces"
|
||||
id="bulk-edit-openai-flatten-namespaces-enabled"
|
||||
type="checkbox"
|
||||
aria-controls="bulk-edit-openai-flatten-namespaces-body"
|
||||
class="rounded border-gray-300 text-primary-600 focus:ring-primary-500"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
id="bulk-edit-openai-flatten-namespaces-body"
|
||||
:class="!enableOpenAIFlattenNamespaces && 'pointer-events-none opacity-50'"
|
||||
role="group"
|
||||
aria-labelledby="bulk-edit-openai-flatten-namespaces-label"
|
||||
>
|
||||
<button
|
||||
id="bulk-edit-openai-flatten-namespaces-toggle"
|
||||
type="button"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Base URL (API Key only) -->
|
||||
<div class="border-t border-gray-200 pt-4 dark:border-dark-600">
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
@@ -1331,6 +1382,16 @@ const allOpenAIOAuth = computed(() => {
|
||||
)
|
||||
})
|
||||
|
||||
// 严格 OAuth(不含 setup-token):namespace 摊平兼容开关只对 OAuth 账号生效
|
||||
const allOpenAIOAuthOnly = computed(() => {
|
||||
return (
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
targetSelectedPlatforms.value[0] === 'openai' &&
|
||||
targetSelectedTypes.value.length > 0 &&
|
||||
targetSelectedTypes.value.every(t => t === 'oauth')
|
||||
)
|
||||
})
|
||||
|
||||
const allOpenAIAPIKey = computed(() => {
|
||||
return (
|
||||
targetSelectedPlatforms.value.length === 1 &&
|
||||
@@ -1398,6 +1459,7 @@ const enableRateMultiplier = ref(false)
|
||||
const enableStatus = ref(false)
|
||||
const enableGroups = ref(false)
|
||||
const enableOpenAIPassthrough = ref(false)
|
||||
const enableOpenAIFlattenNamespaces = ref(false)
|
||||
const enableOpenAIWSMode = ref(false)
|
||||
const enableOpenAIAPIKeyWSMode = ref(false)
|
||||
const enableUpstreamBillingAutoProbe = ref(false)
|
||||
@@ -1429,6 +1491,8 @@ const rateMultiplier = ref(1)
|
||||
const status = ref<'active' | 'inactive'>('active')
|
||||
const groupIds = ref<number[]>([])
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
// Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
|
||||
const openaiFlattenNamespacesEnabled = ref(false)
|
||||
const openaiOAuthResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const openaiAPIKeyResponsesWebSocketV2Mode = ref<OpenAIWSMode>(OPENAI_WS_MODE_OFF)
|
||||
const upstreamBillingAutoProbeMode = ref<'enabled' | 'disabled'>('enabled')
|
||||
@@ -1638,6 +1702,12 @@ const buildUpdatePayload = (): Record<string, unknown> | null => {
|
||||
}
|
||||
}
|
||||
|
||||
// 同时校验可见性:勾选后又改了目标筛选条件时,不应把该键写到非 OAuth 账号上
|
||||
if (enableOpenAIFlattenNamespaces.value && allOpenAIOAuthOnly.value) {
|
||||
const extra = ensureExtra()
|
||||
extra.openai_responses_flatten_namespaces = openaiFlattenNamespacesEnabled.value
|
||||
}
|
||||
|
||||
if (enableModelRestriction.value && !isOpenAIModelRestrictionDisabled.value) {
|
||||
// 统一使用 model_mapping 字段
|
||||
if (modelRestrictionMode.value === 'whitelist') {
|
||||
@@ -1806,6 +1876,7 @@ const handleSubmit = async () => {
|
||||
const hasAnyFieldEnabled =
|
||||
enableBaseUrl.value ||
|
||||
enableOpenAIPassthrough.value ||
|
||||
enableOpenAIFlattenNamespaces.value ||
|
||||
enableModelRestriction.value ||
|
||||
enableCustomErrorCodes.value ||
|
||||
enableInterceptWarmup.value ||
|
||||
@@ -1946,6 +2017,7 @@ watch(
|
||||
enableStatus.value = false
|
||||
enableGroups.value = false
|
||||
enableOpenAIPassthrough.value = false
|
||||
enableOpenAIFlattenNamespaces.value = false
|
||||
enableOpenAIWSMode.value = false
|
||||
enableOpenAIAPIKeyWSMode.value = false
|
||||
enableUpstreamBillingAutoProbe.value = false
|
||||
@@ -1958,6 +2030,7 @@ watch(
|
||||
// Reset all values
|
||||
baseUrl.value = ''
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
modelRestrictionMode.value = 'whitelist'
|
||||
allowedModels.value = []
|
||||
modelMappings.value = []
|
||||
|
||||
@@ -2781,6 +2781,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && form.type === 'oauth'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.flattenNamespaces') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="create-openai-flatten-namespaces-toggle"
|
||||
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI WS Mode 三态(off/ctx_pool/passthrough) -->
|
||||
<div
|
||||
v-if="form.platform === 'openai' && (accountCategory === 'oauth-based' || accountCategory === 'apikey')"
|
||||
@@ -3778,6 +3809,8 @@ const applyGrokOAuthUpstreamConfig = (credentials: Record<string, unknown>) => {
|
||||
const interceptWarmupRequests = ref(false)
|
||||
const autoPauseOnExpired = ref(true)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
|
||||
const openaiFlattenNamespacesEnabled = ref(false)
|
||||
const openAILongContextBillingEnabled = ref(false)
|
||||
const openAILongContextBillingTouched = ref(false)
|
||||
const openAICompactMode = ref<OpenAICompactMode>('auto')
|
||||
@@ -4231,6 +4264,7 @@ watch(
|
||||
}
|
||||
if (newPlatform !== 'openai') {
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
openAIEndpointCapabilities.value = ['chat_completions', 'embeddings']
|
||||
openaiOAuthResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
openaiAPIKeyResponsesWebSocketV2Mode.value = OPENAI_WS_MODE_OFF
|
||||
@@ -4656,6 +4690,7 @@ const resetForm = () => {
|
||||
interceptWarmupRequests.value = false
|
||||
autoPauseOnExpired.value = true
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
openAILongContextBillingEnabled.value = false
|
||||
openAILongContextBillingTouched.value = false
|
||||
openAICompactMode.value = 'auto'
|
||||
@@ -4740,6 +4775,12 @@ const buildOpenAIExtra = (base?: Record<string, unknown>): Record<string, unknow
|
||||
delete extra.openai_passthrough
|
||||
delete extra.openai_oauth_passthrough
|
||||
}
|
||||
// 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项
|
||||
if (form.type === 'oauth' && openaiFlattenNamespacesEnabled.value) {
|
||||
extra.openai_responses_flatten_namespaces = true
|
||||
} else {
|
||||
delete extra.openai_responses_flatten_namespaces
|
||||
}
|
||||
extra.openai_long_context_billing_enabled = openAILongContextBillingEnabled.value
|
||||
|
||||
if (accountCategory.value === 'oauth-based' && codexCLIOnlyEnabled.value) {
|
||||
|
||||
@@ -1481,6 +1481,37 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex namespace 工具摊平(兼容开关,仅 OAuth) -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && account?.type === 'oauth'"
|
||||
class="border-t border-gray-200 pt-4 dark:border-dark-600"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="input-label mb-0">{{ t('admin.accounts.openai.flattenNamespaces') }}</label>
|
||||
<p class="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
{{ t('admin.accounts.openai.flattenNamespacesDesc') }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="edit-openai-flatten-namespaces-toggle"
|
||||
@click="openaiFlattenNamespacesEnabled = !openaiFlattenNamespacesEnabled"
|
||||
:class="[
|
||||
'relative inline-flex h-6 w-11 flex-shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors duration-200 ease-in-out focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2',
|
||||
openaiFlattenNamespacesEnabled ? 'bg-primary-600' : 'bg-gray-200 dark:bg-dark-600'
|
||||
]"
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'pointer-events-none inline-block h-5 w-5 transform rounded-full bg-white shadow ring-0 transition duration-200 ease-in-out',
|
||||
openaiFlattenNamespacesEnabled ? 'translate-x-5' : 'translate-x-0'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OpenAI Codex hosted image_generation bridge policy -->
|
||||
<div
|
||||
v-if="account?.platform === 'openai' && (account?.type === 'oauth' || account?.type === 'setup-token' || account?.type === 'apikey')"
|
||||
@@ -2841,6 +2872,8 @@ const customBaseUrl = ref('')
|
||||
|
||||
// OpenAI 自动透传开关(OAuth/API Key)
|
||||
const openaiPassthroughEnabled = ref(false)
|
||||
// OpenAI Codex namespace 工具摊平兼容开关(仅 OAuth),缺省关闭即原样保留
|
||||
const openaiFlattenNamespacesEnabled = ref(false)
|
||||
const openAILongContextBillingEnabled = ref(false)
|
||||
// OpenAI 订阅档位(Plus/Pro/Free)手动覆盖值,存于 credentials.plan_type;'' 表示清空/自动识别
|
||||
const editPlanType = ref<string>('')
|
||||
@@ -3276,6 +3309,7 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
|
||||
// Load OpenAI passthrough toggle (OpenAI OAuth/SetupToken/API Key)
|
||||
openaiPassthroughEnabled.value = false
|
||||
openaiFlattenNamespacesEnabled.value = false
|
||||
openAILongContextBillingEnabled.value = false
|
||||
editPlanType.value = ''
|
||||
openAICompactMode.value = 'auto'
|
||||
@@ -3292,6 +3326,8 @@ const syncFormFromAccount = (newAccount: Account | null) => {
|
||||
webSearchEmulationMode.value = 'default'
|
||||
if (newAccount.platform === 'openai' && (newAccount.type === 'oauth' || newAccount.type === 'setup-token' || newAccount.type === 'apikey')) {
|
||||
openaiPassthroughEnabled.value = extra?.openai_passthrough === true || extra?.openai_oauth_passthrough === true
|
||||
openaiFlattenNamespacesEnabled.value =
|
||||
newAccount.type === 'oauth' && extra?.openai_responses_flatten_namespaces === true
|
||||
const longContextBillingValue = extra?.openai_long_context_billing_enabled
|
||||
openAILongContextBillingEnabled.value = longContextBillingValue === true
|
||||
// plan_type 手动覆盖仅 OAuth 有实际调度语义(IsOpenAIChatGPTSubscription 要求 oauth),故只对 oauth 回填
|
||||
@@ -4526,6 +4562,12 @@ const handleSubmit = async () => {
|
||||
delete newExtra.openai_passthrough
|
||||
delete newExtra.openai_oauth_passthrough
|
||||
}
|
||||
// 缺省即保留 namespace,不写空值,避免 extra 里堆积默认项
|
||||
if (props.account.type === 'oauth' && openaiFlattenNamespacesEnabled.value) {
|
||||
newExtra.openai_responses_flatten_namespaces = true
|
||||
} else {
|
||||
delete newExtra.openai_responses_flatten_namespaces
|
||||
}
|
||||
if (isSparkShadow.value) {
|
||||
delete newExtra.openai_long_context_billing_enabled
|
||||
} else {
|
||||
|
||||
@@ -237,6 +237,34 @@ describe('BulkEditAccountModal', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 批量编辑可开启 namespace 摊平兼容开关', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth']
|
||||
})
|
||||
|
||||
await wrapper.get('#bulk-edit-openai-flatten-namespaces-enabled').setValue(true)
|
||||
await wrapper.get('#bulk-edit-openai-flatten-namespaces-toggle').trigger('click')
|
||||
await wrapper.get('#bulk-edit-account-form').trigger('submit.prevent')
|
||||
await flushPromises()
|
||||
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledTimes(1)
|
||||
expect(adminAPI.accounts.bulkUpdate).toHaveBeenCalledWith([1, 2], {
|
||||
extra: {
|
||||
openai_responses_flatten_namespaces: true
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
it('namespace 摊平开关不对 setup-token 等非 OAuth 选择展示', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
selectedTypes: ['oauth', 'setup-token']
|
||||
})
|
||||
|
||||
expect(wrapper.find('#bulk-edit-openai-flatten-namespaces-enabled').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('OpenAI OAuth 批量编辑应提交 OAuth 专属 WS mode 字段(含 http_bridge)', async () => {
|
||||
const wrapper = mountModal({
|
||||
selectedPlatforms: ['openai'],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user