Compare commits

...
Author SHA1 Message Date
shaw 21aacde0b3 fix(openai-ws): keep downstream writes off the relay cancellation context
coder/websocket arms a context.AfterFunc that hard-closes the connection
when a write context is canceled, and AfterFunc stop does not wait for a
callback that already started. An external cancellation (e.g. ingress
lease loss) landing inside the disarm window of an already-successful
downstream write could therefore kill the TCP connection before the
retry close frame (1013) was written, leaving the client with a bare
EOF. Mirror the read side: bound downstream writes with the write
timeout only, and rely on the explicit Close/CloseNow performed by every
relay exit path for teardown.

Fixes the flaky TestPassthroughLifecycle_LeaseLossSendsRetryClose.
2026-08-01 20:32:10 +08:00
Wesley Liddick b22f73e725 Merge pull request #5154 from Wei-Shaw/fix/issue-5148-stream-partial-usage-billing
fix(gateway): 流中断时保留已观测 usage 入账,修复 newapi 类上游大面积漏记(#5148)
2026-08-01 13:51:06 +08:00
shaw d6d53052f8 chore: update sponsors 2026-08-01 11:32:31 +08:00
shaw bd52e5d770 fix(gateway): record observed usage when anthropic stream is interrupted
Fixes #5148: with aggregator upstreams (e.g. newapi) that end SSE
streams without a proper terminal event, every such request was
silently missing from usage logs and billing.

Root cause (tracked via the nested audit issue): the low-level
Anthropic SSE readers already return the partially collected usage
together with the stream error (missing terminal event, read error,
interval timeout), but Forward converted every such result to
(nil, err) and the handler returned before submitting RecordUsage.

Changes:
- Add partialStreamUsageResult: on stream errors, wrap observed usage
  into a ForwardResult and return it alongside the error, for both the
  regular Anthropic path and the API-key passthrough path. Invariants:
  UpstreamFailoverError always keeps result=nil (failover retries are
  billed as the successful attempt, never twice), and zero observed
  usage returns no partial result (no phantom zero-usage records).
- Messages handler: hoist the usage submission block into a closure
  shared by the success path and the new partial-result error path.
- Usage record worker pool: distinguish pool-stopped drops
  (dropped_stopped) from operator-configured drop/sample overflow
  drops; billing tasks now fall back to inline synchronous execution
  only during the shutdown window, while explicit drop/sample overflow
  semantics are preserved. Image usage keeps its mandatory fallback
  for both drop kinds via the new mode.Dropped() helper.

Tests: Forward-level regressions for missing-terminal / read-error /
no-usage / failover-invariant on both paths, plus handler-level
stopped-pool sync fallback and drop-policy preservation tests.
2026-08-01 11:29:58 +08:00
Wesley Liddick d4cada3b6b Merge pull request #5089 from feeeei/fix/openai_sse_rate_limit
fix(openai): retry SSE rate limits as HTTP 429
2026-08-01 10:50:57 +08:00
Wesley Liddick 8f5caef78e Merge pull request #5153 from Wei-Shaw/fix/issue-5152-classifier-multi-system-entries
fix(anthropic): recognize classifier requests with extra system entries
2026-08-01 10:50:27 +08:00
shaw 2ef1246295 fix(anthropic): recognize classifier requests with extra system entries
Real claude-cli/2.1.220 auto-mode classifier requests carry two system
entries: the security-monitor prompt plus an appended session-context
block. The previous len(systemEntries) != 1 guard rejected them before
any content check ran, so claude_code_only groups kept refusing the
classifier (#5152, follow-up to #5041/#5048).

Scan every entry for the monitor prompt instead of requiring exactly
one. Discrimination is unchanged: the matching entry still needs the
10k-char minimum, the fixed prefix, and all eight markers.
2026-08-01 10:32:43 +08:00
shaw dd9a177a62 chore: update sponsors 2026-08-01 10:01:52 +08:00
feeeei 85a27fae39 fix(openai): retry SSE rate limits as HTTP 429
OpenAI Responses may return code=rate_limit_exceeded in a response.failed SSE event while the HTTP status remains 200. Classify these failures as 429 so configured pool-mode retries and account failover are applied. No account-level rate-limit state is written on this path: the 200-stream response headers carry normal quota snapshots, and retry semantics stay owned by the failover engine.

Ignore compact keepalive bytes when determining whether semantic output has started, preserving safe retries before real output.
2026-08-01 10:01:47 +08:00
Wesley Liddick eb1c5c7ee8 Merge pull request #5146 from tudoujunha/codex/fix-responses-tool-output-media
fix(apicompat): preserve images in Responses tool outputs
2026-08-01 08:54:54 +08:00
Wesley Liddick d9fba8fe78 Merge pull request #5101 from Tongzai123/feat/admin-select-all-filtered-results
feat(admin): 支持按筛选结果全选账号
2026-08-01 08:54:38 +08:00
Wesley Liddick 15b3c0c5aa Merge pull request #5145 from zvensmoluya/codex/update-auto-review-pricing
[codex] update Codex Auto-review pricing
2026-08-01 08:54:26 +08:00
Wesley Liddick 2e338af822 Merge pull request #5085 from feeeei/main
feat(model-plaza): Filter bar line alignment, model sorting, and table spacing
2026-08-01 08:54:11 +08:00
Wesley Liddick 682c4fe0e6 Merge pull request #5147 from Wei-Shaw/feat/moderation-proxy-and-smtp-starttls
feat(moderation): proxy support for content audit; fix(email): SMTP STARTTLS test/send parity
2026-07-31 23:24:44 +08:00
shaw 948b63c9ca feat(moderation): route content moderation through configurable proxy server
Implements #2646: the risk-control content audit can now send OpenAI
Moderations requests through a proxy from IP Management - Proxy Servers.

Backend:
- ContentModerationConfig gains proxy_id (nil = direct, unchanged default)
- update semantics: null keeps, 0 clears, >0 selects (validated to exist)
- moderation calls build the client via the shared httpclient pool; proxy
  resolution failure surfaces as a moderation error and never silently
  falls back to direct connection
- proxy_id -> URL resolution cached 60s (single-entry, invalidated on
  config save) so the pre-block hot path does not hit the DB per request
- test-key endpoint accepts proxy_id too (null = saved config's proxy,
  0 = force direct), so input-key/saved-key tests exercise the same path
- proxy usage/inactivity logged (content_moderation.proxy_enabled /
  proxy_not_active) without leaking credentials

Frontend:
- ProxySelector in the risk-control basic settings tab, proxy list loaded
  non-blockingly; save and test payloads carry proxy_id; zh/en i18n
2026-07-31 23:12:22 +08:00
shaw 4c80d160dd fix(email): unify SMTP connection path between send and test-connection
- UseTLS now tries implicit TLS first (port 465 semantics) and, when the
  server answers in plaintext (tls.RecordHeaderError, e.g. port 587
  submission), automatically retries with mandatory STARTTLS; encryption
  is never silently downgraded (fixes #1470, supersedes #1488)
- TestSMTPConnectionWithConfig now shares connectSMTP with the send path,
  adding the opportunistic STARTTLS upgrade the send path gained in
  b402c367d; this removes the 'test connection fails but test email
  sends' mismatch reported in #1488
- test-connection now also honors dial/IO timeouts and ignores
  non-standard QUIT responses, matching the send path
2026-07-31 23:12:02 +08:00
Wesley Liddick 570ea74d12 Merge pull request #5117 from gaoren002/feat/prompt-audit-blocking-latest-input
feat(security-audit): add optional narrow blocking audit scope
2026-07-31 22:32:00 +08:00
Wesley Liddick 2980ff3850 Merge pull request #5094 from wucm667/feat/issue-5065-compact-homepage
feat(home): add compact home page preset to avoid abuse classification
2026-07-31 21:51:33 +08:00
Wesley Liddick 04c96a2015 Merge pull request #4981 from INKCR0W/fix/openai-preserve-codex-namespaces
fix(openai): OAuth 原生 Responses 默认保留 Codex namespace,修复 code_mode_only 模型无法派发子代理
2026-07-31 21:49:44 +08:00
Wesley Liddick 07f980b99f Merge pull request #5084 from apple-ouyang/codex/fix-openai-compaction-encrypted-retry
fix(openai): recover stale encrypted compaction
2026-07-31 21:48:10 +08:00
Ouyang Xingyuan fe21725865 fix(openai): recover stale encrypted compaction
Reason:
- Responses retries can carry account-bound encrypted compaction items that OpenAI rejects with invalid_encrypted_content.

Changes:
- Drop encrypted compaction and compaction_summary items only during the existing recovery retry.
- Preserve unencrypted compaction items and cover HTTP and WebSocket recovery paths.
2026-07-31 21:21:13 +08:00
Zven 698547418f fix(pricing): keep Auto-review rates evidence-based 2026-07-31 21:20:32 +08:00
Zven f54e9827a0 fix(pricing): update Codex Auto-review rates 2026-07-31 21:16:06 +08:00
Wesley Liddick d29acc29a5 Merge pull request #5066 from wucm667/fix/issue-5051-subscription-quota-window
fix(subscription): align quota windows with subscription term
2026-07-31 20:40:18 +08:00
Wesley Liddick 66998918b6 Merge pull request #5143 from wucm667/fix/issue-5138-codex-instructions
fix(openai): default missing passthrough instructions
2026-07-31 20:39:57 +08:00
Wesley Liddick da6194c1c3 Merge pull request #5112 from chenty2333/fix/openai-stream-capacity-pool-retry
fix(openai): retry streamed capacity errors in pool mode
2026-07-31 20:38:15 +08:00
Wesley Liddick 132d446ca9 Merge pull request #5133 from dawnx/fix/payment-visible-method-wipe
fix(payment): 保存系统设置时不再清空可见支付方式配置
2026-07-31 20:37:58 +08:00
Wesley Liddick 0eac363e67 Merge pull request #5120 from Vibeone/fix/grok-pool-mode-cooldown-bypass
fix(grok): 公共池模式跳过所有默认冷却路径
2026-07-31 20:27:46 +08:00
Wesley Liddick 796313e993 Merge pull request #5131 from wucm667/fix/issue-5125-image-data-url-offload
fix(images): decode data URLs during task offload
2026-07-31 20:27:35 +08:00
Wesley Liddick c772d18666 Merge pull request #5130 from moonfunjohn/codex/fix-epay-method-selector-overflow
fix(payment): prevent EasyPay method selector overflow
2026-07-31 20:27:25 +08:00
tudoujun 2bf9c6d56b 修复工具输出图片桥接 2026-07-31 19:36:47 +08:00
Wesley Liddick 94df1fffc2 Merge pull request #5124 from wucm667/fix/issue-5105-filter-grok-billing-ping
fix(grok): filter billing ping response events
2026-07-31 19:20:16 +08:00
shaw 30967d5d9a fix(grok): ping 帧统一改写为 SSE 注释并限制过滤缓冲
Responses 事件类型对严格客户端是闭合枚举,任何 event: ping 帧都会令
grok CLI / Codex CLI 整轮失败。原实现只精确匹配 inference-cost 标记帧
和 cost=="0" 帧,上游尾帧携带非零 cost 或格式微调即复发 #5105;且对
其它 ping 变体的保守放行同样会炸掉严格解析器。现改为:event: ping 帧
(data 声明的 type 与事件名不冲突时)一律改写为 SSE 注释 ": ping",
所有解析器安全忽略且保留保活效果。

同时把整帧缓冲改为增量状态机:非 ping 帧首行即判定、逐行零拷贝直通,
不再累积;仅 ping 候选帧缓冲,并设 16 行 / 16KB 上限,超限回放原文
转直通,杜绝上游用永不结束的帧撑爆网关内存。
2026-07-31 18:57:43 +08:00
wucm667 dfdbc27709 fix(openai): default missing passthrough instructions 2026-07-31 18:55:15 +08:00
wucm667 beeb2f989b test(settings): include compact home in API contracts 2026-07-31 18:34:13 +08:00
wucm667 77d4df9544 test(grok): check filter body close error 2026-07-31 18:34:11 +08:00
github-actions[bot] 7ceabb3fd5 chore: sync VERSION to 0.1.169 [skip ci] 2026-07-31 09:19:08 +00:00
wucm667 d6467f6eb0 fix(images): decode data URLs during task offload 2026-07-31 15:04:56 +08:00
moonfunjohn 8ed9f754cf fix(payment): prevent method selector overflow 2026-07-31 14:54:01 +08:00
dawn 3deb2f17d8 fix(payment): 保存系统设置时不再清空可见支付方式配置
UpdatePaymentConfig 无条件写入全部设置键,未传的指针字段经
derefStr(nil) / formatBoolOrEmpty(nil) 转成空串后仍会落库。

setting_handler_update.go 构造 UpdatePaymentConfigRequest 时从不填充
VisibleMethod* 四个字段,而 UpdateSettingsWithAuthSourceDefaultsOmitting
先写入正确值、UpdatePaymentConfig 紧接着以空串覆盖,导致管理员每次保存
系统设置都会静默重置支付宝/微信的可见支付方式路由。

改为仅写入调用方显式提供的字段,符合 PATCH 语义。EnabledTypes 传
空切片仍可显式清空。
2026-07-31 14:21:36 +08:00
wucm667 baaae8e121 fix(grok): filter billing ping response events 2026-07-31 12:31:45 +08:00
eyre 5c9629ddb7 fix(grok): unify pool mode bypass for all default cooldown paths
- Move pool mode check before the status switch so 401/402/403/5xx
  all skip tempUnschedule consistently
- Skip rateLimitGrok in updateGrokUsageSnapshot for pool mode
- Expand test coverage to all affected status codes
2026-07-31 03:02:01 +00:00
eyre 4d13925c9e fix(grok): skip entitlement 403 cooldown for pool mode accounts 2026-07-31 03:02:01 +00:00
gaoren002 d74e669a23 feat(security-audit): add narrow blocking audit scope 2026-07-31 01:47:53 +00:00
diannaaav 7d3bf86e55 fix(openai): retry streamed capacity errors in pool mode 2026-07-31 02:31:35 +08:00
Litong 2a871ec852 feat(admin): 支持按筛选结果全选账号 2026-07-30 18:07:27 +08:00
Litong a35ff9613e feat(admin): 为账号批量删除增加并发限制 2026-07-30 18:03:54 +08:00
wucm667 739c0ff9c5 feat(home): add compact home page preset to avoid abuse classification
- Add compact_home_enabled setting to provide a minimal landing page
- Preserves custom home_content priority over compact mode
- Renders only site identity, navigation, and login/dashboard link
- Avoids marketing copy that triggers anti-fraud systems
- Includes focused backend/frontend tests and i18n support

Fixes #5065
2026-07-30 16:28:19 +08:00
feeeei 3d99acb0ad 优化模型广场UI:筛选栏换行对齐、模型排序与表格留白
- 筛选栏各级标签移出 flex-wrap 容器,chip 单独成组,换行后左边缘与「全部」对齐
- 模型排序增加二级规则:同官方输出价时按名称降序(gpt-5.6 先于 gpt-5.5)
- 按图片/按次计费的模型沉到分组末尾,不再与 token 模型按官方价混排
- 表格顶到卡片边缘,左右留白改由首列/末列 padding 提供,hover 与分区底色横贯整行
2026-07-30 15:32:59 +08:00
wucm667 7b6111f2f7 fix(subscription): align quota windows with subscription term 2026-07-30 02:37:14 +08:00
InkCrow 272735b0a7 fix(openai): preserve Codex namespace tools on OAuth Responses forwarding
Flattening Codex `type:"namespace"` tool declarations into `namespace__name`
renames the tool but cannot rewrite the contract the client already handed the
model: Codex tells it to call `to=functions.collaboration.spawn_agent`. Under
`tool_mode: code_mode_only` (gpt-5.6-*) collaboration tools are the model's only
direct channel, so the rename left it unable to address them — it fell back to
`functions.exec` and looped on meaningless shell commands until the session
collapsed, with no upstream error anywhere. Fixes #4978.

Preserving is what the upstream actually wants. OAuth egress is unconditionally
chatgpt.com/backend-api/codex/responses (base_url is read only for API-key
accounts), i.e. the party that defines the extension; codex-rs builds one
request for both WS and HTTP and falls back WS->HTTP mid-session, so these
declarations already reach that host over HTTP — as they do today via the
responses-lite carrier and the WS/HTTP bridge, neither of which ever flattened.

Namespace names cannot be allowlisted: `features.multi_agent_v2.tool_namespace`
is user-configurable (openai/codex#31864 recommends renaming it to `agents`) and
MCP/connector namespaces are generated at runtime (mcp__codex_apps__gmail).
So the default is inverted rather than extended with more names.

- Preserve namespace declarations by default for OpenAI OAuth; the new
  per-account `extra.openai_responses_flatten_namespaces` restores the previous
  behavior for deployments routing OAuth traffic to a relay that rejects them.
- Keep `input[].namespace` on tool-call items for OAuth non-compact requests.
  The upstream requires the round-trip ("Missing namespace for function_call
  '...'. Round-trip the model's function_call item with its namespace field
  included."), so preserving declarations while stripping the calls would 400 on
  the second turn. The item-type allowlist mirrors the existing reactive strip.
- Keep compact on its current behavior: that endpoint rejects the field outright
  ("Unknown parameter: 'input[894].namespace'", #4761) and there is no evidence
  either way about namespace declarations there, so this change does not widen
  its surface. API-key accounts are untouched — their upstream is a standard
  Responses API and the reactive retry only clears one index per round trip.
- Clear the flatten mapping at the start of each Forward attempt so an account
  reached through failover cannot restore responses with the previous account's
  mapping.

Admin UI gets an OAuth-only toggle (create/edit/bulk) plus zh/en copy noting the
compact exception.
2026-07-28 09:19:13 +08:00
139 changed files with 6336 additions and 709 deletions
+24 -20
View File
@@ -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>
&nbsp;&nbsp;&nbsp;&nbsp;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
View File
@@ -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>
&nbsp;&nbsp;&nbsp;&nbsp;独家特惠:智谱 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
View File
@@ -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
View File
@@ -1 +1 @@
0.1.168
0.1.169
+8 -8
View File
@@ -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,
+2
View File
@@ -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"`
+75 -56
View File
@@ -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")
+84 -77
View File
@@ -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)
+109 -20
View File
@@ -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,
+1
View File
@@ -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)
+16
View File
@@ -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
}(),
+120 -13
View File
@@ -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)
+92 -119
View File
@@ -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}
+66 -1
View File
@@ -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)
+19 -15
View File
@@ -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))
}
+51 -2
View File
@@ -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",
+13
View File
@@ -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,
+5
View File
@@ -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'
+2
View File
@@ -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