Compare commits

...
55 Commits
Author SHA1 Message Date
Wesley Liddick 8f2cbb53d9 Merge pull request #8177 from feeeei/fix/gemini-negative-input-tokens
fix(gemini): prompt 被拦截只报缓存命中时 input_tokens 不再为负
2026-10-19 20:45:15 +08:00
Wesley Liddick 0fa02816af Merge pull request #8168 from feeeei/fix/gemini-stream-client-disconnect-billing
fix(gemini): 流式中途客户端断开时按已收集用量计费
2026-10-19 20:45:02 +08:00
Wesley Liddick 378984d87f Merge pull request #8223 from Wei-Shaw/fix/async-image-download-policy
fix(images): 异步生图转存的图片下载与同步回填策略对齐
2026-10-19 18:33:04 +08:00
shaw ab71e12ec4 fix(images): 异步生图转存的图片下载与同步回填策略对齐
异步生图任务把上游返回的图片 url 下载后转存对象存储,此前沿用默认 http.Client,
与同步 Images 端点 url 回填(c227863d)的下载策略不一致。

- 下载目的地为 localhost、回环、私网、链路本地、CGNAT 等非公网地址时不下载,
  重定向的每一跳同样校验;直连时在 socket 层按实际连接 IP 判断
- 继续支持环境变量代理:经代理时先校验目标主机的解析结果
- 响应声明类型与字节嗅探都不是图片时任务失败,不再按 png 落盘
- 下载客户端进程内共享,复用连接池;超时与大小上限不变
2026-10-19 17:51:18 +08:00
feeeei 9c41b32695 fix(gemini): prompt 被拦截只报缓存命中时 input_tokens 不再为负
- Vertex 拦截 prompt(promptFeedback.blockReason,如 PROHIBITED_CONTENT)时 usageMetadata
  可能只带 cachedContentTokenCount、缺 promptTokenCount,promptTokenCount − cachedContentTokenCount
  得到负的 input_tokens 与负费用
- extractGeminiUsage 与 antigravity 非流式 / 流式(累计用量、message_start)四处换算改为差值下限 0,
  缓存命中仍照上游所报计入 cache_read_input_tokens
- 补充表测试、原生流式拦截用例与 antigravity 两条路径用例
2026-10-17 19:09:23 +08:00
feeeei 2b1f129385 fix(gemini): 流式中途客户端断开时按已收集用量计费
- 原生 /v1beta、Claude 兼容 /v1/messages、Chat 兼容三条流式路径:读取中断由客户端
  断开引起(context.Canceled 或请求 context 已取消),且已观测到 token 用量或已回吐
  图片时,以已收集结果收尾并返回成功,handler 照常提交 usage;ForwardResult 标记
  ClientDisconnect
- 读到结束分片后断开按完整用量计费;中途断开按断开前最后一份 usageMetadata 计费,
  断开前无任何用量时仍不计费
- 图片数:收到 finishReason 前断开只计已送达的图片(原生按已观测 inlineData,Claude/
  Chat 兼容流不转发图片计 0);收到 finishReason 后断开与正常完成同口径
- Chat 兼容流写失败断开的既有返回同样带上断开标记与结束状态
- 原生路径读错误时不处理未以换行结尾的残行,避免截断的 usageMetadata 覆盖已收集用量
- 上游 context 不解绑:Gemini 兼容链路没有流空闲超时,解绑后上游挂起会让读取无限期阻塞
- 客户端未断开时的上游读错误、非流式客户端断开、Antigravity 非流式转换的读错误
  行为不变
2026-10-17 01:00:51 +08:00
github-actions[bot] b0a02a3304 chore: sync VERSION to 0.2.17 [skip ci] 2026-10-16 04:42:02 +00:00
Wesley Liddick 060d016041 Merge pull request #8140 from Wei-Shaw/fix/issue-8118-responses-created-at
fix(responses): 网关送出的 Responses 对象一律带 created_at,修复 grok-build 反序列化失败
2026-10-16 12:28:11 +08:00
Wesley Liddick b14f316ca2 Merge pull request #8080 from SipengXie2024/fix/openai-messages-web-search-emulation
fix(openai): emulate web_search on /v1/messages for OpenAI API key accounts
2026-10-16 12:27:52 +08:00
Wesley Liddick 43157c478e Merge pull request #8121 from Larry-Labs/fix/gpt55-subscription-retirement
fix(openai): respect GPT-5.5 retirement on ChatGPT upstreams
2026-10-16 12:27:39 +08:00
shaw 71e6b6a971 test(responses): 模型改写转发测试夹具补齐上游 created_at
网关现会为缺 created_at 的 Response 对象补齐该字段(#8118),该测试以
"输出包含上游原始字节"断言模型改写,夹具缺 created_at 时输出多出补齐字段
而失配。夹具改为真实上游必带 created_at 的形态,测试意图(仅改写 model)不变。
2026-10-16 11:38:13 +08:00
shaw f2e5b55f3e test(responses): 模型改写转发测试夹具补齐上游 created_at
网关现会为缺 created_at 的 Response 对象补齐该字段(#8118),该测试以
"输出包含上游原始字节"断言模型改写,夹具缺 created_at 时输出多出补齐字段
而失配。夹具改为真实上游必带 created_at 的形态,测试意图(仅改写 model)不变。
2026-10-16 11:37:24 +08:00
shaw bdfe215cbb fix(responses): 网关送出的 Responses 对象一律带 created_at,修复 grok-build 反序列化失败
grok-build / Codex 等 Rust serde 客户端把 Response.created_at 与事件的
sequence_number 当必填字段,缺失即 `missing field 'created_at'` 整轮读不出响应。
#5601 只补了 apicompat 两座桥的合成路径,以下出口仍会送出缺字段的对象:

- 合成的 response.failed:buildOpenAIResponseFailedSSE(主流 + 透传)与
  buildOpenAIWSHTTPBridgeFailedEvent 在流以裸 error 结束时补发终止帧,
  缺 created_at,前者还缺 sequence_number。现统一写出:created_at 取本流
  上游值(无则当前时间),sequence_number 沿用被替换的裸 error 帧序号(无则 0)。
- 上游原样透出:Grok 账号挂第三方中转时,上游 Response 对象可能不带
  created_at,网关逐字节转发后客户端同样失败。现于 Responses 流式出口、
  WS HTTP 桥(Grok WS 恒走此路)与非流式 JSON 出口,对缺失/null 的
  created_at 补一个整条流固定的时间戳;上游已带时输出逐字节不变。

透传路径保持不改写上游事件,只观察 created_at 供合成终止帧对齐。

Fixes #8118
2026-10-16 11:22:48 +08:00
Wesley Liddick 455d317d6a Merge pull request #8114 from feeeei/feat/gemini-compat-thinking
feat(gemini): 兼容入口转发思考档位并流式返回思考摘要
2026-10-16 10:53:56 +08:00
shaw a431e53f9e Merge remote-tracking branch 'origin/main' into pr-8114
# Conflicts:
#	backend/internal/service/gemini_chat_completions_compat_service.go
2026-10-16 10:09:46 +08:00
Wesley Liddick b8cfb78840 Merge pull request #8133 from Wei-Shaw/fix/easypay-query-confirm
fix(payment): fix EasyPay order queries and confirm notifications before crediting
2026-10-16 10:06:43 +08:00
shaw 1b92038eaf fix(payment): fix EasyPay order queries and confirm notifications before crediting
Order query:
- 彩虹易支付 and ZPay read act=order parameters from the URL query only;
  a body-only request is answered with "No Act!", so order status queries
  (reconcile, payment status checks) never reached the order. Parameters
  are now sent in the URL query and mirrored in the form body for forks
  that read the body.
- Decode status/money/trade_no leniently: PHP deployments commonly return
  integers as strings ("status":"1"), which previously failed the whole
  response decode. JSON null falls back to the nested data object.
- Treat a non-success code as a query error instead of an unpaid order,
  and require an echoed out_trade_no to match the queried order.
- Strip the request URL (which carries the merchant key) from transport
  errors before they are returned.

Notifications:
- Add payment.NotificationConfirmer. After a notification is verified, the
  webhook asks the same provider instance to confirm it; EasyPay queries
  the upstream order and requires it to be paid with a matching amount and
  trade_no (a missing notify trade_no is filled from the query). Failures
  are recorded as PAYMENT_UPSTREAM_UNCONFIRMED and answered with 502 so the
  gateway retries; the order stays pending and reconcile still applies.
- EasyPay notifications must carry the instance's configured pid, and
  field values other than name must be plain tokens.
- Return URLs must use a plain hostname or IP literal without userinfo.
2026-10-16 09:39:55 +08:00
Wesley Liddick 52b3ca9609 Merge pull request #8122 from Wei-Shaw/fix/payment-order-type-refund-hold
fix(payment): validate order types, keep refund deductions while pending, load Stripe secret from local snapshot
2026-10-16 08:32:48 +08:00
xugao 86f8443045 fix(openai): evaluate retirement after public alias projection 2026-10-15 22:42:45 +08:00
shaw f37b69b779 fix(payment): treat Alipay refund query errors as pending and delay not-found failure
- The SDK returns signed business-failure responses (for example
  ACQ.SYSTEM_ERROR) with a nil error and empty fields. QueryRefund now
  checks the response code and returns an error, so the order stays
  REFUND_PENDING instead of being read as "no refund record" and failed.
- A missing refund record is treated as failed only after a 10 minute
  grace period from when the refund became pending. Alipay notes that a
  query sent too soon can miss a refund that later succeeds. The pending
  time comes from the REFUND_PENDING audit entry.
2026-10-15 22:26:53 +08:00
shaw 83337a0c65 fix(payment): query Alipay refund status and close unsupported order types
- Alipay: record the generated out_request_no as the refund ID and
  implement QueryRefund (alipay.trade.fastpay.refund.query), so refunds
  that return fund_change=N can be finalized from the admin refund query.
  Refunds recorded before this change return REFUND_QUERY_UNSUPPORTED.
- Fulfillment: mark paid orders with an unsupported order type as FAILED
  with an audit entry instead of erroring, so payment callbacks are
  acknowledged; RetryFulfillment rejects them with INVALID_ORDER_TYPE.
2026-10-15 22:03:15 +08:00
shaw 44e35fbf84 fix(payment): finalize failed pending refunds atomically
- Claim REFUND_PENDING -> REFUND_FAILED, return the held deduction and
  write the audit in one transaction. If the deduction cannot be
  returned, the order is still marked failed together with a
  REFUND_ROLLBACK_FAILED audit so a retried refund skips the deduction.
- Return an error when the REFUND_PENDING audit cannot be loaded or
  parsed instead of falling back to the legacy finalization path.
2026-10-15 21:38:57 +08:00
harukaandClaude Opus 5.5 f996b0bfaa fix(payment): load Stripe client secret from the local order snapshot
The /payment/stripe route no longer carries client_secret in its query.
The page reads it from the recovery snapshot written when the order was
created and requires the snapshot order to match order_id, consistent
with AirwallexPaymentView.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:38:52 +08:00
harukaandClaude Opus 5.5 7c6f70680d fix(payment): validate order types and keep refund deductions while pending
- Validate order_type (balance/subscription) at order creation and in
  fulfillment; other values are rejected with INVALID_ORDER_TYPE.
- Keep the balance/subscription deduction while a gateway refund is
  pending. The deduction is recorded in the REFUND_PENDING audit (written
  in the same transaction as the status change), not applied again on
  successful finalization, and returned once if the gateway reports
  failure. Legacy pending audits keep the previous behaviour.
- REFUND_PENDING orders are finalized via refund query only;
  PrepareRefund/ExecuteRefund no longer accept them.
- Invalidate the billing balance cache after refund deductions/returns.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 21:38:48 +08:00
Wesley Liddick 1b2243a4f5 Merge pull request #8120 from Wei-Shaw/revert/inline-account-filters
revert(accounts): restore inline account filters (#7853)
2026-10-15 21:01:40 +08:00
xugao 26aab6dc07 fix(openai): respect GPT-5.5 retirement on ChatGPT upstreams 2026-10-15 20:53:15 +08:00
shaw c2cca510f2 revert(accounts): restore inline account filters (#7853)
Revert 679a7186f ("feat(accounts): compact account search and filters"),
which moved the type / privacy / group filters behind a collapsed
"More filters" panel on /admin/accounts.

Polish on top of the revert:
- Widen the privacy select (w-48) so "全部Privacy状态" / "All Privacy States"
  no longer truncates.
- Narrow the search input to w-52 so all six controls fit on one row at 1440px.
- Below sm, lay the filters out as a two-column grid that fills the width
  instead of fixed-width selects with a ragged edge.
2026-10-15 20:44:29 +08:00
Wesley Liddick 698d142468 Merge pull request #8088 from wucm667/fix/issue-8077-workspace-aware-import
fix(openai): isolate Codex imports by workspace
2026-10-15 16:40:38 +08:00
Wesley Liddick ddef05527e Merge pull request #8079 from wucm667/fix/issue-8074-anthropic-base-url
fix(gateway): honor Anthropic protocol base URLs.
2026-10-15 16:40:19 +08:00
Wesley Liddick 58b5e858c8 Merge pull request #8094 from wucm667/feat/issue-8092-configurable-min-output
feat(apicompat): configure minimum output token floor
2026-10-15 16:40:06 +08:00
feeeei 64211de8de feat(gemini): 兼容入口转发思考档位并流式返回思考摘要
- chat/messages 兼容入口把 reasoning_effort、thinking、output_config.effort
  映射为 Gemini thinkingConfig:2.5 用 thinkingBudget,3 及以上用 thinkingLevel;
  minimal 只发给 flash 系列,medium 只发给 flash 系列与 3.1 Pro,其余升为 high;
  图像/语音/向量与无法识别版本的型号、带档位后缀的型号不写档位
- 流式请求开启 includeThoughts(messages 需客户端开启 thinking),thought part
  转为 Anthropic thinking 块,chat 侧输出为 reasoning_content
- 历史中的 thinking 块与 chat 回传的推理内容不再发给 Gemini;签名降级重试先删
  历史思考块再降级,思考参数沿用客户端原始请求
- messages 兼容路径记录实际转发的思考档位,与 chat 兼容路径一致
2026-10-15 16:26:46 +08:00
Wesley Liddick 857a76d4a0 Merge pull request #8093 from baryon/contrib/gemini-4-argon-support
feat(antigravity): support Gemini 4 Argon and accurate upstream model catalogs
2026-10-15 16:13:07 +08:00
shaw 9d3cc4507f fix(antigravity): keep legacy default passthroughs and bill Claude 5.5 tier IDs at base rates
- Restore runtime default passthroughs and Gemini 3.1 Pro aliases for
  explicit Antigravity model_mapping. Mappings saved before the Gemini
  3.x Flash / 3.1 Pro releases still depend on them; dropping them would
  silently remove those models from existing accounts on upgrade.
- Capability sync now skips injected-only passthrough targets, so models
  the upstream catalog omits no longer mark the sync as partial.
- Price Antigravity claude-{opus,sonnet}-5-5-{high,medium,low} at the base
  Opus/Sonnet 5.5 card instead of substring-matching claude-opus-5 /
  claude-sonnet-4-5 (1.25x-3x overbilling).
2026-10-15 15:47:42 +08:00
shaw 8afd2abd97 Merge remote-tracking branch 'origin/main' into contrib/gemini-4-argon-support
Resolve Gemini 4 Argon pricing conflicts in favor of main (#8078), which
already provides the base and Cyber Permissive price cards, thinking-tier
pricing aliases, and the local pricing snapshot.
2026-10-15 15:37:14 +08:00
Wesley Liddick dcde68aa58 Merge pull request #8078 from feeeei/feat/gemini-4-argon-pricing
feat(gemini): 补齐 Gemini 4 Argon 计费
2026-10-15 15:27:15 +08:00
Wesley Liddick abdc82725f Merge pull request #8076 from feeeei/fix/gemini-vertex-sa-and-error-policy
fix(gemini): Vertex SA 已删除时停用账号并换号,401 临时不可调度规则首次命中不再永久停用
2026-10-15 15:27:01 +08:00
Wesley Liddick 3b83e045ed Merge pull request #8060 from feeeei/fix/gemini-vertex-shared-capacity-429
fix(gemini): Vertex 共享容量不足的 429 不冷却账号,改为同号退避重试
2026-10-15 15:26:51 +08:00
Wesley Liddick fdc7f1fa01 Merge pull request #8087 from coder-zhw/fix/zhipu-credit-limit-window-classification
fix(zhipu): 修正 GLM Coding Plan 5h/weekly 用量窗口标反 / fix swapped GLM coding plan usage windows
2026-10-15 15:26:38 +08:00
Wesley Liddick 3856a02856 Merge pull request #8106 from Wei-Shaw/fix/composite-oauth-ambiguous-misroute
fix(gateway): 拒绝含 OpenAI 网关 OAuth 的 composite 歧义路由,防止凭据误入 Anthropic 管线
2026-10-15 15:08:47 +08:00
shaw d19265ddb2 fix(gateway): 拒绝含 OpenAI 网关 OAuth 的 composite 歧义路由,防止凭据误入 Anthropic 管线
composite 分组中 openai OAuth 账号与 deepseek API Key 账号同时显式映射同一
gpt 模型时,模型归属判为歧义、路由层不解析平台,请求落到默认 Anthropic
GatewayHandler;handler 再按模型名前缀猜回 openai 并选中 OpenAI OAuth 账号,
ChatGPT token 被发往 api.anthropic.com,上游 401 "Invalid bearer token"
经 failover 逐号打成临时不可调度,单请求即可扫空号池。

- 归属记录声明方是否含 OpenAI 网关族 OAuth / setup-token;当模型名兜底也
  落在 OpenAI 网关族时判为配置错误,路由中间件与 WS 入口直接 400 拒绝并
  提示配置显式路由。纯 API Key 歧义及 Anthropic/Antigravity/Gemini OAuth
  歧义保持原行为。
- 模型归属改按配置计算(ListModelAvailabilityCandidates),不再随限流 /
  临时不可调度在「唯一归属」与「歧义」之间翻转。
- Anthropic 请求构建前校验凭据:非 Anthropic 网关族的 OAuth 凭据不出站,
  返回仅换号、不处罚账号的 failover;count_tokens 返回 404 不支持。
  API Key 不受限(上游可能兼容多种协议格式)。
2026-10-15 14:57:03 +08:00
coder-zhw 1774b79dcb fix(zhipu): 修正 GLM Coding Plan 5h/weekly 用量窗口标反
parseZhipuTokenTiers 只在 limitType == "TOKENS_LIMIT" 分支里按 unit 显式分类,
而智谱当前把 5h / weekly 两条窗口都标成 CREDIT_LIMIT(level=lite)。这些条目被放进
creditFallback,entry 结构体又不带 unit,信息在此永久丢失,降级路径退化为
「按 nextResetTime 升序填空位」。当周窗口先于 5h 重置时(每个周期临尾的几小时),
周窗口会被先填进 5h 槽,两档必然标反 —— 函数文档注释第 1 条已警告过该场景,
只是防线建在了错误的 type 分支上。

因为只在「CREDIT_LIMIT 且周窗口更早重置」这一种组合下出错,症状是间歇性的,
容易被误判为随机抽风。该函数自 v0.2.0 到 v0.2.16 逐字节未变,不属于版本回归。

改动:
- entry 增加 unit 字段;classify 改为直接接收 unit,不再依赖 item。
- 仅在降级路径(无任何 TOKENS_LIMIT 条目)上,对 creditFallback 中 unit 可识别的
  条目先按 unit 分类;unit 仍不可识别的才落入原有的 reset 时间启发式。
- TOKENS_LIMIT 优先占槽的既有语义保持不变:两种 type 同时返回时 CREDIT_LIMIT
  依旧不参与槽位竞争。
- 更新 cnZhipuWindow 与 parseZhipuTokenTiers 的文档注释。
- cn_providers_test.go 补充 3 个回归用例。

验证:
- 反向验证:仅回退源文件保留测试,CreditLimitUnitClassification 失败于
  14 vs 90(两档被对调);另两个钉住原语义的用例修复前后均通过。
- go build ./... 通过;gofmt -l 无输出;golangci-lint v2.14.0 0 issues。
- go test -tags=unit ./internal/service/ 全量通过。

影响范围:backend/internal/service 两个文件(service + unit test)。
2026-10-15 14:56:00 +08:00
shaw 92c9433d40 chore: update sponsors 2026-10-15 08:53:20 +08:00
Long Li feab6d80dc fix(antigravity): preserve tool_result images, native Gemini functionCall/thoughtSignature, and composite /v1beta routing 2026-10-15 06:55:32 +09:00
wucm667 2a8cb088af feat(apicompat): configure minimum output token floor 2026-10-15 01:56:00 +08:00
Long Li 373d43533a feat(antigravity): support Gemini 4 Argon and accurate upstream model catalogs 2026-10-15 02:06:23 +09:00
wucm667 16611a8ee0 fix(openai): isolate Codex imports by workspace 2026-10-14 18:34:52 +08:00
SipengXie2024 7de15113c7 fix(openai): exclude OAuth accounts from web search emulation
Keep OpenAI non-API-key accounts on native search before channel inheritance. Cover 72 combinations of account types, modes, channel flags and global enablement in the shared interception gate.

Validation: the regression test fails in six OAuth/setup-token channel-enabled cases on the original code and passes with this guard. Related WebSearch unit tests pass. The broad service/handler test run was blocked by automatic approval review due to an unrelated daily-cloudcode-pa.googleapis.com request with unverified payload/credential use.
2026-10-14 16:44:53 +08:00
SipengXie2024 26e55cf332 fix(openai): emulate web_search on /v1/messages for OpenAI API Key accounts
Chat Completions conversion drops Anthropic web_search tools, so Claude
Code's WebSearch on OpenAI-platform groups (e.g. DeepSeek upstreams)
got no web_search_tool_result and reported 0 results. Run the existing
search emulation in ForwardAsAnthropic and let OpenAI API Key accounts
opt in via extra.web_search_emulation.
2026-10-14 07:02:52 +00:00
wucm667 867563ae76 fix(gateway): honor Anthropic protocol base URLs 2026-10-14 14:58:26 +08:00
feeeei c6c9935ae3 feat(gemini): 补齐 Gemini 4 Argon 计费
- 内置兜底价:gemini-4-argon 为 $2 输入 / $10 输出 / $0.10 缓存读取(每百万
  token,优惠价有效期至 2026-11-30,2026-12-01 起为 $4 / $20 / $0.20);
  Cyber 变体(gemini-4-argon-cyber-permissive)不参与优惠,为 $4 / $20 / $0.20,
  匹配时先于基础型号判断
- Antigravity 的 -low / -medium / -high 档位名归一到基础价
- 本地价格快照新增两个不带前缀的条目,含 Priority 档价格;所有提示词长度同价,
  不带长上下文阶梯;只支持隐式缓存,不带存储价
2026-10-14 14:22:17 +08:00
feeeei 14cd587175 fix(gemini): 每个上游错误响应只检查一次错误策略
- 三条 Gemini 转发路径在重试循环内和循环外各对最终上游错误响应检查一次错误策略。
  配了账号级 401 临时不可调度规则的 API Key / Vertex 账号首次收到 401 时,第一次
  检查写入 401 暂停记录,第二次检查读回该记录判定为重复 401,同一请求内即升级为
  error 永久停用
- 循环内的检查结果带到循环外复用;循环内未检查的最终响应(Claude 兼容层的签名 400
  分支、原生路径的 cachedContent 透传分支)仍在循环外检查
2026-10-14 12:00:44 +08:00
feeeei 20c4bf5ae5 fix(gemini): Vertex SA 已不存在时账号置为 error 并换号
- 换取 SA access token 时 token 端点返回 400
  {"error":"invalid_grant","error_description":"Invalid grant: account not found"}
  表示服务账号已被删除,凭据再也换不到 token;此前三条 Gemini 转发路径把它当作
  本地构造请求失败直接回 502,不换号也不改账号状态,账号持续被调度
- 识别该错误后账号置为 error 并停止调度(Service account token rejected (400): …),
  记录凭据获取阶段(stage=account_auth)的 Ops 事件,请求交给 handler 换号
- 只匹配 400 + invalid_grant + account not found:invalid_grant 还涵盖时钟偏差、
  签名无效等情况,其他换 token 失败保持原行为
- token 端点地址改为包级变量,测试中替换为本地服务
2026-10-14 11:58:37 +08:00
shaw 9c2cca9e62 chore: update sponsors 2026-10-14 11:30:36 +08:00
feeeei 89ad4c2caa fix(gemini): Vertex 共享容量不足的 429 不冷却账号,改为同号退避重试
- Vertex 按量付费返回的 "Resource exhausted ... try again later"
  (RESOURCE_EXHAUSTED、无错误明细)表示共享资源池当时没有容量,与账号额度
  无关;此前一律冷却账号 1 分钟,冷却期间账号被调度快照剔除,绑定显式缓存
  的请求因无法换号直接 503
- 这类 429 只记日志、账号保持可调度,由请求内同号 1/2/4/8s 退避重试消化,
  重试用尽后未绑定缓存的请求照常换号
- 带 retryDelay、文案重试时长、配额/预留吞吐字样或其他形态的 429 仍按账号
  自身限速冷却;非 Vertex 账号行为不变
2026-10-13 23:51:43 +08:00
github-actions[bot] 4d9ac3e482 chore: sync VERSION to 0.2.16 [skip ci] 2026-10-13 07:20:40 +00:00
162 changed files with 7849 additions and 855 deletions
+7 -24
View File
@@ -54,8 +54,8 @@ Please read the following carefully before using this project:
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
<td width="180"><a href="https://api.aigocode.dev/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://api.aigocode.dev/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
@@ -63,11 +63,6 @@ Please read the following carefully before using this project:
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)</td>
</tr>
<tr>
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
<td>Thanks to Bestproxy for sponsoring this project! <a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> provides high-purity residential IPs with dedicated one-IP-per-account support. By combining real home networks with fingerprint isolation, it enables link environment isolation and reduces the probability of association-based risk control.</td>
@@ -96,18 +91,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>Thanks to Aimzoon for sponsoring this project! <a href="http://aimzoon.com">Aimzoon</a> provides stable, cost-effective AI API access services, enabling developers to quickly connect popular AI services to coding tools such as Codex, Claude Code, and Gemini CLI. No complex configuration — faster onboarding, more stable calls, and lower costs. Ongoing promotions including discounted Codex rates and special pricing, with free trial credits upon registration, bringing AI coding into your daily workflow. <a href="http://aimzoon.com">Click here</a> to register and try it out!
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
@@ -143,11 +126,6 @@ Please read the following carefully before using this project:
<td>Swiftproxy is a high-performance proxy solution built for developers, providing stable and reliable residential and static residential proxy services. With 90M+ clean residential IPs, global coverage, flexible rotation, and precise geo-targeting, it helps projects such as web scraping, AI automation, browser automation, SEO monitoring, and multi-account management overcome access restrictions and improve workflow efficiency. It supports HTTP(S) and SOCKS5 protocols, integrates with popular automation tools like Playwright, Selenium, and Puppeteer, with dynamic proxy traffic that never expires until used and free testing available — <a href="https://www.swiftproxy.net/?ref=sub2api">start your free test now</a>!</td>
</tr>
<tr>
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial.</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
@@ -168,6 +146,11 @@ Please read the following carefully before using this project:
<td>ColaProxy provides high-quality residential proxies built for web scraping, automation, and multi-account management. Get a free trial with non-expiring traffic, pricing starting as low as $0.3/GB, unlimited concurrent connections, and intelligent IP rotation for a smoother, more stable proxy experience. Use discount code COLA10 to get 10% off and start scaling your projects with reliable residential proxies today. <a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">Get started with ColaProxy</a></td>
</tr>
<tr>
<td width="180"><a href="https://modelflare.dev/sign-up?utm_source=github&utm_campaign=sub2api"><img src="assets/partners/logos/modelflare.png" alt="Modelflare" width="150"></a></td>
<td>Modelflare: Full strength, stable, nothing watered down. Global SOTA models at a lower cost. Operated by US-based Havenbyte LLC, Modelflare supports GPT, Claude, Gemini, Grok, and other leading models through common compatible protocols, plus 40+ image and video models. Cache compensation is a highlight: selected OpenAI groups (price-first, stable, and premium) guarantee daily cache hit rates of 65%, 75%, or 85%, and if eligible requests fall short, Modelflare refunds the difference, visible on the usage dashboard after settlement. Special offers: a single top-up of US$20 unlocks the GPT discount group at a 0.015x price multiplier, and a single top-up of US$50 unlocks the Claude discount group at a 0.03x multiplier. Register via <a href="https://modelflare.dev/sign-up?utm_source=github&utm_campaign=sub2api">this exclusive link</a> to get started.</td>
</tr>
</table>
## Overview
+7 -24
View File
@@ -55,8 +55,8 @@
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
<td width="180"><a href="https://api.aigocode.dev/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://api.aigocode.dev/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
</tr>
<tr>
@@ -64,11 +64,6 @@
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
</tr>
<tr>
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
<td>感谢 Bestproxy 赞助了本项目!<a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> 是一家提供高纯度住宅IP,支持一号一IP独享,结合真实家庭网络与指纹隔离,可实现链路环境隔离,降低关联风控概率。</td>
@@ -99,18 +94,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>感谢 Aimzoon 对本项目的赞助! <a href="http://aimzoon.com">Aimzoon</a> 提供稳定、高性价比的 AI API 接入服务,支持开发者将常用 AI 服务快速接入 Codex、Claude Code、Gemini CLI 等编程工具。无需复杂配置,更快接入,更稳调用,更省成本。codex倍率优惠,特价倍率等促销不断,注册即送免费体验额度,让 AI 编程真正进入日常工作流。<a href="http://aimzoon.com">点击这里</a>注册体验!
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
@@ -146,11 +129,6 @@
<td>Swiftproxy 是面向开发者的高性能代理解决方案,提供稳定可靠的住宅代理和静态住宅代理服务。拥有 9000 万+ 纯净住宅 IP,覆盖全球,支持灵活轮换和精准地理定位,帮助网页抓取、AI 自动化、浏览器自动化、SEO 监控和多账号管理等项目突破访问限制,提升工作流效率。支持 HTTP(S) 和 SOCKS5 协议,兼容 Playwright、Selenium、Puppeteer 等主流自动化工具,动态代理流量用完为止永不过期,支持免费测试 — <a href="https://www.swiftproxy.net/?ref=sub2api">立即开始免费测试</a>!</td>
</tr>
<tr>
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
@@ -171,6 +149,11 @@
<td>ColaProxy 提供专为网页抓取、自动化和多账号管理打造的高质量住宅代理。免费试用,流量永不过期,价格低至 $0.3/GB,支持无限并发连接和智能 IP 轮换,带来更流畅、更稳定的代理体验。使用优惠码 COLA10 立享 9 折优惠,立即开始使用可靠的住宅代理扩展您的项目。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">立即开始使用 ColaProxy</a></td>
</tr>
<tr>
<td width="180"><a href="https://modelflare.dev/zh/sign-up?utm_source=github&utm_campaign=sub2api"><img src="assets/partners/logos/modelflare.png" alt="Modelflare" width="150"></a></td>
<td>Modelflare:满血稳定不掺水,低成本享受全球 SOTA 模型。站点由美国 LLC 主体 Havenbyte LLC 运营,支持 GPT、Claude、Gemini、Grok 等主流模型和常用兼容协议,还支持 40 多种图片和视频模型。缓存补偿是一大亮点:部分 OpenAI 价格优先、稳定和高级分组提供每日 65%、75%、85% 的缓存命中率保障,符合条件的请求没达到目标的,平台补偿差额,结算后可在用量看板查看。特惠活动:单笔充值 US$20 即可解锁 0.015 倍价格倍率的 GPT 特惠分组,单笔充值 US$50 即可解锁 0.03 倍价格倍率的 Claude 特惠分组。通过<a href="https://modelflare.dev/zh/sign-up?utm_source=github&utm_campaign=sub2api">专属链接</a>注册即可参与</td>
</tr>
</table>
## 项目概述
+7 -24
View File
@@ -54,8 +54,8 @@
</tr>
<tr>
<td width="180"><a href="https://aigocode.com/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
<td width="180"><a href="https://api.aigocode.dev/invite/SUB2API"><img src="assets/partners/logos/aigocode.png" alt="AIGoCode" width="150"></a></td>
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://api.aigocode.dev/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
</tr>
<tr>
@@ -63,11 +63,6 @@
<td>OpenAI 公式価格のわずか 3% で本物の GPT-5.6 シリーズを提供 — <a href="https://codex-everywhere.com">CodexEverywhere</a> は世界中の開発者にフロンティアモデルへのアクセスを民主化しています。私たちは透明性と誠実さを信条とし、モデル品質は数か月にわたるアクティブなコミュニティの監視によって検証されています。USD および暗号通貨に対応。<a href="https://codex-everywhere.com">codex-everywhere.com</a> で $20 の無料トライアルから始めましょう。</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます!</td>
</tr>
<tr>
<td width="180"><a href="https://bestproxy.com/?keyword=a2e8iuol"><img src="assets/partners/logos/bestproxy.png" alt="bestproxy" width="150"></a></td>
<td>Bestproxy のご支援に感謝します!<a href="https://bestproxy.com/?keyword=a2e8iuol">Bestproxy</a> は高純度の住宅IPを提供し、1アカウント1IP専有をサポートしています。実際の家庭ネットワークとフィンガープリント分離を組み合わせることで、リンク環境の分離を実現し、関連付けによるリスク管理の確率を低減します。</td>
@@ -98,18 +93,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>Aimzoon のご支援に感謝します!<a href="http://aimzoon.com">Aimzoon</a> は安定してコストパフォーマンスに優れた AI API 接続サービスを提供し、開発者が主要な AI サービスを Codex、Claude Code、Gemini CLI などのコーディングツールへ素早く接続できるようにします。複雑な設定は不要で、より速い接続、より安定した呼び出し、より低いコストを実現。Codex レート割引や特価レートなどのキャンペーンも随時開催中、登録するだけで無料お試しクレジットをプレゼント。AI コーディングを日常のワークフローへ。<a href="http://aimzoon.com">こちら</a>から登録してお試しください!
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
@@ -145,11 +128,6 @@
<td>Swiftproxy は開発者向けの高性能プロキシソリューションで、安定して信頼できるレジデンシャルおよび静的レジデンシャルプロキシサービスを提供します。9,000 万以上のクリーンな住宅 IP を保有し、グローバルカバレッジ、柔軟なローテーション、精密なジオターゲティングにより、Web スクレイピング、AI オートメーション、ブラウザ自動化、SEO モニタリング、マルチアカウント管理などのプロジェクトがアクセス制限を克服し、ワークフロー効率を向上させます。HTTP(S) および SOCKS5 プロトコルに対応し、Playwright、Selenium、Puppeteer などの主要な自動化ツールと統合可能。動的プロキシトラフィックは使い切るまで期限切れなし、無料テストも利用可能 — <a href="https://www.swiftproxy.net/?ref=sub2api">今すぐ無料テストを開始</a>!</td>
</tr>
<tr>
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
@@ -170,6 +148,11 @@
<td>ColaProxy は、Web スクレイピング、自動化、マルチアカウント管理向けに構築された高品質な住宅プロキシを提供します。有効期限なしのトラフィックで無料トライアルが利用でき、料金は $0.3/GB から、無制限の同時接続とインテリジェントな IP ローテーションにより、よりスムーズで安定したプロキシ体験を実現します。割引コード COLA10 で 10% オフ、信頼性の高い住宅プロキシでプロジェクトを今すぐスケールしましょう。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">ColaProxy を始める</a></td>
</tr>
<tr>
<td width="180"><a href="https://modelflare.dev/ja/sign-up?utm_source=github&utm_campaign=sub2api"><img src="assets/partners/logos/modelflare.png" alt="Modelflare" width="150"></a></td>
<td>Modelflare:フルスペック、安定、水増しなし。世界の SOTA モデルを、より低いコストで。米国拠点の Havenbyte LLC が運営する Modelflare は、一般的な互換プロトコルを通じて GPT、Claude、Gemini、Grok などの主要モデルに対応し、さらに 40 以上の画像・動画モデルも利用できます。注目はキャッシュ補償です。対象の OpenAI グループ(価格優先・安定・プレミアム)は、1 日のキャッシュヒット率をそれぞれ 65%、75%、85% で保証します。対象リクエストがこの水準に達しない場合、Modelflare が差額を返金し、精算後に利用状況ダッシュボードで確認できます。特別オファー:US$20 を 1 回チャージすると、価格倍率 0.015 倍の GPT 割引グループが解放されます。US$50 を 1 回チャージすると、価格倍率 0.03 倍の Claude 割引グループが解放されます。<a href="https://modelflare.dev/ja/sign-up?utm_source=github&utm_campaign=sub2api">こちらの専用リンク</a>から登録して始めましょう。</td>
</tr>
</table>
## 概要
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.4 KiB

+1 -1
View File
@@ -1 +1 @@
0.2.15
0.2.17
+7
View File
@@ -979,6 +979,9 @@ const (
// GatewayConfig API网关相关配置
type GatewayConfig struct {
// MinOutputTokens is the minimum max_output_tokens used when
// converting Chat Completions or Anthropic requests to Responses.
MinOutputTokens int `mapstructure:"min_output_tokens"`
// 等待上游响应头的超时时间(秒),0表示无超时
// 注意:这不影响流式数据传输,只控制等待响应头的时间
ResponseHeaderTimeout int `mapstructure:"response_header_timeout"`
@@ -2035,6 +2038,7 @@ func configureConfigSource(setConfigFile, addConfigPath func(string)) {
func setDefaults() {
viper.SetDefault("run_mode", RunModeStandard)
viper.SetDefault("gateway.min_output_tokens", 128)
viper.SetDefault("simple_mode.auto_create_default_groups", true)
viper.SetDefault("simple_mode_key_rate_limit_enabled", false)
@@ -2707,6 +2711,9 @@ func setEnvReachableDefaults() {
}
func (c *Config) Validate() error {
if c.Gateway.MinOutputTokens < 16 {
return fmt.Errorf("gateway.min_output_tokens must be at least 16")
}
forwardedClientIPHeaders, err := NormalizeForwardedClientIPHeaders(c.Security.ForwardedClientIPHeaders)
if err != nil {
return fmt.Errorf("security.forwarded_client_ip_headers: %w", err)
+23
View File
@@ -31,6 +31,29 @@ func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) {
require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes)
}
func TestLoadDefaultMinOutputTokens(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, 128, cfg.Gateway.MinOutputTokens)
}
func TestLoadMinOutputTokensFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("GATEWAY_MIN_OUTPUT_TOKENS", "16")
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, 16, cfg.Gateway.MinOutputTokens)
}
func TestValidateMinOutputTokensMinimum(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
cfg.Gateway.MinOutputTokens = 15
require.EqualError(t, cfg.Validate(), "gateway.min_output_tokens must be at least 16")
}
func TestLoadTimezonePrecedence(t *testing.T) {
tests := []struct {
name string
+13
View File
@@ -113,6 +113,14 @@ var DefaultAntigravityModelMapping = map[string]string{
// Claude 白名单
"claude-fable-5-1": "claude-fable-5-1", // 官方模型
"claude-fable-5": "claude-fable-5", // 官方模型
"claude-opus-5-5": "claude-opus-5-5-high", // 简称映射
"claude-opus-5-5-high": "claude-opus-5-5-high", // 官方模型
"claude-opus-5-5-medium": "claude-opus-5-5-medium", // 官方模型
"claude-opus-5-5-low": "claude-opus-5-5-low", // 官方模型
"claude-sonnet-5-5": "claude-sonnet-5-5-high", // 简称映射
"claude-sonnet-5-5-high": "claude-sonnet-5-5-high", // 官方模型
"claude-sonnet-5-5-medium": "claude-sonnet-5-5-medium", // 官方模型
"claude-sonnet-5-5-low": "claude-sonnet-5-5-low", // 官方模型
"claude-opus-4-8": "claude-opus-4-8", // 官方模型
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
"claude-opus-4-6-thinking": "claude-opus-4-6-thinking", // 官方模型
@@ -170,6 +178,11 @@ var DefaultAntigravityModelMapping = map[string]string{
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
// Gemini 4 Argon tiered models
"gemini-4-argon": "gemini-4-argon-high",
"gemini-4-argon-high": "gemini-4-argon-high",
"gemini-4-argon-low": "gemini-4-argon-low",
"gemini-4-argon-medium": "gemini-4-argon-medium",
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
"gemini-3-pro-image": "gemini-3.1-flash-image",
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
+22
View File
@@ -105,6 +105,28 @@ func TestDefaultAntigravityModelMapping_Gemini38FlashModels(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_Gemini4ArgonAndClaude55Models(t *testing.T) {
cases := map[string]string{
"gemini-4-argon": "gemini-4-argon-high",
"gemini-4-argon-high": "gemini-4-argon-high",
"gemini-4-argon-low": "gemini-4-argon-low",
"gemini-4-argon-medium": "gemini-4-argon-medium",
"claude-opus-5-5": "claude-opus-5-5-high",
"claude-opus-5-5-high": "claude-opus-5-5-high",
"claude-opus-5-5-low": "claude-opus-5-5-low",
"claude-opus-5-5-medium": "claude-opus-5-5-medium",
"claude-sonnet-5-5": "claude-sonnet-5-5-high",
"claude-sonnet-5-5-high": "claude-sonnet-5-5-high",
"claude-sonnet-5-5-low": "claude-sonnet-5-5-low",
"claude-sonnet-5-5-medium": "claude-sonnet-5-5-medium",
}
for from, want := range cases {
if got := DefaultAntigravityModelMapping[from]; got != want {
t.Fatalf("expected %s to map to %q, got %q", from, want, got)
}
}
}
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
@@ -908,14 +908,16 @@ func buildCodexAgentIdentityKeys(accountID string) []string {
return []string{"account:" + accountID}
}
// buildCodexStoredIdentityKeys 生成存量账号索引键,保留 user/account 维度,
// 让 accessToken-only 账号后续升级为完整 OAuth 时仍能命中并更新原账号。
// buildCodexStoredIdentityKeys 生成存量账号索引键,按 workspace/account
// 和 user 组合隔离身份,避免同一用户在不同 workspace 的账号误合并。
func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string) []string {
keys := make([]string, 0, 3)
accountID = strings.TrimSpace(accountID)
userID = strings.TrimSpace(userID)
accessToken = strings.TrimSpace(accessToken)
if userID != "" {
if accountID != "" && userID != "" {
keys = append(keys, "account_user:"+codexCompositeIdentityKey(accountID, userID))
} else if userID != "" {
keys = append(keys, "user:"+userID)
}
if accountID == "" && userID == "" {
@@ -932,6 +934,10 @@ func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string)
return keys
}
func codexCompositeIdentityKey(accountID, userID string) string {
return fmt.Sprintf("%d:%s%d:%s", len(accountID), accountID, len(userID), userID)
}
func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex {
index := &codexAccountIndex{
accountsByKey: map[string][]service.Account{},
@@ -303,8 +303,8 @@ func TestResolveCodexImportExpiryForNoRefreshTokenUsesEarlierRequestExpiry(t *te
func TestCodexIdentityKeysPreferStrongIdentifiers(t *testing.T) {
keys := buildCodexImportIdentityKeys("acct-1", "user-1", "same@example.com", "token", "refresh")
if len(keys) == 0 || keys[0] != "user:user-1" {
t.Fatalf("user key should have highest priority when refresh token exists: %v", keys)
if len(keys) == 0 || keys[0] != "account_user:6:acct-16:user-1" {
t.Fatalf("workspace-scoped user key should have highest priority when refresh token exists: %v", keys)
}
if keys[len(keys)-1] != "account:acct-1" {
t.Fatalf("shared account key should be the last fallback: %v", keys)
@@ -465,12 +465,84 @@ func TestCodexAccountIndexKeepsAllCandidatesForSharedAccountKey(t *testing.T) {
if got == nil || got.ID != member.ID {
t.Fatalf("Find by user key = %v, want member account ID %d", got, member.ID)
}
if matchedKey != "user:user-2" {
t.Fatalf("matched key = %q, want user:user-2", matchedKey)
if matchedKey != "account_user:6:team-16:user-2" {
t.Fatalf("matched key = %q, want account_user:6:team-16:user-2", matchedKey)
}
}
}
func TestImportCodexSessionsSameUserDifferentWorkspacesCreatesSeparateAccounts(t *testing.T) {
svc := newCodexImportMemoryAdminService(nil)
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
entries := []codexImportEntry{
{Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "shared-user", "refresh-1")},
{Index: 2, Value: buildCodexRefreshImportValue(t, "workspace-2", "shared-user", "refresh-2")},
}
result, err := handler.importCodexSessions(context.Background(), req, entries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if result.Created != 2 || result.Updated != 0 || result.Skipped != 0 || result.Failed != 0 {
t.Fatalf("result = %+v, want two created accounts", result)
}
if len(svc.createdAccounts) != 2 {
t.Fatalf("created accounts = %d, want 2", len(svc.createdAccounts))
}
if got := svc.createdAccounts[0].Credentials["chatgpt_account_id"]; got != "workspace-1" {
t.Fatalf("first workspace = %v, want workspace-1", got)
}
if got := svc.createdAccounts[1].Credentials["chatgpt_account_id"]; got != "workspace-2" {
t.Fatalf("second workspace = %v, want workspace-2", got)
}
}
func TestCodexImportIdentityKeySeparatesDelimiterValues(t *testing.T) {
left := buildCodexImportIdentityKeys("ab:c", "shared-user", "", "", "refresh")
right := buildCodexImportIdentityKeys("ab", "c:shared-user", "", "", "refresh")
if left[0] == right[0] {
t.Fatalf("composite identity keys collided: %q", left[0])
}
}
func TestImportCodexSessionsSameWorkspaceAccountUpdatesAndDifferentUserIsolated(t *testing.T) {
svc := newCodexImportMemoryAdminService(nil)
handler := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
req := CodexSessionImportRequest{SkipDefaultGroupBind: boolPtr(true)}
initialEntries := []codexImportEntry{
{Index: 1, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-1")},
{Index: 2, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-2", "refresh-2")},
}
initialResult, err := handler.importCodexSessions(context.Background(), req, initialEntries)
if err != nil {
t.Fatalf("importCodexSessions error = %v", err)
}
if initialResult.Created != 2 || initialResult.Updated != 0 || initialResult.Skipped != 0 || initialResult.Failed != 0 {
t.Fatalf("initial result = %+v, want two created accounts", initialResult)
}
updateResult, err := handler.importCodexSessions(context.Background(), req, []codexImportEntry{
{Index: 3, Value: buildCodexRefreshImportValue(t, "workspace-1", "user-1", "refresh-3")},
})
if err != nil {
t.Fatalf("importCodexSessions update error = %v", err)
}
if updateResult.Created != 0 || updateResult.Updated != 1 || updateResult.Skipped != 0 || updateResult.Failed != 0 {
t.Fatalf("update result = %+v, want one updated account", updateResult)
}
if len(svc.createdAccounts) != 2 || len(svc.updatedAccounts) != 1 {
t.Fatalf("created = %d, updated = %d, want 2 and 1", len(svc.createdAccounts), len(svc.updatedAccounts))
}
if svc.updatedAccounts[0].id != 100 {
t.Fatalf("updated account ID = %d, want user-1 account ID 100", svc.updatedAccounts[0].id)
}
if got := svc.updatedAccounts[0].input.Credentials["chatgpt_user_id"]; got != "user-1" {
t.Fatalf("updated user = %v, want user-1", got)
}
}
func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) {
legacy := service.Account{
ID: 40,
@@ -3004,13 +3004,13 @@ func antigravityAccountTestModels(rawMapping any) []antigravity.ClaudeModel {
switch mapping := rawMapping.(type) {
case map[string]any:
for id := range mapping {
if strings.TrimSpace(id) != "" {
if strings.TrimSpace(id) != "" && !strings.Contains(id, "*") {
mappedIDs = append(mappedIDs, id)
}
}
case map[string]string:
for id := range mapping {
if strings.TrimSpace(id) != "" {
if strings.TrimSpace(id) != "" && !strings.Contains(id, "*") {
mappedIDs = append(mappedIDs, id)
}
}
@@ -345,11 +345,12 @@ func TestAccountHandlerGetAvailableModels_Antigravity(t *testing.T) {
{name: "empty JSON mapping", credentials: map[string]any{"model_mapping": map[string]any{}}, want: defaults},
{name: "empty string mapping", credentials: map[string]any{"model_mapping": map[string]string{}}, want: defaults},
{name: "blank key", credentials: map[string]any{"model_mapping": map[string]any{" ": "upstream-model"}}, want: defaults},
{name: "JSON mapping keys and metadata", credentials: map[string]any{"model_mapping": map[string]any{
known.ID: known.ID, custom.ID: "upstream-model",
{name: "wildcard only falls back to defaults", credentials: map[string]any{"model_mapping": map[string]any{"claude-*": "claude-sonnet-4-6", "gemini-3*": "gemini-3-flash"}}, want: defaults},
{name: "JSON mapping keys and metadata skips wildcards", credentials: map[string]any{"model_mapping": map[string]any{
known.ID: known.ID, custom.ID: "upstream-model", "claude-*": "claude-sonnet-4-6",
}}, want: []antigravity.ClaudeModel{custom, known}},
{name: "string mapping keys and metadata", credentials: map[string]any{"model_mapping": map[string]string{
known.ID: known.ID, custom.ID: "upstream-model",
{name: "string mapping keys and metadata skips wildcards", credentials: map[string]any{"model_mapping": map[string]string{
known.ID: known.ID, custom.ID: "upstream-model", "gemini-*": "gemini-3-flash",
}}, want: []antigravity.ClaudeModel{custom, known}},
}
for _, tc := range cases {
+5 -2
View File
@@ -1193,7 +1193,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
return
}
if len(availableModels) > 0 {
if len(availableModels) > 0 || (platform == service.PlatformOpenAI && availableModels != nil) {
writeModelsList(c, platform, availableModels)
return
}
@@ -1308,7 +1308,7 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
continue
}
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
if len(platformModels) == 0 && (platform != service.PlatformOpenAI || platformModels == nil) {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
if _, ok := schedulablePlatforms[platform]; ok && !service.IsMultiProtocolAPIKeyProvider(platform) {
@@ -1457,6 +1457,9 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
// 与平台默认列表(fallbackModels)。账号映射为空时回落默认列表;Anthropic
// 平台两者取并集,其余平台以账号映射键为准。
func modelListingSource(platform string, availableModels, fallbackModels []string) []string {
if platform == service.PlatformOpenAI && availableModels != nil {
return availableModels
}
if len(availableModels) == 0 {
return fallbackModels
}
@@ -324,6 +324,12 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}},
},
}
subscriptionDefaults := make([]string, 0)
for _, model := range openai.DefaultModelIDs() {
if model != "gpt-5.5" {
subscriptionDefaults = append(subscriptionDefaults, model)
}
}
tests := []struct {
name string
accounts []service.Account
@@ -333,7 +339,7 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
{
name: "unmapped parent and Spark shadow retain defaults and aliases",
accounts: accounts,
want: append(openai.DefaultModelIDs(), alias),
want: append(append([]string{}, subscriptionDefaults...), alias),
},
{
name: "unmapped API key account also contributes defaults",
@@ -343,7 +349,7 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
{
name: "unmapped accounts alone retain default response shape",
accounts: accounts[:1],
want: openai.DefaultModelIDs(),
want: subscriptionDefaults,
},
{
name: "custom list can select defaults and aliases",
@@ -372,7 +378,7 @@ func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T
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),
want: append(append([]string{}, subscriptionDefaults...), alias),
},
{
name: "unmapped accounts from another platform do not add defaults",
@@ -1408,6 +1414,60 @@ func modelIDsForTest(models []gatewayModelItemForTest) []string {
return ids
}
func TestGatewayModels_AntigravityExplicitMappingAppearsInBothCatalogs(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 93
mapping := map[string]any{
"gemini-3.8-flash": "gemini-3.8-flash-high",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
"claude-opus-4-6-thinking": "claude-opus-4-6-thinking",
"gemini-pro-agent": "gemini-pro-agent",
}
for _, platform := range []string{service.PlatformAntigravity, service.PlatformComposite, service.PlatformGemini} {
t.Run(platform, func(t *testing.T) {
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{groupID: {{
ID: 25, Platform: service.PlatformAntigravity,
Extra: map[string]any{"mixed_scheduling": true},
Credentials: map[string]any{"model_mapping": mapping},
}}},
})
want := []string{"gemini-3.8-flash", "gemini-3.8-flash-high", "gemini-pro-agent", "claude-opus-4-6-thinking"}
if platform == service.PlatformGemini {
want = want[:3]
}
for _, codex := range []bool{false, true} {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: platform},
})
var ids []string
if codex {
h.CodexModels(c)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
for _, model := range got.Models {
ids = append(ids, model.Slug)
}
} else {
h.Models(c)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
ids = modelIDsForTest(got.Data)
}
require.Equal(t, http.StatusOK, rec.Code)
// 显式条目必须同时出现在两种目录;默认透传补齐的条目不锁定。
require.Subset(t, ids, want, "codex=%t", codex)
if platform == service.PlatformGemini {
require.NotContains(t, ids, "claude-opus-4-6-thinking", "codex=%t", codex)
}
}
})
}
}
// gemini 分组混合调度:组内 antigravity 账号的 gemini-* 映射也应出现在 /v1/models,
// 与路由层(listSchedulableAccountsOnce 的 useMixedScheduling)同源;claude-* 不带入,
// 非混合调度的异平台账号(anthropic)仍被过滤。
@@ -39,9 +39,11 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
googleError(c, http.StatusUnauthorized, "Invalid API key")
return
}
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c)
if !hasForcePlatform && effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
if !hasForcePlatform && effectivePlatform != service.PlatformGemini &&
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return
}
@@ -223,9 +225,11 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
googleError(c, http.StatusUnauthorized, "Invalid API key")
return
}
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组
// 检查平台:优先使用强制平台(/antigravity 路由),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
forcePlatform, hasForcePlatform := middleware.GetForcePlatformFromContext(c)
if !hasForcePlatform && effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
if !hasForcePlatform && effectivePlatform != service.PlatformGemini &&
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return
}
@@ -244,9 +248,13 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
modelName = strings.TrimSpace(resolvedModel)
}
if !hasForcePlatform && servesCompositeAntigravityGemini(apiKey, effectivePlatform) && !isAntigravityGeminiModel(modelName) {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return
}
// 强制 antigravity 模式:返回 antigravity 模型信息
if forcePlatform == service.PlatformAntigravity {
// 强制 antigravity 模式或 composite 路由到 antigravity:返回 antigravity 模型信息
if forcePlatform == service.PlatformAntigravity || effectivePlatform == service.PlatformAntigravity {
c.JSON(http.StatusOK, antigravity.FallbackGeminiModel(modelName))
return
}
@@ -299,9 +307,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
zap.Any("group_id", apiKey.GroupID),
)
// 检查平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则要求 gemini 分组
// 检查平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则要求 gemini 分组(composite 分组允许路由到 antigravity)
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
if !middleware.HasForcePlatform(c) {
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
if effectivePlatform != service.PlatformGemini &&
!servesCompositeAntigravityGemini(apiKey, effectivePlatform) {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return
}
@@ -321,6 +331,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
if resolvedModel, ok := service.ResolvedUpstreamModelFromContext(c.Request.Context()); ok && strings.TrimSpace(resolvedModel) != "" {
modelName = strings.TrimSpace(resolvedModel)
}
if !middleware.HasForcePlatform(c) && servesCompositeAntigravityGemini(apiKey, effectivePlatform) && !isAntigravityGeminiModel(modelName) {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return
}
stream := action == "streamGenerateContent"
reqLog = reqLog.With(zap.String("model", modelName), zap.String("action", action), zap.Bool("stream", stream))
@@ -567,7 +581,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
return
}
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, service.PlatformGemini)
diagPlatform := service.PlatformGemini
if p := effectiveAPIKeyPlatform(c, apiKey); p == service.PlatformAntigravity {
diagPlatform = service.PlatformAntigravity
}
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, modelName, modelName, diagPlatform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -1053,3 +1071,16 @@ func derefGroupID(groupID *int64) int64 {
}
return *groupID
}
func servesCompositeAntigravityGemini(apiKey *service.APIKey, effectivePlatform string) bool {
return apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite && effectivePlatform == service.PlatformAntigravity
}
func isAntigravityGeminiModel(model string) bool {
trimmed := strings.ToLower(strings.TrimSpace(strings.TrimPrefix(model, "models/")))
if trimmed == "" || strings.HasPrefix(trimmed, "claude-") {
return false
}
detected, ok := service.DetectModelPlatform(trimmed)
return ok && detected == service.PlatformGemini
}
@@ -216,3 +216,75 @@ func TestShouldFallbackGeminiModel_DelegatesScopeFallback(t *testing.T) {
}
require.True(t, shouldFallbackGeminiModel("gemini-future-model", res))
}
func TestGeminiV1BetaGetModel_CompositeAntigravityRoute(t *testing.T) {
gin.SetMode(gin.TestMode)
t.Run("composite route to antigravity gemini model succeeds", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-4-argon", nil)
req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{
Matched: true,
TargetPlatform: service.PlatformAntigravity,
UpstreamModel: "gemini-4-argon-high",
}))
c.Request = req
c.Params = gin.Params{{Key: "model", Value: "gemini-4-argon"}}
groupID := int64(4)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
ID: 1,
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
(&GatewayHandler{}).GeminiV1BetaGetModel(c)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.Contains(t, rec.Body.String(), "models/gemini-4-argon-high")
})
t.Run("composite route to antigravity claude model is rejected on v1beta", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodGet, "/v1beta/models/claude-sonnet-5-5-high", nil)
req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{
Matched: true,
TargetPlatform: service.PlatformAntigravity,
UpstreamModel: "claude-sonnet-5-5-high",
}))
c.Request = req
c.Params = gin.Params{{Key: "model", Value: "claude-sonnet-5-5-high"}}
groupID := int64(4)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
ID: 1,
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
(&GatewayHandler{}).GeminiV1BetaGetModel(c)
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
})
t.Run("composite route to antigravity claude model is rejected on generateContent", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodPost, "/v1beta/models/claude-sonnet-5-5-high:generateContent", nil)
req = req.WithContext(service.WithCompositeRouteDecision(req.Context(), service.CompositeRouteDecision{
Matched: true,
TargetPlatform: service.PlatformAntigravity,
UpstreamModel: "claude-sonnet-5-5-high",
}))
c.Request = req
c.Params = gin.Params{{Key: "modelAction", Value: "/claude-sonnet-5-5-high:generateContent"}}
groupID := int64(4)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
ID: 1,
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1, Concurrency: 1})
(&GatewayHandler{}).GeminiV1BetaModels(c)
require.Equal(t, http.StatusBadRequest, rec.Code, rec.Body.String())
})
}
+5 -1
View File
@@ -123,10 +123,14 @@ func classifyNoAccountError(
result := diag.DiagnoseModelAvailabilityForPlatform(ctx, apiKey.GroupID, routingModel, platform)
if result.HasAccountsInPool && !result.HasModelSupport {
message := fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel)
if result.HasRetiredSubscriptionModel {
message = fmt.Sprintf("Model %q targets GPT-5.5, which retired from Codex with ChatGPT sign-in on October 14, 2026. Select an available replacement such as GPT-6.1 Sol or GPT-6 Luna, or configure an API-key upstream that supports GPT-5.5.", displayModel)
}
return noAccountErrorClassification{
Status: http.StatusNotFound,
ErrType: "model_not_found",
Message: fmt.Sprintf("Model %q is not supported by any configured account in this group", displayModel),
Message: message,
ModelNotFound: true,
}
}
@@ -303,3 +303,22 @@ func TestClassifySelectionFailureError_StillUpgradesNonModelNotFoundFallback(t *
require.Equal(t, "rate_limit_error", got.ErrType)
require.False(t, got.ModelNotFound)
}
func TestClassifyNoAccountError_GPT55Retirement(t *testing.T) {
groupID := int64(1)
apiKey := &service.APIKey{GroupID: &groupID}
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasRetiredSubscriptionModel: true}}
cls := classifyNoAccountError(context.Background(), fd, apiKey, "gpt-5.5", "legacy", service.PlatformOpenAI)
require.Equal(t, http.StatusNotFound, cls.Status)
require.Equal(t, "model_not_found", cls.ErrType)
require.Contains(t, cls.Message, "retired")
require.Contains(t, cls.Message, "API-key")
fd.resp.HasModelSupport = true
cls = classifyNoAccountError(context.Background(), fd, apiKey, "gpt-5.5", "legacy", service.PlatformOpenAI)
require.Equal(t, http.StatusServiceUnavailable, cls.Status, "a temporarily unavailable API pool is not a retirement error")
}
func TestGPT55RetirementEmptyListingDoesNotUseDefaults(t *testing.T) {
require.Empty(t, modelListingSource(service.PlatformOpenAI, []string{}, []string{"gpt-5.5"}))
require.Equal(t, []string{"gpt-5.5"}, modelListingSource(service.PlatformOpenAI, nil, []string{"gpt-5.5"}))
}
@@ -2540,6 +2540,11 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusInternalError, "Failed to resolve composite model route")
return
}
if decision.OAuthAmbiguous() {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, decision.AmbiguousMessage())
return
}
if decision.Matched {
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
wsRouteModel = decision.UpstreamModel
@@ -103,7 +103,7 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
headers[strings.ToLower(k)] = c.GetHeader(k)
}
resolvedProviderKey, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
verifiedProvider, notification, err := verifyNotificationWithProviders(c.Request.Context(), providers, rawBody, headers)
if err != nil {
truncatedBody := rawBody
if len(truncatedBody) > webhookLogTruncateLen {
@@ -115,12 +115,20 @@ func (h *PaymentWebhookHandler) handleNotify(c *gin.Context, providerKey string)
return
}
resolvedProviderKey := verifiedProvider.ProviderKey()
// nil notification means irrelevant event (e.g. Stripe non-payment event); return success.
if notification == nil {
writeSuccessResponse(c, resolvedProviderKey)
return
}
if err := h.paymentService.ConfirmNotificationUpstream(c.Request.Context(), verifiedProvider, notification); err != nil {
slog.Error("[Payment Webhook] upstream confirmation failed", "provider", resolvedProviderKey, "outTradeNo", notification.OrderID, "tradeNo", notification.TradeNo, "error", err)
c.String(http.StatusBadGateway, "confirm failed")
return
}
if err := h.paymentService.HandlePaymentNotification(c.Request.Context(), notification, resolvedProviderKey); err != nil {
// Unknown order: ack with 2xx so the provider stops retrying. This
// guards against foreign environments whose webhook endpoints are
@@ -170,7 +178,10 @@ func extractOutTradeNo(rawBody, providerKey string) string {
return ""
}
func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (string, *payment.PaymentNotification, error) {
// verifyNotificationWithProviders returns the provider instance that verified
// the notification so later steps (upstream confirmation) use the same
// merchant configuration.
func verifyNotificationWithProviders(ctx context.Context, providers []payment.Provider, rawBody string, headers map[string]string) (payment.Provider, *payment.PaymentNotification, error) {
var lastErr error
for _, provider := range providers {
if provider == nil {
@@ -181,12 +192,12 @@ func verifyNotificationWithProviders(ctx context.Context, providers []payment.Pr
lastErr = err
continue
}
return provider.ProviderKey(), notification, nil
return provider, notification, nil
}
if lastErr != nil {
return "", nil, lastErr
return nil, nil, lastErr
}
return "", nil, fmt.Errorf("no webhook provider could verify notification")
return nil, nil, fmt.Errorf("no webhook provider could verify notification")
}
// wxpaySuccessResponse is the JSON response expected by WeChat Pay webhook.
@@ -204,9 +204,9 @@ func TestVerifyNotificationWithProvidersReturnsMatchedProvider(t *testing.T) {
},
}
providerKey, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"})
provider, notification, err := verifyNotificationWithProviders(context.Background(), providers, "{}", map[string]string{"wechatpay-signature": "sig"})
require.NoError(t, err)
require.Equal(t, payment.TypeWxpay, providerKey)
require.Equal(t, payment.TypeWxpay, provider.ProviderKey())
require.NotNil(t, notification)
require.Equal(t, "sub2_42", notification.OrderID)
}
+64 -9
View File
@@ -10,6 +10,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/smartwalle/alipay/v3"
)
@@ -24,7 +25,10 @@ const (
const (
alipayFundChangeYes = "Y"
alipayErrTradeNotExist = "ACQ.TRADE_NOT_EXIST"
alipayRefundSuffix = "-refund"
alipayRefundStatusSucc = "REFUND_SUCCESS"
alipayRefundRequestSep = "-refund-"
// 支付宝提示退款发起后过早查询可能查不到、之后又处理成功;超过该时长仍无记录才判定失败。
alipayRefundQueryNotFoundGrace = 10 * time.Minute
)
var (
@@ -37,6 +41,12 @@ var (
alipayTradePagePay = func(client *alipay.Client, param alipay.TradePagePay) (*url.URL, error) {
return client.TradePagePay(param)
}
alipayTradeRefund = func(ctx context.Context, client *alipay.Client, param alipay.TradeRefund) (*alipay.TradeRefundRsp, error) {
return client.TradeRefund(ctx, param)
}
alipayTradeFastPayRefundQuery = func(ctx context.Context, client *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
return client.TradeFastPayRefundQuery(ctx, param)
}
)
// Alipay implements payment.Provider and payment.CancelableProvider using the smartwalle/alipay SDK.
@@ -338,32 +348,76 @@ func (a *Alipay) Refund(ctx context.Context, req payment.RefundRequest) (*paymen
return nil, err
}
result, err := client.TradeRefund(ctx, alipay.TradeRefund{
outRequestNo := fmt.Sprintf("%s%s%d", req.OrderID, alipayRefundRequestSep, time.Now().UnixNano())
result, err := alipayTradeRefund(ctx, client, alipay.TradeRefund{
OutTradeNo: req.OrderID,
RefundAmount: req.Amount,
RefundReason: req.Reason,
OutRequestNo: fmt.Sprintf("%s-refund-%d", req.OrderID, time.Now().UnixNano()),
OutRequestNo: outRequestNo,
})
if err != nil {
return nil, fmt.Errorf("alipay TradeRefund: %w", err)
}
if result == nil {
return nil, fmt.Errorf("alipay TradeRefund: empty response")
}
refundStatus := payment.ProviderStatusPending
if result.FundChange == alipayFundChangeYes {
refundStatus = payment.ProviderStatusSuccess
}
refundID := result.TradeNo
if refundID == "" {
refundID = req.OrderID + alipayRefundSuffix
}
// fund_change != Y 时需凭退款请求号查询终态,因此 RefundID 记录 out_request_no。
return &payment.RefundResponse{
RefundID: refundID,
RefundID: outRequestNo,
Status: refundStatus,
}, nil
}
// QueryRefund 通过 alipay.trade.fastpay.refund.query 查询退款终态。refund_status 为 REFUND_SUCCESS
// 表示成功;退款发起超过宽限期仍查不到记录表示支付宝未受理或退款失败;其余情况视为仍在处理中。
func (a *Alipay) QueryRefund(ctx context.Context, req payment.RefundQueryRequest) (*payment.RefundResponse, error) {
orderID := strings.TrimSpace(req.OrderID)
outRequestNo := strings.TrimSpace(req.RefundID)
// 旧版本把支付宝交易号记为 RefundID,无法据此查询退款请求,只能人工核实。
if orderID == "" || !strings.HasPrefix(outRequestNo, orderID+alipayRefundRequestSep) {
return nil, infraerrors.BadRequest("REFUND_QUERY_UNSUPPORTED", "this refund was issued without a queryable refund request number; please verify manually")
}
client, err := a.getClient()
if err != nil {
return nil, err
}
result, err := alipayTradeFastPayRefundQuery(ctx, client, alipay.TradeFastPayRefundQuery{
OutTradeNo: orderID,
OutRequestNo: outRequestNo,
})
if err != nil {
return nil, fmt.Errorf("alipay query refund: %w", err)
}
if result == nil {
return nil, fmt.Errorf("alipay query refund: empty response")
}
// SDK 对带签名的业务失败响应(如 ACQ.SYSTEM_ERROR)不返回 error,字段为空;
// 不检查错误码会被误判为"无退款记录"而失败收尾。
if result.IsFailure() {
return nil, fmt.Errorf("alipay query refund: %w", result.Error)
}
status := payment.ProviderStatusPending
switch {
case strings.TrimSpace(result.RefundStatus) == alipayRefundStatusSucc:
status = payment.ProviderStatusSuccess
case strings.TrimSpace(result.OutRequestNo) == "" && strings.TrimSpace(result.RefundAmount) == "" &&
alipayRefundAbsenceIsFinal(req.RequestedAt, time.Now()):
status = payment.ProviderStatusFailed
}
return &payment.RefundResponse{RefundID: outRequestNo, Status: status}, nil
}
func alipayRefundAbsenceIsFinal(requestedAt, now time.Time) bool {
return !requestedAt.IsZero() && now.Sub(requestedAt) >= alipayRefundQueryNotFoundGrace
}
// CancelPayment closes a pending trade on Alipay.
func (a *Alipay) CancelPayment(ctx context.Context, tradeNo string) error {
client, err := a.getClient()
@@ -406,5 +460,6 @@ func parseAlipayAmount(values ...string) (float64, error) {
var (
_ payment.Provider = (*Alipay)(nil)
_ payment.CancelableProvider = (*Alipay)(nil)
_ payment.RefundQueryProvider = (*Alipay)(nil)
_ payment.MerchantIdentityProvider = (*Alipay)(nil)
)
@@ -8,6 +8,7 @@ import (
"net/url"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/payment"
"github.com/smartwalle/alipay/v3"
@@ -444,3 +445,129 @@ func TestParseAlipayAmount(t *testing.T) {
t.Fatal("expected error when no valid amount field exists")
}
}
func TestAlipayRefundRecordsOutRequestNoAsRefundID(t *testing.T) {
origRefund := alipayTradeRefund
t.Cleanup(func() { alipayTradeRefund = origRefund })
for _, tc := range []struct {
fundChange string
wantStatus string
}{
{fundChange: alipayFundChangeYes, wantStatus: payment.ProviderStatusSuccess},
{fundChange: "N", wantStatus: payment.ProviderStatusPending},
} {
var gotOutRequestNo string
alipayTradeRefund = func(_ context.Context, _ *alipay.Client, param alipay.TradeRefund) (*alipay.TradeRefundRsp, error) {
if param.OutTradeNo != "sub2_7" || param.RefundAmount != "12.50" {
t.Fatalf("unexpected refund param: %+v", param)
}
gotOutRequestNo = param.OutRequestNo
return &alipay.TradeRefundRsp{TradeNo: "2026101522001", FundChange: tc.fundChange}, nil
}
provider := &Alipay{client: &alipay.Client{}}
resp, err := provider.Refund(context.Background(), payment.RefundRequest{OrderID: "sub2_7", Amount: "12.50", Reason: "r"})
if err != nil {
t.Fatalf("Refund() error = %v", err)
}
if resp.Status != tc.wantStatus {
t.Fatalf("fund_change=%s status = %q, want %q", tc.fundChange, resp.Status, tc.wantStatus)
}
if !strings.HasPrefix(gotOutRequestNo, "sub2_7-refund-") || resp.RefundID != gotOutRequestNo {
t.Fatalf("RefundID = %q, out_request_no = %q", resp.RefundID, gotOutRequestNo)
}
}
}
func TestAlipayQueryRefundMapsStatus(t *testing.T) {
origQuery := alipayTradeFastPayRefundQuery
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
const outRequestNo = "sub2_7-refund-1760500000000000000"
ok := alipay.Error{Code: alipay.CodeSuccess, Msg: "Success"}
settled := time.Now().Add(-alipayRefundQueryNotFoundGrace - time.Minute)
for _, tc := range []struct {
name string
rsp *alipay.TradeFastPayRefundQueryRsp
requestedAt time.Time
wantStatus string
}{
{name: "success", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok, OutRequestNo: outRequestNo, RefundAmount: "12.50", RefundStatus: alipayRefundStatusSucc}, requestedAt: settled, wantStatus: payment.ProviderStatusSuccess},
{name: "record without final status", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok, OutRequestNo: outRequestNo, RefundAmount: "12.50"}, requestedAt: settled, wantStatus: payment.ProviderStatusPending},
{name: "no refund record after grace", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, requestedAt: settled, wantStatus: payment.ProviderStatusFailed},
{name: "no refund record within grace", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, requestedAt: time.Now().Add(-time.Minute), wantStatus: payment.ProviderStatusPending},
{name: "no refund record with unknown request time", rsp: &alipay.TradeFastPayRefundQueryRsp{Error: ok}, wantStatus: payment.ProviderStatusPending},
} {
t.Run(tc.name, func(t *testing.T) {
alipayTradeFastPayRefundQuery = func(_ context.Context, _ *alipay.Client, param alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
if param.OutTradeNo != "sub2_7" || param.OutRequestNo != outRequestNo {
t.Fatalf("unexpected query param: %+v", param)
}
return tc.rsp, nil
}
provider := &Alipay{client: &alipay.Client{}}
resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: outRequestNo, RequestedAt: tc.requestedAt})
if err != nil {
t.Fatalf("QueryRefund() error = %v", err)
}
if resp.Status != tc.wantStatus || resp.RefundID != outRequestNo {
t.Fatalf("QueryRefund() = %+v, want status %q", resp, tc.wantStatus)
}
})
}
}
// SDK 对带签名的业务失败响应返回 err == nil,错误码只在响应体里。
func TestAlipayQueryRefundBusinessFailureKeepsRefundPending(t *testing.T) {
origQuery := alipayTradeFastPayRefundQuery
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
for _, rspErr := range []alipay.Error{
{Code: "40004", Msg: "Business Failed", SubCode: "ACQ.SYSTEM_ERROR", SubMsg: "系统错误"},
{Code: "20000", Msg: "Service Currently Unavailable", SubCode: "isp.unknow-error"},
{},
} {
alipayTradeFastPayRefundQuery = func(context.Context, *alipay.Client, alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
return &alipay.TradeFastPayRefundQueryRsp{Error: rspErr}, nil
}
provider := &Alipay{client: &alipay.Client{}}
resp, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{
OrderID: "sub2_7",
RefundID: "sub2_7-refund-1",
RequestedAt: time.Now().Add(-24 * time.Hour),
})
if err == nil {
t.Fatalf("code %q: QueryRefund() = %+v, want error", rspErr.Code, resp)
}
if rspErr.SubCode != "" && !strings.Contains(err.Error(), string(rspErr.Code)) {
t.Fatalf("code %q: error %q should carry the gateway code", rspErr.Code, err)
}
}
}
func TestAlipayQueryRefundErrorsKeepRefundPending(t *testing.T) {
origQuery := alipayTradeFastPayRefundQuery
t.Cleanup(func() { alipayTradeFastPayRefundQuery = origQuery })
calls := 0
alipayTradeFastPayRefundQuery = func(context.Context, *alipay.Client, alipay.TradeFastPayRefundQuery) (*alipay.TradeFastPayRefundQueryRsp, error) {
calls++
return nil, errors.New("ACQ.SYSTEM_ERROR")
}
provider := &Alipay{client: &alipay.Client{}}
// 旧版本记录的是支付宝交易号,无法查询退款请求,需人工核实,且不能调用网关。
for _, legacyID := range []string{"", "2026101522001", "sub2_7-refund", "sub2_70-refund-1"} {
if _, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: legacyID}); err == nil || !strings.Contains(err.Error(), "verify manually") {
t.Fatalf("legacy refund id %q: err = %v", legacyID, err)
}
}
if calls != 0 {
t.Fatalf("legacy refund ids must not reach the gateway, calls = %d", calls)
}
if _, err := provider.QueryRefund(context.Background(), payment.RefundQueryRequest{OrderID: "sub2_7", RefundID: "sub2_7-refund-1"}); err == nil {
t.Fatal("gateway error must be returned so the refund stays pending")
}
}
+145 -47
View File
@@ -7,8 +7,10 @@ import (
"crypto/md5"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"math"
"net/http"
"net/url"
"sort"
@@ -27,6 +29,7 @@ const (
easypayHTTPTimeout = 10 * time.Second
maxEasypayResponseSize = 1 << 20 // 1MB
maxEasypayErrorSummary = 512
easypayAmountTolerance = 0.005
tradeStatusSuccess = "TRADE_SUCCESS"
signTypeMD5 = "MD5"
paymentModePopup = "popup"
@@ -301,32 +304,86 @@ func (e *EasyPay) upstreamPaymentType(paymentType string) string {
}
func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.QueryOrderResponse, error) {
result, err := e.queryOrder(ctx, tradeNo)
if err != nil {
return nil, err
}
responseTradeNo := tradeNo
if result.tradeNo != "" {
responseTradeNo = result.tradeNo
}
amount, _ := strconv.ParseFloat(result.money, 64)
return &payment.QueryOrderResponse{
TradeNo: responseTradeNo,
Status: result.status,
Amount: amount,
Metadata: e.MerchantIdentityMetadata(),
}, nil
}
// easyPayQueryResult is the normalized api.php?act=order answer. tradeNo is
// the upstream trade number exactly as returned (empty when absent).
type easyPayQueryResult struct {
status string
money string
tradeNo string
}
func (e *EasyPay) queryOrder(ctx context.Context, outTradeNo string) (*easyPayQueryResult, error) {
params := map[string]string{
"act": "order", "pid": e.config["pid"],
"key": e.config["pkey"], "out_trade_no": tradeNo,
"key": e.config["pkey"], "out_trade_no": outTradeNo,
}
body, err := e.post(ctx, e.apiBase()+"/api.php", params)
// 彩虹易支付 and ZPay read act=order parameters from $_GET only (a
// body-only request is answered with "No Act!"), while other forks read
// the form body; send them in both places.
query := url.Values{}
for k, v := range params {
query.Set(k, v)
}
body, err := e.post(ctx, e.apiBase()+"/api.php?"+query.Encode(), params)
if err != nil {
// The request URL carries the merchant key; never let it reach logs
// or audit records through *url.Error.
var urlErr *url.Error
if errors.As(err, &urlErr) {
err = urlErr.Err
}
return nil, fmt.Errorf("easypay query: %w", err)
}
// Scalars are decoded leniently: PHP deployments commonly serialize
// integer columns as strings ("status":"1").
type easyPayQueryData struct {
TradeStatus *string `json:"trade_status"`
Status *int `json:"status"`
Money *string `json:"money"`
TradeNo *string `json:"trade_no"`
TradeStatus *string `json:"trade_status"`
Status json.RawMessage `json:"status"`
Money json.RawMessage `json:"money"`
TradeNo json.RawMessage `json:"trade_no"`
OutTradeNo json.RawMessage `json:"out_trade_no"`
}
var resp struct {
Code int `json:"code"`
Msg string `json:"msg"`
TradeStatus *string `json:"trade_status"`
Status *int `json:"status"`
Money *string `json:"money"`
TradeNo *string `json:"trade_no"`
Data easyPayQueryData `json:"data"`
easyPayQueryData
Code any `json:"code"`
Msg string `json:"msg"`
Data easyPayQueryData `json:"data"`
}
if err := json.Unmarshal(body, &resp); err != nil {
return nil, fmt.Errorf("easypay parse query: %w", err)
}
// An explicit failure code wins over any paid-looking status field that
// may accompany it.
if resp.Code != nil && !easyPayResponseCodeIsSuccess(resp.Code) {
msg := strings.TrimSpace(resp.Msg)
if msg == "" {
msg = summarizeEasyPayResponse(body)
}
return nil, fmt.Errorf("easypay query failed: %s", msg)
}
// When the upstream echoes the order identity it must be this order; some
// implementations silently ignore unknown lookup parameters.
if echoed := easyPayJSONScalar(pickEasyPayJSON(resp.OutTradeNo, resp.Data.OutTradeNo)); echoed != "" && echoed != strings.TrimSpace(outTradeNo) {
return nil, fmt.Errorf("easypay query out_trade_no mismatch: expected %s, got %s", strings.TrimSpace(outTradeNo), echoed)
}
status := payment.ProviderStatusPending
if resp.TradeStatus != nil {
if *resp.TradeStatus == tradeStatusSuccess {
@@ -336,38 +393,74 @@ func (e *EasyPay) QueryOrder(ctx context.Context, tradeNo string) (*payment.Quer
if *resp.Data.TradeStatus == tradeStatusSuccess {
status = payment.ProviderStatusPaid
}
} else if resp.Status != nil {
if *resp.Status == easypayStatusPaid {
} else if raw := pickEasyPayJSON(resp.Status, resp.Data.Status); raw != nil {
if easyPayJSONScalar(raw) == strconv.Itoa(easypayStatusPaid) {
status = payment.ProviderStatusPaid
}
} else if resp.Data.Status != nil && *resp.Data.Status == easypayStatusPaid {
status = payment.ProviderStatusPaid
}
money := ""
if resp.Money != nil {
money = *resp.Money
} else if resp.Data.Money != nil {
money = *resp.Data.Money
}
responseTradeNo := tradeNo
if resp.TradeNo != nil {
if *resp.TradeNo != "" {
responseTradeNo = *resp.TradeNo
}
} else if resp.Data.TradeNo != nil && *resp.Data.TradeNo != "" {
responseTradeNo = *resp.Data.TradeNo
}
amount, _ := strconv.ParseFloat(money, 64)
return &payment.QueryOrderResponse{
TradeNo: responseTradeNo,
Status: status,
Amount: amount,
Metadata: e.MerchantIdentityMetadata(),
return &easyPayQueryResult{
status: status,
money: easyPayJSONScalar(pickEasyPayJSON(resp.Money, resp.Data.Money)),
tradeNo: easyPayJSONScalar(pickEasyPayJSON(resp.TradeNo, resp.Data.TradeNo)),
}, nil
}
// ConfirmNotification checks a verified success notification against the
// upstream order before it is fulfilled, so crediting follows the gateway's
// own order state (paid, amount, trade_no). The query is sent to this
// instance's own apiBase with its own credentials.
func (e *EasyPay) ConfirmNotification(ctx context.Context, n *payment.PaymentNotification) error {
if n == nil || n.Status != payment.NotificationStatusSuccess {
return nil
}
result, err := e.queryOrder(ctx, n.OrderID)
if err != nil {
return fmt.Errorf("easypay confirm: %w", err)
}
if result.status != payment.ProviderStatusPaid {
return fmt.Errorf("easypay confirm: upstream reports order %s as not paid", n.OrderID)
}
queriedAmount, err := strconv.ParseFloat(result.money, 64)
if err != nil || math.Abs(queriedAmount-n.Amount) > easypayAmountTolerance {
return fmt.Errorf("easypay confirm: amount mismatch: notified %v, upstream %q", n.Amount, result.money)
}
if result.tradeNo != "" {
if n.TradeNo != "" && n.TradeNo != result.tradeNo {
return fmt.Errorf("easypay confirm: trade_no mismatch: notified %s, upstream %s", n.TradeNo, result.tradeNo)
}
n.TradeNo = result.tradeNo
}
return nil
}
func easyPayJSONScalar(raw json.RawMessage) string {
if len(raw) == 0 {
return ""
}
var str string
if err := json.Unmarshal(raw, &str); err == nil {
return strings.TrimSpace(str)
}
var num json.Number
if err := json.Unmarshal(raw, &num); err == nil {
return num.String()
}
return ""
}
// pickEasyPayJSON returns the top-level field when present, otherwise the
// nested data field. JSON null counts as absent.
func pickEasyPayJSON(top, nested json.RawMessage) json.RawMessage {
if len(top) > 0 && string(top) != "null" {
return top
}
if len(nested) > 0 && string(nested) != "null" {
return nested
}
return nil
}
func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[string]string) (*payment.PaymentNotification, error) {
values, err := url.ParseQuery(rawBody)
if err != nil {
@@ -379,7 +472,14 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
if !easyPayNotifyAllowedParams[k] {
return nil, fmt.Errorf("unexpected notify param: %s", k)
}
params[k] = values.Get(k)
v := values.Get(k)
// Notify values are plain tokens; only the merchant-defined product
// name is free text. Delimiter characters elsewhere are rejected so
// each value maps to exactly one field of the signed string.
if k != "name" && strings.ContainsAny(v, "&=") {
return nil, fmt.Errorf("invalid notify param value: %s", k)
}
params[k] = v
}
sign := params["sign"]
if sign == "" {
@@ -388,22 +488,20 @@ func (e *EasyPay) VerifyNotification(_ context.Context, rawBody string, _ map[st
if !easyPayVerifySign(params, e.config["pkey"], sign) {
return nil, fmt.Errorf("invalid signature")
}
// Bind the notification to this instance's merchant directly instead of
// relying on the order snapshot, which legacy orders do not carry.
if expected, actual := strings.TrimSpace(e.config["pid"]), strings.TrimSpace(params["pid"]); actual != expected {
return nil, fmt.Errorf("easypay pid mismatch: expected %s, got %s", expected, actual)
}
status := payment.ProviderStatusFailed
if params["trade_status"] == tradeStatusSuccess {
status = payment.ProviderStatusSuccess
}
amount, _ := strconv.ParseFloat(params["money"], 64)
metadata := e.MerchantIdentityMetadata()
if pid := strings.TrimSpace(params["pid"]); pid != "" {
if metadata == nil {
metadata = map[string]string{}
}
metadata["pid"] = pid
}
return &payment.PaymentNotification{
TradeNo: params["trade_no"], OrderID: params["out_trade_no"],
Amount: amount, Status: status, RawData: rawBody, Metadata: metadata,
Amount: amount, Status: status, RawData: rawBody, Metadata: e.MerchantIdentityMetadata(),
}, nil
}
@@ -0,0 +1,253 @@
package provider
import (
"context"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync/atomic"
"testing"
"github.com/Wei-Shaw/sub2api/internal/payment"
)
func newConfirmTestEasyPay(t *testing.T, body string, status int) (*EasyPay, *atomic.Int32) {
t.Helper()
var calls atomic.Int32
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
calls.Add(1)
if err := r.ParseForm(); err != nil {
t.Errorf("ParseForm: %v", err)
}
q := r.URL.Query()
if r.URL.Path != "/api.php" || q.Get("act") != "order" || q.Get("pid") != "1001" || q.Get("key") != "pkey-1" || q.Get("out_trade_no") != "sub2_ORDER1" {
t.Errorf("unexpected query request: path=%s query=%v", r.URL.Path, q)
}
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
provider, err := NewEasyPay("test-instance", map[string]string{
"pid": "1001",
"pkey": "pkey-1",
"apiBase": server.URL,
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
}
return provider, &calls
}
func confirmTestNotification() *payment.PaymentNotification {
return &payment.PaymentNotification{
TradeNo: "2026101600001",
OrderID: "sub2_ORDER1",
Amount: 10,
Status: payment.NotificationStatusSuccess,
}
}
func TestEasyPayConfirmNotification(t *testing.T) {
t.Parallel()
tests := []struct {
name string
body string
status int
notifyTrade string
wantErr string
wantTradeNo string
}{
{
name: "paid with matching amount and trade_no",
body: `{"code":1,"pid":1001,"out_trade_no":"sub2_ORDER1","trade_no":"2026101600001","money":"10.00","status":1}`,
notifyTrade: "2026101600001",
wantTradeNo: "2026101600001",
},
{
name: "missing notify trade_no is filled from upstream",
body: `{"code":1,"trade_no":"2026101600001","money":"10.00","trade_status":"TRADE_SUCCESS"}`,
wantTradeNo: "2026101600001",
},
{
name: "upstream not paid",
body: `{"code":1,"trade_no":"2026101600001","money":"10.00","status":0}`,
notifyTrade: "2026101600001",
wantErr: "not paid",
},
{
name: "upstream failure code with paid status",
body: `{"code":-1,"msg":"订单号不存在","status":1,"money":"10.00"}`,
notifyTrade: "2026101600001",
wantErr: "订单号不存在",
},
{
name: "amount mismatch",
body: `{"code":1,"trade_no":"2026101600001","money":"0.01","status":1}`,
notifyTrade: "2026101600001",
wantErr: "amount mismatch",
},
{
name: "missing upstream amount",
body: `{"code":1,"trade_no":"2026101600001","status":1}`,
notifyTrade: "2026101600001",
wantErr: "amount mismatch",
},
{
name: "trade_no conflict",
body: `{"code":1,"trade_no":"2026101699999","money":"10.00","status":1}`,
notifyTrade: "2026101600001",
wantErr: "trade_no mismatch",
},
{
name: "string status from PHP deployment",
body: `{"code":"1","pid":"1001","out_trade_no":"sub2_ORDER1","trade_no":"2026101600001","money":"10.00","status":"1"}`,
notifyTrade: "2026101600001",
wantTradeNo: "2026101600001",
},
{
name: "foreign order echo",
body: `{"code":1,"out_trade_no":"sub2_OTHER","trade_no":"2026101600001","money":"10.00","status":1}`,
notifyTrade: "2026101600001",
wantErr: "out_trade_no mismatch",
},
{
name: "upstream HTTP error body",
body: `<html>502 Bad Gateway</html>`,
status: http.StatusBadGateway,
notifyTrade: "2026101600001",
wantErr: "parse query",
},
}
for _, tt := range tests {
tt := tt
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
status := tt.status
if status == 0 {
status = http.StatusOK
}
provider, calls := newConfirmTestEasyPay(t, tt.body, status)
n := confirmTestNotification()
n.TradeNo = tt.notifyTrade
err := provider.ConfirmNotification(context.Background(), n)
if calls.Load() != 1 {
t.Fatalf("upstream query calls = %d, want 1", calls.Load())
}
if tt.wantErr != "" {
if err == nil || !strings.Contains(err.Error(), tt.wantErr) {
t.Fatalf("ConfirmNotification error = %v, want containing %q", err, tt.wantErr)
}
return
}
if err != nil {
t.Fatalf("ConfirmNotification returned error: %v", err)
}
if n.TradeNo != tt.wantTradeNo {
t.Fatalf("trade_no = %q, want %q", n.TradeNo, tt.wantTradeNo)
}
})
}
}
func TestEasyPayConfirmNotificationSkipsNonSuccess(t *testing.T) {
t.Parallel()
provider, calls := newConfirmTestEasyPay(t, `{}`, http.StatusOK)
n := confirmTestNotification()
n.Status = payment.ProviderStatusFailed
if err := provider.ConfirmNotification(context.Background(), n); err != nil {
t.Fatalf("ConfirmNotification returned error: %v", err)
}
if calls.Load() != 0 {
t.Fatalf("upstream query calls = %d, want 0", calls.Load())
}
}
func TestEasyPayConfirmNotificationQueryTransportError(t *testing.T) {
t.Parallel()
server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}))
apiBase := server.URL
server.Close()
provider := newTestEasyPay(t, apiBase)
err := provider.ConfirmNotification(context.Background(), confirmTestNotification())
if err == nil {
t.Fatal("ConfirmNotification must fail when the upstream is unreachable")
}
// The query URL carries the merchant key; errors are logged and stored in
// the payment audit trail, so they must not echo the URL.
if strings.Contains(err.Error(), "pkey-1") || strings.Contains(err.Error(), "key=") {
t.Fatalf("error contains the merchant key: %v", err)
}
}
func TestEasyPayNotifyRejectsForeignPid(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()
for _, pid := range []string{"2000", ""} {
params := map[string]string{
"trade_no": "T1",
"out_trade_no": "ORDER123",
"type": "alipay",
"name": "balance recharge",
"money": "650.00",
"trade_status": tradeStatusSuccess,
}
if pid != "" {
params["pid"] = pid
}
params["sign"] = easyPaySign(params, e.config["pkey"])
q := url.Values{}
for k, v := range params {
q.Set(k, v)
}
if _, err := e.VerifyNotification(context.Background(), q.Encode(), nil); err == nil {
t.Fatalf("callback with pid %q must be rejected", pid)
}
}
}
func TestEasyPayNotifyAllowsFreeTextNameOnly(t *testing.T) {
t.Parallel()
e := easyPayPoCProvider()
base := map[string]string{
"pid": "1000",
"trade_no": "T1",
"out_trade_no": "ORDER123",
"type": "alipay",
"name": "Plan A&B = Pro",
"money": "650.00",
"trade_status": tradeStatusSuccess,
}
encode := func(params map[string]string) string {
signed := map[string]string{}
for k, v := range params {
signed[k] = v
}
signed["sign"] = easyPaySign(params, e.config["pkey"])
q := url.Values{}
for k, v := range signed {
q.Set(k, v)
}
return q.Encode()
}
if _, err := e.VerifyNotification(context.Background(), encode(base), nil); err != nil {
t.Fatalf("free-text name must be accepted: %v", err)
}
for _, field := range []string{"trade_no", "out_trade_no", "type", "money", "param"} {
params := map[string]string{}
for k, v := range base {
params[k] = v
}
params[field] = "v&k=x"
if _, err := e.VerifyNotification(context.Background(), encode(params), nil); err == nil {
t.Fatalf("value with &/= in %s must be rejected", field)
}
}
}
@@ -20,6 +20,7 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
wantStatus string
wantTradeNo string
wantAmount float64
wantErr bool
}{
{
name: "top level trade success is paid",
@@ -64,10 +65,64 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
wantAmount: 3.21,
},
{
name: "query failure with missing status is pending",
body: `{"code":0,"msg":"订单不存在"}`,
name: "query failure code is an error",
body: `{"code":0,"msg":"订单不存在"}`,
wantErr: true,
},
{
name: "explicit failure code wins over paid status fields",
body: `{"code":-1,"msg":"签名错误","status":1,"trade_status":"TRADE_SUCCESS","money":"12.34"}`,
wantErr: true,
},
{
name: "string failure code is an error",
body: `{"code":"-1","msg":"error","status":1}`,
wantErr: true,
},
{
name: "foreign order echo is rejected",
body: `{"code":1,"pid":"pid-1","out_trade_no":"order-999","trade_status":"TRADE_SUCCESS","money":"12.34"}`,
wantErr: true,
},
{
name: "foreign order echo in nested data is rejected",
body: `{"code":1,"data":{"out_trade_no":"order-999","trade_status":"TRADE_SUCCESS","money":"12.34"}}`,
wantErr: true,
},
{
name: "string numeric status from PHP deployments is paid",
body: `{"code":"1","msg":"查询订单号成功!","trade_no":"2026101600001","out_trade_no":"order-123","pid":"1001","money":"12.34","status":"1"}`,
wantStatus: payment.ProviderStatusPaid,
wantTradeNo: "2026101600001",
wantAmount: 12.34,
},
{
name: "string numeric unpaid status is pending",
body: `{"code":1,"trade_no":"2026101600001","money":"12.34","status":"0"}`,
wantStatus: payment.ProviderStatusPending,
wantTradeNo: orderID,
wantTradeNo: "2026101600001",
wantAmount: 12.34,
},
{
name: "documented caihong response with integer pid and status is paid",
body: `{"code":1,"msg":"查询订单号成功!","trade_no":"2016080622555342651","out_trade_no":"order-123","api_trade_no":"4200001","type":"alipay","pid":1001,"addtime":"2016-08-06 22:55:52","endtime":"2016-08-06 22:55:52","name":"VIP会员","money":"1.00","status":1,"param":"","buyer":""}`,
wantStatus: payment.ProviderStatusPaid,
wantTradeNo: "2016080622555342651",
wantAmount: 1,
},
{
name: "null top-level fields fall back to nested data",
body: `{"code":1,"status":null,"money":null,"trade_no":null,"data":{"status":1,"money":"5.00","trade_no":"data-789"}}`,
wantStatus: payment.ProviderStatusPaid,
wantTradeNo: "data-789",
wantAmount: 5,
},
{
name: "matching identity echo with numeric fields is paid",
body: `{"code":"1","pid":"pid-1","out_trade_no":"order-123","trade_status":"TRADE_SUCCESS","money":12.34,"trade_no":2026101600001}`,
wantStatus: payment.ProviderStatusPaid,
wantTradeNo: "2026101600001",
wantAmount: 12.34,
},
{
name: "missing fields are pending",
@@ -93,9 +148,16 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
if err := r.ParseForm(); err != nil {
t.Errorf("ParseForm: %v", err)
}
gotForm = make(url.Values, len(r.PostForm))
// Parameters must be in the URL query (彩虹易支付 reads $_GET)
// and mirrored in the body for forks that read $_POST.
gotForm = r.URL.Query()
for key, values := range r.PostForm {
gotForm[key] = append([]string(nil), values...)
if got := gotForm.Get(key); got != values[0] {
t.Errorf("body[%s] = %q, query has %q", key, values[0], got)
}
}
if len(r.PostForm) != len(gotForm) {
t.Errorf("body params %v do not mirror query params %v", r.PostForm, gotForm)
}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(tt.body))
@@ -104,6 +166,12 @@ func TestEasyPayQueryOrderStatusMapping(t *testing.T) {
provider := newTestEasyPay(t, server.URL)
resp, err := provider.QueryOrder(context.Background(), orderID)
if tt.wantErr {
if err == nil {
t.Fatalf("QueryOrder succeeded, want error (response=%+v)", resp)
}
return
}
if err != nil {
t.Fatalf("QueryOrder returned error: %v", err)
}
+16 -1
View File
@@ -2,7 +2,10 @@
// registry, load balancing, and shared utilities for the payment subsystem.
package payment
import "context"
import (
"context"
"time"
)
// PaymentType represents a supported payment method.
type PaymentType = string
@@ -192,6 +195,8 @@ type RefundQueryRequest struct {
OrderID string
RefundID string
Amount string
// RequestedAt 为退款进入 pending 的时间;零值表示未知。
RequestedAt time.Time
}
// RefundResponse is returned after a refund request.
@@ -234,6 +239,16 @@ type RefundQueryProvider interface {
QueryRefund(ctx context.Context, req RefundQueryRequest) (*RefundResponse, error)
}
// NotificationConfirmer extends Provider for gateways whose verified
// notifications are not sufficient proof of payment on their own. The webhook
// path calls ConfirmNotification on the instance that verified the
// notification before fulfillment; implementations query the upstream order
// and may fill in missing notification fields (e.g. TradeNo) from it.
type NotificationConfirmer interface {
Provider
ConfirmNotification(ctx context.Context, n *PaymentNotification) error
}
// CancelableProvider extends Provider with the ability to cancel pending payments.
type CancelableProvider interface {
Provider
@@ -169,6 +169,12 @@ type modelDef struct {
var claudeModels = []modelDef{
{ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", CreatedAt: "2026-09-01T00:00:00Z"},
{ID: "claude-fable-5", DisplayName: "Claude Fable 5", CreatedAt: "2026-06-09T00:00:00Z"},
{ID: "claude-opus-5-5-high", DisplayName: "Claude Opus 5.5 (High)", CreatedAt: "2026-09-22T00:00:00Z"},
{ID: "claude-opus-5-5-medium", DisplayName: "Claude Opus 5.5 (Medium)", CreatedAt: "2026-09-22T00:00:00Z"},
{ID: "claude-opus-5-5-low", DisplayName: "Claude Opus 5.5 (Low)", CreatedAt: "2026-09-22T00:00:00Z"},
{ID: "claude-sonnet-5-5-high", DisplayName: "Claude Sonnet 5.5 (High)", CreatedAt: "2026-09-28T00:00:00Z"},
{ID: "claude-sonnet-5-5-medium", DisplayName: "Claude Sonnet 5.5 (Medium)", CreatedAt: "2026-09-28T00:00:00Z"},
{ID: "claude-sonnet-5-5-low", DisplayName: "Claude Sonnet 5.5 (Low)", CreatedAt: "2026-09-28T00:00:00Z"},
{ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"},
{ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"},
{ID: "claude-sonnet-4-5-thinking", DisplayName: "Claude Sonnet 4.5 Thinking", CreatedAt: "2025-09-29T00:00:00Z"},
@@ -208,6 +214,9 @@ var geminiModels = []modelDef{
{ID: "gemini-3.8-flash-low", DisplayName: "Gemini 3.8 Flash Low", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.8-flash-medium", DisplayName: "Gemini 3.8 Flash Medium", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.8-flash-tiered", DisplayName: "Gemini 3.8 Flash", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
{ID: "gemini-4-argon-high", DisplayName: "Gemini 4 Argon (High)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true},
{ID: "gemini-4-argon-low", DisplayName: "Gemini 4 Argon (Low)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true},
{ID: "gemini-4-argon-medium", DisplayName: "Gemini 4 Argon (Medium)", CreatedAt: "2026-10-13T00:00:00Z", IsReasoning: true},
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
}
@@ -277,6 +286,9 @@ func FallbackGeminiModel(model string) GeminiModel {
// IsGeminiReasoningModel 判断是否为不支持参数和强制 ToolConfig 的 Gemini 推理模型
func IsGeminiReasoningModel(modelID string) bool {
lowerID := strings.ToLower(modelID)
if strings.Contains(lowerID, "gemini-pro-agent") || strings.Contains(lowerID, "gemini-4-argon") {
return true
}
for _, m := range geminiModels {
if strings.Contains(lowerID, m.ID) && m.IsReasoning {
return true
@@ -39,6 +39,15 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
"gemini-3.8-flash-low",
"gemini-3.8-flash-medium",
"gemini-3.8-flash-tiered",
"gemini-4-argon-high",
"gemini-4-argon-low",
"gemini-4-argon-medium",
"claude-opus-5-5-high",
"claude-opus-5-5-low",
"claude-opus-5-5-medium",
"claude-sonnet-5-5-high",
"claude-sonnet-5-5-low",
"claude-sonnet-5-5-medium",
}
for _, id := range requiredIDs {
@@ -67,3 +76,23 @@ func TestClaudeContentItem_ThinkingAlwaysHasSignature(t *testing.T) {
t.Fatalf("text block = %s, want %s", got, want)
}
}
func TestIsGeminiReasoningModel_IncludesProAgent(t *testing.T) {
t.Parallel()
if !IsGeminiReasoningModel("gemini-pro-agent") {
t.Fatal("expected gemini-pro-agent to be recognized as a Gemini reasoning model")
}
if !IsGeminiReasoningModel("gemini-3.8-flash-high") {
t.Fatal("expected gemini-3.8-flash-high to be recognized as a Gemini reasoning model")
}
if !IsGeminiReasoningModel("gemini-4-argon") {
t.Fatal("expected gemini-4-argon to be recognized as a Gemini reasoning model")
}
if !IsGeminiReasoningModel("gemini-4-argon-high") {
t.Fatal("expected gemini-4-argon-high to be recognized as a Gemini reasoning model")
}
if IsGeminiReasoningModel("gemini-2.5-flash") {
t.Fatal("did not expect gemini-2.5-flash to be recognized as a Gemini reasoning model")
}
}
+47 -4
View File
@@ -239,10 +239,39 @@ func TierIDToPlanType(tierID string) string {
if tierID == "" {
return "Free"
}
// Google 新增的细分档位(如 g1-ultra-lite-tier、g1-pro-plus-tier)仍归入 Ultra / Pro。
if plan := paidPlanFromTierID(tierID); plan != "" {
return plan
}
return tierID
}
}
// paidPlanFromTierID 识别 g1-ultra-*-tier / g1-pro-*-tier 形式的付费档位 ID,未知时返回空串。
func paidPlanFromTierID(tierID string) string {
id := strings.ToLower(strings.TrimSpace(tierID))
if !strings.HasSuffix(id, "-tier") {
return ""
}
switch {
case strings.HasPrefix(id, "g1-ultra-"):
return "Ultra"
case strings.HasPrefix(id, "g1-pro-"):
return "Pro"
default:
return ""
}
}
// IsPaidPlanType 判断账号记录的 plan_type 是否为付费套餐,兼容旧记录里直接保存的档位 ID。
func IsPaidPlanType(planType string) bool {
switch strings.ToLower(strings.TrimSpace(planType)) {
case "pro", "ultra":
return true
}
return paidPlanFromTierID(planType) != ""
}
// Client Antigravity API 客户端
type Client struct {
httpClient *http.Client
@@ -653,8 +682,22 @@ type FetchAvailableModelsResponse struct {
}
// FetchAvailableModels 获取可用模型和配额信息,返回解析后的结构体和原始 JSON
// 支持 URL fallback:sandbox → daily → prod
// 保留旧调用的 URL fallback:prod → daily。
func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) {
return c.fetchAvailableModels(ctx, BaseURLs, accessToken, projectID, bodyLimit, false)
}
// FetchAvailableModelsFromURL 获取指定端点的模型目录,不回退到其他端点。
// 模型发现应使用与生成请求相同的端点,避免返回该端点无法访问的模型。
func (c *Client) FetchAvailableModelsFromURL(ctx context.Context, baseURL, accessToken, projectID string, bodyLimit int64) (*FetchAvailableModelsResponse, map[string]any, error) {
baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/")
if baseURL == "" {
return nil, nil, errors.New("fetchAvailableModels base URL is required")
}
return c.fetchAvailableModels(ctx, []string{baseURL}, accessToken, projectID, bodyLimit, true)
}
func (c *Client) fetchAvailableModels(ctx context.Context, availableURLs []string, accessToken, projectID string, bodyLimit int64, fixedEndpoint bool) (*FetchAvailableModelsResponse, map[string]any, error) {
if c == nil || c.httpClient == nil {
return nil, nil, errors.New("antigravity client is not configured")
}
@@ -668,10 +711,10 @@ func (c *Client) FetchAvailableModels(ctx context.Context, accessToken, projectI
return nil, nil, fmt.Errorf("序列化请求失败: %w", err)
}
// 固定顺序:prod -> daily
availableURLs := BaseURLs
fetchClient := c.fetchAvailableModelsHTTPClient()
if fixedEndpoint {
fetchClient.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }
}
var lastErr error
for urlIdx, baseURL := range availableURLs {
apiURL := baseURL + "/v1internal:fetchAvailableModels"
@@ -6,6 +6,7 @@ import (
"context"
"encoding/json"
"fmt"
"io"
"net"
"net/http"
"net/http/httptest"
@@ -260,6 +261,8 @@ func TestTierIDToPlanType(t *testing.T) {
{"free-tier", "Free"},
{"g1-pro-tier", "Pro"},
{"g1-ultra-tier", "Ultra"},
{"g1-ultra-lite-tier", "Ultra"},
{"G1-Pro-Plus-Tier", "Pro"},
{"FREE-TIER", "Free"},
{"", "Free"},
{"unknown-tier", "unknown-tier"},
@@ -1468,6 +1471,113 @@ func TestClient_LoadCodeAssist_ContextCanceled_RealCall(t *testing.T) {
// Client.FetchAvailableModels - 真正调用方法的测试
// ---------------------------------------------------------------------------
func TestClient_FetchAvailableModelsFromURL_UsesSelectedEndpoint(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1internal:fetchAvailableModels" || r.Method != http.MethodPost {
t.Errorf("unexpected model list request: %s %s", r.Method, r.URL.Path)
}
if r.Header.Get("Authorization") != "Bearer selected-token" {
t.Errorf("unexpected authorization header")
}
var body FetchAvailableModelsRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || body.Project != "selected-project" {
t.Errorf("unexpected project request: %+v, %v", body, err)
}
_, _ = w.Write([]byte(`{"models":{"gemini-3.8-flash-high":{"displayName":"Gemini 3.8 Flash High","supportsImages":true,"supportsThinking":true,"thinkingBudget":24576,"maxTokens":1048576,"maxOutputTokens":65536}}}`))
}))
defer server.Close()
client := mustNewClient(t, "")
response, raw, err := client.FetchAvailableModelsFromURL(context.Background(), server.URL, "selected-token", "selected-project", defaultFetchAvailableModelsBodyLimit)
if err != nil {
t.Fatalf("selected endpoint should return its catalog: %v", err)
}
model, ok := response.Models["gemini-3.8-flash-high"]
if !ok || model.SupportsThinking == nil || !*model.SupportsThinking || model.MaxTokens == nil || *model.MaxTokens != 1048576 {
t.Fatalf("selected endpoint metadata must remain available: %+v", model)
}
if raw["models"] == nil {
t.Fatal("selected endpoint raw catalog must remain available")
}
}
func TestClient_FetchAvailableModelsFromURL_DoesNotFollowAnotherEndpoint(t *testing.T) {
otherEndpointCalls := 0
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
otherEndpointCalls++
_, _ = w.Write([]byte(`{"models":{"other-endpoint-model":{}}}`))
}))
defer other.Close()
selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Redirect(w, r, other.URL+"/v1internal:fetchAvailableModels", http.StatusTemporaryRedirect)
}))
defer selected.Close()
withMockBaseURLs(t, []string{selected.URL, other.URL})
response, _, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), selected.URL, "token", "project", defaultFetchAvailableModelsBodyLimit)
if err == nil || response != nil {
t.Fatalf("selected endpoint redirect must not produce another endpoint's catalog: %v, %v", response, err)
}
if otherEndpointCalls != 0 {
t.Fatalf("model discovery must stay on selected endpoint; other endpoint received %d requests", otherEndpointCalls)
}
}
func TestClient_FetchAvailableModelsFromURL_DoesNotFallbackOnFailure(t *testing.T) {
for _, status := range []int{http.StatusNotFound, http.StatusTooManyRequests, http.StatusInternalServerError, http.StatusForbidden} {
t.Run(fmt.Sprint(status), func(t *testing.T) {
otherEndpointCalls := 0
other := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
otherEndpointCalls++
_, _ = w.Write([]byte(`{"models":{"other-endpoint-model":{}}}`))
}))
defer other.Close()
selected := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(status)
_, _ = w.Write([]byte(`{"error":"selected endpoint unavailable"}`))
}))
defer selected.Close()
withMockBaseURLs(t, []string{selected.URL, other.URL})
response, raw, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), selected.URL, "token", "project", defaultFetchAvailableModelsBodyLimit)
if err == nil || response != nil || raw != nil {
t.Fatalf("selected endpoint HTTP %d must fail without another catalog: %v, %v", status, response, err)
}
if otherEndpointCalls != 0 {
t.Fatalf("another endpoint must not receive fallback requests: %d", otherEndpointCalls)
}
if status == http.StatusForbidden {
if _, ok := err.(*ForbiddenError); !ok {
t.Fatalf("403 must preserve typed forbidden error: %T", err)
}
}
})
}
}
func TestClient_FetchAvailableModelsFromURL_ValidatesResponseAndLimit(t *testing.T) {
for _, tc := range []struct {
name string
body string
limit int64
}{
{name: "invalid JSON", body: `not JSON`, limit: defaultFetchAvailableModelsBodyLimit},
{name: "oversized response", body: `{"models":{"model":{}}}`, limit: 8},
{name: "nonpositive limit", body: `{"models":{"model":{}}}`, limit: 0},
} {
t.Run(tc.name, func(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, tc.body)
}))
defer server.Close()
_, _, err := mustNewClient(t, "").FetchAvailableModelsFromURL(context.Background(), server.URL, "token", "project", tc.limit)
if err == nil {
t.Fatal("invalid model catalog response or limit must fail")
}
})
}
}
func TestClient_FetchAvailableModels_Success_RealCall(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
@@ -1787,6 +1897,51 @@ func TestClient_FetchAvailableModels_404Fallback_RealCall(t *testing.T) {
}
}
func TestClient_HubUserAgentAndIDEVersionUseContextOverride(t *testing.T) {
withTestUserAgentVersionResolver(t)
ctx := context.WithValue(context.Background(), userAgentVersionTestContextKey{}, "3.4.5")
const expectedUserAgent = "antigravity/hub/3.4.5 windows/amd64"
for _, action := range []string{"generateContent", "streamGenerateContent"} {
req, err := NewAPIRequestWithURL(ctx, "https://upstream.example", action, "test-token", []byte(`{}`))
if err != nil {
t.Fatal(err)
}
if got := req.Header.Get("User-Agent"); got != expectedUserAgent {
t.Fatalf("generation request must use the context Hub user agent: %q", got)
}
}
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("User-Agent"); got != expectedUserAgent {
t.Errorf("client API must use the context Hub user agent: %q", got)
}
switch r.URL.Path {
case "/v1internal:loadCodeAssist":
var body LoadCodeAssistRequest
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Error(err)
}
if body.Metadata.IDEVersion != "3.4.5" {
t.Errorf("ideVersion must match the user agent version: %q", body.Metadata.IDEVersion)
}
_, _ = io.WriteString(w, `{"cloudaicompanionProject":"test-project"}`)
case "/v1internal:fetchAvailableModels":
_, _ = io.WriteString(w, `{"models":{"gemini-4-argon-high":{}}}`)
default:
t.Errorf("unexpected client endpoint: %s", r.URL.Path)
w.WriteHeader(http.StatusNotFound)
}
}))
defer server.Close()
withMockBaseURLs(t, []string{server.URL})
client := mustNewClient(t, "")
if _, _, err := client.LoadCodeAssist(ctx, "test-token"); err != nil {
t.Fatal(err)
}
if _, _, err := client.FetchAvailableModelsFromURL(ctx, server.URL, "test-token", "test-project", defaultFetchAvailableModelsBodyLimit); err != nil {
t.Fatal(err)
}
}
func TestExtractProjectIDFromOnboardResponse(t *testing.T) {
t.Parallel()
+3 -3
View File
@@ -34,7 +34,7 @@ const (
AntigravityUserAgentVersionEnv = "ANTIGRAVITY_USER_AGENT_VERSION"
// DefaultUserAgentVersion 是未通过环境变量或后台设置覆盖时使用的默认版本号。
DefaultUserAgentVersion = "2.9.1"
DefaultUserAgentVersion = "2.23.0"
// 固定的 redirect_uri(用户需手动复制 code)
RedirectURI = "http://localhost:8085/callback"
@@ -123,9 +123,9 @@ func GetUserAgentVersionForContext(ctx context.Context) string {
// BuildUserAgent 使用指定版本号构造 User-Agent;版本为空或非法时回退默认值。
func BuildUserAgent(version string) string {
if normalized := NormalizeUserAgentVersion(version); normalized != "" {
return fmt.Sprintf("antigravity/%s windows/amd64", normalized)
return fmt.Sprintf("antigravity/hub/%s windows/amd64", normalized)
}
return fmt.Sprintf("antigravity/%s windows/amd64", defaultUserAgentVersion)
return fmt.Sprintf("antigravity/hub/%s windows/amd64", defaultUserAgentVersion)
}
// GetUserAgentForContext 返回当前请求应使用的 User-Agent。
+107 -1
View File
@@ -3,16 +3,119 @@
package antigravity
import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"net/url"
"os"
"os/exec"
"strings"
"testing"
"time"
)
func TestBuildUserAgentUsesHubClientFamily(t *testing.T) {
if got := BuildUserAgent(" 2.23.0 "); got != "antigravity/hub/2.23.0 windows/amd64" {
t.Fatalf("Hub client family must be sent with the configured version: %q", got)
}
}
func TestBuildUserAgentValidatesCustomVersion(t *testing.T) {
previous := defaultUserAgentVersion
defaultUserAgentVersion = "2.23.0"
t.Cleanup(func() { defaultUserAgentVersion = previous })
for _, tc := range []struct {
version string
want string
}{
{version: " 3.4.5 ", want: "antigravity/hub/3.4.5 windows/amd64"},
{version: "", want: "antigravity/hub/2.23.0 windows/amd64"},
{version: "hub/3.4.5", want: "antigravity/hub/2.23.0 windows/amd64"},
{version: "3.4", want: "antigravity/hub/2.23.0 windows/amd64"},
{version: "3.4.5\r\nInjected: header", want: "antigravity/hub/2.23.0 windows/amd64"},
} {
t.Run(tc.version, func(t *testing.T) {
if got := BuildUserAgent(tc.version); got != tc.want {
t.Fatalf("custom version must be validated before building Hub user agent: got %q, want %q", got, tc.want)
}
})
}
}
type userAgentVersionTestContextKey struct{}
func withTestUserAgentVersionResolver(t *testing.T) {
t.Helper()
userAgentVersionMu.RLock()
previous := userAgentVersionResolver
userAgentVersionMu.RUnlock()
SetUserAgentVersionResolver(func(ctx context.Context) string {
version, _ := ctx.Value(userAgentVersionTestContextKey{}).(string)
return version
})
t.Cleanup(func() { SetUserAgentVersionResolver(previous) })
}
func TestGetUserAgentForContextPreservesVersionOverride(t *testing.T) {
withTestUserAgentVersionResolver(t)
previous := defaultUserAgentVersion
defaultUserAgentVersion = "2.23.0"
t.Cleanup(func() { defaultUserAgentVersion = previous })
for _, tc := range []struct {
version string
want string
}{
{version: " 3.4.5 ", want: "antigravity/hub/3.4.5 windows/amd64"},
{version: "invalid", want: "antigravity/hub/2.23.0 windows/amd64"},
} {
ctx := context.WithValue(context.Background(), userAgentVersionTestContextKey{}, tc.version)
if got := GetUserAgentForContext(ctx); got != tc.want {
t.Fatalf("context version override must use Hub family: got %q, want %q", got, tc.want)
}
}
if got := GetUserAgentForContext(nil); got != "antigravity/hub/2.23.0 windows/amd64" {
t.Fatalf("nil context must keep the default Hub user agent: %q", got)
}
}
func TestGetUserAgentEnvironmentOverride(t *testing.T) {
const expectedVersionEnv = "SUB2API_TEST_EXPECTED_ANTIGRAVITY_VERSION"
if expected := os.Getenv(expectedVersionEnv); expected != "" {
if got := GetDefaultUserAgentVersion(); got != expected {
t.Fatalf("environment version must be loaded during initialization: got %q, want %q", got, expected)
}
if got := GetUserAgent(); got != "antigravity/hub/"+expected+" windows/amd64" {
t.Fatalf("environment override must keep Hub family: %q", got)
}
return
}
for _, tc := range []struct {
version string
want string
}{
{version: " 3.4.5 ", want: "3.4.5"},
{version: "invalid", want: "2.23.0"},
} {
t.Run(tc.version, func(t *testing.T) {
binary, err := os.Executable()
if err != nil {
t.Fatal(err)
}
command := exec.Command(binary, "-test.run=^TestGetUserAgentEnvironmentOverride$")
for _, variable := range os.Environ() {
if !strings.HasPrefix(variable, AntigravityUserAgentVersionEnv+"=") && !strings.HasPrefix(variable, expectedVersionEnv+"=") {
command.Env = append(command.Env, variable)
}
}
command.Env = append(command.Env, AntigravityUserAgentVersionEnv+"="+tc.version, expectedVersionEnv+"="+tc.want)
if output, err := command.CombinedOutput(); err != nil {
t.Fatalf("environment override failed: %v\n%s", err, output)
}
})
}
}
// ---------------------------------------------------------------------------
// getClientSecret
// ---------------------------------------------------------------------------
@@ -693,7 +796,10 @@ func TestConstants_值正确(t *testing.T) {
if RedirectURI != "http://localhost:8085/callback" {
t.Errorf("RedirectURI 不匹配: got %s", RedirectURI)
}
if GetUserAgent() != "antigravity/2.9.1 windows/amd64" {
if DefaultUserAgentVersion != "2.23.0" {
t.Errorf("default Hub version must match the verified upstream release: got %s", DefaultUserAgentVersion)
}
if GetUserAgent() != "antigravity/hub/2.23.0 windows/amd64" {
t.Errorf("UserAgent 不匹配: got %s", GetUserAgent())
}
if SessionTTL != 30*time.Minute {
@@ -588,6 +588,7 @@ func buildParts(content json.RawMessage, toolIDToName map[string]string, allowDu
ID: block.ToolUseID,
},
})
parts = append(parts, extractToolResultInlineParts(block.Content)...)
}
}
@@ -638,6 +639,31 @@ func parseToolResultContent(content json.RawMessage, isError bool) string {
return string(content)
}
func extractToolResultInlineParts(content json.RawMessage) []GeminiPart {
if len(content) == 0 {
return nil
}
var innerBlocks []ContentBlock
if err := json.Unmarshal(content, &innerBlocks); err != nil {
return nil
}
var inlineParts []GeminiPart
for _, item := range innerBlocks {
if (item.Type == "image" || item.Type == "document") &&
item.Source != nil &&
item.Source.Type == "base64" &&
strings.TrimSpace(item.Source.Data) != "" {
inlineParts = append(inlineParts, GeminiPart{
InlineData: &GeminiInlineData{
MimeType: item.Source.MediaType,
Data: item.Source.Data,
},
})
}
}
return inlineParts
}
// buildGenerationConfig 构建 generationConfig
const (
defaultMaxOutputTokens = 64000
@@ -694,3 +694,64 @@ func TestToolConfigAlwaysPresent(t *testing.T) {
})
}
}
func TestTransformClaudeToGemini_ToolResultImagesReachUpstream(t *testing.T) {
claudeReq := &ClaudeRequest{
Model: "claude-sonnet-4-5",
Messages: []ClaudeMessage{
{
Role: "assistant",
Content: json.RawMessage(`[
{"type":"tool_use","id":"toolu_img_1","name":"read_image","input":{"path":"colors.png"}}
]`),
},
{
Role: "user",
Content: json.RawMessage(`[
{
"type":"tool_result",
"tool_use_id":"toolu_img_1",
"content":[
{"type":"text","text":"loaded colors.png"},
{"type":"image","source":{"type":"base64","media_type":"image/png","data":"aGVsbG8="}}
]
},
{
"type":"tool_result",
"tool_use_id":"toolu_img_1",
"content":[
{"type":"image","source":{"type":"base64","media_type":"image/jpeg","data":"d29ybGQ="}}
]
}
]`),
},
},
}
body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-5", DefaultTransformOptions())
require.NoError(t, err)
var req V1InternalRequest
require.NoError(t, json.Unmarshal(body, &req))
require.Len(t, req.Request.Contents, 2)
userParts := req.Request.Contents[1].Parts
require.Len(t, userParts, 4)
require.NotNil(t, userParts[0].FunctionResponse)
require.Equal(t, "read_image", userParts[0].FunctionResponse.Name)
require.Equal(t, "toolu_img_1", userParts[0].FunctionResponse.ID)
require.Equal(t, "loaded colors.png", userParts[0].FunctionResponse.Response["result"])
require.NotNil(t, userParts[1].InlineData)
require.Equal(t, "image/png", userParts[1].InlineData.MimeType)
require.Equal(t, "aGVsbG8=", userParts[1].InlineData.Data)
require.NotNil(t, userParts[2].FunctionResponse)
require.Equal(t, "read_image", userParts[2].FunctionResponse.Name)
require.Equal(t, "Command executed successfully.", userParts[2].FunctionResponse.Response["result"])
require.NotNil(t, userParts[3].InlineData)
require.Equal(t, "image/jpeg", userParts[3].InlineData.MimeType)
require.Equal(t, "d29ybGQ=", userParts[3].InlineData.Data)
}
@@ -277,11 +277,12 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
}
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去。
// prompt 被拦截时上游可能只报 cachedContentTokenCount,差值下限为 0。
usage := ClaudeUsage{}
if geminiResp.UsageMetadata != nil {
cached := geminiResp.UsageMetadata.CachedContentTokenCount
usage.InputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
usage.InputTokens = max(geminiResp.UsageMetadata.PromptTokenCount-cached, 0)
usage.OutputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
usage.CacheReadInputTokens = cached
usage.ImageOutputTokens = geminiResp.UsageMetadata.ImageOutputTokens()
@@ -109,10 +109,11 @@ func (p *StreamingProcessor) ProcessLine(line string) []byte {
// 更新 usage
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去。
// prompt 被拦截时上游可能只报 cachedContentTokenCount,差值下限为 0。
if geminiResp.UsageMetadata != nil {
cached := geminiResp.UsageMetadata.CachedContentTokenCount
p.inputTokens = geminiResp.UsageMetadata.PromptTokenCount - cached
p.inputTokens = max(geminiResp.UsageMetadata.PromptTokenCount-cached, 0)
p.outputTokens = geminiResp.UsageMetadata.CandidatesTokenCount + geminiResp.UsageMetadata.ThoughtsTokenCount
p.cacheReadTokens = cached
p.imageOutputTokens = geminiResp.UsageMetadata.ImageOutputTokens()
@@ -190,7 +191,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
usage := ClaudeUsage{}
if v1Resp.Response.UsageMetadata != nil {
cached := v1Resp.Response.UsageMetadata.CachedContentTokenCount
usage.InputTokens = v1Resp.Response.UsageMetadata.PromptTokenCount - cached
usage.InputTokens = max(v1Resp.Response.UsageMetadata.PromptTokenCount-cached, 0)
usage.OutputTokens = v1Resp.Response.UsageMetadata.CandidatesTokenCount + v1Resp.Response.UsageMetadata.ThoughtsTokenCount
usage.CacheReadInputTokens = cached
usage.ImageOutputTokens = v1Resp.Response.UsageMetadata.ImageOutputTokens()
@@ -38,3 +38,29 @@ func TestGeminiUsageMapping_NoCacheCreationTokens(t *testing.T) {
require.Zero(t, usage.CacheCreationInputTokens)
})
}
// usageMetadata 缺 promptTokenCount、只有 cachedContentTokenCount 时,input_tokens 下限为 0,
// 缓存命中仍计入 cache_read_input_tokens。
func TestGeminiUsageMapping_MissingPromptTokenCountKeepsInputNonNegative(t *testing.T) {
const geminiBody = `{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}],` +
`"usageMetadata":{"cachedContentTokenCount":30}}`
t.Run("non-stream", func(t *testing.T) {
_, usage, err := TransformGeminiToClaude([]byte(geminiBody), "gemini-3.1-pro-preview")
require.NoError(t, err)
require.NotNil(t, usage)
require.Zero(t, usage.InputTokens)
require.Equal(t, 30, usage.CacheReadInputTokens)
})
t.Run("stream", func(t *testing.T) {
p := NewStreamingProcessor("gemini-3.1-pro-preview")
out := p.ProcessLine(`data: {"response":` + geminiBody + `}`)
require.True(t, strings.Contains(string(out), `"message_start"`))
require.NotContains(t, string(out), `"input_tokens":-`, "message_start 的 input_tokens 不应为负")
_, usage := p.Finish()
require.NotNil(t, usage)
require.Zero(t, usage.InputTokens)
require.Equal(t, 30, usage.CacheReadInputTokens)
})
}
@@ -13,6 +13,12 @@ import (
// Chat Completions intermediary round-trip (e.g. thinking, cache_control,
// structured system prompts).
func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
return AnthropicToResponsesWithMinOutputTokens(req, DefaultMinMaxOutputTokens)
}
// AnthropicToResponsesWithMinOutputTokens converts an Anthropic request using
// the supplied max_output_tokens floor.
func AnthropicToResponsesWithMinOutputTokens(req *AnthropicRequest, minOutputTokens int) (*ResponsesRequest, error) {
if err := openai.ValidateGPT61SolReasoningEffort(req.Model, anthropicReasoningEffort(req)); err != nil {
return nil, err
}
@@ -50,8 +56,8 @@ func AnthropicToResponses(req *AnthropicRequest) (*ResponsesRequest, error) {
if req.MaxTokens > 0 {
v := req.MaxTokens
if v < minMaxOutputTokens {
v = minMaxOutputTokens
if v < minOutputTokens {
v = minOutputTokens
}
out.MaxOutputTokens = &v
}
@@ -40,6 +40,12 @@ import (
// ResponsesToChatCompletionsRequest but avoids materializing the intermediate
// ResponsesRequest and the extra marshal/unmarshal cycle.
func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsRequest, error) {
return AnthropicToChatCompletionsRequestWithMinOutputTokens(req, DefaultMinMaxOutputTokens)
}
// AnthropicToChatCompletionsRequestWithMinOutputTokens converts an Anthropic
// Messages request using the supplied max_completion_tokens floor.
func AnthropicToChatCompletionsRequestWithMinOutputTokens(req *AnthropicRequest, minOutputTokens int) (*ChatCompletionsRequest, error) {
if req == nil {
return nil, fmt.Errorf("anthropic request is nil")
}
@@ -63,8 +69,8 @@ func AnthropicToChatCompletionsRequest(req *AnthropicRequest) (*ChatCompletionsR
if req.MaxTokens > 0 {
v := req.MaxTokens
if v < minMaxOutputTokens {
v = minMaxOutputTokens
if v < minOutputTokens {
v = minOutputTokens
}
out.MaxCompletionTokens = &v
}
@@ -267,6 +267,43 @@ func TestAnthropicToChatCompletionsRequest_MaxTokensFloor(t *testing.T) {
require.Equal(t, minMaxOutputTokens, *out.MaxCompletionTokens)
}
func TestAnthropicToChatCompletionsRequestWithMinOutputTokens_ConfiguredFloor(t *testing.T) {
req := &AnthropicRequest{
Model: "claude-sonnet-4-20250514",
MaxTokens: 16,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
}
out, err := AnthropicToChatCompletionsRequestWithMinOutputTokens(req, 64)
require.NoError(t, err)
require.NotNil(t, out.MaxCompletionTokens)
require.Equal(t, 64, *out.MaxCompletionTokens)
}
func TestAnthropicToResponses_ConfiguredMaxTokensFloor(t *testing.T) {
for _, input := range []int{16, 64, 127} {
req := &AnthropicRequest{
Model: "claude-sonnet-4-20250514",
MaxTokens: input,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
}
out, err := AnthropicToResponsesWithMinOutputTokens(req, 16)
require.NoError(t, err)
require.NotNil(t, out.MaxOutputTokens)
require.Equal(t, input, *out.MaxOutputTokens)
}
req := &AnthropicRequest{
Model: "claude-sonnet-4-20250514",
MaxTokens: 1,
Messages: []AnthropicMessage{{Role: "user", Content: json.RawMessage(`"hi"`)}},
}
out, err := AnthropicToResponsesWithMinOutputTokens(req, 16)
require.NoError(t, err)
require.NotNil(t, out.MaxOutputTokens)
require.Equal(t, 16, *out.MaxOutputTokens)
}
func TestAnthropicToChatCompletionsRequest_ReasoningEffortMapping(t *testing.T) {
req := &AnthropicRequest{
Model: "gpt-5.4",
@@ -2,6 +2,7 @@ package apicompat
import (
"encoding/json"
"fmt"
"testing"
"github.com/stretchr/testify/assert"
@@ -290,6 +291,46 @@ func mustMarshalJSON(t *testing.T, value any) []byte {
}
func TestChatCompletionsToResponses_MaxTokens(t *testing.T) {
t.Run("default floor", func(t *testing.T) {
maxTokens := 1
req := &ChatCompletionsRequest{
Model: "gpt-4o",
MaxTokens: &maxTokens,
Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
}
resp, err := ChatCompletionsToResponses(req)
require.NoError(t, err)
require.NotNil(t, resp.MaxOutputTokens)
assert.Equal(t, DefaultMinMaxOutputTokens, *resp.MaxOutputTokens)
})
t.Run("configured floor", func(t *testing.T) {
for _, input := range []int{16, 64, 127, 128} {
input := input
t.Run(fmt.Sprintf("input_%d", input), func(t *testing.T) {
req := &ChatCompletionsRequest{
Model: "gpt-4o",
MaxTokens: &input,
Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
}
resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16)
require.NoError(t, err)
require.NotNil(t, resp.MaxOutputTokens)
assert.Equal(t, input, *resp.MaxOutputTokens)
})
}
input := 1
req := &ChatCompletionsRequest{
Model: "gpt-4o",
MaxTokens: &input,
Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
}
resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16)
require.NoError(t, err)
require.NotNil(t, resp.MaxOutputTokens)
assert.Equal(t, 16, *resp.MaxOutputTokens)
})
t.Run("max_tokens", func(t *testing.T) {
maxTokens := 100
req := &ChatCompletionsRequest{
@@ -318,6 +359,21 @@ func TestChatCompletionsToResponses_MaxTokens(t *testing.T) {
require.NotNil(t, resp.MaxOutputTokens)
assert.Equal(t, 500, *resp.MaxOutputTokens)
})
t.Run("max_completion_tokens_preferred before configured floor", func(t *testing.T) {
maxTokens := 500
maxCompletion := 1
req := &ChatCompletionsRequest{
Model: "gpt-4o",
MaxTokens: &maxTokens,
MaxCompletionTokens: &maxCompletion,
Messages: []ChatMessage{{Role: "user", Content: json.RawMessage(`"Hi"`)}},
}
resp, err := ChatCompletionsToResponsesWithMinOutputTokens(req, 16)
require.NoError(t, err)
require.NotNil(t, resp.MaxOutputTokens)
assert.Equal(t, 16, *resp.MaxOutputTokens)
})
}
func TestChatCompletionsToResponses_ReasoningEffort(t *testing.T) {
@@ -18,6 +18,12 @@ type chatMessageContent struct {
// true. store is always false and reasoning.encrypted_content is always
// included so that the response translator has full context.
func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest, error) {
return ChatCompletionsToResponsesWithMinOutputTokens(req, DefaultMinMaxOutputTokens)
}
// ChatCompletionsToResponsesWithMinOutputTokens converts a Chat Completions
// request using the supplied max_output_tokens floor.
func ChatCompletionsToResponsesWithMinOutputTokens(req *ChatCompletionsRequest, minOutputTokens int) (*ResponsesRequest, error) {
if err := openai.ValidateGPT61SolReasoningEffort(req.Model, req.ReasoningEffort); err != nil {
return nil, err
}
@@ -62,8 +68,8 @@ func ChatCompletionsToResponses(req *ChatCompletionsRequest) (*ResponsesRequest,
}
if maxTokens > 0 {
v := maxTokens
if v < minMaxOutputTokens {
v = minMaxOutputTokens
if v < minOutputTokens {
v = minOutputTokens
}
out.MaxOutputTokens = &v
}
+6 -3
View File
@@ -860,6 +860,9 @@ func (d ChatDelta) reasoningText() *string {
// Shared constants
// ---------------------------------------------------------------------------
// minMaxOutputTokens is the floor for max_output_tokens in a Responses request.
// Very small values may cause upstream API errors, so we enforce a minimum.
const minMaxOutputTokens = 128
// DefaultMinMaxOutputTokens is the default floor for max_output_tokens in a
// Responses request. Very small values may cause upstream API errors, so we
// enforce a minimum.
const DefaultMinMaxOutputTokens = 128
const minMaxOutputTokens = DefaultMinMaxOutputTokens
+1
View File
@@ -28,6 +28,7 @@ func DefaultModels() []Model {
{Name: "models/gemini-3.1-pro-preview", SupportedGenerationMethods: methods},
{Name: "models/gemini-3.1-pro-preview-customtools", SupportedGenerationMethods: methods},
{Name: "models/gemini-3.1-flash-image", SupportedGenerationMethods: methods},
{Name: "models/gemini-4-argon", SupportedGenerationMethods: methods},
}
}
+1
View File
@@ -20,6 +20,7 @@ var DefaultModels = []Model{
{ID: "gemini-3-pro-preview", Type: "model", DisplayName: "Gemini 3 Pro Preview", CreatedAt: ""},
{ID: "gemini-3.1-pro-preview", Type: "model", DisplayName: "Gemini 3.1 Pro Preview", CreatedAt: ""},
{ID: "gemini-3.1-flash-image", Type: "model", DisplayName: "Gemini 3.1 Flash Image", CreatedAt: ""},
{ID: "gemini-4-argon", Type: "model", DisplayName: "Gemini 4 Argon", CreatedAt: ""},
}
// GoogleOneModels is the conservative model set exposed for legacy Google One
@@ -185,6 +185,53 @@ func TestCompositeTargetPlatformMiddlewareRewritesNestedLiveModel(t *testing.T)
require.Equal(t, http.StatusNoContent, w.Code)
}
// Scenario: 回归 —— openai OAuth 与 deepseek API Key 同时映射 gpt-6-astra 时,
// 请求必须在路由层被拒绝,不能落到默认 Anthropic handler 后再按模型名猜平台。
// 仅 API Key 的跨平台歧义保持原行为(放行且不解析平台)。
func TestCompositeTargetPlatformMiddlewareRejectsOnlyOAuthAmbiguousOwnership(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
oauthClaimed bool
wantStatus int
}{
{name: "oauth claimant", oauthClaimed: true, wantStatus: http.StatusBadRequest},
{name: "apikey only", oauthClaimed: false, wantStatus: http.StatusNoContent},
} {
t.Run(tc.name, func(t *testing.T) {
resolver := service.NewCompositeRouteResolver(nil)
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (service.CompositeModelOwnership, error) {
return service.CompositeModelOwnership{Ambiguous: true, OpenAIGatewayOAuthClaimed: tc.oauthClaimed}, nil
})
router := gin.New()
router.Use(gin.HandlerFunc(servermiddleware.APIKeyAuthMiddleware(func(c *gin.Context) {
groupID := int64(8)
c.Set(string(servermiddleware.ContextKeyAPIKey), &service.APIKey{
GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
c.Next()
})))
router.Use(compositeTargetPlatformMiddleware(resolver))
router.POST("/v1/responses", func(c *gin.Context) {
_, resolved := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.False(t, resolved)
c.Status(http.StatusNoContent)
})
req := httptest.NewRequest(http.MethodPost, "/v1/responses", strings.NewReader(`{"model":"gpt-6-astra"}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, tc.wantStatus, w.Code)
if tc.oauthClaimed {
require.Contains(t, w.Body.String(), "explicit composite route")
}
})
}
}
func TestCompositeRequestModelFromMultipartLiveSession(t *testing.T) {
var body bytes.Buffer
writer := multipart.NewWriter(&body)
@@ -586,6 +586,14 @@ func compositeTargetPlatformMiddleware(resolver *service.CompositeRouteResolver)
c.Abort()
return
}
// 含 OAuth 账号的跨平台歧义是分组配置错误:拒绝而不是放行。否则未解析的
// 请求落到默认 Anthropic handler,handler 再按模型名前缀另猜平台,
// OAuth 凭据被送进不匹配的协议管线(如 ChatGPT token 发往 Anthropic)。
if decision.OAuthAmbiguous() {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": gin.H{"type": "invalid_request_error", "message": decision.AmbiguousMessage()}})
return
}
if decision.Matched {
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), decision))
if upstreamModel := strings.TrimSpace(decision.UpstreamModel); upstreamModel != "" && upstreamModel != model && gjson.ValidBytes(body) {
@@ -27,6 +27,14 @@ func (r *pinnedModelsRoutesRepository) ListByGroup(context.Context, int64) ([]se
return []service.Account{r.account}, nil
}
func (r *pinnedModelsRoutesRepository) ListSchedulableByGroupID(context.Context, int64) ([]service.Account, error) {
return []service.Account{r.account}, nil
}
func (r *pinnedModelsRoutesRepository) ListModelAvailabilityCandidates(context.Context, *int64, []string, bool) ([]service.Account, error) {
return []service.Account{r.account}, nil
}
type pinnedModelsRoutesUpstream struct {
service.HTTPUpstream
ordinaryCalls atomic.Int32
+55 -21
View File
@@ -8,6 +8,7 @@ import (
"log/slog"
"net/url"
"reflect"
"slices"
"sort"
"strconv"
"strings"
@@ -690,26 +691,7 @@ func (a *Account) resolveModelMapping(rawMapping map[string]any) map[string]stri
}
if len(result) > 0 {
if a.Platform == domain.PlatformAntigravity {
ensureAntigravityDefaultPassthroughs(result, []string{
"gemini-3-flash",
"gemini-3.1-pro-high",
"gemini-3.1-pro-low",
"gemini-3.6-flash",
"gemini-3.6-flash-high",
"gemini-3.6-flash-low",
"gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered",
"gemini-3.7-flash",
"gemini-3.7-flash-high",
"gemini-3.7-flash-low",
"gemini-3.7-flash-medium",
"gemini-3.7-flash-tiered",
"gemini-3.8-flash",
"gemini-3.8-flash-high",
"gemini-3.8-flash-low",
"gemini-3.8-flash-medium",
"gemini-3.8-flash-tiered",
})
ensureAntigravityDefaultPassthroughs(result, antigravityDefaultPassthroughModels)
applyAntigravityGemini31ProAliases(result)
}
return result
@@ -759,6 +741,51 @@ func modelMappingSignature(rawMapping map[string]any) uint64 {
return h.Sum64()
}
// antigravityDefaultPassthroughModels 是 Antigravity 显式 model_mapping 运行时补齐的默认透传条目:
// 让新模型发布前保存的映射也能调用这些模型。
var antigravityDefaultPassthroughModels = []string{
"gemini-3-flash",
"gemini-3.1-pro-high",
"gemini-3.1-pro-low",
"gemini-3.6-flash",
"gemini-3.6-flash-high",
"gemini-3.6-flash-low",
"gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered",
"gemini-3.7-flash",
"gemini-3.7-flash-high",
"gemini-3.7-flash-low",
"gemini-3.7-flash-medium",
"gemini-3.7-flash-tiered",
"gemini-3.8-flash",
"gemini-3.8-flash-high",
"gemini-3.8-flash-low",
"gemini-3.8-flash-medium",
"gemini-3.8-flash-tiered",
}
// isAntigravityInjectedDefaultPassthrough 判断 GetModelMapping 中的 key→target 是否仅由
// resolveModelMapping 运行时补齐(管理员未写入 credentials.model_mapping)。
func (a *Account) isAntigravityInjectedDefaultPassthrough(key, target string) bool {
if a == nil || a.Platform != domain.PlatformAntigravity || key != target || !slices.Contains(antigravityDefaultPassthroughModels, key) {
return false
}
rawMapping, _ := a.Credentials["model_mapping"].(map[string]any)
hasExplicitEntry := false
for _, v := range rawMapping {
if _, ok := v.(string); ok {
hasExplicitEntry = true
break
}
}
if !hasExplicitEntry {
// 空映射走默认目录,不做运行时补齐。
return false
}
_, configured := rawMapping[key].(string)
return !configured
}
func ensureAntigravityDefaultPassthrough(mapping map[string]string, model string) {
if mapping == nil || model == "" {
return
@@ -887,6 +914,10 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
// (isDeepseekServableModel)——未知模型名透传上游只会得到 404/400,并误触发
// per-(账号,模型) 30 分钟冷却;带 [1m] 上下文后缀的写法先归一化再比对。
func (a *Account) IsModelSupported(requestedModel string) bool {
return supportsOpenAIRequestModel(a, requestedModel, false)
}
func (a *Account) isModelMappingSupported(requestedModel string) bool {
// 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
// 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
// credentials 里常残留旧的非空 model_mapping,若不在此放行,透传账号会被
@@ -2316,7 +2347,10 @@ const (
// 三态:default(跟随渠道)/ enabled(强制开启)/ disabled(强制关闭)。
// 兼容旧 bool 值:true→enabled, false→default(并记录 debug 日志)。
func (a *Account) GetWebSearchEmulationMode() string {
if a == nil || a.Platform != PlatformAnthropic || a.Type != AccountTypeAPIKey || a.Extra == nil {
// OpenAI API Key accounts reach this via /v1/messages; their upstreams
// (DeepSeek etc. over Chat Completions) have no web_search tool.
if a == nil || a.Type != AccountTypeAPIKey || a.Extra == nil ||
(a.Platform != PlatformAnthropic && a.Platform != PlatformOpenAI) {
return WebSearchModeDefault
}
raw := a.Extra[featureKeyWebSearchEmulation]
@@ -2464,12 +2464,12 @@ func (s *AccountTestService) routeAntigravityTest(c *gin.Context, account *Accou
}
return s.testClaudeAccountConnection(c, account, modelID)
}
return s.testAntigravityAccountConnection(c, account, modelID)
return s.testAntigravityAccountConnection(c, account, modelID, prompt)
}
// testAntigravityAccountConnection tests an Antigravity account's connection
// 支持 Claude 和 Gemini 两种协议,使用非流式请求
func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string) error {
func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, account *Account, modelID string, prompt string) error {
ctx := c.Request.Context()
testModelID := antigravityConnectionTestModel(modelID)
@@ -2488,8 +2488,8 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac
// Send test_start event
s.sendEvent(c, TestEvent{Type: "test_start", Model: testModelID})
// 调用 AntigravityGatewayService.TestConnection(复用协议转换逻辑)
result, err := s.antigravityGatewayService.TestConnection(ctx, account, testModelID)
// 调用 AntigravityGatewayService.TestConnectionWithPrompt(复用协议转换逻辑)
result, err := s.antigravityGatewayService.TestConnectionWithPrompt(ctx, account, testModelID, prompt)
if err != nil {
return s.sendErrorAndEnd(c, err.Error())
}
@@ -2498,6 +2498,13 @@ func (s *AccountTestService) testAntigravityAccountConnection(c *gin.Context, ac
if result.Text != "" {
s.sendEvent(c, TestEvent{Type: "content", Text: result.Text})
}
for _, img := range result.Images {
s.sendEvent(c, TestEvent{
Type: "image",
ImageURL: img.ImageURL,
MimeType: img.MimeType,
})
}
s.sendEvent(c, TestEvent{Type: "test_complete", Success: true})
return nil
@@ -3,6 +3,7 @@
package service
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
@@ -14,3 +15,68 @@ func TestAntigravityConnectionTestModel(t *testing.T) {
require.Equal(t, "claude-sonnet-4-6", antigravityConnectionTestModel(""))
require.Equal(t, "gemini-3.1-pro-preview", antigravityConnectionTestModel("gemini-3.1-pro-preview"))
}
func TestAntigravityBuildGeminiTestRequest_IncludesValidatedToolConfig(t *testing.T) {
t.Parallel()
svc := &AntigravityGatewayService{}
raw, err := svc.buildGeminiTestRequestWithPrompt("proj-123", "gemini-3.8-flash-high", "")
require.NoError(t, err)
var v1Req map[string]any
require.NoError(t, json.Unmarshal(raw, &v1Req))
require.Equal(t, "proj-123", v1Req["project"])
require.Equal(t, "gemini-3.8-flash-high", v1Req["model"])
inner, ok := v1Req["request"].(map[string]any)
require.True(t, ok)
toolCfg, ok := inner["toolConfig"].(map[string]any)
require.True(t, ok, "expected toolConfig to be present in Gemini test request")
fcCfg, ok := toolCfg["functionCallingConfig"].(map[string]any)
require.True(t, ok)
require.Equal(t, "VALIDATED", fcCfg["mode"])
}
func TestAntigravityBuildGeminiTestRequestWithPrompt_ImageModel(t *testing.T) {
t.Parallel()
svc := &AntigravityGatewayService{}
raw, err := svc.buildGeminiTestRequestWithPrompt("proj-123", "gemini-3.1-flash-image", "a cute cat")
require.NoError(t, err)
var v1Req map[string]any
require.NoError(t, json.Unmarshal(raw, &v1Req))
inner, ok := v1Req["request"].(map[string]any)
require.True(t, ok)
genCfg, ok := inner["generationConfig"].(map[string]any)
require.True(t, ok)
require.Equal(t, []any{"TEXT", "IMAGE"}, genCfg["responseModalities"])
contents, ok := inner["contents"].([]any)
require.True(t, ok)
require.Len(t, contents, 1)
firstContent := contents[0].(map[string]any)
parts := firstContent["parts"].([]any)
require.Equal(t, "a cute cat", parts[0].(map[string]any)["text"])
}
func TestExtractContentFromSSEResponse_ExtractsTextAndInlineImages(t *testing.T) {
t.Parallel()
sse := "data: {\"response\":{\"candidates\":[{\"content\":{\"parts\":[{\"text\":\"Here is your image\"},{\"inlineData\":{\"mimeType\":\"image/png\",\"data\":\"iVBORw0KGgo=\"}}]}}]}}\n\n"
text, images := extractContentFromSSEResponse([]byte(sse))
require.Equal(t, "Here is your image", text)
require.Len(t, images, 1)
require.Equal(t, "image/png", images[0].MimeType)
require.Equal(t, "data:image/png;base64,iVBORw0KGgo=", images[0].ImageURL)
}
func TestAntigravityShouldUseGeminiTestBuilder_UsesMappedModel(t *testing.T) {
t.Parallel()
require.True(t, shouldUseAntigravityGeminiTestRequest("custom-alias", "gemini-2.5-flash"))
require.True(t, shouldUseAntigravityGeminiTestRequest("gemini-3.1-pro", "gemini-pro-agent"))
require.False(t, shouldUseAntigravityGeminiTestRequest("gemini-alias", "claude-sonnet-4-6"))
require.False(t, shouldUseAntigravityGeminiTestRequest("claude-sonnet-4-6", "claude-sonnet-4-6"))
}
@@ -86,12 +86,30 @@ func TestGetWebSearchEmulationMode_MissingField(t *testing.T) {
require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode())
}
func TestGetWebSearchEmulationMode_NonAnthropicPlatform(t *testing.T) {
func TestGetWebSearchEmulationMode_OpenAIAPIKey(t *testing.T) {
a := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"},
}
require.Equal(t, WebSearchModeEnabled, a.GetWebSearchEmulationMode())
}
func TestGetWebSearchEmulationMode_OpenAIOAuth(t *testing.T) {
a := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"},
}
require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode())
}
func TestGetWebSearchEmulationMode_OtherPlatform(t *testing.T) {
a := &Account{
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: "enabled"},
}
require.Equal(t, WebSearchModeDefault, a.GetWebSearchEmulationMode())
}
@@ -71,7 +71,7 @@ func (s *AntigravityGatewayService) ForwardAsChatCompletions(
return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", "model is required")
}
responsesRequest, err := apicompat.ChatCompletionsToResponses(&request)
responsesRequest, err := chatCompletionsToResponses(settingServiceConfig(s.settingService), &request)
if err != nil {
return nil, s.writeAntigravityCompatError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
@@ -141,6 +141,8 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
injectedBody = reconciled
}
injectedBody = ensureGeminiFunctionCallThoughtSignatures(injectedBody)
// 包装请求
wrappedBody, err := s.wrapV1InternalRequest(projectID, mappedModel, injectedBody)
if err != nil {
@@ -78,12 +78,7 @@ func accountHasAntigravityPaidTier(account *Account) bool {
if !ok {
return false
}
switch strings.ToLower(strings.TrimSpace(planType)) {
case "pro", "ultra":
return true
default:
return false
}
return antigravity.IsPaidPlanType(planType)
}
// smartRetryAction 智能重试的处理结果
@@ -344,16 +344,32 @@ func (s *AntigravityGatewayService) IsModelSupported(requestedModel string) bool
strings.HasPrefix(requestedModel, "gemini-")
}
// TestConnectionImage 测试连接返回的图片结果
type TestConnectionImage struct {
ImageURL string
MimeType string
}
// TestConnectionResult 测试连接结果
type TestConnectionResult struct {
Text string // 响应文本
MappedModel string // 实际使用的模型
Text string // 响应文本
Images []TestConnectionImage // 响应图片(图片生成模型)
MappedModel string // 实际使用的模型
}
func shouldUseAntigravityGeminiTestRequest(_, mappedModel string) bool {
return strings.HasPrefix(strings.ToLower(strings.TrimSpace(mappedModel)), "gemini-")
}
// TestConnection 测试 Antigravity 账号连接。
// 复用 antigravityRetryLoop 的完整重试 / credits overages / 智能重试逻辑,
// 与真实调度行为一致。差异:不做账号切换(测试指定账号)、不记录 ops 错误。
func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account *Account, modelID string) (*TestConnectionResult, error) {
return s.TestConnectionWithPrompt(ctx, account, modelID, "")
}
// TestConnectionWithPrompt 测试 Antigravity 账号连接,支持为图片生成模型指定测试提示词。
func (s *AntigravityGatewayService) TestConnectionWithPrompt(ctx context.Context, account *Account, modelID string, prompt string) (*TestConnectionResult, error) {
// 获取 token
if s.tokenProvider == nil {
@@ -377,8 +393,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
// 构建请求体
var requestBody []byte
if strings.HasPrefix(modelID, "gemini-") {
requestBody, err = s.buildGeminiTestRequest(projectID, mappedModel)
if shouldUseAntigravityGeminiTestRequest(modelID, mappedModel) {
requestBody, err = s.buildGeminiTestRequestWithPrompt(projectID, mappedModel, prompt)
} else {
requestBody, err = s.buildClaudeTestRequest(projectID, mappedModel)
}
@@ -434,8 +450,8 @@ func (s *AntigravityGatewayService) TestConnection(ctx context.Context, account
return nil, fmt.Errorf("API 返回 %d: %s", result.resp.StatusCode, string(respBody))
}
text := extractTextFromSSEResponse(respBody)
return &TestConnectionResult{Text: text, MappedModel: mappedModel}, nil
text, images := extractContentFromSSEResponse(respBody)
return &TestConnectionResult{Text: text, Images: images, MappedModel: mappedModel}, nil
}
// testConnectionHandleError 是 TestConnection 使用的轻量 handleError 回调。
@@ -451,9 +467,39 @@ func testConnectionHandleError(
return nil
}
// buildGeminiTestRequest 构建 Gemini 格式测试请求
// 使用最小 token 消耗:输入 "." + maxOutputTokens: 1
func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model string) ([]byte, error) {
// buildGeminiTestRequestWithPrompt 构建 Gemini 格式测试请求
// 文本模型使用最小 token 消耗:输入 "." + maxOutputTokens: 1;图片模型使用图片生成配置
func (s *AntigravityGatewayService) buildGeminiTestRequestWithPrompt(projectID, model, prompt string) ([]byte, error) {
if isImageGenerationModel(model) {
imagePrompt := strings.TrimSpace(prompt)
if imagePrompt == "" {
imagePrompt = defaultGeminiImageTestPrompt
}
payload := map[string]any{
"contents": []map[string]any{
{
"role": "user",
"parts": []map[string]any{
{"text": imagePrompt},
},
},
},
"systemInstruction": map[string]any{
"parts": []map[string]any{
{"text": antigravity.GetDefaultIdentityPatch()},
},
},
"generationConfig": map[string]any{
"responseModalities": []string{"TEXT", "IMAGE"},
"imageConfig": map[string]any{
"aspectRatio": "1:1",
},
},
}
payloadBytes, _ := json.Marshal(payload)
return s.wrapV1InternalRequest(projectID, model, payloadBytes)
}
payload := map[string]any{
"contents": []map[string]any{
{
@@ -472,6 +518,11 @@ func (s *AntigravityGatewayService) buildGeminiTestRequest(projectID, model stri
"generationConfig": map[string]any{
"maxOutputTokens": 1,
},
"toolConfig": map[string]any{
"functionCallingConfig": map[string]any{
"mode": "VALIDATED",
},
},
}
payloadBytes, _ := json.Marshal(payload)
return s.wrapV1InternalRequest(projectID, model, payloadBytes)
@@ -504,9 +555,10 @@ func (s *AntigravityGatewayService) getClaudeTransformOptions(ctx context.Contex
return opts
}
// extractTextFromSSEResponse 从 SSE 流式响应中提取文本
func extractTextFromSSEResponse(respBody []byte) string {
// extractContentFromSSEResponse 从 SSE 流式响应中提取文本和内联图片
func extractContentFromSSEResponse(respBody []byte) (string, []TestConnectionImage) {
var texts []string
var images []TestConnectionImage
lines := bytes.Split(respBody, []byte("\n"))
for _, line := range lines {
@@ -532,7 +584,7 @@ func extractTextFromSSEResponse(respBody []byte) string {
continue
}
// 尝试从 response.candidates[0].content.parts[].text 提取
// 尝试从 response.candidates[0].content.parts[] 提取
response, ok := data["response"].(map[string]any)
if !ok {
// 尝试直接从 candidates 提取(某些响应格式)
@@ -564,11 +616,21 @@ func extractTextFromSSEResponse(respBody []byte) string {
if text, ok := partMap["text"].(string); ok && text != "" {
texts = append(texts, text)
}
if inlineData, ok := partMap["inlineData"].(map[string]any); ok {
mimeType, _ := inlineData["mimeType"].(string)
b64Data, _ := inlineData["data"].(string)
if strings.HasPrefix(strings.ToLower(mimeType), "image/") && b64Data != "" {
images = append(images, TestConnectionImage{
ImageURL: fmt.Sprintf("data:%s;base64,%s", mimeType, b64Data),
MimeType: mimeType,
})
}
}
}
}
}
return strings.Join(texts, "")
return strings.Join(texts, ""), images
}
// injectIdentityPatchToGeminiRequest 为 Gemini 格式请求注入身份提示词
@@ -2012,3 +2012,175 @@ func generateLargeUnwrapJSON(minSize int) []byte {
b, _ := json.Marshal(outer)
return b
}
func TestAntigravityGatewayService_ForwardGemini_NonStreamPreservesFunctionCallAndThoughtSignature(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","description":"Get weather","parameters":{"type":"object","properties":{"city":{"type":"string"}},"required":["city"]}}]}]}`)
writer := httptest.NewRecorder()
c, _ := gin.CreateTestContext(writer)
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:generateContent", bytes.NewReader(body))
sseStream := strings.Join([]string{
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_fc_1","functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_1"}}]}}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":16,"totalTokenCount":328,"thoughtsTokenCount":12},"modelVersion":"gemini-4-argon","responseId":"resp_1"}}`,
"",
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":300,"candidatesTokenCount":22,"totalTokenCount":334,"thoughtsTokenCount":12},"modelVersion":"gemini-4-argon","responseId":"resp_1"}}`,
"",
}, "\n")
upstream := &queuedHTTPUpstreamStub{
responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(sseStream)),
}},
}
svc := &AntigravityGatewayService{
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
tokenProvider: &AntigravityTokenProvider{},
httpUpstream: upstream,
}
account := &Account{
ID: 109,
Name: "antigravity-fc-nonstream",
Platform: PlatformAntigravity,
Type: AccountTypeOAuth,
Status: StatusActive,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "test-access-token",
"project_id": "test-project",
"model_mapping": map[string]any{
"gemini-4-argon": "gemini-4-argon",
},
},
}
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-4-argon", "generateContent", false, body, false)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, http.StatusOK, writer.Code)
var respPayload map[string]any
require.NoError(t, json.Unmarshal(writer.Body.Bytes(), &respPayload))
candidates, ok := respPayload["candidates"].([]any)
require.True(t, ok)
require.Len(t, candidates, 1)
candidate, ok := candidates[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "STOP", candidate["finishReason"])
content, ok := candidate["content"].(map[string]any)
require.True(t, ok)
parts, ok := content["parts"].([]any)
require.True(t, ok)
require.Len(t, parts, 1, "trailing empty text part should not overwrite or pollute functionCall part")
part0, ok := parts[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "sig_fc_1", part0["thoughtSignature"])
fc, ok := part0["functionCall"].(map[string]any)
require.True(t, ok)
require.Equal(t, "get_weather", fc["name"])
require.Equal(t, "call_1", fc["id"])
require.Equal(t, map[string]any{"city": "Tokyo"}, fc["args"])
}
func TestAntigravityGatewayService_ForwardGemini_StreamAndFunctionResponseFollowUp(t *testing.T) {
gin.SetMode(gin.TestMode)
// 1) Stream request with functionDeclarations returns unwrapped functionCall and thoughtSignature
streamReqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}]}]}`)
streamWriter := httptest.NewRecorder()
streamCtx, _ := gin.CreateTestContext(streamWriter)
streamCtx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:streamGenerateContent?alt=sse", bytes.NewReader(streamReqBody))
streamSSE := strings.Join([]string{
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_stream_1","functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5}}}`,
"",
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":6}}}`,
"",
}, "\n")
// 2) Non-stream follow-up with functionCall (missing thoughtSignature) + functionResponse (with inlineData image)
followUpReqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"Call get_weather for Tokyo."}]},{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}}]},{"role":"user","parts":[{"functionResponse":{"name":"get_weather","id":"call_s1","response":{"weather":"Sunny"},"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="}}]}}]}],"tools":[{"functionDeclarations":[{"name":"get_weather","parameters":{"type":"object","properties":{"city":{"type":"string"}}}}]}]}`)
followUpWriter := httptest.NewRecorder()
followUpCtx, _ := gin.CreateTestContext(followUpWriter)
followUpCtx.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-4-argon:generateContent", bytes.NewReader(followUpReqBody))
followUpSSE := strings.Join([]string{
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"It is sunny in "}]}}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":4}}}`,
"",
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"text":"Tokyo."}]}}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":6}}}`,
"",
`data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig_final","text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":20,"candidatesTokenCount":8}}}`,
"",
}, "\n")
upstream := &queuedHTTPUpstreamStub{
responses: []*http.Response{
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(streamSSE)),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(followUpSSE)),
},
},
}
svc := &AntigravityGatewayService{
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
tokenProvider: &AntigravityTokenProvider{},
httpUpstream: upstream,
}
account := &Account{
ID: 110,
Name: "antigravity-fc-stream",
Platform: PlatformAntigravity,
Type: AccountTypeOAuth,
Status: StatusActive,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "test-access-token",
"project_id": "test-project",
"model_mapping": map[string]any{
"gemini-4-argon": "gemini-4-argon",
},
},
}
res1, err := svc.ForwardGemini(context.Background(), streamCtx, account, "gemini-4-argon", "streamGenerateContent", true, streamReqBody, false)
require.NoError(t, err)
require.NotNil(t, res1)
require.Contains(t, streamWriter.Body.String(), `"functionCall":{"name":"get_weather","args":{"city":"Tokyo"},"id":"call_s1"}`)
require.Contains(t, streamWriter.Body.String(), `"thoughtSignature":"sig_stream_1"`)
res2, err := svc.ForwardGemini(context.Background(), followUpCtx, account, "gemini-4-argon", "generateContent", false, followUpReqBody, false)
require.NoError(t, err)
require.NotNil(t, res2)
require.Len(t, upstream.requestBodies, 2)
// Verify follow-up request had dummy thoughtSignature injected on functionCall and preserved functionResponse image part
secondSent := string(upstream.requestBodies[1])
require.Contains(t, secondSent, `"thoughtSignature":"skip_thought_signature_validator"`)
require.Contains(t, secondSent, `"mimeType":"image/png"`)
var followUpResp map[string]any
require.NoError(t, json.Unmarshal(followUpWriter.Body.Bytes(), &followUpResp))
cands, ok := followUpResp["candidates"].([]any)
require.True(t, ok)
cand0, ok := cands[0].(map[string]any)
require.True(t, ok)
content, ok := cand0["content"].(map[string]any)
require.True(t, ok)
parts, ok := content["parts"].([]any)
require.True(t, ok)
require.NotEmpty(t, parts)
firstPart, ok := parts[0].(map[string]any)
require.True(t, ok)
require.Equal(t, "It is sunny in Tokyo.", firstPart["text"])
}
@@ -350,8 +350,7 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
var firstTokenMs *int
var last map[string]any
var lastWithParts map[string]any
var collectedImageParts []map[string]any // 收集所有包含图片的 parts
var collectedTextParts []string // 收集所有文本片段
var collectedParts []map[string]any // 收集所有 parts(text、thinking、functionCall、inlineData 等)
type scanEvent struct {
line string
@@ -467,19 +466,10 @@ func (s *AntigravityGatewayService) handleGeminiStreamToNonStreaming(c *gin.Cont
}
}
// 保留最后一个有 parts 的响应
// 保留最后一个有 parts 的响应,并收集所有 parts
if parts := extractGeminiParts(parsed); len(parts) > 0 {
lastWithParts = parsed
// 收集包含图片和文本的 parts
for _, part := range parts {
if inlineData, ok := part["inlineData"].(map[string]any); ok {
collectedImageParts = append(collectedImageParts, part)
_ = inlineData // 避免 unused 警告
}
if text, ok := part["text"].(string); ok && text != "" {
collectedTextParts = append(collectedTextParts, text)
}
}
collectedParts = append(collectedParts, parts...)
}
case <-intervalCh:
@@ -506,14 +496,9 @@ returnResponse:
}
}
// 如果收集到了图片 parts,需要合并到最终响应中
if len(collectedImageParts) > 0 {
finalResponse = mergeImagePartsToResponse(finalResponse, collectedImageParts)
}
// 如果收集到了文本,需要合并到最终响应中
if len(collectedTextParts) > 0 {
finalResponse = mergeTextPartsToResponse(finalResponse, collectedTextParts)
// 将收集的所有 parts 合并到最终响应中
if len(collectedParts) > 0 {
finalResponse = mergeCollectedPartsToResponse(finalResponse, collectedParts)
}
respBody, err := json.Marshal(finalResponse)
@@ -576,21 +561,27 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map
return response
}
result, _, setParts := getOrCreateGeminiParts(response)
result, existingParts, setParts := getOrCreateGeminiParts(response)
// 合并策略:
// 1. 保持原始顺序
// 2. 连续的普通 text parts 合并为一个
// 2. 连续的普通 text parts 合并为一个,并保留关联的 thoughtSignature
// 3. thinking、functionCall、inlineData 等保持原样
var mergedParts []any
var textBuffer strings.Builder
var textSignature string
flushTextBuffer := func() {
if textBuffer.Len() > 0 {
mergedParts = append(mergedParts, map[string]any{
if textBuffer.Len() > 0 || textSignature != "" {
part := map[string]any{
"text": textBuffer.String(),
})
}
if textSignature != "" {
part["thoughtSignature"] = textSignature
}
mergedParts = append(mergedParts, part)
textBuffer.Reset()
textSignature = ""
}
}
@@ -605,6 +596,9 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map
} else {
// 普通 text,累积到 buffer
_, _ = textBuffer.WriteString(text)
if sig, ok := part["thoughtSignature"].(string); ok && sig != "" {
textSignature = sig
}
}
} else {
// 非 text part(functionCall、inlineData 等),先刷新 text buffer,然后保留原样
@@ -616,76 +610,14 @@ func mergeCollectedPartsToResponse(response map[string]any, collectedParts []map
// 刷新剩余的 text
flushTextBuffer()
if len(mergedParts) == 0 {
mergedParts = existingParts
}
setParts(mergedParts)
return result
}
// mergeImagePartsToResponse 将收集到的图片 parts 合并到 Gemini 响应中
func mergeImagePartsToResponse(response map[string]any, imageParts []map[string]any) map[string]any {
if len(imageParts) == 0 {
return response
}
result, existingParts, setParts := getOrCreateGeminiParts(response)
// 检查现有 parts 中是否已经有图片
for _, p := range existingParts {
if pm, ok := p.(map[string]any); ok {
if _, hasInline := pm["inlineData"]; hasInline {
return result // 已有图片,不重复添加
}
}
}
// 添加收集到的图片 parts
for _, imgPart := range imageParts {
existingParts = append(existingParts, imgPart)
}
setParts(existingParts)
return result
}
// mergeTextPartsToResponse 将收集到的文本合并到 Gemini 响应中
func mergeTextPartsToResponse(response map[string]any, textParts []string) map[string]any {
if len(textParts) == 0 {
return response
}
mergedText := strings.Join(textParts, "")
result, existingParts, setParts := getOrCreateGeminiParts(response)
// 查找并更新第一个 text part,或创建新的
newParts := make([]any, 0, len(existingParts)+1)
textUpdated := false
for _, p := range existingParts {
pm, ok := p.(map[string]any)
if !ok {
newParts = append(newParts, p)
continue
}
if _, hasText := pm["text"]; hasText && !textUpdated {
// 用累积的文本替换
newPart := make(map[string]any)
for k, v := range pm {
newPart[k] = v
}
newPart["text"] = mergedText
newParts = append(newParts, newPart)
textUpdated = true
} else {
newParts = append(newParts, pm)
}
}
if !textUpdated {
newParts = append([]any{map[string]any{"text": mergedText}}, newParts...)
}
setParts(newParts)
return result
}
// antigravityStatusClientClosed 是客户端在上游响应前断开时回写的状态码(499 client closed request)。
const antigravityStatusClientClosed = 499
@@ -108,17 +108,6 @@ func geminiThinkingLevelFromClaudeThinking(thinking *antigravity.ThinkingConfig)
}
}
// accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里
// (而不是 resolveModelMapping 运行时补进来的默认透传条目)。
func accountRawModelMappingHasKey(account *Account, key string) bool {
if account == nil || account.Credentials == nil {
return false
}
raw, _ := account.Credentials["model_mapping"].(map[string]any)
_, ok := raw[key]
return ok
}
// resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。
// 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。
func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) {
@@ -140,16 +129,16 @@ func resolveGeminiThinkingVariantForLevel(account *Account, requestedModel strin
if len(mapping) == 0 {
return "", false
}
// 裸名本身已有"真正的"映射 → 尊重现有配置,不做推导。判定分两层:
// 1. 映射目标不是自己(如 gemini-3.8-flash → gemini-3.8-flash-tiered):用户明确指定了目标;
// 2. 映射目标是自己(原样透传):仅当这条是用户亲手写进 credentials.model_mapping 的才尊重。
// resolveModelMapping 会给每个 Antigravity 账号自动补 gemini-3.x-flash 裸名自映射
// (ensureAntigravityDefaultPassthroughs),而上游目录里并没有裸名模型,那条自映射
// 正是导致 404 的来源,不能算作用户意图。
if mapped, matched := resolveRequestedModelInMapping(mapping, model); matched {
if strings.TrimSpace(mapped) != model || accountRawModelMappingHasKey(account, model) {
return "", false
}
// 裸名本身若已映射到其他具体模型(如 gemini-3.8-flash → gemini-3.8-flash-tiered、
// gemini-3.1-pro → gemini-pro-agent),尊重显式别名配置。
// 若裸名映射目标是自己(如前端从 DefaultAntigravityModelMapping 拉取并持久化的
// gemini-3.8-flash → gemini-3.8-flash,或 ensureAntigravityDefaultPassthroughs 注入的条目),
// 当映射表中存在该模型的精确思考变体(-high/-medium/-low/-tiered)时继续解析变体,
// 避免把上游不存在的裸名直接发出导致 404;而对已在映射表中的真实裸模型(如 gemini-2.5-flash),
// 仅查找精确变体键以避免被通配符误匹配。
mapped, modelMatched := resolveRequestedModelInMapping(mapping, model)
if modelMatched && strings.TrimSpace(mapped) != model {
return "", false
}
if preferred == "" {
@@ -163,8 +152,17 @@ func resolveGeminiThinkingVariantForLevel(account *Account, requestedModel strin
}
for _, level := range order {
candidate := model + "-" + level
if mapped, matched := resolveRequestedModelInMapping(mapping, candidate); matched && strings.TrimSpace(mapped) != "" {
return mapped, true
var (
variantMapped string
variantMatched bool
)
if modelMatched {
variantMapped, variantMatched = mapping[candidate]
} else {
variantMapped, variantMatched = resolveRequestedModelInMapping(mapping, candidate)
}
if variantMatched && strings.TrimSpace(variantMapped) != "" {
return variantMapped, true
}
}
return "", false
@@ -74,10 +74,10 @@ func TestResolveGeminiThinkingVariant(t *testing.T) {
{"agy low (1000) -> -low", catalog, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
{"models/ prefix stripped", catalog, "models/gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
{"no thinkingConfig -> -high", catalog, "gemini-3.8-flash", []byte(`{"contents":[]}`), "gemini-3.8-flash-high", true},
// gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐,所以降级用一个不在默认表里的型号
{"only -high exists: low request degrades to high", map[string]string{
"gemini-3.5-flash-high": "gemini-3.5-flash-high",
}, "gemini-3.5-flash", budget("1000"), "gemini-3.5-flash-high", true},
// gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐(存量显式映射账号的兼容透传)。
{"injected default variants are usable", catalog, "gemini-3.6-flash", budget("1000"), "gemini-3.6-flash-low", true},
{"already suffixed: untouched", catalog, "gemini-3.8-flash-low", budget("-1"), "", false},
{"bare name explicitly mapped: untouched", catalog, "gemini-2.5-flash", budget("-1"), "", false},
@@ -90,16 +90,22 @@ func TestResolveGeminiThinkingVariant(t *testing.T) {
{"variant value is itself a mapping target", map[string]string{
"gemini-3.8-flash-high": "gemini-3.8-flash-tiered",
}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-tiered", true},
{"user-written bare identity passthrough: respected", map[string]string{
// 前端持久化的默认目录含裸名自映射;-low 由默认透传补齐,按预算命中。
{"persisted default bare identity passthrough still resolves when variants exist", map[string]string{
"gemini-3.8-flash": "gemini-3.8-flash",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
}, "gemini-3.8-flash", budget("1000"), "", false},
{"runtime-injected bare passthrough (not in credentials): still resolved", map[string]string{
// resolveModelMapping 会补 gemini-3.7-flash → gemini-3.7-flash 的默认透传,
// 但 credentials 里没有这条,应当继续推导变体。
}, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
{"bare identity passthrough resolves a configured variant outside the default passthroughs", map[string]string{
"gemini-3.5-flash": "gemini-3.5-flash",
"gemini-3.5-flash-high": "gemini-3.5-flash-high",
}, "gemini-3.5-flash", budget("1000"), "gemini-3.5-flash-high", true},
{"wildcard mapping does not fabricate variants for non-tiered bare model", map[string]string{
"gemini-2.5*": "gemini-2.5-flash",
}, "gemini-2.5-flash", budget("-1"), "", false},
{"bare model absent from whitelist: configured variant still resolved", map[string]string{
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
}, "gemini-3.7-flash", budget("4000"), "gemini-3.7-flash-medium", true},
// 空 credentials 映射 → 走 DefaultAntigravityModelMapping,其中同样只有带后缀的 3.8 flash
// 空 credentials 映射保留默认目录兼容行为。
{"empty mapping falls back to default catalog", map[string]string{}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
}
for _, tt := range tests {
@@ -1093,6 +1093,15 @@ func TestResolveAntigravityForwardBaseURL(t *testing.T) {
name: "ultra defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "ULTRA"}},
want: dailyURL,
},
{
name: "stored ultra tier id defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "g1-ultra-lite-tier"}},
want: dailyURL,
},
{
name: "stored pro tier id defaults to daily", account: &Account{Credentials: map[string]any{"plan_type": "g1-pro-tier"}},
want: dailyURL,
},
{name: "free tier id defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "free-tier"}}, want: prodURL},
{name: "free defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "free"}}, want: prodURL},
{name: "abnormal defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "Abnormal"}}, want: prodURL},
{name: "unknown defaults to prod", account: &Account{Credentials: map[string]any{"plan_type": "enterprise"}}, want: prodURL},
@@ -0,0 +1,28 @@
package service
import (
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
)
func configuredMinOutputTokens(cfg *config.Config) int {
if cfg != nil && cfg.Gateway.MinOutputTokens >= 16 {
return cfg.Gateway.MinOutputTokens
}
return apicompat.DefaultMinMaxOutputTokens
}
func chatCompletionsToResponses(cfg *config.Config, req *apicompat.ChatCompletionsRequest) (*apicompat.ResponsesRequest, error) {
return apicompat.ChatCompletionsToResponsesWithMinOutputTokens(req, configuredMinOutputTokens(cfg))
}
func anthropicToResponses(cfg *config.Config, req *apicompat.AnthropicRequest) (*apicompat.ResponsesRequest, error) {
return apicompat.AnthropicToResponsesWithMinOutputTokens(req, configuredMinOutputTokens(cfg))
}
func settingServiceConfig(settingService *SettingService) *config.Config {
if settingService == nil {
return nil
}
return settingService.cfg
}
+29 -1
View File
@@ -524,6 +524,27 @@ func (s *BillingService) initFallbackPricing() {
SupportsCacheBreakdown: false,
}
// Gemini 4 Argon (Google AI pricing: $2 input / $10 output / $0.10 cached
// input per MTok, promotional through 2026-11-30; official rates double to
// $4/$20/$0.20 from 2026-12-01). One rate covers every prompt length.
// Antigravity's -high/-low/-medium aliases are matched below so unavailable
// remote pricing never records token-bearing requests at $0.
s.fallbackPrices["gemini-4-argon"] = &ModelPricing{
InputPricePerToken: 2e-6,
OutputPricePerToken: 10e-6,
CacheReadPricePerToken: 0.1e-6,
SupportsCacheBreakdown: false,
}
// Gemini 4 Argon Cyber Permissive (Vertex AI pricing: $4 input / $20 output /
// $0.20 cached input per MTok). The Gemini 4 Argon promotion does not apply.
s.fallbackPrices["gemini-4-argon-cyber-permissive"] = &ModelPricing{
InputPricePerToken: 4e-6,
OutputPricePerToken: 20e-6,
CacheReadPricePerToken: 0.2e-6,
SupportsCacheBreakdown: false,
}
// OpenAI GPT-5.4(业务指定价格)
s.fallbackPrices["gpt-5.4"] = &ModelPricing{
InputPricePerToken: 2.5e-6, // $2.5 per MTok
@@ -1014,7 +1035,7 @@ func (s *BillingService) initFallbackPricing() {
// getFallbackPricing 根据模型系列获取回退价格
func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
modelLower := strings.ToLower(model)
modelLower := normalizeAntigravityClaudeTierAlias(strings.ToLower(model))
if modelLower == "jev-latest" {
return s.fallbackPrices["jev-latest"]
}
@@ -1082,6 +1103,13 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
if strings.Contains(modelLower, "gemini-3.8-flash") || strings.Contains(modelLower, "gemini-3-8-flash") {
return s.fallbackPrices["gemini-3.8-flash"]
}
// Cyber 变体不享受 Gemini 4 Argon 的优惠价,必须先于 "gemini-4-argon" 判。
if strings.Contains(modelLower, "gemini-4-argon-cyber") {
return s.fallbackPrices["gemini-4-argon-cyber-permissive"]
}
if strings.Contains(modelLower, "gemini-4-argon") {
return s.fallbackPrices["gemini-4-argon"]
}
// DeepSeek 系列:官方模型 V4 Pro/Flash(含 vision-exp)按各自价卡;
// 其余 deepseek-*(含已停服的 deepseek-chat / deepseek-reasoner 与未知型号)
@@ -497,7 +497,7 @@ func minimaxResetTime(v gjson.Result) string {
return time.UnixMilli(ms).UTC().Format(time.RFC3339)
}
// cnZhipuWindow 标识智谱 TOKENS_LIMIT 条目所属窗口。
// cnZhipuWindow 标识智谱额度条目(TOKENS_LIMIT / CREDIT_LIMIT)所属窗口。
type cnZhipuWindow int
const (
@@ -529,7 +529,8 @@ func classifyZhipuWindowUnit(unit int64) cnZhipuWindow {
//
// CREDIT_LIMIT(信用额度)与 TOKENS_LIMIT(token 窗口)度量不同:两者同时返回时
// 只让 TOKENS_LIMIT 参与 5h/weekly 槽位竞争,避免信用额度百分比污染阈值停调
// 快照;仅当无任何 TOKENS_LIMIT 条目时才降级用 CREDIT_LIMIT 展示。
// 快照;仅当无任何 TOKENS_LIMIT 条目时才降级用 CREDIT_LIMIT 展示 —— 此时
// CREDIT_LIMIT 同样按 unit 分类(它也携带 unit),不依赖 reset 时间排序。
// 老套餐只回 1 条 TOKENS_LIMIT,自然降级为仅 5h;新套餐回 2 条。
func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier {
type entry struct {
@@ -537,6 +538,7 @@ func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier {
hasReset bool
percentage float64
resetISO string
unit int64
}
var (
fiveHour entry
@@ -546,8 +548,8 @@ func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier {
unclassified []entry
)
classify := func(item gjson.Result, e entry) {
switch classifyZhipuWindowUnit(item.Get("unit").Int()) {
classify := func(unit int64, e entry) {
switch classifyZhipuWindowUnit(unit) {
case cnZhipuWindow5h:
if !fiveHourSet {
fiveHour, fiveHourSet = e, true
@@ -592,10 +594,10 @@ func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier {
hasReset = resetISO != ""
}
}
e := entry{resetMs: resetMs, hasReset: hasReset, percentage: percentage, resetISO: resetISO}
e := entry{resetMs: resetMs, hasReset: hasReset, percentage: percentage, resetISO: resetISO, unit: item.Get("unit").Int()}
if limitType == "TOKENS_LIMIT" {
hasTokensLimit = true
classify(item, e)
classify(e.unit, e)
} else {
creditFallback = append(creditFallback, e)
}
@@ -603,8 +605,17 @@ func parseZhipuTokenTiers(data gjson.Result) []CNQuotaTier {
})
// 无任何 TOKENS_LIMIT 条目(部分套餐只报信用额度):降级用 CREDIT_LIMIT 展示。
// CREDIT_LIMIT 条目同样带 unit(3=5h / 6=weekly;实测 GLM Coding Plan 两条窗口
// 都是 CREDIT_LIMIT),必须和 TOKENS_LIMIT 一样先按 unit 显式分类 —— 否则会退化
// 成 reset 时间启发式,而周期末尾周窗口比 5h 更早重置,时间排序必然把两档标反。
if !hasTokensLimit {
unclassified = append(unclassified, creditFallback...)
for _, e := range creditFallback {
if classifyZhipuWindowUnit(e.unit) != cnZhipuWindowUnknown {
classify(e.unit, e)
continue
}
unclassified = append(unclassified, e)
}
}
// 无 reset 的条目排前,再按 reset 升序,依次填入仍空缺的槽位。
@@ -126,6 +126,60 @@ func TestParseZhipuTokenTiers_UnitClassification(t *testing.T) {
require.InDelta(t, 70.0, tiers[1].UsedPercent, 1e-9)
}
// TestParseZhipuTokenTiers_CreditLimitUnitClassification GLM Coding Plan 当前把 5h / weekly
// 两条窗口都标成 CREDIT_LIMIT(实测 level=lite)。CREDIT_LIMIT 同样携带 unit,
// 必须和 TOKENS_LIMIT 一样按 unit 显式分类,否则会退化成 reset 时间排序 ——
// 此处刻意让 weekly 的 nextResetTime 早于 5h(周期末尾),两档会被标反。
func TestParseZhipuTokenTiers_CreditLimitUnitClassification(t *testing.T) {
t.Parallel()
data := gjson.Parse(`{
"limits": [
{"type":"CREDIT_LIMIT","unit":3,"number":5,"percentage":14,"nextResetTime":1700000099999},
{"type":"CREDIT_LIMIT","unit":6,"number":1,"percentage":90,"nextResetTime":1700000000000}
]
}`)
tiers := parseZhipuTokenTiers(data)
require.Len(t, tiers, 2)
require.Equal(t, "5h", tiers[0].Window)
require.InDelta(t, 14.0, tiers[0].UsedPercent, 1e-9)
require.Equal(t, "weekly", tiers[1].Window)
require.InDelta(t, 90.0, tiers[1].UsedPercent, 1e-9)
}
// TestParseZhipuTokenTiers_CreditLimitWithoutUnitFallback CREDIT_LIMIT 缺 unit 时,
// 保留原有 fallback:无 nextResetTime 的优先归 5h,其余按 reset 升序填入剩余槽位。
func TestParseZhipuTokenTiers_CreditLimitWithoutUnitFallback(t *testing.T) {
t.Parallel()
data := gjson.Parse(`{
"limits": [
{"type":"CREDIT_LIMIT","percentage":30,"nextResetTime":1700000000000},
{"type":"CREDIT_LIMIT","percentage":80}
]
}`)
tiers := parseZhipuTokenTiers(data)
require.Len(t, tiers, 2)
require.Equal(t, "5h", tiers[0].Window)
require.InDelta(t, 80.0, tiers[0].UsedPercent, 1e-9)
require.Equal(t, "weekly", tiers[1].Window)
require.InDelta(t, 30.0, tiers[1].UsedPercent, 1e-9)
}
// TestParseZhipuTokenTiers_TokensLimitWinsOverCreditLimit 两种 type 同时返回时,
// TOKENS_LIMIT 仍然优先占槽、CREDIT_LIMIT 不参与(原有语义保持不变)。
func TestParseZhipuTokenTiers_TokensLimitWinsOverCreditLimit(t *testing.T) {
t.Parallel()
data := gjson.Parse(`{
"limits": [
{"type":"TOKENS_LIMIT","unit":3,"percentage":20,"nextResetTime":1700000000000},
{"type":"CREDIT_LIMIT","unit":6,"percentage":99,"nextResetTime":1700000099999}
]
}`)
tiers := parseZhipuTokenTiers(data)
require.Len(t, tiers, 1)
require.Equal(t, "5h", tiers[0].Window)
require.InDelta(t, 20.0, tiers[0].UsedPercent, 1e-9)
}
// TestParseZhipuTokenTiers_SingleTierOldPlan 老套餐仅回 1 条 → 降级为仅 5h。
func TestParseZhipuTokenTiers_SingleTierOldPlan(t *testing.T) {
t.Parallel()
@@ -32,6 +32,11 @@ type CompositeModelOwnership struct {
TargetPlatform string
Matched bool
Ambiguous bool
// OpenAIGatewayOAuthClaimed 表示声明方中含 OpenAI 网关族平台的 OAuth /
// setup-token 凭据。歧义请求无法解析平台,会落到默认的 Anthropic 网关
// handler:Anthropic / Gemini / Antigravity 的 OAuth 在那里有原生或兼容
// 转发,API Key 上游可能兼容多种格式,唯独这类凭据只能走 OpenAI 网关。
OpenAIGatewayOAuthClaimed bool
}
type CompositeModelOwnershipResolver func(context.Context, int64, string) (CompositeModelOwnership, error)
@@ -13,7 +13,7 @@ type compositeOwnershipAccountRepo struct {
accounts []Account
}
func (r *compositeOwnershipAccountRepo) ListSchedulableByGroupID(context.Context, int64) ([]Account, error) {
func (r *compositeOwnershipAccountRepo) ListModelAvailabilityCandidates(context.Context, *int64, []string, bool) ([]Account, error) {
return r.accounts, nil
}
@@ -103,6 +103,35 @@ func TestResolveCompositeModelOwnershipAllowsSamePlatformAndRejectsCrossPlatform
require.Equal(t, CompositeModelOwnership{Ambiguous: true}, ambiguous)
}
// Scenario: 只有 OpenAI 网关族的 OAuth 声明方会被标记(API Key 上游可能兼容多协议,
// Anthropic / Antigravity 的 OAuth 在默认 Anthropic handler 中可正常转发)
func TestResolveCompositeModelOwnershipFlagsOpenAIGatewayOAuthClaimants(t *testing.T) {
groupID := int64(7)
repo := &compositeOwnershipAccountRepo{
accounts: []Account{
{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-6-astra": "gpt-6-astra"}}},
{ID: 2, Platform: PlatformDeepseek, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"gpt-6-astra": "deepseek-v4-pro", "apikey-only": "deepseek-v4-pro"}}},
{ID: 3, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{"model_mapping": map[string]any{"apikey-only": "gpt-5"}}},
// Antigravity 账号创建时会把默认映射(含 claude-*)写入 model_mapping。
{ID: 4, Platform: PlatformAntigravity, Type: AccountTypeOAuth, Credentials: map[string]any{"model_mapping": map[string]any{"claude-opus-4-8": "claude-opus-4-8"}}},
{ID: 5, Platform: PlatformAnthropic, Type: AccountTypeSetupToken, Credentials: map[string]any{"model_mapping": map[string]any{"claude-opus-4-8": "claude-opus-4-8"}}},
},
}
svc := &GatewayService{accountRepo: repo}
withOAuth, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "gpt-6-astra")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{Ambiguous: true, OpenAIGatewayOAuthClaimed: true}, withOAuth)
apiKeyOnly, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "apikey-only")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{Ambiguous: true}, apiKeyOnly)
anthropicFamily, err := svc.resolveCompositeModelOwnership(context.Background(), groupID, "claude-opus-4-8")
require.NoError(t, err)
require.Equal(t, CompositeModelOwnership{Ambiguous: true}, anthropicFamily)
}
func TestNewGatewayServiceWiresCompositeModelOwnershipResolver(t *testing.T) {
groupID := int64(7)
repo := &compositeOwnershipAccountRepo{
@@ -5,8 +5,34 @@ import (
"fmt"
"sort"
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
const (
// CompositeRouteReasonAmbiguous marks a model whose explicit account
// mappings span several provider platforms.
CompositeRouteReasonAmbiguous = "model is exposed by multiple provider platforms"
// CompositeRouteReasonOAuthAmbiguous is the ambiguous case where a claimant
// holds an OAuth / setup-token credential that only the OpenAI gateway can
// carry, and the model-name fallback would also pick an OpenAI-gateway
// platform. The unresolved request would land in the Anthropic gateway
// handler and select those credentials, so this group misconfiguration must
// be rejected instead of guessing a platform from the model name.
CompositeRouteReasonOAuthAmbiguous = "model is exposed by multiple provider platforms including OAuth accounts"
)
// OAuthAmbiguous reports whether the request must be rejected because the
// cross-platform ownership involves OpenAI-gateway OAuth credentials.
func (d CompositeRouteDecision) OAuthAmbiguous() bool {
return !d.Matched && d.Reason == CompositeRouteReasonOAuthAmbiguous
}
// AmbiguousMessage is the client-facing rejection for an OAuthAmbiguous decision.
func (d CompositeRouteDecision) AmbiguousMessage() string {
return fmt.Sprintf("Model %q is mapped on multiple provider platforms in this group; configure an explicit composite route", d.PublicModel)
}
type CompositeRouteResolver struct {
repo CompositeModelRouteRepository
modelOwnershipResolver CompositeModelOwnershipResolver
@@ -113,7 +139,12 @@ func (r *CompositeRouteResolver) Resolve(ctx context.Context, groupID int64, mod
return decision, fmt.Errorf("resolve account model ownership: %w", err)
}
} else if ownership.Ambiguous {
decision.Reason = "model is exposed by multiple provider platforms"
decision.Reason = CompositeRouteReasonAmbiguous
// 未解析的请求由下游 handler 按模型名前缀兜底选平台;只有兜底落在
// OpenAI 网关族时,OpenAI 网关 OAuth 声明方才会被误送进 Anthropic 管线。
if guessed, ok := DetectModelPlatform(model); ok && ownership.OpenAIGatewayOAuthClaimed && domain.UsesOpenAIGateway(guessed) {
decision.Reason = CompositeRouteReasonOAuthAmbiguous
}
return decision, nil
} else if ownership.Matched {
platform := strings.TrimSpace(ownership.TargetPlatform)
@@ -221,6 +221,33 @@ func TestCompositeRouteResolverDoesNotGuessAmbiguousAccountOwnership(t *testing.
require.False(t, decision.Matched)
require.Empty(t, decision.TargetPlatform)
require.Equal(t, "model is exposed by multiple provider platforms", decision.Reason)
require.False(t, decision.OAuthAmbiguous())
}
// Scenario: 含 OAuth 账号的跨平台歧义必须被识别为需拒绝的配置错误
func TestCompositeRouteResolverFlagsOAuthAmbiguousOwnership(t *testing.T) {
resolver := NewCompositeRouteResolver(nil)
resolver.SetModelOwnershipResolver(func(context.Context, int64, string) (CompositeModelOwnership, error) {
return CompositeModelOwnership{Ambiguous: true, OpenAIGatewayOAuthClaimed: true}, nil
})
// gpt-* 可被内置探测器识别,验证歧义不会退化为按模型名猜平台。
decision, err := resolver.Resolve(context.Background(), 7, "gpt-6-astra", CompositeRouteEndpointResponses)
require.NoError(t, err)
require.False(t, decision.Matched)
require.Empty(t, decision.TargetPlatform)
require.True(t, decision.OAuthAmbiguous())
require.Contains(t, decision.AmbiguousMessage(), `"gpt-6-astra"`)
// 兜底按模型名落到 Anthropic(如 OpenAI OAuth 映射 claude-* 给 Claude Code 用)
// 或无法兜底的自定义别名:OpenAI 网关 OAuth 不会被选中,保持原有行为。
for _, model := range []string{"claude-sonnet-4-6", "team-alias"} {
decision, err := resolver.Resolve(context.Background(), 7, model, CompositeRouteEndpointMessages)
require.NoError(t, err)
require.False(t, decision.OAuthAmbiguous(), "model=%s", model)
require.Equal(t, CompositeRouteReasonAmbiguous, decision.Reason, "model=%s", model)
}
}
func TestCompositeRouteResolverOwnershipLookupErrorFallsBackOnlyForDetectableModels(t *testing.T) {
@@ -0,0 +1,106 @@
//go:build unit
package service
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func gatewayAnthropicBaseURLAccount(apiBaseURLs map[string]any) *Account {
return &Account{
ID: 8074,
Platform: PlatformDeepseek,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "test-key",
"base_url": "https://api.deepseek.com",
"api_protocol": APIProtocolAdaptive,
"api_base_urls": apiBaseURLs,
},
}
}
func gatewayAnthropicBaseURLContext(t *testing.T) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
return c
}
func TestGatewayService_AdaptiveAnthropicBaseURLRouting(t *testing.T) {
svc := &GatewayService{cfg: &config.Config{}}
body := []byte(`{"model":"deepseek-chat","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`)
t.Run("base present is used by messages and count_tokens", func(t *testing.T) {
account := gatewayAnthropicBaseURLAccount(map[string]any{
APIProtocolAnthropic: "https://anthropic.example.com",
APIProtocolChatCompletions: "https://chat.example.com",
})
c := gatewayAnthropicBaseURLContext(t)
req, _, err := svc.buildUpstreamRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-chat", false, false)
require.NoError(t, err)
require.Equal(t, "https://anthropic.example.com/v1/messages?beta=true", req.URL.String())
countReq, _, err := svc.buildCountTokensRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-chat", false)
require.NoError(t, err)
require.Equal(t, "https://anthropic.example.com/v1/messages/count_tokens?beta=true", countReq.URL.String())
})
t.Run("missing anthropic base uses provider fallback", func(t *testing.T) {
account := gatewayAnthropicBaseURLAccount(map[string]any{
APIProtocolChatCompletions: "https://chat.example.com",
})
req, _, err := svc.buildUpstreamRequest(context.Background(), gatewayAnthropicBaseURLContext(t), account, body, "test-key", "apikey", "deepseek-chat", false, false)
require.NoError(t, err)
require.Equal(t, "https://api.deepseek.com/anthropic/v1/messages?beta=true", req.URL.String())
})
t.Run("ordinary anthropic api key keeps base_url fallback", func(t *testing.T) {
account := &Account{
Platform: PlatformAnthropic,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "test-key",
"base_url": "https://custom-anthropic.example.com",
},
}
req, _, err := svc.buildUpstreamRequest(context.Background(), gatewayAnthropicBaseURLContext(t), account, body, "test-key", "apikey", "deepseek-chat", false, false)
require.NoError(t, err)
require.Equal(t, "https://custom-anthropic.example.com/v1/messages?beta=true", req.URL.String())
})
}
func TestGatewayService_AnthropicPassthroughAndCountTokensUseSelectedBase(t *testing.T) {
svc := &GatewayService{cfg: &config.Config{}}
account := gatewayAnthropicBaseURLAccount(map[string]any{
APIProtocolAnthropic: "https://ollama.com",
})
body := []byte(`{"model":"deepseek-v4-flash","max_tokens":256000,"messages":[{"role":"user","content":"hi"}]}`)
c := gatewayAnthropicBaseURLContext(t)
passthroughReq, wireBody, err := svc.buildUpstreamRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "test-key")
require.NoError(t, err)
require.Equal(t, "https://ollama.com/v1/messages?beta=true", passthroughReq.URL.String())
require.Equal(t, "Bearer test-key", passthroughReq.Header.Get("Authorization"))
require.Equal(t, int64(65535), gjson.GetBytes(wireBody, "max_tokens").Int())
countPassthroughReq, err := svc.buildCountTokensRequestAnthropicAPIKeyPassthrough(context.Background(), c, account, body, "test-key")
require.NoError(t, err)
require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countPassthroughReq.URL.String())
require.Equal(t, "Bearer test-key", countPassthroughReq.Header.Get("Authorization"))
countReq, _, err := svc.buildCountTokensRequest(context.Background(), c, account, body, "test-key", "apikey", "deepseek-v4-flash", false)
require.NoError(t, err)
require.Equal(t, "https://ollama.com/v1/messages/count_tokens?beta=true", countReq.URL.String())
require.Equal(t, "Bearer test-key", countReq.Header.Get("Authorization"))
}
@@ -301,7 +301,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
) (*http.Request, []byte, error) {
body = stripDeferredToolCacheControl(body)
targetURL := claudeAPIURL
baseURL := account.GetBaseURL()
baseURL := getAnthropicGatewayBaseURL(account)
if baseURL != "" {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
@@ -326,8 +326,8 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
}
// Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的
// base 取值同源(GetBaseURL),详见 helper 注释。
body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body)
// base 取值同源,详见 helper 注释。
body = clampOllamaCloudAnthropicMessagesMaxTokens(account, baseURL, body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL, bytes.NewReader(body))
if err != nil {
@@ -354,7 +354,7 @@ func (s *GatewayService) buildUpstreamRequestAnthropicAPIKeyPassthrough(
req.Header.Del("cookie")
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
// targetURL 的 base 取值),其余保持 extra/default 行为。
setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL)
if getHeaderRaw(req.Header, "content-type") == "" {
setHeaderRaw(req.Header, "content-type", "application/json")
@@ -22,6 +22,13 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
s.countTokensError(c, http.StatusBadRequest, "invalid_request_error", "Request body is empty")
return fmt.Errorf("parse request: empty request")
}
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
// 凭据只能走其他协议的 OAuth 账号同理,且必须在取 token、建请求之前拦下。
if account != nil && (account.Platform == PlatformAntigravity || !anthropicMessagesCredentialAllowed(account)) {
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform")
return nil
}
validationModel := parsed.Model
if account != nil {
@@ -105,13 +112,6 @@ func (s *GatewayService) ForwardCountTokens(ctx context.Context, c *gin.Context,
}
}
// Antigravity 账户不支持 count_tokens,返回 404 让客户端 fallback 到本地估算。
// 返回 nil 避免 handler 层记录为错误,也不设置 ops 上游错误上下文。
if account.Platform == PlatformAntigravity {
s.countTokensError(c, http.StatusNotFound, "not_found_error", "count_tokens endpoint is not supported for this platform")
return nil
}
// 应用模型映射:
// - APIKey 账号:使用账号级别的显式映射(如果配置),否则透传原始模型名
// - OAuth/SetupToken 账号:使用 Anthropic 标准映射(短ID → 长ID)
@@ -399,7 +399,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
) (*http.Request, error) {
body = stripDeferredToolCacheControl(body)
targetURL := claudeAPICountTokensURL
baseURL := account.GetBaseURL()
baseURL := getAnthropicGatewayBaseURL(account)
if baseURL != "" {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
@@ -446,7 +446,7 @@ func (s *GatewayService) buildCountTokensRequestAnthropicAPIKeyPassthrough(
req.Header.Del("cookie")
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
// targetURL 的 base 取值),其余保持 extra/default 行为。
setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL)
if req.Header.Get("content-type") == "" {
req.Header.Set("content-type", "application/json")
@@ -467,8 +467,9 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
body = stripDeferredToolCacheControl(body)
// 确定目标 URL
targetURL := claudeAPICountTokensURL
baseURL := ""
if account.Type == AccountTypeAPIKey {
baseURL := account.GetBaseURL()
baseURL = getAnthropicGatewayBaseURL(account)
if baseURL != "" {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
@@ -558,7 +559,7 @@ func (s *GatewayService) buildCountTokensRequest(ctx context.Context, c *gin.Con
} else {
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
// targetURL 的 base 取值),其余保持 extra/default 行为。
setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL)
}
// 白名单透传 headers(恢复真实 wire casing)
@@ -44,7 +44,7 @@ func (s *GatewayService) ForwardAsChatCompletions(
clientStream := ccReq.Stream
// 2. Convert CC → Responses → Anthropic (chained conversion)
responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq)
responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq)
if err != nil {
return nil, fmt.Errorf("convert chat completions to responses: %w", err)
}
@@ -37,7 +37,7 @@ func (s *GatewayService) ForwardAsResponses(
) (*ForwardResult, error) {
startTime := time.Now()
normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngress(body)
normalizedBody, normalized, err := normalizeOpenAIResponsesLegacyIngressWithConfig(s.cfg, body)
if err != nil {
return nil, err
}
@@ -180,6 +180,10 @@ func (s *modelsListAccountRepoStub) ListSchedulableByGroupID(ctx context.Context
return out, nil
}
func (s *modelsListAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]Account, error) {
return s.ListSchedulableByGroupID(ctx, *groupID)
}
func (s *modelsListAccountRepoStub) ListSchedulable(ctx context.Context) ([]Account, error) {
s.listAllCalls.Add(1)
if s.err != nil {
@@ -21,6 +21,9 @@ type ModelAvailabilityDiagnosis struct {
// HasModelSupport is true if at least one account's model mapping admits
// the requested model.
HasModelSupport bool
// HasRetiredSubscriptionModel records a ChatGPT upstream blocked by model
// retirement, so an unsupported request can include migration guidance.
HasRetiredSubscriptionModel bool
}
// ModelAvailabilityDiagnoser is implemented by gateway services that can
+22 -6
View File
@@ -20,7 +20,9 @@ import (
"unsafe"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/cespare/xxhash/v2"
@@ -1460,6 +1462,12 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
}
// If no account has model_mapping, return nil (use default)
if !hasAnyMapping && platform == PlatformOpenAI {
models := filterRetiredOpenAIModelIDs(accounts, openai.DefaultModelIDs())
if len(models) != len(openai.DefaultModels) {
return models
}
}
if !hasAnyMapping {
if s.modelsListCache != nil {
s.modelsListCache.Set(cacheKey, []string(nil), s.modelsListCacheTTL)
@@ -1477,6 +1485,12 @@ func (s *GatewayService) GetAvailableModels(ctx context.Context, groupID *int64,
if platform == PlatformOpenAI {
models = supplementUnmappedOpenAIModels(accounts, models)
models = filterRetiredOpenAIModelIDs(accounts, models)
// nil means "use defaults" to listing callers; an empty filtered catalog
// must remain authoritative instead of resurrecting retired defaults.
if len(models) == 0 {
return models
}
}
if s.modelsListCache != nil {
@@ -1501,21 +1515,23 @@ func (s *GatewayService) resolveCompositeModelOwnership(ctx context.Context, gro
}
}
accounts, err := s.accountRepo.ListSchedulableByGroupID(ctx, groupID)
// 归属取决于配置而非运行时健康:限流/临时不可调度不应让同一模型在
// 「唯一归属」与「歧义」之间来回翻转,否则配置错误只会间歇暴露。
accounts, err := s.accountRepo.ListModelAvailabilityCandidates(ctx, &groupID, domain.ConcretePlatformIDs(), false)
if err != nil {
return CompositeModelOwnership{}, err
}
platforms := make(map[string]struct{})
ownership := CompositeModelOwnership{}
for _, account := range accounts {
platform := strings.TrimSpace(account.Platform)
if !isConcreteRequestPlatform(platform) || !explicitModelMappingClaims(account, model) {
continue
if explicitModelMappingClaims(account, model) {
platforms[account.Platform] = struct{}{}
ownership.OpenAIGatewayOAuthClaimed = ownership.OpenAIGatewayOAuthClaimed ||
(account.IsOAuth() && domain.UsesOpenAIGateway(account.Platform))
}
platforms[platform] = struct{}{}
}
ownership := CompositeModelOwnership{}
if len(platforms) == 1 {
for platform := range platforms {
ownership.TargetPlatform = platform
@@ -9,6 +9,7 @@ import (
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
@@ -18,7 +19,37 @@ import (
"github.com/gin-gonic/gin"
)
// anthropicMessagesForeignOAuthReason 标记账号凭据与 Anthropic Messages 协议不匹配。
const anthropicMessagesForeignOAuthReason = GatewayFailureReason("anthropic_messages_foreign_oauth")
// anthropicMessagesCredentialAllowed 判断账号凭据能否经 Anthropic Messages 协议
// 出站。OAuth / setup-token 只对签发它的供应商有效(ChatGPT token 只认
// chatgpt.com),送错协议既泄露凭据,上游 401 还会被误记到账号头上;
// API Key 不受限:其上游可能同时兼容多种协议格式。
func anthropicMessagesCredentialAllowed(account *Account) bool {
if !account.IsOAuth() {
return true
}
spec, ok := domain.LookupPlatform(account.Platform)
return ok && spec.Gateway == domain.PlatformGatewayAnthropic
}
// checkAnthropicMessagesCredential 在发出任何上游请求前拦截不匹配的凭据。返回的
// failover 错误只让 handler 换下一个账号:没有上游响应,也就不触发任何账号处罚。
func checkAnthropicMessagesCredential(account *Account) error {
if anthropicMessagesCredentialAllowed(account) {
return nil
}
return &UpstreamFailoverError{
StatusCode: http.StatusBadGateway,
Reason: anthropicMessagesForeignOAuthReason,
}
}
func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Context, account *Account, body []byte, token, tokenType, modelID string, reqStream bool, mimicClaudeCode bool) (*http.Request, []byte, error) {
if err := checkAnthropicMessagesCredential(account); err != nil {
return nil, nil, err
}
body = stripDeferredToolCacheControl(body)
if account.Platform == PlatformAnthropic && account.Type == AccountTypeServiceAccount {
req, err := s.buildUpstreamRequestAnthropicVertex(ctx, c, account, body, token, modelID, reqStream)
@@ -27,8 +58,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
// 确定目标URL
targetURL := claudeAPIURL
baseURL := ""
if account.Type == AccountTypeAPIKey {
baseURL := account.GetBaseURL()
baseURL = getAnthropicGatewayBaseURL(account)
if baseURL != "" {
validatedURL, err := s.validateUpstreamBaseURL(baseURL)
if err != nil {
@@ -124,9 +156,9 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
}
// Ollama Cloud DeepSeek 出站 max_tokens clamp:判定与上方 targetURL 的
// base 取值同源(GetBaseURL),仅实际上游为 ollama.com 且映射后出站模型
// base 取值同源,仅实际上游为 ollama.com 且映射后出站模型
// 为 DeepSeek 系时压到 cap,详见 helper 注释。
body = clampOllamaCloudAnthropicMessagesMaxTokens(account, account.GetBaseURL(), body)
body = clampOllamaCloudAnthropicMessagesMaxTokens(account, baseURL, body)
req, err := http.NewRequestWithContext(ctx, "POST", targetURL, bytes.NewReader(body))
if err != nil {
@@ -139,7 +171,7 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
} else {
// Ollama Cloud Anthropic 兼容端点按实际 base_url 强制 Bearer(同上方
// targetURL 的 base 取值),其余保持 extra/default 行为。
setAnthropicAPIKeyAuthHeader(req.Header, account, token, account.GetBaseURL())
setAnthropicAPIKeyAuthHeader(req.Header, account, token, baseURL)
}
// 白名单透传 headers
@@ -225,6 +257,16 @@ func (s *GatewayService) buildUpstreamRequest(ctx context.Context, c *gin.Contex
return req, body, nil
}
func getAnthropicGatewayBaseURL(account *Account) string {
if account == nil {
return ""
}
if baseURL := account.GetAnthropicProtocolBaseURL(); baseURL != "" {
return baseURL
}
return account.GetBaseURL()
}
// vertexSupportedBetaTokens 是 Vertex AI 的 Anthropic 端点接受的 anthropic-beta
// 白名单。Vertex 对任何未知 token 直接 HTTP 400,故采用白名单(与 Bedrock 的
// bedrockSupportedBetaTokens 同思路)而非黑名单:未来 Claude Code 新增的、Vertex 尚未
@@ -0,0 +1,73 @@
//go:build unit
package service
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// Scenario: OAuth / setup-token 凭据只能走签发方协议,API Key 与其他凭据类型不受限
func TestAnthropicMessagesCredentialAllowed(t *testing.T) {
cases := []struct {
platform string
typ string
allowed bool
}{
{PlatformAnthropic, AccountTypeOAuth, true},
{PlatformAnthropic, AccountTypeSetupToken, true},
{PlatformAnthropic, AccountTypeServiceAccount, true},
{PlatformAnthropic, AccountTypeBedrock, true},
{PlatformTypeSafe, AccountTypeOAuth, true},
{PlatformOpenAI, AccountTypeAPIKey, true},
{PlatformDeepseek, AccountTypeAPIKey, true},
{PlatformOpenAI, AccountTypeOAuth, false},
{PlatformOpenAI, AccountTypeSetupToken, false},
{PlatformGrok, AccountTypeOAuth, false},
{PlatformGemini, AccountTypeOAuth, false},
{PlatformAntigravity, AccountTypeOAuth, false},
}
for _, tc := range cases {
allowed := anthropicMessagesCredentialAllowed(&Account{ID: 1, Platform: tc.platform, Type: tc.typ})
require.Equal(t, tc.allowed, allowed, "%s/%s", tc.platform, tc.typ)
}
}
// Scenario: 回归 —— OpenAI OAuth 账号误入 Anthropic 管线时,在构建请求前即被拒绝
// (ChatGPT token 不会发往 api.anthropic.com),且只让 handler 换号、不处罚账号
func TestGatewayBuildUpstreamRequestRejectsForeignOAuthWithoutPenalty(t *testing.T) {
account := &Account{ID: 25, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
body := []byte(`{"model":"gpt-6-astra","messages":[]}`)
req, _, err := (&GatewayService{}).buildUpstreamRequest(context.Background(), nil, account, body, "chatgpt-token", "oauth", "gpt-6-astra", true, false)
require.Nil(t, req)
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr))
require.True(t, failoverErr.ShouldRetryNextAccount())
require.False(t, failoverErr.RetryableOnSameAccount, "same-account retry would end in temp-unschedulable")
require.False(t, failoverErr.IsCredentialFailure())
require.Equal(t, anthropicMessagesForeignOAuthReason, failoverErr.Reason)
}
// Scenario: count_tokens 对不匹配凭据按「平台不支持」返回 404,不取 token、不出站
func TestForwardCountTokensRejectsForeignOAuthAsUnsupported(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages/count_tokens", nil)
account := &Account{ID: 25, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
parsed := &ParsedRequest{Model: "gpt-6-astra", Body: NewRequestBodyRef([]byte(`{"model":"gpt-6-astra","messages":[]}`))}
err := (&GatewayService{}).ForwardCountTokens(context.Background(), c, account, parsed)
require.NoError(t, err)
require.Equal(t, http.StatusNotFound, w.Code)
}
@@ -52,13 +52,27 @@ func getWebSearchManager() *websearch.Manager {
// Judgment chain: manager exists → only web_search tool → global enabled → account/channel enabled.
// Account-level mode: "enabled" (force on), "disabled" (force off), "default" (follow channel).
func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Account, groupID *int64, body []byte) bool {
if getWebSearchManager() == nil {
return shouldEmulateWebSearchFor(ctx, s.settingService, s.channelService, account, groupID, body)
}
// shouldEmulateWebSearchFor is shared by the Anthropic gateway and the OpenAI
// gateway's /v1/messages path.
func shouldEmulateWebSearchFor(
ctx context.Context, settingService *SettingService, channelService *ChannelService,
account *Account, groupID *int64, body []byte,
) bool {
if getWebSearchManager() == nil || account == nil || settingService == nil {
return false
}
// OpenAI OAuth/setup-token accounts retain native search regardless of
// channel configuration. Only API key accounts opt into emulation.
if account.Platform == PlatformOpenAI && account.Type != AccountTypeAPIKey {
return false
}
if !isOnlyWebSearchToolInBody(body) {
return false
}
if !s.settingService.IsWebSearchEmulationEnabled(ctx) {
if !settingService.IsWebSearchEmulationEnabled(ctx) {
return false
}
@@ -69,10 +83,10 @@ func (s *GatewayService) shouldEmulateWebSearch(ctx context.Context, account *Ac
case WebSearchModeDisabled:
return false
default: // "default" → follow channel config
if groupID == nil || s.channelService == nil {
if groupID == nil || channelService == nil {
return false
}
ch, err := s.channelService.GetChannelForGroup(ctx, *groupID)
ch, err := channelService.GetChannelForGroup(ctx, *groupID)
if err != nil || ch == nil {
return false
}
@@ -143,14 +157,21 @@ func extractWebSearchTextFromContent(content gjson.Result) string {
func (s *GatewayService) handleWebSearchEmulation(
ctx context.Context, c *gin.Context, account *Account, parsed *ParsedRequest,
) (*ForwardResult, error) {
startTime := time.Now()
// Release the serial queue lock immediately — we don't need upstream.
if parsed.OnUpstreamAccepted != nil {
parsed.OnUpstreamAccepted()
}
return writeEmulatedWebSearch(ctx, c, account, parsed.Body.Bytes(), parsed.Model, parsed.Stream)
}
query := extractSearchQueryFromBody(parsed.Body.Bytes())
// writeEmulatedWebSearch runs the search for the last user message and writes
// an Anthropic Messages response (SSE when stream is true).
func writeEmulatedWebSearch(
ctx context.Context, c *gin.Context, account *Account, body []byte, model string, stream bool,
) (*ForwardResult, error) {
startTime := time.Now()
query := extractSearchQueryFromBody(body)
if query == "" {
return nil, fmt.Errorf("web search emulation: no query found in messages")
}
@@ -173,12 +194,11 @@ func (s *GatewayService) handleWebSearchEmulation(
slog.Info("web search emulation: search completed",
"provider", providerName, "results_count", len(resp.Results))
model := parsed.Model
if model == "" {
model = defaultWebSearchModel
}
if parsed.Stream {
if stream {
return writeWebSearchStreamResponse(c, query, resp, model, startTime)
}
return writeWebSearchNonStreamResponse(c, query, resp, model, startTime)
@@ -378,3 +378,88 @@ func TestShouldEmulateWebSearch_DefaultMode_NilChannelService(t *testing.T) {
// nil channelService + default mode → returns false
require.False(t, svc.shouldEmulateWebSearch(context.Background(), account, &groupID, webSearchToolBody))
}
func TestShouldEmulateWebSearchFor_OpenAIAPIKeyAccountEnabled(t *testing.T) {
mgr := websearch.NewManager([]websearch.ProviderConfig{{Type: "brave", APIKey: "k"}}, nil)
SetWebSearchManager(mgr)
defer SetWebSearchManager(nil)
setGlobalWebSearchConfig(&WebSearchEmulationConfig{
Enabled: true,
Providers: []WebSearchProviderConfig{{Type: "brave", APIKey: "k"}},
})
defer clearGlobalWebSearchConfig()
settingSvc := newSettingServiceForWebSearchTest(true)
account := &Account{
ID: 1,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{featureKeyWebSearchEmulation: WebSearchModeEnabled},
}
groupID := int64(0)
require.True(t, shouldEmulateWebSearchFor(context.Background(), settingSvc, nil, account, &groupID, webSearchToolBody))
require.False(t, shouldEmulateWebSearchFor(context.Background(), settingSvc, nil, account, &groupID, nonWebSearchToolBody))
}
// Exercise the shared interception gate, including channel inheritance.
func TestShouldEmulateWebSearchFor_AccountChannelModes(t *testing.T) {
previousManager := getWebSearchManager()
previousConfig := webSearchEmulationCache.Load()
SetWebSearchManager(websearch.NewManager([]websearch.ProviderConfig{{Type: "brave", APIKey: "k"}}, nil))
t.Cleanup(func() {
SetWebSearchManager(previousManager)
if previousConfig == nil {
clearGlobalWebSearchConfig()
} else {
webSearchEmulationCache.Store(previousConfig)
}
})
settingSvc := newSettingServiceForWebSearchTest(true)
groupID := int64(42)
for _, platform := range []string{PlatformOpenAI, PlatformAnthropic} {
for _, accountType := range []string{AccountTypeAPIKey, AccountTypeOAuth, AccountTypeSetupToken} {
for _, mode := range []string{WebSearchModeDefault, WebSearchModeEnabled, WebSearchModeDisabled} {
for _, channelEnabled := range []bool{false, true} {
channelMode := "channel_off"
if channelEnabled {
channelMode = "channel_on"
}
t.Run(platform+"/"+accountType+"/"+mode+"/"+channelMode, func(t *testing.T) {
account := &Account{
Platform: platform, Type: accountType,
Extra: map[string]any{featureKeyWebSearchEmulation: mode},
}
channelSvc := newChannelServiceWithCache(groupID, &Channel{
ID: 10, Status: StatusActive,
FeaturesConfig: map[string]any{
featureKeyWebSearchEmulation: map[string]any{platform: channelEnabled},
},
})
want := channelEnabled
if accountType == AccountTypeAPIKey {
switch mode {
case WebSearchModeEnabled:
want = true
case WebSearchModeDisabled:
want = false
}
} else if platform == PlatformOpenAI {
want = false
}
for _, globalEnabled := range []bool{false, true} {
setGlobalWebSearchConfig(&WebSearchEmulationConfig{
Enabled: globalEnabled,
Providers: []WebSearchProviderConfig{{Type: "brave", APIKey: "k"}},
})
require.Equal(t, globalEnabled && want, shouldEmulateWebSearchFor(
context.Background(), settingSvc, channelSvc, account, &groupID, webSearchToolBody),
"globalEnabled=%v", globalEnabled)
}
})
}
}
}
}
}
@@ -43,7 +43,10 @@ func TestBuiltinGeminiCacheStoragePrice(t *testing.T) {
{"gemini-flash-latest", 1e-6},
{"gemini-flash-lite-latest", 1e-6},
{"gemini-pro-latest", 4.5e-6},
// 无存储价
// 无存储价(Gemini 4 Argon 仅支持隐式缓存)
{"gemini-4-argon", 0},
{"gemini-4-argon-high", 0},
{"gemini-4-argon-cyber-permissive", 0},
{"gemini-embedding-001", 0},
{"gemini-unknown-model", 0},
{"claude-sonnet-5-5", 0},
@@ -42,7 +42,16 @@ func (s *GeminiMessagesCompatService) ForwardAsChatCompletions(
clientStream := ccReq.Stream
includeUsage := ccReq.StreamOptions != nil && ccReq.StreamOptions.IncludeUsage
responsesReq, err := apicompat.ChatCompletionsToResponses(&ccReq)
// 历史推理内容不回传给 Gemini:Gemini 的思考连续性由 thoughtSignature 承载。
for i := range ccReq.Messages {
ccReq.Messages[i].ReasoningContent = ""
ccReq.Messages[i].Reasoning = ""
if ccReq.Messages[i].Role == "assistant" {
ccReq.Messages[i].Content = dropChatReasoningContentParts(ccReq.Messages[i].Content)
}
}
responsesReq, err := chatCompletionsToResponses(s.cfg, &ccReq)
if err != nil {
return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
@@ -92,6 +101,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
return nil, s.writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
geminiReq = ensureGeminiFunctionCallThoughtSignatures(geminiReq)
geminiReq = applyGeminiCompatThinkingConfig(geminiReq, claudeBody, mappedModel, clientStream)
proxyURL := ""
if account.ProxyID != nil && account.Proxy != nil {
@@ -112,12 +122,17 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
)
var resp *http.Response
// checkedPolicy 是循环内对当前 resp 的错误策略检查结果,未检查时为 nil。
var checkedPolicy *ErrorPolicyResult
for attempt := 1; attempt <= geminiMaxRetries; attempt++ {
upstreamReq, idHeader, err := buildReq(ctx)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
if failoverErr := s.handleVertexServiceAccountNotFound(ctx, c, account, err); failoverErr != nil {
return nil, failoverErr
}
return nil, s.writeChatCompletionsError(c, http.StatusBadGateway, "upstream_error", err.Error())
}
requestIDHeader = idHeader
@@ -127,11 +142,11 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
return nil, s.handleUpstreamTransportError(ctx, c, account, err)
}
if matched, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp, mappedModel); matched {
resp = rebuilt
checked, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp, mappedModel)
resp = rebuilt
checkedPolicy = checked
if checked != nil && *checked != ErrorPolicyNone {
break
} else {
resp = rebuilt
}
if resp.StatusCode >= 400 && s.shouldRetryGeminiUpstreamError(account, resp.StatusCode) {
@@ -196,7 +211,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
respBody := s.readUpstreamErrorBody(resp)
policy := ErrorPolicyNone
if s.rateLimitService != nil {
policy = s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel)
policy = s.finalErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel, checkedPolicy)
}
// 与 messages 兼容层一致:只有 None / Matched 才走账号状态处理。
// Skipped(池模式、或自定义错误码未命中)与 TempUnscheduled 已由策略层裁决完毕。
@@ -235,6 +250,8 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
var usage *ClaudeUsage
var firstTokenMs *int
clientDisconnect := false
streamFinished := false
if clientStream {
streamRes, err := s.handleChatCompletionsStreamingResponseFromGemini(c, resp, startTime, originalModel, account.Type == AccountTypeOAuth, includeUsage)
if err != nil {
@@ -242,6 +259,8 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
}
usage = streamRes.usage
firstTokenMs = streamRes.firstTokenMs
clientDisconnect = streamRes.clientDisconnect
streamFinished = streamRes.finished
} else if useUpstreamStream {
collected, usageObj, err := collectGeminiSSE(resp.Body, account.Type == AccountTypeOAuth)
if err != nil {
@@ -272,6 +291,10 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
if isImageGenerationModel(originalModel) {
imageCount = 1
}
// 客户端在收到结束分片前断开时只计已送达的图片:Chat 流不向客户端转发图片。
if clientDisconnect && !streamFinished {
imageCount = 0
}
return &ForwardResult{
RequestID: requestID,
@@ -286,7 +309,7 @@ func (s *GeminiMessagesCompatService) forwardClaudeBodyAsChatCompletions(
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
ClientDisconnect: false,
ClientDisconnect: clientDisconnect,
}, nil
}
@@ -517,6 +540,11 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
var usage ClaudeUsage
var firstTokenMs *int
firstChunk := true
finishReason := ""
clientDisconnect := false
streamResult := func() *geminiStreamResult {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnect, finished: finishReason != ""}
}
writeChatChunk := func(chunk apicompat.ChatCompletionsChunk) bool {
sse, err := apicompat.ChatChunkToSSE(chunk)
@@ -524,6 +552,7 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
return false
}
if _, err := io.WriteString(c.Writer, sse); err != nil {
clientDisconnect = true
return true
}
return false
@@ -556,15 +585,15 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
Usage: apicompat.AnthropicUsage{},
},
}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
finishReason := ""
sawToolUse := false
nextBlockIndex := 0
openBlockIndex := -1
openBlockType := ""
seenText := ""
seenThought := ""
openToolIndex := -1
openToolName := ""
seenToolJSON := ""
@@ -619,10 +648,45 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
}
for _, part := range extractGeminiParts(geminiResp) {
if text, ok := part["text"].(string); ok && isGeminiThoughtPart(part) {
delta, newSeen := computeGeminiTextDelta(seenThought, text)
seenThought = newSeen
if delta == "" {
continue
}
if openToolIndex >= 0 {
if closeOpenTool() {
return streamResult(), nil
}
}
if openBlockType != "thinking" {
if closeOpenBlock() {
return streamResult(), nil
}
idx := nextBlockIndex
nextBlockIndex++
openBlockIndex = idx
openBlockType = "thinking"
if emitAnthropicEvent(&apicompat.AnthropicStreamEvent{
Type: "content_block_start",
Index: &idx,
ContentBlock: &apicompat.AnthropicContentBlock{Type: "thinking"},
}) {
return streamResult(), nil
}
}
if emitAnthropicEvent(&apicompat.AnthropicStreamEvent{
Type: "content_block_delta",
Delta: &apicompat.AnthropicDelta{Type: "thinking_delta", Thinking: delta},
}) {
return streamResult(), nil
}
continue
}
if text, ok := part["text"].(string); ok && text != "" {
if openToolIndex >= 0 {
if closeOpenTool() {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
}
delta, newSeen := computeGeminiTextDelta(seenText, text)
@@ -632,7 +696,7 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
}
if openBlockType != "text" {
if closeOpenBlock() {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
idx := nextBlockIndex
nextBlockIndex++
@@ -646,7 +710,7 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
Text: "",
},
}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
}
if emitAnthropicEvent(&apicompat.AnthropicStreamEvent{
@@ -656,7 +720,7 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
Text: delta,
},
}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
continue
}
@@ -667,11 +731,11 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
name = "tool"
}
if closeOpenBlock() {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
if openToolIndex >= 0 && openToolName != name {
if closeOpenTool() {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
}
if openToolIndex < 0 {
@@ -690,7 +754,7 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
Input: json.RawMessage(`{}`),
},
}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
}
@@ -716,7 +780,7 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
PartialJSON: delta,
},
}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
}
}
@@ -730,15 +794,20 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
break
}
if err != nil {
if geminiStreamAbortedWithBillableOutput(c, err, &usage) {
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] Client disconnected during chat completions stream, billing collected usage: %v", err)
clientDisconnect = true
return streamResult(), nil
}
return nil, fmt.Errorf("stream read error: %w", err)
}
}
if closeOpenBlock() {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
if closeOpenTool() {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
stopReason := mapGeminiFinishReasonToClaudeStopReason(finishReason)
@@ -759,30 +828,30 @@ func (s *GeminiMessagesCompatService) handleChatCompletionsStreamingResponseFrom
CacheReadInputTokens: usage.CacheReadInputTokens,
},
}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
if emitAnthropicEvent(&apicompat.AnthropicStreamEvent{Type: "message_stop"}) {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
for _, resEvt := range apicompat.FinalizeAnthropicResponsesStream(anthState) {
chunks := apicompat.ResponsesEventToChatChunks(&resEvt, ccState)
for _, chunk := range chunks {
if disconnected := writeChatChunk(chunk); disconnected {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
}
}
for _, chunk := range apicompat.FinalizeResponsesChatStream(ccState) {
if disconnected := writeChatChunk(chunk); disconnected {
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
}
_, _ = io.WriteString(c.Writer, "data: [DONE]\n\n")
flusher.Flush()
return &geminiStreamResult{usage: &usage, firstTokenMs: firstTokenMs}, nil
return streamResult(), nil
}
func (s *GeminiMessagesCompatService) writeGeminiChatCompletionsMappedError(
@@ -0,0 +1,320 @@
package service
import (
"bytes"
"encoding/json"
"strconv"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// geminiThinkingParam 是上游模型接受的思考参数形态。
type geminiThinkingParam int
const (
geminiThinkingParamNone geminiThinkingParam = iota
geminiThinkingParamBudget
geminiThinkingParamLevel
)
const (
gemini25ProMaxThinkingBudget = 32768
gemini25ProMinThinkingBudget = 128
gemini25FlashMaxThinkingBudget = 24576
gemini25FlashLiteMinThinkingBudget = 512
)
// geminiThinkingParamForModel 按上游模型名判断思考参数形态:2.5 系列用 thinkingBudget,
// 3 及以上用 thinkingLevel。2.5 之前的版本、无法解析版本号的名字、图像/语音/向量类模型
// 不写思考参数。
func geminiThinkingParamForModel(model string) geminiThinkingParam {
name := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(model)), "models/")
rest, ok := strings.CutPrefix(name, "gemini-")
if !ok {
return geminiThinkingParamNone
}
for _, kind := range []string{"image", "tts", "embedding", "live", "audio"} {
if strings.Contains(rest, kind) {
return geminiThinkingParamNone
}
}
version, _, _ := strings.Cut(rest, "-")
majorText, minorText, _ := strings.Cut(version, ".")
major, err := strconv.Atoi(majorText)
if err != nil {
return geminiThinkingParamNone
}
minor := 0
if minorText != "" {
if minor, err = strconv.Atoi(minorText); err != nil {
return geminiThinkingParamNone
}
}
switch {
case major >= 3:
return geminiThinkingParamLevel
case major == 2 && minor >= 5:
return geminiThinkingParamBudget
default:
return geminiThinkingParamNone
}
}
// geminiModelNameCarriesThinkingTier 判断模型名是否以档位后缀结尾(如 -low/-high),
// 这类名字由上游按名字选档,不再叠加思考档位参数。
func geminiModelNameCarriesThinkingTier(model string) bool {
name := strings.ToLower(strings.TrimSpace(model))
for _, suffix := range []string{"-minimal", "-low", "-medium", "-high", "-xhigh", "-max", "-tiered"} {
if strings.HasSuffix(name, suffix) {
return true
}
}
return false
}
// claudeThinkingIntent 是 Anthropic 请求体里与思考相关的意图。
type claudeThinkingIntent struct {
thinkingType string // enabled / adaptive / disabled / 空
tier string // minimal / low / medium / high / 空
budgetTokens int // thinking.type=enabled 时的 budget_tokens
}
// parseClaudeThinkingIntent 读取 Anthropic 请求的思考参数。thinking.type=disabled 优先于
// output_config.effort;effort 归一到 minimal/low/medium/high,未识别的取值视为未指定。
func parseClaudeThinkingIntent(claudeBody []byte) claudeThinkingIntent {
intent := claudeThinkingIntent{
thinkingType: strings.ToLower(strings.TrimSpace(gjson.GetBytes(claudeBody, "thinking.type").String())),
}
if intent.thinkingType == "disabled" {
intent.tier = "minimal"
return intent
}
switch strings.ToLower(strings.TrimSpace(gjson.GetBytes(claudeBody, "output_config.effort").String())) {
case "none", "minimal":
intent.tier = "minimal"
case "low":
intent.tier = "low"
case "medium":
intent.tier = "medium"
case "high", "xhigh", "max":
intent.tier = "high"
}
if intent.thinkingType == "enabled" {
intent.budgetTokens = int(gjson.GetBytes(claudeBody, "thinking.budget_tokens").Int())
}
return intent
}
// claudeRequestEnablesThinking 判断 Anthropic 请求是否开启了思考输出。
func claudeRequestEnablesThinking(claudeBody []byte) bool {
switch parseClaudeThinkingIntent(claudeBody).thinkingType {
case "enabled", "adaptive":
return true
default:
return false
}
}
// geminiThinkingLevelFor 把思考意图映射为 thinkingLevel;返回空串表示交给模型默认档位。
// minimal 只发给 flash 系列,其余模型用 low;medium 只发给 flash 系列与 Gemini 3.1 Pro,
// 其余模型用 high(Gemini 3 及以上未指定档位时的默认档位)。
func geminiThinkingLevelFor(model string, intent claudeThinkingIntent) string {
tier := intent.tier
if tier == "" && intent.budgetTokens > 0 {
switch {
case intent.budgetTokens <= 1024:
tier = "low"
case intent.budgetTokens <= 8192:
tier = "medium"
default:
tier = "high"
}
}
name := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(model)), "models/")
isFlash := strings.Contains(name, "flash")
if tier == "minimal" && !isFlash {
tier = "low"
}
if tier == "medium" && !isFlash && !strings.HasPrefix(name, "gemini-3.1-pro") {
tier = "high"
}
return tier
}
// geminiThinkingBudgetFor 把思考意图映射为 Gemini 2.5 的 thinkingBudget;ok=false 表示交给
// 模型动态预算。Pro 不能关闭思考(最小 128,最大 32768),Flash 为 0–24576,
// Flash-Lite 的正预算最小 512。
func geminiThinkingBudgetFor(model string, intent claudeThinkingIntent) (budget int, ok bool) {
name := strings.ToLower(model)
isPro := strings.Contains(name, "pro")
maxBudget, minPositive := gemini25FlashMaxThinkingBudget, 1
switch {
case isPro:
maxBudget, minPositive = gemini25ProMaxThinkingBudget, gemini25ProMinThinkingBudget
case strings.Contains(name, "flash-lite"):
minPositive = gemini25FlashLiteMinThinkingBudget
}
switch intent.tier {
case "minimal":
if isPro {
return gemini25ProMinThinkingBudget, true
}
return 0, true
case "low":
budget = 1024
case "medium":
budget = 8192
case "high":
budget = maxBudget
default:
if intent.budgetTokens <= 0 {
return 0, false
}
budget = intent.budgetTokens
}
budget = min(max(budget, minPositive), maxBudget)
return budget, true
}
// applyGeminiCompatThinkingConfig 把兼容入口(Anthropic 形态请求体)的思考意图写进转换后的
// Gemini 请求 generationConfig.thinkingConfig。includeThoughts 为 true 时要求上游返回
// 思考摘要(thought=true 的 part);思考被关闭(预算为 0)时不要求。
func applyGeminiCompatThinkingConfig(geminiReq, claudeBody []byte, upstreamModel string, includeThoughts bool) []byte {
param := geminiThinkingParamForModel(upstreamModel)
if param == geminiThinkingParamNone {
return geminiReq
}
intent := parseClaudeThinkingIntent(claudeBody)
out := geminiReq
thinkingOff := false
if !geminiModelNameCarriesThinkingTier(upstreamModel) {
switch param {
case geminiThinkingParamLevel:
if level := geminiThinkingLevelFor(upstreamModel, intent); level != "" {
out = setGeminiThinkingField(out, "thinkingLevel", level)
}
case geminiThinkingParamBudget:
if budget, ok := geminiThinkingBudgetFor(upstreamModel, intent); ok {
out = setGeminiThinkingField(out, "thinkingBudget", budget)
thinkingOff = budget == 0
}
}
}
if includeThoughts && !thinkingOff {
out = setGeminiThinkingField(out, "includeThoughts", true)
}
return out
}
func setGeminiThinkingField(body []byte, field string, value any) []byte {
updated, err := sjson.SetBytes(body, "generationConfig.thinkingConfig."+field, value)
if err != nil {
return body
}
return updated
}
// isGeminiThoughtPart 判断响应 part 是否为思考摘要。
func isGeminiThoughtPart(part map[string]any) bool {
thought, _ := part["thought"].(bool)
return thought
}
// dropClaudeHistoryThinkingBlocks 删除 Anthropic 请求历史消息里的 thinking/redacted_thinking 块,
// 只含思考块的消息整条删除;与 convertClaudeMessagesToGeminiContents 的取舍一致,供签名降级
// 重试在把思考块改写成正文之前使用。
func dropClaudeHistoryThinkingBlocks(claudeBody []byte) []byte {
var req map[string]any
if err := json.Unmarshal(claudeBody, &req); err != nil {
return claudeBody
}
messages, ok := req["messages"].([]any)
if !ok {
return claudeBody
}
changed := false
kept := make([]any, 0, len(messages))
for _, m := range messages {
mm, ok := m.(map[string]any)
if !ok {
kept = append(kept, m)
continue
}
blocks, ok := mm["content"].([]any)
if !ok {
kept = append(kept, m)
continue
}
filtered := make([]any, 0, len(blocks))
for _, block := range blocks {
if bm, ok := block.(map[string]any); ok {
if bt, _ := bm["type"].(string); bt == "thinking" || bt == "redacted_thinking" {
continue
}
}
filtered = append(filtered, block)
}
if len(filtered) == len(blocks) {
kept = append(kept, m)
continue
}
// 原消息是多块,转换器会过滤空白正文块;删掉思考块后同样过滤,避免剩下的单块空白正文被原样发出。
nonBlank := filtered[:0]
for _, block := range filtered {
if bm, ok := block.(map[string]any); ok {
if bt, _ := bm["type"].(string); bt == "text" {
if text, _ := bm["text"].(string); strings.TrimSpace(text) == "" {
continue
}
}
}
nonBlank = append(nonBlank, block)
}
filtered = nonBlank
changed = true
if len(filtered) == 0 {
continue
}
mm["content"] = filtered
kept = append(kept, mm)
}
if !changed {
return claudeBody
}
req["messages"] = kept
out, err := json.Marshal(req)
if err != nil {
return claudeBody
}
return out
}
// dropChatReasoningContentParts 删除 Chat Completions assistant 消息 content 数组里
// type 为 thinking/reasoning 的部分;字符串形态的 content 原样返回。
func dropChatReasoningContentParts(content json.RawMessage) json.RawMessage {
if !bytes.HasPrefix(bytes.TrimSpace(content), []byte("[")) {
return content
}
var parts []json.RawMessage
if err := json.Unmarshal(content, &parts); err != nil {
return content
}
kept := make([]json.RawMessage, 0, len(parts))
for _, part := range parts {
switch gjson.GetBytes(part, "type").String() {
case "thinking", "reasoning":
continue
}
kept = append(kept, part)
}
if len(kept) == len(parts) {
return content
}
out, err := json.Marshal(kept)
if err != nil {
return content
}
return out
}
@@ -0,0 +1,367 @@
package service
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestApplyGeminiCompatThinkingConfig(t *testing.T) {
budget := func(v int64) *int64 { return &v }
const baseReq = `{"contents":[{"role":"user","parts":[{"text":"hi"}]}],"generationConfig":{"maxOutputTokens":8192}}`
tests := []struct {
name string
model string
claudeBody string
includeThoughts bool
wantLevel string
wantBudget *int64
wantThoughts bool
wantNoConfig bool
}{
{name: "effort medium on flash", model: "gemini-3.8-flash", claudeBody: `{"output_config":{"effort":"medium"},"thinking":{"type":"enabled","budget_tokens":4096}}`, wantLevel: "medium"},
{name: "effort low", model: "gemini-3.7-flash", claudeBody: `{"output_config":{"effort":"low"}}`, wantLevel: "low"},
{name: "effort none on flash", model: "gemini-3.8-flash", claudeBody: `{"output_config":{"effort":"none"},"thinking":{"type":"enabled","budget_tokens":10240}}`, wantLevel: "minimal"},
{name: "effort minimal on pro", model: "gemini-3.1-pro-preview", claudeBody: `{"output_config":{"effort":"minimal"}}`, wantLevel: "low"},
{name: "effort medium on gemini 3 pro", model: "gemini-3-pro-preview", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "high"},
{name: "effort medium on gemini 3.1 pro", model: "gemini-3.1-pro-preview", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "medium"},
{name: "effort max", model: "gemini-3.7-flash", claudeBody: `{"output_config":{"effort":"max"}}`, wantLevel: "high"},
{name: "effort xhigh", model: "gemini-3.7-flash", claudeBody: `{"output_config":{"effort":"xhigh"}}`, wantLevel: "high"},
{name: "thinking disabled wins over effort", model: "gemini-3.8-flash", claudeBody: `{"thinking":{"type":"disabled"},"output_config":{"effort":"high"}}`, wantLevel: "minimal"},
{name: "budget only maps to high", model: "gemini-3.8-flash", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":31999}}`, wantLevel: "high"},
{name: "budget only maps to medium", model: "gemini-3.8-flash", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":4096}}`, wantLevel: "medium"},
{name: "budget only maps to low", model: "gemini-3.8-flash", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":1024}}`, wantLevel: "low"},
{name: "adaptive keeps default level", model: "gemini-3.7-flash", claudeBody: `{"thinking":{"type":"adaptive"}}`, includeThoughts: true, wantThoughts: true},
{name: "no thinking params streams thoughts only", model: "gemini-3.7-flash", claudeBody: `{}`, includeThoughts: true, wantThoughts: true},
{name: "unknown effort ignored", model: "gemini-3.7-flash", claudeBody: `{"output_config":{"effort":"turbo"}}`, wantNoConfig: true},
{name: "level with thoughts", model: "gemini-3.8-flash", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantLevel: "high", wantThoughts: true},
{name: "gemini 4 uses level", model: "gemini-4-argon", claudeBody: `{"output_config":{"effort":"low"}}`, wantLevel: "low"},
{name: "medium outside verified models becomes high", model: "gemini-4-argon", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "high"},
{name: "medium budget outside verified models becomes high", model: "gemini-4-argon", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":4096}}`, wantLevel: "high"},
{name: "medium on unverified pro becomes high", model: "gemini-3.5-pro", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "high"},
{name: "medium on flash lite", model: "gemini-3.1-flash-lite", claudeBody: `{"output_config":{"effort":"medium"}}`, wantLevel: "medium"},
{name: "tier suffix model keeps name tier", model: "gemini-4-argon-high", claudeBody: `{"output_config":{"effort":"low"}}`, includeThoughts: true, wantThoughts: true},
{name: "tiered suffix model keeps name tier", model: "gemini-3.8-flash-tiered", claudeBody: `{"output_config":{"effort":"low"}}`, includeThoughts: true, wantThoughts: true},
{name: "2.5 flash low", model: "gemini-2.5-flash", claudeBody: `{"output_config":{"effort":"low"}}`, wantBudget: budget(1024)},
{name: "2.5 flash high", model: "gemini-2.5-flash", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantBudget: budget(24576), wantThoughts: true},
{name: "2.5 flash none disables thinking and thoughts", model: "gemini-2.5-flash", claudeBody: `{"output_config":{"effort":"none"}}`, includeThoughts: true, wantBudget: budget(0)},
{name: "2.5 pro cannot disable thinking", model: "gemini-2.5-pro", claudeBody: `{"thinking":{"type":"disabled"}}`, includeThoughts: true, wantBudget: budget(128), wantThoughts: true},
{name: "2.5 pro high", model: "gemini-2.5-pro", claudeBody: `{"output_config":{"effort":"max"}}`, wantBudget: budget(32768)},
{name: "2.5 pro budget clamps up", model: "gemini-2.5-pro", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":50}}`, wantBudget: budget(128)},
{name: "2.5 flash budget clamps down", model: "gemini-2.5-flash", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":31999}}`, wantBudget: budget(24576)},
{name: "2.5 flash lite budget clamps up", model: "gemini-2.5-flash-lite", claudeBody: `{"thinking":{"type":"enabled","budget_tokens":100}}`, wantBudget: budget(512)},
{name: "2.5 adaptive keeps dynamic budget", model: "gemini-2.5-flash", claudeBody: `{"thinking":{"type":"adaptive"}}`, wantNoConfig: true},
{name: "2.0 untouched", model: "gemini-2.0-flash", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantNoConfig: true},
{name: "image model untouched", model: "gemini-3-pro-image-preview", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantNoConfig: true},
{name: "unparsable model untouched", model: "gemini-flash-latest", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantNoConfig: true},
{name: "non gemini model untouched", model: "custom-model", claudeBody: `{"output_config":{"effort":"high"}}`, includeThoughts: true, wantNoConfig: true},
{name: "models prefix", model: "models/gemini-3.8-flash", claudeBody: `{"output_config":{"effort":"low"}}`, wantLevel: "low"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
out := applyGeminiCompatThinkingConfig([]byte(baseReq), []byte(tt.claudeBody), tt.model, tt.includeThoughts)
require.Equal(t, int64(8192), gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int())
require.Equal(t, "hi", gjson.GetBytes(out, "contents.0.parts.0.text").String())
cfg := gjson.GetBytes(out, "generationConfig.thinkingConfig")
if tt.wantNoConfig {
require.False(t, cfg.Exists(), cfg.Raw)
return
}
require.Equal(t, tt.wantLevel, cfg.Get("thinkingLevel").String())
if tt.wantBudget != nil {
require.True(t, cfg.Get("thinkingBudget").Exists(), cfg.Raw)
require.Equal(t, *tt.wantBudget, cfg.Get("thinkingBudget").Int())
} else {
require.False(t, cfg.Get("thinkingBudget").Exists(), cfg.Raw)
}
require.Equal(t, tt.wantThoughts, cfg.Get("includeThoughts").Bool(), cfg.Raw)
require.False(t, cfg.Get("thinkingLevel").Exists() && cfg.Get("thinkingBudget").Exists(), "thinkingLevel and thinkingBudget are mutually exclusive")
})
}
}
func geminiCompatThinkingTestAccount() *Account {
return &Account{
ID: 611,
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "gemini-api-key"},
Concurrency: 1,
}
}
func TestGeminiForwardAsChatCompletions_StreamsThoughtsAsReasoningContent(t *testing.T) {
gin.SetMode(gin.TestMode)
upstreamBody := `data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Weighing options","thought":true}]}}]}` + "\n\n" +
`data: {"candidates":[{"content":{"role":"model","parts":[{"text":" carefully.","thought":true}]}}]}` + "\n\n" +
`data: {"candidates":[{"content":{"role":"model","parts":[{"text":"Final answer","thoughtSignature":"sig"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":4,"candidatesTokenCount":2,"thoughtsTokenCount":7}}` + "\n\n"
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
body := []byte(`{"model":"gemini-3.8-flash","stream":true,"reasoning_effort":"medium","messages":[{"role":"user","content":"hi"}]}`)
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCompatThinkingTestAccount(), body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
require.Equal(t, "medium", gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig.thinkingLevel").String())
require.True(t, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig.includeThoughts").Bool())
require.Equal(t, "medium", optionalStringValue(result.ReasoningEffort))
require.Equal(t, 9, result.Usage.OutputTokens)
var reasoning, content strings.Builder
firstContentChunk, lastReasoningChunk := -1, -1
for i, line := range strings.Split(recorder.Body.String(), "\n") {
payload, ok := strings.CutPrefix(line, "data: ")
if !ok || payload == "[DONE]" {
continue
}
delta := gjson.Get(payload, "choices.0.delta")
if r := delta.Get("reasoning_content"); r.Exists() {
_, _ = reasoning.WriteString(r.String())
lastReasoningChunk = i
}
if text := delta.Get("content"); text.Exists() && text.String() != "" {
_, _ = content.WriteString(text.String())
if firstContentChunk < 0 {
firstContentChunk = i
}
}
}
require.Equal(t, "Weighing options carefully.", reasoning.String())
require.Equal(t, "Final answer", content.String())
require.Less(t, lastReasoningChunk, firstContentChunk)
}
func TestGeminiForwardAsChatCompletions_NonStreamForwardsEffortWithoutThoughts(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}`)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
body := []byte(`{"model":"gemini-3.8-flash","reasoning_effort":"none","messages":[{"role":"user","content":"hi"}]}`)
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCompatThinkingTestAccount(), body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
cfg := gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig")
require.Equal(t, "minimal", cfg.Get("thinkingLevel").String())
require.False(t, cfg.Get("includeThoughts").Exists(), cfg.Raw)
require.Equal(t, "minimal", optionalStringValue(result.ReasoningEffort))
require.Equal(t, "ok", gjson.Get(recorder.Body.String(), "choices.0.message.content").String())
}
func TestGeminiMessagesForward_IncludeThoughtsFollowsClientThinking(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
name string
body string
wantThoughts bool
wantLevel string
}{
{name: "stream with adaptive thinking", body: `{"model":"gemini-3.7-flash","stream":true,"max_tokens":1024,"thinking":{"type":"adaptive"},"output_config":{"effort":"medium"},"messages":[{"role":"user","content":"hi"}]}`, wantThoughts: true, wantLevel: "medium"},
{name: "stream without thinking", body: `{"model":"gemini-3.7-flash","stream":true,"max_tokens":1024,"messages":[{"role":"user","content":"hi"}]}`},
{name: "non stream with thinking", body: `{"model":"gemini-3.7-flash","max_tokens":1024,"thinking":{"type":"enabled","budget_tokens":31999},"messages":[{"role":"user","content":"hi"}]}`, wantLevel: "high"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stream := gjson.Get(tt.body, "stream").Bool()
respBody := `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}`
contentType := "application/json"
if stream {
respBody = "data: " + respBody + "\n\n"
contentType = "text/event-stream"
}
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{contentType}},
Body: io.NopCloser(strings.NewReader(respBody)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/messages", []byte(tt.body))
result, err := svc.Forward(context.Background(), c, geminiCompatThinkingTestAccount(), []byte(tt.body))
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
cfg := gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig")
require.Equal(t, tt.wantThoughts, cfg.Get("includeThoughts").Bool(), cfg.Raw)
require.Equal(t, tt.wantLevel, cfg.Get("thinkingLevel").String(), cfg.Raw)
require.Equal(t, tt.wantLevel, optionalStringValue(result.ReasoningEffort))
})
}
}
func TestGeminiMessagesHandleStreamingResponse_EmitsThinkingBlockBeforeText(t *testing.T) {
gin.SetMode(gin.TestMode)
upstreamBody := `data: {"candidates":[{"content":{"parts":[{"text":"Plan the reply","thought":true}]}}]}` + "\n\n" +
`data: {"candidates":[{"content":{"parts":[{"text":"Hello"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":5,"candidatesTokenCount":1,"thoughtsTokenCount":4}}` + "\n\n"
resp := &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(upstreamBody)),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
svc := &GeminiMessagesCompatService{}
result, err := svc.handleStreamingResponse(c, resp, time.Now(), "claude-sonnet-4-5")
require.NoError(t, err)
require.NotNil(t, result.firstTokenMs)
events := parseAnthropicContentBlockEvents(t, rec.Body.String())
open := -1
var started []string
for _, ev := range events {
switch ev.event {
case "content_block_start":
require.Equalf(t, -1, open, "block %d opened while block %d was still open", ev.index, open)
open = ev.index
started = append(started, ev.blockType)
case "content_block_stop":
require.Equal(t, open, ev.index)
open = -1
}
}
require.Equal(t, -1, open)
require.Equal(t, []string{"thinking", "text"}, started)
out := rec.Body.String()
require.Contains(t, out, `"content_block":{"signature":"","thinking":"","type":"thinking"}`)
require.Contains(t, out, `"type":"thinking_delta"`)
require.Contains(t, out, `"thinking":"Plan the reply"`)
require.NotContains(t, out, `"text":"Plan the reply"`)
}
func TestConvertClaudeMessagesToGemini_DropsHistoryThinkingBlocks(t *testing.T) {
body := []byte(`{"model":"gemini-3.7-flash","max_tokens":1024,"messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":[{"type":"thinking","thinking":"PRIOR_THOUGHT","signature":""},{"type":"text","text":"Hello"}]},
{"role":"user","content":"again"},
{"role":"assistant","content":[{"type":"thinking","thinking":"ONLY_THOUGHT","signature":""},{"type":"redacted_thinking","data":"opaque"}]},
{"role":"user","content":"last"}
]}`)
out, err := convertClaudeMessagesToGeminiGenerateContent(body)
require.NoError(t, err)
require.NotContains(t, string(out), "PRIOR_THOUGHT")
require.NotContains(t, string(out), "ONLY_THOUGHT")
require.NotContains(t, string(out), "opaque")
contents := gjson.GetBytes(out, "contents").Array()
require.Len(t, contents, 4)
require.Equal(t, "model", contents[1].Get("role").String())
require.Len(t, contents[1].Get("parts").Array(), 1)
require.Equal(t, "Hello", contents[1].Get("parts.0.text").String())
for i, content := range contents {
require.NotEmpty(t, content.Get("parts").Array(), "contents[%d] has empty parts", i)
}
}
func TestGeminiForwardAsChatCompletions_DropsHistoryReasoningContent(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}`)),
}}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
body := []byte(`{"model":"gemini-3.8-flash","messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":"Hello","reasoning_content":"PRIOR_REASONING"},
{"role":"assistant","content":"Again","reasoning":"ALIAS_REASONING"},
{"role":"assistant","content":[{"type":"thinking","thinking":"PART_THINKING"},{"type":"reasoning","text":"PART_REASONING"},{"type":"text","text":"Third"}]},
{"role":"user","content":"next"}
]}`)
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/chat/completions", body)
_, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiCompatThinkingTestAccount(), body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
sent := string(upstream.requestBodies[0])
require.NotContains(t, sent, "PRIOR_REASONING")
require.NotContains(t, sent, "ALIAS_REASONING")
require.NotContains(t, sent, "PART_THINKING")
require.NotContains(t, sent, "PART_REASONING")
require.NotContains(t, sent, "<thinking>")
require.Contains(t, sent, "Third")
require.Contains(t, sent, "Hello")
require.Contains(t, sent, "Again")
}
func TestGeminiMessagesForward_SignatureRetryKeepsClientThinkingParams(t *testing.T) {
gin.SetMode(gin.TestMode)
okBody := "data: " + `{"candidates":[{"content":{"parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":1}}` + "\n\n"
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{
{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"code":400,"message":"Function call is missing a thought_signature","status":"INVALID_ARGUMENT"}}`)),
},
{
StatusCode: http.StatusBadRequest,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"code":400,"message":"Function call is missing a thought_signature","status":"INVALID_ARGUMENT"}}`)),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(strings.NewReader(okBody)),
},
}}
svc := &GeminiMessagesCompatService{httpUpstream: upstream, cfg: &config.Config{}}
body := []byte(`{"model":"claude-sonnet-4-5","stream":true,"max_tokens":1024,"thinking":{"type":"adaptive"},"output_config":{"effort":"low"},"messages":[
{"role":"user","content":"hi"},
{"role":"assistant","content":[{"type":"thinking","thinking":"RETRY_THOUGHT","signature":""},{"type":"tool_use","id":"toolu_1","name":"lookup","input":{"q":"x"}}]},
{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_1","content":"found"}]},
{"role":"assistant","content":[{"type":"thinking","thinking":"RETRY_ONLY_THOUGHT","signature":""}]},
{"role":"user","content":"next"},
{"role":"assistant","content":[{"type":"thinking","thinking":"RETRY_BLANK_THOUGHT","signature":""},{"type":"text","text":""}]},
{"role":"user","content":"again"},
{"role":"assistant","content":[{"type":"thinking","thinking":"RETRY_SPACE_THOUGHT","signature":""},{"type":"text","text":" "}]},
{"role":"user","content":"last"}
]}`)
account := geminiCompatThinkingTestAccount()
account.Credentials["model_mapping"] = map[string]any{"claude-sonnet-4-5": "gemini-3.8-flash"}
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1/messages", body)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 3)
for i, sent := range upstream.requestBodies {
require.NotContains(t, string(sent), "RETRY_THOUGHT", "request %d", i)
require.NotContains(t, string(sent), "RETRY_ONLY_THOUGHT", "request %d", i)
require.NotContains(t, string(sent), "RETRY_BLANK_THOUGHT", "request %d", i)
require.NotContains(t, string(sent), "RETRY_SPACE_THOUGHT", "request %d", i)
for j, content := range gjson.GetBytes(sent, "contents").Array() {
require.NotEmpty(t, content.Get("parts").Array(), "request %d contents[%d] has empty parts", i, j)
for k, part := range content.Get("parts").Array() {
if text := part.Get("text"); text.Exists() {
require.NotEmpty(t, strings.TrimSpace(text.String()), "request %d contents[%d].parts[%d] is blank text", i, j, k)
}
}
}
cfg := gjson.GetBytes(sent, "generationConfig.thinkingConfig")
require.Equal(t, "low", cfg.Get("thinkingLevel").String(), "request %d: %s", i, cfg.Raw)
require.True(t, cfg.Get("includeThoughts").Bool(), "request %d: %s", i, cfg.Raw)
}
require.Equal(t, "low", optionalStringValue(result.ReasoningEffort))
}
@@ -0,0 +1,201 @@
//go:build unit
package service
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// Gemini 三条转发路径对每个上游错误响应只检查一次错误策略:
// - 重试循环内已检查的最终响应,循环外的错误处理复用该结果;
// - 账号级 401 规则首次命中只临时不可调度,账号上已有 401 暂停记录时才升级为 error;
// - 循环内未检查的最终响应(如 Claude 兼容层的非签名 400)在循环外检查。
// ---------------------------------------------------------------------------
type geminiSingleCheckRepoStub struct {
errorPolicyRepoStub
tempReason string
}
func (r *geminiSingleCheckRepoStub) SetTempUnschedulable(_ context.Context, _ int64, _ time.Time, reason string) error {
r.tempCalls++
r.tempReason = reason
return nil
}
func (r *geminiSingleCheckRepoStub) GetByID(_ context.Context, id int64) (*Account, error) {
return &Account{ID: id, TempUnschedulableReason: r.tempReason}, nil
}
type geminiSequenceUpstreamResponse struct {
status int
body string
}
type geminiSequenceUpstreamStub struct {
responses []geminiSequenceUpstreamResponse
calls int
}
func (s *geminiSequenceUpstreamStub) Do(*http.Request, string, int64, int) (*http.Response, error) {
r := s.responses[min(s.calls, len(s.responses)-1)]
s.calls++
return &http.Response{
StatusCode: r.status,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(r.body)),
}, nil
}
func (s *geminiSequenceUpstreamStub) DoWithTLS(req *http.Request, proxyURL string, accountID int64, concurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return s.Do(req, proxyURL, accountID, concurrency)
}
func newGeminiSingleCheckService(responses ...geminiSequenceUpstreamResponse) (*GeminiMessagesCompatService, *geminiSequenceUpstreamStub, *geminiSingleCheckRepoStub) {
repo := &geminiSingleCheckRepoStub{}
rateLimit := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
upstream := &geminiSequenceUpstreamStub{responses: responses}
svc := &GeminiMessagesCompatService{
accountRepo: repo,
httpUpstream: upstream,
rateLimitService: rateLimit,
cfg: &config.Config{},
}
return svc, upstream, repo
}
func geminiAPIKeyAccountWithTempRule(rule map[string]any) *Account {
return &Account{
ID: 830,
Platform: PlatformGemini,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"api_key": "test-key",
"temp_unschedulable_enabled": true,
"temp_unschedulable_rules": []any{rule},
},
}
}
const geminiSingleCheck401Body = `{"error":{"code":401,"message":"API key expired. Please renew the API key.","status":"UNAUTHENTICATED"}}`
func geminiAccountScoped401Rule() map[string]any {
return map[string]any{
"error_code": float64(401),
"keywords": []any{"expired"},
"duration_minutes": float64(30),
"scope": TempUnschedScopeAccount,
}
}
// geminiSingleCheckForwarders 以三条转发路径各发一次请求,返回转发错误与 gin.Context。
var geminiSingleCheckForwarders = []struct {
name string
forward func(t *testing.T, svc *GeminiMessagesCompatService, account *Account) (error, *gin.Context)
}{
{
name: "native",
forward: func(t *testing.T, svc *GeminiMessagesCompatService, account *Account) (error, *gin.Context) {
c, _ := newGeminiNativeTestContext(t)
_, err := svc.ForwardNative(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false,
[]byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
return err, c
},
},
{
name: "claude_compat",
forward: func(t *testing.T, svc *GeminiMessagesCompatService, account *Account) (error, *gin.Context) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
body := []byte(`{"model":"gemini-2.5-flash","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
_, err := svc.Forward(context.Background(), c, account, body)
return err, c
},
},
{
name: "chat_completions_compat",
forward: func(t *testing.T, svc *GeminiMessagesCompatService, account *Account) (error, *gin.Context) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
body := []byte(`{"model":"gemini-2.5-flash","messages":[{"role":"user","content":"hi"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
_, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body)
return err, c
},
},
}
func TestGeminiForward_First401RuleHitOnlyTempUnschedules(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, fw := range geminiSingleCheckForwarders {
t.Run(fw.name, func(t *testing.T) {
svc, upstream, repo := newGeminiSingleCheckService(geminiSequenceUpstreamResponse{status: http.StatusUnauthorized, body: geminiSingleCheck401Body})
err, _ := fw.forward(t, svc, geminiAPIKeyAccountWithTempRule(geminiAccountScoped401Rule()))
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr), "规则命中应换号,got %T: %v", err, err)
require.Equal(t, http.StatusUnauthorized, failoverErr.StatusCode)
require.Equal(t, 1, upstream.calls)
require.Equal(t, 1, repo.tempCalls, "首次 401 命中规则应临时不可调度")
require.Zero(t, repo.setErrCalls, "首次 401 命中规则不应升级为 error")
})
}
}
func TestGeminiForward_Repeated401RuleHitEscalatesToError(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, fw := range geminiSingleCheckForwarders {
t.Run(fw.name, func(t *testing.T) {
svc, _, repo := newGeminiSingleCheckService(geminiSequenceUpstreamResponse{status: http.StatusUnauthorized, body: geminiSingleCheck401Body})
repo.tempReason = `{"until_unix":1,"triggered_at_unix":1,"status_code":401,"matched_keyword":"expired","rule_index":0,"scope":"account"}`
_, _ = fw.forward(t, svc, geminiAPIKeyAccountWithTempRule(geminiAccountScoped401Rule()))
require.Zero(t, repo.tempCalls, "已有 401 暂停记录时直接升级,不临时不可调度")
require.Equal(t, 1, repo.setErrCalls, "已有 401 暂停记录时应升级为 error")
require.Contains(t, repo.lastErrorMsg, "Authentication failed (401)")
})
}
}
func TestGeminiForward_UncheckedFinalResponseIsCheckedAfterLoop(t *testing.T) {
gin.SetMode(gin.TestMode)
if testing.Short() {
t.Skip("包含一次 1s 的重试退避")
}
// 第一次 503 在循环内检查后重试;第二次非签名 400 走 Claude 兼容层的签名分支,循环内不检查。
svc, upstream, repo := newGeminiSingleCheckService(
geminiSequenceUpstreamResponse{status: http.StatusServiceUnavailable, body: `{"error":{"code":503,"message":"backend busy","status":"UNAVAILABLE"}}`},
geminiSequenceUpstreamResponse{status: http.StatusBadRequest, body: `{"error":{"code":400,"message":"quota project misconfigured","status":"FAILED_PRECONDITION"}}`},
)
account := geminiAPIKeyAccountWithTempRule(map[string]any{
"error_code": float64(400),
"keywords": []any{"misconfigured"},
"duration_minutes": float64(30),
"scope": TempUnschedScopeAccount,
})
c, _ := gin.CreateTestContext(httptest.NewRecorder())
body := []byte(`{"model":"gemini-2.5-flash","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
_, err := svc.Forward(context.Background(), c, account, body)
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr), "400 规则命中应换号,got %T: %v", err, err)
require.Equal(t, 2, upstream.calls)
require.Equal(t, 1, repo.tempCalls, "循环内未检查的最终响应应在循环外检查")
}
@@ -631,30 +631,159 @@ func TestHandleGeminiUpstreamError_VertexRetryInfoUsesParsedDelay(t *testing.T)
require.WithinDuration(t, before.Add(39*time.Second), repo.lastRateLimitReset, 2*time.Second)
}
// Vertex(service_account)429 响应不带任何可解析的重置时间时,走短冷却兜底,
// 不再冷却到 PST 午夜(按量付费没有每日配额,长冷却会导致整组不可用)。
func TestHandleGeminiUpstreamError_VertexFallbackUsesShortCooldown(t *testing.T) {
// Vertex(service_account)共享容量不足的 429 不写账号级限流:账号保持可调度,
// 由请求内的同号退避重试与换号消化。
func TestHandleGeminiUpstreamError_VertexSharedCapacityKeepsAccountSchedulable(t *testing.T) {
tests := []struct {
name string
body string
}{
{
name: "object",
body: `{"error":{"code":429,"message":"Resource exhausted. Please try again later. Please refer to https://cloud.google.com/vertex-ai/generative-ai/docs/error-code-429 for more details.","status":"RESOURCE_EXHAUSTED"}}`,
},
{
name: "array_wrapped",
body: `[{"error":{"code":429,"message":"Resource exhausted. Please try again later.","status":"RESOURCE_EXHAUSTED"}}]`,
},
{
name: "empty_details",
body: `{"error":{"code":429,"message":"Resource exhausted. Please try again later.","status":"RESOURCE_EXHAUSTED","details":[]}}`,
},
{
name: "documented_comma_wording",
body: `{"error":{"code":429,"message":"Resource exhausted, please try again later.","status":"RESOURCE_EXHAUSTED"}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &rateLimit429AccountRepoStub{}
svc := &GeminiMessagesCompatService{accountRepo: repo}
account := &Account{
ID: 602,
Platform: PlatformGemini,
Type: AccountTypeServiceAccount,
Credentials: map[string]any{
"project_id": "my-vertex-project",
},
}
svc.handleGeminiUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, []byte(tt.body))
require.Zero(t, repo.rateLimitCalls)
})
}
}
// Vertex(service_account)项目配额、预留吞吐等账号自身的 429 不带可解析的重置时间时,
// 走短冷却兜底,不冷却到 PST 午夜(按量付费没有每日配额,长冷却会导致整组不可用)。
func TestHandleGeminiUpstreamError_VertexAccountScoped429UsesShortCooldown(t *testing.T) {
tests := []struct {
name string
body string
}{
{
name: "project_quota",
body: `{"error":{"code":429,"message":"Quota exceeded for aiplatform.googleapis.com/generate_content_requests_per_minute_per_project_per_base_model with base model: gemini-2.5-pro. Please submit a quota increase request.","status":"RESOURCE_EXHAUSTED"}}`,
},
{
name: "provisioned_throughput",
body: `{"error":{"code":429,"message":"Too many requests. Exceeded the Provisioned Throughput.","status":"RESOURCE_EXHAUSTED"}}`,
},
{
name: "quota_failure_details",
body: `{"error":{"code":429,"message":"Resource exhausted. Please try again later.","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"subject":"project"}]}]}}`,
},
{
name: "other_status",
body: `{"error":{"code":429,"message":"Too many requests.","status":"UNAVAILABLE"}}`,
},
{
name: "unknown_wording",
body: `{"error":{"code":429,"message":"Rate limit exceeded for requests per minute per project.","status":"RESOURCE_EXHAUSTED"}}`,
},
{
name: "missing_message",
body: `{"error":{"code":429,"status":"RESOURCE_EXHAUSTED"}}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &rateLimit429AccountRepoStub{}
svc := &GeminiMessagesCompatService{accountRepo: repo}
account := &Account{
ID: 604,
Platform: PlatformGemini,
Type: AccountTypeServiceAccount,
Credentials: map[string]any{
"project_id": "my-vertex-project",
},
}
before := time.Now()
svc.handleGeminiUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, []byte(tt.body))
require.Equal(t, 1, repo.rateLimitCalls)
require.Equal(t, int64(604), repo.lastRateLimitID)
require.WithinDuration(t, before.Add(geminiVertexFallbackCooldown), repo.lastRateLimitReset, 2*time.Second)
// 显式守护:不应被冷却超过 15 分钟(更不应到次日 PST 午夜)
require.True(t, repo.lastRateLimitReset.Before(before.Add(15*time.Minute)))
})
}
}
// Vertex 共享容量形态的 429 只对 Vertex 账号豁免冷却:AI Studio 官方 API Key 与 Code Assist OAuth
// 收到同样的响应体仍按各自语义冷却。
func TestHandleGeminiUpstreamError_SharedCapacityBodyStillCoolsNonVertexAccounts(t *testing.T) {
body := []byte(`{"error":{"code":429,"message":"Resource exhausted. Please try again later. Please refer to https://cloud.google.com/vertex-ai/generative-ai/docs/error-code-429 for more details.","status":"RESOURCE_EXHAUSTED"}}`)
tests := []struct {
name string
account *Account
}{
{
name: "ai_studio_api_key",
account: &Account{ID: 605, Platform: PlatformGemini, Type: AccountTypeAPIKey, Credentials: map[string]any{"api_key": "k"}},
},
{
name: "code_assist_oauth",
account: &Account{ID: 606, Platform: PlatformGemini, Type: AccountTypeOAuth, Credentials: map[string]any{
"oauth_type": "code_assist", "project_id": "my-code-assist-project",
}},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
repo := &rateLimit429AccountRepoStub{}
svc := &GeminiMessagesCompatService{accountRepo: repo}
svc.handleGeminiUpstreamError(context.Background(), tt.account, http.StatusTooManyRequests, http.Header{}, body)
require.Equal(t, 1, repo.rateLimitCalls)
require.Equal(t, tt.account.ID, repo.lastRateLimitID)
})
}
}
// Vertex 429 文案里给出了重试时长时按该时长冷却,不归为共享容量不足。
func TestHandleGeminiUpstreamError_VertexMessageRetryHintUsesParsedDelay(t *testing.T) {
repo := &rateLimit429AccountRepoStub{}
svc := &GeminiMessagesCompatService{accountRepo: repo}
account := &Account{
ID: 602,
ID: 607,
Platform: PlatformGemini,
Type: AccountTypeServiceAccount,
Credentials: map[string]any{
"project_id": "my-vertex-project",
},
}
body := []byte(`{"error":{"code":429,"message":"Resource exhausted. Please try again later.","status":"RESOURCE_EXHAUSTED"}}`)
body := []byte(`{"error":{"code":429,"message":"Resource exhausted. Please retry in 5s","status":"RESOURCE_EXHAUSTED"}}`)
before := time.Now()
svc.handleGeminiUpstreamError(context.Background(), account, http.StatusTooManyRequests, http.Header{}, body)
require.Equal(t, 1, repo.rateLimitCalls)
require.Equal(t, int64(602), repo.lastRateLimitID)
require.WithinDuration(t, before.Add(geminiVertexFallbackCooldown), repo.lastRateLimitReset, 2*time.Second)
// 显式守护:不应被冷却超过 15 分钟(更不应到次日 PST 午夜)
require.True(t, repo.lastRateLimitReset.Before(before.Add(15*time.Minute)))
require.Equal(t, int64(607), repo.lastRateLimitID)
require.WithinDuration(t, before.Add(5*time.Second), repo.lastRateLimitReset, 2*time.Second)
}
// API Key(AI Studio)无法解析重置时间时仍冷却到 PST 午夜,行为保持不变。
@@ -45,8 +45,8 @@ const (
geminiRetryInfoTypeURL = "type.googleapis.com/google.rpc.RetryInfo"
// RetryInfo.retryDelay 的上限,防止异常值导致账号长时间不可用
geminiRetryInfoMaxDelay = 15 * time.Minute
// Vertex(service_account,按量付费)429 无法解析重置时间时的兜底冷却:
// 按量付费限速是短窗口 RPM/TPM,没有每日配额,不适用 PST 午夜重置
// Vertex(service_account)账号自身的 429(按量付费的项目配额、预留吞吐超额等)无法解析重置时间时的兜底冷却:
// 这类限速是短窗口 RPM/TPM,没有每日配额,不适用 PST 午夜重置;共享容量不足的 429 不冷却
geminiVertexFallbackCooldown = time.Minute
)
@@ -556,6 +556,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
}
geminiReq = ensureGeminiFunctionCallThoughtSignatures(geminiReq)
includeThoughts := req.Stream && claudeRequestEnablesThinking(body)
geminiReq = applyGeminiCompatThinkingConfig(geminiReq, body, mappedModel, includeThoughts)
originalClaudeBody := body
proxyURL := ""
@@ -709,13 +711,19 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
var resp *http.Response
// checkedPolicy 是循环内对当前 resp 的错误策略检查结果,未检查时为 nil。
var checkedPolicy *ErrorPolicyResult
signatureRetryStage := 0
for attempt := 1; attempt <= geminiMaxRetries; attempt++ {
checkedPolicy = nil
upstreamReq, idHeader, err := buildReq(ctx)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
if failoverErr := s.handleVertexServiceAccountNotFound(ctx, c, account, err); failoverErr != nil {
return nil, failoverErr
}
// Local build error: don't retry.
if strings.Contains(err.Error(), "missing project_id") {
return nil, s.writeClaudeError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
@@ -765,25 +773,28 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
var strippedClaudeBody []byte
stageName := ""
// 降级函数会把思考块改写成正文,Gemini 侧先删掉历史思考块再降级。
retrySourceBody := dropClaudeHistoryThinkingBlocks(originalClaudeBody)
// 路径说明:本处上游是 Gemini,但被剥离的 body 是 Anthropic 格式。传 originalModel
// (客户端原 Anthropic model)而非 mappedModel(上游 Gemini model),让剥离逻辑按
// 客户端请求的 Anthropic 子协议族判定(详见 ResolveThinkingProtocol 文档)。
switch signatureRetryStage {
case 0:
// Stage 1: disable thinking + thinking->text
strippedClaudeBody = FilterThinkingBlocksForRetry(originalClaudeBody, originalModel)
strippedClaudeBody = FilterThinkingBlocksForRetry(retrySourceBody, originalModel)
stageName = "thinking-only"
signatureRetryStage = 1
default:
// Stage 2: additionally downgrade tool_use/tool_result blocks to text
strippedClaudeBody = FilterSignatureSensitiveBlocksForRetry(originalClaudeBody, originalModel)
strippedClaudeBody = FilterSignatureSensitiveBlocksForRetry(retrySourceBody, originalModel)
stageName = "thinking+tools"
signatureRetryStage = 2
}
retryGeminiReq, txErr := convertClaudeMessagesToGeminiGenerateContent(strippedClaudeBody)
if txErr == nil {
logger.LegacyPrintf("service.gemini_messages_compat", "Gemini account %d: detected signature-related 400, retrying with downgraded Claude blocks (%s)", account.ID, stageName)
geminiReq = retryGeminiReq
// 降级只改写历史块,思考参数沿用客户端原始请求。
geminiReq = applyGeminiCompatThinkingConfig(retryGeminiReq, originalClaudeBody, mappedModel, includeThoughts)
// Consume one retry budget attempt and continue with the updated request payload.
sleepGeminiBackoff(1)
continue
@@ -800,11 +811,11 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
// 错误策略优先:匹配则跳过重试直接处理。
if matched, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp, mappedModel); matched {
resp = rebuilt
checked, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp, mappedModel)
resp = rebuilt
checkedPolicy = checked
if checked != nil && *checked != ErrorPolicyNone {
break
} else {
resp = rebuilt
}
if resp.StatusCode >= 400 && s.shouldRetryGeminiUpstreamError(account, resp.StatusCode) {
@@ -820,7 +831,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
break
}
if resp.StatusCode == 429 {
// Mark as rate-limited early so concurrent requests avoid this account.
// Apply account-scoped 429 handling early so concurrent requests avoid a rate-limited account.
s.handleGeminiUpstreamError(ctx, account, resp.StatusCode, resp.Header, respBody)
}
if attempt < geminiMaxRetries {
@@ -872,7 +883,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
respBody := s.readUpstreamErrorBody(resp)
// 统一错误策略:自定义错误码 + 临时不可调度
if s.rateLimitService != nil {
policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel)
policy := s.finalErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel, checkedPolicy)
switch policy {
case ErrorPolicySkipped:
upstreamReqID := resp.Header.Get(requestIDHeader)
@@ -1004,6 +1015,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
var usage *ClaudeUsage
var firstTokenMs *int
clientDisconnect := false
streamFinished := false
if req.Stream {
streamRes, err := s.handleStreamingResponse(c, resp, startTime, originalModel)
if err != nil {
@@ -1011,6 +1024,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
}
usage = streamRes.usage
firstTokenMs = streamRes.firstTokenMs
clientDisconnect = streamRes.clientDisconnect
streamFinished = streamRes.finished
} else {
if useUpstreamStream {
collected, usageObj, err := collectGeminiSSE(resp.Body, true)
@@ -1038,6 +1053,10 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
imageInputSize := s.extractImageInputSize(body)
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
imageCount := resolveGeminiImageCount(c, originalModel, mappedModel)
// 客户端在收到结束分片前断开时只计已送达的图片:Claude 兼容流不向客户端转发图片。
if clientDisconnect && !streamFinished {
imageCount = 0
}
return &ForwardResult{
RequestID: requestID,
@@ -1050,6 +1069,8 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
Stream: req.Stream,
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ReasoningEffort: extractGeminiReasoningEffortFromBody(geminiReq),
ClientDisconnect: clientDisconnect,
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
@@ -1239,12 +1260,18 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
var resp *http.Response
// checkedPolicy 是循环内对当前 resp 的错误策略检查结果,未检查时为 nil。
var checkedPolicy *ErrorPolicyResult
for attempt := 1; attempt <= geminiMaxRetries; attempt++ {
checkedPolicy = nil
upstreamReq, idHeader, err := buildReq(ctx)
if err != nil {
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
return nil, err
}
if failoverErr := s.handleVertexServiceAccountNotFound(ctx, c, account, err); failoverErr != nil {
return nil, failoverErr
}
// Local build error: don't retry.
if strings.Contains(err.Error(), "missing project_id") {
return nil, s.writeGoogleError(c, http.StatusBadRequest, err.Error())
@@ -1284,11 +1311,11 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
// 错误策略优先:匹配则跳过重试直接处理。
if matched, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp, mappedModel); matched {
resp = rebuilt
checked, rebuilt := s.checkErrorPolicyInLoop(ctx, account, resp, mappedModel)
resp = rebuilt
checkedPolicy = checked
if checked != nil && *checked != ErrorPolicyNone {
break
} else {
resp = rebuilt
}
if resp.StatusCode >= 400 && s.shouldRetryGeminiUpstreamError(account, resp.StatusCode) {
@@ -1404,7 +1431,7 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
// 统一错误策略:自定义错误码 + 临时不可调度
if s.rateLimitService != nil {
policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel)
policy := s.finalErrorPolicy(ctx, account, resp.StatusCode, respBody, mappedModel, checkedPolicy)
switch policy {
case ErrorPolicySkipped:
if failoverErr := s.skippedErrorPolicyFailoverError(c, account, resp.StatusCode, respBody, requestID); failoverErr != nil {
@@ -1512,6 +1539,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
var usage *ClaudeUsage
var firstTokenMs *int
clientDisconnect := false
streamFinished := false
if stream {
streamRes, err := s.handleNativeStreamingResponse(c, resp, startTime, isOAuth, account, requestID)
@@ -1520,6 +1549,8 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
}
usage = streamRes.usage
firstTokenMs = streamRes.firstTokenMs
clientDisconnect = streamRes.clientDisconnect
streamFinished = streamRes.finished
} else {
if useUpstreamStream {
var best geminiResponseSignal
@@ -1554,6 +1585,10 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
imageInputSize := s.extractImageInputSize(body)
imageSize := normalizeOpenAIImageSizeTier(imageInputSize)
imageCount := resolveGeminiImageCount(c, originalModel, mappedModel)
// 客户端在收到结束分片前断开时只计已送达的图片。
if clientDisconnect && !streamFinished {
imageCount = observedGeminiImageOutputs(c)
}
return &ForwardResult{
RequestID: requestID,
@@ -1567,20 +1602,21 @@ func (s *GeminiMessagesCompatService) ForwardNative(ctx context.Context, c *gin.
ReasoningEffort: extractGeminiReasoningEffortFromBody(body),
Duration: time.Since(startTime),
FirstTokenMs: firstTokenMs,
ClientDisconnect: clientDisconnect,
ImageCount: imageCount,
ImageSize: imageSize,
ImageInputSize: imageInputSize,
}, nil
}
// checkErrorPolicyInLoop 在重试循环内预检查错误策略。
// 返回 true 表示策略已匹配(调用者应 break),resp 已重建可直接使用。
// 返回 false 表示 ErrorPolicyNone,resp 已重建,调用者继续走重试逻辑。
// checkErrorPolicyInLoop 在重试循环内对上游错误响应检查错误策略,resp 已重建可直接使用。
// checked 为 nil 表示未检查(非错误响应或未配置限流服务);否则指向检查结果,
// 非 ErrorPolicyNone 时调用者应 break 并由循环外的错误处理复用该结果。
func (s *GeminiMessagesCompatService) checkErrorPolicyInLoop(
ctx context.Context, account *Account, resp *http.Response, mappedModel string,
) (matched bool, rebuilt *http.Response) {
) (checked *ErrorPolicyResult, rebuilt *http.Response) {
if resp.StatusCode < 400 || s.rateLimitService == nil {
return false, resp
return nil, resp
}
body := s.readUpstreamErrorBody(resp)
_ = resp.Body.Close()
@@ -1590,7 +1626,20 @@ func (s *GeminiMessagesCompatService) checkErrorPolicyInLoop(
Body: io.NopCloser(bytes.NewReader(body)),
}
policy := s.rateLimitService.CheckErrorPolicy(ctx, account, resp.StatusCode, body, mappedModel)
return policy != ErrorPolicyNone, rebuilt
return &policy, rebuilt
}
// finalErrorPolicy 返回循环外处理最终上游错误响应时使用的错误策略。每个上游响应只检查一次:
// 错误策略检查会触发临时不可调度规则,并据账号上已记录的 401 暂停把 401 升级为 error。
// checked 非 nil 时它就是重试循环内对同一响应的检查结果,直接复用;否则在此检查。
// 调用方保证 s.rateLimitService 非 nil。
func (s *GeminiMessagesCompatService) finalErrorPolicy(
ctx context.Context, account *Account, statusCode int, body []byte, mappedModel string, checked *ErrorPolicyResult,
) ErrorPolicyResult {
if checked != nil {
return *checked
}
return s.rateLimitService.CheckErrorPolicy(ctx, account, statusCode, body, mappedModel)
}
// cachedContentPassthroughInLoop 判断上游响应是否跳过同号重试与错误策略直接处理:引用缓存不存在,
@@ -2037,8 +2086,10 @@ func mapGeminiStatusToClaudeErrorType(status string) string {
}
type geminiStreamResult struct {
usage *ClaudeUsage
firstTokenMs *int
usage *ClaudeUsage
firstTokenMs *int
clientDisconnect bool // 客户端在流式传输中途断开,结果为断开前已收集的部分
finished bool // 断开前已收到上游 finishReason
}
func (s *GeminiMessagesCompatService) handleNonStreamingResponse(c *gin.Context, resp *http.Response, originalModel string) (*ClaudeUsage, error) {
@@ -2110,6 +2161,7 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re
openBlockIndex := -1
openBlockType := ""
seenText := ""
seenThought := ""
openToolIndex := -1
openToolID := ""
openToolName := ""
@@ -2119,6 +2171,15 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re
for {
line, err := reader.ReadString('\n')
if err != nil && !errors.Is(err, io.EOF) {
if geminiStreamAbortedWithBillableOutput(c, err, &usage) {
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] Client disconnected during messages stream, billing collected usage: %v", err)
return &geminiStreamResult{
usage: &usage,
firstTokenMs: firstTokenMs,
clientDisconnect: true,
finished: finishReason != "",
}, nil
}
return nil, fmt.Errorf("stream read error: %w", err)
}
@@ -2158,6 +2219,56 @@ func (s *GeminiMessagesCompatService) handleStreamingResponse(c *gin.Context, re
parts := extractGeminiParts(geminiResp)
for _, part := range parts {
if text, ok := part["text"].(string); ok && isGeminiThoughtPart(part) {
delta, newSeen := computeGeminiTextDelta(seenThought, text)
seenThought = newSeen
if delta == "" {
continue
}
if openToolIndex >= 0 {
writeSSE(c.Writer, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": openToolIndex,
})
openToolIndex = -1
openToolName = ""
seenToolJSON = ""
}
if openBlockType != "thinking" {
if openBlockIndex >= 0 {
writeSSE(c.Writer, "content_block_stop", map[string]any{
"type": "content_block_stop",
"index": openBlockIndex,
})
}
openBlockType = "thinking"
openBlockIndex = nextBlockIndex
nextBlockIndex++
writeSSE(c.Writer, "content_block_start", map[string]any{
"type": "content_block_start",
"index": openBlockIndex,
"content_block": map[string]any{
"type": "thinking",
"thinking": "",
"signature": "",
},
})
}
if firstTokenMs == nil {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
}
writeSSE(c.Writer, "content_block_delta", map[string]any{
"type": "content_block_delta",
"index": openBlockIndex,
"delta": map[string]any{
"type": "thinking_delta",
"thinking": delta,
},
})
flusher.Flush()
continue
}
if text, ok := part["text"].(string); ok && text != "" {
// Close an open tool_use block before starting text, mirroring
// the functionCall branch (which closes open text blocks) and
@@ -2578,8 +2689,10 @@ func mergeCollectedTextParts(response map[string]any, textParts []string) map[st
}
type geminiNativeStreamResult struct {
usage *ClaudeUsage
firstTokenMs *int
usage *ClaudeUsage
firstTokenMs *int
clientDisconnect bool // 客户端在流式传输中途断开,结果为断开前已收集的部分
finished bool // 断开前已收到上游 finishReason
}
func isGeminiInsufficientScope(headers http.Header, body []byte) bool {
@@ -2756,10 +2869,13 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte
var best geminiResponseSignal
sawDataEvent := false
fallback := &geminiSSEFallbackBody{}
clientDisconnect := false
finished := false
for {
line, err := reader.ReadString('\n')
if len(line) > 0 {
// 读错误时 line 是未以换行结尾的残行,不透传也不参与用量统计;EOF 时的末行完整有效。
if len(line) > 0 && (err == nil || errors.Is(err, io.EOF)) {
trimmed := strings.TrimRight(line, "\r\n")
if strings.HasPrefix(trimmed, "data:") {
payload := strings.TrimSpace(strings.TrimPrefix(trimmed, "data:"))
@@ -2792,6 +2908,10 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte
observer.ObserveGemini(rawBytes)
observeGeminiImageOutputs(c, rawBytes)
if gjson.GetBytes(rawBytes, "candidates.0.finishReason").String() != "" {
finished = true
}
if firstTokenMs == nil {
ms := int(time.Since(startTime).Milliseconds())
firstTokenMs = &ms
@@ -2819,13 +2939,18 @@ func (s *GeminiMessagesCompatService) handleNativeStreamingResponse(c *gin.Conte
break
}
if err != nil {
return nil, err
if !geminiStreamAbortedWithBillableOutput(c, err, usage) {
return nil, err
}
clientDisconnect = true
logger.LegacyPrintf("service.gemini_messages_compat", "[GeminiAPI] Client disconnected during native stream (account=%d), billing collected usage: %v", account.ID, err)
break
}
}
s.finalizeGeminiSSESignal(c, account, true, upstreamRequestID, best, sawDataEvent, fallback)
return &geminiNativeStreamResult{usage: usage, firstTokenMs: firstTokenMs}, nil
return &geminiNativeStreamResult{usage: usage, firstTokenMs: firstTokenMs, clientDisconnect: clientDisconnect, finished: finished}, nil
}
// unwrapGeminiResponse 解包 Gemini OAuth 响应中的 response 字段
@@ -2954,9 +3079,10 @@ func extractGeminiUsage(data []byte) *ClaudeUsage {
}
// 注意:Gemini 的 promptTokenCount 包含 cachedContentTokenCount,
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去
// 但 Claude 的 input_tokens 不包含 cache_read_input_tokens,需要减去。
// prompt 被拦截时上游可能只报 cachedContentTokenCount,差值下限为 0。
return &ClaudeUsage{
InputTokens: prompt - cached,
InputTokens: max(prompt-cached, 0),
OutputTokens: cand + thoughts,
CacheReadInputTokens: cached,
ImageOutputTokens: imageTokens,
@@ -3026,7 +3152,13 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (Google One OAuth, tier=%s, project=%s) rate limited, cooldown=%v", account.ID, tierID, projectID, time.Until(ra).Truncate(time.Second))
}
} else if account.IsVertexServiceAccount() {
// Vertex 按量付费:短窗限速,无每日配额,短冷却后即可恢复
// 共享容量不足描述的是请求当时的上游容量,不是账号额度:账号保持可调度,
// 由请求内的同号退避重试与换号消化。
if isVertexSharedCapacity429(body) {
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (Vertex service account, project=%s) shared capacity exhausted, account stays schedulable", account.ID, projectID)
return
}
// 项目配额、预留吞吐等账号自身的限速是短窗口 RPM/TPM,无每日配额,短冷却后即可恢复
ra = time.Now().Add(geminiVertexFallbackCooldown)
logger.LegacyPrintf("service.gemini_messages_compat", "[Gemini 429] Account %d (Vertex service account, project=%s) rate limited, cooldown=%v", account.ID, projectID, time.Until(ra).Truncate(time.Second))
} else {
@@ -3051,6 +3183,30 @@ func (s *GeminiMessagesCompatService) handleGeminiUpstreamError(ctx context.Cont
account.ID, resetTime, oauthType, tierID)
}
// isVertexSharedCapacity429 判断 Vertex 429 是否为按量付费共享容量不足:RESOURCE_EXHAUSTED、
// 不带任何错误明细(RetryInfo / QuotaFailure / ErrorInfo),文案为 "Resource exhausted ... try again later"
// 且不涉及项目配额或预留吞吐。其他形态的 429 按账号自身限速处理。
func isVertexSharedCapacity429(body []byte) bool {
errObj := gjson.GetBytes(body, "error")
if !errObj.Exists() {
errObj = gjson.GetBytes(body, "0.error")
}
if !errObj.IsObject() {
return false
}
if !strings.EqualFold(strings.TrimSpace(errObj.Get("status").String()), "RESOURCE_EXHAUSTED") {
return false
}
if len(errObj.Get("details").Array()) > 0 {
return false
}
msg := strings.ToLower(errObj.Get("message").String())
if !strings.Contains(msg, "resource exhausted") || !strings.Contains(msg, "try again later") {
return false
}
return !strings.Contains(msg, "quota") && !strings.Contains(msg, "provisioned throughput")
}
// handleGeminiThirdParty429 处理第三方上游的 429:不套用 AI Studio 每日配额语义,
// 只认响应里显式给出的重试时间,否则走全局 429 兜底冷却。
func (s *GeminiMessagesCompatService) handleGeminiThirdParty429(ctx context.Context, account *Account, body []byte) {
@@ -3359,6 +3515,7 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
}
parts := make([]any, 0)
droppedThinking := false
switch content := mm["content"].(type) {
case string:
// 字符串形式的 content,保留所有内容(包括空白)
@@ -3373,6 +3530,11 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
if !ok {
continue
}
// 历史思考块不回传给 Gemini:Gemini 的思考连续性由 thoughtSignature 承载。
if bt, _ := bm["type"].(string); bt == "thinking" || bt == "redacted_thinking" {
droppedThinking = true
continue
}
bt, _ := bm["type"].(string)
switch bt {
case "text":
@@ -3447,6 +3609,10 @@ func convertClaudeMessagesToGeminiContents(messages any, toolUseIDToName map[str
default:
// ignore
}
// 只含思考块的消息去掉思考后为空,整条跳过(Gemini 拒收空 parts)。
if droppedThinking && len(parts) == 0 {
continue
}
out = append(out, map[string]any{
"role": gRole,
@@ -947,6 +947,16 @@ func TestExtractGeminiUsage(t *testing.T) {
CacheReadInputTokens: 0,
},
},
{
name: "prompt 被拦截只报 cachedContentTokenCount — input 下限为 0",
input: `{"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"},"usageMetadata":{"cachedContentTokenCount":12980}}`,
wantNil: false,
wantUsage: &ClaudeUsage{
InputTokens: 0,
OutputTokens: 0,
CacheReadInputTokens: 12980,
},
},
{
name: "无 usageMetadata",
input: `{"candidates":[]}`,
@@ -137,7 +137,7 @@ func TestGeminiNativeReasoningPricingUsesExplicitForwardedLevel(t *testing.T) {
}
}
func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T) {
func TestGeminiChatCompatReasoningPricingUsesForwardedEffort(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, stream := range []bool{false, true} {
mode := "buffered"
@@ -168,8 +168,9 @@ func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T)
require.NoError(t, err)
require.Equal(t, http.StatusOK, recorder.Code)
require.Len(t, upstream.requestBodies, 1)
require.False(t, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig").Exists())
require.Nil(t, result.ReasoningEffort)
require.Equal(t, "high", gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig.thinkingLevel").String())
require.Equal(t, stream, gjson.GetBytes(upstream.requestBodies[0], "generationConfig.thinkingConfig.includeThoughts").Bool())
require.Equal(t, "high", optionalStringValue(result.ReasoningEffort))
billing := NewBillingService(&config.Config{}, nil)
cost, err := billing.CalculateTokenCostForRequest(TokenCostRequest{
@@ -184,7 +185,7 @@ func TestGeminiChatCompatReasoningPricingIgnoresUnforwardedEffort(t *testing.T)
Resolver: NewModelPricingResolver(nil, billing),
})
require.NoError(t, err)
require.InDelta(t, 1, cost.TotalCost, 1e-12)
require.InDelta(t, 2, cost.TotalCost, 1e-12)
})
}
}
@@ -180,6 +180,29 @@ func TestGeminiForwardNative_StreamPromptBlockedMarksContentPolicy(t *testing.T)
require.Empty(t, upstreamErrorEventsFromContext(t, c))
}
// Vertex 拦截 prompt 时 usageMetadata 可能只带 cachedContentTokenCount、缺 promptTokenCount:
// input 下限为 0,缓存命中照上游所报计入 cache_read。
func TestGeminiForwardNative_StreamPromptBlockedWithOnlyCachedTokensKeepsInputNonNegative(t *testing.T) {
gin.SetMode(gin.TestMode)
body := `data: {"promptFeedback":{"blockReason":"PROHIBITED_CONTENT"},"usageMetadata":{"cachedContentTokenCount":12980,"totalTokenCount":0,"trafficType":"ON_DEMAND"},"modelVersion":"gemini-3.5-flash-lite"}
`
svc := newGeminiSignalService("text/event-stream", body)
c, rec := newGeminiNativeTestContext(t)
result, err := svc.ForwardNative(context.Background(), c, geminiSignalTestAccount(),
"gemini-3.5-flash-lite", "streamGenerateContent", true, geminiSignalTestRequest())
require.NoError(t, err)
require.Equal(t, body, rec.Body.String())
require.Equal(t, 0, result.Usage.InputTokens)
require.Equal(t, 12980, result.Usage.CacheReadInputTokens)
require.Equal(t, 0, result.Usage.OutputTokens)
streamErrs := GetOpsStreamErrors(c)
require.Len(t, streamErrs, 1)
require.Equal(t, "PROHIBITED_CONTENT", streamErrs[0].Code)
}
func TestGeminiForwardNative_EmptyStreamMarksUpstreamFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newGeminiSignalService("text/event-stream", "")
@@ -0,0 +1,25 @@
package service
import (
"context"
"errors"
"io"
"github.com/gin-gonic/gin"
)
// geminiStreamAbortedWithBillableOutput 判断流式读取中断是否由客户端断开引起,且断开前
// 已观测到可计费的输出(token 用量或已回吐的图片)。满足时调用方以已收集的结果收尾并照常计费。
// usageMetadata 为累计值,按断开前收到的最后一份计费:读到结束分片后断开即完整用量;
// 中途断开时中间分片可能只含输入计数或不含用量,此时按已有计数计费或不计费。
func geminiStreamAbortedWithBillableOutput(c *gin.Context, err error, usage *ClaudeUsage) bool {
if err == nil || errors.Is(err, io.EOF) {
return false
}
clientGone := errors.Is(err, context.Canceled) ||
(c != nil && c.Request != nil && c.Request.Context().Err() != nil)
if !clientGone {
return false
}
return usage.hasObservedTokens() || observedGeminiImageOutputs(c) > 0
}
@@ -0,0 +1,244 @@
//go:build unit
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// geminiAbortingStreamBody 先吐出 data,读完后返回 abortErr;onAbort 用于模拟客户端断开时请求 context 被取消。
type geminiAbortingStreamBody struct {
data *bytes.Reader
abortErr error
onAbort func()
}
func (b *geminiAbortingStreamBody) Read(p []byte) (int, error) {
n, err := b.data.Read(p)
if err == io.EOF {
if b.onAbort != nil {
b.onAbort()
}
return n, b.abortErr
}
return n, err
}
func (b *geminiAbortingStreamBody) Close() error { return nil }
const (
geminiAbortTextChunk = `data: {"candidates":[{"content":{"parts":[{"text":"hello"}],"role":"model"}}]}` + "\n\n"
geminiAbortFinalChunk = `data: {"candidates":[{"content":{"parts":[{"text":" world"}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}` + "\n\n"
geminiAbortImageChunk = `data: {"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":1290,"totalTokenCount":1300}}` + "\n\n"
geminiAbortUsageChunk = `data: {"candidates":[{"content":{"parts":[{"text":"drawing"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":2,"totalTokenCount":12}}` + "\n\n"
)
// newGeminiAbortingStreamService 返回的上游流在吐完 sse 后中断;clientGone=true 时中断伴随请求 context 取消。
func newGeminiAbortingStreamService(t *testing.T, path string, reqBody []byte, sse string, clientGone bool) (*GeminiMessagesCompatService, *gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
reqCtx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
c.Request = httptest.NewRequest(http.MethodPost, path, bytes.NewReader(reqBody)).WithContext(reqCtx)
body := &geminiAbortingStreamBody{data: bytes.NewReader([]byte(sse)), abortErr: io.ErrUnexpectedEOF}
if clientGone {
body.abortErr = context.Canceled
body.onAbort = cancel
}
svc, httpStub := newGeminiVertexAPIKeyTestService()
httpStub.response = &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: body,
}
return svc, c, rec
}
func TestGeminiForwardNative_ClientDisconnectAfterFinalChunkBillsCollectedUsage(t *testing.T) {
reqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
svc, c, _ := newGeminiAbortingStreamService(t, "/v1beta/models/gemini-3-flash-preview:streamGenerateContent", reqBody, geminiAbortTextChunk+geminiAbortFinalChunk, true)
result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""), "gemini-3-flash-preview", "streamGenerateContent", true, reqBody)
require.NoError(t, err)
require.NotNil(t, result)
require.True(t, result.ClientDisconnect)
require.Equal(t, 10, result.Usage.InputTokens)
require.Equal(t, 5, result.Usage.OutputTokens)
require.Zero(t, result.ImageCount)
}
func TestGeminiForwardNative_ClientDisconnectWithoutUsageStaysUnbilled(t *testing.T) {
reqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
svc, c, _ := newGeminiAbortingStreamService(t, "/v1beta/models/gemini-3-flash-preview:streamGenerateContent", reqBody, geminiAbortTextChunk, true)
result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""), "gemini-3-flash-preview", "streamGenerateContent", true, reqBody)
require.ErrorIs(t, err, context.Canceled)
require.Nil(t, result)
}
func TestGeminiForwardNative_UpstreamReadErrorWithLiveClientStaysError(t *testing.T) {
reqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
svc, c, _ := newGeminiAbortingStreamService(t, "/v1beta/models/gemini-3-flash-preview:streamGenerateContent", reqBody, geminiAbortTextChunk+geminiAbortFinalChunk, false)
result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""), "gemini-3-flash-preview", "streamGenerateContent", true, reqBody)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
require.Nil(t, result)
}
func TestGeminiForwardNative_ClientDisconnectBillsOnlyDeliveredImages(t *testing.T) {
reqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"draw a cat"}]}]}`)
t.Run("image delivered before disconnect", func(t *testing.T) {
svc, c, _ := newGeminiAbortingStreamService(t, "/v1beta/models/gemini-3-pro-image:streamGenerateContent", reqBody, geminiAbortImageChunk, true)
result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""), "gemini-3-pro-image", "streamGenerateContent", true, reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Equal(t, 1, result.ImageCount)
})
t.Run("disconnect before the image arrives", func(t *testing.T) {
svc, c, _ := newGeminiAbortingStreamService(t, "/v1beta/models/gemini-3-pro-image:streamGenerateContent", reqBody, geminiAbortUsageChunk, true)
result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""), "gemini-3-pro-image", "streamGenerateContent", true, reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Zero(t, result.ImageCount)
require.Equal(t, 2, result.Usage.OutputTokens)
})
}
func TestGeminiCompatForward_ClientDisconnectAfterFinalChunkBillsCollectedUsage(t *testing.T) {
reqBody := []byte(`{"model":"gemini-3-flash-preview","max_tokens":16,"stream":true,"messages":[{"role":"user","content":"hi"}]}`)
t.Run("client gone", func(t *testing.T) {
svc, c, _ := newGeminiAbortingStreamService(t, "/v1/messages", reqBody, geminiAbortTextChunk+geminiAbortFinalChunk, true)
result, err := svc.Forward(context.Background(), c, geminiVertexAPIKeyTestAccount(""), reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Equal(t, 10, result.Usage.InputTokens)
require.Equal(t, 5, result.Usage.OutputTokens)
})
t.Run("upstream read error with live client", func(t *testing.T) {
svc, c, _ := newGeminiAbortingStreamService(t, "/v1/messages", reqBody, geminiAbortTextChunk+geminiAbortFinalChunk, false)
result, err := svc.Forward(context.Background(), c, geminiVertexAPIKeyTestAccount(""), reqBody)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
require.Nil(t, result)
})
}
func TestGeminiForwardAsChatCompletions_ClientDisconnectAfterFinalChunkBillsCollectedUsage(t *testing.T) {
reqBody := []byte(`{"model":"gemini-3-flash-preview","stream":true,"messages":[{"role":"user","content":"hi"}]}`)
t.Run("client gone", func(t *testing.T) {
svc, c, _ := newGeminiAbortingStreamService(t, "/v1/chat/completions", reqBody, geminiAbortTextChunk+geminiAbortFinalChunk, true)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiVertexAPIKeyTestAccount(""), reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Equal(t, 10, result.Usage.InputTokens)
require.Equal(t, 5, result.Usage.OutputTokens)
})
t.Run("client gone before usage", func(t *testing.T) {
svc, c, _ := newGeminiAbortingStreamService(t, "/v1/chat/completions", reqBody, geminiAbortTextChunk, true)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiVertexAPIKeyTestAccount(""), reqBody)
require.ErrorIs(t, err, context.Canceled)
require.Nil(t, result)
})
}
func TestGeminiForwardNative_ClientDisconnectAfterFinishKeepsImageFallback(t *testing.T) {
// 结束分片已到达但图片不以 inlineData 回吐时,断开与正常完成同口径按模型名兜底。
finishWithoutInline := `data: {"candidates":[{"content":{"parts":[{"fileData":{"mimeType":"image/png","fileUri":"gs://bucket/cat.png"}}],"role":"model"},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":1290,"totalTokenCount":1300}}` + "\n\n"
reqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"draw a cat"}]}]}`)
svc, c, _ := newGeminiAbortingStreamService(t, "/v1beta/models/gemini-3-pro-image:streamGenerateContent", reqBody, finishWithoutInline, true)
result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""), "gemini-3-pro-image", "streamGenerateContent", true, reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Equal(t, 1, result.ImageCount)
}
func TestGeminiForwardNative_ClientDisconnectIgnoresTruncatedTrailingLine(t *testing.T) {
complete := `data: {"candidates":[{"content":{"parts":[{"text":"hello"}],"role":"model"}}],"usageMetadata":{"promptTokenCount":1000,"candidatesTokenCount":300,"totalTokenCount":1300}}` + "\n\n"
truncated := `data: {"candidates":[{"content":{"parts":[{"text":" more"}],"role":"model"}}],"usageMetadata":{`
reqBody := []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`)
svc, c, _ := newGeminiAbortingStreamService(t, "/v1beta/models/gemini-3-flash-preview:streamGenerateContent", reqBody, complete+truncated, true)
result, err := svc.ForwardNative(context.Background(), c, geminiVertexAPIKeyTestAccount(""), "gemini-3-flash-preview", "streamGenerateContent", true, reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Equal(t, 1000, result.Usage.InputTokens)
require.Equal(t, 300, result.Usage.OutputTokens)
}
func TestGeminiForwardAsChatCompletions_ClientDisconnectImageCount(t *testing.T) {
t.Run("disconnect before finish bills no image", func(t *testing.T) {
reqBody := []byte(`{"model":"gemini-3-pro-image","stream":true,"messages":[{"role":"user","content":"draw a cat"}]}`)
svc, c, _ := newGeminiAbortingStreamService(t, "/v1/chat/completions", reqBody, geminiAbortUsageChunk, true)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiVertexAPIKeyTestAccount(""), reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Zero(t, result.ImageCount)
require.Equal(t, 2, result.Usage.OutputTokens)
})
t.Run("disconnect after finish keeps completed-request image count", func(t *testing.T) {
reqBody := []byte(`{"model":"gemini-3-pro-image","stream":true,"messages":[{"role":"user","content":"draw a cat"}]}`)
svc, c, _ := newGeminiAbortingStreamService(t, "/v1/chat/completions", reqBody, geminiAbortImageChunk, true)
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiVertexAPIKeyTestAccount(""), reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Equal(t, 1, result.ImageCount)
})
}
// geminiFailingResponseWriter 模拟客户端已断开:所有写入失败。
type geminiFailingResponseWriter struct {
header http.Header
}
func (w *geminiFailingResponseWriter) Header() http.Header { return w.header }
func (w *geminiFailingResponseWriter) Write([]byte) (int, error) { return 0, io.ErrClosedPipe }
func (w *geminiFailingResponseWriter) WriteHeader(int) {}
func (w *geminiFailingResponseWriter) Flush() {}
func TestGeminiForwardAsChatCompletions_WriteFailureOnImageModelBillsNoImage(t *testing.T) {
gin.SetMode(gin.TestMode)
reqBody := []byte(`{"model":"gemini-3-pro-image","stream":true,"messages":[{"role":"user","content":"draw a cat"}]}`)
c, _ := gin.CreateTestContext(&geminiFailingResponseWriter{header: http.Header{}})
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(reqBody))
svc, httpStub := newGeminiVertexAPIKeyTestService()
httpStub.response = &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(bytes.NewReader([]byte(geminiAbortUsageChunk))),
}
result, err := svc.ForwardAsChatCompletions(context.Background(), c, geminiVertexAPIKeyTestAccount(""), reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Zero(t, result.ImageCount)
}
func TestGeminiCompatForward_ClientDisconnectBeforeFinishBillsNoImage(t *testing.T) {
inlineWithoutFinish := `data: {"candidates":[{"content":{"parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}],"role":"model"}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":1290,"totalTokenCount":1300}}` + "\n\n"
reqBody := []byte(`{"model":"gemini-3-pro-image","max_tokens":16,"stream":true,"messages":[{"role":"user","content":"draw a cat"}]}`)
svc, c, _ := newGeminiAbortingStreamService(t, "/v1/messages", reqBody, inlineWithoutFinish, true)
result, err := svc.Forward(context.Background(), c, geminiVertexAPIKeyTestAccount(""), reqBody)
require.NoError(t, err)
require.True(t, result.ClientDisconnect)
require.Zero(t, result.ImageCount)
require.Equal(t, 1290, result.Usage.OutputTokens)
}
@@ -12,6 +12,7 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
@@ -99,3 +100,66 @@ func TestGeminiForwardAsChatCompletions_VertexAPIKeyUsesAPIKeyHeader(t *testing.
require.Equal(t, "https://aiplatform.googleapis.com/v1/publishers/google/models/gemini-3-flash-preview:generateContent", httpStub.lastReq.URL.String())
requireVertexAPIKeyAuth(t, httpStub.lastReq)
}
type geminiVertex429SequenceUpstream struct {
responses []*http.Response
calls int
}
func (s *geminiVertex429SequenceUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
resp := s.responses[min(s.calls, len(s.responses)-1)]
s.calls++
return resp, nil
}
func (s *geminiVertex429SequenceUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return s.Do(req, proxyURL, accountID, accountConcurrency)
}
// Vertex 共享容量不足的 429 在同一账号上退避重试,成功后账号状态不变;绑定显式缓存的请求同样如此。
func TestGeminiForwardNative_VertexSharedCapacity429RetriesSameAccountWithoutCooldown(t *testing.T) {
gin.SetMode(gin.TestMode)
sharedCapacity := `{"error":{"code":429,"message":"Resource exhausted. Please try again later.","status":"RESOURCE_EXHAUSTED"}}`
tests := []struct {
name string
ctx context.Context
}{
{name: "unbound", ctx: context.Background()},
{name: "bound_cached_content", ctx: WithGeminiCachedContentBound(context.Background())},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
upstream := &geminiVertex429SequenceUpstream{responses: []*http.Response{
{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(sharedCapacity)),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(geminiVertexAPIKeyTestResponse)),
},
}}
repo := &geminiBoundCacheRateLimitRepo{}
svc := &GeminiMessagesCompatService{
httpUpstream: upstream,
cfg: &config.Config{},
accountRepo: repo,
rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil),
}
c, rec := newGeminiNativeTestContext(t)
result, err := svc.ForwardNative(tt.ctx, c, geminiVertexAPIKeyTestAccount("my-project"),
"gemini-3.7-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, 2, upstream.calls, "429 后在同一账号上重试")
require.Zero(t, repo.rateLimitedCalls, "不写账号级限流")
require.Zero(t, repo.tempCalls)
require.Zero(t, repo.setErrCalls)
require.Empty(t, repo.modelRateLimitCalls)
})
}
}
@@ -0,0 +1,60 @@
package service
import (
"context"
"log/slog"
"net/http"
"github.com/gin-gonic/gin"
)
// vertexServiceAccountNotFoundReason 标识换取 SA access token 时 SA 已不存在。
const vertexServiceAccountNotFoundReason GatewayFailureReason = "vertex_service_account_not_found"
// geminiVertexCredentialFailoverBody 是 SA 凭据失效时挂在 failover 错误上的 Google 格式错误体,
// 供 handler 在换号耗尽后做透传规则匹配与错误信息提取。
var geminiVertexCredentialFailoverBody = []byte(`{"error":{"code":401,"message":"Upstream authentication failed","status":"UNAUTHENTICATED"}}`)
// handleVertexServiceAccountNotFound 处理 Gemini 三条转发路径(原生 / Claude 兼容 /
// Chat Completions 兼容)构造上游请求时换取 SA access token 失败、且 SA 已不存在的情况:
//
// - 账号置为 error 并停止调度,错误信息带上 token 端点的 error_description;
// - 记录凭据获取阶段(stage=account_auth)的 Ops 事件,token 端点的状态码只出现在 Message 中;
// - 返回 *UpstreamFailoverError(401),由 handler 的 failover 循环换号。
//
// 不是该类失败时返回 nil,由调用方继续处理。本函数不写响应。
func (s *GeminiMessagesCompatService) handleVertexServiceAccountNotFound(ctx context.Context, c *gin.Context, account *Account, err error) *UpstreamFailoverError {
tokenErr, ok := vertexServiceAccountNotFoundError(err)
if !ok {
return nil
}
msg := "Service account token rejected (400): " + tokenErr.Description
stateCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), openAIAccountStateUpdateTimeout)
defer cancel()
if s.rateLimitService != nil {
s.rateLimitService.handleAuthError(stateCtx, account, msg)
} else if s.accountRepo != nil {
if setErr := s.accountRepo.SetError(stateCtx, account.ID, msg); setErr != nil {
slog.Warn("account_set_error_failed", "account_id", account.ID, "error", setErr)
}
}
event := OpsUpstreamErrorEvent{
Platform: account.Platform,
AccountID: account.ID,
AccountName: account.Name,
Kind: "failover",
Stage: string(GatewayFailureStageAccountAuth),
Scope: string(GatewayFailureScopeAccount),
Reason: string(vertexServiceAccountNotFoundReason),
Message: sanitizeUpstreamErrorMessage(err.Error()),
}
event.ProxyID, event.ProxyName = opsUpstreamProxyAttribution(account)
appendOpsUpstreamError(c, event)
return &UpstreamFailoverError{
StatusCode: http.StatusUnauthorized,
ResponseBody: geminiVertexCredentialFailoverBody,
}
}
@@ -0,0 +1,207 @@
//go:build unit
package service
import (
"bytes"
"context"
"crypto/rand"
"crypto/rsa"
"crypto/x509"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// ---------------------------------------------------------------------------
// 换取 SA access token 时 SA 已不存在(token 端点 400 invalid_grant
// "Invalid grant: account not found")的契约,三条转发路径一致:
// - 账号置为 error 并停止调度,错误信息带 token 端点的 error_description;
// - 不发出上游请求,返回 *UpstreamFailoverError(401) 交给 handler 换号,service 不写响应;
// - Ops 事件记为凭据获取阶段(stage=account_auth);
// - 其他换 token 失败不改账号状态、不换号,直接回写 502。
// ---------------------------------------------------------------------------
const vertexAccountNotFoundTokenBody = `{"error":"invalid_grant","error_description":"Invalid grant: account not found"}`
func newVertexTokenTestAccount(t *testing.T) *Account {
t.Helper()
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
require.NoError(t, err)
pemBytes := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(privateKey)})
saJSON, err := json.Marshal(map[string]string{
"type": "service_account",
"project_id": "proj-1",
"private_key_id": "kid-1",
"private_key": string(pemBytes),
"client_email": "svc@proj-1.iam.gserviceaccount.com",
})
require.NoError(t, err)
return &Account{
ID: 820,
Platform: PlatformGemini,
Type: AccountTypeServiceAccount,
Credentials: map[string]any{
"service_account_json": string(saJSON),
"project_id": "proj-1",
"location": "global",
},
}
}
func useVertexTokenEndpoint(t *testing.T, status int, body string) {
t.Helper()
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_, _ = w.Write([]byte(body))
}))
t.Cleanup(server.Close)
original := vertexServiceAccountTokenURL
vertexServiceAccountTokenURL = server.URL
t.Cleanup(func() { vertexServiceAccountTokenURL = original })
}
func newVertexTokenErrorService() (*GeminiMessagesCompatService, *geminiCompatHTTPUpstreamStub, *errorPolicyRepoStub) {
repo := &errorPolicyRepoStub{}
httpStub := &geminiCompatHTTPUpstreamStub{}
svc := &GeminiMessagesCompatService{
accountRepo: repo,
httpUpstream: httpStub,
tokenProvider: NewGeminiTokenProvider(repo, nil, nil),
rateLimitService: NewRateLimitService(repo, nil, &config.Config{}, nil, nil),
cfg: &config.Config{},
}
return svc, httpStub, repo
}
func requireVertexAccountNotFoundHandled(t *testing.T, c *gin.Context, err error, httpStub *geminiCompatHTTPUpstreamStub, repo *errorPolicyRepoStub) {
t.Helper()
var failoverErr *UpstreamFailoverError
require.True(t, errors.As(err, &failoverErr), "SA 不存在应换号,got %T: %v", err, err)
require.Equal(t, http.StatusUnauthorized, failoverErr.StatusCode)
require.Equal(t, string(geminiVertexCredentialFailoverBody), string(failoverErr.ResponseBody))
require.True(t, failoverErr.ShouldRetryNextAccount())
require.False(t, failoverErr.RetryableOnSameAccount)
require.Equal(t, 1, repo.setErrCalls, "SA 不存在应把账号置为 error")
require.Equal(t, "Service account token rejected (400): Invalid grant: account not found", repo.lastErrorMsg)
require.Zero(t, httpStub.calls, "换不到 token 时不应发出上游请求")
require.False(t, c.Writer.Written(), "换号场景不应写客户端响应")
raw, ok := c.Get(OpsUpstreamErrorsKey)
require.True(t, ok, "应记录 ops 上游错误事件")
events := raw.([]*OpsUpstreamErrorEvent)
require.Len(t, events, 1)
require.Equal(t, "failover", events[0].Kind)
require.Equal(t, int64(820), events[0].AccountID)
require.Equal(t, string(GatewayFailureStageAccountAuth), events[0].Stage)
require.Equal(t, string(GatewayFailureScopeAccount), events[0].Scope)
require.Equal(t, string(vertexServiceAccountNotFoundReason), events[0].Reason)
require.Zero(t, events[0].UpstreamStatusCode, "换 token 失败不占用上游状态码")
require.Contains(t, events[0].Message, "service account token request returned 400: Invalid grant: account not found")
}
func TestExchangeVertexServiceAccountToken_AccountNotFoundIsRecognized(t *testing.T) {
useVertexTokenEndpoint(t, http.StatusBadRequest, vertexAccountNotFoundTokenBody)
key, err := parseVertexServiceAccountKey(newVertexTokenTestAccount(t))
require.NoError(t, err)
_, _, err = exchangeVertexServiceAccountToken(contextWithTestTimeout(t), key, "")
require.EqualError(t, err, "service account token request returned 400: Invalid grant: account not found")
tokenErr, ok := vertexServiceAccountNotFoundError(fmt.Errorf("build request: %w", err))
require.True(t, ok)
require.Equal(t, "invalid_grant", tokenErr.Code)
require.Equal(t, "Invalid grant: account not found", tokenErr.Description)
}
func TestVertexServiceAccountNotFoundError_OnlyMatchesAccountNotFound(t *testing.T) {
tests := []struct {
name string
err error
}{
{"other_invalid_grant", &vertexTokenRequestError{StatusCode: 400, Code: "invalid_grant", Description: "Invalid JWT Signature."}},
{"not_400", &vertexTokenRequestError{StatusCode: 401, Code: "invalid_grant", Description: "Invalid grant: account not found"}},
{"other_code", &vertexTokenRequestError{StatusCode: 400, Code: "invalid_request", Description: "Invalid grant: account not found"}},
{"server_error", &vertexTokenRequestError{StatusCode: 500, Message: "backend error"}},
{"plain_error", errors.New("service account token request returned 400: Invalid grant: account not found")},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
_, ok := vertexServiceAccountNotFoundError(tt.err)
require.False(t, ok)
})
}
}
func TestGeminiForwardNative_VertexServiceAccountNotFoundDisablesAndFailsOver(t *testing.T) {
gin.SetMode(gin.TestMode)
useVertexTokenEndpoint(t, http.StatusBadRequest, vertexAccountNotFoundTokenBody)
svc, httpStub, repo := newVertexTokenErrorService()
c, _ := newGeminiNativeTestContext(t)
result, err := svc.ForwardNative(context.Background(), c, newVertexTokenTestAccount(t),
"gemini-3.8-flash", "streamGenerateContent", true, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
require.Nil(t, result)
requireVertexAccountNotFoundHandled(t, c, err, httpStub, repo)
}
func TestGeminiForward_VertexServiceAccountNotFoundDisablesAndFailsOver(t *testing.T) {
gin.SetMode(gin.TestMode)
useVertexTokenEndpoint(t, http.StatusBadRequest, vertexAccountNotFoundTokenBody)
svc, httpStub, repo := newVertexTokenErrorService()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gemini-3.8-flash","max_tokens":16,"messages":[{"role":"user","content":"hello"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
result, err := svc.Forward(context.Background(), c, newVertexTokenTestAccount(t), body)
require.Nil(t, result)
requireVertexAccountNotFoundHandled(t, c, err, httpStub, repo)
}
func TestGeminiForwardAsChatCompletions_VertexServiceAccountNotFoundDisablesAndFailsOver(t *testing.T) {
gin.SetMode(gin.TestMode)
useVertexTokenEndpoint(t, http.StatusBadRequest, vertexAccountNotFoundTokenBody)
svc, httpStub, repo := newVertexTokenErrorService()
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
body := []byte(`{"model":"gemini-3.8-flash","messages":[{"role":"user","content":"hi"}]}`)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
result, err := svc.ForwardAsChatCompletions(context.Background(), c, newVertexTokenTestAccount(t), body)
require.Nil(t, result)
requireVertexAccountNotFoundHandled(t, c, err, httpStub, repo)
}
func TestGeminiForwardNative_OtherVertexTokenErrorKeepsAccountState(t *testing.T) {
gin.SetMode(gin.TestMode)
useVertexTokenEndpoint(t, http.StatusBadRequest, `{"error":"invalid_grant","error_description":"Invalid JWT Signature."}`)
svc, httpStub, repo := newVertexTokenErrorService()
c, rec := newGeminiNativeTestContext(t)
result, err := svc.ForwardNative(context.Background(), c, newVertexTokenTestAccount(t),
"gemini-3.8-flash", "generateContent", false, []byte(`{"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`))
require.Nil(t, result)
require.Error(t, err)
var failoverErr *UpstreamFailoverError
require.False(t, errors.As(err, &failoverErr), "未识别的换 token 失败不换号")
require.Zero(t, repo.setErrCalls, "未识别的换 token 失败不改账号状态")
require.Zero(t, httpStub.calls)
require.Equal(t, http.StatusBadGateway, rec.Code)
require.True(t, strings.Contains(rec.Body.String(), "Invalid JWT Signature."))
}

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