Compare commits

...
70 Commits
Author SHA1 Message Date
Wesley Liddick 4c00df2e01 Merge pull request #7638 from Achordchan/fix/anthropic-structured-output-beta
fix(anthropic): preserve requested structured outputs beta
2026-09-28 10:52:52 +08:00
Wesley Liddick b5298fdde8 Merge pull request #7538 from TouHouQing/fix/claude-thinking-disabled-openai
fix(openai): honor disabled thinking in Claude Messages bridge
2026-09-28 10:51:30 +08:00
Wesley Liddick 5a350108c4 Merge pull request #7607 from ResidualBlood/fix/antigravity-malformed-function-call-retry
fix(antigravity): trigger failover retry on empty stream caused by MALFORMED_FUNCTION_CALL
2026-09-28 10:50:53 +08:00
Wesley Liddick 1df5c39311 Merge pull request #7562 from william-drakemond/fix/opencode-zen-deepseek-reasoning-placeholder
fix(openai): inject DeepSeek reasoning placeholder for OpenCode Zen upstream
2026-09-28 10:50:02 +08:00
Wesley Liddick b3c7038cf4 Merge pull request #7526 from HuntercodeT/fix/passthrough-model-discovery-merge
fix(gateway): let OpenAI passthrough accounts supplement model discovery instead of hiding mapped models
2026-09-28 10:49:01 +08:00
Wesley Liddick 8616d4e036 Merge pull request #7617 from korkin25/fix/responses-beta-upstream
fix(openai): preserve caller beta headers for HTTP Responses
2026-09-28 10:48:48 +08:00
Wesley Liddick 93c3da03eb Merge pull request #7555 from Yukinoshita-Yukino/fix/openai-auto-reset-credit-sched-projection
fix(scheduler): keep auto reset-credit fields in the scheduler projection
2026-09-28 10:27:00 +08:00
Achord Chan 68c2f5ae6d fix(anthropic): preserve requested structured outputs beta 2026-09-28 10:10:47 +08:00
Wesley Liddick ff6b85e30b Merge pull request #7615 from UnlastingR/fix/codex-ws-context-rollover-upstream
fix(openai): break websocket chain on context rollover
2026-09-28 10:05:05 +08:00
Wesley Liddick 79c18ec836 Merge pull request #7609 from feeeei/fix/client-disconnect-499
fix(gateway): 客户端断开统一标记 499,不再被记成 502/200 上游错误
2026-09-28 10:04:07 +08:00
Wesley Liddick 790723e040 Merge pull request #7610 from nhirsama/fix/redis-command-exec-form
fix(deploy): redis 启动命令改用 exec 列表,修复密码含 shell 特殊字符时失效
2026-09-28 10:01:15 +08:00
Wesley Liddick 1ff2bd6d95 Merge pull request #7573 from Yukinoshita-Yukino/fix/channel-image-price-inherit-when-unset
fix(billing): keep catalog image prices when channel pricing leaves them unset
2026-09-28 09:57:02 +08:00
Wesley Liddick f592b336a3 Merge pull request #7595 from wucm667/feat/issue-7587-allowlist-glob
feat(groups): support wildcards anywhere in model allowlists
2026-09-28 09:55:55 +08:00
Wesley Liddick 37f35b12ea Merge pull request #7524 from Achordchan/fix/plaza-video-rate
fix(model-plaza): apply independent video billing multipliers
2026-09-28 09:55:34 +08:00
Wesley Liddick e4b34446af Merge pull request #7619 from SavitarC/fix/account-stats-long-context-billing
fix(billing): 账号统计成本按 OpenAI 账号开关计入长上下文价
2026-09-28 09:55:19 +08:00
Wesley Liddick 90ae81e9f8 Merge pull request #7624 from wucm667/fix/issue-7620-future-quota-reset
fix(openai): retain quota pause until known future reset
2026-09-28 09:54:52 +08:00
Wesley Liddick 9375e288ba Merge pull request #7571 from SPHINX998/fix/openai-probe-model-unavailable-inconclusive
fix(openai): keep Responses support unknown when the probe model is missing
2026-09-28 09:54:31 +08:00
Wesley Liddick 06f70a6232 Merge pull request #7572 from SPHINX998/fix/openai-alpha-search-require-completion
fix(openai): require a successful completion before billing an alpha search fallback
2026-09-28 09:54:12 +08:00
Wesley Liddick 6906062187 Merge pull request #7597 from far5man/codex/opus55-openrouter-alias-main
fix: recognize OpenRouter Claude Opus 5.5 alias for billing
2026-09-28 09:53:49 +08:00
Wesley Liddick dc4874a2f1 Merge pull request #7613 from yujunkim/fix/openai-free-fast-missing-pricing
fix(billing): keep zero-cost usage log when Free Fast has no pricing
2026-09-28 09:53:26 +08:00
Wesley Liddick 11608c51f0 Merge pull request #7568 from SPHINX998/fix/apicompat-gpt6-reasoning-models
fix(apicompat): treat every GPT generation from 5 on as a reasoning model
2026-09-28 09:53:05 +08:00
Wesley Liddick 8aa7fb8c2d Merge pull request #7569 from SPHINX998/fix/apicompat-recover-empty-final-text
fix(apicompat): recover streamed text when the final output message is empty
2026-09-28 09:52:48 +08:00
Wesley Liddick 00ae7c38e2 Merge pull request #7570 from SPHINX998/fix/apicompat-tool-input-on-block-start
fix(apicompat): keep tool arguments sent on content_block_start
2026-09-28 09:52:13 +08:00
Wesley Liddick 51a10e42f7 Merge pull request #7585 from ResidualBlood/fix/antigravity-pdf-inline-data
fix(antigravity): forward chat PDF file parts as Gemini inlineData
2026-09-28 09:51:28 +08:00
Wesley Liddick 6655f4ec0e Merge pull request #7393 from Achordchan/fix/antigravity-string-const
fix(antigravity): intersect string const with existing enums
2026-09-28 09:51:04 +08:00
Wesley Liddick be64b553f9 Merge pull request #7635 from wucm667/fix/issue-7629-responses-message-type
fix(apicompat): mark converted Chat role items as Responses messages
2026-09-28 09:50:34 +08:00
Wesley Liddick 66f07efd26 Merge pull request #7549 from helianthus1/fix/ccswitch-usage-v1-path
fix(ccswitch): stop usage query from requesting /v1/v1/usage
2026-09-28 09:49:47 +08:00
Wesley Liddick 89e9976950 Merge pull request #7497 from ouchihao/codex/bug-group-modal-cleanup
fix(groups): clean up modal listeners and pending searches
2026-09-28 09:42:09 +08:00
Wesley Liddick 99d86e41ac Merge pull request #7611 from ZeroClover/fix/usage-bar-idle-reset-time
fix(ui): show reset countdown for idle usage windows with known reset time
2026-09-28 09:41:52 +08:00
Wesley Liddick ec334fda2c Merge pull request #7628 from Sterbennicht/fix/codex-windows-catalog-path
fix(keys): use ~/ for the Codex model catalog path on Windows
2026-09-28 09:41:35 +08:00
Wesley Liddick 35d2ef0cbd Merge pull request #7636 from wucm667/fix/issue-7631-remove-unused-rate-limit
fix(setup): omit unused rate limit fields from generated config
2026-09-28 09:41:16 +08:00
wucm667 512aa8bc93 fix(setup): omit obsolete rate limit defaults 2026-09-28 08:50:36 +08:00
Wesley Liddick f45126c8ec Merge pull request #7622 from Anti2077/fix/ccswitch-codex-root-endpoint
fix(ccswitch): preserve Codex provider root endpoint
2026-09-28 08:48:37 +08:00
wucm667 82d2552360 fix(apicompat): mark converted role items as messages 2026-09-28 08:40:41 +08:00
Sterbennicht f652d0ed38 fix(keys): use ~/ for the Codex model catalog path on Windows
Codex expands a leading ~/ in config.toml paths on every platform but
does not expand %userprofile%. The Windows config generated by the Use
Key dialog wrote model_catalog_json = "%userprofile%\.codex\codex-models.json",
which Codex resolves relative to the config directory
(C:\Users\<user>\.codex\%userprofile%\.codex\codex-models.json), so it
fails to load the config with "The system cannot find the path
specified (os error 3)".

Always write ~/.codex/codex-models.json into config.toml. The download
hint still shows the Windows location to save the file.
2026-09-27 22:58:44 +08:00
ResidualBlood 6b40449c3f fix(antigravity): exclude Signature-only events from meaningful data check
MALFORMED_FUNCTION_CALL responses from upstream contain only
thoughtSignature without any text, thinking, or tool calls.
The previous fix still treated delta.Signature and block.Signature
as meaningful data, so the empty-stream failover was never triggered.

- Remove Signature from isMeaningfulAntigravityCompatEvent checks
- Let hasMeaningfulData() also consult processor.HasContent() as
  a secondary guard for edge cases
2026-09-27 13:15:56 +00:00
wucm667 02088be2d1 fix(openai): retain quota pause until known future reset 2026-09-27 14:56:38 +08:00
anti 14483c9254 fix(ccswitch): preserve Codex provider root endpoint 2026-09-27 13:48:39 +08:00
SavitarC fd7af1d462 fix(billing): honor long-context gate in account stats pricing 2026-09-27 12:12:25 +08:00
korkin25 8b95dc94f8 fix(openai): preserve Responses multi-agent beta 2026-09-27 09:57:51 +08:00
UnlastingR 7626110d0b fix(openai): break websocket chain on context rollover 2026-09-26 07:45:10 -04:00
Yujun KimandClaude Opus 5.5 3c5ea297ff fix(billing): keep zero-cost usage log when Free Fast has no pricing
RecordUsage treats a missing price as zero cost on the first pass, but
the Free Fast Standard re-evaluation returned the same pricing error.
Requests in a free_openai_fast group sent at priority/fast tier to a
model without pricing therefore lost their usage log entirely.

Ignore pricing-unavailable errors on the Standard pass and keep the
zero-cost entry, matching the first pass.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:11:03 +09:00
Zero Clover 994048e6a4 fix(ui): show reset countdown for idle usage windows with known reset time
UsageProgressBar returned 'now' whenever showNowWhenIdle was set and
utilization was 0%, ignoring resetsAt. OpenAI Codex 7d windows at 0%
usage still carry a future reset time, so the admin account list hid it.
Only fall back to 'now' when no reset time is known.
2026-09-26 18:49:51 +08:00
ling c802c45e37 fix(deploy): 用 exec 列表形式重写 redis command
参数逐项传递,不再经 sh 拼接,避免密码含 shell 特殊字符时被展开或截断;
空密码时按 redis 语义不设密码。
2026-09-26 17:56:19 +08:00
feeeei f4f8ff04db fix(gateway): 客户端断开统一标记 499,不再被记成 502/200 上游错误
客户端在响应提交前断开时,只有排队/换号阶段和 OpenAI 的兜底写入会标 499。
Claude 非流式与 Gemini 的 Chat Completions / Messages / Responses 兼容入口
在等待上游时断开,由 GatewayHandler.ensureForwardErrorResponse 补写 502;
Gemini 原生入口什么都不写,落成 200 并被 ops 记成一条 provider 的
"Recovered upstream error";Antigravity 回写 502。这些行都按上游失败计入
SLA 与上游错误数。

- GatewayHandler.ensureForwardErrorResponse 补客户端断开检查,与 OpenAI 版一致
- GatewayHandler 的 Messages / ChatCompletions / Responses 与 Gemini 原生入口:
  首次选号失败时先判断客户端断开;ChatCompletions / Responses 循环开头改用
  failoverClientGone,不再直接 return 落成 200
- Gemini 原生入口:forward 失败时补 499 标记;并发槽错误改走
  concurrencyErrorResponse 统一映射(断开 499、排队满/超时 429、其余 503)
- Antigravity 上游响应前断开回写 499;googleapi 与 Antigravity 的 Google 错误
  格式把 499 映射为 CANCELLED
- OpenAI alpha search 非 failover 错误分支补断开检查
- Claude / Gemini / OpenAI 传输层错误:请求 context 已取消时不记 ops 上游事件
- ops:IgnoreContextCanceled 开启时跳过没有上游错误上下文的 499;带上游错误的
  499(上游失败后客户端没等到换号结果就离开)仍按上游失败落库

计费不变:Gemini 流式中途断开的用量处理不在本次范围。新增 handler/service
单测覆盖各入口的断开场景与 ops 过滤规则。
2026-09-26 17:13:10 +08:00
Achord Chan 510b680fca Merge upstream main and retain upstream scheduler test fix 2026-09-26 15:47:52 +08:00
Achord Chan afbe51abf8 Merge upstream main and retain string const enum intersection 2026-09-26 15:42:47 +08:00
ResidualBlood 52c2f4cb10 fix(antigravity): trigger failover retry on empty stream caused by MALFORMED_FUNCTION_CALL 2026-09-26 05:29:18 +00:00
far5man 630639f747 test: preserve Opus 5.5 thinking for OpenRouter alias 2026-09-25 07:01:27 +02:00
far5man 11ffee5543 fix: recognize OpenRouter Claude Opus 5.5 alias for billing 2026-09-25 06:56:40 +02:00
Yuan LiuandClaude Opus 5.5 85810365a6 fix(auto-reset): stop re-querying confirmed no-credit accounts and back off after query failures
With the credit config now visible to the scheduler, every scheduling pass
notifies the auto-reset worker for paused credit-enabled accounts.
evaluateAccount forced an upstream query whenever the reset threshold was
reached, regardless of the last result. Accounts with no credits left and
7d >= 95% were therefore polled on /wham/usage every ~15-20s across
instances (previously once a minute by the scanner), and during upstream
outages the failed state caused the same storm.

- At the reset threshold, skip the forced query while a no_credit result is
  still within the state TTL (10 min); re-query once it expires so newly
  granted credits are still picked up.
- After a query-stage failure (RESET_CREDIT_QUERY_FAILED,
  USAGE_SNAPSHOT_WRITE_FAILED, RESET_CREDIT_DETAILS_UNAVAILABLE), wait one
  minute before querying again, including snapshot-staleness triggers.
- Redemption-stage failures are unaffected and still retry immediately with
  the same idempotency key; accounts with available credits still query and
  redeem immediately.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:17:11 +08:00
wucm667 f0ebc183e1 feat(groups): allow wildcard positions in model allowlist 2026-09-25 09:12:09 +08:00
ResidualBlood ca2746f3bb fix(antigravity): forward chat PDF file parts as Gemini inlineData
Chat Completions file parts already become Responses input_file, but the
Antigravity path dropped them when converting to Anthropic and then to
Gemini. Map data-URI input_file to a document block and emit it as
inlineData so Gemini receives the PDF.

Fixes #7583
2026-09-24 15:17:25 +00:00
SPHINX998andClaude Opus 5.5 1d126c833b fix(openai): require a successful completion before billing an alpha search fallback
The personal-access-token /v1/alpha/search fallback turned any upstream
Responses SSE body into a 200 search result and billed one web search call.
Error, response.failed and response.incomplete events, a response.completed
with a non-success status or without its response, a truncated stream and a
bare [DONE] were all accepted, returning partial text as a successful and
billable search.

Parse the stream strictly: those shapes now return an error, so nothing is
written to the client and no WebSearchCalls result is produced. A successful
response.completed keeps the existing output and citation handling. The
existing PAT metadata test now feeds a Responses SSE stream, which is what
that upstream path returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:54:15 +08:00
SPHINX998andClaude Opus 5.5 9461196864 fix(openai): keep Responses support unknown when the probe model is missing
The API-key Responses capability probe treated every 404 as conclusive and
stored responses_supported=false. The probe model is the first mapped
upstream model in lexical order, so on third-party accounts it is often an
auxiliary entry such as codex-auto-review; a model_not_found answer then
permanently routed the account away from /v1/responses although the
endpoint works.

A 400/404 whose error code/type or message says the probe model does not
exist or is unavailable is now inconclusive, so the stored capability is not
overwritten. Plain 404/405 still mean the endpoint is missing. The probe
also prefers a general gpt-* text model (not an image model) among the
mapped models before falling back to the first one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:50:00 +08:00
Yuan LiuandClaude Opus 5.5 a2007db707 fix(billing): keep catalog image prices when channel pricing leaves them unset
When a channel pricing entry left image_output_price empty,
GetModelPricingWithChannel and the pricing resolver zeroed the image
output price and marked it explicit, so image output tokens were billed
at $0. image_input_price followed the same "unset means zero" rule and
fell back to the text input price instead of the catalog image input
price. A channel that lists an image model such as gpt-image-2 in token
mode without filling in prices therefore generated images without
charging for image output tokens, while the available-channels view
(#2475) displayed the catalog image price for the same entry.

#2930 wanted an explicit 0 on the channel to stop falling back to the
catalog; the change applied the same treatment to unset values as well.

Treat the two image fields like every other token price field:
- Set on the channel (including 0): override the catalog. An explicit
  image_output_price keeps ImageOutputPriceExplicit, so 0 stays free.
- Left empty: keep the catalog price (LiteLLM or built-in fallback). If
  the catalog has none, computeTokenBreakdown still falls back to the
  text input/output price.

The admin UI saves an empty field as null and only an entered 0 as 0,
so the two cases stay distinguishable. The flat, interval and
GetModelPricingWithChannel paths now share applyChannelImagePriceOverrides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:46:14 +08:00
SPHINX998andClaude Opus 5.5 97bdde3135 fix(apicompat): keep tool arguments sent on content_block_start
The canonical Anthropic stream leaves tool_use input empty on
content_block_start and streams the arguments as input_json_delta.
Anthropic-compatible relays may instead put the complete arguments on
content_block_start and send no delta. The Anthropic-to-Responses stream
converter ignored that input, so function_call_arguments.done carried ""
and the closed item "{}", and clients executed the tool with no arguments.

Keep the inline input as a seed. A real input_json_delta supersedes it, so
canonical streams keep their exact event sequence and two JSON documents are
never concatenated. When no delta arrived by content_block_stop, emit the
seed as a single arguments delta before the done event, which still repeats
exactly what the deltas streamed. Empty, "{}" and null inputs keep the
existing "{}" fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:45:14 +08:00
SPHINX998andClaude Opus 5.5 090b2cc000 fix(apicompat): recover streamed text when the final output message is empty
BufferedResponseAccumulator.SupplementResponseOutput rebuilt the output only
when the terminal response had an empty output array. Upstreams can also end
with a non-empty output whose message carries no (or only blank) output_text.
The buffered Chat Completions and Anthropic Messages paths then returned an
empty reply although the text had streamed, while usage still billed the
terminal output_tokens.

When the terminal output has no usable message text, fill the first empty
output_text part (or append one, or append a message item) from the
accumulated deltas. Non-empty terminal text stays authoritative, and nothing
is synthesized when no text streamed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:44:05 +08:00
SPHINX998andClaude Opus 5.5 82b092ffc2 fix(apicompat): treat every GPT generation from 5 on as a reasoning model
isReasoningModel matched the literal "gpt-5" prefix plus the GPT-6 Sol/Luna
spellings, so gpt-6 and gpt-6-astra (both already in the model catalog and
price list) fell into the sampling branch. Anthropic Messages and Chat
Completions requests converted to the Responses API then forwarded
temperature/top_p, which the Responses API rejects for these models with
"Unsupported parameter".

Key the check on the parsed GPT generation number instead, keeping the
existing Sol/Luna spelling match, so later families are covered without
another edit. Non-generation GPT families (gpt-image-1, gpt-audio) and
gpt-4.x are unaffected.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-24 15:37:16 +08:00
William DrakeandClaude Opus 5 5979521309 fix(openai): inject DeepSeek reasoning placeholder for OpenCode Zen upstream
- OpenCode Zen / Go 把 deepseek-* 模型转发给 DeepSeek,thinking mode 的
  reasoning_content 约束原样生效,上游 400 原文一字不差地回吐
- 原判定只认 platform=deepseek 与 base_url 指向 api.deepseek.com,
  opencode_go 账号漏出,Responses→CC 回退在缓存未命中时必踩 400
- 按「官方 OpenCode 上游 + deepseek-* 模型」补一条判定,不放宽成任意上游的
  deepseek-* 模型名,避免对无此约束的上游无谓改写请求体

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-23 18:32:49 -04:00
Yuan LiuandClaude Opus 5.5 1ca7b2a519 fix(scheduler): keep auto reset-credit fields in the scheduler projection
OpenAI candidate filtering reads the Redis metadata projection, but
filterSchedulerExtra dropped auto_reset_credit_enabled, the 5h/7d
reset-credit thresholds and codex_auto_reset_credit_state. The scheduler
therefore always saw automatic reset credits as disabled, so accounts
were paused at the ops quota auto-pause threshold even when a fresh
state showed an available reset credit. The "keep scheduling until the
reset-credit threshold" branch never took effect, and accounts with
credits stayed idle until their window reset naturally.

- Keep the four keys in the scheduler projection; the test round-trips
  the real JSON payload and resolves the credit config from it.
- Once visible, every scheduling pass notifies the auto-reset worker for
  paused credit-enabled accounts, and each notification reloads the
  account. Add a 30s per-account cooldown for the scheduler hot path
  only; other event-driven notifications are unchanged and the
  per-minute scan remains the fallback.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-23 20:02:50 +08:00
helianthus1 00f6f4e4a8 fix(ccswitch): stop usage query from requesting /v1/v1/usage
#7395 made Codex (and Grok) imports store a base URL ending in /v1, but the
usage script still appends a hard-coded /v1/usage, so CC Switch requests
/v1/v1/usage, gets a 404 and shows "query failed" for every newly imported
Codex/Grok provider. Move the script into ccswitchImport.ts and strip an
existing trailing /v1 from {{baseUrl}} before appending /v1/usage, which
covers both the imported value and base URLs users edit either way later.
2026-09-23 04:24:52 -07:00
github-actions[bot] a3eb7ef302 chore: sync VERSION to 0.2.8 [skip ci] 2026-09-23 09:18:46 +00:00
touhouqing 2fc91663d2 fix(apicompat): honor disabled thinking in OpenAI bridges 2026-09-23 16:48:15 +08:00
HuntercodeT ecba49f711 fix(gateway): let OpenAI passthrough accounts supplement model discovery instead of hiding mapped models
A single passthrough account made GetAvailableModels return nil for the whole
group, so every other account's model_mapping was dropped from /v1/models and
the Codex catalog. Treat passthrough accounts like unmapped ones: skip their
(possibly stale) mapping and let supplementUnmappedOpenAIModels add the
default set, while ordinary accounts' mappings still reach the list.

Refs #6647, #7517
2026-09-23 11:51:06 +08:00
Achord Chan 17ce21c038 test(scheduler): pass explicit multiplier pointer to snapshot 2026-09-23 11:38:23 +08:00
Achord Chan b5b5f1f94b fix(model-plaza): apply independent video billing multipliers 2026-09-23 11:28:41 +08:00
chihao.ou 1af3269adb fix(groups): clean up modal listeners and pending searches 2026-09-22 21:43:13 +08:00
Achord Chan f867e3e2ec test(antigravity): check schema properties type assertion 2026-09-22 18:34:36 +08:00
Achord Chan ef51690b5a fix(antigravity): preserve string const constraints in tool schemas 2026-09-20 10:46:09 +08:00
117 changed files with 3135 additions and 380 deletions
+1 -1
View File
@@ -1 +1 @@
0.2.7
0.2.8
@@ -2,10 +2,13 @@ package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -73,3 +76,65 @@ func TestConcurrencyErrorResponse(t *testing.T) {
})
}
}
func TestGoogleConcurrencyError(t *testing.T) {
tests := []struct {
name string
err error
wantStatus int
wantGStatus string
wantMessage string
}{
{
name: "client cancellation is 499",
err: context.Canceled,
wantStatus: statusClientClosedRequest,
wantGStatus: "CANCELLED",
wantMessage: "context canceled",
},
{
name: "full wait queue is 429",
err: &WaitQueueFullError{SlotType: "user"},
wantStatus: http.StatusTooManyRequests,
wantGStatus: "RESOURCE_EXHAUSTED",
wantMessage: "Too many pending requests, please retry later",
},
{
name: "slot wait timeout is 429",
err: &ConcurrencyError{SlotType: "user", IsTimeout: true},
wantStatus: http.StatusTooManyRequests,
wantGStatus: "RESOURCE_EXHAUSTED",
wantMessage: "Concurrency limit exceeded for user, please retry later",
},
{
name: "acquire backend error is 503",
err: errors.New("redis unavailable"),
wantStatus: http.StatusServiceUnavailable,
wantGStatus: "INTERNAL",
wantMessage: "Service temporarily unavailable, please retry later",
},
}
gin.SetMode(gin.TestMode)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
googleConcurrencyError(c, tt.err, "user")
require.Equal(t, tt.wantStatus, rec.Code)
var body struct {
Error struct {
Code int `json:"code"`
Message string `json:"message"`
Status string `json:"status"`
} `json:"error"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &body))
require.Equal(t, tt.wantStatus, body.Error.Code)
require.Equal(t, tt.wantGStatus, body.Error.Status)
require.Equal(t, tt.wantMessage, body.Error.Message)
})
}
}
@@ -330,6 +330,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
for {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gateway.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformGemini)
if !cls.ModelNotFound {
@@ -660,6 +664,10 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
)
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), currentAPIKey.GroupID, sessionKey, reqModel, fs.FailedAccountIDs, parsedReq.MetadataUserID, subject.UserID)
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gateway.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, currentAPIKey, reqModel, reqModel, platform)
if !cls.ModelNotFound {
@@ -1971,10 +1979,15 @@ func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, statu
// Writer 已被写过时(ping 已 flush)走 streamStarted 分支,
// 让 handleStreamingAwareError 通过 SSE 发协议合规的终止事件,
// 否则下游收到的就是 silent EOF。
// 客户端已断开时不补写,响应未提交则标记 499(见 failoverClientGone)。
func (h *GatewayHandler) ensureForwardErrorResponse(c *gin.Context, streamStarted bool) bool {
if c == nil || c.Writer == nil {
return false
}
if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
failoverClientGone(c)
return false
}
if service.IsResponseCommitted(c) {
return false
}
@@ -93,6 +93,8 @@ func TestGatewayHandlerPreCancelledCompatibleRequestsDoNotSelectAccount(t *testi
require.Zero(t, schedulerCache.snapshotCalls.Load(), "a cancelled request must stop before the account selector")
_, selected := c.Get(opsAccountIDKey)
require.False(t, selected)
require.Equal(t, statusClientClosedRequest, c.Writer.Status(), "an uncommitted cancelled request is marked 499")
require.Zero(t, recorder.Body.Len())
})
}
}
@@ -168,11 +168,15 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
}
for {
if c.Request.Context().Err() != nil {
if failoverClientGone(c) {
return
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, selectionSessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gateway.cc.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
cls = classifySelectionFailureError(err, cls)
@@ -1,6 +1,7 @@
package handler
import (
"context"
"encoding/json"
"errors"
"net/http"
@@ -70,6 +71,43 @@ func TestGatewayEnsureForwardErrorResponse_SkipsCommittedSSEError(t *testing.T)
require.Equal(t, 1, strings.Count(w.Body.String(), "event: error"))
}
// 客户端已断开且响应未提交:不补写 502,标记 499 供访问日志与 ops 归类。
func TestGatewayEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
ctx, cancel := context.WithCancel(context.Background())
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil).WithContext(ctx)
cancel()
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, false)
require.False(t, wrote)
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
require.Empty(t, w.Body.String())
}
// 客户端已断开但流已开始:状态码已固化为 200,不再向断开的连接追加错误帧。
func TestGatewayEnsureForwardErrorResponse_CanceledClientAfterStreamStartedAppendsNothing(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
ctx, cancel := context.WithCancel(context.Background())
c.Request = httptest.NewRequest(http.MethodPost, EndpointMessages, nil).WithContext(ctx)
c.Header("Content-Type", "text/event-stream")
_, _ = c.Writer.WriteString(":\n\n")
cancel()
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, true)
require.False(t, wrote)
require.Equal(t, http.StatusOK, c.Writer.Status())
require.Equal(t, ":\n\n", w.Body.String())
require.Empty(t, service.GetOpsStreamErrors(c))
}
// case B 回归:Anthropic-backed /responses,Writer 已被写过时
// ensureForwardErrorResponse 仍要发 response.failed。
func TestGatewayEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsResponseFailed(t *testing.T) {
@@ -170,11 +170,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
fs := NewFailoverState(h.maxAccountSwitches, false)
for {
if requestCtx.Err() != nil {
if failoverClientGone(c) {
return
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gateway.responses.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey))
cls = classifySelectionFailureError(err, cls)
@@ -287,6 +287,18 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
accounts: accounts[1:],
want: []string{sparkModel, alias},
},
{
// A passthrough account with a stale mapping behaves like an unmapped
// one: it adds the defaults but never its own mapping keys, and it no
// longer hides the aliases declared on ordinary accounts.
name: "passthrough account contributes defaults without hiding mapped aliases",
accounts: append([]service.Account{{
ID: 5, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "stale-model"}},
Extra: map[string]any{"openai_passthrough": true},
}}, accounts[1:]...),
want: append(openai.DefaultModelIDs(), alias),
},
{
name: "unmapped accounts from another platform do not add defaults",
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...),
@@ -0,0 +1,130 @@
//go:build unit
package handler
import (
"bytes"
"context"
"net/http"
"net/http/httptest"
"net/url"
"sync/atomic"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// clientCancelUpstream 模拟上游尚未响应时客户端断开:Do 期间取消请求 context,
// 并按 net/http 的形态返回包裹 context.Canceled 的 *url.Error。
type clientCancelUpstream struct {
service.HTTPUpstream
cancel context.CancelFunc
calls atomic.Int32
}
func (u *clientCancelUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
u.calls.Add(1)
u.cancel()
return nil, &url.Error{Op: "Post", URL: req.URL.String(), Err: context.Canceled}
}
type geminiClientCancelFixture struct {
handler *GatewayHandler
group *service.Group
apiKey *service.APIKey
upstream *clientCancelUpstream
ctx context.Context
}
func newGeminiClientCancelFixture(t *testing.T) *geminiClientCancelFixture {
t.Helper()
groupID := int64(9200)
accountID := int64(9201)
group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformGemini, Status: service.StatusActive}
account := &service.Account{
ID: accountID,
Platform: service.PlatformGemini,
Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "test-key"},
Status: service.StatusActive,
Schedulable: true,
Concurrency: 1,
Priority: 1,
AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
}
h, cleanup := newTestGatewayHandler(t, group, []*service.Account{account})
t.Cleanup(cleanup)
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
upstream := &clientCancelUpstream{cancel: cancel}
h.geminiCompatService = service.NewGeminiMessagesCompatService(nil, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})
apiKey := &service.APIKey{
ID: 9202, UserID: 9203, GroupID: &groupID, Group: group, Status: service.StatusActive,
User: &service.User{ID: 9203, Concurrency: 10, Balance: 100},
}
return &geminiClientCancelFixture{handler: h, group: group, apiKey: apiKey, upstream: upstream, ctx: ctx}
}
// serve 经 ops 错误日志中间件执行请求,返回响应与 ops 队列中的条目数。
func (f *geminiClientCancelFixture) serve(t *testing.T, route, path, body string, call func(*gin.Context)) (*httptest.ResponseRecorder, int64) {
t.Helper()
setupOpsErrorLogTestQueue(t, 4)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST(route, func(c *gin.Context) {
c.Set(string(middleware.ContextKeyAPIKey), f.apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: f.apiKey.UserID, Concurrency: 10})
call(c)
})
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req = req.WithContext(context.WithValue(f.ctx, ctxkey.Group, f.group))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec, OpsErrorLogQueueLength()
}
// 原生入口:上游响应前客户端断开,响应未提交,标记 499;纯客户端取消不落 ops 错误日志。
func TestGeminiV1BetaModels_ClientCancelBeforeUpstreamResponseMarks499(t *testing.T) {
gin.SetMode(gin.TestMode)
f := newGeminiClientCancelFixture(t)
rec, opsQueued := f.serve(t,
"/v1beta/models/*modelAction",
"/v1beta/models/gemini-2.5-flash:streamGenerateContent?alt=sse",
`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`,
f.handler.GeminiV1BetaModels,
)
require.Equal(t, int32(1), f.upstream.calls.Load())
require.Equal(t, statusClientClosedRequest, rec.Code)
require.Zero(t, rec.Body.Len())
require.Zero(t, opsQueued)
}
// Chat Completions 兼容入口(Gemini 分组):同一场景标记 499,不再补写 502。
func TestGatewayChatCompletions_GeminiClientCancelBeforeUpstreamResponseMarks499(t *testing.T) {
gin.SetMode(gin.TestMode)
f := newGeminiClientCancelFixture(t)
rec, opsQueued := f.serve(t,
"/v1/chat/completions",
"/v1/chat/completions",
`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}],"stream":true}`,
f.handler.ChatCompletions,
)
require.Equal(t, int32(1), f.upstream.calls.Load())
require.Equal(t, statusClientClosedRequest, rec.Code)
require.Zero(t, rec.Body.Len())
require.Zero(t, opsQueued)
}
@@ -370,7 +370,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, stream, &streamStarted)
if err != nil {
reqLog.Warn("gemini.user_slot_acquire_failed", zap.Error(err))
googleError(c, http.StatusTooManyRequests, err.Error())
googleConcurrencyError(c, err, "user")
return
}
// 确保请求取消时也会释放槽位,避免长连接被动中断造成泄漏
@@ -506,6 +506,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
for {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gemini.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
if !cls.ModelNotFound {
@@ -598,7 +602,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
)
if err != nil {
reqLog.Warn("gemini.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
googleError(c, http.StatusTooManyRequests, err.Error())
googleConcurrencyError(c, err, "account")
return
}
if accountWaitCounted {
@@ -674,7 +678,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
return
}
}
// ForwardNative already wrote the response
// 转发层已写出错误响应;客户端断开时转发层不写,响应未提交则标记 499。
failoverClientGone(c)
reqLog.Error("gemini.forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
return
}
@@ -836,6 +841,13 @@ func googleError(c *gin.Context, status int, message string) {
})
}
// googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案
// 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。
func googleConcurrencyError(c *gin.Context, err error, slotType string) {
status, _, _, message := concurrencyErrorResponse(err, slotType)
googleError(c, status, message)
}
func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) {
if res == nil {
googleError(c, http.StatusBadGateway, "Empty upstream response")
@@ -92,6 +92,8 @@ type modelPlazaGroup struct {
// 不取分组/用户专属倍率。
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier float64 `json:"video_rate_multiplier"`
// 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
Models []modelPlazaModel `json:"models"`
@@ -211,6 +213,8 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
IsExclusive: g.IsExclusive,
ImageRateIndependent: g.ImageRateIndependent,
ImageRateMultiplier: g.ImageRateMultiplier,
VideoRateIndependent: g.VideoRateIndependent,
VideoRateMultiplier: g.VideoRateMultiplier,
LongContextPricingEnabled: g.LongContextPricingEnabled,
Models: models,
}
@@ -89,6 +89,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
g := service.PlazaGroup{
ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
VideoRateIndependent: true, VideoRateMultiplier: 0.7,
Models: []service.PlazaModel{{
Name: "claude-sonnet",
Platform: "anthropic",
@@ -115,11 +116,14 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
"image_rate_independent", "image_rate_multiplier", "long_context_pricing_enabled",
"video_rate_independent", "video_rate_multiplier",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
}
require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
require.Equal(t, true, decoded["video_rate_independent"])
require.Equal(t, 0.7, decoded["video_rate_multiplier"])
// 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
models := decoded["models"].([]any)
@@ -197,6 +197,10 @@ func (h *OpenAIGatewayHandler) AlphaSearch(c *gin.Context) {
var failoverErr *service.UpstreamFailoverError
if !errors.As(err, &failoverErr) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account, openAIAccountScheduleModel(c, account, requestedModel, false, result), false, nil, err)
if failoverClientGone(c) {
reqLog.Info("openai_alpha_search.forward_aborted_client_disconnected", zap.Int64("account_id", account.ID), zap.Error(err))
return
}
if c.Writer.Size() == writerSizeBeforeForward {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
}
@@ -1146,6 +1146,9 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
if shouldSkipOpsErrorLog(c.Request.Context(), ops, parsed.Message, string(body), c.Request.URL.Path) {
return
}
if shouldSkipOpsClientClosed(c, ops, status) {
return
}
apiKey := getOpsAPIKey(c)
@@ -2491,6 +2494,21 @@ func shouldSkipOpsErrorLog(ctx context.Context, ops *service.OpsService, message
return false
}
// shouldSkipOpsClientClosed 按 IgnoreContextCanceled 过滤纯客户端取消的 499。
// 499 表示客户端在响应提交前断开(见 failoverClientGone),通常不带
// "context canceled" 文案,shouldSkipOpsErrorLog 的文本过滤命中不了。
// 本次请求未观察到上游错误时是纯客户端取消;已有上游错误的 499 表示上游失败后
// 客户端没等到换号结果就离开,仍按上游失败落库。
func shouldSkipOpsClientClosed(c *gin.Context, ops *service.OpsService, status int) bool {
if status != statusClientClosedRequest || ops == nil {
return false
}
if !ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled {
return false
}
return !hasOpsUpstreamErrorContext(c)
}
// shouldSkipOpsErrorLogForCyber:cyber_policy 命中的请求由 recordCyberPolicyIfMarked
// 统一落一条 status=403 的错误请求,故中间件跳过自身落库,避免双写。
func shouldSkipOpsErrorLogForCyber(c *gin.Context) bool {
@@ -2168,3 +2168,83 @@ func TestNormalizeOpsErrorType_KeepsGeminiInBandSignalTypes(t *testing.T) {
require.Equal(t, errType, normalizeOpsErrorType(errType, "PROHIBITED_CONTENT"), errType)
}
}
type opsAdvancedSettingsRepoStub struct {
service.SettingRepository
advanced string
}
func (r *opsAdvancedSettingsRepoStub) GetValue(context.Context, string) (string, error) {
return "", service.ErrSettingNotFound
}
func (r *opsAdvancedSettingsRepoStub) GetMultiple(context.Context, []string) (map[string]string, error) {
return map[string]string{service.SettingKeyOpsAdvancedSettings: r.advanced}, nil
}
func (r *opsAdvancedSettingsRepoStub) Set(context.Context, string, string) error {
return nil
}
// serveClientClosedRequest 以已取消的请求 context 走 failoverClientGone,复现网关标记 499 的收尾路径。
func serveClientClosedRequest(t *testing.T, ops *service.OpsService, prepare func(c *gin.Context)) {
t.Helper()
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1/messages", func(c *gin.Context) {
if prepare != nil {
prepare(c)
}
failoverClientGone(c)
})
ctx, cancel := context.WithCancel(context.Background())
cancel()
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1/messages", nil).WithContext(ctx))
require.Equal(t, statusClientClosedRequest, recorder.Code)
require.Zero(t, recorder.Body.Len())
}
func TestOpsErrorLoggerMiddleware_SkipsPureClientClosed(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
require.True(t, ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled)
serveClientClosedRequest(t, ops, nil)
require.Zero(t, OpsErrorLogQueueLength(), "纯客户端取消的 499 按 IgnoreContextCanceled 跳过")
}
func TestOpsErrorLoggerMiddleware_RecordsClientClosedAfterUpstreamFailure(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
serveClientClosedRequest(t, ops, func(c *gin.Context) {
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
AccountID: 7, UpstreamStatusCode: 524, Kind: "failover", Message: "upstream timeout",
}})
})
require.Equal(t, int64(1), OpsErrorLogQueueLength(), "上游失败后客户端离开仍按上游失败落库")
job := <-opsErrorLogQueue
require.Equal(t, statusClientClosedRequest, job.entry.StatusCode)
require.Equal(t, "upstream", job.entry.ErrorPhase)
require.NotNil(t, job.entry.UpstreamStatusCode)
require.Equal(t, 524, *job.entry.UpstreamStatusCode)
}
func TestOpsErrorLoggerMiddleware_RecordsClientClosedWhenIgnoreContextCanceledDisabled(t *testing.T) {
setupOpsErrorLogTestQueue(t, 2)
gin.SetMode(gin.TestMode)
settings := &opsAdvancedSettingsRepoStub{advanced: `{"ignore_context_canceled":false}`}
ops := service.NewOpsService(nil, settings, nil, nil, nil, nil, nil, nil, nil, nil, nil)
require.False(t, ops.OpsAdvancedSettingsSnapshot().IgnoreContextCanceled)
serveClientClosedRequest(t, ops, nil)
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.Equal(t, statusClientClosedRequest, job.entry.StatusCode)
}
@@ -532,8 +532,8 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
}
parts = append(parts, part)
case "image":
if block.Source != nil && block.Source.Type == "base64" {
case "image", "document":
if block.Source != nil && block.Source.Type == "base64" && strings.TrimSpace(block.Source.Data) != "" {
parts = append(parts, GeminiPart{
InlineData: &GeminiInlineData{
MimeType: block.Source.MediaType,
@@ -646,6 +646,20 @@ func TestGeminiToolConfig_DropsBuiltinsWhenClientFunctionsPresent(t *testing.T)
})
}
func TestBuildParts_DocumentBecomesInlineData(t *testing.T) {
content := `[
{"type":"text","text":"read this"},
{"type":"document","source":{"type":"base64","media_type":"application/pdf","data":"JVBERi0="}}
]`
parts, stripped, err := buildParts(json.RawMessage(content), map[string]string{}, true)
require.NoError(t, err)
require.False(t, stripped)
require.Len(t, parts, 2)
require.NotNil(t, parts[1].InlineData)
require.Equal(t, "application/pdf", parts[1].InlineData.MimeType)
require.Equal(t, "JVBERi0=", parts[1].InlineData.Data)
}
// TestToolConfigAlwaysPresent ensures toolConfig is always emitted, including for
// reasoning models without any tools: upstream rejects requests that omit it.
func TestToolConfigAlwaysPresent(t *testing.T) {
@@ -258,6 +258,18 @@ func cleanJSONSchemaRecursive(value any) any {
if constVal, exists := schemaMap["const"]; exists {
if _, hasEnum := schemaMap["enum"]; !hasEnum {
schemaMap["enum"] = []any{constVal}
} else if constant, ok := constVal.(string); ok {
if existing, ok := schemaMap["enum"].([]any); ok {
// Both constraints apply; preserve their intersection before dropping const.
values := []any{}
for _, value := range existing {
if text, ok := value.(string); ok && text == constant {
values = append(values, constant)
break
}
}
schemaMap["enum"] = values
}
}
if _, hasType := schemaMap["type"]; !hasType {
switch constVal.(type) {
@@ -0,0 +1,45 @@
package antigravity
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestBuildToolsPreservesStringConst(t *testing.T) {
for _, tc := range []struct {
name string
schema string
want string
}{
{"typed", `{"type":"string","const":"browser"}`, `{"type":"string","enum":["browser"]}`},
{"inferred", `{"const":"browser"}`, `{"type":"string","enum":["browser"]}`},
{"empty", `{"type":"string","const":""}`, `{"type":"string","enum":[""]}`},
{"existing enum", `{"type":"string","const":"browser","enum":["browser","shell"]}`, `{"type":"string","enum":["browser"]}`},
{"conflicting enum", `{"type":"string","const":"browser","enum":["shell"]}`, `{"type":"string","enum":[]}`},
{"ordinary enum", `{"type":"string","enum":["browser","shell"]}`, `{"type":"string","enum":["browser","shell"]}`},
{"array items", `{"type":"array","items":{"type":"string","const":"browser"}}`, `{"type":"array","items":{"type":"string","enum":["browser"]}}`},
{"nested property named const", `{"type":"object","properties":{"const":{"const":"browser"}}}`, `{"type":"object","properties":{"const":{"type":"string","enum":["browser"]}}}`},
{"schema metadata", `{"type":"string","const":"browser","$schema":"https://json-schema.org/draft/2020-12/schema","description":"Tool kind"}`, `{"type":"string","enum":["browser"],"description":"Tool kind"}`},
} {
t.Run(tc.name, func(t *testing.T) {
var property map[string]any
require.NoError(t, json.Unmarshal([]byte(tc.schema), &property))
tools := buildTools([]ClaudeTool{{
Name: "dispatch",
InputSchema: map[string]any{
"type": "object",
"properties": map[string]any{"action": property},
},
}})
require.Len(t, tools, 1)
require.Len(t, tools[0].FunctionDeclarations, 1)
properties, ok := tools[0].FunctionDeclarations[0].Parameters["properties"].(map[string]any)
require.True(t, ok, "tool parameters must contain a properties object")
got, err := json.Marshal(properties["action"])
require.NoError(t, err)
require.JSONEq(t, tc.want, string(got))
})
}
}
@@ -40,6 +40,7 @@ type StreamingProcessor struct {
outputTokens int
cacheReadTokens int
imageOutputTokens int
hasContent bool
}
// NewStreamingProcessor 创建流式响应处理器
@@ -175,6 +176,11 @@ func (p *StreamingProcessor) MessageStartSent() bool {
return p.messageStartSent
}
// HasContent reports whether any substantive text, thinking, or tool calls were emitted.
func (p *StreamingProcessor) HasContent() bool {
return p.hasContent
}
// emitMessageStart 发送 message_start 事件
func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte {
if p.messageStartSent {
@@ -296,6 +302,7 @@ func (p *StreamingProcessor) processThinking(text, signature string) []byte {
}
if text != "" {
p.hasContent = true
_, _ = result.Write(p.emitDelta("thinking_delta", map[string]any{
"thinking": text,
}))
@@ -350,6 +357,7 @@ func (p *StreamingProcessor) processText(text, signature string) []byte {
}))
}
p.hasContent = true
_, _ = result.Write(p.emitDelta("text_delta", map[string]any{
"text": text,
}))
@@ -362,6 +370,7 @@ func (p *StreamingProcessor) processFunctionCall(fc *GeminiFunctionCall, signatu
var result bytes.Buffer
p.usedTool = true
p.hasContent = true
toolID := fc.ID
if toolID == "" {
@@ -1136,9 +1136,24 @@ func TestAnthropicToResponses_ThinkingDisabled(t *testing.T) {
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
// Default effort applies (medium) even when thinking is disabled.
require.NotNil(t, resp.Reasoning)
assert.Equal(t, "medium", resp.Reasoning.Effort)
assert.Equal(t, "none", resp.Reasoning.Effort)
assert.Empty(t, resp.Reasoning.Summary)
}
func TestAnthropicToResponses_ThinkingDisabledOverridesOutputEffort(t *testing.T) {
req := &AnthropicRequest{
Model: "gpt-5.6-sol",
MaxTokens: 1024,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
Thinking: &AnthropicThinking{Type: "disabled"},
OutputConfig: &AnthropicOutputConfig{Effort: "max"},
}
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.Reasoning)
assert.Equal(t, "none", resp.Reasoning.Effort)
}
func TestAnthropicToResponses_NoThinking(t *testing.T) {
@@ -57,17 +57,13 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
out.Tools = convertAnthropicToolsToResponses(req.Tools)
}
// Determine reasoning effort: only output_config.effort controls the
// level; thinking.type is ignored. Default follows Codex CLI / airgate's
// Anthropic bridge shape, which uses medium when unset.
// Anthropic levels map 1:1 to OpenAI: low→low, medium→medium, high→high, max→xhigh.
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
// An explicit thinking disable takes precedence over output_config.effort.
effort := anthropicReasoningEffort(req)
out.Reasoning = &ResponsesReasoning{
Effort: mapAnthropicEffortToResponses(effort),
Summary: "auto",
Effort: effort,
}
if effort != "none" {
out.Reasoning.Summary = "auto"
}
// Convert tool_choice
@@ -423,17 +419,22 @@ func extractAnthropicTextFromBlocks(blocks []AnthropicContentBlock) string {
return strings.Join(parts, "\n\n")
}
// mapAnthropicEffortToResponses converts Anthropic reasoning effort levels to
// OpenAI Responses API effort levels.
//
// Both APIs default to "high". The mapping is 1:1 for shared levels;
// only Anthropic's "max" (Opus 4.6 exclusive) maps to OpenAI's "xhigh"
// (GPT-5.2+ exclusive) as both represent the highest reasoning tier.
//
// low → low
// medium → medium
// high → high
// max → xhigh
// anthropicReasoningEffort resolves the Anthropic request preference for both
// OpenAI bridges. Explicitly disabled thinking overrides output_config.effort;
// otherwise the bridge keeps its medium default.
func anthropicReasoningEffort(req *AnthropicRequest) string {
if req.Thinking != nil && req.Thinking.Type == "disabled" {
return "none"
}
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
return mapAnthropicEffortToResponses(effort)
}
// mapAnthropicEffortToResponses maps shared effort levels directly and maps
// Anthropic's max to OpenAI's xhigh.
func mapAnthropicEffortToResponses(effort string) string {
if effort == "max" {
return "xhigh"
@@ -469,10 +470,38 @@ func boolPtr(v bool) *bool {
// isReasoningModel reports whether model is a reasoning model that does not
// support sampling parameters (temperature, top_p) via the Responses API.
// All gpt-5.x models are reasoning-only; the Responses API returns
// "Unsupported parameter: temperature" if these fields are present.
// GPT-5 and every later generation are reasoning-only; the Responses API
// returns "Unsupported parameter: temperature" if these fields are present.
//
// Keyed on the generation number instead of a "gpt-5" prefix: pinning the
// prefix meant each new family (gpt-6-astra and whatever follows) silently
// fell through to the sampling branch and failed upstream on every compat
// request until someone edited this line.
func isReasoningModel(model string) bool {
return strings.HasPrefix(model, "gpt-5") || openai.IsGPT6SolOrLunaModelSpelling(model)
major, ok := openAIModelGeneration(model)
return (ok && major >= 5) || openai.IsGPT6SolOrLunaModelSpelling(model)
}
// openAIModelGeneration extracts N from a "gpt-N[.M][-suffix]" model id.
// ok is false for non-GPT ids and for GPT families that carry no numeric
// generation (gpt-image-1, gpt-audio, ...).
func openAIModelGeneration(model string) (int, bool) {
rest, ok := strings.CutPrefix(strings.ToLower(strings.TrimSpace(model)), "gpt-")
if !ok {
return 0, false
}
major, digits := 0, 0
for _, r := range rest {
if r < '0' || r > '9' {
break
}
major = major*10 + int(r-'0')
digits++
}
if digits == 0 {
return 0, false
}
return major, true
}
// normalizeToolParameters ensures the tool parameter schema is valid for
@@ -6,6 +6,7 @@ import (
"encoding/hex"
"encoding/json"
"fmt"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
@@ -198,6 +199,12 @@ type AnthropicEventToResponsesState struct {
CurrentThinking AnthropicContentBlock
PreserveThinkingSignatures bool
// PendingToolInput holds tool arguments that arrived complete on
// content_block_start instead of as input_json_delta. It is only consumed at
// content_block_stop, and only when no delta ever arrived, so a canonical
// Anthropic stream keeps its exact event sequence.
PendingToolInput string
// Outputs accumulates every closed output item so that response.completed
// can carry the full output list. The OpenAI SDK's get_final_response()
// parses the terminal event's response directly; without this, clients see
@@ -382,6 +389,12 @@ func anthToResHandleContentBlockStart(evt *AnthropicStreamEvent, state *Anthropi
state.CurrentItemType = "function_call"
state.CurrentCallID = toResponsesCallID(evt.ContentBlock.ID)
state.CurrentName = evt.ContentBlock.Name
// The canonical Anthropic stream leaves input empty here and streams the
// arguments as input_json_delta, but Anthropic-compatible relays may put
// the complete arguments on this event and never send a delta. Keep them
// as a seed rather than emitting now: a delta, if one follows, is
// authoritative and must not be concatenated onto this JSON.
state.PendingToolInput = seedToolArguments(evt.ContentBlock.Input)
events = append(events, makeResponsesEvent(state, "response.output_item.added", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
@@ -432,6 +445,9 @@ func anthToResHandleContentBlockDelta(evt *AnthropicStreamEvent, state *Anthropi
if evt.Delta.PartialJSON == "" {
return nil
}
// A real delta supersedes whatever content_block_start carried; keeping
// both would splice two complete JSON documents together.
state.PendingToolInput = ""
state.CurrentArgs += evt.Delta.PartialJSON
return []ResponsesStreamEvent{makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
@@ -467,21 +483,35 @@ func anthToResHandleContentBlockStop(evt *AnthropicStreamEvent, state *Anthropic
return events
case "function_call":
var events []ResponsesStreamEvent
// No delta ever arrived, so the arguments the upstream put on
// content_block_start are all there is. Emit them as one delta here so
// the done event below still repeats exactly what the deltas streamed.
if state.CurrentArgs == "" && state.PendingToolInput != "" {
state.CurrentArgs = state.PendingToolInput
events = append(events, makeResponsesEvent(state, "response.function_call_arguments.delta", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
Delta: state.PendingToolInput,
ItemID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
}))
}
state.PendingToolInput = ""
// Emit function_call_arguments.done + output item done.
// arguments must repeat exactly what the deltas already streamed for this
// item: clients reconcile the done event against the accumulated
// function_call_arguments.delta payloads and reject the call as
// inconsistent_tool_call when the two disagree. Omitting the field left it
// empty while the deltas carried the whole JSON.
events := []ResponsesStreamEvent{
makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ItemID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
Arguments: state.CurrentArgs,
}),
}
events = append(events, makeResponsesEvent(state, "response.function_call_arguments.done", &ResponsesStreamEvent{
OutputIndex: state.OutputIndex,
ItemID: state.CurrentItemID,
CallID: state.CurrentCallID,
Name: state.CurrentName,
Arguments: state.CurrentArgs,
}))
events = append(events, closeCurrentResponsesItem(state)...)
return events
@@ -562,6 +592,19 @@ func anthropicResponsesStreamTerminalState(stopReason string) (string, *Response
return "completed", nil
}
// seedToolArguments normalizes a tool_use content block's inline input into a
// seed for the streaming converter. Empty, absent and no-argument payloads
// return "" so the existing "{}" fallback still applies and no empty delta is
// synthesized.
func seedToolArguments(input json.RawMessage) string {
trimmed := strings.TrimSpace(string(input))
switch trimmed {
case "", "{}", "null":
return ""
}
return trimmed
}
func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if state.CurrentItemType == "" {
return nil
@@ -605,6 +648,7 @@ func closeCurrentResponsesItem(state *AnthropicEventToResponsesState) []Response
state.CurrentName = ""
state.CurrentContent = nil
state.CurrentArgs = ""
state.PendingToolInput = ""
state.CurrentSummary = ""
state.CurrentThinking = AnthropicContentBlock{}
state.TextAccum = ""
@@ -0,0 +1,168 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// collectToolCallStreamEvents drives a tool_use block through the Anthropic →
// Responses stream converter and returns every emitted event in order.
func collectToolCallStreamEvents(t *testing.T, blockInput json.RawMessage, partials []string) []ResponsesStreamEvent {
t.Helper()
state := NewAnthropicEventToResponsesState()
var all []ResponsesStreamEvent
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{
ID: "msg_tool_input",
Model: "gemini-3.7-flash",
Usage: AnthropicUsage{InputTokens: 12},
},
}, state)...)
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_start",
ContentBlock: &AnthropicContentBlock{
Type: "tool_use",
ID: "toolu_01abc",
Name: "eval",
Input: blockInput,
},
}, state)...)
for _, partial := range partials {
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "content_block_delta",
Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: partial},
}, state)...)
}
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "content_block_stop"}, state)...)
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{
Type: "message_delta",
Usage: &AnthropicUsage{OutputTokens: 9},
}, state)...)
all = append(all, AnthropicEventToResponsesEvents(&AnthropicStreamEvent{Type: "message_stop"}, state)...)
return all
}
func concatArgumentDeltas(events []ResponsesStreamEvent) string {
out := ""
for _, e := range events {
if e.Type == "response.function_call_arguments.delta" {
out += e.Delta
}
}
return out
}
func findFunctionCallOutput(events []ResponsesStreamEvent) *ResponsesOutput {
for i := range events {
if events[i].Type != "response.output_item.done" || events[i].Item == nil {
continue
}
if events[i].Item.Type == "function_call" {
return events[i].Item
}
}
return nil
}
// Upstreams that are not the canonical Anthropic API (here: an Anthropic-compatible
// relay fronting Gemini) put the complete tool arguments on content_block_start and
// never emit an input_json_delta. The converter must not drop them: this repository
// already reads ContentBlock.Input on the non-streaming path
// (anthropicResponseToResponsesOutputs) and on the ChatCompletions bridge, so the
// streaming path has to agree. Dropping it makes every client-side tool call arrive
// with `{}` and the agent loop cannot proceed.
func TestAnthropicEventToResponses_ToolInputOnContentBlockStart(t *testing.T) {
const args = `{"language":"py","code":"print(1)"}`
events := collectToolCallStreamEvents(t, json.RawMessage(args), nil)
item := findFunctionCallOutput(events)
require.NotNil(t, item, "a function_call output item must be emitted")
assert.Equal(t, args, item.Arguments,
"arguments carried on content_block_start must survive to output_item.done")
assert.Equal(t, "eval", item.Name)
// Clients that accumulate from the event stream (rather than reading the
// terminal item) must see the arguments too.
assert.Equal(t, args, concatArgumentDeltas(events),
"argument deltas must reconstruct the full arguments JSON")
done := findEvent(events, "response.function_call_arguments.done")
require.NotNil(t, done, "function_call_arguments.done must be emitted")
assert.Equal(t, args, done.Arguments,
"function_call_arguments.done must carry the complete arguments")
completed := findEvent(events, "response.completed")
require.NotNil(t, completed)
require.NotNil(t, completed.Response)
require.Len(t, completed.Response.Output, 1)
assert.Equal(t, args, completed.Response.Output[0].Arguments,
"response.completed must carry the same arguments")
}
// The canonical Anthropic shape (empty input on content_block_start, arguments
// streamed as input_json_delta) must keep its existing event sequence exactly.
func TestAnthropicEventToResponses_ToolInputFromDeltasUnchanged(t *testing.T) {
const args = `{"language":"py","code":"print(1)"}`
events := collectToolCallStreamEvents(t, json.RawMessage(`{}`),
[]string{`{"language":"py",`, `"code":"print(1)"}`})
item := findFunctionCallOutput(events)
require.NotNil(t, item)
assert.Equal(t, args, item.Arguments)
// Exactly the two upstream deltas, not duplicated by the seed.
var deltas []string
for _, e := range events {
if e.Type == "response.function_call_arguments.delta" {
deltas = append(deltas, e.Delta)
}
}
assert.Equal(t, []string{`{"language":"py",`, `"code":"print(1)"}`}, deltas,
"canonical delta streaming must not gain or lose events")
}
// An upstream that sends both a populated content_block_start and real deltas
// must not have the two concatenated into malformed JSON.
func TestAnthropicEventToResponses_ToolInputSeedNotDuplicatedByDeltas(t *testing.T) {
const args = `{"language":"py","code":"print(1)"}`
events := collectToolCallStreamEvents(t, json.RawMessage(args),
[]string{`{"language":"py",`, `"code":"print(1)"}`})
item := findFunctionCallOutput(events)
require.NotNil(t, item)
assert.Equal(t, args, item.Arguments, "deltas win over the content_block_start seed")
assert.True(t, json.Valid([]byte(item.Arguments)), "arguments must stay valid JSON")
assert.Equal(t, args, concatArgumentDeltas(events), "seed must not be replayed alongside deltas")
}
// An empty or absent input on content_block_start with no deltas keeps the
// existing "{}" fallback rather than emitting a spurious delta.
func TestAnthropicEventToResponses_ToolInputEmptyStaysEmptyObject(t *testing.T) {
for name, input := range map[string]json.RawMessage{
"absent": nil,
"empty": json.RawMessage(`{}`),
} {
t.Run(name, func(t *testing.T) {
events := collectToolCallStreamEvents(t, input, nil)
item := findFunctionCallOutput(events)
require.NotNil(t, item)
assert.Equal(t, "{}", item.Arguments)
assert.Empty(t, concatArgumentDeltas(events),
"no argument delta should be synthesized when there are no arguments")
})
}
}
@@ -0,0 +1,131 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// driveToolUseThroughRestorer runs a tool_use block through the full outbound
// chain the gateway actually uses: the Anthropic→Responses stream converter
// feeding ResponsesClientToolStreamRestorer. Returns the events a client sees.
func driveToolUseThroughRestorer(
blockInput json.RawMessage,
partials []string,
mapping ResponsesClientToolMapping,
) []ResponsesStreamEvent {
state := NewAnthropicEventToResponsesState()
restorer := NewResponsesClientToolStreamRestorer(mapping)
var clientEvents []ResponsesStreamEvent
feed := func(evt *AnthropicStreamEvent) {
for _, converted := range AnthropicEventToResponsesEvents(evt, state) {
clientEvents = append(clientEvents, restorer.Restore(converted)...)
}
}
feed(&AnthropicStreamEvent{
Type: "message_start",
Message: &AnthropicResponse{
ID: "msg_chain",
Model: "gemini-3.7-flash",
Usage: AnthropicUsage{InputTokens: 11},
},
})
feed(&AnthropicStreamEvent{
Type: "content_block_start",
ContentBlock: &AnthropicContentBlock{
Type: "tool_use",
ID: "toolu_chain",
Name: "eval",
Input: blockInput,
},
})
for _, partial := range partials {
feed(&AnthropicStreamEvent{
Type: "content_block_delta",
Delta: &AnthropicDelta{Type: "input_json_delta", PartialJSON: partial},
})
}
feed(&AnthropicStreamEvent{Type: "content_block_stop"})
feed(&AnthropicStreamEvent{Type: "message_delta", Usage: &AnthropicUsage{OutputTokens: 6}})
feed(&AnthropicStreamEvent{Type: "message_stop"})
return clientEvents
}
func firstEventOfType(events []ResponsesStreamEvent, typ string) *ResponsesStreamEvent {
for i := range events {
if events[i].Type == typ {
return &events[i]
}
}
return nil
}
// A client-side custom tool whose arguments arrived only on content_block_start
// must still reach the client as a populated custom_tool_call. The restorer
// suppresses function_call_arguments.delta for adapted tools and rebuilds the
// input from the accumulated arguments, so a converter that drops the inline
// input yields an empty tool call even though nothing errors.
func TestToolInputOnContentBlockStart_SurvivesClientToolRestore(t *testing.T) {
mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"eval": true}}
events := driveToolUseThroughRestorer(
json.RawMessage(`{"input":"print(1)"}`), nil, mapping)
inputDone := firstEventOfType(events, "response.custom_tool_call_input.done")
require.NotNil(t, inputDone, "custom_tool_call_input.done must be emitted")
assert.Equal(t, "print(1)", inputDone.Input,
"inline tool input must survive conversion and client-tool restoration")
var itemDone *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.output_item.done" && events[i].Item != nil &&
events[i].Item.Type == "custom_tool_call" {
itemDone = &events[i]
}
}
require.NotNil(t, itemDone, "a custom_tool_call item must be closed")
assert.Equal(t, "print(1)", itemDone.Item.Input)
}
// The same block delivered as a plain (non-adapted) function tool must carry
// its arguments through untouched.
func TestToolInputOnContentBlockStart_PlainFunctionToolChain(t *testing.T) {
const args = `{"language":"py","code":"print(1)"}`
events := driveToolUseThroughRestorer(
json.RawMessage(args), nil, ResponsesClientToolMapping{})
done := firstEventOfType(events, "response.function_call_arguments.done")
require.NotNil(t, done)
assert.Equal(t, args, done.Arguments)
var itemDone *ResponsesStreamEvent
for i := range events {
if events[i].Type == "response.output_item.done" && events[i].Item != nil &&
events[i].Item.Type == "function_call" {
itemDone = &events[i]
}
}
require.NotNil(t, itemDone)
assert.Equal(t, args, itemDone.Item.Arguments)
}
// Canonical delta streaming through the same chain must keep working, so the
// seed path cannot be mistaken for the only source of arguments.
func TestToolInputFromDeltas_SurvivesClientToolRestore(t *testing.T) {
mapping := ResponsesClientToolMapping{CustomTools: map[string]bool{"eval": true}}
events := driveToolUseThroughRestorer(
json.RawMessage(`{}`),
[]string{`{"input":"pri`, `nt(1)"}`},
mapping)
inputDone := firstEventOfType(events, "response.custom_tool_call_input.done")
require.NotNil(t, inputDone)
assert.Equal(t, "print(1)", inputDone.Input)
}
@@ -99,13 +99,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR
}
}
// Reasoning effort: output_config.effort maps 1:1 (max→xhigh). thinking.type
// itself is ignored (the Responses bridge behaves identically).
effort := "medium"
if req.OutputConfig != nil && req.OutputConfig.Effort != "" {
effort = req.OutputConfig.Effort
}
out.ReasoningEffort = mapAnthropicEffortToResponses(effort)
// Match the Responses bridge, including an explicit thinking disable.
out.ReasoningEffort = anthropicReasoningEffort(req)
parallelToolCalls := true
out.ParallelToolCalls = &parallelToolCalls
@@ -150,6 +150,20 @@ func TestAnthropicToChatCompletionsRequest_ThinkingDropped(t *testing.T) {
require.Empty(t, out.Messages[0].ReasoningContent)
}
func TestAnthropicToChatCompletionsRequest_ThinkingDisabledOverridesOutputEffort(t *testing.T) {
req := &AnthropicRequest{
Model: "gpt-5.6-luna",
MaxTokens: 1024,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
Thinking: &AnthropicThinking{Type: "disabled"},
OutputConfig: &AnthropicOutputConfig{Effort: "max"},
}
out, err := AnthropicToChatCompletionsRequest(req)
require.NoError(t, err)
require.Equal(t, "none", out.ReasoningEffort)
}
func TestAnthropicToChatCompletionsRequest_ToolChoiceAuto(t *testing.T) {
req := &AnthropicRequest{
Model: "claude-sonnet-4-20250514",
@@ -92,6 +92,56 @@ func TestChatCompletionsToResponses_SystemMessage(t *testing.T) {
assert.Equal(t, "user", items[1].Role)
}
func TestChatCompletionsToResponses_MessageTypesWithReasoningAndTools(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "step-5-preview",
Messages: []ChatMessage{
{Role: "system", Content: json.RawMessage(`"You are helpful."`)},
{Role: "user", Content: json.RawMessage(`"Check the directory"`)},
{
Role: "assistant",
Content: json.RawMessage(`"I will check."`),
ReasoningContent: "Need to inspect the directory.",
ToolCalls: []ChatToolCall{{
ID: "call_1", Type: "function",
Function: ChatFunctionCall{Name: "bash", Arguments: `{"cmd":"pwd"}`},
}},
},
{Role: "tool", ToolCallID: "call_1", Content: json.RawMessage(`"/tmp"`)},
{Role: "assistant", ReasoningContent: "The tool returned /tmp."},
{Role: "user", Content: json.RawMessage(`"Continue"`)},
},
}
resp, err := ChatCompletionsToResponses(req)
require.NoError(t, err)
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(resp.Input, &items))
require.Len(t, items, 7)
for i, want := range []struct{ typ, role string }{
{"message", "system"},
{"message", "user"},
{"message", "assistant"},
{"function_call", ""},
{"function_call_output", ""},
{"message", "assistant"},
{"message", "user"},
} {
assert.Equal(t, want.typ, items[i].Type, "input item %d type", i)
assert.Equal(t, want.role, items[i].Role, "input item %d role", i)
}
var assistantContent, reasoningOnlyContent []ResponsesContentPart
require.NoError(t, json.Unmarshal(items[2].Content, &assistantContent))
require.NoError(t, json.Unmarshal(items[5].Content, &reasoningOnlyContent))
require.Len(t, assistantContent, 1)
require.Len(t, reasoningOnlyContent, 1)
assert.Equal(t, "<thinking>Need to inspect the directory.</thinking>\nI will check.", assistantContent[0].Text)
assert.Equal(t, "<thinking>The tool returned /tmp.</thinking>", reasoningOnlyContent[0].Text)
assert.Equal(t, "call_1", items[3].CallID)
assert.Equal(t, "call_1", items[4].CallID)
}
func TestChatCompletionsToResponses_ToolCalls(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "gpt-4o",
@@ -143,7 +143,7 @@ func chatSystemToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Role: "system", Content: content}}, nil
return []ResponsesInputItem{{Type: "message", Role: "system", Content: content}}, nil
}
// chatUserToResponses converts a user message, handling both plain strings and
@@ -157,7 +157,7 @@ func chatUserToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
if err != nil {
return nil, err
}
return []ResponsesInputItem{{Role: "user", Content: content}}, nil
return []ResponsesInputItem{{Type: "message", Role: "user", Content: content}}, nil
}
// chatAssistantToResponses converts an assistant message. If there is both
@@ -192,7 +192,7 @@ func chatAssistantToResponses(m ChatMessage) ([]ResponsesInputItem, error) {
if err != nil {
return nil, err
}
items = append(items, ResponsesInputItem{Role: "assistant", Content: partsJSON})
items = append(items, ResponsesInputItem{Type: "message", Role: "assistant", Content: partsJSON})
}
// Emit one function_call item per tool_call.
@@ -0,0 +1,78 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// GPT-5 and every later generation are reasoning-only: forwarding temperature or
// top_p makes the Responses API reject the request with "Unsupported parameter".
// The check is keyed on the generation number so a new family (gpt-6-astra and
// whatever follows) does not silently fall into the sampling branch.
func TestIsReasoningModelCoversLaterGenerations(t *testing.T) {
for _, tc := range []struct {
model string
want bool
}{
{"gpt-6-astra", true},
{"gpt-6", true},
{"gpt-7-whatever", true},
{"gpt-6-sol", true},
{"gpt-5.5", true},
{"gpt-5.2", true},
{"gpt-5", true},
{"GPT-6-Astra", true},
{" gpt-6-astra ", true},
{"gpt-4o", false},
{"gpt-4.1", false},
{"gpt-image-1", false},
{"gpt-audio", false},
{"claude-opus-4-6", false},
{"gemini-3.1-pro", false},
{"", false},
} {
if got := isReasoningModel(tc.model); got != tc.want {
t.Errorf("isReasoningModel(%q) = %v, want %v", tc.model, got, tc.want)
}
}
}
func TestOpenAIModelGeneration(t *testing.T) {
for _, tc := range []struct {
model string
wantMajor int
wantOK bool
}{
{"gpt-6-astra", 6, true},
{"gpt-5.5", 5, true},
{"gpt-4o", 4, true},
{"gpt-10-future", 10, true},
{"gpt-image-1", 0, false},
{"claude-opus-4-6", 0, false},
{"", 0, false},
} {
major, ok := openAIModelGeneration(tc.model)
if major != tc.wantMajor || ok != tc.wantOK {
t.Errorf("openAIModelGeneration(%q) = (%d, %v), want (%d, %v)",
tc.model, major, ok, tc.wantMajor, tc.wantOK)
}
}
}
func TestAnthropicToResponses_TemperatureStrippedForGPT6Astra(t *testing.T) {
temp := 0.7
req := &AnthropicRequest{
Model: "gpt-6-astra",
MaxTokens: 1024,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
Temperature: &temp,
TopP: &temp,
}
resp, err := AnthropicToResponses(req)
require.NoError(t, err)
require.Nil(t, resp.Temperature, "gpt-6-astra is reasoning-only: temperature must be stripped")
require.Nil(t, resp.TopP, "gpt-6-astra is reasoning-only: top_p must be stripped")
}
@@ -109,7 +109,23 @@ func TestResponsesToAnthropic_UnknownItemTypeKeepsRecognizableText(t *testing.T)
require.NotContains(t, string(messages[0].Content), "drop me")
}
// user 消息的分片全部不可识别时,以前会退化成 content:"",Anthropic 拒收空内容消息。
// data URI 形式的 input_file 要变成 Anthropic document,供后续 Gemini inlineData 使用。
func TestResponsesToAnthropic_InputFileDataURIBecomesDocument(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[
{"type":"input_text","text":"read this"},
{"type":"input_file","filename":"token.pdf","file_data":"data:application/pdf;base64,JVBERi0="}
]}
]`)
requireAnthropicMessagesAreSendable(t, messages)
require.Len(t, messages, 1)
require.Contains(t, string(messages[0].Content), `"type":"document"`)
require.Contains(t, string(messages[0].Content), `"media_type":"application/pdf"`)
require.Contains(t, string(messages[0].Content), `"data":"JVBERi0="`)
}
// 只有 file_id、没有 data URI 的 input_file 仍然无法转换,整条消息丢掉。
func TestResponsesToAnthropic_UserMessageWithOnlyUnknownPartsIsDropped(t *testing.T) {
messages := responsesToAnthropicMessages(t, `[
{"type":"message","role":"user","content":[{"type":"input_file","file_id":"file_1"}]}
@@ -530,6 +530,14 @@ func convertResponsesUserToAnthropicContent(raw json.RawMessage) (json.RawMessag
Source: src,
})
}
case "input_file":
src := dataURIToAnthropicFileSource(p.FileData)
if src != nil {
blocks = append(blocks, AnthropicContentBlock{
Type: "document",
Source: src,
})
}
}
}
@@ -617,6 +625,12 @@ func dataURIToAnthropicImageSource(dataURI string) *AnthropicImageSource {
}
}
// dataURIToAnthropicFileSource parses a data URI into a document source.
// file_id-only parts are not convertible here and stay dropped.
func dataURIToAnthropicFileSource(fileData string) *AnthropicImageSource {
return dataURIToAnthropicImageSource(fileData)
}
// mergeConsecutiveMessages merges consecutive messages with the same role
// because Anthropic requires alternating user/assistant turns.
func mergeConsecutiveMessages(messages []AnthropicMessage) []AnthropicMessage {
@@ -609,6 +609,24 @@ func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesRe
return
}
// The terminal event can carry a non-empty output array whose message has
// no usable text. Trusting it as-is silently drops the text that already
// streamed: the client gets an empty reply while usage still bills the
// terminal output_tokens. Refill it from the accumulated deltas; non-empty
// terminal text stays authoritative.
if a.text.Len() > 0 && !responsesOutputHasText(resp.Output) {
if !fillResponsesOutputText(resp.Output, a.text.String()) {
resp.Output = append(resp.Output, ResponsesOutput{
Type: "message",
Role: "assistant",
Content: []ResponsesContentPart{{
Type: "output_text",
Text: a.text.String(),
}},
})
}
}
for outputIndex := range resp.Output {
item := &resp.Output[outputIndex]
if item.Type != "function_call" || item.Arguments != "" {
@@ -627,3 +645,46 @@ func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesRe
}
}
}
// responsesOutputHasText reports whether the terminal output already carries
// usable message text. Whitespace-only text does not count.
func responsesOutputHasText(output []ResponsesOutput) bool {
for i := range output {
if output[i].Type != "message" {
continue
}
for _, part := range output[i].Content {
if part.Type == "output_text" && strings.TrimSpace(part.Text) != "" {
return true
}
}
}
return false
}
// fillResponsesOutputText writes text into the first empty output_text part of
// the first message item, adding an output_text part when that message has
// none. It returns false when the output holds no message item, leaving the
// caller to append one.
func fillResponsesOutputText(output []ResponsesOutput, text string) bool {
for i := range output {
if output[i].Type != "message" {
continue
}
for j := range output[i].Content {
if output[i].Content[j].Type != "output_text" {
continue
}
if strings.TrimSpace(output[i].Content[j].Text) == "" {
output[i].Content[j].Text = text
return true
}
}
output[i].Content = append(output[i].Content, ResponsesContentPart{
Type: "output_text",
Text: text,
})
return true
}
return false
}
@@ -0,0 +1,134 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// When the terminal event carries a non-empty output array whose message has
// no text, the text that already streamed must be restored. Otherwise the
// client gets an empty reply while usage still bills the terminal
// output_tokens. The accumulator previously rebuilt output only when the
// terminal output array was empty, so this upstream shape silently lost text.
func TestSupplementResponseOutput_RecoversTextWhenTerminalMessageIsEmpty(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello, "})
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "world"})
resp := &ResponsesResponse{
ID: "resp_empty_msg",
Status: "completed",
Output: []ResponsesOutput{{
Type: "message",
Role: "assistant",
Content: []ResponsesContentPart{},
}},
Usage: &ResponsesUsage{InputTokens: 13168, OutputTokens: 3329},
}
acc.SupplementResponseOutput(resp)
chat := ResponsesToChatCompletions(resp, "gpt-5.5")
require.Len(t, chat.Choices, 1)
require.NotNil(t, chat.Choices[0].Message.Content, "the client must not receive an empty reply")
var got string
require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &got))
assert.Equal(t, "Hello, world", got)
}
// Whitespace-only terminal text counts as missing too.
func TestSupplementResponseOutput_RecoversTextWhenTerminalTextIsBlank(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "real text"})
resp := &ResponsesResponse{
Status: "completed",
Output: []ResponsesOutput{{
Type: "message",
Content: []ResponsesContentPart{{Type: "output_text", Text: " "}},
}},
}
acc.SupplementResponseOutput(resp)
chat := ResponsesToChatCompletions(resp, "m")
var got string
require.NotNil(t, chat.Choices[0].Message.Content)
require.NoError(t, json.Unmarshal(chat.Choices[0].Message.Content, &got))
assert.Equal(t, "real text", got)
}
// A terminal output array without any message item gets one appended to carry
// the accumulated text; existing items are kept.
func TestSupplementResponseOutput_AppendsMessageWhenTerminalHasNoMessage(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "only in the stream"})
resp := &ResponsesResponse{
Status: "completed",
Output: []ResponsesOutput{{
Type: "function_call",
CallID: "call_1",
Name: "verify",
}},
}
acc.SupplementResponseOutput(resp)
var text string
for _, item := range resp.Output {
if item.Type != "message" {
continue
}
for _, p := range item.Content {
text += p.Text
}
}
assert.Equal(t, "only in the stream", text)
require.Len(t, resp.Output, 2)
assert.Equal(t, "function_call", resp.Output[0].Type)
assert.Equal(t, "verify", resp.Output[0].Name)
}
// Non-empty terminal text stays authoritative and is never overwritten by the
// accumulated stream.
func TestSupplementResponseOutput_KeepsTerminalTextAuthoritative(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "from the stream"})
resp := &ResponsesResponse{
Status: "completed",
Output: []ResponsesOutput{{
Type: "message",
Content: []ResponsesContentPart{{Type: "output_text", Text: "from the terminal event"}},
}},
}
acc.SupplementResponseOutput(resp)
require.Len(t, resp.Output, 1)
assert.Equal(t, "from the terminal event", resp.Output[0].Content[0].Text)
}
// Without streamed text no synthetic message is created.
func TestSupplementResponseOutput_NoSyntheticMessageWithoutStreamText(t *testing.T) {
acc := NewBufferedResponseAccumulator()
resp := &ResponsesResponse{
Status: "completed",
Output: []ResponsesOutput{{
Type: "message",
Content: []ResponsesContentPart{},
}},
}
acc.SupplementResponseOutput(resp)
require.Len(t, resp.Output, 1)
assert.Empty(t, resp.Output[0].Content)
}
+1 -1
View File
@@ -263,7 +263,7 @@ type ResponsesText struct {
// The Type field determines which other fields are populated.
type ResponsesInputItem struct {
// Common
Type string `json:"type,omitempty"` // "" for role-based messages
Type string `json:"type,omitempty"` // "message" for role-based messages
// Role-based messages (developer/system/user/assistant)
Role string `json:"role,omitempty"`
+2
View File
@@ -18,6 +18,8 @@ const (
BetaTokenCounting = "token-counting-2024-11-01"
BetaContext1M = "context-1m-2025-08-07"
BetaFastMode = "fast-mode-2026-02-01"
// Legacy structured output compatibility; forwarded only when explicitly requested.
BetaStructuredOutputs = "structured-outputs-2025-11-13"
// 新增(对齐官方 CLI 2.1.9x 以来的流量)
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
@@ -53,6 +53,11 @@ func normalizeEffortModelID(model string) string {
}
id = strings.TrimPrefix(id, "anthropic.")
id = strings.TrimSuffix(id, "-thinking")
// OpenRouter uses a dotted minor version for this exact Opus 5.5 ID.
// Normalize it before effort, thinking, and billing family lookups.
if id == "claude-opus-5.5" {
id = "claude-opus-5-5"
}
if mapped, ok := ModelIDReverseOverrides[id]; ok {
id = mapped
}
@@ -16,6 +16,7 @@ func TestEffortLevelsForModel(t *testing.T) {
{model: "claude-opus-4-6", want: []string{"low", "medium", "high", "max"}},
{model: "anthropic/claude-sonnet-4-6", want: []string{"low", "medium", "high", "max"}},
{model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "anthropic/claude-opus-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}},
{model: "claude-haiku-4-5-20251001", want: nil},
{model: "gpt-5.6", want: nil},
@@ -27,3 +28,13 @@ func TestEffortLevelsForModel(t *testing.T) {
})
}
}
func TestIsOpus55OpenRouterExactAlias(t *testing.T) {
t.Parallel()
for _, model := range []string{"claude-opus-5-5", "anthropic/claude-opus-5.5"} {
require.True(t, IsOpus55(model), model)
}
for _, model := range []string{"claude-opus-5", "anthropic/claude-opus-5.6", "anthropic/claude-opus-5.5-preview"} {
require.False(t, IsOpus55(model), model)
}
}
+2
View File
@@ -16,6 +16,8 @@ func HTTPStatusToGoogleStatus(status int) string {
return "NOT_FOUND"
case http.StatusTooManyRequests:
return "RESOURCE_EXHAUSTED"
case 499: // client closed request
return "CANCELLED"
default:
if status >= 500 {
return "INTERNAL"
@@ -0,0 +1,28 @@
package googleapi
import (
"net/http"
"testing"
)
func TestHTTPStatusToGoogleStatus(t *testing.T) {
cases := []struct {
status int
want string
}{
{http.StatusBadRequest, "INVALID_ARGUMENT"},
{http.StatusUnauthorized, "UNAUTHENTICATED"},
{http.StatusForbidden, "PERMISSION_DENIED"},
{http.StatusNotFound, "NOT_FOUND"},
{http.StatusTooManyRequests, "RESOURCE_EXHAUSTED"},
{499, "CANCELLED"},
{http.StatusBadGateway, "INTERNAL"},
{http.StatusServiceUnavailable, "INTERNAL"},
{http.StatusConflict, "UNKNOWN"},
}
for _, tc := range cases {
if got := HTTPStatusToGoogleStatus(tc.status); got != tc.want {
t.Errorf("HTTPStatusToGoogleStatus(%d) = %q, want %q", tc.status, got, tc.want)
}
}
}
@@ -1034,6 +1034,13 @@ func filterSchedulerExtra(extra map[string]any) map[string]any {
"auto_pause_7d_threshold",
"auto_pause_5h_disabled",
"auto_pause_7d_disabled",
// 自动用卡:卡可用的 OpenAI 号在暂停阈值与用卡阈值之间继续调度。
// 候选过滤读的是本投影,缺这几个键时放行分支永远不会生效,
// 账号会在暂停阈值处被一刀切停调,直到窗口自然重置。
service.OpenAIAutoResetCreditEnabledExtraKey,
service.OpenAIAutoResetCredit5hThresholdExtraKey,
service.OpenAIAutoResetCredit7dThresholdExtraKey,
service.OpenAIAutoResetCreditStateExtraKey,
"model_rate_limits",
service.UpstreamBillingProbeExtraKey,
service.GrokMediaEligibleExtraKey,
@@ -446,6 +446,47 @@ func TestBuildSchedulerMetadataAccount_KeepsQuotaAutoPauseFields(t *testing.T) {
require.Equal(t, false, got.Extra["auto_pause_7d_disabled"])
}
// 候选过滤读的是 Redis 元数据投影;自动用卡的开关、阈值和卡状态缺失时,
// 卡可用的 OpenAI 号会在暂停阈值处被一刀切停调,放行到用卡阈值的逻辑永远不生效。
func TestSchedulerMetadataPayload_KeepsOpenAIAutoResetCreditFields(t *testing.T) {
checkedAt := time.Now().UTC().Format(time.RFC3339)
account := service.Account{
ID: 40,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Schedulable: true,
Extra: map[string]any{
"codex_7d_used_percent": 90.0,
service.OpenAIAutoResetCreditEnabledExtraKey: true,
service.OpenAIAutoResetCredit5hThresholdExtraKey: 0.95,
service.OpenAIAutoResetCredit7dThresholdExtraKey: 0.98,
service.OpenAIAutoResetCreditStateExtraKey: map[string]any{
"status": service.OpenAIAutoResetStatusAvailable,
"available_count": 3,
"checked_at": checkedAt,
"trigger_window": "7d",
},
},
}
_, metaPayload, err := marshalSchedulerCacheAccount(account)
require.NoError(t, err)
var cached service.Account
require.NoError(t, json.Unmarshal(metaPayload, &cached))
config := service.ResolveOpenAIAutoResetCreditConfig(&cached)
require.True(t, config.Enabled)
require.Equal(t, 0.95, config.Threshold5h)
require.Equal(t, 0.98, config.Threshold7d)
state, ok := cached.Extra[service.OpenAIAutoResetCreditStateExtraKey].(map[string]any)
require.True(t, ok, "卡状态必须进入调度投影")
require.Equal(t, service.OpenAIAutoResetStatusAvailable, state["status"])
require.EqualValues(t, 3, state["available_count"])
require.Equal(t, checkedAt, state["checked_at"])
}
func TestBuildSchedulerMetadataAccount_KeepsQuotaStateForCachedAccounts(t *testing.T) {
now := time.Now().UTC()
activeStart := now.Add(-time.Hour).Format(time.RFC3339)
@@ -271,13 +271,19 @@ func openAIThresholdCandidate(extra map[string]any, window string, now time.Time
if !ok {
return nil
}
if openAIQuotaWindowReset(extra, window, now) || openAICodexSnapshotStaleForPause(extra, now) {
if openAIQuotaWindowReset(extra, window, now) || (openAICodexSnapshotStaleForPause(extra, now) && !openAIQuotaWindowResetPending(extra, window, now)) {
return nil
}
until := parseSchedulingResetAt(extra[resetAtKey])
if until == nil {
if resetAt, ok := openAICodexWindowResetAt(extra, window); ok {
until = &resetAt
}
}
return &accountSchedulingThresholdCandidate{
window: window,
usedPercent: schedulingPercentValue(usedPercent),
until: parseSchedulingResetAt(extra[resetAtKey]),
until: until,
}
}
@@ -172,7 +172,7 @@ func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.
Extra: map[string]any{
"codex_usage_updated_at": now.Add(-2 * time.Hour).Format(time.RFC3339),
"codex_5h_used_percent": 100.0,
"codex_5h_reset_at": now.Add(3 * time.Hour).Format(time.RFC3339),
"codex_5h_reset_at": "invalid",
},
}
@@ -181,6 +181,63 @@ func TestEvaluateAccountSchedulingThreshold_OpenAISkipsStaleSnapshot(t *testing.
require.False(t, decision.ShouldPause)
}
func TestOpenAIThresholdCandidate_StaleSnapshotFutureReset(t *testing.T) {
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
reset map[string]any
want bool
}{
{"absolute future", map[string]any{"codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339)}, true},
{"relative future", map[string]any{"codex_5h_reset_after_seconds": 4 * 3600}, true},
{"missing", nil, false},
{"invalid", map[string]any{"codex_5h_reset_at": "invalid"}, false},
{"past", map[string]any{"codex_5h_reset_at": now.Add(-time.Minute).Format(time.RFC3339)}, false},
} {
t.Run(tc.name, func(t *testing.T) {
extra := map[string]any{"codex_usage_updated_at": now.Add(-3 * time.Hour).Format(time.RFC3339), "codex_5h_used_percent": 99.0}
for key, value := range tc.reset {
extra[key] = value
}
candidate := openAIThresholdCandidate(extra, "5h", now)
require.Equal(t, tc.want, candidate != nil)
if tc.want {
decision := EvaluateAccountSchedulingThreshold(&Account{Platform: PlatformOpenAI, Extra: extra}, map[string]int{PlatformOpenAI: 95}, now)
require.True(t, decision.ShouldPause)
require.NotNil(t, decision.Until)
require.True(t, now.Before(*decision.Until))
}
})
}
}
func TestResolveOpenAIQuotaUtilization_StaleSnapshotFutureReset(t *testing.T) {
now := time.Date(2026, 6, 3, 12, 0, 0, 0, time.UTC)
for _, tc := range []struct {
name string
reset map[string]any
want bool
}{
{"absolute future", map[string]any{"codex_5h_reset_at": now.Add(time.Hour).Format(time.RFC3339)}, true},
{"relative future", map[string]any{"codex_5h_reset_after_seconds": 4 * 3600}, true},
{"missing", nil, false},
{"invalid", map[string]any{"codex_5h_reset_at": "invalid"}, false},
{"past", map[string]any{"codex_5h_reset_at": now.Add(-time.Minute).Format(time.RFC3339)}, false},
} {
t.Run(tc.name, func(t *testing.T) {
extra := map[string]any{"codex_usage_updated_at": now.Add(-3 * time.Hour).Format(time.RFC3339), "codex_5h_used_percent": 99.0}
for key, value := range tc.reset {
extra[key] = value
}
utilization, ok := resolveOpenAIQuotaUtilization(extra, "5h", now)
require.Equal(t, tc.want, ok)
if ok {
require.Equal(t, 0.99, utilization)
}
})
}
}
func TestEvaluateAccountSchedulingThreshold_OpenAISkipsResetWindow(t *testing.T) {
t.Parallel()
@@ -19,6 +19,8 @@ import (
// totalCost 是本次请求的客户计费(倍率前),用于优先级 2。
// serviceTier 是最终参与用户计费的 OpenAI 服务层级,用于优先级 3。
// pricingAt 与本次客户计费使用同一时刻,避免跨峰谷请求的成本与售价错位。
// longContextPricingEnabled 表示上游是否对本次请求收取长上下文费率,用于优先级 3;
// 由 accountStatsLongContextPricingEnabled 按账号开关得出,不受分组售价开关影响。
// reasoningEffort 是最终转发等级;按账号统计定价中配置的等级倍率计费。
func resolveAccountStatsCost(
ctx context.Context,
@@ -32,6 +34,7 @@ func resolveAccountStatsCost(
totalCost float64,
serviceTier string,
pricingAt time.Time,
longContextPricingEnabled bool,
reasoningEfforts ...string,
) *float64 {
reasoningEffort := ""
@@ -64,7 +67,7 @@ func resolveAccountStatsCost(
// 优先级 3:模型定价文件(LiteLLM)默认价格
if billingService != nil {
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, reasoningEffort)
return tryModelFilePricing(billingService, upstreamModel, tokens, serviceTier, pricingAt, longContextPricingEnabled, reasoningEffort)
}
return nil
@@ -74,11 +77,16 @@ func resolveAccountStatsCost(
// 与用户计费共用同一条定价管线,避免这里维护第二份"单价 × token 数"实现后,
// 每加一个定价特性都要手工镜像一次。解析器不配置渠道或分组,保持优先级 3 的
// 语义:只取模型定价文件,不引入自定义售价。
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, reasoningEfforts ...string) *float64 {
func tryModelFilePricing(billingService *BillingService, model string, tokens UsageTokens, serviceTier string, pricingAt time.Time, longContextPricingEnabled bool, reasoningEfforts ...string) *float64 {
reasoningEffort := ""
if len(reasoningEfforts) > 0 {
reasoningEffort = reasoningEfforts[0]
}
resolver := NewModelPricingResolver(nil, billingService)
resolved := resolver.Resolve(context.Background(), PricingInput{Model: model})
// 无分组的解析结果默认开启长上下文,CostInput.LongContextBillingEnabled=false 无法否决,
// 因此直接覆写解析结果。
resolved.longContextPricingEnabled = longContextPricingEnabled
breakdown, err := billingService.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: model,
@@ -87,7 +95,8 @@ func tryModelFilePricing(billingService *BillingService, model string, tokens Us
ServiceTier: normalizeBillingServiceTier(serviceTier),
ReasoningEffort: reasoningEffort,
PricingAt: pricingAt,
Resolver: NewModelPricingResolver(nil, billingService),
Resolver: resolver,
Resolved: resolved,
})
if err != nil || breakdown == nil || breakdown.TotalCost <= 0 {
return nil
@@ -263,6 +272,7 @@ func applyAccountStatsCost(
tokens UsageTokens,
totalCost float64,
pricingAt time.Time,
longContextPricingEnabled bool,
) {
model := upstreamModel
if model == "" {
@@ -281,6 +291,14 @@ func applyAccountStatsCost(
reasoningEffort = *usageLog.ReasoningEffort
}
usageLog.AccountStatsCost = resolveAccountStatsCost(
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, reasoningEffort,
ctx, cs, bs, accountID, groupID, model, tokens, requestCount, totalCost, serviceTier, pricingAt, longContextPricingEnabled, reasoningEffort,
)
}
// accountStatsLongContextPricingEnabled 判断账号统计成本是否计入长上下文阶梯。
// 账号统计成本反映上游实际成本:OpenAI 账号由开关声明上游是否收取长上下文费率;
// 其它平台没有该开关(accountGate 为 nil),按官方阶梯计。分组开关只决定客户售价,
// 不参与成本判断。
func accountStatsLongContextPricingEnabled(accountGate *bool) bool {
return accountGate == nil || *accountGate
}
@@ -472,7 +472,7 @@ func TestTryModelFilePricing_Success(t *testing.T) {
},
})
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
require.NotNil(t, result)
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
require.InDelta(t, 0.2, *result, 1e-12)
@@ -483,8 +483,8 @@ func TestTryModelFilePricing_Fable51HasNoImplicitReasoningMultiplier(t *testing.
"claude-fable-5-1": {InputPricePerToken: 0.001},
})
tokens := UsageTokens{InputTokens: 100}
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "xhigh")
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, "max")
standard := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "xhigh")
max := tryModelFilePricing(bs, "claude-fable-5-1", tokens, "", time.Time{}, true, "max")
require.NotNil(t, standard)
require.NotNil(t, max)
require.Equal(t, *standard, *max)
@@ -503,7 +503,7 @@ func TestTryModelFilePricing_AppliesLongContextPricing(t *testing.T) {
})
tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5}
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{})
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "", time.Time{}, true)
require.NotNil(t, result)
// Input and cache-read use the 2x input tier; output uses the 1.5x tier.
@@ -542,7 +542,7 @@ func TestTryModelFilePricing_AppliesServiceTierPricing(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{})
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, tt.serviceTier, time.Time{}, true)
require.NotNil(t, result)
require.InDelta(t, tt.want, *result, 1e-12)
})
@@ -572,7 +572,7 @@ func TestTryModelFilePricing_CombinesPriorityAndLongContextPricing(t *testing.T)
CacheReadTokens: 5,
}
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{})
result := tryModelFilePricing(bs, "gpt-5.6-sol", tokens, "priority", time.Time{}, true)
require.NotNil(t, result)
// priority 单价先应用,再叠加长上下文输入 2x、输出 1.5x。
@@ -583,7 +583,7 @@ func TestTryModelFilePricing_PricingNotFound(t *testing.T) {
// "nonexistent-model" does not match any fallback pattern
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{})
tokens := UsageTokens{InputTokens: 100, OutputTokens: 50}
result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{})
result := tryModelFilePricing(bs, "nonexistent-model", tokens, "", time.Time{}, true)
require.Nil(t, result)
}
@@ -593,7 +593,7 @@ func TestTryModelFilePricing_NilFallback(t *testing.T) {
"claude-sonnet-4": nil,
})
tokens := UsageTokens{InputTokens: 100}
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
require.Nil(t, result)
}
@@ -605,7 +605,7 @@ func TestTryModelFilePricing_ZeroCost(t *testing.T) {
},
})
tokens := UsageTokens{} // all zero tokens → cost = 0 → nil
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
require.Nil(t, result)
}
@@ -622,7 +622,7 @@ func TestTryModelFilePricing_WithImageOutput(t *testing.T) {
OutputTokens: 50,
ImageOutputTokens: 10,
}
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
require.NotNil(t, result)
// ImageOutputTokens 是 OutputTokens 的子集,先扣除再按图片单价计。
// 100*0.001 + (50-10)*0.002 + 10*0.01 = 0.1 + 0.08 + 0.1 = 0.28
@@ -644,7 +644,7 @@ func TestTryModelFilePricing_WithCacheTokens(t *testing.T) {
CacheCreationTokens: 200,
CacheReadTokens: 300,
}
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{})
result := tryModelFilePricing(bs, "claude-sonnet-4", tokens, "", time.Time{}, true)
require.NotNil(t, result)
// 100*0.001 + 50*0.002 + 200*0.003 + 300*0.0005
// = 0.1 + 0.1 + 0.6 + 0.15 = 0.95
@@ -692,7 +692,7 @@ func TestTryModelFilePricing_DeepSeekPeakPricing(t *testing.T) {
{"sunday", time.Date(2026, time.August, 23, 7, 0, 0, 0, time.UTC), 1},
} {
t.Run(slot.name, func(t *testing.T) {
cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at)
cost := tryModelFilePricing(bs, model.name, tokens, "", slot.at, true)
require.NotNil(t, cost)
require.InDelta(t, baseCost*slot.multiplier, *cost, 1e-12)
})
@@ -738,7 +738,7 @@ func TestResolveAccountStatsCost_DeepSeekPricingPriority(t *testing.T) {
groupID = 99
}
cost := resolveAccountStatsCost(context.Background(), cs, newTestBillingService(),
1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak)
1, groupID, "deepseek-v4-flash", UsageTokens{InputTokens: 1000}, 1, 0.75, "", peak, true)
if tt.noChannel {
require.Nil(t, cost)
return
@@ -759,7 +759,7 @@ func TestResolveAccountStatsCost_NilChannelService(t *testing.T) {
nil, // channelService is nil
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
1, 1, "claude-sonnet-4",
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
)
require.Nil(t, result)
}
@@ -775,7 +775,7 @@ func TestResolveAccountStatsCost_EmptyUpstreamModel(t *testing.T) {
cs,
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
1, 1, "", // empty upstream model
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
)
require.Nil(t, result)
}
@@ -792,7 +792,7 @@ func TestResolveAccountStatsCost_GetChannelForGroupReturnsNil(t *testing.T) {
cs,
newTestBillingServiceWithPrices(map[string]*ModelPricing{}),
1, 99, "claude-sonnet-4", // groupID 99 has no channel
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{},
UsageTokens{InputTokens: 100}, 1, 0.5, "", time.Time{}, true,
)
require.Nil(t, result)
}
@@ -823,7 +823,7 @@ func TestResolveAccountStatsCost_HitsCustomRule(t *testing.T) {
context.Background(),
cs, nil, // billingService not needed when custom rule hits
1, 10, "claude-sonnet-4",
tokens, 1, 999.0, "priority", time.Time{}, // 自定义账号价格不叠加服务层级倍率
tokens, 1, 999.0, "priority", time.Time{}, true, // 自定义账号价格不叠加服务层级倍率
)
require.NotNil(t, result)
// 100*0.01 + 50*0.02 = 1.0 + 1.0 = 2.0
@@ -845,7 +845,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_UsesTotalCost(t *tes
context.Background(),
cs, nil,
1, 10, "claude-sonnet-4",
tokens, 1, 0.75, "priority", time.Time{}, // 已完成用户计费,不再重复应用服务层级倍率
tokens, 1, 0.75, "priority", time.Time{}, true, // 已完成用户计费,不再重复应用服务层级倍率
)
require.NotNil(t, result)
require.InDelta(t, 0.75, *result, 1e-12)
@@ -863,7 +863,7 @@ func TestResolveAccountStatsCost_ApplyPricingToAccountStats_ZeroTotalCost_Return
context.Background(),
cs, nil,
1, 10, "claude-sonnet-4",
UsageTokens{}, 1, 0.0, "", time.Time{}, // totalCost = 0
UsageTokens{}, 1, 0.0, "", time.Time{}, true, // totalCost = 0
)
require.Nil(t, result)
}
@@ -890,7 +890,7 @@ func TestResolveAccountStatsCost_FallsBackToLiteLLM(t *testing.T) {
context.Background(),
cs, bs,
1, 10, "claude-sonnet-4",
tokens, 1, 999.0, "", time.Time{}, // totalCost ignored
tokens, 1, 999.0, "", time.Time{}, true, // totalCost ignored
)
require.NotNil(t, result)
// 100*0.001 + 50*0.002 = 0.1 + 0.1 = 0.2
@@ -911,7 +911,7 @@ func TestResolveAccountStatsCost_FallbackHonorsAnthropicFast(t *testing.T) {
context.Background(), cs, bs,
1, 10, "claude-opus-5",
UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000},
1, 0, "fast", time.Time{},
1, 0, "fast", time.Time{}, true,
)
require.NotNil(t, result)
require.InDelta(t, 60, *result, 1e-12)
@@ -930,7 +930,7 @@ func TestResolveAccountStatsCost_Gemini36FlashTierUsesFallbackPricing(t *testing
context.Background(),
cs, bs,
1, 10, "gemini-3.6-flash-low",
UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{},
UsageTokens{InputTokens: 1_000_000, OutputTokens: 1_000_000, CacheReadTokens: 1_000_000}, 1, 0, "", time.Time{}, true,
)
require.NotNil(t, result)
require.InDelta(t, 9.15, *result, 1e-12)
@@ -954,7 +954,7 @@ func TestResolveAccountStatsCost_AllMiss_ReturnsNil(t *testing.T) {
context.Background(),
cs, bs,
1, 10, "totally-unknown-model",
tokens, 1, 0.0, "", time.Time{},
tokens, 1, 0.0, "", time.Time{}, true,
)
require.Nil(t, result)
}
@@ -971,7 +971,7 @@ func TestResolveAccountStatsCost_NilBillingService_SkipsLiteLLM(t *testing.T) {
context.Background(),
cs, nil, // billingService is nil
1, 10, "claude-sonnet-4",
UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{},
UsageTokens{InputTokens: 100}, 1, 0.0, "", time.Time{}, true,
)
require.Nil(t, result)
}
@@ -1004,7 +1004,7 @@ func TestResolveAccountStatsCost_CustomRulePriorityOverApplyPricing(t *testing.T
context.Background(),
cs, nil,
1, 10, "claude-sonnet-4",
tokens, 1, 99.0, "", time.Time{}, // totalCost = 99.0 (would be used if ApplyPricing wins)
tokens, 1, 99.0, "", time.Time{}, true, // totalCost = 99.0 (would be used if ApplyPricing wins)
)
require.NotNil(t, result)
// Custom rule: 100*0.05 = 5.0 (NOT 99.0 from totalCost)
@@ -1032,13 +1032,111 @@ func TestApplyAccountStatsCost_UsesUsageLogServiceTier(t *testing.T) {
applyAccountStatsCost(
context.Background(), usageLog, cs, bs,
1, 10, "gpt-5.6-sol", "gpt-5.6-sol",
UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{},
UsageTokens{InputTokens: 100, OutputTokens: 50}, 999, time.Time{}, true,
)
require.NotNil(t, usageLog.AccountStatsCost)
require.InDelta(t, 0.4, *usageLog.AccountStatsCost, 1e-12)
}
func TestApplyAccountStatsCost_LongContextFollowsAccountGate(t *testing.T) {
// 渠道售价不参与优先级 3:结果只取模型定价文件。
channel := &Channel{
ID: 1,
Status: StatusActive,
ModelPricing: []ChannelModelPricing{{
Models: []string{"gpt-5.6-sol"}, InputPrice: testPtrFloat64(0.01),
}},
}
cs := newTestChannelServiceForStats(t, channel, 10, PlatformOpenAI)
bs := newTestBillingServiceWithPrices(map[string]*ModelPricing{
"gpt-5.6-sol": {
InputPricePerToken: 0.001,
InputPricePerTokenPriority: 0.002,
OutputPricePerToken: 0.002,
OutputPricePerTokenPriority: 0.004,
CacheReadPricePerToken: 0.0001,
CacheReadPricePerTokenPriority: 0.0002,
LongContextInputThreshold: 100,
LongContextInputMultiplier: 2,
LongContextOutputMultiplier: 1.5,
},
})
tokens := UsageTokens{InputTokens: 101, OutputTokens: 10, CacheReadTokens: 5}
accountOff, accountOn := false, true
for _, tt := range []struct {
name string
gate *bool
tier string
wantCost float64
}{
{name: "account_off", gate: &accountOff, wantCost: 0.1215},
{name: "account_off_priority", gate: &accountOff, tier: "priority", wantCost: 0.243},
{name: "account_on_priority", gate: &accountOn, tier: "priority", wantCost: 0.466},
// 非 OpenAI 平台没有账号开关,按官方阶梯计。
{name: "no_gate", wantCost: 0.233},
{name: "no_gate_priority", tier: "priority", wantCost: 0.466},
} {
t.Run(tt.name, func(t *testing.T) {
usageLog := &UsageLog{ServiceTier: &tt.tier}
applyAccountStatsCost(context.Background(), usageLog, cs, bs,
1, 10, "gpt-5.6-sol", "gpt-5.6-sol", tokens, 999, time.Time{},
accountStatsLongContextPricingEnabled(tt.gate))
require.NotNil(t, usageLog.AccountStatsCost)
require.InDelta(t, tt.wantCost, *usageLog.AccountStatsCost, 1e-12)
})
}
}
// 分组开关只决定客户售价;账号统计成本按账号开关判断上游是否收取长上下文费率。
func TestOpenAIGatewayServiceRecordUsage_AccountStatsLongContextFollowsAccountGate(t *testing.T) {
baseCost := 300000*2.5e-6 + 2000*15e-6
longContextCost := 300000*2.5e-6*2 + 2000*15e-6*1.5
for _, tt := range []struct {
name string
groupLongContext bool
accountExtra map[string]any
wantTotalCost float64
wantAccountCost float64
}{
{name: "group_on_account_off", groupLongContext: true, wantTotalCost: longContextCost, wantAccountCost: baseCost},
{name: "group_off_account_off", wantTotalCost: baseCost, wantAccountCost: baseCost},
{
name: "group_off_account_on",
accountExtra: map[string]any{"openai_long_context_billing_enabled": true},
wantTotalCost: longContextCost,
wantAccountCost: longContextCost,
},
} {
t.Run(tt.name, func(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(usageRepo, &openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{}, nil)
swapInOpenAILadderCatalog(t, svc)
svc.channelService = newTestChannelServiceForStats(t, &Channel{ID: 1, Status: StatusActive}, 1, PlatformOpenAI)
apiKey := openAIRecordUsageAPIKeyWithGroup(svc, 1015, tt.groupLongContext)
apiKey.GroupID = i64p(1)
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_account_stats_long_context_" + tt.name,
Usage: OpenAIUsage{InputTokens: 300000, OutputTokens: 2000},
Model: "gpt-5.4-2026-03-05",
Duration: time.Second,
},
APIKey: apiKey,
User: &User{ID: 2015},
Account: &Account{ID: 3015, Platform: PlatformOpenAI, Extra: tt.accountExtra},
})
require.NoError(t, err)
require.NotNil(t, usageRepo.lastLog)
require.InDelta(t, tt.wantTotalCost, usageRepo.lastLog.TotalCost, 1e-10)
require.NotNil(t, usageRepo.lastLog.AccountStatsCost)
require.InDelta(t, tt.wantAccountCost, *usageRepo.lastLog.AccountStatsCost, 1e-10)
})
}
}
// ---------------------------------------------------------------------------
// helpers for resolveAccountStatsCost tests
// ---------------------------------------------------------------------------
@@ -29,7 +29,7 @@ func TestAdminService_CreateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T
require.Nil(t, repo.created, "拒绝时不得落库")
}
func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) {
func TestAdminService_CreateGroup_AcceptsInteriorAllowlistWildcard(t *testing.T) {
repo := &groupRepoStubForAdmin{createID: 51}
svc := &adminServiceImpl{groupRepo: repo}
@@ -40,11 +40,9 @@ func TestAdminService_CreateGroup_RejectsInvalidAllowlistWildcard(t *testing.T)
ModelAllowlist: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}},
})
require.Error(t, err)
appErr := infraerrors.FromError(err)
require.Equal(t, int32(http.StatusBadRequest), appErr.Code)
require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason)
require.Nil(t, repo.created, "拒绝时不得落库")
require.NoError(t, err)
require.NotNil(t, repo.created)
require.Equal(t, []string{"gpt-*-5.4"}, repo.created.ModelAllowlist.Models)
}
func TestAdminService_CreateGroup_NormalizesModelAllowlist(t *testing.T) {
@@ -83,7 +81,7 @@ func TestAdminService_UpdateGroup_RejectsEmptyEnabledModelAllowlist(t *testing.T
require.Nil(t, repo.updated, "拒绝时不得落库")
}
func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T) {
func TestAdminService_UpdateGroup_AcceptsInteriorAllowlistWildcard(t *testing.T) {
existing := &Group{ID: 1, Name: "existing", Platform: PlatformOpenAI, Status: StatusActive}
repo := &groupRepoStubForAdmin{getByID: existing}
svc := &adminServiceImpl{groupRepo: repo}
@@ -92,11 +90,9 @@ func TestAdminService_UpdateGroup_RejectsInvalidAllowlistWildcard(t *testing.T)
ModelAllowlist: &GroupModelAllowlist{Enabled: true, Models: []string{"foo-*bar"}},
})
require.Error(t, err)
appErr := infraerrors.FromError(err)
require.Equal(t, int32(http.StatusBadRequest), appErr.Code)
require.Equal(t, "INVALID_MODEL_ALLOWLIST", appErr.Reason)
require.Nil(t, repo.updated, "拒绝时不得落库")
require.NoError(t, err)
require.NotNil(t, repo.updated)
require.Equal(t, []string{"foo-*bar"}, repo.updated.ModelAllowlist.Models)
}
func TestAdminService_UpdateGroup_NormalizesAndResetsModelAllowlist(t *testing.T) {
@@ -131,7 +131,7 @@ func (s *AntigravityGatewayService) Forward(ctx context.Context, c *gin.Context,
}
// 区分客户端取消和真正的上游失败,返回更准确的错误消息
if c.Request.Context().Err() != nil {
return nil, s.writeClaudeError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response")
return nil, s.writeClaudeError(c, antigravityStatusClientClosed, "client_disconnected", "Client disconnected before upstream response")
}
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
}
@@ -374,7 +374,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatTransportError(c *gin
}
}
if c.Request.Context().Err() != nil {
return s.writeAntigravityCompatError(c, http.StatusBadGateway, "client_disconnected", "Client disconnected before upstream response")
return s.writeAntigravityCompatError(c, antigravityStatusClientClosed, "client_disconnected", "Client disconnected before upstream response")
}
return s.writeAntigravityCompatError(c, http.StatusBadGateway, "upstream_error", "Upstream request failed after retries")
}
@@ -141,15 +141,18 @@ func (s *antigravityCompatStreamSession) consume(line string) {
}
func (s *antigravityCompatStreamSession) hasMeaningfulData() bool {
return s.meaningfulData
return s.meaningfulData || s.processor.HasContent()
}
func (s *antigravityCompatStreamSession) finish() *antigravityStreamResult {
func (s *antigravityCompatStreamSession) finish() (*antigravityStreamResult, error) {
finalEvents, usage := s.processor.Finish()
mergeAntigravityCompatUsage(s.usage, usage)
s.consumeClaudeEvents(finalEvents)
if !s.hasMeaningfulData() && !s.writer.Disconnected() {
return nil, antigravityCompatEmptyStreamError()
}
s.adapter.Finalize(s.writer)
return s.result(s.writer.Disconnected())
return s.result(s.writer.Disconnected()), nil
}
func (s *antigravityCompatStreamSession) collectResult(clientDisconnect bool) *antigravityStreamResult {
@@ -220,24 +223,18 @@ func isMeaningfulAntigravityCompatEvent(event *apicompat.AnthropicStreamEvent) b
if event == nil {
return false
}
if event.Type == "message_stop" {
return true
}
if event.ContentBlock != nil {
block := event.ContentBlock
return block.Type == "tool_use" ||
block.Text != "" ||
block.Thinking != "" ||
block.Signature != "" ||
block.Source != nil
}
if event.Delta != nil {
delta := event.Delta
return delta.Text != "" ||
delta.PartialJSON != "" ||
delta.Thinking != "" ||
delta.Signature != "" ||
delta.StopReason != ""
delta.Thinking != ""
}
return false
}
@@ -295,7 +292,7 @@ func (s *AntigravityGatewayService) handleAntigravityCompatStream(
if !session.hasMeaningfulData() && !writer.Disconnected() {
return nil, antigravityCompatEmptyStreamError()
}
return session.finish(), nil
return session.finish()
}
if event.err != nil {
return s.handleAntigravityCompatReadError(c, session, event.err, maxLineSize, prefix)
@@ -180,7 +180,7 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
}
// 区分客户端取消和真正的上游失败,返回更准确的错误消息
if c.Request.Context().Err() != nil {
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Client disconnected before upstream response")
return nil, s.writeGoogleError(c, antigravityStatusClientClosed, "Client disconnected before upstream response")
}
return nil, s.writeGoogleError(c, http.StatusBadGateway, "Upstream request failed after retries")
}
@@ -686,6 +686,9 @@ func mergeTextPartsToResponse(response map[string]any, textParts []string) map[s
return result
}
// antigravityStatusClientClosed 是客户端在上游响应前断开时回写的状态码(499 client closed request)。
const antigravityStatusClientClosed = 499
func (s *AntigravityGatewayService) writeClaudeError(c *gin.Context, status int, errType, message string) error {
MarkResponseCommitted(c)
c.JSON(status, gin.H{
@@ -790,6 +793,8 @@ func (s *AntigravityGatewayService) writeGoogleError(c *gin.Context, status int,
statusStr = "NOT_FOUND"
case 429:
statusStr = "RESOURCE_EXHAUSTED"
case antigravityStatusClientClosed:
statusStr = "CANCELLED"
case 500:
statusStr = "INTERNAL"
case 502, 503:
+3 -9
View File
@@ -1350,8 +1350,8 @@ func (s *BillingService) getModelPricingAt(model string, pricingAt time.Time) (*
return nil, fmt.Errorf("%w for model: %s", ErrModelPricingUnavailable, model)
}
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值
// 渠道存在时,未配置的图片输出价格归零(不回退到 LiteLLM)
// GetModelPricingWithChannel 获取模型定价,渠道配置的价格覆盖默认值。
// 与其他 token 字段一致,渠道留空的图片输入/输出价沿用目录价,见 applyChannelImagePriceOverrides。
func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing *ChannelModelPricing) (*ModelPricing, error) {
pricing, err := s.GetModelPricing(model)
if err != nil {
@@ -1367,13 +1367,7 @@ func (s *BillingService) GetModelPricingWithChannel(model string, channelPricing
pricing.FastMultiplier = channelPricing.FastMultiplier
pricing.FlexMultiplier = channelPricing.FlexMultiplier
pricing.ReasoningEffortMultipliers = maps.Clone(channelPricing.ReasoningEffortMultipliers)
if channelPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *channelPricing.ImageOutputPrice
} else {
pricing.ImageOutputPricePerToken = 0
}
pricing.ImageOutputPriceExplicit = true
applyChannelImageInputPrice(channelPricing, pricing)
applyChannelImagePriceOverrides(channelPricing, pricing)
return pricing, nil
}
@@ -1874,19 +1874,49 @@ func TestGetModelPricingWithChannel_UnknownModelReturnsError(t *testing.T) {
require.Contains(t, err.Error(), "pricing not found")
}
func TestGetModelPricingWithChannel_NilImageOutputPriceZerosAndMarksExplicit(t *testing.T) {
svc := newTestBillingService()
func TestGetModelPricingWithChannel_NilImagePricesInheritCatalog(t *testing.T) {
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gpt-image-2": {
Mode: "image_generation",
InputCostPerToken: 5e-6,
OutputCostPerToken: 10e-6,
InputCostPerImageToken: 8e-6,
OutputCostPerImageToken: 30e-6,
},
}))
chPricing := &ChannelModelPricing{
InputPrice: testPtrFloat64(10e-6),
OutputPrice: testPtrFloat64(20e-6),
// ImageOutputPrice intentionally nil
InputPrice: testPtrFloat64(6e-6),
OutputPrice: testPtrFloat64(12e-6),
// ImageInputPrice / ImageOutputPrice intentionally nil
}
pricing, err := svc.GetModelPricingWithChannel("claude-sonnet-4", chPricing)
pricing, err := svc.GetModelPricingWithChannel("gpt-image-2", chPricing)
require.NoError(t, err)
require.InDelta(t, 6e-6, pricing.InputPricePerToken, 1e-12)
require.InDelta(t, 30e-6, pricing.ImageOutputPricePerToken, 1e-12)
require.False(t, pricing.ImageOutputPriceExplicit)
require.InDelta(t, 8e-6, pricing.ImageInputPricePerToken, 1e-12)
}
func TestGetModelPricingWithChannel_ExplicitImagePricesOverrideCatalog(t *testing.T) {
svc := NewBillingService(&config.Config{}, newStubPricingServiceFromMap(map[string]*LiteLLMModelPricing{
"gpt-image-2": {
Mode: "image_generation",
InputCostPerToken: 5e-6,
InputCostPerImageToken: 8e-6,
OutputCostPerImageToken: 30e-6,
},
}))
pricing, err := svc.GetModelPricingWithChannel("gpt-image-2", &ChannelModelPricing{
ImageInputPrice: testPtrFloat64(9e-6),
ImageOutputPrice: testPtrFloat64(0),
})
require.NoError(t, err)
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
require.True(t, pricing.ImageOutputPriceExplicit)
require.True(t, pricing.ImageOutputPriceExplicit, "显式 0 仍表示图片输出免费")
require.InDelta(t, 9e-6, pricing.ImageInputPricePerToken, 1e-12)
}
func TestComputeTokenBreakdown_ExplicitZeroImagePrice_NoFallback(t *testing.T) {
@@ -1976,18 +2006,20 @@ func TestNewModelPricingCatalogFallbackAndContext(t *testing.T) {
}
})
}
t.Run(source+"/opus", func(t *testing.T) {
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600}
for tier, mult := range map[string]float64{"": 1, "fast": 2} {
cost, err := svc.CalculateCostWithServiceTier("claude-opus-5-5", tokens, 1, tier)
require.NoError(t, err)
require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10)
require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10)
require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10)
require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10)
require.False(t, cost.LongContextBillingApplied)
}
})
for _, model := range []string{"claude-opus-5-5", "anthropic/claude-opus-5.5"} {
t.Run(source+"/"+model, func(t *testing.T) {
tokens := UsageTokens{InputTokens: 300000, OutputTokens: 500, CacheReadTokens: 1000, CacheCreationTokens: 1000, CacheCreation5mTokens: 400, CacheCreation1hTokens: 600}
for tier, mult := range map[string]float64{"": 1, "fast": 2} {
cost, err := svc.CalculateCostWithServiceTier(model, tokens, 1, tier)
require.NoError(t, err)
require.InDelta(t, 1.2*mult, cost.InputCost, 1e-10)
require.InDelta(t, (400*5e-6+600*8e-6)*mult, cost.CacheCreationCost, 1e-10)
require.InDelta(t, 1000*0.2e-6*mult, cost.CacheReadCost, 1e-10)
require.InDelta(t, 500*20e-6*mult, cost.OutputCost, 1e-10)
require.False(t, cost.LongContextBillingApplied)
}
})
}
}
}
@@ -0,0 +1,93 @@
//go:build unit
package service
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// 三条传输层失败处理的共同契约:请求 context 已取消(客户端断开)时不记 Ops 上游错误事件;
// 请求 context 仍存活时即便错误是 context.Canceled 也照常记录,不归为客户端断开。
func TestTransportErrorHandlers_ClientDisconnectRecordsNoOpsEvent(t *testing.T) {
account := &Account{ID: 9, Name: "acc", Platform: PlatformAnthropic}
clientErr := &url.Error{Op: "Post", URL: "https://upstream.example/v1/messages", Err: context.Canceled}
canceledCtx, cancel := context.WithCancel(context.Background())
cancel()
handlers := []struct {
name string
handle func(ctx context.Context, c *gin.Context) error
}{
{"anthropic", func(ctx context.Context, c *gin.Context) error {
s := &GatewayService{accountRepo: &transportTempUnschedRepoStub{}}
return s.handleUpstreamTransportError(ctx, c, account, clientErr, OpsUpstreamErrorEvent{})
}},
{"gemini", func(ctx context.Context, c *gin.Context) error {
s := &GeminiMessagesCompatService{accountRepo: &transportTempUnschedRepoStub{}}
return s.handleUpstreamTransportError(ctx, c, account, clientErr)
}},
{"openai", func(ctx context.Context, c *gin.Context) error {
s := &OpenAIGatewayService{accountRepo: &openaiTransportAccountRepoStub{}}
return s.handleOpenAIUpstreamTransportError(ctx, c, account, clientErr, false)
}},
}
for _, h := range handlers {
t.Run(h.name+"/client disconnected", func(t *testing.T) {
c := newTransportErrorTestGin(t)
err := h.handle(canceledCtx, c)
require.ErrorIs(t, err, context.Canceled)
_, recorded := c.Get(OpsUpstreamErrorsKey)
require.False(t, recorded, "客户端断开不是上游故障")
_, messageSet := c.Get(OpsUpstreamErrorMessageKey)
require.False(t, messageSet)
})
t.Run(h.name+"/request context alive", func(t *testing.T) {
c := newTransportErrorTestGin(t)
err := h.handle(context.Background(), c)
require.ErrorIs(t, err, context.Canceled)
raw, recorded := c.Get(OpsUpstreamErrorsKey)
require.True(t, recorded)
require.Len(t, raw.([]*OpsUpstreamErrorEvent), 1)
})
}
}
func TestAntigravityCompatTransportError_ClientDisconnectWrites499(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
ctx, cancel := context.WithCancel(context.Background())
cancel()
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil).WithContext(ctx)
err := (&AntigravityGatewayService{}).handleAntigravityCompatTransportError(c, context.Canceled)
require.Error(t, err)
require.Equal(t, antigravityStatusClientClosed, rec.Code)
require.Equal(t, "client_disconnected", gjson.Get(rec.Body.String(), "error.type").String())
}
func TestAntigravityWriteGoogleError_ClientClosedStatus(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", nil)
_ = (&AntigravityGatewayService{}).writeGoogleError(c, antigravityStatusClientClosed, "Client disconnected before upstream response")
require.Equal(t, antigravityStatusClientClosed, rec.Code)
require.Equal(t, "CANCELLED", gjson.Get(rec.Body.String(), "error.status").String())
}
@@ -1803,6 +1803,7 @@ func TestOpus55RejectsUnsupportedParametersBeforeMimicry(t *testing.T) {
func TestOpus55ThinkingDefaultPreservesSignedHistory(t *testing.T) {
body := []byte(`{"model":"claude-opus-5-5","messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"","signature":"signed"},{"type":"redacted_thinking","data":"encrypted"},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{}}]},{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"ok"}]}],"tool_choice":{"type":"none"},"thinking":{"type":"adaptive","display":"omitted"}}`)
require.Equal(t, string(body), string(FilterThinkingBlocks(body, "claude-opus-5-5")))
require.Equal(t, string(body), string(FilterThinkingBlocks(body, "anthropic/claude-opus-5.5")))
withoutThinking, _ := deleteJSONPathBytes(body, "thinking")
require.Equal(t, string(withoutThinking), string(FilterThinkingBlocks(withoutThinking, "claude-opus-5-5")))
out, _ := normalizeClaudeOAuthRequestBody(body, "claude-opus-5-5", claudeOAuthNormalizeOptions{})
@@ -10,6 +10,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/usagestats"
gocache "github.com/patrickmn/go-cache"
"github.com/stretchr/testify/require"
@@ -670,7 +671,9 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) {
want: nil,
},
{
name: "passthrough wins over ordinary account mapping",
// The passthrough account serves the default set (its stale mapping is
// ignored), while the ordinary account's mapping still reaches the list.
name: "passthrough contributes defaults alongside ordinary account mapping",
accounts: []Account{
{
ID: 2,
@@ -684,7 +687,7 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) {
Extra: map[string]any{"openai_passthrough": true},
},
},
want: nil,
want: dedupeAndSortModelIDs(append([]string{"configured-model"}, openai.DefaultModelIDs()...)),
},
{
name: "ordinary accounts preserve mapped whitelist",
@@ -708,7 +711,9 @@ func TestGetAvailableModels_OpenAIPassthroughUsesDefaultFallback(t *testing.T) {
modelsListCacheTTL: time.Minute,
}
require.Equal(t, tt.want, svc.GetAvailableModels(context.Background(), &groupID, PlatformOpenAI))
got := svc.GetAvailableModels(context.Background(), &groupID, PlatformOpenAI)
require.Equal(t, tt.want, got)
require.NotContains(t, got, "stale-model", "passthrough mapping must never reach the public list")
})
}
}
+6 -8
View File
@@ -1433,15 +1433,13 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
hasAnyMapping := false
for _, acc := range accounts {
// Passthrough routing accepts models independently of model_mapping. A stale
// mapping on any eligible passthrough account therefore cannot define the
// public whitelist; return nil so the handler uses its default model set.
// Passthrough routing accepts models independently of model_mapping, so a
// stale mapping on a passthrough account must not narrow the public list.
// Treat it like an unmapped account: skip its mapping here and let
// supplementUnmappedOpenAIModels contribute the default set. Mappings on
// the ordinary accounts in the same group still count.
if platform == PlatformOpenAI && acc.IsOpenAIPassthroughEnabled() {
if s.modelsListCache != nil {
s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL)
modelsListCacheStoreTotal.Add(1)
}
return nil
continue
}
mapping := acc.GetModelMapping()
@@ -0,0 +1,74 @@
//go:build unit
package service
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestStructuredOutputsBetaOAuthMimic(t *testing.T) {
const beta = "structured-outputs-2025-11-13"
for _, tc := range []struct {
name string
header string
drop map[string]struct{}
want bool
}{
{"explicit", beta, nil, true},
{"mixed and duplicate", "custom-beta, " + beta + "," + beta, nil, true},
{"absent", "custom-beta", nil, false},
{"similar token", beta + "-other", nil, false},
{"filtered", beta, map[string]struct{}{beta: {}}, false},
} {
t.Run(tc.name, func(t *testing.T) {
s := newTestGatewayServiceForBeta(false)
header := http.Header{}
header.Set("anthropic-beta", tc.header)
body := []byte(`{"output_format":{"type":"json_schema","schema":{"type":"object"}}}`)
got, set := s.computeFinalAnthropicBeta("oauth", true, "claude-sonnet-5", header, body, tc.drop)
require.True(t, set)
require.Equal(t, tc.want, containsBetaToken(got, beta))
require.False(t, containsBetaToken(got, "custom-beta"))
require.True(t, containsBetaToken(got, "oauth-2025-04-20"))
if tc.want {
require.Equal(t, 1, strings.Count(got, beta))
}
})
}
}
func TestBuildUpstreamRequestStructuredOutputsBeta(t *testing.T) {
const beta = "structured-outputs-2025-11-13"
for _, filtered := range []bool{false, true} {
t.Run(map[bool]string{false: "preserved", true: "policy filtered"}[filtered], func(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("anthropic-beta", beta+",custom-beta")
if filtered {
c.Set(betaPolicyFilterSetKey, map[string]struct{}{beta: {}})
}
account := &Account{ID: 701, Platform: PlatformAnthropic, Type: AccountTypeOAuth,
Credentials: map[string]any{"access_token": "test-token"}, Status: StatusActive, Schedulable: true}
body := []byte(`{"model":"claude-sonnet-5","max_tokens":1024,"output_format":{"type":"json_schema","schema":{"type":"object","properties":{"ok":{"type":"boolean"}},"required":["ok"],"additionalProperties":false}},"messages":[{"role":"user","content":"Return JSON"}]}`)
svc := &GatewayService{cfg: &config.Config{}}
req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body,
"test-token", "oauth", "claude-sonnet-5", false, true)
require.NoError(t, err)
out := readUpstreamBodyForTest(t, req)
require.JSONEq(t, gjson.GetBytes(body, "output_format").Raw, gjson.GetBytes(out, "output_format").Raw)
header := getHeaderRaw(req.Header, "anthropic-beta")
require.Equal(t, !filtered, containsBetaToken(header, beta))
require.False(t, containsBetaToken(header, "custom-beta"))
})
}
}
@@ -97,8 +97,8 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// === 计算最终 anthropic-beta header(先于 body sanitize 与 CCH 签名)===
//
// 顺序约束:
// 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径会忽略客户端 beta,
// 与原“OAuth + mimicClaudeCode 跳过白名单透传”行为对齐)
// 1) 算 finalBeta(纯函数,不依赖 req.Header;mimicry 路径仅保留明确支持的
// 客户端兼容性 beta,其余使用固定列表)
// 2) 按 finalBeta 做能力维度 body sanitize(如 context-management beta 缺失 →
// strip body.context_management,与 Bedrock 路径对称)
// 3) CCH 签名(必须使用 strip 后的 body,否则 hash 与最终 body 不一致 →
@@ -509,9 +509,14 @@ func (s *GatewayService) computeFinalAnthropicBeta(
if tokenType == "oauth" {
if mimicClaudeCode {
// mimic 路径跳过白名单透传,incomingBeta 始终为空;所有模型都必须
// 携带完整 Claude Code beta 集合,避免 Haiku 被识别为第三方客户端。
return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), "", effectiveDropSet), true
// Keep the default beta set, with a narrow opt-in for legacy structured
// output requests. Unknown client betas remain excluded and policy drops
// still take precedence over this compatibility token.
incomingBeta := ""
if containsBetaToken(clientBeta, claude.BetaStructuredOutputs) {
incomingBeta = claude.BetaStructuredOutputs
}
return mergeAnthropicBetaDropping(claude.FullClaudeCodeMimicryBetas(), incomingBeta, effectiveDropSet), true
}
// 真 Claude Code 客户端透传路径
return stripBetaTokensWithSet(s.getBetaHeader(modelID, clientBeta), effectiveDropSet), true
@@ -536,7 +541,7 @@ func (s *GatewayService) computeFinalAnthropicBeta(
// 两条特殊规则:
//
// - OAuth mimic:requiredBetas 为 FullClaudeCodeMimicryBetas + BetaTokenCounting;
// count_tokens 另外保留客户端 beta,而 messages mimic 会忽略客户端 beta。
// count_tokens 另外保留客户端 beta,而 messages mimic 仅保留明确支持的兼容性 token。
// - OAuth 透传 + 客户端未传 anthropic-beta:补齐 CountTokensBetaHeader
// - OAuth 透传 + 客户端传了:补齐 BetaTokenCounting(如果未含)
//
@@ -28,7 +28,8 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst
// 1. records the failure in Ops error logs (status 0, kind=request_error) —
// the caller passes path-specific fields (UpstreamURL, Passthrough) via
// event; identity, proxy attribution and classification fields are
// filled here from the same account snapshot that built the transport;
// filled here from the same account snapshot that built the transport.
// A client disconnect is not recorded (see isClientCanceledTransportError);
// 2. for durable faults (expired/rejected proxy creds, dead proxy,
// DNS/routing) temporarily unschedules the account and logs a stable warn
// event that alert rules can key on;
@@ -39,6 +40,9 @@ var gatewayTransportFailoverBody = []byte(`{"type":"error","error":{"type":"upst
// It deliberately does NOT write to the response: the handler owns the
// response (failover, or a protocol-correct error once failover is exhausted).
func (s *GatewayService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, event OpsUpstreamErrorEvent) error {
if isClientCanceledTransportError(ctx, err) {
return err
}
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
event.ProxyID, event.ProxyName = opsUpstreamProxyAttribution(account)
@@ -870,6 +870,7 @@ func (s *GatewayService) recordUsageCore(ctx context.Context, input *recordUsage
ImageOutputTokens: result.Usage.ImageOutputTokens,
},
cost.TotalCost, pricingAt,
accountStatsLongContextPricingEnabled(nil),
)
}
@@ -16,7 +16,8 @@ var geminiTransportFailoverBody = []byte(`{"error":{"code":502,"message":"Upstre
// Chat Completions 兼容)上的传输层失败:Do 返回非 HTTP 错误(代理 / DNS / TCP / TLS /
// 响应头超时),没有拿到任何上游状态码。
//
// - 记录 Ops 错误事件(status 0,kind=request_error);
// - 记录 Ops 错误事件(status 0,kind=request_error);客户端断开不记录
// (见 isClientCanceledTransportError);
// - 持久性故障(代理凭据失效、端点拒绝连接、DNS/路由不可达)临时摘除账号;
// 瞬时故障(EOF / reset / 超时)账号保持可调度;
// - 客户端已断开(context.Canceled)原样返回:不换号、不摘号;
@@ -24,6 +25,9 @@ var geminiTransportFailoverBody = []byte(`{"error":{"code":502,"message":"Upstre
//
// 本函数不写响应:响应归 handler 所有(换号,或耗尽后按端点格式渲染错误)。
func (s *GeminiMessagesCompatService) handleUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error) error {
if isClientCanceledTransportError(ctx, err) {
return err
}
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
event := OpsUpstreamErrorEvent{
@@ -29,15 +29,17 @@ func GroupModelAllowlistFromDomain(cfg domain.GroupModelAllowlist) GroupModelAll
}
// supplementUnmappedOpenAIModels ensures a partial mapping catalog does not
// hide models from unmapped OpenAI accounts. An empty catalog is left unchanged
// so callers retain their existing discovery fallback.
// hide models from unmapped or passthrough OpenAI accounts (passthrough routing
// ignores model_mapping, so it serves the same default set as an unmapped
// account). An empty catalog is left unchanged so callers retain their existing
// discovery fallback.
func supplementUnmappedOpenAIModels(accounts []Account, models []string) []string {
if len(models) == 0 {
return models
}
for i := range accounts {
account := &accounts[i]
if account.Platform == PlatformOpenAI && len(account.GetModelMapping()) == 0 {
if account.Platform == PlatformOpenAI && (account.IsOpenAIPassthroughEnabled() || len(account.GetModelMapping()) == 0) {
return dedupeAndSortModelIDs(slices.Concat(models, openai.DefaultModelIDs()))
}
}
@@ -45,7 +47,7 @@ func supplementUnmappedOpenAIModels(accounts []Account, models []string) []strin
}
// normalizeGroupModelAllowlist 归一化管理端提交的分组模型白名单:
// 条目 TrimSpace、按小写去重保序;`*` 只允许出现在条目末尾;
// 条目 TrimSpace、按小写去重保序;`*` 可出现在任意位置;
// enabled=true 且列表为空视为配置错误,返回 400 而不是运行时静默放行/拒绝。
func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist, error) {
out := GroupModelAllowlist{Enabled: cfg.Enabled}
@@ -63,9 +65,6 @@ func normalizeGroupModelAllowlist(cfg GroupModelAllowlist) (GroupModelAllowlist,
if model == "" {
continue
}
if strings.Contains(strings.TrimSuffix(model, "*"), "*") {
return out, infraerrors.New(http.StatusBadRequest, "INVALID_MODEL_ALLOWLIST", `wildcard "*" is only allowed at the end of an allowlist entry`)
}
key := strings.ToLower(model)
if _, ok := seen[key]; ok {
continue
@@ -106,18 +105,8 @@ func (a GroupModelAllowlist) Allows(model string) bool {
if entry == "" {
continue
}
entry = strings.ToLower(entry)
if strings.HasSuffix(entry, "*") {
prefix := strings.TrimSuffix(entry, "*")
for _, candidate := range candidates {
if strings.HasPrefix(candidate, prefix) {
return true
}
}
continue
}
for _, candidate := range candidates {
if candidate == entry {
if groupAllowlistPatternMatches(entry, candidate) {
return true
}
}
@@ -125,6 +114,29 @@ func (a GroupModelAllowlist) Allows(model string) bool {
return false
}
// groupAllowlistPatternMatches treats only * as a wildcard, including in the middle
// or at either end. Matching is anchored and case-insensitive.
func groupAllowlistPatternMatches(pattern, model string) bool {
pattern = strings.ToLower(pattern)
model = strings.ToLower(model)
parts := strings.Split(pattern, "*")
if len(parts) == 1 {
return pattern == model
}
if !strings.HasPrefix(model, parts[0]) {
return false
}
model = model[len(parts[0]):]
for _, part := range parts[1 : len(parts)-1] {
index := strings.Index(model, part)
if index < 0 {
return false
}
model = model[index+len(part):]
}
return strings.HasSuffix(model, parts[len(parts)-1])
}
// groupModelAllowlistCandidates 返回客户端模型名在白名单匹配中的候选形式(均已小写)。
func groupModelAllowlistCandidates(model string) []string {
candidates := make([]string, 0, 4)
@@ -151,6 +163,9 @@ func groupModelAllowlistCandidates(model string) []string {
// FilterForListing 按白名单条目顺序生成模型列表输出。
// 精确条目沿用既有规则:只有出现在 source(账号映射键 ∪ 平台默认列表)的模式
// 集合中才输出;通配条目展开为 source 中所有匹配项并保持 source 顺序;全局去重。
// 当 source 是前缀通配模式且白名单模式的固定前缀位于该前缀之内时,
// 可直接输出白名单模式:它的所有实例都属于 source。其他未处理的无限
// 交集不会被枚举为模型 ID,列表可能省略仍可请求的模型。
func (a GroupModelAllowlist) FilterForListing(source []string) []string {
if !a.Enabled {
return source
@@ -188,12 +203,14 @@ func (a GroupModelAllowlist) FilterForListing(source []string) []string {
if entry == "" {
continue
}
if strings.HasSuffix(entry, "*") {
// 空 前缀(裸 `*` 条目)匹配全部来源,与 Allows 的全放行语义一致。
prefix := strings.ToLower(strings.TrimSuffix(entry, "*"))
if strings.Contains(entry, "*") {
for _, pattern := range patterns {
if strings.HasPrefix(strings.ToLower(pattern), prefix) {
if groupAllowlistPatternMatches(entry, pattern) {
add(pattern)
} else if strings.HasSuffix(pattern, "*") &&
!strings.Contains(strings.TrimSuffix(pattern, "*"), "*") &&
strings.HasPrefix(strings.ToLower(entry), strings.ToLower(strings.TrimSuffix(pattern, "*"))) {
add(entry)
}
}
continue
@@ -41,9 +41,9 @@ func TestNormalizeGroupModelAllowlist(t *testing.T) {
want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-5.5-*"}},
},
{
name: "wildcard in the middle is rejected",
in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4"}},
wantErr: "INVALID_MODEL_ALLOWLIST",
name: "wildcards at any position are accepted",
in: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4", "*codex", "gpt-*-codex-*"}},
want: GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-5.4", "*codex", "gpt-*-codex-*"}},
},
{
name: "bare wildcard is accepted as allow-all",
@@ -52,9 +52,9 @@ func TestNormalizeGroupModelAllowlist(t *testing.T) {
wantErr: "",
},
{
name: "disabled config with invalid wildcard still rejected",
in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}},
wantErr: "INVALID_MODEL_ALLOWLIST",
name: "disabled config preserves interior wildcard",
in: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}},
want: GroupModelAllowlist{Enabled: false, Models: []string{"foo-*bar"}},
},
}
@@ -106,6 +106,7 @@ func TestGroupModelAllowlistAllows(t *testing.T) {
{name: "openai reasoning suffix on unlisted model", model: "gpt-4.1-low", want: false},
{name: "trailing wildcard prefix match", model: "grok-4.6", want: true},
{name: "trailing wildcard requires prefix", model: "grok", want: false},
{name: "interior and suffix wildcard", model: "gpt-5-codex-fast", want: false},
{name: "empty model passes (handler decides required-ness)", model: "", want: true},
}
@@ -117,6 +118,20 @@ func TestGroupModelAllowlistAllows(t *testing.T) {
})
}
t.Run("wildcards match at any position and remain anchored", func(t *testing.T) {
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex-*", "*sonnet*", "gemini-?.*-pro"}}
for _, model := range []string{"gpt-5-codex-high", "CLAUDE-SONNET-4.5", "models/claude-sonnet-4.5"} {
if !cfg.Allows(model) {
t.Fatalf("expected %q to match", model)
}
}
for _, model := range []string{"xgpt-5-codex-high", "gpt-5-codex", "gemini-2.5-pro"} {
if cfg.Allows(model) {
t.Fatalf("expected %q not to match", model)
}
}
})
t.Run("disabled allowlist allows everything", func(t *testing.T) {
disabled := GroupModelAllowlist{Enabled: false, Models: []string{"only-model"}}
if !disabled.Allows("anything") {
@@ -191,6 +206,42 @@ func TestGroupModelAllowlistFilterForListing(t *testing.T) {
}
})
t.Run("interior and leading wildcards expand source models", func(t *testing.T) {
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex", "*sonnet*"}}
got := cfg.FilterForListing(source)
want := []string{"gpt-5.5-codex", "claude-sonnet-4.5"}
if strings.Join(got, ",") != strings.Join(want, ",") {
t.Fatalf("got %#v want %#v", got, want)
}
})
t.Run("source prefix wildcard retains only a safe infinite intersection", func(t *testing.T) {
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex"}}
if !cfg.Allows("gpt-5-codex") || cfg.Allows("gpt-5-mini") {
t.Fatal("request admission must distinguish allowed and denied models")
}
got := cfg.FilterForListing([]string{"gpt-*"})
if strings.Join(got, ",") != "gpt-*-codex" {
t.Fatalf("expected safe allowlist pattern, got %#v", got)
}
if groupAllowlistPatternMatches(got[0], "gpt-5-mini") {
t.Fatal("listing pattern must not expose unauthorized models")
}
})
t.Run("unrepresentable wildcard intersections do not leak source patterns", func(t *testing.T) {
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*-codex"}}
for _, source := range [][]string{{"gpt-5*"}, {"*-codex"}} {
if got := cfg.FilterForListing(source); len(got) != 0 {
t.Fatalf("source %#v would expose unauthorized models: %#v", source, got)
}
}
leadingWildcard := GroupModelAllowlist{Enabled: true, Models: []string{"*codex"}}
if got := leadingWildcard.FilterForListing([]string{"gpt-*"}); len(got) != 0 {
t.Fatalf("source prefix would expose unauthorized models: %#v", got)
}
})
t.Run("mixed exact and wildcard dedupes globally", func(t *testing.T) {
cfg := GroupModelAllowlist{Enabled: true, Models: []string{"gpt-*", "gpt-5.4"}}
got := cfg.FilterForListing(source)
@@ -52,6 +52,9 @@ type PlazaGroup struct {
// = 档位价 × ImageRateMultiplier,不乘分组/用户专属倍率(与计费口径一致)。
ImageRateIndependent bool
ImageRateMultiplier float64
// 视频独立倍率与图片独立倍率分别配置,开启时覆盖分组/用户专属倍率。
VideoRateIndependent bool
VideoRateMultiplier float64
// LongContextPricingEnabled 分组是否按上下文长度应用阶梯价;关闭时模型展示的是最低档。
LongContextPricingEnabled bool
Models []PlazaModel
@@ -132,6 +135,8 @@ func (s *ModelPlazaService) ListGroups(ctx context.Context) ([]PlazaGroup, error
IsExclusive: g.IsExclusive,
ImageRateIndependent: g.ImageRateIndependent,
ImageRateMultiplier: g.ImageRateMultiplier,
VideoRateIndependent: g.VideoRateIndependent,
VideoRateMultiplier: g.VideoRateMultiplier,
LongContextPricingEnabled: g.LongContextPricingEnabled,
}
groupEnt[g.ID] = g
@@ -265,7 +265,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
}}
groups := []Group{
{ID: 10, Name: "g-media", Platform: "openai", RateMultiplier: 1,
ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1},
ImagePrice1K: &imgPrice, ImageRateIndependent: true, ImageRateMultiplier: 1,
VideoRateIndependent: true, VideoRateMultiplier: 0.7},
{ID: 20, Name: "g-plain", Platform: "openai", RateMultiplier: 0.1},
}
svc := newPlazaService(channels, groups, nil)
@@ -279,6 +280,8 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
media := byName["g-media"]
require.True(t, media.ImageRateIndependent)
require.True(t, media.VideoRateIndependent)
require.InDelta(t, 0.7, media.VideoRateMultiplier, 1e-9)
require.InDelta(t, 1.0, media.ImageRateMultiplier, 1e-9)
require.Len(t, media.Models, 1)
p := media.Models[0].Pricing
@@ -295,6 +298,7 @@ func TestListPlazaGroups_GroupImagePriceOverridesChannelPricing(t *testing.T) {
plain := byName["g-plain"]
require.False(t, plain.ImageRateIndependent)
require.False(t, plain.VideoRateIndependent)
require.Len(t, plain.Models, 1)
pp := plain.Models[0].Pricing
require.NotNil(t, pp)
@@ -249,29 +249,30 @@ func (r *ModelPricingResolver) applyTokenOverrides(chPricing *ChannelModelPricin
resolved.BasePricing.FastMultiplier = chPricing.FastMultiplier
resolved.BasePricing.FlexMultiplier = chPricing.FlexMultiplier
resolved.BasePricing.ReasoningEffortMultipliers = maps.Clone(chPricing.ReasoningEffortMultipliers)
// 渠道定价覆盖一切:显式配置则用配置值,未配置则归零(不回退到 LiteLLM)
if chPricing.ImageOutputPrice != nil {
resolved.BasePricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
} else {
resolved.BasePricing.ImageOutputPricePerToken = 0
}
resolved.BasePricing.ImageOutputPriceExplicit = true
applyChannelImageInputPrice(chPricing, resolved.BasePricing)
applyChannelImagePriceOverrides(chPricing, resolved.BasePricing)
// 区间未命中时回退到上面已经应用渠道覆盖的基础价。
resolved.Intervals = filterValidIntervals(chPricing.Intervals)
}
// applyChannelImageInputPrice 应用渠道图片输入价:显式配置则用配置值;
// 未配置时归零,使 computeTokenBreakdown 回退到文本输入价(向后兼容,
// 避免 commit 引入的 LiteLLM 图片输入价泄漏进渠道自定义定价)。
// 与 image_output 不同,此处不设 Explicit 标志——图片输入未配置应回退文本价,
// 而非硬置 0。
func applyChannelImageInputPrice(chPricing *ChannelModelPricing, pricing *ModelPricing) {
if chPricing != nil && chPricing.ImageInputPrice != nil {
// applyChannelImagePriceOverrides 应用渠道图片输入/输出价,规则与其他 token 字段一致:
// - 渠道填写了(含 0):覆盖目录价。图片输出价同时标记 ImageOutputPriceExplicit,
// 显式 0 表示图片输出免费,computeTokenBreakdown 不再回退文本输出价。
// - 渠道留空(nil):沿用目录价(LiteLLM 价格表或内置兜底价)。目录也没有图片价时,
// computeTokenBreakdown 分别回退文本输入/输出价。
//
// 此前留空会被归零,图片输出还被标成显式 0:只建了空价卡的图片模型,图片输出
// 一律按 0 计费,图片输入按文本价计费。
func applyChannelImagePriceOverrides(chPricing *ChannelModelPricing, pricing *ModelPricing) {
if chPricing == nil || pricing == nil {
return
}
if chPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
pricing.ImageOutputPriceExplicit = true
}
if chPricing.ImageInputPrice != nil {
pricing.ImageInputPricePerToken = *chPricing.ImageInputPrice
} else {
pricing.ImageInputPricePerToken = 0
}
}
@@ -369,15 +370,8 @@ func intervalToModelPricing(iv *PricingInterval, base *ModelPricing, chPricing *
pricing.CacheReadPricePerToken = applyMultiplier(pricing.CacheReadPricePerToken, iv.CacheReadMultiplier)
pricing.CacheReadPricePerTokenPriority = applyMultiplier(pricing.CacheReadPricePerTokenPriority, iv.CacheReadMultiplier)
}
// 渠道定价存在时,ImageOutputPrice 显式覆盖;图片输入价用渠道级配置
// (区间不携带图片输入价,与 image_output 一致)。
if chPricing != nil {
pricing.ImageOutputPriceExplicit = true
if chPricing.ImageOutputPrice != nil {
pricing.ImageOutputPricePerToken = *chPricing.ImageOutputPrice
}
applyChannelImageInputPrice(chPricing, pricing)
}
// 区间不携带图片价,沿用渠道级配置;渠道留空时保留基础价里的目录图片价。
applyChannelImagePriceOverrides(chPricing, pricing)
return pricing
}
@@ -722,23 +722,50 @@ func TestFilterValidIntervals(t *testing.T) {
// 9. ImageOutputPriceExplicit tests
// ===========================================================================
func TestApplyTokenOverrides_FlatSetsImageOutputPriceExplicit(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
// withCatalogImagePrices 给测试基础价补上目录图片价($8 输入 / $30 输出 per MTok)。
func withCatalogImagePrices(r *ModelPricingResolver) *ModelPricingResolver {
base := r.billingService.fallbackPrices["claude-sonnet-4"]
base.ImageInputPricePerToken = 8e-6
base.ImageOutputPricePerToken = 30e-6
return r
}
func TestApplyTokenOverrides_FlatUnsetImagePricesInheritCatalog(t *testing.T) {
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
InputPrice: testPtrFloat64(3e-6),
OutputPrice: testPtrFloat64(15e-6),
// ImageOutputPrice intentionally nil
}})
// ImageInputPrice / ImageOutputPrice intentionally nil
}}))
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
require.Equal(t, PricingSourceChannel, resolved.Source)
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit, "留空不应标记显式")
require.InDelta(t, 30e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
require.InDelta(t, 8e-6, resolved.BasePricing.ImageInputPricePerToken, 1e-12)
}
func TestApplyTokenOverrides_ExplicitZeroImageOutputPriceStaysZero(t *testing.T) {
// 渠道显式填 0 仍表示图片输出免费,不回退目录价,也不回退文本输出价。
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
ImageOutputPrice: testPtrFloat64(0),
}}))
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken)
require.InDelta(t, 8e-6, resolved.BasePricing.ImageInputPricePerToken, 1e-12, "图片输入留空仍沿用目录价")
}
func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T) {
@@ -759,29 +786,72 @@ func TestApplyTokenOverrides_FlatWithImageOutputPriceSetsExplicit(t *testing.T)
require.InDelta(t, 50e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
}
func TestApplyTokenOverrides_IntervalSetsImageOutputPriceExplicit(t *testing.T) {
r := newResolverWithChannel(t, []ChannelModelPricing{{
func TestApplyTokenOverrides_IntervalUnsetImagePricesInheritCatalog(t *testing.T) {
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
// No ImageOutputPrice
// No ImageInputPrice / ImageOutputPrice
Intervals: []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)},
},
}})
}}))
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
// BasePricing should have explicit mark (for interval fallback)
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, resolved.BasePricing.ImageOutputPricePerToken)
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.InDelta(t, 30e-6, resolved.BasePricing.ImageOutputPricePerToken, 1e-12)
// 区间命中时同样沿用目录图片价
pricing := r.GetIntervalPricing(resolved, 50000)
require.False(t, pricing.ImageOutputPriceExplicit)
require.InDelta(t, 30e-6, pricing.ImageOutputPricePerToken, 1e-12)
require.InDelta(t, 8e-6, pricing.ImageInputPricePerToken, 1e-12)
}
func TestApplyTokenOverrides_IntervalExplicitImageOutputPrice(t *testing.T) {
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
ImageOutputPrice: testPtrFloat64(50e-6),
Intervals: []PricingInterval{
{MinTokens: 0, MaxTokens: testPtrInt(100000), InputPrice: testPtrFloat64(3e-6), OutputPrice: testPtrFloat64(15e-6)},
},
}}))
resolved := r.Resolve(context.Background(), PricingInput{
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
})
// intervalToModelPricing should also have explicit mark
pricing := r.GetIntervalPricing(resolved, 50000)
require.True(t, pricing.ImageOutputPriceExplicit)
require.Equal(t, 0.0, pricing.ImageOutputPricePerToken)
require.InDelta(t, 50e-6, pricing.ImageOutputPricePerToken, 1e-12)
}
func TestCalculateCostUnified_EmptyChannelEntryBillsCatalogImagePrices(t *testing.T) {
// 常见配置:图片模型只建了空价卡(token 模式、价格全留空)。图片输出此前按 0 计费。
r := withCatalogImagePrices(newResolverWithChannel(t, []ChannelModelPricing{{
Platform: "anthropic",
Models: []string{"claude-sonnet-4"},
BillingMode: BillingModeToken,
}}))
tokens := UsageTokens{InputTokens: 1000, ImageInputTokens: 600, OutputTokens: 500, ImageOutputTokens: 500}
cost, err := r.billingService.CalculateCostUnified(CostInput{
Ctx: context.Background(),
Model: "claude-sonnet-4",
GroupID: groupIDPtr(),
Tokens: tokens,
RateMultiplier: 1,
Resolver: r,
})
require.NoError(t, err)
require.InDelta(t, 500*30e-6, cost.ImageOutputCost, 1e-12)
require.InDelta(t, 600*8e-6, cost.ImageInputCost, 1e-12)
require.InDelta(t, 400*3e-6, cost.InputCost, 1e-12)
require.InDelta(t, 0, cost.OutputCost, 1e-12, "输出全部是图片 token")
}
// ===========================================================================
@@ -835,7 +905,7 @@ func TestApplyTokenOverrides_IntervalDoesNotPolluteFallbackPrices(t *testing.T)
})
require.NotNil(t, resolved)
require.True(t, resolved.BasePricing.ImageOutputPriceExplicit)
require.False(t, resolved.BasePricing.ImageOutputPriceExplicit, "渠道未填图片价,不应标记显式")
// Global fallbackPrices must NOT be polluted
fp := r.billingService.fallbackPrices["claude-sonnet-4"]
@@ -1906,7 +1906,7 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_FreshUsageWind
// inversion) gets excluded from scheduling, and a paused account never receives traffic to
// refresh its snapshot. When the snapshot is stale (codex_usage_updated_at older than the
// staleness bound) the account must be allowed a request so it can self-heal from the real
// response headers — independent of the window's reset time.
// response headers when no valid future window reset is known.
func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_StaleUsageSnapshotSkipsPause_Issue2994(t *testing.T) {
ctx := context.Background()
primary := Account{
@@ -1920,8 +1920,7 @@ func TestOpenAIGatewayService_SelectAccountForModelWithExclusions_StaleUsageSnap
Extra: map[string]any{
"codex_5h_used_percent": 99.0,
"auto_pause_5h_threshold": 0.95,
// Window has NOT reset yet, so the reset guard stays inactive.
"codex_5h_reset_at": time.Now().Add(time.Hour).Format(time.RFC3339),
// No reset time is known, so stale snapshot self-heal applies.
// Snapshot is stale: older than openAICodexAutoPauseStaleAfter (2h).
"codex_usage_updated_at": time.Now().Add(-3 * time.Hour).Format(time.RFC3339),
},
@@ -553,7 +553,10 @@ func shouldApplyOpenAIAlphaSearchAccountErrorSideEffects(statusCode int) bool {
}
func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
output, results := parseOpenAIResponsesSSEForAlphaSearch(body)
output, results, err := parseOpenAIResponsesSSEForAlphaSearch(body)
if err != nil {
return nil, err
}
resp := map[string]any{
"output": output,
}
@@ -563,7 +566,7 @@ func openAIAlphaSearchResponseFromResponsesSSE(body []byte) ([]byte, error) {
return json.Marshal(resp)
}
func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) {
func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any, error) {
text := strings.ReplaceAll(string(body), "\r\n", "\n")
var output strings.Builder
var completedResponse any
@@ -579,21 +582,36 @@ func parseOpenAIResponsesSSEForAlphaSearch(body []byte) (string, []any) {
if err := json.Unmarshal([]byte(data), &event); err != nil {
continue
}
switch event["type"] {
case "error", "response.failed", "response.incomplete":
return "", nil, fmt.Errorf("alpha search responses stream ended with %s", event["type"])
case "response.completed":
response, ok := event["response"].(map[string]any)
if !ok || response == nil {
return "", nil, fmt.Errorf("alpha search responses completion is missing its response")
}
if status, present := response["status"]; present && status != "completed" {
return "", nil, fmt.Errorf("alpha search responses completion has a non-success status")
}
completedResponse = response
}
if delta, _ := event["delta"].(string); delta != "" && event["type"] == "response.output_text.delta" {
_, _ = output.WriteString(delta)
}
if event["type"] == "response.completed" {
completedResponse = event["response"]
}
collectOpenAIAlphaSearchURLCitations(event, &results, seenURLs)
}
// A transport-level 200, deltas or [DONE] alone are not a successful
// search. Do not return partial content or a billable result on EOF.
if completedResponse == nil {
return "", nil, fmt.Errorf("alpha search responses stream ended before completion")
}
out := output.String()
if strings.TrimSpace(out) == "" && completedResponse != nil {
out = extractOpenAIResponsesCompletedText(completedResponse)
collectOpenAIAlphaSearchURLCitations(completedResponse, &results, seenURLs)
}
return out, results
return out, results, nil
}
func openAIAlphaSearchSSEData(block string) string {
@@ -0,0 +1,61 @@
package service
import (
"bytes"
"context"
"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"
)
func TestAlphaSearchFallbackRequiresSuccessfulCompletion(t *testing.T) {
gin.SetMode(gin.TestMode)
delta := "data: {\"type\":\"response.output_text.delta\",\"delta\":\"unfinished answer\"}\n\n"
cases := map[string]string{
"empty": "",
"truncated": delta,
"done_only": delta + "data: [DONE]\n\n",
"failed": delta + "data: {\"type\":\"response.failed\",\"response\":{\"status\":\"failed\"}}\n\n",
"incomplete": delta + "data: {\"type\":\"response.incomplete\",\"response\":{\"status\":\"incomplete\"}}\n\n",
"error": delta + "data: {\"type\":\"error\",\"error\":{\"message\":\"unavailable\"}}\n\n",
"invalid_completed": delta + "data: {\"type\":\"response.completed\",\"response\":{\"status\":\"failed\"}}\n\n",
"missing_response": delta + "data: {\"type\":\"response.completed\"}\n\n",
}
for name, wire := range cases {
t.Run(name, func(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","commands":{"search_query":[{"q":"example"}]}}`)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/alpha/search", bytes.NewReader(body))
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(wire)),
}}
service := &OpenAIGatewayService{cfg: &config.Config{}, httpUpstream: upstream}
account := &Account{ID: 43, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Concurrency: 1,
Credentials: map[string]any{"access_token": "at-test-token", "auth_mode": OpenAIAuthModePersonalAccessToken, "chatgpt_account_id": "fixture-account"}}
result, err := service.ForwardAlphaSearch(context.Background(), c, account, body)
require.Error(t, err)
require.Nil(t, result, "a failed search must not produce a billable WebSearchCalls result")
require.False(t, c.Writer.Written(), "do not write partial content as a successful search")
})
}
}
func TestAlphaSearchFallbackCompletionPreservesOutputAndCitations(t *testing.T) {
for _, wire := range []string{
alphaSearchResponsesSSE("search result"),
"data: {\"type\":\"response.completed\",\"response\":{\"status\":\"completed\",\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"search result\",\"annotations\":[{\"type\":\"url_citation\",\"url\":\"https://example.com/news\",\"title\":\"Example News\"}]}]}]}}\n\n",
} {
body, err := openAIAlphaSearchResponseFromResponsesSSE([]byte(wire))
require.NoError(t, err)
require.JSONEq(t, `{"output":"search result","results":[{"type":"text_result","ref_id":"turn0search0","url":"https://example.com/news","title":"Example News"}]}`, string(body))
}
}
@@ -212,8 +212,8 @@ func TestForwardAlphaSearchPATBackfillsMissingChatGPTAccountMetadata(t *testing.
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"output":"search result"}`)),
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(alphaSearchResponsesSSE("search result"))),
}}
oauthService := NewOpenAIOAuthService(nil, nil)
service := &OpenAIGatewayService{
@@ -77,7 +77,8 @@ func openaiResponsesProbePayload(modelID string) []byte {
//
// 工具能力探测必须用上游真实存在的模型——用占位模型(DefaultTestModel)打第三方
// 上游只会拿到 400 model-not-found,无从判定工具能力。优先取账号 model_mapping
// 的上游模型(值),按字典序取首个具体(非通配符)模型以保证可复现;无映射时回退
// 的上游模型(值),其中优先通用 GPT 文本模型,同类按字典序取首个具体(非通配符)
// 模型以保证可复现;无映射时回退
// DefaultTestModel(适配 OpenAI 官方 APIKey 账号)。
func selectResponsesProbeModel(account *Account) string {
mapping := account.GetModelMapping()
@@ -93,6 +94,13 @@ func selectResponsesProbeModel(account *Account) string {
return openai.DefaultTestModel
}
sort.Strings(candidates)
// Prefer a general text model over auxiliary entries such as
// codex-auto-review or image-only models, which cannot prove tool support.
for _, candidate := range candidates {
if strings.HasPrefix(candidate, "gpt-") && !isOpenAIImageGenerationModel(candidate) {
return candidate
}
}
return candidates[0]
}
@@ -259,9 +267,13 @@ func (s *AccountTestService) ProbeOpenAIAPIKeyResponsesSupport(ctx context.Conte
// 其余 2xx 一律可下结论——尤其 status=completed 却只回 reasoning 的上游(火山方舟
// coding/v3 × kimi-k2.6),仍按原逻辑判为不支持。
//
// 非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。
// 明确指向探测模型不可用的 400/404(model_not_found 等)只说明模型不存在,不说明端点
// 能力,不下结论;其余非 2xx 的结论只看状态码、不依赖响应内容,恒可下结论。
// 缺少 status 字段的响应体(含非 JSON)也按可下结论处理,保持既有行为。
func responsesProbeVerdictIsConclusive(status int, body []byte) bool {
if isResponsesProbeModelUnavailable(status, body) {
return false
}
if status < 200 || status >= 300 {
return true
}
@@ -296,13 +308,17 @@ func isResponsesEndpointSupportedByStatus(status int) bool {
// decideResponsesProbeSupport 依据探测响应判定上游 /v1/responses 是否真正可用于
// 携带工具的请求。
//
// - 404 / 405:端点不存在 → false
// - 探测模型不可用的 400/404:与端点能力无关 → true(调用方同时不下结论,不写入)
// - 其他 404 / 405:端点不存在 → false
// - 其他非 2xx(401/403/422/5xx 等):端点存在,但本次无法判定工具能力
// (鉴权/校验/瞬时故障)→ 保守按 true,保持既有"端点存在即支持"行为
// - 2xx:探测以 tool_choice=required 强制工具调用,响应必须含 function_call
// 输出项才算真正可用;否则(如火山方舟 coding/v3 × kimi-k2.6 仅回 reasoning)
// 判为 false,使网关改走 /v1/chat/completions 直转路径。
func decideResponsesProbeSupport(status int, body []byte) bool {
if isResponsesProbeModelUnavailable(status, body) {
return true // The model error says nothing negative about the endpoint.
}
if status == http.StatusNotFound || status == http.StatusMethodNotAllowed {
return false
}
@@ -312,6 +328,21 @@ func decideResponsesProbeSupport(status int, body []byte) bool {
return responsesProbeBodyHasFunctionCall(body)
}
// isResponsesProbeModelUnavailable 判断 400/404 是否只是在说探测模型不可用
// (错误码/类型为 model_not_found 等,或错误文案明确说模型不存在/不可用)。
func isResponsesProbeModelUnavailable(status int, body []byte) bool {
if status != http.StatusBadRequest && status != http.StatusNotFound {
return false
}
for _, path := range []string{"error.code", "error.type", "response.error.code", "response.error.type"} {
switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(body, path).String())) {
case "model_not_found", "model_not_available", "unsupported_model", "invalid_model":
return true
}
}
return isExplicitOpenAIModelAvailabilityMessage(extractUpstreamErrorMessage(body))
}
// responsesProbeBodyHasFunctionCall 判断非流式 Responses 响应体的 output 数组里
// 是否存在 function_call 输出项。
func responsesProbeBodyHasFunctionCall(body []byte) bool {
@@ -0,0 +1,49 @@
package service
import (
"context"
"io"
"net/http"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
// A 400/404 that only says the probe model does not exist says nothing about
// the /v1/responses endpoint: the verdict must stay inconclusive and the
// account capability must not be overwritten with "unsupported".
func TestResponsesProbeModelUnavailableIsInconclusive(t *testing.T) {
for _, body := range []string{
`{"error":{"type":"model_not_found","message":"Model codex-auto-review is not supported by any configured account in this group"}}`,
`{"error":{"code":"model_not_available"}}`,
`{"error":{"message":"The model missing does not exist"}}`,
} {
require.False(t, responsesProbeVerdictIsConclusive(404, []byte(body)))
require.True(t, decideResponsesProbeSupport(404, []byte(body)))
updates := make(chan map[string]any, 1)
account := Account{ID: 96, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test", "base_url": "https://upstream.example"}}
repo := &snapshotUpdateAccountRepo{stubOpenAIAccountRepo: stubOpenAIAccountRepo{accounts: []Account{account}}, updateExtraCalls: updates}
svc := &AccountTestService{accountRepo: repo, cfg: &config.Config{}, httpUpstream: &httpUpstreamRecorder{resp: &http.Response{
StatusCode: 404, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body)),
}}}
svc.ProbeOpenAIAPIKeyResponsesSupport(context.Background(), account.ID)
select {
case <-updates:
t.Fatal("a model failure must not overwrite the account endpoint capability")
default:
}
}
// A plain 404/405 still means the endpoint does not exist.
require.True(t, responsesProbeVerdictIsConclusive(404, []byte("404 page not found")))
require.False(t, decideResponsesProbeSupport(404, []byte("404 page not found")))
require.False(t, decideResponsesProbeSupport(405, nil))
}
func TestSelectResponsesProbeModelPrefersGeneralTextModel(t *testing.T) {
account := &Account{Credentials: map[string]any{"model_mapping": map[string]any{
"codex-auto-review": "codex-auto-review", "gpt-image-2": "gpt-image-2", "gpt-5.5": "gpt-5.5",
}}}
require.Equal(t, "gpt-5.5", selectResponsesProbeModel(account))
}
@@ -107,6 +107,9 @@ func openAIReasoningEffortToClaudeOutputEffort(effort string) string {
// normally translated to OpenAI xhigh, but GPT-5.6 accepts the original max
// value on Responses and Chat Completions.
func openAICompatAnthropicReasoningEffort(req *apicompat.AnthropicRequest, upstreamModel, convertedEffort string) string {
if convertedEffort == "none" {
return convertedEffort
}
if req == nil || req.OutputConfig == nil || !strings.EqualFold(strings.TrimSpace(req.OutputConfig.Effort), "max") {
return convertedEffort
}
@@ -313,6 +313,31 @@ func TestForwardAsAnthropic_PreservesMaxForFinalGPT56ResponsesModel(t *testing.T
}
}
func TestForwardAsAnthropic_DisabledThinkingOverridesMaxForResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"sol","max_tokens":16,"messages":[{"role":"user","content":"hello"}],"thinking":{"type":"disabled"},"output_config":{"effort":"max"},"stream":false}`)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
upstream := &httpUpstreamRecorder{resp: openAICompatSSECompletedResponse("resp_no_thinking", "gpt-5.6-sol")}
svc := &OpenAIGatewayService{
httpUpstream: upstream,
cfg: &config.Config{Security: config.SecurityConfig{URLAllowlist: config.URLAllowlistConfig{Enabled: false}}},
}
account := rawGPT56ResponsesOAuthAccount("sol", "gpt-5.6-sol")
result, err := svc.ForwardAsAnthropic(context.Background(), c, account, body, "", "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "none", gjson.GetBytes(upstream.lastBody, "reasoning.effort").String())
require.NotNil(t, result.ReasoningEffort)
require.Equal(t, "none", *result.ReasoningEffort)
}
func rawGPT56ResponsesAPIKeyAccount(requestedModel, mappedModel string) *Account {
return &Account{
ID: 501,
@@ -87,6 +87,66 @@ func TestEnsureDeepSeekChatReasoningPlaceholders(t *testing.T) {
})
}
// OpenCode Zen / Go 把 deepseek-* 模型转发给 DeepSeek,同一条 thinking-mode 400
// 会原样回吐,占位注入必须覆盖这条链路;同账号下的非 DeepSeek 模型不受影响。
func TestEnsureDeepSeekChatReasoningPlaceholders_OpenCodeZenUpstream(t *testing.T) {
openCodeGo := &Account{
ID: 24,
Name: "opencode-go",
Platform: PlatformOpenCodeGo,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-test", "base_url": DefaultOpenCodeGoBaseURL},
}
openAIViaZen := &Account{
ID: 25,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-test", "base_url": DefaultOpenCodeZenBaseURL},
}
thirdPartyDeepSeekName := &Account{
ID: 26,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"},
}
missingFor := func(model string) []byte {
return []byte(`{"model":"` + model + `","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`)
}
t.Run("opencode_go_deepseek_model_fills_empty_assistant", func(t *testing.T) {
body := missingFor("deepseek-v4.1-flash")
got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body)
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
require.False(t, gjson.GetBytes(got, "messages.0.reasoning_content").Exists())
require.False(t, gjson.GetBytes(got, "messages.2.reasoning_content").Exists())
})
t.Run("opencode_go_non_deepseek_model_unchanged", func(t *testing.T) {
body := missingFor("glm-5.3")
got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body)
require.Equal(t, string(body), string(got))
})
t.Run("opencode_prefixed_model_id_fills_empty_assistant", func(t *testing.T) {
body := missingFor("opencode-go/DeepSeek-V4-Pro")
got := ensureDeepSeekChatReasoningPlaceholders(openCodeGo, body)
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
})
t.Run("openai_platform_pointed_at_zen_host_fills_empty_assistant", func(t *testing.T) {
body := missingFor("deepseek-v4-flash")
got := ensureDeepSeekChatReasoningPlaceholders(openAIViaZen, body)
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
})
t.Run("unrelated_upstream_with_deepseek_model_name_unchanged", func(t *testing.T) {
body := missingFor("deepseek-v4.1-flash")
got := ensureDeepSeekChatReasoningPlaceholders(thirdPartyDeepSeekName, body)
require.Equal(t, string(body), string(got))
})
}
type reasoningHitCache struct {
stubGatewayCache
values map[string]string
@@ -1454,6 +1454,7 @@ func (s *OpenAIGatewayService) buildUpstreamRequest(ctx context.Context, c *gin.
req.Header.Del("OpenAI-Beta")
req.Header.Del("originator")
} else {
stripOpenAILegacyResponsesBeta(req.Header)
req.Header.Set("originator", resolveOpenAIUpstreamOriginator(c, isCodexCLI))
}
apiKeyID := getAPIKeyIDFromContext(c)
@@ -91,6 +91,13 @@ func TestForwardAsAnthropic_ForceChatCompletionsPreservesFinalModelReasoningEffo
mapped: "gpt-5.6-luna",
wantEffort: "medium",
},
{
name: "disabled thinking overrides max",
model: "gpt-5.6-luna",
mapped: "gpt-5.6-luna",
effortJSON: `,"output_config":{"effort":"max"},"thinking":{"type":"disabled"}`,
wantEffort: "none",
},
}
for _, tt := range tests {
@@ -3210,6 +3210,51 @@ func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastChargesStandard(t *testin
require.InDelta(t, standardTotal*0.5, usageRepo.lastLog.ActualCost, 1e-10)
}
func TestOpenAIGatewayServiceRecordUsage_FreeOpenAIFastMissingPricingRecordsZeroCostUsageLog(t *testing.T) {
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
svc := newOpenAIRecordUsageServiceForTest(
usageRepo,
&openAIRecordUsageUserRepoStub{},
&openAIRecordUsageSubRepoStub{},
nil,
)
svc.resolver = NewModelPricingResolver(nil, svc.billingService)
groupID := int64(78)
serviceTier := "priority"
apiKey := &APIKey{
ID: 1021,
GroupID: &groupID,
Group: &Group{
ID: groupID, Platform: PlatformOpenAI, Status: StatusActive,
Hydrated: true, RateMultiplier: 1, FreeOpenAIFast: true,
},
}
err := svc.RecordUsage(context.Background(), &OpenAIRecordUsageInput{
Result: &OpenAIForwardResult{
RequestID: "resp_free_fast_missing_pricing",
ServiceTier: &serviceTier,
Usage: OpenAIUsage{InputTokens: 1200, OutputTokens: 300},
Model: "pricing-missing-test-model",
Duration: time.Second,
},
APIKey: apiKey,
User: &User{ID: 2021},
Account: &Account{ID: 3021, Platform: PlatformOpenAI, Type: AccountTypeAPIKey},
})
require.NoError(t, err)
require.Equal(t, 1, usageRepo.calls)
require.NotNil(t, usageRepo.lastLog)
require.Equal(t, "resp_free_fast_missing_pricing", usageRepo.lastLog.RequestID)
require.Equal(t, 1200, usageRepo.lastLog.InputTokens)
require.Equal(t, 300, usageRepo.lastLog.OutputTokens)
require.NotNil(t, usageRepo.lastLog.ServiceTier)
require.Equal(t, "priority", *usageRepo.lastLog.ServiceTier)
require.Zero(t, usageRepo.lastLog.TotalCost)
require.Zero(t, usageRepo.lastLog.ActualCost)
}
func TestGroupBillsOpenAIFastAtStandardRequiresOpenAIAccount(t *testing.T) {
apiKey := &APIKey{Group: &Group{Platform: PlatformComposite, FreeOpenAIFast: true}}
@@ -431,15 +431,53 @@ func targetsDeepSeekAPIHost(account *Account) bool {
return strings.EqualFold(u.Hostname(), ds.Hostname())
}
// targetsOpenCodeZenUpstream 报告该账号上游是否是官方 OpenCode Zen / Go 网关。
// platform=opencode_go 直接命中;其他平台按 base_url 主机判定,覆盖用 API Key
// 直接把 openai 平台账号指向 opencode.ai 的接入方式。
func targetsOpenCodeZenUpstream(account *Account) bool {
if account == nil {
return false
}
if account.IsOpenCodeGo() {
return true
}
return isOfficialOpenCodeHost(account.GetOpenAIBaseURL())
}
// isDeepSeekCatalogModel 判定(剥掉 opencode 前缀后的)模型 ID 是否属于
// DeepSeek 系列,例如 deepseek-v4-flash / deepseek-v4.1-flash / deepseek-chat。
func isDeepSeekCatalogModel(model string) bool {
return strings.HasPrefix(normalizeOpenCodeGoModelID(model), "deepseek")
}
// requiresDeepSeekChatReasoning 报告该 Chat Completions 请求的上游是否按
// DeepSeek thinking mode 语义校验 reasoning_content。
//
// DeepSeek 官方 API 直接命中。OpenCode Zen / Go 是转发型订阅网关:deepseek-*
// 模型由 DeepSeek 实际承载,同一条 400 原文会被一字不差地回吐,所以按
// 「官方 OpenCode 上游 + deepseek-* 模型」补一条判定。
//
// 不放宽成「任意上游的 deepseek-* 模型名」:占位符是写进请求体的额外字段,
// 对没有这条约束的上游属于无谓改写,且可能触发未知字段校验。
func requiresDeepSeekChatReasoning(account *Account, body []byte) bool {
if targetsDeepSeekAPIHost(account) {
return true
}
if !targetsOpenCodeZenUpstream(account) {
return false
}
return isDeepSeekCatalogModel(gjson.GetBytes(body, "model").String())
}
// ensureDeepSeekChatReasoningPlaceholders 给缺 reasoning_content 的 assistant
// 消息补单个空格占位。DeepSeek thinking mode 要求历史里每条产生过思维的
// assistant 消息都回传该字段,否则 400
// "The `reasoning_content` in the thinking mode must be passed back to the API"。
//
// 桥接会从 summary / 缓存回注真实明文;这里只填仍为空的缺口,不覆盖已有内容。
// 非 DeepSeek 上游原样返回(字节不变)。
// 判定见 requiresDeepSeekChatReasoning,不命中的上游原样返回(字节不变)。
func ensureDeepSeekChatReasoningPlaceholders(account *Account, body []byte) []byte {
if !targetsDeepSeekAPIHost(account) {
if !requiresDeepSeekChatReasoning(account, body) {
return body
}
messages := gjson.GetBytes(body, "messages")
@@ -529,11 +529,11 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account)
utilization5h, has5h := resolveOpenAIQuotaUtilization(account.Extra, "5h", now)
utilization7d, has7d := resolveOpenAIQuotaUtilization(account.Extra, "7d", now)
if has5h && utilization5h >= config.Threshold5h {
notifyOpenAIAutoReset(account.ID)
notifyOpenAIAutoResetFromScheduler(account.ID)
return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: config.Threshold5h, utilization: utilization5h, reason: "quota_auto_reset_pending_5h"}
}
if has7d && utilization7d >= config.Threshold7d {
notifyOpenAIAutoReset(account.ID)
notifyOpenAIAutoResetFromScheduler(account.ID)
return true, openAIQuotaAutoPauseDecision{window: "7d", threshold: config.Threshold7d, utilization: utilization7d, reason: "quota_auto_reset_pending_7d"}
}
@@ -547,7 +547,7 @@ func shouldAutoPauseOpenAIAccountByQuota(ctx context.Context, account *Account)
if state != nil && state.Status == OpenAIAutoResetStatusAvailable && state.AvailableCount > 0 && !openAIAutoResetStateStale(state, now) {
return false, openAIQuotaAutoPauseDecision{}
}
notifyOpenAIAutoReset(account.ID)
notifyOpenAIAutoResetFromScheduler(account.ID)
if pauseReached5h {
return true, openAIQuotaAutoPauseDecision{window: "5h", threshold: pause5h, utilization: utilization5h, reason: "quota_auto_reset_credit_check_5h"}
}
@@ -662,8 +662,8 @@ func resolveAccountExtraNumber(extra map[string]any, keys ...string) (float64, b
// resolveOpenAIQuotaUtilization returns the current utilization ratio (0..1) for the
// given Codex usage window. ok=false means there is no usable signal to pause on:
// either no snapshot exists, or the window has already rolled over so the cached
// percentage is stale. The stale guard matters because a paused account stops
// either no snapshot exists, the window has rolled over, or an old snapshot has
// no known future reset. The stale guard matters because a paused account stops
// receiving requests, so its snapshot is never refreshed from upstream headers —
// without this check an old used_percent would keep the account paused forever even
// after the real window reset.
@@ -675,14 +675,19 @@ func resolveOpenAIQuotaUtilization(extra map[string]any, window string, now time
if openAIQuotaWindowReset(extra, window, now) {
return 0, false
}
// 快照过于陈旧(账号长期未收到流量刷新)时,不再据此暂停。放行后下一次响应头
// 快照过于陈旧且没有明确的未来重置时间时,不再据此暂停。放行后下一次响应头
// 会刷新快照实现自愈,避免账号在错误/过期的 used% 上被永久跳过(issue #2994)。
if openAICodexSnapshotStaleForPause(extra, now) {
if openAICodexSnapshotStaleForPause(extra, now) && !openAIQuotaWindowResetPending(extra, window, now) {
return 0, false
}
return usedPercent / 100, true
}
func openAIQuotaWindowResetPending(extra map[string]any, window string, now time.Time) bool {
resetAt, ok := openAICodexWindowResetAt(extra, window)
return ok && now.Before(resetAt)
}
// openAICodexSnapshotStaleForPause reports whether the Codex usage snapshot is stale
// enough that it should no longer keep an account auto-paused. It anchors on
// codex_usage_updated_at (always written by buildCodexUsageExtraUpdates). A missing or
@@ -75,6 +75,7 @@ var openaiAllowedHeaders = map[string]bool{
"accept-language": true,
"content-type": true,
"conversation_id": true,
"openai-beta": true,
"user-agent": true,
"originator": true,
"session_id": true,
@@ -3250,6 +3250,58 @@ func TestOpenAIBuildUpstreamRequestOAuthMessagesBridgeUsesSessionOnly(t *testing
require.Empty(t, req.Header.Get("originator"))
}
func TestOpenAIBuildUpstreamRequestOAuthResponsesPreservesCallerBeta(t *testing.T) {
gin.SetMode(gin.TestMode)
makeRequest := func(betaValues ...string) *gin.Context {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(`{"model":"gpt-5.5","input":"hello"}`)))
for _, value := range betaValues {
c.Request.Header.Add("OpenAI-Beta", value)
}
return c
}
svc := &OpenAIGatewayService{}
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"chatgpt_account_id": "chatgpt-acc"}}
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test-api-key"}}
t.Run("preserves caller multi-agent beta", func(t *testing.T) {
c := makeRequest("responses_multi_agent=v1")
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false)
require.NoError(t, err)
require.Equal(t, "responses_multi_agent=v1", req.Header.Get("OpenAI-Beta"))
})
t.Run("preserves mixed beta tokens and removes legacy token", func(t *testing.T) {
c := makeRequest("responses_multi_agent=v1, responses=experimental", "future_feature=v2")
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false)
require.NoError(t, err)
require.Equal(t, []string{"responses_multi_agent=v1", "future_feature=v2"}, req.Header.Values("OpenAI-Beta"))
})
t.Run("absent beta remains absent", func(t *testing.T) {
c := makeRequest()
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false)
require.NoError(t, err)
require.Empty(t, req.Header.Values("OpenAI-Beta"))
})
t.Run("api key remains caller controlled", func(t *testing.T) {
c := makeRequest("responses=experimental, responses_multi_agent=v1")
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, apiKey, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", true, "", false)
require.NoError(t, err)
require.Equal(t, []string{"responses=experimental, responses_multi_agent=v1"}, req.Header.Values("OpenAI-Beta"))
})
t.Run("compact OAuth removes legacy token and preserves independent beta", func(t *testing.T) {
c := makeRequest("responses=experimental, future_feature=v1")
c.Request.URL.Path = "/v1/responses/compact"
req, err := svc.buildUpstreamRequest(c.Request.Context(), c, oauth, []byte(`{"model":"gpt-5.5","input":"hello"}`), "token", false, "", false)
require.NoError(t, err)
require.Equal(t, "future_feature=v1", req.Header.Get("OpenAI-Beta"))
})
}
func TestOpenAIBuildUpstreamRequestPreservesCompactPathForAPIKeyBaseURL(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
@@ -314,10 +314,12 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
longContextBillingGate,
pricingAt,
)
if standardErr != nil {
if standardErr != nil && !isUsagePricingUnavailableError(standardErr) {
return standardErr
}
if cost != nil && standardCost != nil {
// Missing pricing already fell back to a zero-cost log above; keep that
// usage row instead of dropping it on the Standard re-evaluation.
if standardErr == nil && cost != nil && standardCost != nil {
cost.ActualCost = standardCost.ActualCost
}
}
@@ -482,6 +484,7 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
applyAccountStatsCost(ctx, usageLog, s.channelService, s.billingService,
account.ID, *apiKey.GroupID, result.UpstreamModel, result.Model,
tokens, cost.TotalCost, pricingAt,
accountStatsLongContextPricingEnabled(longContextBillingGate),
)
}
@@ -27,6 +27,13 @@ const (
openAIAutoResetQueueCapacity = 1024
openAIAutoResetAttemptTTL = 8 * 24 * time.Hour
openAIAutoResetLeaderLockKey = "jobs:openai-auto-reset-credit"
// 查询阶段失败后的最短重试间隔。调度热路径和扫描都会触发评估,不设间隔时上游
// 故障期间同一账号会被十几秒重查一次;用卡阶段的失败不受此限,超时后仍立即用
// 同一幂等键重试。
openAIAutoResetQueryFailureRetryAfter = time.Minute
// 调度热路径每次过滤候选都会评估暂停,对同一账号的通知按此冷却合并;
// 后台每分钟全量扫描兜底,冷却不会让账号漏检。
openAIAutoResetSchedulerNotifyCooldown = 30 * time.Second
)
const (
@@ -288,10 +295,16 @@ func (s *OpenAIQuotaAutoResetService) evaluateAccount(ctx context.Context, accou
now := time.Now()
assessment := s.assessExtra(account, config, now)
state := openAIAutoResetStateFromExtra(account.Extra)
needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) || assessment.resetReached
// 达到用卡阈值本应立即查询以便用卡;但 10 分钟内已确认无卡时,重查不会改变结论,
// 只会让调度热路径的通知把同一账号的上游额度接口打到十几秒一次。
needsQuery := openAIAutoResetSnapshotStale(account.Extra, now) ||
(assessment.resetReached && !openAIAutoResetNoCreditConfirmed(state, now))
if assessment.pauseReached && !assessment.resetReached {
needsQuery = needsQuery || state == nil || state.Status == OpenAIAutoResetStatusChecking || state.Status == OpenAIAutoResetStatusFailed || openAIAutoResetStateStale(state, now)
}
if needsQuery && openAIAutoResetQueryFailureBackoffActive(state, now) {
needsQuery = false
}
if !needsQuery {
if !assessment.pauseReached && state != nil && state.TriggerWindow != "" {
state.TriggerWindow = ""
@@ -690,6 +703,30 @@ func openAIAutoResetStateFromExtra(extra map[string]any) *OpenAIAutoResetCreditS
return &state
}
// openAIAutoResetNoCreditConfirmed 报告账号在状态有效期内已被确认无卡。
func openAIAutoResetNoCreditConfirmed(state *OpenAIAutoResetCreditState, now time.Time) bool {
return state != nil && state.Status == OpenAIAutoResetStatusNoCredit && !openAIAutoResetStateStale(state, now)
}
// openAIAutoResetQueryFailureBackoffActive 报告查询阶段刚失败、仍在重试间隔内。
// 只覆盖查询、快照写入、卡明细缺失三类失败;用卡阶段的失败需要立即按原幂等键重试。
func openAIAutoResetQueryFailureBackoffActive(state *OpenAIAutoResetCreditState, now time.Time) bool {
if state == nil || state.Status != OpenAIAutoResetStatusFailed {
return false
}
switch state.ErrorCode {
case "RESET_CREDIT_QUERY_FAILED", "USAGE_SNAPSHOT_WRITE_FAILED", "RESET_CREDIT_DETAILS_UNAVAILABLE":
default:
return false
}
failedAt, err := time.Parse(time.RFC3339, state.LastResultAt)
if err != nil {
return false
}
elapsed := now.Sub(failedAt)
return elapsed >= 0 && elapsed < openAIAutoResetQueryFailureRetryAfter
}
func openAIAutoResetStateStale(state *OpenAIAutoResetCreditState, now time.Time) bool {
if state == nil || state.CheckedAt == "" {
return true
@@ -808,3 +845,29 @@ func notifyOpenAIAutoReset(accountID int64) {
func NotifyOpenAIAutoResetCredit(accountID int64) {
notifyOpenAIAutoReset(accountID)
}
// openAIAutoResetSchedulerNotifiedAt 记录调度热路径最近一次为某账号发出通知的时间。
var openAIAutoResetSchedulerNotifiedAt sync.Map // accountID(int64) -> time.Time
// notifyOpenAIAutoResetFromScheduler 供调度候选过滤使用。候选过滤按请求逐账号执行,
// 每条通知都会让后台读一次账号,不做冷却时无卡或待用卡的账号会持续占满后台协程。
func notifyOpenAIAutoResetFromScheduler(accountID int64) {
notifyOpenAIAutoResetFromSchedulerAt(accountID, time.Now())
}
func notifyOpenAIAutoResetFromSchedulerAt(accountID int64, now time.Time) bool {
if accountID <= 0 {
return false
}
if last, ok := openAIAutoResetSchedulerNotifiedAt.Load(accountID); ok {
if lastAt, ok := last.(time.Time); ok {
elapsed := now.Sub(lastAt)
if elapsed >= 0 && elapsed < openAIAutoResetSchedulerNotifyCooldown {
return false
}
}
}
openAIAutoResetSchedulerNotifiedAt.Store(accountID, now)
notifyOpenAIAutoReset(accountID)
return true
}
@@ -3,6 +3,7 @@ package service
import (
"context"
"encoding/json"
"errors"
"sync"
"sync/atomic"
"testing"
@@ -100,6 +101,43 @@ func TestShouldAutoPauseOpenAIAccountByQuota_AutoResetCreditStates(t *testing.T)
})
}
func TestNotifyOpenAIAutoResetFromScheduler_CoolsDownPerAccount(t *testing.T) {
svc := &OpenAIQuotaAutoResetService{ctx: context.Background(), queue: make(chan int64, 8)}
setOpenAIAutoResetNotifier(svc)
t.Cleanup(func() { clearOpenAIAutoResetNotifier(svc) })
const accountA, accountB int64 = 9_900_001, 9_900_002
t.Cleanup(func() {
openAIAutoResetSchedulerNotifiedAt.Delete(accountA)
openAIAutoResetSchedulerNotifiedAt.Delete(accountB)
})
drain := func() []int64 {
var got []int64
for {
select {
case id := <-svc.queue:
svc.pending.Delete(id)
got = append(got, id)
default:
return got
}
}
}
base := time.Now()
require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base))
require.Equal(t, []int64{accountA}, drain())
require.False(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base.Add(10*time.Second)), "冷却期内同一账号不重复通知")
require.Empty(t, drain())
require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountB, base.Add(10*time.Second)), "冷却按账号独立")
require.Equal(t, []int64{accountB}, drain())
require.True(t, notifyOpenAIAutoResetFromSchedulerAt(accountA, base.Add(openAIAutoResetSchedulerNotifyCooldown)))
require.Equal(t, []int64{accountA}, drain())
}
func TestSelectOpenAIAutoResetCandidate_FailsClosed(t *testing.T) {
candidates := []openAIAutoResetCreditCandidate{
{ID: "later", ExpiresAt: "2026-09-02T00:00:00Z"},
@@ -181,9 +219,15 @@ type autoResetTestQuota struct {
mu sync.Mutex
resetArgs [][2]string
failFirst bool
queryCalls atomic.Int32
queryErr error
}
func (q *autoResetTestQuota) QueryUsage(context.Context, int64) (*OpenAIQuotaUsage, error) {
q.queryCalls.Add(1)
if q.queryErr != nil {
return nil, q.queryErr
}
copy := *q.usage
return &copy, nil
}
@@ -334,3 +378,100 @@ func TestOpenAIQuotaAutoResetService_TimeoutRetryReusesRequestBody(t *testing.T)
require.Len(t, args, 2)
require.Equal(t, args[0], args[1], "超时重试必须复用相同 credit_id 与 redeem_request_id")
}
// newResetThresholdTestFixture 构造一个 7d 用量已过用卡阈值、处于停调中的账号。
func newResetThresholdTestFixture(t *testing.T, state *OpenAIAutoResetCreditState, availableCredits int) (*OpenAIQuotaAutoResetService, *autoResetTestAccountRepo, *autoResetTestQuota) {
t.Helper()
now := time.Now().UTC()
extra := map[string]any{
OpenAIAutoResetCreditEnabledExtraKey: true,
OpenAIAutoResetCredit5hThresholdExtraKey: 0.95,
OpenAIAutoResetCredit7dThresholdExtraKey: 0.95,
"codex_7d_used_percent": 99.0,
"codex_usage_updated_at": now.Format(time.RFC3339),
"codex_7d_reset_at": now.Add(48 * time.Hour).Format(time.RFC3339),
}
if state != nil {
extra[OpenAIAutoResetCreditStateExtraKey] = state
}
account := &Account{ID: 501, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Status: StatusActive, Schedulable: true, Extra: extra}
repo := &autoResetTestAccountRepo{account: account}
quota := &autoResetTestQuota{usage: &OpenAIQuotaUsage{
FetchedAt: now.Unix(),
RateLimit: &OpenAIRateLimit{
SecondaryWindow: &OpenAIRateLimitWindow{UsedPercent: 99, LimitWindowSeconds: 7 * 24 * 60 * 60, ResetAfterSeconds: 48 * 3600, ResetAt: now.Add(48 * time.Hour).Unix()},
},
RateLimitResetCredits: &OpenAIRateLimitResetCredits{AvailableCount: availableCredits},
}}
idempotencyConfig := DefaultIdempotencyConfig()
idempotencyConfig.ObserveOnly = false
service := NewOpenAIQuotaAutoResetService(repo, quota, autoResetTestRecoverer{},
NewIdempotencyCoordinator(newInMemoryIdempotencyRepo(), idempotencyConfig), nil, nil, nil)
return service, repo, quota
}
func (r *autoResetTestAccountRepo) setExtraForTest(key string, value any) {
r.mu.Lock()
defer r.mu.Unlock()
r.account.Extra[key] = value
}
func (r *autoResetTestAccountRepo) stateForTest() *OpenAIAutoResetCreditState {
r.mu.Lock()
defer r.mu.Unlock()
return openAIAutoResetStateFromExtra(r.account.Extra)
}
func TestOpenAIQuotaAutoResetService_ResetThresholdSkipsRequeryWhileNoCreditIsFresh(t *testing.T) {
now := time.Now().UTC()
service, repo, quota := newResetThresholdTestFixture(t, &OpenAIAutoResetCreditState{
Status: OpenAIAutoResetStatusNoCredit, TriggerWindow: "7d", CheckedAt: now.Format(time.RFC3339), ErrorCode: "NO_RESET_CREDIT",
}, 0)
for i := 0; i < 3; i++ {
require.NoError(t, service.evaluateAccount(context.Background(), 501))
}
require.Zero(t, quota.queryCalls.Load(), "10 分钟内已确认无卡,调度通知不应再触发上游查询")
stale := now.Add(-openAIAutoResetSnapshotTTL - time.Minute).Format(time.RFC3339)
repo.setExtraForTest(OpenAIAutoResetCreditStateExtraKey, &OpenAIAutoResetCreditState{
Status: OpenAIAutoResetStatusNoCredit, TriggerWindow: "7d", CheckedAt: stale, ErrorCode: "NO_RESET_CREDIT",
})
repo.setExtraForTest("codex_usage_updated_at", stale)
require.NoError(t, service.evaluateAccount(context.Background(), 501))
require.Equal(t, int32(1), quota.queryCalls.Load(), "无卡结论过期后应重新查询,以便发现新发放的卡")
require.Equal(t, OpenAIAutoResetStatusNoCredit, repo.stateForTest().Status)
}
func TestOpenAIQuotaAutoResetService_ResetThresholdStillQueriesWhenCreditAvailable(t *testing.T) {
now := time.Now().UTC()
service, _, quota := newResetThresholdTestFixture(t, &OpenAIAutoResetCreditState{
Status: OpenAIAutoResetStatusAvailable, AvailableCount: 1, TriggerWindow: "7d", CheckedAt: now.Format(time.RFC3339),
}, 0)
require.NoError(t, service.evaluateAccount(context.Background(), 501))
require.Equal(t, int32(1), quota.queryCalls.Load(), "有卡时到达用卡阈值必须立即查询并进入用卡流程")
}
func TestOpenAIQuotaAutoResetService_QueryFailureBacksOffBeforeRetry(t *testing.T) {
service, repo, quota := newResetThresholdTestFixture(t, nil, 0)
quota.queryErr = errors.New("upstream EOF")
require.Error(t, service.evaluateAccount(context.Background(), 501))
require.Equal(t, int32(1), quota.queryCalls.Load())
state := repo.stateForTest()
require.Equal(t, OpenAIAutoResetStatusFailed, state.Status)
require.Equal(t, "RESET_CREDIT_QUERY_FAILED", state.ErrorCode)
for i := 0; i < 3; i++ {
require.NoError(t, service.evaluateAccount(context.Background(), 501))
}
require.Equal(t, int32(1), quota.queryCalls.Load(), "查询失败后一分钟内不应重复打上游")
state.LastResultAt = time.Now().UTC().Add(-openAIAutoResetQueryFailureRetryAfter - time.Second).Format(time.RFC3339)
repo.setExtraForTest(OpenAIAutoResetCreditStateExtraKey, state)
quota.queryErr = nil
require.NoError(t, service.evaluateAccount(context.Background(), 501))
require.Equal(t, int32(2), quota.queryCalls.Load(), "重试间隔过后应恢复查询")
require.Equal(t, OpenAIAutoResetStatusNoCredit, repo.stateForTest().Status)
}
@@ -91,9 +91,18 @@ func classifyUpstreamTransportError(err error) upstreamTransportErrorClass {
return upstreamTransportErrorClass{}
}
// isClientCanceledTransportError reports whether a transport-level failure was
// caused by the client disconnecting: the request context itself is canceled
// and the round-trip aborted with context.Canceled. Such a failure says nothing
// about the upstream, so it is not recorded as an Ops upstream error event.
func isClientCanceledTransportError(ctx context.Context, err error) bool {
return errors.Is(err, context.Canceled) && ctx != nil && errors.Is(ctx.Err(), context.Canceled)
}
// handleOpenAIUpstreamTransportError handles a transport-level upstream failure
// (Do/DoWithTLS returned a non-HTTP error: proxy/DNS/TCP/TLS). It:
// 1. records the failure in Ops error logs (status 0, kind=request_error);
// 1. records the failure in Ops error logs (status 0, kind=request_error),
// except when the client disconnected (see isClientCanceledTransportError);
// 2. for durable faults (expired/rejected proxy creds, dead proxy, DNS/routing)
// temporarily unschedules the account (DB + in-memory) and logs a stable
// warn event that alert rules can key on;
@@ -106,6 +115,9 @@ func classifyUpstreamTransportError(err error) upstreamTransportErrorClass {
//
// passthrough tags the Ops error event for the OpenAI passthrough forward path.
func (s *OpenAIGatewayService) handleOpenAIUpstreamTransportError(ctx context.Context, c *gin.Context, account *Account, err error, passthrough bool) error {
if isClientCanceledTransportError(ctx, err) {
return err
}
safeErr := sanitizeUpstreamErrorMessage(err.Error())
setOpsUpstreamError(c, 0, safeErr, "")
appendOpsUpstreamError(c, OpsUpstreamErrorEvent{
@@ -1340,6 +1340,7 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
turnPrevRecoveryTried := false
lastTurnFinishedAt := time.Time{}
lastTurnResponseID := ""
lastTurnWindowID := ""
lastTurnPayload := []byte(nil)
var lastTurnStrictState *openAIWSIngressPreviousTurnStrictState
lastTurnReplayInput := []json.RawMessage(nil)
@@ -1484,8 +1485,33 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
lastTurnReplayInput, _ = stripOpenAIInvalidEncryptedContentFromReplayItems(lastTurnReplayInput, invalidDigests)
}
}
boundaryPayload, contextWindowBoundary, boundaryErr := normalizeOpenAIWSContextWindowBoundary(
currentPayload,
lastTurnWindowID,
)
if boundaryErr != nil {
return fmt.Errorf("normalize Codex websocket context-window boundary: %w", boundaryErr)
}
if contextWindowBoundary.Changed {
currentPayload = boundaryPayload
currentPayloadBytes = len(boundaryPayload)
logOpenAIWSModeInfo(
"ingress_ws_context_window_changed account_id=%d turn=%d conn_id=%s action=break_previous_response_chain previous_window_id=%s current_window_id=%s previous_response_id_removed=%v",
account.ID,
turn,
truncateOpenAIWSLogValue(sessionConnID, openAIWSIDValueMaxLen),
truncateOpenAIWSLogValue(lastTurnWindowID, openAIWSIDValueMaxLen),
truncateOpenAIWSLogValue(contextWindowBoundary.WindowID, openAIWSIDValueMaxLen),
contextWindowBoundary.PreviousResponseIDRemoved,
)
}
currentPreviousResponseID := openAIWSPayloadStringFromRaw(currentPayload, "previous_response_id")
expectedPrev := strings.TrimSpace(lastTurnResponseID)
if contextWindowBoundary.Changed {
// A context-window rollover is a new Responses root. Do not infer a
// continuation anchor from the response produced in the old window.
expectedPrev = ""
}
toolSignals := ToolContinuationSignals{
HasFunctionCallOutput: openAIWSRawPayloadHasToolCallOutput(currentPayload),
}
@@ -1822,6 +1848,9 @@ func (s *OpenAIGatewayService) ProxyResponsesWebSocketFromClient(
}
responseID := strings.TrimSpace(result.RequestID)
lastTurnResponseID = responseID
if contextWindowBoundary.WindowID != "" {
lastTurnWindowID = contextWindowBoundary.WindowID
}
// 正文共享:currentPayload/currentTurnReplayInput 均不可变,历史直接引用;
// collector 增量经 combine 合并(新头数组)。
lastTurnReplayInput = currentTurnReplayInput
@@ -144,6 +144,49 @@ func TestDropPreviousResponseIDFromRawPayload(t *testing.T) {
})
}
func TestNormalizeOpenAIWSContextWindowBoundary(t *testing.T) {
t.Parallel()
t.Run("same_window_keeps_previous_response_id", func(t *testing.T) {
payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-window-id\":\"window-a\"}}")
updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a")
require.NoError(t, err)
require.False(t, boundary.Changed)
require.False(t, boundary.PreviousResponseIDRemoved)
require.Equal(t, "window-a", boundary.WindowID)
require.Equal(t, "resp_old", gjson.GetBytes(updated, "previous_response_id").String())
})
t.Run("new_window_drops_previous_response_id", func(t *testing.T) {
payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-window-id\":\"window-b\"}}")
updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a")
require.NoError(t, err)
require.True(t, boundary.Changed)
require.True(t, boundary.PreviousResponseIDRemoved)
require.Equal(t, "window-b", boundary.WindowID)
require.False(t, gjson.GetBytes(updated, "previous_response_id").Exists())
})
t.Run("new_window_without_previous_response_id_still_marks_boundary", func(t *testing.T) {
payload := []byte("{\"type\":\"response.create\",\"client_metadata\":{\"x-codex-window-id\":\"window-b\"}}")
updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a")
require.NoError(t, err)
require.True(t, boundary.Changed)
require.False(t, boundary.PreviousResponseIDRemoved)
require.Equal(t, string(payload), string(updated))
})
t.Run("embedded_turn_metadata_is_fallback", func(t *testing.T) {
payload := []byte("{\"type\":\"response.create\",\"previous_response_id\":\"resp_old\",\"client_metadata\":{\"x-codex-turn-metadata\":\"{\\\"window_id\\\":\\\"window-b\\\"}\"}}")
updated, boundary, err := normalizeOpenAIWSContextWindowBoundary(payload, "window-a")
require.NoError(t, err)
require.True(t, boundary.Changed)
require.True(t, boundary.PreviousResponseIDRemoved)
require.Equal(t, "window-b", boundary.WindowID)
require.False(t, gjson.GetBytes(updated, "previous_response_id").Exists())
})
}
func TestStripCodexSparkImageGenerationToolFromRawPayload(t *testing.T) {
t.Run("strips_image_generation_for_spark", func(t *testing.T) {
payload := []byte(`{"type":"response.create","model":"gpt-5.3-codex-spark","tools":[{"type":"function","name":"shell"},{"type":"image_generation","output_format":"png"}]}`)
@@ -378,6 +378,48 @@ func setPreviousResponseIDToRawPayload(payload []byte, previousResponseID string
return rebuilt, nil
}
type openAIWSContextWindowBoundary struct {
WindowID string
Changed bool
PreviousResponseIDRemoved bool
}
func openAIWSPayloadCodexWindowID(payload []byte) string {
if len(payload) == 0 {
return ""
}
if windowID := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-window-id").String()); windowID != "" {
return windowID
}
turnMetadata := strings.TrimSpace(gjson.GetBytes(payload, "client_metadata.x-codex-turn-metadata").String())
if turnMetadata == "" {
return ""
}
return strings.TrimSpace(gjson.Get(turnMetadata, "window_id").String())
}
// normalizeOpenAIWSContextWindowBoundary breaks a Responses continuation chain
// when Codex moves to a new local context window. WebSocket response.create can
// still carry the previous window's previous_response_id after new_context,
// while HTTP starts the new window without that continuation anchor.
func normalizeOpenAIWSContextWindowBoundary(
payload []byte,
previousWindowID string,
) ([]byte, openAIWSContextWindowBoundary, error) {
currentWindowID := openAIWSPayloadCodexWindowID(payload)
boundary := openAIWSContextWindowBoundary{WindowID: currentWindowID}
if previousWindowID == "" || currentWindowID == "" || currentWindowID == previousWindowID {
return payload, boundary, nil
}
boundary.Changed = true
updated, removed, err := dropPreviousResponseIDFromRawPayload(payload)
if err != nil {
return payload, boundary, err
}
boundary.PreviousResponseIDRemoved = removed
return updated, boundary, nil
}
func shouldInferIngressFunctionCallOutputPreviousResponseID(
storeDisabled bool,
turn int,
-11
View File
@@ -495,10 +495,6 @@ func writeConfigFile(cfg *SetupConfig) error {
APIKeyPrefix string `yaml:"api_key_prefix"`
RateMultiplier float64 `yaml:"rate_multiplier"`
} `yaml:"default"`
RateLimit struct {
RequestsPerMinute int `yaml:"requests_per_minute"`
BurstSize int `yaml:"burst_size"`
} `yaml:"rate_limit"`
Timezone string `yaml:"timezone"`
}{
Server: cfg.Server,
@@ -522,13 +518,6 @@ func writeConfigFile(cfg *SetupConfig) error {
APIKeyPrefix: "sk-",
RateMultiplier: 1.0,
},
RateLimit: struct {
RequestsPerMinute int `yaml:"requests_per_minute"`
BurstSize int `yaml:"burst_size"`
}{
RequestsPerMinute: 60,
BurstSize: 10,
},
Timezone: tz,
}
+43
View File
@@ -11,6 +11,7 @@ import (
"github.com/DATA-DOG/go-sqlmock"
"github.com/lib/pq"
"gopkg.in/yaml.v3"
)
func TestDecideAdminBootstrap(t *testing.T) {
@@ -231,6 +232,48 @@ func TestWriteConfigFileIncludesRedisUsername(t *testing.T) {
}
}
func TestWriteConfigFileOmitsObsoleteRateLimit(t *testing.T) {
t.Setenv("DATA_DIR", t.TempDir())
if err := writeConfigFile(&SetupConfig{
Redis: RedisConfig{Host: "redis", Port: 6379, Username: "app-user"},
Timezone: "UTC",
}); err != nil {
t.Fatalf("writeConfigFile() error = %v", err)
}
data, err := os.ReadFile(GetConfigFilePath())
if err != nil {
t.Fatalf("ReadFile() error = %v", err)
}
var config map[string]any
if err := yaml.Unmarshal(data, &config); err != nil {
t.Fatalf("generated config is invalid YAML: %v", err)
}
if rateLimit, exists := config["rate_limit"]; exists {
fields, ok := rateLimit.(map[string]any)
if !ok {
t.Fatalf("generated rate_limit must be a mapping: %#v", rateLimit)
}
for _, key := range []string{"requests_per_minute", "burst_size"} {
if _, present := fields[key]; present {
t.Fatalf("generated config contains obsolete rate_limit.%s: %s", key, data)
}
}
}
redis, ok := config["redis"].(map[string]any)
if !ok || redis["host"] != "redis" || redis["port"] != 6379 || redis["username"] != "app-user" {
t.Fatalf("generated config lost Redis settings: %#v", config["redis"])
}
if config["timezone"] != "UTC" {
t.Fatalf("generated config timezone = %#v, want UTC", config["timezone"])
}
defaults, ok := config["default"].(map[string]any)
if !ok || defaults["user_concurrency"] != defaultUserConcurrency || defaults["api_key_prefix"] != "sk-" {
t.Fatalf("generated config lost default settings: %#v", config["default"])
}
}
func TestDatabaseConnectionDSNsUseConfiguredTargetAndLegacyBootstrapDatabase(t *testing.T) {
cfg := &DatabaseConfig{
Host: "db",
+11 -11
View File
@@ -147,17 +147,17 @@ services:
restart: unless-stopped
volumes:
- ./redis_data:/data:Z
# The command is one quoted script for the inner `sh -c`. Compose keeps
# the newlines inside the quoted string, so every line needs a trailing
# `\` — without it, `redis-server` on the first line runs with no flags
# at all, and the --save/--appendonly/--appendfsync lines are never read.
command: >
sh -c '
redis-server \
--save 60 1 \
--appendonly yes \
--appendfsync everysec \
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
command:
- redis-server
- --save
- "60"
- "1"
- --appendonly
- "yes"
- --appendfsync
- everysec
- --requirepass
- ${REDIS_PASSWORD:-}
environment:
- TZ=${TZ:-Asia/Shanghai}
- REDISCLI_AUTH=${REDIS_PASSWORD:-}
+11 -11
View File
@@ -264,17 +264,17 @@ services:
volumes:
# Local directory mapping for easy migration
- ./redis_data:/data:Z
# The command is one quoted script for the inner `sh -c`. Compose keeps
# the newlines inside the quoted string, so every line needs a trailing
# `\` — without it, `redis-server` on the first line runs with no flags
# at all, and the --save/--appendonly/--appendfsync lines are never read.
command: >
sh -c '
redis-server \
--save 60 1 \
--appendonly yes \
--appendfsync everysec \
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
command:
- redis-server
- --save
- "60"
- "1"
- --appendonly
- "yes"
- --appendfsync
- everysec
- --requirepass
- ${REDIS_PASSWORD:-}
environment:
- TZ=${TZ:-Asia/Shanghai}
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
+11 -11
View File
@@ -287,17 +287,17 @@ services:
hard: 100000
volumes:
- redis_data:/data
# The command is one quoted script for the inner `sh -c`. Compose keeps
# the newlines inside the quoted string, so every line needs a trailing
# `\` — without it, `redis-server` on the first line runs with no flags
# at all, and the --save/--appendonly/--appendfsync lines are never read.
command: >
sh -c '
redis-server \
--save 60 1 \
--appendonly yes \
--appendfsync everysec \
${REDIS_PASSWORD:+--requirepass "$REDIS_PASSWORD"}'
command:
- redis-server
- --save
- "60"
- "1"
- --appendonly
- "yes"
- --appendfsync
- everysec
- --requirepass
- ${REDIS_PASSWORD:-}
environment:
- TZ=${TZ:-Asia/Shanghai}
# REDISCLI_AUTH is used by redis-cli for authentication (safer than -a flag)
+3
View File
@@ -73,6 +73,9 @@ export interface ModelPlazaGroup {
/** 生图独立倍率:true 时图片计费模型的实付倍率取 image_rate_multiplier,不取分组/专属倍率。 */
image_rate_independent: boolean
image_rate_multiplier: number
/** 视频独立倍率开启时,覆盖视频模型的分组/用户专属倍率。 */
video_rate_independent: boolean
video_rate_multiplier: number
/** 分组是否启用长上下文阶梯计费;false 时实付列只展示最低档,官方阶梯仅供参考。 */
long_context_pricing_enabled: boolean
models: PlazaModel[]
@@ -188,13 +188,11 @@ const shouldShowResetTime = computed(() => {
// Format reset time
const formatResetTime = computed(() => {
// For rolling windows, when utilization is 0%, treat as immediately available.
if (props.showNowWhenIdle && props.utilization <= 0) {
return t('usage.resetNow')
if (!props.resetsAt) {
// Idle rolling window without a known reset time: treat as immediately available.
return props.showNowWhenIdle && props.utilization <= 0 ? t('usage.resetNow') : '-'
}
if (!props.resetsAt) return '-'
const date = new Date(props.resetsAt)
const diffMs = date.getTime() - now.value.getTime()
@@ -22,7 +22,7 @@ describe('UsageProgressBar', () => {
vi.useRealTimers()
})
it('showNowWhenIdle=true 且利用率为 0 时显示“现在”', () => {
it('showNowWhenIdle=true 且利用率为 0 但有未来 resetsAt 时显示倒计时', () => {
const wrapper = mount(UsageProgressBar, {
props: {
label: '5h',
@@ -33,8 +33,8 @@ describe('UsageProgressBar', () => {
}
})
expect(wrapper.text()).toContain('usage.resetNow')
expect(wrapper.text()).not.toContain('2h 30m')
expect(wrapper.text()).toContain('2h 30m')
expect(wrapper.text()).not.toContain('usage.resetNow')
})
it('showNowWhenIdle=true 但利用率大于 0 时显示倒计时', () => {

Some files were not shown because too many files have changed in this diff Show More