Compare commits

..
Author SHA1 Message Date
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
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
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
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
Wesley Liddick 01842576b3 Merge pull request #7810 from wucm667/fix/issue-6247-responses-chat-fallback
fix(gateway): retry rejected structured input through chat endpoint
2026-10-13 15:01:20 +08:00
Wesley Liddick 49b2122dc6 Merge pull request #7991 from wucm667/fix/issue-7988-international-quota
fix(quota): recognize international coding endpoints
2026-10-13 15:00:31 +08:00
Wesley Liddick 8d7d3185e9 Merge pull request #8038 from wucm667/fix/issue-8035-gemini-tool-result-images
Preserve images from tool results
2026-10-13 15:00:04 +08:00
Wesley Liddick bba85923b9 Merge pull request #8046 from wucm667/fix/issue-8040-antigravity-image-rate-limit
Isolate Antigravity image model rate limits
2026-10-13 14:59:39 +08:00
Wesley Liddick 49dc6e2363 Merge pull request #7828 from feeeei/feat/temp-unsched-rule-scope
feat(account): 临时不可调度规则新增作用范围 scope 选项
2026-10-13 14:59:27 +08:00
shaw 9d5e63a598 合并 main 并解决 PR #7828 的测试文件冲突
完整保留临时不可调度规则作用范围测试与 Gemini 显式缓存测试;保留双方生产代码改动。
2026-10-13 14:59:02 +08:00
Wesley Liddick 53c21fed25 Merge pull request #8006 from feeeei/feat/gemini-explicit-cache
feat(gemini): 支持显式上下文缓存,并完善 Vertex 与第三方 API Key 账号
2026-10-13 14:51:11 +08:00
Wesley Liddick 7c5afa4c36 Merge pull request #7957 from xcrong/feat/haiku-5-5-support-7919
feat(gateway): adapt Claude Haiku 5.5
2026-10-13 14:50:40 +08:00
shaw b669dc6daf test(frontend): 将模型白名单回归用例纳入 CI 2026-10-13 14:42:29 +08:00
shaw c4bdc48475 fix(haiku): 修正采样边界、默认模型配置与 lint 2026-10-13 14:38:16 +08:00
xcrong 26f70c6a87 fix(gateway): wire Claude Haiku 5.5 into Responses bridge
ResponsesToAnthropicRequest only recognized opus/sonnet 5.5, so Haiku
requests fell into the generic enabled+budget_tokens path (upstream 400)
and lost signed thinking history (preserveThinking=false). Mirror the
Messages-side rules: adaptive+medium default, none->disabled+low,
temp/top_p/combo guards, forced tool_choice allowed.

Also preserve Haiku55 thinking signatures in AnthropicToResponsesResponse
and streaming state, and name the model correctly in the between_tools
rejection.
2026-10-13 10:45:13 +08:00
wucm667 cfbbfd6b3b fix(antigravity): isolate image model rate limits 2026-10-13 09:24:38 +08:00
feeeei 43a3c197cb fix(gemini): 显式缓存恢复流程区分账号已删除与暂不支持缓存,上游必已过期后作废意图
后台回滚在持有账号返回 nil 时把修改意图当作已回滚清除:账号临时关闭显式缓存能力后
意图丢失,重新开启也无法补偿。

- boundAccount 区分两种情形:账号已删除返回 nil,账号存在但不支持显式缓存返回
  errGeminiCachedContentAccountUnsupported;BoundAccount 对外行为不变
- 账号暂不支持缓存时不改动上游、保留意图,租约过期后重试(期间同一缓存的修改返回 409);
  账号已删除时没有凭据可回滚上游,软删本地记录
- 修改前到期时间加上单次最长有效期与 1 小时容忍都已过去时上游缓存必已自行过期:
  账号不支持缓存或上游回滚持续失败的意图不再重试,直接作废本地记录
- 测试夹具 GetForOwner 过滤已软删记录
2026-10-13 00:54:27 +08:00
feeeei 2023c290e1 fix(account): 计数、Bedrock 与重试耗尽入口把上游模型交给错误处理
OpenAI 与 Anthropic 的 count_tokens / input_tokens 入口、Bedrock 故障转移入口
以及 OAuth 403 重试耗尽入口调 HandleUpstreamError 时没有传入实际发往上游的模型,
显式 scope=model 的 401 规则在这些入口看到空模型,仍落到 handleAuthError 整号置 error。

- 四处计数入口传入映射后的上游模型;Anthropic 透传计数函数新增 upstreamModel 参数
- handleRetryExhaustedSideEffects / handleRetryExhaustedError 增加模型变参,
  Forward、Anthropic 透传与 Bedrock 三处调用方传入实际模型
- Bedrock 入口传调度器的模型键(account.GetMappedModel),不是发往上游的
  Bedrock 模型 ID,模型级冷却才会被调度器读取
- 这些入口的 404 模型未找到与缺省 scope 的非 401 规则随之按主转发路径语义处理
  (模型已知走模型级)
2026-10-13 00:53:39 +08:00
Wesley Liddick ab80abc6d1 Merge pull request #7960 from feeeei/feat/group-i18n
feat(groups): 分组名称与描述支持多语言
2026-10-12 22:16:51 +08:00
wucm667 492c630599 fix(gemini): satisfy test lint 2026-10-12 19:13:17 +08:00
wucm667 ba2a717332 fix(gemini): preserve images from tool results 2026-10-12 18:52:00 +08:00
feeeei 8b74741efe fix(groups): 广场 Vary 保留 Origin,分组译文语言代码统一大小写
- 模型广场以追加方式写入 Vary: Accept-Language,保留 CORS 中间件为具体来源
  写入的 Vary: Origin
- 管理端写入分组译文时语言代码统一为 BCP 47 惯用大小写(en、zh-TW),
  统一后重复的语言代码(如 en 与 EN)返回 400 INVALID_GROUP_I18N
- 用户侧按语言取译文时规范写法的键优先,仅大小写不同的多个键按字节序取最小者,
  同一请求语言的结果固定
2026-10-12 18:09:03 +08:00
feeeei fc82e83102 fix(account): 显式模型级 401 规则在网关故障转移入口生效
OpenAI 转发 fastpath、HandleUpstreamError 及两处池模式分支对 401 一律跳过
临时不可调度规则,显式 scope=model 的 401 规则在 API Key 账号上仍落到
handleAuthError 整号置 error。

- 新增 tempUnschedRulesApply:401 仅在首条命中规则按模型级生效时交给规则,
  其余保持认证错误处理
- 凭据级 401(OAuth 账号与 Spark 影子、OpenAI token_invalidated/token_revoked、
  {"detail":"Unauthorized"})不受规则作用范围影响,各入口沿用原有处理
- 模型级命中不参与重复 401 升级;fastpath 不再因模型级命中封整号
2026-10-12 17:39:26 +08:00
Wesley Liddick 9591136d45 Merge pull request #8033 from Larry-Labs/fix/gpt61-sol-500k-context
fix(codex): raise GPT-6.1 Sol generated context default to 500k
2026-10-12 17:18:51 +08:00
feeeei 26b31a83e4 fix(gemini): 显式缓存修改串行化、失败回滚,确定金额费用按 Key / 订阅预留
同一缓存的并发 PATCH 可能以与上游提交不同的顺序写回本地到期时间;
确定金额的存储费只预留用户余额,并发续期可越过 API Key 总额度与订阅额度;
上游已修改而本地写回失败时没有可恢复的状态。

- 新增迁移 245:缓存记录增加修改意图(mutation_token / mutation_lease_until)。
  PATCH 在调用上游前登记意图并取得 2 分钟租约,条件为没有进行中的修改且到期时间
  未变;上游修改、本地写回与计费均由持有者完成,并发的修改返回 409 ABORTED
- 上游结果未知、本地写回失败或进程退出时修改意图保留且不计费;租约过期后由下一次
  修改或后台每分钟的恢复流程把上游有效期回滚到修改前的到期时间(已到期则删除上游
  缓存与本地记录)。上游明确拒绝或请求未发出时直接清除意图
- 修改 / 删除上游缓存的单次调用超时改为 30 秒;请求发出前的失败包装为
  ErrGeminiCachedContentRequestNotSent
- CheckChargeAffordable 与 ReserveInflightCharge 合并为 ReserveFixedCharge:在途预留
  新增订阅、API Key 两个维度(Redis billing:inflight_scope:*),先登记本笔预留再读取
  库中权威用量(Key quota_used 与限速窗口、订阅日 / 周 / 月用量)叠加同维度在途合计
  校验;预留随计费任务交接,落库后释放
- GetRateLimitData 一并读取 quota_used
2026-10-12 17:14:56 +08:00
Wesley Liddick 7b03b1f75a Merge pull request #8036 from Wei-Shaw/fix/gpt61-sol-subscription-long-context
fix(billing): 取消 GPT-6.1 Sol 订阅长上下文附加费
2026-10-12 17:04:09 +08:00
shaw 43768680d3 fix(billing): 取消 GPT-6.1 Sol 订阅长上下文附加费 2026-10-12 16:47:22 +08:00
xugao 0718581265 fix(codex): raise GPT-6.1 Sol generated context default to 500k 2026-10-12 15:16:44 +08:00
Wesley Liddick e468262294 Merge pull request #8002 from obst4cle/fix/group-rpm-override-stale-load
fix(groups): ignore stale RPM override loads
2026-10-12 14:38:14 +08:00
Wesley Liddick 5dbb8f7085 Merge pull request #7649 from Yukinoshita-Yukino/fix/codex-image-bridge-openai-only
fix(openai): only inject the Codex image generation bridge for OpenAI accounts
2026-10-12 14:38:00 +08:00
Wesley Liddick 6d509c632d Merge pull request #7711 from wucm667/fix/issue-7709-risk-control-groups
fix(risk-control): remove deleted groups from moderation scope
2026-10-12 14:37:44 +08:00
Wesley Liddick 6a6d4728a0 Merge pull request #7696 from a408665321/codex/issue-7687-timer-only
fix(ui): refresh usage countdown when tab becomes visible
2026-10-12 14:37:26 +08:00
Wesley Liddick 832677a5e6 Merge pull request #7717 from ouchihao/codex/bug-payment-countdown-drift
fix(payment): calculate QR countdowns from elapsed time
2026-10-12 14:37:11 +08:00
Wesley Liddick c2885c430f Merge pull request #7738 from r266-tech/codex/monitor-add-platform
fix(monitor): allow adding missing platforms in v2 settings
2026-10-12 14:36:41 +08:00
Wesley Liddick 59fb22791e Merge pull request #7933 from yan9651688/feat/issue-7926-ops-details-account-column
feat(ops): show account names in request details table
2026-10-12 14:36:28 +08:00
Wesley Liddick 20b0be5b05 Merge pull request #7969 from ouchihao/codex/bug-ops-custom-timezone
fix(ops): initialize custom time ranges in local time
2026-10-12 14:36:12 +08:00
Wesley Liddick d70265306b Merge pull request #7970 from ouchihao/codex/bug-select-ime-navigation
fix(select): preserve keyboard input during IME composition
2026-10-12 14:35:53 +08:00
Wesley Liddick f2b1556bd4 Merge pull request #7972 from ouchihao/codex/bug-group-rate-edit-validation
fix(groups): reject nonpositive rate multiplier edits
2026-10-12 14:35:37 +08:00
Wesley Liddick 0ae1b371fa Merge pull request #7973 from ouchihao/codex/bug-monitor-auto-detail-refresh
fix(monitor): refresh cached availability on automatic reload
2026-10-12 14:35:24 +08:00
Wesley Liddick da9799542d Merge pull request #7974 from ouchihao/codex/bug-gemini-auth-url-error
fix(gemini): preserve authorization URL error messages
2026-10-12 14:35:12 +08:00
Wesley Liddick 6977bb63bb Merge pull request #7983 from hutututtt/fix/account-group-names
fix(frontend): show full account group names
2026-10-12 14:34:58 +08:00
Wesley Liddick b63dafa426 Merge pull request #7982 from wucm667/fix/issue-7981-embedded-static-asset
test(web): use shipped SVG asset in embedded frontend tests
2026-10-12 14:34:38 +08:00
feeeei daebb7a80c fix(gemini): 模型列表与模型详情接口支持 Agent Platform (Vertex) 账号
Gemini 分组对外提供 AI Studio 兼容协议,分组内账号不一定是 AI Studio。
分组只剩 Vertex 服务账号时,GET /v1beta/models 与 /v1beta/models/{model}
选中服务账号后报 502 unsupported account type: service_account。

- SelectAccountForAIStudioEndpoints 改名 SelectAccountForModelsEndpoints,
  Vertex 账号排在所有 AI Studio 兼容账号之后参与选号
- ForwardAIStudioGET 拆为 ForwardListModels / ForwardGetModel,按账号类型分流:
  AI Studio 兼容账号原样转发;Vertex 服务账号读取 Google 发布方模型列表并
  转换为 AI Studio 格式,模型详情在该模型所用区域的列表中查找;Vertex API Key
  账号返回内置列表
- Vertex 发布方模型分页读取抽成 listVertexGeminiPublisherModels,与后台
  同步上游模型共用
- 模型接口响应体上限改用 gateway.models_list_read_max_bytes,超出时报错
2026-10-11 22:03:36 +08:00
feeeei 4426593e9d fix(gemini): 3.6 Flash 兜底价改为官方优惠价
- 内置兜底价与本地价格表快照的 gemini-3.6-flash 改为 $0.75 输入 / $3.75 输出 /
  $0.075 缓存读取(Priority 档 $1.35 / $6.75 / $0.135),有效期至 2026-12-31,
  2027-01-01 起恢复 $1.50 / $7.50 / $0.15,与 3.7 / 3.8 Flash 及远程价格目录一致
2026-10-11 11:22:48 +08:00
feeeei f0ee427f0a feat(gemini): 显式缓存存储价内置官方价并按 Pro / Flash 系列兜底
- 新增 Gemini 显式缓存存储单价表(Standard 档,USD / token / 小时):
  Pro 全系 $4.50、Flash 与 Flash-Lite 全系 $1.00;3.6 / 3.7 / 3.8 Flash
  为优惠价 $0.50(有效期至 2026-12-31,2027-01-01 起恢复 $1.00),
  AI Studio 与 Vertex 统一按优惠价计
- 型号表未命中的 Gemini 模型按系列兜底:Pro 取 $4.50,Flash / Flash-Lite
  取标准价 $1.00;Flash-Lite 变体不落到同版本 Flash 的优惠价
- 取值优先级:价格表(override_file 等)配置值 > 内置型号价 > 内置系列价;
  两者都没有的模型仍拒绝创建缓存
2026-10-11 11:22:43 +08:00
feeeei 05d7946af8 fix(gemini): 显式缓存错误透传收窄到上游网关账号,延长沿用创建时计费口径
- 绑定缓存的请求与缓存管理接口收到 429 / 5xx 时,只有上游网关账号的响应原样回写
  (UpstreamFailoverError.BoundUpstreamPassthrough / GeminiCachedContentUpstreamError.UpstreamGateway),
  官方账号仍走错误透传规则与通用映射,不向客户端暴露配额明细
- 记录新增 channel_usage(迁移 244)保存创建时的渠道计费口径,PATCH 延长按同一口径预检、报价与记账
- 创建 / 延长费用行的 request_id 改为固定前缀 gcache:{create|patch}:{id},不被客户端或网关请求 id 覆盖,
  按缓存 ID 即可检索一个缓存的全部费用行
- 调度快照凭据投影补齐 base_url / auth_mode / 上游显式缓存声明,显式缓存候选过滤一次算全,
  不再逐个选号后剔除
2026-10-11 01:37:04 +08:00
feeeei c9e4b267b6 fix(account): 同步上游模型失败时显示后端返回的错误原因
- apiClient 拒绝的是普通对象而非 Error,原写法 instanceof Error 恒为假,
  错误提示被替换成通用文案"同步上游模型失败:同步上游模型失败"
- 白名单选择器与 Antigravity 同步按钮改用 extractApiErrorMessage 取消息
2026-10-10 23:37:48 +08:00
feeeei 6f9b02f12c feat(gemini): Vertex 服务账号支持同步上游模型列表
- Gemini 平台 Service Account 账号改用 v1beta1 publishers/google/models
  分页拉取模型(pageSize 300,最多 20 页),按账号默认区域选端点;
  只保留 publishers/google/models/gemini-* 条目,排除区域端点里的
  Gemma、Veo、AutoML 等模型花园条目
- 凭据缺失或 location 非法返回配置错误(400),且不发起 token 交换;
  API Key 认证的 Vertex 账号返回不支持
- 翻页途中的 404/405 不再触发配置模型回退
- 模型列表的请求、读体与状态码检查抽成 readUpstreamModelsResponse
  供各平台共用
2026-10-10 23:37:48 +08:00
feeeei 1c68f9996d feat(gemini): 适配 AI Studio AQ. 格式 Key
- AI Studio 新建 Key 为 AQ.Ab 前缀的 auth key:logredact.RedactText 与
  渠道监控 sanitizeErrorMessage 增加 AQ.Ab 规则,长度按 40 位以上放宽以覆盖格式变体
- 创建 / 编辑账号的 Key 占位符改为 AQ.Ab...,提示文案说明新旧两种前缀
  与第三方上游 Key
2026-10-10 23:37:48 +08:00
feeeei 2588675ce7 fix(gemini): 第三方上游 API Key 与 Vertex 不再套用 AI Studio 等级和模拟配额
- 按 base_url 判定上游:非官方地址的 Gemini API Key 账号视为第三方上游
- 第三方上游与 Vertex 不返回本地模拟配额,隐藏等级与配额说明;
  账号列表中第三方上游按普通 Key 账号展示今日统计与额度进度条,
  Vertex 显示 vertex 徽章与今日统计
- 第三方上游的 429 只认显式重试时长,否则走全局 429 兜底冷却,
  不按每日配额冷却到 PST 午夜
- 创建账号:第三方 Base URL 不显示 AI Studio 等级选项,也不写入 tier_id;
  API 密钥卡片标题与说明不再限定 AI Studio,免费层说明只在官方 Base URL 下显示;
  OAuth 等级回退下拉只在 OAuth 表单显示
2026-10-10 23:37:48 +08:00
feeeei 81e6ec5346 feat(gemini): Vertex 账号支持 API Key 认证
- Gemini 平台的 Vertex 账号新增 API Key 认证(credentials.auth_mode=apikey),
  不填 Project ID 走全局端点,填写后走项目级端点,Key 通过 x-goog-api-key 传递;
  Anthropic 平台的 Vertex 账号仍只支持 Service Account JSON
- 转发(Claude 兼容 / 原生 / Chat Completions)与测试连接统一走
  buildVertexGeminiAccountURL / setVertexGeminiAuth
- API Key 账号不参与显式缓存(Vertex 缓存接口不接受 API Key)与批量生图
- 创建弹窗新增认证方式选择;编辑弹窗支持轮换 Key 与修改 Project ID,
  Key 留空表示保留
2026-10-10 23:37:48 +08:00
feeeei 64331a9792 ui(gemini): Vertex 显示名改为 Agent Platform (Vertex)
Google 已将 Vertex AI 更名为 Gemini Enterprise Agent Platform。
账号类型按钮、平台徽章、Vertex 相关提示、配额文档链接与批量生图报错文案
统一显示为 Agent Platform (Vertex);创建账号弹窗的 Vertex 按钮改用 i18n 文案。
2026-10-10 23:37:48 +08:00
feeeei 3abd646582 fix(ops): Gemini 原生路由的网关本地错误按错误类型归类
Google 格式错误体没有 type 字段,ops 一律记为 api_error,客户端参数错误、
引用不存在的缓存等都被归为平台内部错误。

- googleError 写出时按状态码向 ops 登记错误类型:400/413 invalid_request_error、
  401 authentication_error、403 permission_error、404 not_found_error,
  429 与 5xx 不登记
- 计费、并发与风控拦截沿用 Claude / OpenAI 格式错误的类型与错误码,归类口径一致
- 存储价未配置、显式缓存未启用改用 googlePlatformError,仍按平台错误统计;
  错误透传规则改写的上游错误不登记本地类型
- 上游返回的缓存不存在标记为请求级失败:按客户端请求错误分类,
  上游原始错误保留为明细但不参与上游归因
2026-10-10 23:37:48 +08:00
feeeei d5a802f84a feat(gemini): 支持显式上下文缓存(AI Studio / Vertex / 自建网关上游)
新增 /v1beta/cachedContents 的创建、列表、查询、更新有效期与删除接口。
参与账号:官方地址的 AI Studio API Key 账号、Vertex service account 账号,
以及开启"上游支持显式缓存"的自定义地址 API Key 账号。

- 网关自发缓存 ID,绑定上游资源名、持有账号与创建它的 API Key
  (迁移 243 gemini_cached_contents);list/get 只读本地记录
- 引用缓存的 generateContent / streamGenerateContent / countTokens
  改写为上游资源名,只发往持有缓存的账号,不参与会话粘性、不换号;
  拒绝重复键与 snake_case 写法;在持有账号上重试耗尽后,
  上游 429 / 5xx 原样透传,401 / 403 映射为 502
- 计费:创建时一次性收取缓存 token 输入费与整个有效期的存储费;
  延长按库中实际推进的时长收费,纯存储费不算一次请求;缩短与删除不退费
- 存储单价随计费模型取价,来自定价覆盖文件字段
  cache_storage_cost_per_token_per_hour,未配置的模型拒绝创建;
  存储费同时计入账号统计成本,渠道按 token 重算成本时叠加且不重复计入
- 扣费前校验:创建在上游建成后按确定金额校验余额 / 订阅额度 /
  API Key 额度并登记不封顶的在途预留,不足时删除上游缓存、只收输入费;
  延长在调用上游前按增量时长校验
- 有效期上限 gateway.gemini_cached_content_max_ttl_seconds,默认 86400,
  有效期向上取整到秒;创建请求保留数字原文、兼容 snake_case 元数据字段
- 上游调用与落库同客户端连接脱钩;2xx 不可用响应不换号并删除已建缓存
- 缓存不存在:AI Studio 的 403、Vertex 生成请求的 400 与缓存资源接口的
  404 统一识别;原生转发(含流式)不重试、不换号、不改账号状态,
  向客户端返回标准 403,不暴露上游缓存资源 ID,上游原始错误登记到 ops;
  PATCH 遇上游 404 移除本地记录并返回 403,DELETE 视为成功
- 自建网关上游:账号开关 explicit_cache_upstream,缓存接口发往账号自身
  base_url;可选 explicit_cache_upstream_max_ttl_seconds 对齐上游 TTL 上限,
  显式 TTL 超限时创建跳过该账号(全部超限返回 400),未显式时按上限创建,
  PATCH 取本站与账号上限的较小值;绑定缓存的请求在网关账号上收到
  429 / 5xx 时不重试、不限流该账号;前端创建 / 编辑账号弹窗新增开关与上限输入框
- 用量与 ops 端点统计:带缓存 ID 的路径归一化为 /v1beta/cachedContents,
  缓存操作的上游端点记为 /v1beta/cachedContents
2026-10-10 23:37:47 +08:00
feeeei 3bfd40f5af fix(gemini): 引用缓存不存在的 403 不再停用账号并换号
上游对不存在、已过期、已删除或不属于当前项目的 cachedContent 一律返回
403 "CachedContent not found (or permission denied)"。原生转发路径在错误
策略之前识别该响应,原样返回客户端,不标记账号错误、不临时停调度、
不重试、不换号;其余 403 处理不变。
2026-10-10 23:36:17 +08:00
obstacle-Sweden-Avicii 0f189af8de fix(groups): ignore stale RPM override loads 2026-10-10 19:44:19 +08:00
wucm667 ce6242fca1 fix(quota): recognize international coding endpoints 2026-10-10 14:47:11 +08:00
hutututtt 66b7ce08e7 fix(frontend): show full account group names 2026-10-10 09:48:09 +08:00
wucm667 29b2d721f4 test(web): use shipped SVG asset in embedded frontend tests 2026-10-10 08:39:49 +08:00
chihao.ou 5753552b95 fix(gemini): preserve authorization URL error messages 2026-10-09 20:07:28 +08:00
chihao.ou 8e3d75b57b fix(monitor): refresh cached availability on automatic reload 2026-10-09 20:05:17 +08:00
chihao.ou bd2f53b855 fix(groups): reject nonpositive rate multiplier edits 2026-10-09 20:04:44 +08:00
chihao.ou d01628975d fix(select): preserve keyboard input during IME composition 2026-10-09 20:00:54 +08:00
chihao.ou 2bfc49477c fix(ops): initialize custom time ranges in local time 2026-10-09 20:00:02 +08:00
wucm667 b686842716 fix(gateway): sync main routing without widening responses retry 2026-10-09 13:36:36 +08:00
feeeei 9a51333101 feat(groups): 分组名称与描述支持多语言
- groups 新增 i18n JSONB 列(迁移 242):语言代码 -> {name, description},只存译文;
  name/description 原列语义不变,仍用于唯一性校验、搜索与各类日志
- 用户侧接口由后端按请求语言输出:取 Accept-Language 中权重最高的一项,先按完整
  语言代码、再按主语言匹配;该语言有译文的字段换成译文,否则保持原字段,且不再带出
  译文表。覆盖密钥、可用分组、订阅(含进度与汇总)、兑换、使用记录、可用渠道、
  模型广场、套餐列表
- 管理端接口一律返回原字段;分组管理接口额外带 i18n 供编辑
- 创建/编辑分组:名称、描述的标题行右侧新增语言下拉,同一个输入框在默认内容与各语言
  版本之间切换;语种跟随前端 availableLocales
- 管理端写入:创建/更新接受 i18n(更新时缺省不修改,传入则整体替换),校验语言代码
  形态与名称长度并丢弃空译文;简易模式同样放行;复制分组只带走描述译文
- 切换界面语言后整页刷新,让已加载的数据换成新语言;模型广场响应加
  Vary: Accept-Language
- 网关路径,以及只带 group_name 字符串的统计、错误记录出口不做本地化
2026-10-09 11:39:18 +08:00
github-actions[bot] 3a6fd1c9db chore: sync VERSION to 0.2.15 [skip ci] 2026-10-09 03:36:07 +00:00
xcrong d56f5e7879 feat(gateway): adapt Claude Haiku 5.5
Closes #7919. Wire claude-haiku-5-5 (released 2026-10-07) across model
registry, effort catalog, pricing, billing, validation and Codex
descriptors, following the opus-5-5/sonnet-5-5 template and the official
migration guide + overview specs.

- registry: DefaultModels entry (fixed ID, no date suffix per guide)
- effort: full low/medium/high/xhigh/max family + IsHaiku55 +
  claude-haiku-5.5 dotted normalization (official default: medium)
- pricing catalog: 1M/128k limits, base (<=100k) prices and
  *_above_100k_tokens absolute prices; the existing ladder derivation
  yields threshold=100000 with uniform 5x multipliers, matching the
  official two-tier table exactly (cache tiers follow input x5)
- billing/pricing fallbacks carry the same tier so a missing catalog
  entry can never silently bill >100k prompts at base rate
- validation: adaptive-only thinking (enabled always 400; disabled 400
  at xhigh/max), sonnet-style sampling locks, temp+top_p combo rejected,
  forced tool_choice explicitly allowed (differs from opus/sonnet)
- oauth mimicry: skip temperature:1 top-up when top_p is present on
  sampling-locked models (sonnet-5-5 included), otherwise the gateway
  manufactures the temp+top_p 400 itself
- toolset beta filter + Codex 1M context cover haiku-5-5
- Bedrock/Antigravity mappings intentionally left out (no 5.5 entries
  exist there at all, same as opus-5-5); Vertex passes the fixed ID
  through untouched
2026-10-09 10:07:36 +08:00
yan9651688 ba280b3d5e feat(ops): show account names in request details table 2026-10-08 01:10:22 -07:00
wucm667 df3c38e10f chore: align branch with current upstream main 2026-10-08 08:46:25 +08:00
wucm667 07d51a6aef chore: sync upstream security updates 2026-10-08 08:46:24 +08:00
wucm667 daa7915749 fix(deps): update SheetJS past expired security exceptions 2026-10-07 08:50:14 +08:00
wucm667 63800c50d4 fix(gateway): guard structured-input fallback against unsafe retries 2026-10-07 08:50:13 +08:00
wucm667 f7f1112cda fix(deps): update Vue and source map security patches 2026-10-06 10:49:15 +08:00
wucm667 c15d858eca fix(gateway): preserve Responses semantics in chat fallback 2026-10-06 10:49:15 +08:00
feeeei a46249aac5 test(account): 补充临时不可调度作用范围单元测试
- 后端:scope 解析归一化、缺省/显式账号级/显式模型级/模型未知兜底的分流判定,
  以及显式账号级、显式模型级 401、模型未知三种命中的落库位置与状态 JSON
- 前端:未设置 scope 的存量规则按 401 账号级、其余模型级回显并原样保存,
  显式 scope 回显与切换后提交,新增规则默认模型级
2026-10-03 12:08:05 +08:00
feeeei 5a25507dcb feat(account): 临时不可调度规则新增作用范围 scope 选项
- 规则新增 scope 字段:account 命中后暂停整个账号,model 只冷却本次请求的模型;
  字段缺省时保持现有语义(模型已知且非 401 走模型级,否则账号级),显式取值由运营人员决定,401 也不例外
- 模型未知的路径按账号级兜底;状态 JSON 记录 scope/model 便于排查
- 创建/编辑账号弹窗的规则编辑器新增作用范围单选卡片,新规则默认模型级;
  未设置 scope 的存量规则按后端缺省语义回显(401 账号级、其余模型级),打开再保存不改变行为
2026-10-03 12:08:05 +08:00
wucm667 983cf297da fix(gateway): retry rejected structured input through chat endpoint 2026-10-02 17:16:37 +08:00
r266-tech 69d6d106fd fix(monitor): allow adding missing platforms in v2 settings 2026-09-30 06:56:15 +08:00
chihao.ou c4224045dc fix(payment): calculate QR countdowns from elapsed time 2026-09-30 02:17:47 +08:00
wucm667 27de27ac64 fix(risk-control): remove deleted groups from moderation scope 2026-09-29 23:24:45 +08:00
a408665321 a56720855c fix(ui): refresh countdown when tab becomes visible 2026-09-29 11:51:09 +08:00
Yuan LiuandClaude Opus 5.5 f48866958b fix(openai): only inject the Codex image generation bridge for OpenAI accounts
The hosted image_generation tool is OpenAI-only. With the global switch or
a channel's openai bridge flag enabled, isCodexImageGenerationBridgeEnabled
also returned true for Kimi / DeepSeek / MiniMax accounts that forward
natively over Responses, so their upstream requests carried the tool,
tool_choice and the bridge instructions and were rejected with 400.

Return false for nil and non-OpenAI accounts before evaluating the
account / channel / global settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-28 14:59:35 +08:00
299 changed files with 18286 additions and 1423 deletions
+1
View File
@@ -5,6 +5,7 @@ FRONTEND_CRITICAL_VITEST := \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/keys.bulkUpdate.spec.ts \
src/composables/__tests__/useModelWhitelist.spec.ts \
src/components/account/__tests__/OpenAIReferralCell.spec.ts \
src/components/account/__tests__/OpenAIReferralCell.transport.spec.ts \
src/components/account/__tests__/OpenAIQuotaResetCell.spark_shadow.spec.ts \
+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.14
0.2.16
+7
View File
@@ -104,6 +104,7 @@ func provideCleanup(
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
idempotencyCleanup *service.IdempotencyCleanupService,
geminiCachedContent *service.GeminiCachedContentService,
batchImageCleanup *service.BatchImageCleanupService,
batchImageWorker *service.BatchImageWorkerRuntime,
pricing *service.PricingService,
@@ -244,6 +245,12 @@ func provideCleanup(
}
return nil
}},
{"GeminiCachedContentService", func() error {
if geminiCachedContent != nil {
geminiCachedContent.Stop()
}
return nil
}},
{"BatchImageCleanupService", func() error {
if batchImageCleanup != nil {
batchImageCleanup.Stop()
+11 -2
View File
@@ -296,7 +296,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
geminiCachedContentRepository := repository.NewGeminiCachedContentRepository(db)
geminiCachedContentService := service.ProvideGeminiCachedContentService(geminiCachedContentRepository, geminiMessagesCompatService, configConfig)
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator, geminiCachedContentService)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator, compositeRouteResolver)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
@@ -353,7 +355,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, claudeCodeVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, geminiCachedContentService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, openCodeGoUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -413,6 +415,7 @@ func provideCleanup(
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
idempotencyCleanup *service.IdempotencyCleanupService,
geminiCachedContent *service.GeminiCachedContentService,
batchImageCleanup *service.BatchImageCleanupService,
batchImageWorker *service.BatchImageWorkerRuntime,
pricing *service.PricingService,
@@ -552,6 +555,12 @@ func provideCleanup(
}
return nil
}},
{"GeminiCachedContentService", func() error {
if geminiCachedContent != nil {
geminiCachedContent.Stop()
}
return nil
}},
{"BatchImageCleanupService", func() error {
if batchImageCleanup != nil {
batchImageCleanup.Stop()
+1
View File
@@ -74,6 +74,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
subscriptionExpirySvc,
&service.UsageCleanupService{},
idempotencyCleanupSvc,
service.NewGeminiCachedContentService(nil, nil, cfg),
&service.BatchImageCleanupService{},
nil, // batchImageWorker
pricingSvc,
+14 -1
View File
@@ -30,6 +30,8 @@ type Group struct {
Name string `json:"name,omitempty"`
// Description holds the value of the "description" field.
Description *string `json:"description,omitempty"`
// 名称与描述的界面语言译文:语言代码 -> {name, description};缺失的语言沿用 name/description
I18n domain.GroupI18n `json:"i18n,omitempty"`
// RateMultiplier holds the value of the "rate_multiplier" field.
RateMultiplier float64 `json:"rate_multiplier,omitempty"`
// 是否启用高峰时段倍率
@@ -258,7 +260,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
case group.FieldI18n, group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
values[i] = new([]byte)
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
values[i] = new(sql.NullBool)
@@ -323,6 +325,14 @@ func (_m *Group) assignValues(columns []string, values []any) error {
_m.Description = new(string)
*_m.Description = value.String
}
case group.FieldI18n:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field i18n", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.I18n); err != nil {
return fmt.Errorf("unmarshal field i18n: %w", err)
}
}
case group.FieldRateMultiplier:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field rate_multiplier", values[i])
@@ -817,6 +827,9 @@ func (_m *Group) String() string {
builder.WriteString(*v)
}
builder.WriteString(", ")
builder.WriteString("i18n=")
builder.WriteString(fmt.Sprintf("%v", _m.I18n))
builder.WriteString(", ")
builder.WriteString("rate_multiplier=")
builder.WriteString(fmt.Sprintf("%v", _m.RateMultiplier))
builder.WriteString(", ")
+5
View File
@@ -26,6 +26,8 @@ const (
FieldName = "name"
// FieldDescription holds the string denoting the description field in the database.
FieldDescription = "description"
// FieldI18n holds the string denoting the i18n field in the database.
FieldI18n = "i18n"
// FieldRateMultiplier holds the string denoting the rate_multiplier field in the database.
FieldRateMultiplier = "rate_multiplier"
// FieldPeakRateEnabled holds the string denoting the peak_rate_enabled field in the database.
@@ -228,6 +230,7 @@ var Columns = []string{
FieldDeletedAt,
FieldName,
FieldDescription,
FieldI18n,
FieldRateMultiplier,
FieldPeakRateEnabled,
FieldPeakStart,
@@ -326,6 +329,8 @@ var (
UpdateDefaultUpdatedAt func() time.Time
// NameValidator is a validator for the "name" field. It is called by the builders before save.
NameValidator func(string) error
// DefaultI18n holds the default value on creation for the "i18n" field.
DefaultI18n domain.GroupI18n
// DefaultRateMultiplier holds the default value on creation for the "rate_multiplier" field.
DefaultRateMultiplier float64
// DefaultPeakRateEnabled holds the default value on creation for the "peak_rate_enabled" field.
+57
View File
@@ -92,6 +92,12 @@ func (_c *GroupCreate) SetNillableDescription(v *string) *GroupCreate {
return _c
}
// SetI18n sets the "i18n" field.
func (_c *GroupCreate) SetI18n(v domain.GroupI18n) *GroupCreate {
_c.mutation.SetI18n(v)
return _c
}
// SetRateMultiplier sets the "rate_multiplier" field.
func (_c *GroupCreate) SetRateMultiplier(v float64) *GroupCreate {
_c.mutation.SetRateMultiplier(v)
@@ -1047,6 +1053,10 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultUpdatedAt()
_c.mutation.SetUpdatedAt(v)
}
if _, ok := _c.mutation.I18n(); !ok {
v := group.DefaultI18n
_c.mutation.SetI18n(v)
}
if _, ok := _c.mutation.RateMultiplier(); !ok {
v := group.DefaultRateMultiplier
_c.mutation.SetRateMultiplier(v)
@@ -1230,6 +1240,9 @@ func (_c *GroupCreate) check() error {
return &ValidationError{Name: "name", err: fmt.Errorf(`ent: validator failed for field "Group.name": %w`, err)}
}
}
if _, ok := _c.mutation.I18n(); !ok {
return &ValidationError{Name: "i18n", err: errors.New(`ent: missing required field "Group.i18n"`)}
}
if _, ok := _c.mutation.RateMultiplier(); !ok {
return &ValidationError{Name: "rate_multiplier", err: errors.New(`ent: missing required field "Group.rate_multiplier"`)}
}
@@ -1465,6 +1478,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldDescription, field.TypeString, value)
_node.Description = &value
}
if value, ok := _c.mutation.I18n(); ok {
_spec.SetField(group.FieldI18n, field.TypeJSON, value)
_node.I18n = value
}
if value, ok := _c.mutation.RateMultiplier(); ok {
_spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value)
_node.RateMultiplier = value
@@ -1925,6 +1942,18 @@ func (u *GroupUpsert) ClearDescription() *GroupUpsert {
return u
}
// SetI18n sets the "i18n" field.
func (u *GroupUpsert) SetI18n(v domain.GroupI18n) *GroupUpsert {
u.Set(group.FieldI18n, v)
return u
}
// UpdateI18n sets the "i18n" field to the value that was provided on create.
func (u *GroupUpsert) UpdateI18n() *GroupUpsert {
u.SetExcluded(group.FieldI18n)
return u
}
// SetRateMultiplier sets the "rate_multiplier" field.
func (u *GroupUpsert) SetRateMultiplier(v float64) *GroupUpsert {
u.Set(group.FieldRateMultiplier, v)
@@ -3039,6 +3068,20 @@ func (u *GroupUpsertOne) ClearDescription() *GroupUpsertOne {
})
}
// SetI18n sets the "i18n" field.
func (u *GroupUpsertOne) SetI18n(v domain.GroupI18n) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetI18n(v)
})
}
// UpdateI18n sets the "i18n" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateI18n() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateI18n()
})
}
// SetRateMultiplier sets the "rate_multiplier" field.
func (u *GroupUpsertOne) SetRateMultiplier(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -4485,6 +4528,20 @@ func (u *GroupUpsertBulk) ClearDescription() *GroupUpsertBulk {
})
}
// SetI18n sets the "i18n" field.
func (u *GroupUpsertBulk) SetI18n(v domain.GroupI18n) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetI18n(v)
})
}
// UpdateI18n sets the "i18n" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateI18n() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateI18n()
})
}
// SetRateMultiplier sets the "rate_multiplier" field.
func (u *GroupUpsertBulk) SetRateMultiplier(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+18
View File
@@ -97,6 +97,12 @@ func (_u *GroupUpdate) ClearDescription() *GroupUpdate {
return _u
}
// SetI18n sets the "i18n" field.
func (_u *GroupUpdate) SetI18n(v domain.GroupI18n) *GroupUpdate {
_u.mutation.SetI18n(v)
return _u
}
// SetRateMultiplier sets the "rate_multiplier" field.
func (_u *GroupUpdate) SetRateMultiplier(v float64) *GroupUpdate {
_u.mutation.ResetRateMultiplier()
@@ -1581,6 +1587,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.DescriptionCleared() {
_spec.ClearField(group.FieldDescription, field.TypeString)
}
if value, ok := _u.mutation.I18n(); ok {
_spec.SetField(group.FieldI18n, field.TypeJSON, value)
}
if value, ok := _u.mutation.RateMultiplier(); ok {
_spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value)
}
@@ -2291,6 +2300,12 @@ func (_u *GroupUpdateOne) ClearDescription() *GroupUpdateOne {
return _u
}
// SetI18n sets the "i18n" field.
func (_u *GroupUpdateOne) SetI18n(v domain.GroupI18n) *GroupUpdateOne {
_u.mutation.SetI18n(v)
return _u
}
// SetRateMultiplier sets the "rate_multiplier" field.
func (_u *GroupUpdateOne) SetRateMultiplier(v float64) *GroupUpdateOne {
_u.mutation.ResetRateMultiplier()
@@ -3805,6 +3820,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if _u.mutation.DescriptionCleared() {
_spec.ClearField(group.FieldDescription, field.TypeString)
}
if value, ok := _u.mutation.I18n(); ok {
_spec.SetField(group.FieldI18n, field.TypeJSON, value)
}
if value, ok := _u.mutation.RateMultiplier(); ok {
_spec.SetField(group.FieldRateMultiplier, field.TypeFloat64, value)
}
+7 -6
View File
@@ -908,6 +908,7 @@ var (
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "description", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
{Name: "i18n", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "rate_multiplier", Type: field.TypeFloat64, Default: 1, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "peak_rate_enabled", Type: field.TypeBool, Default: false},
{Name: "peak_start", Type: field.TypeString, Size: 5, Default: ""},
@@ -979,22 +980,22 @@ var (
{
Name: "group_status",
Unique: false,
Columns: []*schema.Column{GroupsColumns[12]},
Columns: []*schema.Column{GroupsColumns[13]},
},
{
Name: "group_platform",
Unique: false,
Columns: []*schema.Column{GroupsColumns[14]},
Columns: []*schema.Column{GroupsColumns[15]},
},
{
Name: "group_subscription_type",
Unique: false,
Columns: []*schema.Column{GroupsColumns[15]},
Columns: []*schema.Column{GroupsColumns[16]},
},
{
Name: "group_is_exclusive",
Unique: false,
Columns: []*schema.Column{GroupsColumns[11]},
Columns: []*schema.Column{GroupsColumns[12]},
},
{
Name: "group_deleted_at",
@@ -1004,12 +1005,12 @@ var (
{
Name: "group_sort_order",
Unique: false,
Columns: []*schema.Column{GroupsColumns[49]},
Columns: []*schema.Column{GroupsColumns[50]},
},
{
Name: "idx_groups_duplicate_operation_id_active",
Unique: true,
Columns: []*schema.Column{GroupsColumns[13]},
Columns: []*schema.Column{GroupsColumns[14]},
Annotation: &entsql.IndexAnnotation{
Where: "duplicate_operation_id IS NOT NULL AND deleted_at IS NULL",
},
+55 -1
View File
@@ -22086,6 +22086,7 @@ type GroupMutation struct {
deleted_at *time.Time
name *string
description *string
i18n *domain.GroupI18n
rate_multiplier *float64
addrate_multiplier *float64
peak_rate_enabled *bool
@@ -22505,6 +22506,42 @@ func (m *GroupMutation) ResetDescription() {
delete(m.clearedFields, group.FieldDescription)
}
// SetI18n sets the "i18n" field.
func (m *GroupMutation) SetI18n(di domain.GroupI18n) {
m.i18n = &di
}
// I18n returns the value of the "i18n" field in the mutation.
func (m *GroupMutation) I18n() (r domain.GroupI18n, exists bool) {
v := m.i18n
if v == nil {
return
}
return *v, true
}
// OldI18n returns the old "i18n" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldI18n(ctx context.Context) (v domain.GroupI18n, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldI18n is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldI18n requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldI18n: %w", err)
}
return oldValue.I18n, nil
}
// ResetI18n resets all changes to the "i18n" field.
func (m *GroupMutation) ResetI18n() {
m.i18n = nil
}
// SetRateMultiplier sets the "rate_multiplier" field.
func (m *GroupMutation) SetRateMultiplier(f float64) {
m.rate_multiplier = &f
@@ -25921,7 +25958,7 @@ func (m *GroupMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *GroupMutation) Fields() []string {
fields := make([]string, 0, 66)
fields := make([]string, 0, 67)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -25937,6 +25974,9 @@ func (m *GroupMutation) Fields() []string {
if m.description != nil {
fields = append(fields, group.FieldDescription)
}
if m.i18n != nil {
fields = append(fields, group.FieldI18n)
}
if m.rate_multiplier != nil {
fields = append(fields, group.FieldRateMultiplier)
}
@@ -26138,6 +26178,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.Name()
case group.FieldDescription:
return m.Description()
case group.FieldI18n:
return m.I18n()
case group.FieldRateMultiplier:
return m.RateMultiplier()
case group.FieldPeakRateEnabled:
@@ -26279,6 +26321,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldName(ctx)
case group.FieldDescription:
return m.OldDescription(ctx)
case group.FieldI18n:
return m.OldI18n(ctx)
case group.FieldRateMultiplier:
return m.OldRateMultiplier(ctx)
case group.FieldPeakRateEnabled:
@@ -26445,6 +26489,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetDescription(v)
return nil
case group.FieldI18n:
v, ok := value.(domain.GroupI18n)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetI18n(v)
return nil
case group.FieldRateMultiplier:
v, ok := value.(float64)
if !ok {
@@ -27398,6 +27449,9 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldDescription:
m.ResetDescription()
return nil
case group.FieldI18n:
m.ResetI18n()
return nil
case group.FieldRateMultiplier:
m.ResetRateMultiplier()
return nil
+50 -46
View File
@@ -1037,204 +1037,208 @@ func init() {
return nil
}
}()
// groupDescI18n is the schema descriptor for i18n field.
groupDescI18n := groupFields[2].Descriptor()
// group.DefaultI18n holds the default value on creation for the i18n field.
group.DefaultI18n = groupDescI18n.Default.(domain.GroupI18n)
// groupDescRateMultiplier is the schema descriptor for rate_multiplier field.
groupDescRateMultiplier := groupFields[2].Descriptor()
groupDescRateMultiplier := groupFields[3].Descriptor()
// group.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
group.DefaultRateMultiplier = groupDescRateMultiplier.Default.(float64)
// groupDescPeakRateEnabled is the schema descriptor for peak_rate_enabled field.
groupDescPeakRateEnabled := groupFields[3].Descriptor()
groupDescPeakRateEnabled := groupFields[4].Descriptor()
// group.DefaultPeakRateEnabled holds the default value on creation for the peak_rate_enabled field.
group.DefaultPeakRateEnabled = groupDescPeakRateEnabled.Default.(bool)
// groupDescPeakStart is the schema descriptor for peak_start field.
groupDescPeakStart := groupFields[4].Descriptor()
groupDescPeakStart := groupFields[5].Descriptor()
// group.DefaultPeakStart holds the default value on creation for the peak_start field.
group.DefaultPeakStart = groupDescPeakStart.Default.(string)
// group.PeakStartValidator is a validator for the "peak_start" field. It is called by the builders before save.
group.PeakStartValidator = groupDescPeakStart.Validators[0].(func(string) error)
// groupDescPeakEnd is the schema descriptor for peak_end field.
groupDescPeakEnd := groupFields[5].Descriptor()
groupDescPeakEnd := groupFields[6].Descriptor()
// group.DefaultPeakEnd holds the default value on creation for the peak_end field.
group.DefaultPeakEnd = groupDescPeakEnd.Default.(string)
// group.PeakEndValidator is a validator for the "peak_end" field. It is called by the builders before save.
group.PeakEndValidator = groupDescPeakEnd.Validators[0].(func(string) error)
// groupDescPeakRateMultiplier is the schema descriptor for peak_rate_multiplier field.
groupDescPeakRateMultiplier := groupFields[6].Descriptor()
groupDescPeakRateMultiplier := groupFields[7].Descriptor()
// group.DefaultPeakRateMultiplier holds the default value on creation for the peak_rate_multiplier field.
group.DefaultPeakRateMultiplier = groupDescPeakRateMultiplier.Default.(float64)
// groupDescIsExclusive is the schema descriptor for is_exclusive field.
groupDescIsExclusive := groupFields[7].Descriptor()
groupDescIsExclusive := groupFields[8].Descriptor()
// group.DefaultIsExclusive holds the default value on creation for the is_exclusive field.
group.DefaultIsExclusive = groupDescIsExclusive.Default.(bool)
// groupDescStatus is the schema descriptor for status field.
groupDescStatus := groupFields[8].Descriptor()
groupDescStatus := groupFields[9].Descriptor()
// group.DefaultStatus holds the default value on creation for the status field.
group.DefaultStatus = groupDescStatus.Default.(string)
// group.StatusValidator is a validator for the "status" field. It is called by the builders before save.
group.StatusValidator = groupDescStatus.Validators[0].(func(string) error)
// groupDescDuplicateOperationID is the schema descriptor for duplicate_operation_id field.
groupDescDuplicateOperationID := groupFields[9].Descriptor()
groupDescDuplicateOperationID := groupFields[10].Descriptor()
// group.DuplicateOperationIDValidator is a validator for the "duplicate_operation_id" field. It is called by the builders before save.
group.DuplicateOperationIDValidator = groupDescDuplicateOperationID.Validators[0].(func(string) error)
// groupDescPlatform is the schema descriptor for platform field.
groupDescPlatform := groupFields[10].Descriptor()
groupDescPlatform := groupFields[11].Descriptor()
// group.DefaultPlatform holds the default value on creation for the platform field.
group.DefaultPlatform = groupDescPlatform.Default.(string)
// group.PlatformValidator is a validator for the "platform" field. It is called by the builders before save.
group.PlatformValidator = groupDescPlatform.Validators[0].(func(string) error)
// groupDescSubscriptionType is the schema descriptor for subscription_type field.
groupDescSubscriptionType := groupFields[11].Descriptor()
groupDescSubscriptionType := groupFields[12].Descriptor()
// group.DefaultSubscriptionType holds the default value on creation for the subscription_type field.
group.DefaultSubscriptionType = groupDescSubscriptionType.Default.(string)
// group.SubscriptionTypeValidator is a validator for the "subscription_type" field. It is called by the builders before save.
group.SubscriptionTypeValidator = groupDescSubscriptionType.Validators[0].(func(string) error)
// groupDescDefaultValidityDays is the schema descriptor for default_validity_days field.
groupDescDefaultValidityDays := groupFields[15].Descriptor()
groupDescDefaultValidityDays := groupFields[16].Descriptor()
// group.DefaultDefaultValidityDays holds the default value on creation for the default_validity_days field.
group.DefaultDefaultValidityDays = groupDescDefaultValidityDays.Default.(int)
// groupDescAllowImageGeneration is the schema descriptor for allow_image_generation field.
groupDescAllowImageGeneration := groupFields[16].Descriptor()
groupDescAllowImageGeneration := groupFields[17].Descriptor()
// group.DefaultAllowImageGeneration holds the default value on creation for the allow_image_generation field.
group.DefaultAllowImageGeneration = groupDescAllowImageGeneration.Default.(bool)
// groupDescAllowBatchImageGeneration is the schema descriptor for allow_batch_image_generation field.
groupDescAllowBatchImageGeneration := groupFields[17].Descriptor()
groupDescAllowBatchImageGeneration := groupFields[18].Descriptor()
// group.DefaultAllowBatchImageGeneration holds the default value on creation for the allow_batch_image_generation field.
group.DefaultAllowBatchImageGeneration = groupDescAllowBatchImageGeneration.Default.(bool)
// groupDescImageRateIndependent is the schema descriptor for image_rate_independent field.
groupDescImageRateIndependent := groupFields[18].Descriptor()
groupDescImageRateIndependent := groupFields[19].Descriptor()
// group.DefaultImageRateIndependent holds the default value on creation for the image_rate_independent field.
group.DefaultImageRateIndependent = groupDescImageRateIndependent.Default.(bool)
// groupDescImageRateMultiplier is the schema descriptor for image_rate_multiplier field.
groupDescImageRateMultiplier := groupFields[19].Descriptor()
groupDescImageRateMultiplier := groupFields[20].Descriptor()
// group.DefaultImageRateMultiplier holds the default value on creation for the image_rate_multiplier field.
group.DefaultImageRateMultiplier = groupDescImageRateMultiplier.Default.(float64)
// groupDescBatchImageDiscountMultiplier is the schema descriptor for batch_image_discount_multiplier field.
groupDescBatchImageDiscountMultiplier := groupFields[23].Descriptor()
groupDescBatchImageDiscountMultiplier := groupFields[24].Descriptor()
// group.DefaultBatchImageDiscountMultiplier holds the default value on creation for the batch_image_discount_multiplier field.
group.DefaultBatchImageDiscountMultiplier = groupDescBatchImageDiscountMultiplier.Default.(float64)
// groupDescBatchImageHoldMultiplier is the schema descriptor for batch_image_hold_multiplier field.
groupDescBatchImageHoldMultiplier := groupFields[24].Descriptor()
groupDescBatchImageHoldMultiplier := groupFields[25].Descriptor()
// group.DefaultBatchImageHoldMultiplier holds the default value on creation for the batch_image_hold_multiplier field.
group.DefaultBatchImageHoldMultiplier = groupDescBatchImageHoldMultiplier.Default.(float64)
// groupDescVideoRateIndependent is the schema descriptor for video_rate_independent field.
groupDescVideoRateIndependent := groupFields[25].Descriptor()
groupDescVideoRateIndependent := groupFields[26].Descriptor()
// group.DefaultVideoRateIndependent holds the default value on creation for the video_rate_independent field.
group.DefaultVideoRateIndependent = groupDescVideoRateIndependent.Default.(bool)
// groupDescVideoRateMultiplier is the schema descriptor for video_rate_multiplier field.
groupDescVideoRateMultiplier := groupFields[26].Descriptor()
groupDescVideoRateMultiplier := groupFields[27].Descriptor()
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
// groupDescSearchPricePer1k is the schema descriptor for search_price_per_1k field.
groupDescSearchPricePer1k := groupFields[32].Descriptor()
groupDescSearchPricePer1k := groupFields[33].Descriptor()
// group.SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save.
group.SearchPricePer1kValidator = groupDescSearchPricePer1k.Validators[0].(func(float64) error)
// groupDescAudioRealtimePricePerMin is the schema descriptor for audio_realtime_price_per_min field.
groupDescAudioRealtimePricePerMin := groupFields[33].Descriptor()
groupDescAudioRealtimePricePerMin := groupFields[34].Descriptor()
// group.AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save.
group.AudioRealtimePricePerMinValidator = groupDescAudioRealtimePricePerMin.Validators[0].(func(float64) error)
// groupDescAudioTtsPricePerMillionChars is the schema descriptor for audio_tts_price_per_million_chars field.
groupDescAudioTtsPricePerMillionChars := groupFields[34].Descriptor()
groupDescAudioTtsPricePerMillionChars := groupFields[35].Descriptor()
// group.AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save.
group.AudioTtsPricePerMillionCharsValidator = groupDescAudioTtsPricePerMillionChars.Validators[0].(func(float64) error)
// groupDescAudioSttPricePerHour is the schema descriptor for audio_stt_price_per_hour field.
groupDescAudioSttPricePerHour := groupFields[35].Descriptor()
groupDescAudioSttPricePerHour := groupFields[36].Descriptor()
// group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error)
// groupDescLongContextPricingEnabled is the schema descriptor for long_context_pricing_enabled field.
groupDescLongContextPricingEnabled := groupFields[36].Descriptor()
groupDescLongContextPricingEnabled := groupFields[37].Descriptor()
// group.DefaultLongContextPricingEnabled holds the default value on creation for the long_context_pricing_enabled field.
group.DefaultLongContextPricingEnabled = groupDescLongContextPricingEnabled.Default.(bool)
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
groupDescClaudeCodeOnly := groupFields[38].Descriptor()
groupDescClaudeCodeOnly := groupFields[39].Descriptor()
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
groupDescModelRoutingEnabled := groupFields[42].Descriptor()
groupDescModelRoutingEnabled := groupFields[43].Descriptor()
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
groupDescMcpXMLInject := groupFields[43].Descriptor()
groupDescMcpXMLInject := groupFields[44].Descriptor()
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
groupDescSupportedModelScopes := groupFields[44].Descriptor()
groupDescSupportedModelScopes := groupFields[45].Descriptor()
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
// groupDescSortOrder is the schema descriptor for sort_order field.
groupDescSortOrder := groupFields[45].Descriptor()
groupDescSortOrder := groupFields[46].Descriptor()
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
groupDescAllowMessagesDispatch := groupFields[46].Descriptor()
groupDescAllowMessagesDispatch := groupFields[47].Descriptor()
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
// groupDescAllowLive is the schema descriptor for allow_live field.
groupDescAllowLive := groupFields[47].Descriptor()
groupDescAllowLive := groupFields[48].Descriptor()
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
// groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field.
groupDescForceOpenaiFast := groupFields[48].Descriptor()
groupDescForceOpenaiFast := groupFields[49].Descriptor()
// group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field.
group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool)
// groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field.
groupDescFreeOpenaiFast := groupFields[49].Descriptor()
groupDescFreeOpenaiFast := groupFields[50].Descriptor()
// group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field.
group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[50].Descriptor()
groupDescRequireOauthOnly := groupFields[51].Descriptor()
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
groupDescRequirePrivacySet := groupFields[51].Descriptor()
groupDescRequirePrivacySet := groupFields[52].Descriptor()
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
groupDescDefaultMappedModel := groupFields[52].Descriptor()
groupDescDefaultMappedModel := groupFields[53].Descriptor()
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
groupDescMessagesDispatchModelConfig := groupFields[53].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[54].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescModelAllowlist is the schema descriptor for model_allowlist field.
groupDescModelAllowlist := groupFields[54].Descriptor()
groupDescModelAllowlist := groupFields[55].Descriptor()
// group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field.
group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist)
// groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field.
groupDescCodexModelsManifestConfig := groupFields[55].Descriptor()
groupDescCodexModelsManifestConfig := groupFields[56].Descriptor()
// group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field.
group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
groupDescRpmLimit := groupFields[56].Descriptor()
groupDescRpmLimit := groupFields[57].Descriptor()
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
groupDescMaxReasoningEffort := groupFields[57].Descriptor()
groupDescMaxReasoningEffort := groupFields[58].Descriptor()
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor()
groupDescMaxReasoningEffortOverLimit := groupFields[59].Descriptor()
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
groupDescReasoningEffortMappings := groupFields[59].Descriptor()
groupDescReasoningEffortMappings := groupFields[60].Descriptor()
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
groupDescProfitControlEnabled := groupFields[60].Descriptor()
groupDescProfitControlEnabled := groupFields[61].Descriptor()
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
groupDescProfitMinMargin := groupFields[61].Descriptor()
groupDescProfitMinMargin := groupFields[62].Descriptor()
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
groupDescProfitSafetyBuffer := groupFields[62].Descriptor()
groupDescProfitSafetyBuffer := groupFields[63].Descriptor()
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
+5
View File
@@ -44,6 +44,11 @@ func (Group) Fields() []ent.Field {
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "text"}),
// 名称与描述的界面语言译文 (added by migration 242)
field.JSON("i18n", domain.GroupI18n{}).
Default(domain.GroupI18n{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("名称与描述的界面语言译文:语言代码 -> {name, description};缺失的语言沿用 name/description"),
field.Float("rate_multiplier").
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
Default(1.0),
+11
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"`
@@ -1005,6 +1008,8 @@ type GatewayConfig struct {
ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"`
// Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销)
GeminiDebugResponseHeaders bool `mapstructure:"gemini_debug_response_headers"`
// GeminiCachedContentMaxTTLSeconds: Gemini 显式缓存单次可设置的最长有效期(秒),<=0 时按 86400 处理
GeminiCachedContentMaxTTLSeconds int `mapstructure:"gemini_cached_content_max_ttl_seconds"`
// ConnectionPoolIsolation: 上游连接池隔离策略(proxy/account/account_proxy)
ConnectionPoolIsolation string `mapstructure:"connection_pool_isolation"`
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
@@ -2033,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)
@@ -2090,6 +2096,7 @@ func setDefaults() {
"api.openai.com",
"api.anthropic.com",
"api.kimi.com",
"api.kimi.ai",
"api.moonshot.ai",
"api.moonshot.cn",
"open.bigmodel.cn",
@@ -2527,6 +2534,7 @@ func setDefaults() {
viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes)
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
viper.SetDefault("gateway.gemini_debug_response_headers", false)
viper.SetDefault("gateway.gemini_cached_content_max_ttl_seconds", 86400)
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
// HTTP 上游连接池配置(针对 5000+ 并发用户优化)
viper.SetDefault("gateway.max_idle_conns", 2560) // 最大空闲连接总数(高并发场景可调大)
@@ -2703,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)
+38
View File
@@ -10,6 +10,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
)
@@ -30,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
@@ -831,6 +855,7 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
wantHosts := []string{
"api.kimi.com",
"api.kimi.ai",
"api.moonshot.ai",
"api.moonshot.cn",
}
@@ -843,6 +868,19 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
t.Fatalf("URLAllowlist.UpstreamHosts missing %q; got %v", want, cfg.Security.URLAllowlist.UpstreamHosts)
}
}
opts := urlvalidator.ValidationOptions{
AllowedHosts: cfg.Security.URLAllowlist.UpstreamHosts,
RequireAllowlist: true,
AllowPrivate: cfg.Security.URLAllowlist.AllowPrivateHosts,
}
for _, host := range []string{"api.kimi.com", "api.kimi.ai"} {
target := "https://" + host + "/coding/v1/usages"
normalized, err := urlvalidator.ValidateHTTPSURL(target, opts)
require.NoError(t, err)
require.Equal(t, target, normalized)
_, err = urlvalidator.ValidateHTTPSURL("https://"+host+".attacker.example/coding/v1/usages", opts)
require.Error(t, err)
}
}
func TestLoadDefaultServerMode(t *testing.T) {
+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()
+113
View File
@@ -0,0 +1,113 @@
package domain
import (
"strconv"
"strings"
)
// GroupLocaleText 是分组名称与描述在某个界面语言下的译文。
// 字段为空表示该语言沿用分组自身的 name/description。
type GroupLocaleText struct {
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
}
// GroupI18n 以界面语言代码(如 "en"、"zh")为键保存分组展示文案的译文。
// 仅用于页面展示;分组的唯一性、搜索与各类日志仍以 name 为准。
type GroupI18n map[string]GroupLocaleText
// Localize 返回 name、description 在请求首选语言下的版本;该语言没有译文的字段保持原值。
// acceptLanguage 是 Accept-Language 请求头。只采用其中优先级最高的语言:原字段的语言
// 未知,退到次选语言的译文不一定比原字段更合适。
func (m GroupI18n) Localize(acceptLanguage, name, description string) (string, string) {
text, ok := m.lookup(preferredLanguage(acceptLanguage))
if !ok {
return name, description
}
if text.Name != "" {
name = text.Name
}
if text.Description != "" {
description = text.Description
}
return name, description
}
// LocalizeName 是只需要名称时的 Localize。
func (m GroupI18n) LocalizeName(acceptLanguage, name string) string {
name, _ = m.Localize(acceptLanguage, name, "")
return name
}
// CanonicalLocale 按 BCP 47 的惯用大小写书写语言代码:主语言小写,4 位文字代码首字母大写,
// 2 位地区代码大写,其余小写(zh-hant-tw -> zh-Hant-TW);单字母扩展标记之后的部分全部小写。
// 只调整大小写,不校验代码是否合法。
func CanonicalLocale(locale string) string {
subtags := strings.Split(locale, "-")
extension := false
for i, subtag := range subtags {
subtag = strings.ToLower(subtag)
switch {
case i == 0 || extension:
case len(subtag) == 1:
extension = true
case len(subtag) == 2:
subtag = strings.ToUpper(subtag)
case len(subtag) == 4:
subtag = strings.ToUpper(subtag[:1]) + subtag[1:]
}
subtags[i] = subtag
}
return strings.Join(subtags, "-")
}
// lookup 先按完整语言代码匹配(zh-TW),再按主语言匹配(zh-CN -> zh),不区分大小写。
// 写入时语言代码已统一为 CanonicalLocale,同一语言只有一个键;若存有仅大小写不同的多个键,
// 规范写法的键优先,其余按字节序取最小者,保证同一请求语言的结果固定。
func (m GroupI18n) lookup(tag string) (GroupLocaleText, bool) {
if tag == "" || len(m) == 0 {
return GroupLocaleText{}, false
}
primary, _, _ := strings.Cut(tag, "-")
for _, candidate := range []string{tag, primary} {
candidate = CanonicalLocale(candidate)
if text, ok := m[candidate]; ok {
return text, true
}
matched := ""
for locale := range m {
if strings.EqualFold(locale, candidate) && (matched == "" || locale < matched) {
matched = locale
}
}
if matched != "" {
return m[matched], true
}
}
return GroupLocaleText{}, false
}
// preferredLanguage 取 Accept-Language 中权重最高的语言代码,权重相同取靠前者;
// 通配符 * 与 q=0 的条目不参与。
func preferredLanguage(acceptLanguage string) string {
best, bestQ := "", 0.0
for _, part := range strings.Split(acceptLanguage, ",") {
tag, params, _ := strings.Cut(part, ";")
tag = strings.ReplaceAll(strings.TrimSpace(tag), "_", "-")
if tag == "" || tag == "*" {
continue
}
q := 1.0
if value, ok := strings.CutPrefix(strings.TrimSpace(params), "q="); ok {
parsed, err := strconv.ParseFloat(strings.TrimSpace(value), 64)
if err != nil {
continue
}
q = parsed
}
if q > bestQ {
best, bestQ = tag, q
}
}
return best
}
+107
View File
@@ -0,0 +1,107 @@
package domain
import "testing"
func TestGroupI18nLocalize(t *testing.T) {
i18n := GroupI18n{
"en": {Name: "Standard", Description: "Pro + Max pool"},
"zh-TW": {Name: "標準"},
"ja": {Description: "説明のみ"},
}
const name, description = "标准", "号池"
tests := []struct {
title string
acceptLanguage string
wantName string
wantDescription string
}{
{"exact locale", "en", "Standard", "Pro + Max pool"},
{"region falls back to the primary language", "en-US", "Standard", "Pro + Max pool"},
{"locale match ignores case and underscore", "ZH_tw", "標準", description},
{"a missing field keeps the original text", "ja", name, "説明のみ"},
{"no translation for the language keeps both", "zh", name, description},
{"primary language does not match a regional translation", "zh-CN", name, description},
{"empty header keeps both", "", name, description},
{"wildcard only keeps both", "*", name, description},
// 原字段的语言未知,不退到次选语言:浏览器的 zh 用户不应拿到英文译文。
{"only the preferred language is considered", "zh-CN,zh;q=0.9,en;q=0.8", name, description},
{"highest weight wins over position", "zh;q=0.5, en;q=0.9", "Standard", "Pro + Max pool"},
{"equal weight keeps the first", "en, zh", "Standard", "Pro + Max pool"},
{"entries with q=0 are ignored", "en;q=0, ja", name, "説明のみ"},
{"malformed weight is skipped", "en;q=abc, ja", name, "説明のみ"},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
gotName, gotDescription := i18n.Localize(tt.acceptLanguage, name, description)
if gotName != tt.wantName || gotDescription != tt.wantDescription {
t.Fatalf("Localize(%q) = (%q, %q), want (%q, %q)",
tt.acceptLanguage, gotName, gotDescription, tt.wantName, tt.wantDescription)
}
})
}
}
func TestGroupI18nLocalize_NilMap(t *testing.T) {
var i18n GroupI18n
name, description := i18n.Localize("en", "标准", "号池")
if name != "标准" || description != "号池" {
t.Fatalf("nil translations must keep the original text, got (%q, %q)", name, description)
}
if got := i18n.LocalizeName("en", "标准"); got != "标准" {
t.Fatalf("LocalizeName on nil translations = %q", got)
}
}
func TestGroupI18nLocalizeName(t *testing.T) {
i18n := GroupI18n{"en": {Name: "Standard"}}
if got := i18n.LocalizeName("en", "标准"); got != "Standard" {
t.Fatalf("LocalizeName(en) = %q", got)
}
if got := i18n.LocalizeName("zh", "标准"); got != "标准" {
t.Fatalf("LocalizeName(zh) = %q", got)
}
}
func TestCanonicalLocale(t *testing.T) {
tests := map[string]string{
"en": "en",
"EN": "en",
"zh-tw": "zh-TW",
"ZH-Hant-tw": "zh-Hant-TW",
"es-419": "es-419",
"de-CH-1901": "de-CH-1901",
"en-US-x-Twain": "en-US-x-twain",
}
for in, want := range tests {
if got := CanonicalLocale(in); got != want {
t.Errorf("CanonicalLocale(%q) = %q, want %q", in, got, want)
}
}
}
// 写入路径已拒绝仅大小写不同的语言代码;已存在的此类数据读取时也必须得到固定结果。
func TestGroupI18nLocalize_CaseCollidingKeysAreDeterministic(t *testing.T) {
tests := []struct {
title string
i18n GroupI18n
acceptLanguage string
want string
}{
{"canonical key wins", GroupI18n{"EN": {Name: "B"}, "en": {Name: "A"}, "En": {Name: "C"}}, "en", "A"},
{"otherwise the smallest key wins", GroupI18n{"eN": {Name: "B"}, "EN": {Name: "A"}}, "en", "A"},
{"primary language fallback is deterministic too", GroupI18n{"ZH": {Name: "B"}, "zh": {Name: "A"}}, "zh-CN", "A"},
}
for _, tt := range tests {
t.Run(tt.title, func(t *testing.T) {
for range 200 {
if got := tt.i18n.LocalizeName(tt.acceptLanguage, "原名"); got != tt.want {
t.Fatalf("LocalizeName(%q) = %q, want %q", tt.acceptLanguage, got, tt.want)
}
}
})
}
}
@@ -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 {
@@ -142,6 +142,8 @@ type simpleModeGroupResponse struct {
Platform string `json:"platform"`
Status string `json:"status"`
I18n service.GroupI18n `json:"i18n,omitempty"`
AccountCount int64 `json:"account_count,omitempty"`
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
@@ -157,6 +159,7 @@ func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
return &simpleModeGroupResponse{
ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
Status: group.Status,
I18n: group.I18n,
AccountCount: group.AccountCount,
ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
@@ -167,7 +170,7 @@ func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
if req == nil {
return
}
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, I18n: req.I18n, Platform: req.Platform}
allowed.RateMultiplier = 1
allowed.SubscriptionType = service.SubscriptionTypeStandard
*req = allowed
@@ -177,13 +180,14 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
if req == nil {
return
}
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description, I18n: req.I18n}
}
// CreateGroupRequest represents create group request
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
I18n service.GroupI18n `json:"i18n"` // 名称与描述的界面语言译文
Platform string `json:"platform" binding:"omitempty,group_platform"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
@@ -258,6 +262,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
I18n *service.GroupI18n `json:"i18n"` // 缺省不修改,传入则整体替换
Platform string `json:"platform" binding:"omitempty,group_platform"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
@@ -664,6 +669,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{
Name: req.Name,
Description: req.Description,
I18n: req.I18n,
Platform: req.Platform,
RateMultiplier: req.RateMultiplier,
IsExclusive: req.IsExclusive,
@@ -809,6 +815,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
Name: req.Name,
Description: req.Description,
I18n: req.I18n,
Platform: req.Platform,
RateMultiplier: req.RateMultiplier,
IsExclusive: req.IsExclusive,
+5 -5
View File
@@ -142,7 +142,7 @@ func (h *APIKeyHandler) List(c *gin.Context) {
out := make([]dto.APIKey, 0, len(keys))
for i := range keys {
out = append(out, *dto.APIKeyFromService(&keys[i]))
out = append(out, *dto.APIKeyFromService(&keys[i]).Localized(c.GetHeader("Accept-Language")))
}
response.Paginated(c, out, result.Total, page, pageSize)
}
@@ -174,7 +174,7 @@ func (h *APIKeyHandler) GetByID(c *gin.Context) {
return
}
response.Success(c, dto.APIKeyFromService(key))
response.Success(c, dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language")))
}
// Create handles creating a new API key
@@ -222,7 +222,7 @@ func (h *APIKeyHandler) Create(c *gin.Context) {
if err != nil {
return nil, err
}
return dto.APIKeyFromService(key), nil
return dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language")), nil
})
}
@@ -290,7 +290,7 @@ func (h *APIKeyHandler) Update(c *gin.Context) {
return
}
response.Success(c, dto.APIKeyFromService(key))
response.Success(c, dto.APIKeyFromService(key).Localized(c.GetHeader("Accept-Language")))
}
// Delete handles deleting an API key
@@ -334,7 +334,7 @@ func (h *APIKeyHandler) GetAvailableGroups(c *gin.Context) {
out := make([]dto.Group, 0, len(groups))
for i := range groups {
out = append(out, *dto.GroupFromService(&groups[i]))
out = append(out, *dto.GroupFromService(&groups[i]).Localized(c.GetHeader("Accept-Language")))
}
response.Success(c, out)
}
@@ -62,6 +62,9 @@ type userAvailableGroup struct {
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
// 不对外输出;List 按请求语言把 Name 换成译文后丢弃。
i18n service.GroupI18n
}
// userSupportedModelPricing 用户可见的定价字段白名单。
@@ -154,6 +157,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
return
}
acceptLanguage := c.GetHeader("Accept-Language")
out := make([]userAvailableChannel, 0, len(channels))
for _, ch := range channels {
if ch.Status != service.StatusActive {
@@ -163,6 +167,7 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
if len(visibleGroups) == 0 {
continue
}
localizeUserAvailableGroups(visibleGroups, acceptLanguage)
sections := buildPlatformSections(ch, visibleGroups)
if len(sections) == 0 {
continue
@@ -177,6 +182,13 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
response.Success(c, out)
}
// localizeUserAvailableGroups 把分组名称就地换成请求首选语言的版本。
func localizeUserAvailableGroups(groups []userAvailableGroup, acceptLanguage string) {
for i := range groups {
groups[i].Name = groups[i].i18n.LocalizeName(acceptLanguage, groups[i].Name)
}
}
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
// 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。
//
@@ -254,6 +266,7 @@ func filterUserVisibleGroups(
visible = append(visible, userAvailableGroup{
ID: g.ID,
Name: g.Name,
i18n: g.I18n,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
@@ -0,0 +1,49 @@
package dto
// 用户侧接口在输出前调用 Localized:把分组的名称与描述换成请求首选语言
// (Accept-Language)的版本,并去掉译文表。管理端接口不调用,保留原字段与译文表,
// 分组编辑表单依赖原字段回写。
// Localized 就地本地化分组并返回自身。
func (g *Group) Localized(acceptLanguage string) *Group {
if g == nil {
return nil
}
g.Name, g.Description = g.I18n.Localize(acceptLanguage, g.Name, g.Description)
g.I18n = nil
return g
}
// Localized 本地化 API Key 所属分组并返回自身。
func (k *APIKey) Localized(acceptLanguage string) *APIKey {
if k != nil {
k.Group.Localized(acceptLanguage)
}
return k
}
// Localized 本地化兑换码关联分组并返回自身。
func (r *RedeemCode) Localized(acceptLanguage string) *RedeemCode {
if r != nil {
r.Group.Localized(acceptLanguage)
}
return r
}
// Localized 本地化使用记录关联的分组(含其 API Key 与订阅各自携带的分组)并返回自身。
func (l *UsageLog) Localized(acceptLanguage string) *UsageLog {
if l != nil {
l.Group.Localized(acceptLanguage)
l.APIKey.Localized(acceptLanguage)
l.Subscription.Localized(acceptLanguage)
}
return l
}
// Localized 本地化订阅关联分组并返回自身。
func (s *UserSubscription) Localized(acceptLanguage string) *UserSubscription {
if s != nil {
s.Group.Localized(acceptLanguage)
}
return s
}
@@ -0,0 +1,71 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/stretchr/testify/require"
)
func localizableGroup() *Group {
return &Group{
ID: 1, Name: "标准", Description: "号池",
I18n: domain.GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}},
}
}
func TestGroupLocalized_ReplacesTextAndDropsTranslations(t *testing.T) {
g := localizableGroup().Localized("en")
require.Equal(t, "Standard", g.Name)
require.Equal(t, "Pro + Max pool", g.Description)
raw, err := json.Marshal(g)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
require.NotContains(t, decoded, "i18n", "user-facing output must not carry the translation table")
}
func TestGroupLocalized_KeepsOriginalWhenLanguageHasNoTranslation(t *testing.T) {
g := localizableGroup().Localized("zh")
require.Equal(t, "标准", g.Name)
require.Equal(t, "号池", g.Description)
require.Nil(t, g.I18n)
}
func TestLocalized_NilSafe(t *testing.T) {
require.Nil(t, (*Group)(nil).Localized("en"))
require.Nil(t, (*APIKey)(nil).Localized("en"))
require.Nil(t, (*RedeemCode)(nil).Localized("en"))
require.Nil(t, (*UsageLog)(nil).Localized("en"))
require.Nil(t, (*UserSubscription)(nil).Localized("en"))
// 未携带分组的记录原样返回
require.Nil(t, (&APIKey{}).Localized("en").Group)
}
func TestLocalized_NestedGroups(t *testing.T) {
key := (&APIKey{Group: localizableGroup()}).Localized("en")
require.Equal(t, "Standard", key.Group.Name)
code := (&RedeemCode{Group: localizableGroup()}).Localized("en")
require.Equal(t, "Standard", code.Group.Name)
sub := (&UserSubscription{Group: localizableGroup()}).Localized("en")
require.Equal(t, "Standard", sub.Group.Name)
// 使用记录除自身分组外,还带着 API Key 与订阅各自的分组
log := (&UsageLog{
Group: localizableGroup(),
APIKey: &APIKey{Group: localizableGroup()},
Subscription: &UserSubscription{Group: localizableGroup()},
}).Localized("en")
require.Equal(t, "Standard", log.Group.Name)
require.Equal(t, "Standard", log.APIKey.Group.Name)
require.Equal(t, "Standard", log.Subscription.Group.Name)
require.Nil(t, log.APIKey.Group.I18n)
require.Nil(t, log.Subscription.Group.I18n)
}
+1
View File
@@ -182,6 +182,7 @@ func groupFromServiceBase(g *service.Group) Group {
ID: g.ID,
Name: g.Name,
Description: g.Description,
I18n: g.I18n,
Platform: g.Platform,
RateMultiplier: g.RateMultiplier,
IsExclusive: g.IsExclusive,
+4
View File
@@ -99,6 +99,10 @@ type Group struct {
IsExclusive bool `json:"is_exclusive"`
Status string `json:"status"`
// I18n 名称与描述的各语言译文。只有管理端输出;用户侧接口经 Localized 把
// name/description 换成请求语言的版本后清空本字段。
I18n domain.GroupI18n `json:"i18n,omitempty"`
SubscriptionType string `json:"subscription_type"`
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
+8 -1
View File
@@ -32,6 +32,7 @@ const (
EndpointVideos = "/v1/videos"
EndpointSeedanceTasks = "/api/v3/contents/generations/tasks"
EndpointGeminiModels = "/v1beta/models"
EndpointGeminiCachedContents = "/v1beta/cachedContents"
)
const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent"
@@ -53,6 +54,7 @@ const (
// "/v1/chat/completions" → "/v1/chat/completions"
// "/openai/v1/responses/foo" → "/v1/responses"
// "/v1beta/models/gemini:gen" → "/v1beta/models"
// "/v1beta/cachedContents/abc" → "/v1beta/cachedContents"
//
// The OpenAI Responses API is also exposed via a few bare/alias
// routes that do not carry a "/v1/" prefix (top-level bare route and
@@ -117,6 +119,8 @@ func NormalizeInboundEndpoint(path string) string {
return EndpointResponses
case strings.Contains(path, EndpointGeminiModels):
return EndpointGeminiModels
case strings.Contains(path, EndpointGeminiCachedContents):
return EndpointGeminiCachedContents
default:
return path
}
@@ -194,7 +198,7 @@ func isBareOrSubpathOf(path, root string) bool {
// retain their paths. Grok raw Chat requests override this through the
// forwarding result consumed by resolveOpenAIUpstreamEndpoint.
// - Anthropic → /v1/messages
// - Gemini → /v1beta/models
// - Gemini → /v1beta/models(显式缓存管理接口保留 /v1beta/cachedContents)
// - Antigravity → /v1/messages (Claude) or gemini (Gemini)
// - Antigravity routes may target either Claude or Gemini, so the
// inbound endpoint is used to distinguish.
@@ -230,6 +234,9 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
return EndpointSystemOne
case service.PlatformGemini:
if inbound == EndpointGeminiCachedContents {
return EndpointGeminiCachedContents
}
return EndpointGeminiModels
case service.PlatformAntigravity:
@@ -37,6 +37,8 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
{"/v1/videos/generations", EndpointVideosGenerations},
{"/v1/videos/req_123", EndpointVideos},
{"/v1beta/models", EndpointGeminiModels},
{"/v1beta/cachedContents", EndpointGeminiCachedContents},
{"/v1beta/cachedContents/gfo2i2haszm3wbwiyivyok5cyddubfwsr77efg7h", EndpointGeminiCachedContents},
// Prefixed paths (antigravity, openai) — root Responses.
{"/antigravity/v1/messages", EndpointMessages},
@@ -102,6 +104,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
// Gemini.
{"gemini models", EndpointGeminiModels, "/v1beta/models/gemini:gen", service.PlatformGemini, EndpointGeminiModels},
{"gemini cached contents", EndpointGeminiCachedContents, "/v1beta/cachedContents/abc", service.PlatformGemini, EndpointGeminiCachedContents},
// OpenAI — root Responses.
{"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses},
+8 -6
View File
@@ -52,12 +52,14 @@ type GatewayHandler struct {
errorPassthroughService *service.ErrorPassthroughService
contentModerationService *service.ContentModerationService
securityAuditCoordinator *securityaudit.Coordinator
concurrencyHelper *ConcurrencyHelper
userMsgQueueHelper *UserMsgQueueHelper
maxAccountSwitches int
maxAccountSwitchesGemini int
cfg *config.Config
settingService *service.SettingService
// geminiCachedContentService 管理 Gemini 显式缓存;为 nil 时相关接口不可用。
geminiCachedContentService *service.GeminiCachedContentService
concurrencyHelper *ConcurrencyHelper
userMsgQueueHelper *UserMsgQueueHelper
maxAccountSwitches int
maxAccountSwitchesGemini int
cfg *config.Config
settingService *service.SettingService
}
// NewGatewayHandler creates a new GatewayHandler
@@ -1408,6 +1408,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)仍被过滤。
@@ -0,0 +1,955 @@
//go:build unit
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strconv"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
const (
cachedBillingInputPrice = 0.00000125
cachedBillingStoragePrice = 0.0000045
cachedBillingAliasStoragePrice = 0.000009
cachedBillingTokens = 1_000_000
)
type cachedBillingRepo struct {
mu sync.Mutex
records map[string]*service.GeminiCachedContent
// mutations 按记录 id 保存修改意图。
mutations map[int64]cachedBillingMutation
failCommit bool
nextID int64
deleted []int64
}
type cachedBillingMutation struct {
token string
leaseUntil time.Time
}
func (r *cachedBillingRepo) Create(_ context.Context, record *service.GeminiCachedContent) error {
r.mu.Lock()
defer r.mu.Unlock()
r.nextID++
record.ID = r.nextID
cp := *record
r.records[record.PublicID] = &cp
return nil
}
func (r *cachedBillingRepo) GetForOwner(_ context.Context, apiKeyID int64, publicID string) (*service.GeminiCachedContent, error) {
r.mu.Lock()
defer r.mu.Unlock()
record, ok := r.records[publicID]
if !ok || record.APIKeyID != apiKeyID || r.liveLocked(record.ID) == nil {
return nil, service.ErrGeminiCachedContentNotFound
}
cp := *record
return &cp, nil
}
func (r *cachedBillingRepo) ListForOwner(context.Context, int64, int64, time.Time, int64, int) ([]*service.GeminiCachedContent, error) {
return nil, nil
}
// liveLocked 返回未删除的记录;调用方须持有 mu。
func (r *cachedBillingRepo) liveLocked(id int64) *service.GeminiCachedContent {
for _, deleted := range r.deleted {
if deleted == id {
return nil
}
}
for _, record := range r.records {
if record.ID == id {
return record
}
}
return nil
}
func (r *cachedBillingRepo) BeginMutation(_ context.Context, id int64, baseline time.Time, token string, lease time.Duration) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
record := r.liveLocked(id)
if record == nil {
return false, service.ErrGeminiCachedContentNotFound
}
if _, pending := r.mutations[id]; pending || !record.ExpireTime.Equal(baseline) {
return false, nil
}
r.mutations[id] = cachedBillingMutation{token: token, leaseUntil: time.Now().Add(lease)}
return true, nil
}
func (r *cachedBillingRepo) TakeOverStaleMutation(_ context.Context, id int64, token string, lease time.Duration) (*service.GeminiCachedContent, error) {
r.mu.Lock()
defer r.mu.Unlock()
record := r.liveLocked(id)
mutation, pending := r.mutations[id]
if record == nil || !pending || mutation.leaseUntil.After(time.Now()) {
return nil, nil
}
r.mutations[id] = cachedBillingMutation{token: token, leaseUntil: time.Now().Add(lease)}
cp := *record
return &cp, nil
}
func (r *cachedBillingRepo) CommitMutation(_ context.Context, id int64, token string, expireTime time.Time) error {
r.mu.Lock()
defer r.mu.Unlock()
if r.failCommit {
return errors.New("db unavailable")
}
record := r.liveLocked(id)
mutation, pending := r.mutations[id]
if record == nil || !pending || mutation.token != token {
return service.ErrGeminiCachedContentMutationConflict
}
record.ExpireTime = expireTime
delete(r.mutations, id)
return nil
}
func (r *cachedBillingRepo) ReleaseMutation(_ context.Context, id int64, token string) error {
r.mu.Lock()
defer r.mu.Unlock()
if mutation, pending := r.mutations[id]; pending && mutation.token == token {
delete(r.mutations, id)
}
return nil
}
func (r *cachedBillingRepo) ListStaleMutations(_ context.Context, limit int) ([]int64, error) {
r.mu.Lock()
defer r.mu.Unlock()
var ids []int64
for id, mutation := range r.mutations {
if len(ids) < limit && mutation.leaseUntil.Before(time.Now()) && r.liveLocked(id) != nil {
ids = append(ids, id)
}
}
return ids, nil
}
func (r *cachedBillingRepo) SoftDelete(_ context.Context, id int64) error {
r.mu.Lock()
defer r.mu.Unlock()
r.deleted = append(r.deleted, id)
return nil
}
// pendingMutation 返回记录是否有未清除的修改意图。
func (r *cachedBillingRepo) pendingMutation(id int64) bool {
r.mu.Lock()
defer r.mu.Unlock()
_, pending := r.mutations[id]
return pending
}
// expireLease 让记录上的修改意图租约立即过期。
func (r *cachedBillingRepo) expireLease(id int64) {
r.mu.Lock()
defer r.mu.Unlock()
mutation := r.mutations[id]
mutation.leaseUntil = time.Now().Add(-time.Second)
r.mutations[id] = mutation
}
type cachedBillingAccountRepo struct {
service.AccountRepository
accounts map[int64]*service.Account
}
func (r *cachedBillingAccountRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
account, ok := r.accounts[id]
if !ok {
return nil, service.ErrAccountNotFound
}
cp := *account
return &cp, nil
}
type cachedBillingUpstream struct {
service.HTTPUpstream
mu sync.Mutex
createBody string
calls []string
// patchTTLs 按顺序记录 PATCH 请求体中的 ttl。
patchTTLs []string
beforePost func(n int)
// failures 按请求方法返回上游错误响应;transportErrors 按请求方法返回传输层错误(上游结果未知)。
failures map[string]cachedBillingUpstreamFailure
transportErrors map[string]error
}
type cachedBillingUpstreamFailure struct {
status int
body string
}
func (u *cachedBillingUpstream) Do(req *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
var patch struct {
TTL string `json:"ttl"`
}
if req.Method == http.MethodPatch {
_ = json.NewDecoder(req.Body).Decode(&patch)
}
u.mu.Lock()
u.calls = append(u.calls, req.Method+" "+req.URL.Path)
n := len(u.calls)
if req.Method == http.MethodPatch {
u.patchTTLs = append(u.patchTTLs, patch.TTL)
}
failure, failed := u.failures[req.Method]
transportErr := u.transportErrors[req.Method]
u.mu.Unlock()
if transportErr != nil {
return nil, transportErr
}
if failed {
return &http.Response{StatusCode: failure.status, Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(failure.body))}, nil
}
body := "{}"
switch req.Method {
case http.MethodPost:
body = u.createBody
case http.MethodPatch:
if u.beforePost != nil {
u.beforePost(n)
}
seconds, _ := strconv.ParseInt(strings.TrimSuffix(patch.TTL, "s"), 10, 64)
body = fmt.Sprintf(`{"name":"cachedContents/up1","expireTime":%q}`, time.Now().Add(time.Duration(seconds)*time.Second).UTC().Format(time.RFC3339Nano))
}
return &http.Response{StatusCode: http.StatusOK, Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body))}, nil
}
func (u *cachedBillingUpstream) methodCalls(method string) int {
u.mu.Lock()
defer u.mu.Unlock()
n := 0
for _, call := range u.calls {
if strings.HasPrefix(call, method+" ") {
n++
}
}
return n
}
type cachedBillingUserRepo struct {
service.UserRepository
balance float64
}
func (r *cachedBillingUserRepo) GetByID(_ context.Context, id int64) (*service.User, error) {
return &service.User{ID: id, Balance: r.balance}, nil
}
func (r *cachedBillingUserRepo) DeductBalance(context.Context, int64, float64) error { return nil }
type cachedBillingUsageRepo struct {
service.UsageLogRepository
mu sync.Mutex
logs []*service.UsageLog
}
func (r *cachedBillingUsageRepo) Create(_ context.Context, log *service.UsageLog) (bool, error) {
r.mu.Lock()
defer r.mu.Unlock()
r.logs = append(r.logs, log)
return true, nil
}
func (r *cachedBillingUsageRepo) first() *service.UsageLog {
r.mu.Lock()
defer r.mu.Unlock()
return r.logs[0]
}
func (r *cachedBillingUsageRepo) totals() []float64 {
r.mu.Lock()
defer r.mu.Unlock()
out := make([]float64, 0, len(r.logs))
for _, log := range r.logs {
out = append(out, log.TotalCost)
}
return out
}
type cachedBillingEnv struct {
h *GatewayHandler
repo *cachedBillingRepo
upstream *cachedBillingUpstream
usage *cachedBillingUsageRepo
apiKey *service.APIKey
group *service.Group
// accounts 是调度快照桩直接返回的账号对象,测试可改其凭据。
accounts map[int64]*service.Account
}
func newCachedBillingEnv(t *testing.T, balance float64, accountIDs ...int64) *cachedBillingEnv {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dir, "model_pricing.json"), []byte(fmt.Sprintf(`{"gemini-2.5-pro":{
"litellm_provider":"gemini","mode":"chat","input_cost_per_token":%g,"output_cost_per_token":0.00001,
"cache_storage_cost_per_token_per_hour":%g},"gemini-alias":{
"litellm_provider":"gemini","mode":"chat","input_cost_per_token":%g,"output_cost_per_token":0.00001,
"cache_storage_cost_per_token_per_hour":%g}}`,
cachedBillingInputPrice, cachedBillingStoragePrice, cachedBillingInputPrice, cachedBillingAliasStoragePrice)), 0o644))
cfg := &config.Config{}
cfg.Pricing.DataDir = dir
cfg.Pricing.UpdateIntervalHours = 100000
cfg.Default.RateMultiplier = 1
pricing := service.NewPricingService(cfg, nil)
require.NoError(t, pricing.Initialize())
billing := service.NewBillingService(cfg, pricing)
groupID := int64(42)
group := &service.Group{ID: groupID, Hydrated: true, Platform: service.PlatformGemini, Status: service.StatusActive, RateMultiplier: 1}
accounts := map[int64]*service.Account{}
var scheduled []*service.Account
for _, id := range accountIDs {
account := &service.Account{ID: id, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "k"}, Status: service.StatusActive, Schedulable: true,
Concurrency: 5, Priority: 1, AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}}}
accounts[id] = account
scheduled = append(scheduled, account)
}
userRepo := &cachedBillingUserRepo{balance: balance}
usage := &cachedBillingUsageRepo{}
bcs := service.NewBillingCacheService(nil, userRepo, nil, nil, nil, nil, cfg, nil)
t.Cleanup(bcs.Stop)
snapshot := service.NewSchedulerSnapshotService(&fakeSchedulerCache{accounts: scheduled}, nil, nil, nil, nil)
gw := service.NewGatewayService(nil, &fakeGroupRepo{group: group}, usage, nil, userRepo, nil, nil, nil, cfg, snapshot, nil,
billing, nil, &service.BillingCacheService{}, nil, nil, &service.DeferredService{}, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
upstream := &cachedBillingUpstream{}
compat := service.NewGeminiMessagesCompatService(&cachedBillingAccountRepo{accounts: accounts}, nil, nil, nil, nil, nil, upstream, nil, cfg)
repo := &cachedBillingRepo{records: map[string]*service.GeminiCachedContent{}, mutations: map[int64]cachedBillingMutation{}}
h := &GatewayHandler{
gatewayService: gw,
billingCacheService: bcs,
concurrencyHelper: NewConcurrencyHelper(service.NewConcurrencyService(&fakeConcurrencyCache{}), SSEPingFormatNone, 0),
geminiCachedContentService: service.NewGeminiCachedContentService(repo, compat, cfg),
}
apiKey := &service.APIKey{ID: 7, UserID: 3, GroupID: &groupID, Group: group, Status: service.StatusActive,
User: &service.User{ID: 3, Concurrency: 10, Balance: balance}}
return &cachedBillingEnv{h: h, repo: repo, upstream: upstream, usage: usage, apiKey: apiKey, group: group, accounts: accounts}
}
// TestGeminiExplicitCacheExclusions_FiltersUnsupportedAccountsFromSnapshot 候选过滤直接按快照里的凭据判定:
// 第三方地址且未声明上游显式缓存的 Key 账号在选号前就被排除,官方地址与声明了上游显式缓存的账号保留。
func TestGeminiExplicitCacheExclusions_FiltersUnsupportedAccountsFromSnapshot(t *testing.T) {
env := newCachedBillingEnv(t, 500, 900, 901, 902)
env.accounts[901].Credentials["base_url"] = "https://third.example.com"
env.accounts[902].Credentials["base_url"] = "https://gw.example.com"
env.accounts[902].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true
groupID := env.group.ID
excluded, err := env.h.gatewayService.GeminiExplicitCacheExclusions(context.Background(), &groupID, 0)
require.NoError(t, err)
require.Equal(t, map[int64]struct{}{901: {}}, excluded)
excluded, err = env.h.gatewayService.GeminiExplicitCacheExclusions(context.Background(), &groupID, 902)
require.NoError(t, err)
require.Equal(t, map[int64]struct{}{900: {}, 901: {}}, excluded, "绑定缓存的请求只保留持有账号")
}
func (e *cachedBillingEnv) serve(method, target, body string, params gin.Params, call func(*gin.Context)) *httptest.ResponseRecorder {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(method, target, strings.NewReader(body))
c.Request = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, e.group))
c.Params = params
c.Set(string(middleware.ContextKeyAPIKey), e.apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: e.apiKey.UserID, Concurrency: 10})
call(c)
return rec
}
// waitForUsage 等待异步计费任务落库(无 worker 池时同步执行,直接返回)。
func (e *cachedBillingEnv) waitForUsage(t *testing.T, n int) []float64 {
t.Helper()
require.Eventually(t, func() bool { return len(e.usage.totals()) >= n }, time.Second, 10*time.Millisecond)
return e.usage.totals()
}
func (e *cachedBillingEnv) create() *httptest.ResponseRecorder {
return e.serve(http.MethodPost, "/v1beta/cachedContents",
`{"model":"models/gemini-2.5-pro","ttl":"86400s","contents":[{"role":"user","parts":[{"text":"doc"}]}]}`,
nil, e.h.GeminiCachedContentsCreate)
}
func (e *cachedBillingEnv) seed(expire time.Time) {
e.repo.records["abc"] = &service.GeminiCachedContent{
ID: 100, PublicID: "abc", UserID: 3, APIKeyID: 7, GroupID: 42, AccountID: 900,
UpstreamName: "cachedContents/up1", Model: "gemini-2.5-pro", UpstreamModel: "models/gemini-2.5-pro",
TotalTokenCount: cachedBillingTokens, ExpireTime: expire,
}
}
func (e *cachedBillingEnv) patch() *httptest.ResponseRecorder {
return e.serve(http.MethodPatch, "/v1beta/cachedContents/abc?updateMask=ttl", `{"ttl":"86400s"}`,
gin.Params{{Key: "cacheID", Value: "abc"}}, e.h.GeminiCachedContentsPatch)
}
// TestGeminiCachedContentsPatch_UsesChannelUsageRecordedAtCreate 延长有效期沿用创建时的渠道计费口径:
// 计费模型来源为请求模型时按别名定价,用量记录带渠道 ID。
func TestGeminiCachedContentsPatch_UsesChannelUsageRecordedAtCreate(t *testing.T) {
env := newCachedBillingEnv(t, 500, 900)
env.seed(time.Now().Add(time.Hour))
env.repo.records["abc"].ChannelUsage = service.ChannelUsageFields{
ChannelID: 5, OriginalModel: "gemini-alias", ChannelMappedModel: "gemini-2.5-pro",
BillingModelSource: service.BillingModelSourceRequested,
}
rec := env.patch()
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
totals := env.waitForUsage(t, 1)
require.Len(t, totals, 1)
require.InDelta(t, cachedBillingTokens*cachedBillingAliasStoragePrice*23, totals[0], 0.01, "按创建时的计费模型(请求模型别名)计存储费")
usageLog := env.usage.first()
require.NotNil(t, usageLog.ChannelID)
require.Equal(t, int64(5), *usageLog.ChannelID)
require.True(t, strings.HasPrefix(usageLog.RequestID, "gcache:patch:abc:"), usageLog.RequestID)
}
func cachedBillingCreateBody(expire time.Time) string {
return fmt.Sprintf(`{"name":"cachedContents/up1","usageMetadata":{"totalTokenCount":%d},"expireTime":%q}`,
cachedBillingTokens, expire.UTC().Format(time.RFC3339Nano))
}
func TestGeminiCachedContentsCreate_ChargesInputAndStorage(t *testing.T) {
env := newCachedBillingEnv(t, 500, 900)
env.upstream.createBody = cachedBillingCreateBody(time.Now().Add(100 * time.Hour))
rec := env.create()
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
totals := env.waitForUsage(t, 1)
require.Len(t, totals, 1)
require.InDelta(t, cachedBillingTokens*cachedBillingInputPrice+cachedBillingTokens*cachedBillingStoragePrice*24, totals[0], 1e-9)
require.Len(t, env.repo.records, 1)
for _, record := range env.repo.records {
require.False(t, record.ExpireTime.After(time.Now().Add(24*time.Hour)), "本地到期时间不超过请求的有效期")
require.Equal(t, "gcache:create:"+record.PublicID, env.usage.first().RequestID, "费用行按缓存 ID 可检索")
}
}
func TestGeminiCachedContentsCreate_UnaffordableStorageDeletesUpstreamAndBillsInputOnly(t *testing.T) {
env := newCachedBillingEnv(t, 1, 900)
env.upstream.createBody = cachedBillingCreateBody(time.Now().Add(24 * time.Hour))
rec := env.create()
require.NotEqual(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), "error")
require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete), "删除已创建的上游缓存,避免产生存储费")
require.Empty(t, env.repo.records, "不登记缓存")
totals := env.waitForUsage(t, 1)
require.Len(t, totals, 1)
require.InDelta(t, cachedBillingTokens*cachedBillingInputPrice, totals[0], 1e-9, "只收已发生的缓存 token 输入费")
}
func TestGeminiCachedContentsCreate_UnusableUpstreamResponseCleansUpWithoutRetry(t *testing.T) {
env := newCachedBillingEnv(t, 500, 900, 901)
env.upstream.createBody = `{"name":"cachedContents/up1","expireTime":"2099-01-01T00:00:00Z"}`
rec := env.create()
require.Equal(t, http.StatusBadGateway, rec.Code)
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPost), "不换号重建")
require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete), "删除已创建的上游缓存")
require.Empty(t, env.repo.records)
require.Empty(t, env.usage.totals())
}
func (e *cachedBillingEnv) patchTTL(id, ttl string) *httptest.ResponseRecorder {
return e.serve(http.MethodPatch, "/v1beta/cachedContents/"+id+"?updateMask=ttl", fmt.Sprintf(`{"ttl":%q}`, ttl),
gin.Params{{Key: "cacheID", Value: id}}, e.h.GeminiCachedContentsPatch)
}
// requireTTLNear 断言发往上游的 ttl("Ns")与 want 相差不超过 5 秒。
func requireTTLNear(t *testing.T, ttl string, want time.Duration) {
t.Helper()
seconds, err := strconv.ParseInt(strings.TrimSuffix(ttl, "s"), 10, 64)
require.NoError(t, err, ttl)
require.InDelta(t, want.Seconds(), float64(seconds), 5, ttl)
}
// 同一缓存的修改串行执行:第一个 PATCH 的上游调用返回前,第二个 PATCH 直接得到 409 且不调用上游,
// 本地到期时间与上游最后生效的修改一致,只按第一个 PATCH 实际推进的时长收费。
func TestGeminiCachedContentsPatch_ConcurrentUpdateConflictsAndKeepsUpstreamInSync(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(time.Hour))
env.upstream.beforePost = func(n int) {
if n == 1 {
rec := env.patchTTL("abc", "86400s")
require.Equal(t, http.StatusConflict, rec.Code, rec.Body.String())
require.Contains(t, rec.Body.String(), `"ABORTED"`)
}
}
rec := env.patchTTL("abc", "7200s")
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "冲突的修改不调用上游")
require.WithinDuration(t, time.Now().Add(2*time.Hour), env.repo.records["abc"].ExpireTime, 5*time.Second, "本地到期时间与上游一致")
require.False(t, env.repo.pendingMutation(100))
totals := env.waitForUsage(t, 1)
require.Len(t, totals, 1)
require.InDelta(t, cachedBillingTokens*cachedBillingStoragePrice, totals[0], cachedBillingTokens*cachedBillingStoragePrice*time.Minute.Hours(), "只收推进的 1 小时")
}
// 上游已修改但本地写回失败:不计费并保留修改意图;租约内的修改返回 409,
// 租约过期后后台恢复把上游有效期回滚到修改前的到期时间并清除意图。
func TestGeminiCachedContentsPatch_CommitFailureRollsBackUpstreamWithoutCharge(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
baseline := time.Now().Add(time.Hour)
env.seed(baseline)
env.repo.failCommit = true
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch))
require.True(t, env.repo.pendingMutation(100), "上游可能已修改:保留修改意图")
require.Empty(t, env.usage.totals(), "未提交的修改不收费")
env.repo.failCommit = false
require.Equal(t, http.StatusConflict, env.patch().Code, "租约内的修改被拒绝")
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch))
env.repo.expireLease(100)
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Equal(t, 1, recovered)
require.Equal(t, 2, env.upstream.methodCalls(http.MethodPatch))
requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline))
require.False(t, env.repo.pendingMutation(100))
require.True(t, env.repo.records["abc"].ExpireTime.Equal(baseline), "本地到期时间保持修改前的值")
require.Empty(t, env.usage.totals())
}
// 租约过期的遗留修改意图在下一次修改前先回滚,随后的修改按修改前的到期时间计费。
func TestGeminiCachedContentsPatch_StaleMutationRolledBackBeforeNextUpdate(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
baseline := time.Now().Add(time.Hour)
env.seed(baseline)
env.repo.failCommit = true
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
env.repo.failCommit = false
env.repo.expireLease(100)
rec := env.patch()
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.Equal(t, 3, env.upstream.methodCalls(http.MethodPatch), "失败的修改、回滚、本次修改")
requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline))
require.Equal(t, "86400s", env.upstream.patchTTLs[2])
totals := env.waitForUsage(t, 1)
require.Len(t, totals, 1)
require.InDelta(t, cachedBillingTokens*cachedBillingStoragePrice*23, totals[0], 0.01)
}
// 上游结果未知(传输层错误)时保留修改意图等待回滚;上游明确拒绝时清除修改意图。两种情况都不收费。
func TestGeminiCachedContentsPatch_MutationKeptOnlyWhenUpstreamOutcomeUnknown(t *testing.T) {
t.Run("transport error", func(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(time.Hour))
env.upstream.transportErrors = map[string]error{http.MethodPatch: errors.New("connection reset")}
require.Equal(t, http.StatusBadGateway, env.patch().Code)
require.True(t, env.repo.pendingMutation(100))
require.Empty(t, env.usage.totals())
})
t.Run("upstream rejected", func(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(time.Hour))
env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodPatch: {status: http.StatusBadRequest,
body: `{"error":{"code":400,"message":"invalid ttl","status":"INVALID_ARGUMENT"}}`}}
require.Equal(t, http.StatusBadRequest, env.patch().Code)
require.False(t, env.repo.pendingMutation(100))
require.Empty(t, env.usage.totals())
})
}
// 修改前的到期时间已过:恢复时删除上游缓存与本地记录。
func TestGeminiCachedContentMutationRecovery_DeletesUpstreamWhenBaselineExpired(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(-time.Minute))
env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)}
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Equal(t, 1, recovered)
require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete))
require.Zero(t, env.upstream.methodCalls(http.MethodPatch))
require.Equal(t, []int64{100}, env.repo.deleted)
}
// cachedBillingReservationCache 是启用在途预留时的计费缓存桩:余额与订阅用量取固定值,各维度预留按 key 记账。
type cachedBillingReservationCache struct {
service.BillingCache
mu sync.Mutex
balance float64
sub *service.SubscriptionCacheData
reserved map[string]map[string]float64
}
func (c *cachedBillingReservationCache) GetUserBalance(context.Context, int64) (float64, error) {
return c.balance, nil
}
func (c *cachedBillingReservationCache) GetSubscriptionCache(context.Context, int64, int64) (*service.SubscriptionCacheData, error) {
return c.sub, nil
}
func (c *cachedBillingReservationCache) reserveLocked(key, requestID string, amount float64) float64 {
if c.reserved == nil {
c.reserved = map[string]map[string]float64{}
}
if c.reserved[key] == nil {
c.reserved[key] = map[string]float64{}
}
others := 0.0
for _, v := range c.reserved[key] {
others += v
}
c.reserved[key][requestID] = amount
return others
}
func (c *cachedBillingReservationCache) ReserveInflightBalance(_ context.Context, userID int64, requestID string, amount, balance float64, _ time.Duration) (bool, float64, error) {
c.mu.Lock()
defer c.mu.Unlock()
key := fmt.Sprintf("user:%d", userID)
others := c.reserveLocked(key, requestID, amount)
if len(c.reserved[key]) > 1 && balance-others < amount {
delete(c.reserved[key], requestID)
return false, others, nil
}
return true, others, nil
}
func (c *cachedBillingReservationCache) ReleaseInflightBalance(_ context.Context, userID int64, requestID string) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.reserved[fmt.Sprintf("user:%d", userID)], requestID)
return nil
}
func (c *cachedBillingReservationCache) ReserveInflightScope(_ context.Context, scope, requestID string, amount float64, _ time.Duration) (float64, error) {
c.mu.Lock()
defer c.mu.Unlock()
return c.reserveLocked(scope, requestID, amount), nil
}
func (c *cachedBillingReservationCache) ReleaseInflightScope(_ context.Context, scope, requestID string) error {
c.mu.Lock()
defer c.mu.Unlock()
delete(c.reserved[scope], requestID)
return nil
}
func enableCachedBillingReservations(t *testing.T, env *cachedBillingEnv, cache *cachedBillingReservationCache) {
t.Helper()
cfg := &config.Config{}
cfg.Billing.InflightReservation.Enabled = true
bcs := service.NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(bcs.Stop)
env.h.billingCacheService = bcs
}
// 确定金额的续期费按 API Key 与余额维度预留:第一笔续期(约 $103.5)尚未入账时,
// 第二个缓存的续期超出 Key 剩余额度或余额,在调用上游前被拒绝。
func TestGeminiCachedContentsPatch_UnsettledChargeHoldsKeyQuotaAndBalance(t *testing.T) {
for _, tc := range []struct {
name string
balance, quota float64
}{
{name: "api key quota", balance: 1000, quota: 110},
{name: "balance", balance: 110},
} {
t.Run(tc.name, func(t *testing.T) {
env := newCachedBillingEnv(t, tc.balance, 900)
env.seed(time.Now().Add(time.Hour))
second := *env.repo.records["abc"]
second.ID, second.PublicID, second.UpstreamName = 101, "def", "cachedContents/up2"
env.repo.records["def"] = &second
env.apiKey.Quota = tc.quota
enableCachedBillingReservations(t, env, &cachedBillingReservationCache{balance: tc.balance})
env.upstream.beforePost = func(n int) {
if n == 1 {
rec := env.patchTTL("def", "86400s")
require.NotEqual(t, http.StatusOK, rec.Code, rec.Body.String())
}
}
require.Equal(t, http.StatusOK, env.patch().Code)
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "第二笔续期没有到达上游")
})
}
}
// 订阅模式的确定金额费用按订阅维度预留:第一笔未入账时第二笔超出日剩余额度被拒绝,释放后放行。
func TestReserveGeminiCachedContentCharge_UnsettledChargeHoldsSubscriptionHeadroom(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
limit := 110.0
env.group.SubscriptionType = service.SubscriptionTypeSubscription
env.group.DailyLimitUSD = &limit
enableCachedBillingReservations(t, env, &cachedBillingReservationCache{balance: 1000,
sub: &service.SubscriptionCacheData{Status: service.SubscriptionStatusActive, ExpiresAt: time.Now().Add(time.Hour)}})
sub := &service.UserSubscription{ID: 1, UserID: env.apiKey.UserID, GroupID: env.group.ID, Status: service.SubscriptionStatusActive,
ExpiresAt: time.Now().Add(time.Hour)}
reserve := func() (func(), error) {
var done func()
var err error
env.serve(http.MethodPatch, "/", `{}`, nil, func(c *gin.Context) {
done, err = env.h.reserveGeminiCachedContentCharge(c, env.apiKey, sub, 103.5)
})
return done, err
}
firstDone, err := reserve()
require.NoError(t, err)
_, err = reserve()
require.ErrorIs(t, err, service.ErrDailyLimitExceeded)
firstDone()
secondDone, err := reserve()
require.NoError(t, err)
secondDone()
}
func TestGeminiCachedContentsPatch_UnaffordableExtensionRejectedBeforeUpstream(t *testing.T) {
env := newCachedBillingEnv(t, 1, 900)
env.seed(time.Now().Add(time.Hour))
rec := env.patch()
require.NotEqual(t, http.StatusOK, rec.Code)
require.Zero(t, env.upstream.methodCalls(http.MethodPatch), "余额不足以支付延长部分时不调用上游")
require.Empty(t, env.usage.totals())
}
// 上游对缓存不存在的响应:AI Studio 403;Vertex 缓存资源接口 404,文案含上游缓存数字 ID。
var cachedBillingUpstreamNotFoundCases = []struct {
name string
failure cachedBillingUpstreamFailure
}{
{name: "ai studio 403", failure: cachedBillingUpstreamFailure{status: http.StatusForbidden,
body: `{"error":{"code":403,"message":"CachedContent not found (or permission denied)","status":"PERMISSION_DENIED"}}`}},
{name: "vertex 404", failure: cachedBillingUpstreamFailure{status: http.StatusNotFound,
body: `{"error":{"code":404,"message":"Cached content 8079767076522164224 is not found.","status":"NOT_FOUND"}}`}},
}
func TestGeminiCachedContentsPatch_UpstreamNotFoundForgetsAndReturnsStandardForbidden(t *testing.T) {
for _, tc := range cachedBillingUpstreamNotFoundCases {
t.Run(tc.name, func(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(time.Hour))
env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodPatch: tc.failure}
rec := env.patch()
require.Equal(t, http.StatusForbidden, rec.Code)
require.Equal(t, service.GeminiCachedContentNotFoundResponse, rec.Body.String())
require.NotContains(t, rec.Body.String(), "8079767076522164224", "不应暴露上游缓存 ID")
require.Equal(t, []int64{100}, env.repo.deleted, "上游已不存在的缓存应从本地移除")
require.Empty(t, env.usage.totals(), "上游未延长不收费")
})
}
}
func TestGeminiCachedContentsDelete_UpstreamNotFoundStillForgets(t *testing.T) {
for _, tc := range cachedBillingUpstreamNotFoundCases {
t.Run(tc.name, func(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(time.Hour))
env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodDelete: tc.failure}
rec := env.serve(http.MethodDelete, "/v1beta/cachedContents/abc", "",
gin.Params{{Key: "cacheID", Value: "abc"}}, env.h.GeminiCachedContentsDelete)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.JSONEq(t, `{}`, rec.Body.String())
require.Equal(t, []int64{100}, env.repo.deleted)
})
}
}
// 持有账号暂时不支持显式缓存(能力被关闭)时,恢复流程不能把修改意图当作已回滚而清除;
// 能力恢复后再回滚上游并清除意图。
func TestGeminiCachedContentMutationRecovery_KeepsIntentWhileAccountCannotCache(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.accounts[900].Credentials["base_url"] = "https://gw.example.com"
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true
baseline := time.Now().Add(time.Hour)
env.seed(baseline)
env.repo.failCommit = true
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
require.True(t, env.repo.pendingMutation(100))
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch))
env.repo.failCommit = false
env.repo.expireLease(100)
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.Error(t, err, "账号暂不支持缓存时恢复失败")
require.Zero(t, recovered)
require.True(t, env.repo.pendingMutation(100), "未回滚上游前保留修改意图")
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "不改动上游")
require.Empty(t, env.repo.deleted)
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true
env.repo.expireLease(100)
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Equal(t, 1, recovered)
require.Equal(t, 2, env.upstream.methodCalls(http.MethodPatch))
requireTTLNear(t, env.upstream.patchTTLs[1], time.Until(baseline))
require.False(t, env.repo.pendingMutation(100))
require.True(t, env.repo.records["abc"].ExpireTime.Equal(baseline))
require.Empty(t, env.usage.totals())
}
// 持有账号长期不支持显式缓存,且修改前到期时间加上单次最长有效期都已过去:上游缓存必已自行过期,
// 恢复流程不再保留意图,直接移除本地记录。
func TestGeminiCachedContentMutationRecovery_AbandonsIntentOnceUpstreamMustHaveExpired(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.accounts[900].Credentials["base_url"] = "https://gw.example.com"
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = true
env.seed(time.Now().Add(time.Hour))
env.repo.failCommit = true
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
env.repo.failCommit = false
env.repo.expireLease(100)
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false
// 修改前到期时间 + 24h 上限 + 1h 容忍仍未过去:保留意图。
env.repo.records["abc"].ExpireTime = time.Now().Add(-20 * time.Hour)
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.Error(t, err)
require.Zero(t, recovered)
require.True(t, env.repo.pendingMutation(100))
require.Empty(t, env.repo.deleted)
env.repo.expireLease(100)
env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour)
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Equal(t, 1, recovered)
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "不改动上游")
require.Zero(t, env.upstream.methodCalls(http.MethodDelete))
require.Equal(t, []int64{100}, env.repo.deleted)
require.Empty(t, env.usage.totals())
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Zero(t, recovered, "作废的记录不再被选中")
}
// 作废判定的边界:默认上限 24h 加 1h 容忍;配置上限低于 24h 时仍按 24h 兜底。
func TestGeminiCachedContentMutationRecovery_AbandonBoundary(t *testing.T) {
disableCache := func(env *cachedBillingEnv) {
env.accounts[900].Credentials["base_url"] = "https://gw.example.com"
env.accounts[900].Credentials[service.GeminiExplicitCacheUpstreamCredentialKey] = false
}
t.Run("within grace keeps intent", func(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(-25*time.Hour + time.Minute))
env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)}
disableCache(env)
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.Error(t, err)
require.Zero(t, recovered)
require.True(t, env.repo.pendingMutation(100))
require.Empty(t, env.repo.deleted)
})
t.Run("configured max ttl below default still waits a day", func(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(-12 * time.Hour))
env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)}
disableCache(env)
cfg := &config.Config{}
cfg.Gateway.GeminiCachedContentMaxTTLSeconds = 3600
compat := service.NewGeminiMessagesCompatService(&cachedBillingAccountRepo{accounts: env.accounts}, nil, nil, nil, nil, nil, env.upstream, nil, cfg)
svc := service.NewGeminiCachedContentService(env.repo, compat, cfg)
recovered, err := svc.RecoverStaleMutations(context.Background())
require.Error(t, err)
require.Zero(t, recovered)
require.True(t, env.repo.pendingMutation(100))
env.repo.expireLease(100)
env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour)
recovered, err = svc.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Equal(t, 1, recovered)
require.Equal(t, []int64{100}, env.repo.deleted)
})
}
// 持有账号可用但上游回滚持续失败(如 Key 已失效):上游缓存必已自行过期后不再重试,移除本地记录。
func TestGeminiCachedContentMutationRecovery_AbandonsIntentWhenRollbackKeepsFailing(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(-time.Minute))
env.repo.mutations[100] = cachedBillingMutation{token: "stale", leaseUntil: time.Now().Add(-time.Second)}
env.upstream.failures = map[string]cachedBillingUpstreamFailure{http.MethodDelete: {status: http.StatusUnauthorized,
body: `{"error":{"code":401,"message":"API key not valid","status":"UNAUTHENTICATED"}}`}}
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.Error(t, err, "上游缓存可能仍存活:保留意图等待重试")
require.Zero(t, recovered)
require.Equal(t, 1, env.upstream.methodCalls(http.MethodDelete))
require.True(t, env.repo.pendingMutation(100))
require.Empty(t, env.repo.deleted)
env.repo.expireLease(100)
env.repo.records["abc"].ExpireTime = time.Now().Add(-26 * time.Hour)
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Equal(t, 1, recovered)
require.Equal(t, 2, env.upstream.methodCalls(http.MethodDelete), "账号可用时仍先尝试回滚")
require.Equal(t, []int64{100}, env.repo.deleted)
require.Empty(t, env.usage.totals())
}
// 持有账号已删除时没有凭据可回滚上游:恢复流程移除本地记录并清除意图,不再重试。
func TestGeminiCachedContentMutationRecovery_ForgetsRecordWhenAccountDeleted(t *testing.T) {
env := newCachedBillingEnv(t, 1000, 900)
env.seed(time.Now().Add(time.Hour))
env.repo.failCommit = true
require.Equal(t, http.StatusInternalServerError, env.patch().Code)
require.True(t, env.repo.pendingMutation(100))
env.repo.failCommit = false
env.repo.expireLease(100)
delete(env.accounts, 900)
recovered, err := env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Equal(t, 1, recovered)
require.Equal(t, 1, env.upstream.methodCalls(http.MethodPatch), "无凭据,不改动上游")
require.Zero(t, env.upstream.methodCalls(http.MethodDelete))
require.Equal(t, []int64{100}, env.repo.deleted, "本地记录作废")
require.Empty(t, env.usage.totals())
require.Equal(t, http.StatusForbidden, env.serve(http.MethodGet, "/v1beta/cachedContents/abc", "", gin.Params{{Key: "cacheID", Value: "abc"}}, env.h.GeminiCachedContentsGet).Code, "作废的记录对客户端不可见")
recovered, err = env.h.geminiCachedContentService.RecoverStaleMutations(context.Background())
require.NoError(t, err)
require.Zero(t, recovered, "不再重复处理")
}
@@ -0,0 +1,848 @@
package handler
import (
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// geminiCachedContentCreateMaxAttempts 限制创建缓存时实际发往上游的账号数。
const geminiCachedContentCreateMaxAttempts = 3
// geminiCachedContentPersistTimeout 是上游操作完成后落库的超时;落库与客户端连接脱钩。
const geminiCachedContentPersistTimeout = 15 * time.Second
const geminiCachedContentUnavailableMessage = "The account holding this cached content is temporarily unavailable"
// geminiCachedContentsPreamble 校验显式缓存管理接口的公共前置条件:gemini 分组、非 composite 分组、功能已装配。
func (h *GatewayHandler) geminiCachedContentsPreamble(c *gin.Context, component string) (*service.APIKey, middleware.AuthSubject, *zap.Logger, bool) {
apiKey, ok := middleware.GetAPIKeyFromContext(c)
if !ok || apiKey == nil {
googleError(c, http.StatusUnauthorized, "Invalid API key")
return nil, middleware.AuthSubject{}, nil, false
}
authSubject, ok := middleware.GetAuthSubjectFromContext(c)
if !ok {
googleError(c, http.StatusInternalServerError, "User context not found")
return nil, middleware.AuthSubject{}, nil, false
}
if h.geminiCachedContentService == nil {
googlePlatformError(c, http.StatusNotFound, "Explicit context caching is not enabled")
return nil, middleware.AuthSubject{}, nil, false
}
if apiKey.Group == nil || apiKey.GroupID == nil || apiKey.Group.Platform == service.PlatformComposite {
googleError(c, http.StatusBadRequest, "Explicit context caching is not supported for this API key group")
return nil, middleware.AuthSubject{}, nil, false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformGemini {
googleError(c, http.StatusBadRequest, "API key group platform is not gemini")
return nil, middleware.AuthSubject{}, nil, false
}
reqLog := requestLogger(
c,
component,
zap.Int64("user_id", authSubject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
)
return apiKey, authSubject, reqLog, true
}
func readGeminiCachedContentBody(c *gin.Context) ([]byte, bool) {
body, err := pkghttputil.ReadRequestBodyWithPrealloc(c.Request)
if err != nil {
if maxErr, ok := extractMaxBytesError(err); ok {
googleError(c, http.StatusRequestEntityTooLarge, buildBodyTooLargeMessage(maxErr.Limit))
return nil, false
}
googleError(c, http.StatusBadRequest, "Failed to read request body")
return nil, false
}
if len(body) == 0 {
googleError(c, http.StatusBadRequest, "Request body is empty")
return nil, false
}
return body, true
}
func (h *GatewayHandler) checkGeminiCachedContentBilling(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subscription *service.UserSubscription) bool {
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("gemini.cached_content.billing_eligibility_check_failed", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
googleErrorWithType(c, status, code, "", message)
return false
}
return true
}
// GeminiCachedContentsCreate POST /v1beta/cachedContents
// 创建请求内一次性收取缓存 token 的输入费与整个有效期的存储费。
func (h *GatewayHandler) GeminiCachedContentsCreate(c *gin.Context) {
apiKey, authSubject, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.create")
if !ok {
return
}
svc := h.geminiCachedContentService
body, ok := readGeminiCachedContentBody(c)
if !ok {
return
}
req, err := service.ParseGeminiCachedContentCreateRequest(body, svc.Now(), svc.MaxTTL())
if err != nil {
googleError(c, http.StatusBadRequest, err.Error())
return
}
modelName := req.Model
reqLog = reqLog.With(zap.String("model", modelName))
setOpsRequestContext(c, modelName, false)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(false, false)))
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
c.Request = c.Request.WithContext(pricingCtx)
if decision := h.checkSecurityAudit(c, reqLog, apiKey, authSubject, service.ContentModerationProtocolGemini, modelName, body); decision != nil && !decision.AllowNextStage {
googleSecurityAuditError(c, decision)
return
}
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, modelName)
reqModel := modelName
if channelMapping.Mapped {
modelName = channelMapping.MappedModel
}
subscription, _ := middleware.GetSubscriptionFromContext(c)
geminiConcurrency := NewConcurrencyHelper(h.concurrencyHelper.concurrencyService, SSEPingFormatNone, 0)
streamStarted := false
userReleaseFunc, err := geminiConcurrency.AcquireUserSlotWithWait(c, authSubject.UserID, authSubject.Concurrency, false, &streamStarted)
if err != nil {
reqLog.Warn("gemini.cached_content.user_slot_acquire_failed", zap.Error(err))
googleConcurrencyError(c, err, "user")
return
}
userReleaseFunc = wrapReleaseOnDone(c.Request.Context(), userReleaseFunc)
if userReleaseFunc != nil {
defer userReleaseFunc()
}
if !h.checkGeminiCachedContentBilling(c, reqLog, apiKey, subscription) {
return
}
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body))
if err != nil {
reqLog.Info("gemini.cached_content.inflight_reservation_rejected", zap.Error(err))
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
googleErrorWithType(c, status, code, "", message)
return
}
defer inflightRelease()
excluded, err := h.gatewayService.GeminiExplicitCacheExclusions(c.Request.Context(), apiKey.GroupID, 0)
if err != nil {
reqLog.Warn("gemini.cached_content.list_accounts_failed", zap.Error(err))
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts support explicit context caching")
return
}
var lastUpstreamErr *service.GeminiCachedContentUpstreamError
var ttlRejectedLimit time.Duration
for attempts := 0; attempts < geminiCachedContentCreateMaxAttempts; {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, "", modelName, excluded, "", int64(0))
if err != nil {
if failoverClientGone(c) {
return
}
reqLog.Info("gemini.cached_content.account_select_exhausted", zap.Error(err))
break
}
account := selection.Account
if !service.IsGeminiExplicitCacheAccount(account) {
if selection.Acquired && selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
excluded[account.ID] = struct{}{}
continue
}
ttl, ttlAllowed := req.TTLForAccount(account)
if !ttlAllowed {
if selection.Acquired && selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
excluded[account.ID] = struct{}{}
ttlRejectedLimit = max(ttlRejectedLimit, account.GeminiExplicitCacheUpstreamMaxTTL())
continue
}
accountReleaseFunc, ok := h.acquireGeminiCachedContentAccountSlot(c, reqLog, geminiConcurrency, selection)
if !ok {
return
}
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
if vetoed {
accountReleaseFunc()
reqLog.Debug("gemini.cached_content.account_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
excluded[account.ID] = struct{}{}
continue
}
account = latest
attempts++
setOpsSelectedAccount(c, account.ID, account.Platform)
mappedModel := account.GetMappedModel(modelName)
usageFields := clientRequestedUsageFields(c, channelMapping, reqModel, mappedModel)
if !h.gatewayService.CacheStoragePriced(c.Request.Context(), &service.RecordUsageInput{
Result: &service.ForwardResult{Model: modelName, UpstreamModel: mappedModel, CacheStorageTokenHours: 1},
APIKey: apiKey,
ChannelUsageFields: usageFields,
}) {
accountReleaseFunc()
googlePlatformError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", modelName))
return
}
upstreamModel, err := service.GeminiCachedContentUpstreamModelResource(account, mappedModel)
if err != nil {
accountReleaseFunc()
reqLog.Warn("gemini.cached_content.upstream_model_invalid", zap.Int64("account_id", account.ID), zap.Error(err))
excluded[account.ID] = struct{}{}
continue
}
publicID, err := service.NewGeminiCachedContentPublicID()
if err != nil {
accountReleaseFunc()
googleError(c, http.StatusInternalServerError, "Failed to allocate cached content id")
return
}
upstream, err := svc.CreateUpstream(c.Request.Context(), account, req, mappedModel, publicID, ttl)
accountReleaseFunc()
if err != nil {
var upErr *service.GeminiCachedContentUpstreamError
if errors.As(err, &upErr) {
reqLog.Info("gemini.cached_content.upstream_create_failed", zap.Int64("account_id", account.ID), zap.Int("status", upErr.StatusCode))
if upErr.RetryableOnOtherAccount() {
lastUpstreamErr = upErr
excluded[account.ID] = struct{}{}
continue
}
h.writeGeminiCachedContentUpstreamError(c, upErr)
return
}
// 上游已返回成功但响应不可用:不换号重建,能识别出资源名时删除上游缓存。
var malformed *service.GeminiCachedContentMalformedResponseError
if errors.As(err, &malformed) {
reqLog.Error("gemini.cached_content.upstream_create_malformed", zap.Int64("account_id", account.ID), zap.Error(err))
if malformed.Name != "" {
h.cleanupGeminiCachedContentUpstream(reqLog, account, malformed.Name)
}
googleError(c, http.StatusBadGateway, "Upstream returned an invalid cached content response")
return
}
if failoverClientGone(c) {
return
}
reqLog.Warn("gemini.cached_content.upstream_create_error", zap.Int64("account_id", account.ID), zap.Error(err))
excluded[account.ID] = struct{}{}
continue
}
// 缓存 token 数在上游创建后才确定:按确定金额校验余额 / 额度并预留,不足时删除上游缓存,
// 只收取已发生的缓存 token 输入费。
tokens := upstream.TotalTokenCount
storageTokenHours := service.GeminiCachedContentStorageTokenHours(tokens, ttl)
amount := h.gatewayService.QuoteUsageCost(c.Request.Context(), &service.RecordUsageInput{
Result: &service.ForwardResult{
Model: modelName, UpstreamModel: mappedModel,
Usage: service.ClaudeUsage{InputTokens: int(tokens)}, CacheStorageTokenHours: storageTokenHours,
},
APIKey: apiKey, User: apiKey.User, Subscription: subscription, PricingAt: pricingAt, ChannelUsageFields: usageFields,
})
inflightRelease()
chargeDone, err := h.reserveGeminiCachedContentCharge(c, apiKey, subscription, amount)
if err != nil {
reqLog.Info("gemini.cached_content.storage_charge_rejected", zap.Float64("amount", amount), zap.Error(err))
h.cleanupGeminiCachedContentUpstream(reqLog, account, upstream.Name)
h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt,
service.GeminiCachedContentCreateUsageRequestID(publicID), modelName, mappedModel, int(tokens), 0, usageFields, body)
writeGeminiCachedContentBillingError(c, err)
return
}
defer chargeDone()
expireTime := service.ClampGeminiCachedContentExpire(upstream.ExpireTime, svc.Now().Add(ttl))
record := &service.GeminiCachedContent{
PublicID: publicID,
UserID: authSubject.UserID,
APIKeyID: apiKey.ID,
GroupID: *apiKey.GroupID,
AccountID: account.ID,
UpstreamName: upstream.Name,
Model: modelName,
UpstreamModel: upstreamModel,
DisplayName: req.DisplayName,
TotalTokenCount: tokens,
ExpireTime: expireTime,
ChannelUsage: usageFields,
}
persistCtx, cancelPersist := geminiCachedContentPersistContext(c)
err = svc.Save(persistCtx, record)
cancelPersist()
if err != nil {
reqLog.Error("gemini.cached_content.save_failed", zap.Int64("account_id", account.ID), zap.Error(err))
h.cleanupGeminiCachedContentUpstream(reqLog, account, record.UpstreamName)
googleError(c, http.StatusInternalServerError, "Failed to persist cached content")
return
}
h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt,
service.GeminiCachedContentCreateUsageRequestID(publicID), modelName, mappedModel, int(tokens), storageTokenHours, usageFields, body)
c.JSON(http.StatusOK, service.GeminiCachedContentView(record))
return
}
if lastUpstreamErr != nil {
h.writeGeminiCachedContentUpstreamError(c, lastUpstreamErr)
return
}
if ttlRejectedLimit > 0 {
googleError(c, http.StatusBadRequest, fmt.Sprintf("ttl exceeds the maximum allowed %ds by the upstream accounts", int64(ttlRejectedLimit.Seconds())))
return
}
markOpsRoutingCapacityLimited(c)
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts support explicit context caching")
}
// GeminiCachedContentsList GET /v1beta/cachedContents
// 只返回当前 API Key 在当前分组下仍有效的缓存,不透传上游 list(同一账号上其他租户的缓存不可见)。
func (h *GatewayHandler) GeminiCachedContentsList(c *gin.Context) {
apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.list")
if !ok {
return
}
pageSize := service.GeminiCachedContentListDefaultPageSize
if raw := strings.TrimSpace(c.Query("pageSize")); raw != "" {
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
googleError(c, http.StatusBadRequest, "pageSize must be a non-negative integer")
return
}
if n > 0 {
pageSize = min(n, service.GeminiCachedContentListMaxPageSize)
}
}
beforeID := int64(0)
if raw := strings.TrimSpace(c.Query("pageToken")); raw != "" {
id, ok := decodeGeminiCachedContentPageToken(raw)
if !ok {
googleError(c, http.StatusBadRequest, "Invalid pageToken")
return
}
beforeID = id
}
records, err := h.geminiCachedContentService.List(c.Request.Context(), apiKey.ID, *apiKey.GroupID, beforeID, pageSize+1)
if err != nil {
reqLog.Error("gemini.cached_content.list_failed", zap.Error(err))
googleError(c, http.StatusInternalServerError, "Failed to list cached contents")
return
}
resp := gin.H{}
if len(records) > pageSize {
records = records[:pageSize]
resp["nextPageToken"] = encodeGeminiCachedContentPageToken(records[len(records)-1].ID)
}
if len(records) > 0 {
views := make([]map[string]any, 0, len(records))
for _, record := range records {
views = append(views, service.GeminiCachedContentView(record))
}
resp["cachedContents"] = views
}
c.JSON(http.StatusOK, resp)
}
// GeminiCachedContentsGet GET /v1beta/cachedContents/:cacheID
func (h *GatewayHandler) GeminiCachedContentsGet(c *gin.Context) {
apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.get")
if !ok {
return
}
record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID"))
if !ok {
return
}
c.JSON(http.StatusOK, service.GeminiCachedContentView(record))
}
// GeminiCachedContentsPatch PATCH /v1beta/cachedContents/:cacheID
// 只允许修改有效期;延长部分在本次请求内按增量时长收取存储费,缩短不退费。
// 同一缓存的修改串行执行:上游修改、本地写回与计费都在修改意图的租约内完成,并发的修改返回 409。
// 上游结果未知或本地写回失败的修改不计费,租约过期后由恢复流程把上游有效期回滚到修改前的到期时间。
func (h *GatewayHandler) GeminiCachedContentsPatch(c *gin.Context) {
apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.patch")
if !ok {
return
}
svc := h.geminiCachedContentService
body, ok := readGeminiCachedContentBody(c)
if !ok {
return
}
record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID"))
if !ok {
return
}
account, err := svc.BoundAccount(c.Request.Context(), record)
if err != nil {
reqLog.Error("gemini.cached_content.load_account_failed", zap.Int64("account_id", record.AccountID), zap.Error(err))
googleError(c, http.StatusInternalServerError, "Failed to load cached content account")
return
}
if account == nil {
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
return
}
ttl, err := service.ParseGeminiCachedContentPatchRequest(body, c.Query("updateMask"), svc.Now(), svc.MaxTTLForAccount(account))
if err != nil {
googleError(c, http.StatusBadRequest, err.Error())
return
}
setOpsRequestContext(c, record.Model, false)
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
c.Request = c.Request.WithContext(pricingCtx)
subscription, _ := middleware.GetSubscriptionFromContext(c)
if extension := svc.Now().Add(ttl).Sub(record.ExpireTime); extension > 0 {
usage := &service.RecordUsageInput{
Result: &service.ForwardResult{
Model: record.Model, UpstreamModel: record.UpstreamModelID(),
CacheStorageTokenHours: service.GeminiCachedContentStorageTokenHours(record.TotalTokenCount, extension),
},
APIKey: apiKey, User: apiKey.User, Subscription: subscription, PricingAt: pricingAt,
ChannelUsageFields: record.ChannelUsage,
}
if !h.gatewayService.CacheStoragePriced(c.Request.Context(), usage) {
googlePlatformError(c, http.StatusBadRequest, fmt.Sprintf("Explicit context caching is not available for model %s: storage pricing is not configured", record.Model))
return
}
if !h.checkGeminiCachedContentBilling(c, reqLog, apiKey, subscription) {
return
}
chargeDone, err := h.reserveGeminiCachedContentCharge(c, apiKey, subscription, h.gatewayService.QuoteUsageCost(c.Request.Context(), usage))
if err != nil {
reqLog.Info("gemini.cached_content.storage_charge_rejected", zap.Error(err))
writeGeminiCachedContentBillingError(c, err)
return
}
defer chargeDone()
}
setOpsSelectedAccount(c, account.ID, account.Platform)
mutation, err := svc.BeginMutation(c.Request.Context(), record)
if err != nil {
switch {
case errors.Is(err, service.ErrGeminiCachedContentNotFound):
googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage)
case errors.Is(err, service.ErrGeminiCachedContentMutationConflict):
reqLog.Info("gemini.cached_content.mutation_conflict", zap.Int64("account_id", account.ID), zap.Error(err))
writeGeminiCachedContentMutationConflict(c)
default:
reqLog.Error("gemini.cached_content.begin_mutation_failed", zap.Int64("account_id", account.ID), zap.Error(err))
googleError(c, http.StatusInternalServerError, "Failed to update cached content")
}
return
}
upstream, err := svc.PatchUpstream(c.Request.Context(), account, record, ttl)
if err != nil {
var upErr *service.GeminiCachedContentUpstreamError
upstreamRejected := errors.As(err, &upErr)
if upstreamRejected || errors.Is(err, service.ErrGeminiCachedContentRequestNotSent) {
// 上游有效期未变:清除修改意图。
persistCtx, cancelPersist := geminiCachedContentPersistContext(c)
if releaseErr := svc.ReleaseMutation(persistCtx, mutation); releaseErr != nil {
reqLog.Warn("gemini.cached_content.release_mutation_failed", zap.Error(releaseErr))
}
cancelPersist()
}
if upstreamRejected {
if upErr.NotFound() {
if forgetErr := svc.Forget(c.Request.Context(), record); forgetErr != nil {
reqLog.Warn("gemini.cached_content.forget_failed", zap.Error(forgetErr))
}
}
h.writeGeminiCachedContentUpstreamError(c, upErr)
return
}
reqLog.Warn("gemini.cached_content.upstream_patch_error", zap.Int64("account_id", account.ID), zap.Error(err))
googleError(c, http.StatusBadGateway, "Upstream request failed")
return
}
expireTime := service.ClampGeminiCachedContentExpire(upstream.ExpireTime, svc.Now().Add(ttl))
persistCtx, cancelPersist := geminiCachedContentPersistContext(c)
err = svc.CommitMutation(persistCtx, mutation, expireTime)
cancelPersist()
if err != nil {
reqLog.Error("gemini.cached_content.commit_mutation_failed", zap.Int64("account_id", account.ID), zap.Error(err))
if errors.Is(err, service.ErrGeminiCachedContentMutationConflict) {
writeGeminiCachedContentMutationConflict(c)
return
}
googleError(c, http.StatusInternalServerError, "Failed to persist cached content expiration")
return
}
// 提交前库中的到期时间一直是修改前的值:按本次实际推进的时长收费。
if extended := expireTime.Sub(mutation.Baseline()); extended > 0 {
h.submitGeminiCachedContentUsage(c, apiKey, account, subscription, pricingAt,
service.GeminiCachedContentPatchUsageRequestID(record.PublicID), record.Model, record.UpstreamModelID(), 0,
service.GeminiCachedContentStorageTokenHours(record.TotalTokenCount, extended), record.ChannelUsage, body)
}
c.JSON(http.StatusOK, service.GeminiCachedContentView(record))
}
// GeminiCachedContentsDelete DELETE /v1beta/cachedContents/:cacheID
// 删除不退还已收取的存储费。
func (h *GatewayHandler) GeminiCachedContentsDelete(c *gin.Context) {
apiKey, _, reqLog, ok := h.geminiCachedContentsPreamble(c, "handler.gemini_v1beta.cached_contents.delete")
if !ok {
return
}
svc := h.geminiCachedContentService
record, ok := h.resolveGeminiCachedContentOrWrite(c, reqLog, apiKey, c.Param("cacheID"))
if !ok {
return
}
account, err := svc.BoundAccount(c.Request.Context(), record)
if err != nil {
reqLog.Error("gemini.cached_content.load_account_failed", zap.Int64("account_id", record.AccountID), zap.Error(err))
googleError(c, http.StatusInternalServerError, "Failed to load cached content account")
return
}
if account != nil {
setOpsSelectedAccount(c, account.ID, account.Platform)
if err := svc.DeleteUpstream(c.Request.Context(), account, record); err != nil {
var upErr *service.GeminiCachedContentUpstreamError
if !errors.As(err, &upErr) {
reqLog.Warn("gemini.cached_content.upstream_delete_error", zap.Int64("account_id", account.ID), zap.Error(err))
googleError(c, http.StatusBadGateway, "Upstream request failed")
return
}
if !upErr.NotFound() {
h.writeGeminiCachedContentUpstreamError(c, upErr)
return
}
}
}
persistCtx, cancelPersist := geminiCachedContentPersistContext(c)
err = svc.Forget(persistCtx, record)
cancelPersist()
if err != nil {
reqLog.Error("gemini.cached_content.forget_failed", zap.Error(err))
googleError(c, http.StatusInternalServerError, "Failed to delete cached content")
return
}
c.JSON(http.StatusOK, gin.H{})
}
func (h *GatewayHandler) resolveGeminiCachedContentOrWrite(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, cacheID string) (*service.GeminiCachedContent, bool) {
record, err := h.geminiCachedContentService.Resolve(c.Request.Context(), apiKey.ID, apiKey.GroupID, cacheID)
if err != nil {
if errors.Is(err, service.ErrGeminiCachedContentNotFound) {
googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage)
return nil, false
}
reqLog.Error("gemini.cached_content.lookup_failed", zap.Error(err))
googleError(c, http.StatusInternalServerError, "Failed to load cached content")
return nil, false
}
return record, true
}
// resolveGeminiCachedContentBinding 处理 generateContent / countTokens 请求对显式缓存的引用:
// 校验归属与模型后把引用改写为上游资源名,返回绑定记录;请求未引用缓存时返回 nil。
// 返回 false 表示已写出错误响应。
func (h *GatewayHandler) resolveGeminiCachedContentBinding(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, modelName string, body []byte) (*service.GeminiCachedContent, []byte, bool) {
ref, err := service.FindGeminiCachedContentReference(body)
if err != nil {
googleError(c, http.StatusBadRequest, err.Error())
return nil, nil, false
}
if ref == nil {
return nil, body, true
}
if middleware.HasForcePlatform(c) || h.geminiCachedContentService == nil ||
apiKey.Group == nil || apiKey.Group.Platform == service.PlatformComposite {
googleError(c, http.StatusBadRequest, "cachedContent is not supported on this route")
return nil, nil, false
}
record, err := h.geminiCachedContentService.Resolve(c.Request.Context(), apiKey.ID, apiKey.GroupID, ref.Name)
if err != nil {
if errors.Is(err, service.ErrGeminiCachedContentNotFound) {
googleError(c, http.StatusForbidden, service.GeminiCachedContentNotFoundMessage)
return nil, nil, false
}
reqLog.Error("gemini.cached_content.lookup_failed", zap.Error(err))
googleError(c, http.StatusInternalServerError, "Failed to load cached content")
return nil, nil, false
}
if record.Model != modelName {
googleError(c, http.StatusBadRequest, fmt.Sprintf(
"Model used by GenerateContent request (models/%s) and CachedContent (models/%s) has to be the same.", modelName, record.Model))
return nil, nil, false
}
rewritten, err := service.RewriteGeminiCachedContentReference(body, ref, record.UpstreamName)
if err != nil {
googleError(c, http.StatusBadRequest, "Invalid cachedContent reference")
return nil, nil, false
}
return record, rewritten, true
}
// acquireGeminiCachedContentAccountSlot 获取选中账号的并发槽(含等待计划);返回 false 表示已写出错误响应。
func (h *GatewayHandler) acquireGeminiCachedContentAccountSlot(c *gin.Context, reqLog *zap.Logger, helper *ConcurrencyHelper, selection *service.AccountSelectionResult) (func(), bool) {
if selection.Acquired {
release := selection.ReleaseFunc
if release == nil {
release = func() {}
}
return wrapReleaseOnDone(c.Request.Context(), release), true
}
account := selection.Account
if selection.WaitPlan == nil {
markOpsRoutingCapacityLimited(c)
googleError(c, http.StatusServiceUnavailable, "No available Gemini accounts")
return nil, false
}
canWait, err := helper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selection.WaitPlan.MaxWaiting)
if err != nil {
reqLog.Warn("gemini.cached_content.account_wait_counter_increment_failed", zap.Int64("account_id", account.ID), zap.Error(err))
} else if !canWait {
googleError(c, http.StatusTooManyRequests, "Too many pending requests, please retry later")
return nil, false
}
streamStarted := false
release, acquireErr := helper.AcquireAccountSlotWithWaitTimeout(c, account.ID, selection.WaitPlan.MaxConcurrency, selection.WaitPlan.Timeout, false, &streamStarted)
if err == nil {
helper.DecrementAccountWaitCount(c.Request.Context(), account.ID)
}
if acquireErr != nil {
reqLog.Warn("gemini.cached_content.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(acquireErr))
googleConcurrencyError(c, acquireErr, "account")
return nil, false
}
if release == nil {
release = func() {}
}
return wrapReleaseOnDone(c.Request.Context(), release), true
}
// submitGeminiCachedContentUsage 记录缓存创建 / 延长的计费:inputTokens 按输入价计,storageTokenHours 按计费模型的存储单价计;
// requestID 是带固定前缀的费用行标识(见 GeminiCachedContentUsageRequestIDPrefix),落库时不被请求 id 覆盖。
func (h *GatewayHandler) submitGeminiCachedContentUsage(
c *gin.Context,
apiKey *service.APIKey,
account *service.Account,
subscription *service.UserSubscription,
pricingAt time.Time,
requestID string,
model string,
upstreamModel string,
inputTokens int,
storageTokenHours float64,
usageFields service.ChannelUsageFields,
payload []byte,
) {
if inputTokens <= 0 && storageTokenHours <= 0 {
return
}
if upstreamModel == model {
upstreamModel = ""
}
result := &service.ForwardResult{
RequestID: requestID,
Model: model,
UpstreamModel: upstreamModel,
Usage: service.ClaudeUsage{InputTokens: inputTokens},
CacheStorageTokenHours: storageTokenHours,
}
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
requestPayloadHash := service.HashUsageRequestPayload(payload)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: usageFields,
}); err != nil {
logger.L().With(
zap.String("component", "handler.gemini_v1beta.cached_contents"),
zap.Int64("api_key_id", apiKey.ID),
zap.Int64("account_id", account.ID),
zap.String("request_id", result.RequestID),
).Error("gemini.cached_content.record_usage_failed", zap.Error(err))
}
})
}
// geminiCachedContentPersistContext 返回与客户端连接脱钩的落库 context:上游操作已生效时,客户端断开不应让本地记录缺失。
func geminiCachedContentPersistContext(c *gin.Context) (context.Context, context.CancelFunc) {
return context.WithTimeout(context.WithoutCancel(c.Request.Context()), geminiCachedContentPersistTimeout)
}
// cleanupGeminiCachedContentUpstream 尽力删除已在上游创建、但网关不会登记的缓存,避免其持续产生存储费。
func (h *GatewayHandler) cleanupGeminiCachedContentUpstream(reqLog *zap.Logger, account *service.Account, upstreamName string) {
ctx, cancel := context.WithTimeout(context.Background(), geminiCachedContentPersistTimeout)
defer cancel()
if err := h.geminiCachedContentService.DeleteUpstream(ctx, account, &service.GeminiCachedContent{UpstreamName: upstreamName}); err != nil {
reqLog.Error("gemini.cached_content.orphan_cleanup_failed", zap.Int64("account_id", account.ID), zap.String("upstream_name", upstreamName), zap.Error(err))
}
}
// reserveGeminiCachedContentCharge 校验余额 / 订阅额度 / API Key 额度足以支付确定金额 amount(倍率后),
// 并按该金额在用户、订阅、API Key 维度登记在途预留(不受预留上限封顶,见 ReserveFixedCharge)。
// 返回的 done 须在 handler 结束时调用;预留随计费任务交接,落库后释放。
func (h *GatewayHandler) reserveGeminiCachedContentCharge(c *gin.Context, apiKey *service.APIKey, subscription *service.UserSubscription, amount float64) (func(), error) {
ctx := c.Request.Context()
res, err := h.billingCacheService.ReserveFixedCharge(ctx, apiKey.User, apiKey, apiKey.Group, subscription, amount)
if err != nil {
return inflightNoop, err
}
if res == nil {
return inflightNoop, nil
}
c.Request = c.Request.WithContext(service.WithInflightReservation(ctx, res))
return res.HandlerDone, nil
}
// writeGeminiCachedContentMutationConflict 回写同一缓存的修改冲突(Google 并发冲突语义 ABORTED),客户端可稍后重试。
func writeGeminiCachedContentMutationConflict(c *gin.Context) {
c.JSON(http.StatusConflict, gin.H{
"error": gin.H{
"code": http.StatusConflict,
"message": "Another update to this cached content is in progress, please retry later",
"status": "ABORTED",
},
})
}
func writeGeminiCachedContentBillingError(c *gin.Context, err error) {
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
googleErrorWithType(c, status, code, "", message)
}
// writeGeminiCachedContentUpstreamError 回写上游错误:缓存不存在统一返回标准 403(各上游形态不同且可能含上游资源 ID);
// 账号凭据或权限类错误不是客户端问题,统一改为 502;限流 / 不可用按普通 Gemini 路径处理(透传规则、通用映射),
// 只有上游网关账号的这类响应反映持有缓存的上游账号状态,原样回写。
func (h *GatewayHandler) writeGeminiCachedContentUpstreamError(c *gin.Context, upErr *service.GeminiCachedContentUpstreamError) {
status := upErr.StatusCode
message := strings.TrimSpace(extractGoogleErrorMessage(upErr.Body))
service.SetOpsUpstreamError(c, status, message, "")
if upErr.NotFound() {
service.MarkOpsRequestScopedError(c, "permission_error")
c.Data(http.StatusForbidden, "application/json", []byte(service.GeminiCachedContentNotFoundResponse))
return
}
if (status == http.StatusTooManyRequests || status >= http.StatusInternalServerError) && !upErr.UpstreamGateway {
h.handleGeminiFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: status, ResponseBody: upErr.Body})
return
}
if status == http.StatusUnauthorized || status == http.StatusForbidden {
googleError(c, http.StatusBadGateway, "Upstream account rejected the cached content request")
return
}
if len(upErr.Body) == 0 || !json.Valid(upErr.Body) {
if message == "" {
message = http.StatusText(status)
}
googleError(c, status, message)
return
}
c.Data(status, "application/json", upErr.Body)
}
func extractGoogleErrorMessage(body []byte) string {
var envelope struct {
Error struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(body, &envelope); err != nil {
return ""
}
return envelope.Error.Message
}
func encodeGeminiCachedContentPageToken(id int64) string {
return base64.RawURLEncoding.EncodeToString([]byte(strconv.FormatInt(id, 10)))
}
func decodeGeminiCachedContentPageToken(raw string) (int64, bool) {
decoded, err := base64.RawURLEncoding.DecodeString(raw)
if err != nil {
return 0, false
}
id, err := strconv.ParseInt(string(decoded), 10, 64)
if err != nil || id <= 0 {
return 0, false
}
return id, true
}
// mergeAccountIDSets 返回两个账号 ID 集合的并集(不修改入参)。
func mergeAccountIDSets(a, b map[int64]struct{}) map[int64]struct{} {
out := make(map[int64]struct{}, len(a)+len(b))
for id := range a {
out[id] = struct{}{}
}
for id := range b {
out[id] = struct{}{}
}
return out
}
// handleGeminiCachedContentFailoverExhausted 处理绑定缓存的请求在持有账号上重试耗尽:没有其他账号可换。
// 上游网关账号的限流 / 不可用响应反映持有缓存的上游账号状态,原样返回供客户端判断;
// 其余(含官方账号)仍按普通 Gemini 路径处理:透传规则、通用映射。
func (h *GatewayHandler) handleGeminiCachedContentFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError) {
if failoverErr == nil || !failoverErr.BoundUpstreamPassthrough || len(failoverErr.ResponseBody) == 0 || !json.Valid(failoverErr.ResponseBody) {
h.handleGeminiFailoverExhausted(c, failoverErr)
return
}
service.SetOpsUpstreamError(c, failoverErr.StatusCode, service.ExtractUpstreamErrorMessage(failoverErr.ResponseBody), "")
c.Data(failoverErr.StatusCode, "application/json", failoverErr.ResponseBody)
}
@@ -0,0 +1,264 @@
//go:build unit
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
type geminiCachedContentHandlerRepoStub struct {
records []*service.GeminiCachedContent
}
func (r *geminiCachedContentHandlerRepoStub) Create(_ context.Context, record *service.GeminiCachedContent) error {
record.ID = int64(len(r.records) + 1)
r.records = append(r.records, record)
return nil
}
func (r *geminiCachedContentHandlerRepoStub) GetForOwner(_ context.Context, apiKeyID int64, publicID string) (*service.GeminiCachedContent, error) {
for _, record := range r.records {
if record.PublicID == publicID && record.APIKeyID == apiKeyID {
return record, nil
}
}
return nil, service.ErrGeminiCachedContentNotFound
}
func (r *geminiCachedContentHandlerRepoStub) ListForOwner(_ context.Context, apiKeyID, groupID int64, activeAt time.Time, beforeID int64, limit int) ([]*service.GeminiCachedContent, error) {
var out []*service.GeminiCachedContent
for i := len(r.records) - 1; i >= 0; i-- {
record := r.records[i]
if record.APIKeyID == apiKeyID && record.GroupID == groupID && record.ExpireTime.After(activeAt) && (beforeID <= 0 || record.ID < beforeID) {
out = append(out, record)
}
if len(out) == limit {
break
}
}
return out, nil
}
func (r *geminiCachedContentHandlerRepoStub) BeginMutation(context.Context, int64, time.Time, string, time.Duration) (bool, error) {
return true, nil
}
func (r *geminiCachedContentHandlerRepoStub) TakeOverStaleMutation(context.Context, int64, string, time.Duration) (*service.GeminiCachedContent, error) {
return nil, nil
}
func (r *geminiCachedContentHandlerRepoStub) CommitMutation(context.Context, int64, string, time.Time) error {
return nil
}
func (r *geminiCachedContentHandlerRepoStub) ReleaseMutation(context.Context, int64, string) error {
return nil
}
func (r *geminiCachedContentHandlerRepoStub) ListStaleMutations(context.Context, int) ([]int64, error) {
return nil, nil
}
func (r *geminiCachedContentHandlerRepoStub) SoftDelete(context.Context, int64) error {
return nil
}
func newGeminiCachedContentHandlerForTest(repo *geminiCachedContentHandlerRepoStub) *GatewayHandler {
return &GatewayHandler{geminiCachedContentService: service.NewGeminiCachedContentService(repo, nil, &config.Config{})}
}
func geminiCachedContentTestAPIKey(platform string) *service.APIKey {
groupID := int64(42)
return &service.APIKey{ID: 7, UserID: 3, GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: platform}}
}
func newGeminiCachedContentTestContext(method, target string, apiKey *service.APIKey) (*gin.Context, *httptest.ResponseRecorder) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(method, target, nil)
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
return c, rec
}
func seedGeminiCachedContent(repo *geminiCachedContentHandlerRepoStub, publicID string, apiKeyID int64, expire time.Time) *service.GeminiCachedContent {
record := &service.GeminiCachedContent{
PublicID: publicID,
UserID: 3,
APIKeyID: apiKeyID,
GroupID: 42,
AccountID: 900,
UpstreamName: "cachedContents/upstream" + publicID,
Model: "gemini-3.8-flash",
UpstreamModel: "models/gemini-3.8-flash",
TotalTokenCount: 6273,
ExpireTime: expire,
CreatedAt: time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC),
UpdatedAt: time.Date(2026, 10, 9, 8, 0, 0, 0, time.UTC),
}
_ = repo.Create(context.Background(), record)
return record
}
func TestResolveGeminiCachedContentBinding(t *testing.T) {
repo := &geminiCachedContentHandlerRepoStub{}
seedGeminiCachedContent(repo, "abc", 7, time.Now().Add(time.Hour))
seedGeminiCachedContent(repo, "foreign", 8, time.Now().Add(time.Hour))
h := newGeminiCachedContentHandlerForTest(repo)
apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini)
t.Run("request without cache passes through", func(t *testing.T) {
c, _ := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
body := []byte(`{"contents":[]}`)
record, out, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash", body)
require.True(t, ok)
require.Nil(t, record)
require.Equal(t, body, out)
})
t.Run("owned cache rewrites reference and binds account", func(t *testing.T) {
c, _ := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
record, out, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash",
[]byte(`{"cachedContent":"cachedContents/abc","contents":[]}`))
require.True(t, ok)
require.Equal(t, int64(900), record.AccountID)
require.JSONEq(t, `{"cachedContent":"cachedContents/upstreamabc","contents":[]}`, string(out))
})
t.Run("cache owned by another key looks missing", func(t *testing.T) {
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
_, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-3.8-flash",
[]byte(`{"cachedContent":"cachedContents/foreign","contents":[]}`))
require.False(t, ok)
require.Equal(t, http.StatusForbidden, rec.Code)
require.Contains(t, rec.Body.String(), service.GeminiCachedContentNotFoundMessage)
})
t.Run("model must match the cache literally", func(t *testing.T) {
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-flash-latest:generateContent", apiKey)
_, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), apiKey, "gemini-flash-latest",
[]byte(`{"cachedContent":"cachedContents/abc","contents":[]}`))
require.False(t, ok)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), "has to be the same")
})
t.Run("composite groups are rejected", func(t *testing.T) {
composite := geminiCachedContentTestAPIKey(service.PlatformComposite)
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", composite)
_, _, ok := h.resolveGeminiCachedContentBinding(c, zap.NewNop(), composite, "gemini-3.8-flash",
[]byte(`{"cachedContent":"cachedContents/abc","contents":[]}`))
require.False(t, ok)
require.Equal(t, http.StatusBadRequest, rec.Code)
})
}
func TestGeminiCachedContentsListAndGet(t *testing.T) {
repo := &geminiCachedContentHandlerRepoStub{}
seedGeminiCachedContent(repo, "first", 7, time.Now().Add(time.Hour))
seedGeminiCachedContent(repo, "second", 7, time.Now().Add(time.Hour))
seedGeminiCachedContent(repo, "expired", 7, time.Now().Add(-time.Minute))
seedGeminiCachedContent(repo, "foreign", 8, time.Now().Add(time.Hour))
h := newGeminiCachedContentHandlerForTest(repo)
apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini)
c, rec := newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents?pageSize=1", apiKey)
h.GeminiCachedContentsList(c)
require.Equal(t, http.StatusOK, rec.Code)
var page struct {
CachedContents []map[string]any `json:"cachedContents"`
NextPageToken string `json:"nextPageToken"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page))
require.Len(t, page.CachedContents, 1)
require.Equal(t, "cachedContents/second", page.CachedContents[0]["name"])
require.NotEmpty(t, page.NextPageToken)
c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents?pageSize=1&pageToken="+page.NextPageToken, apiKey)
h.GeminiCachedContentsList(c)
require.Equal(t, http.StatusOK, rec.Code)
page.NextPageToken = ""
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &page))
require.Len(t, page.CachedContents, 1)
require.Equal(t, "cachedContents/first", page.CachedContents[0]["name"])
require.Empty(t, page.NextPageToken, "过期与其他 Key 的缓存不出现在列表里")
c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents/first", apiKey)
c.Params = gin.Params{{Key: "cacheID", Value: "first"}}
h.GeminiCachedContentsGet(c)
require.Equal(t, http.StatusOK, rec.Code)
var view map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &view))
require.Equal(t, "cachedContents/first", view["name"])
require.Equal(t, "models/gemini-3.8-flash", view["model"])
require.NotContains(t, rec.Body.String(), "upstreamfirst", "上游资源名不暴露给客户端")
c, rec = newGeminiCachedContentTestContext(http.MethodGet, "/v1beta/cachedContents/foreign", apiKey)
c.Params = gin.Params{{Key: "cacheID", Value: "foreign"}}
h.GeminiCachedContentsGet(c)
require.Equal(t, http.StatusForbidden, rec.Code)
require.True(t, strings.Contains(rec.Body.String(), service.GeminiCachedContentNotFoundMessage))
}
func TestHandleGeminiCachedContentFailoverExhausted(t *testing.T) {
h := &GatewayHandler{}
apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini)
unavailable := `{"error":{"code":503,"message":"The account holding this cached content is temporarily unavailable","status":"UNAVAILABLE"}}`
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusServiceUnavailable, ResponseBody: []byte(unavailable), BoundUpstreamPassthrough: true})
require.Equal(t, http.StatusServiceUnavailable, rec.Code, "绑定缓存的请求无账号可换,上游网关的 503 原样返回")
require.JSONEq(t, unavailable, rec.Body.String())
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, ResponseBody: []byte(`{"error":{"code":429,"message":"Too many pending requests, please retry later","status":"RESOURCE_EXHAUSTED"}}`), BoundUpstreamPassthrough: true})
require.Equal(t, http.StatusTooManyRequests, rec.Code)
require.Contains(t, rec.Body.String(), "Too many pending requests")
officialQuota := `{"error":{"code":429,"message":"You exceeded your current quota","status":"RESOURCE_EXHAUSTED","details":[{"@type":"type.googleapis.com/google.rpc.QuotaFailure","violations":[{"quotaId":"GenerateRequestsPerDayPerProjectPerModel-FreeTier"}]}]}}`
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests, ResponseBody: []byte(officialQuota)})
require.Equal(t, http.StatusTooManyRequests, rec.Code, "官方账号的限流按普通 Gemini 路径映射")
require.NotContains(t, rec.Body.String(), "FreeTier", "不向客户端暴露上游账号的配额明细")
require.Contains(t, rec.Body.String(), "Upstream rate limit exceeded")
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:generateContent", apiKey)
h.handleGeminiCachedContentFailoverExhausted(c, &service.UpstreamFailoverError{StatusCode: http.StatusUnauthorized, ResponseBody: []byte(`{"error":{"code":401,"message":"bad key"}}`)})
require.Equal(t, http.StatusBadGateway, rec.Code, "账号凭据类错误仍按网关映射,不透传给客户端")
require.NotContains(t, rec.Body.String(), "bad key")
}
func TestWriteGeminiCachedContentUpstreamError_RateLimitMappedUnlessUpstreamGateway(t *testing.T) {
h := &GatewayHandler{}
apiKey := geminiCachedContentTestAPIKey(service.PlatformGemini)
quota := `{"error":{"code":429,"message":"You exceeded your current quota","status":"RESOURCE_EXHAUSTED","details":[{"quotaId":"GenerateRequestsPerDayPerProjectPerModel-FreeTier"}]}}`
c, rec := newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey)
h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusTooManyRequests, Body: []byte(quota)})
require.Equal(t, http.StatusTooManyRequests, rec.Code)
require.NotContains(t, rec.Body.String(), "FreeTier", "官方账号的限流不透传配额明细")
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey)
h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusTooManyRequests, Body: []byte(quota), UpstreamGateway: true})
require.Equal(t, http.StatusTooManyRequests, rec.Code)
require.JSONEq(t, quota, rec.Body.String(), "上游网关账号的限流反映持有缓存的上游账号状态,原样回写")
c, rec = newGeminiCachedContentTestContext(http.MethodPost, "/v1beta/cachedContents", apiKey)
h.writeGeminiCachedContentUpstreamError(c, &service.GeminiCachedContentUpstreamError{StatusCode: http.StatusBadRequest, Body: []byte(`{"error":{"code":400,"message":"Cached content is too small","status":"INVALID_ARGUMENT"}}`)})
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), "too small", "请求校验类错误仍原样回写")
}
+139 -25
View File
@@ -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
}
@@ -74,7 +76,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID)
if err != nil {
if len(agModels) > 0 {
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
@@ -85,7 +87,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models")
res, err := h.geminiCompatService.ForwardListModels(c.Request.Context(), account)
if err != nil {
googleError(c, http.StatusBadGateway, err.Error())
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,14 +248,18 @@ 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
}
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
account, err := h.geminiCompatService.SelectAccountForModelsEndpoints(c.Request.Context(), apiKey.GroupID)
if err != nil {
// 没有 gemini 账户,检查是否有 antigravity 账户可用
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
@@ -265,7 +273,7 @@ func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
return
}
res, err := h.geminiCompatService.ForwardAIStudioGET(c.Request.Context(), account, "/v1beta/models/"+modelName)
res, err := h.geminiCompatService.ForwardGetModel(c.Request.Context(), account, modelName)
if err != nil {
googleError(c, http.StatusBadGateway, err.Error())
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))
@@ -356,6 +370,12 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
modelName = channelMapping.MappedModel
}
// 引用显式缓存的请求只能发往持有该缓存的账号,且不参与会话粘性与换号。
cachedContentBinding, body, ok := h.resolveGeminiCachedContentBinding(c, reqLog, apiKey, modelName, body)
if !ok {
return
}
// Get subscription (may be nil)
subscription, _ := middleware.GetSubscriptionFromContext(c)
@@ -382,11 +402,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// 2) billing eligibility check (after wait)
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
reqLog.Info("gemini.billing_eligibility_check_failed", zap.Error(err))
status, _, message, retryAfter := billingErrorDetails(err)
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
googleError(c, status, message)
googleErrorWithType(c, status, code, "", message)
return
}
@@ -394,19 +414,22 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
inflightRelease, err := reserveInflightBalance(c, h.billingCacheService, h.gatewayService, apiKey, subscription, tokenInflightEstimate(modelName, body))
if err != nil {
reqLog.Info("gemini.inflight_reservation_rejected", zap.Error(err))
status, _, message, retryAfter := billingErrorDetails(err)
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
googleError(c, status, message)
googleErrorWithType(c, status, code, "", message)
return
}
defer inflightRelease()
// 3) select account (sticky session based on request body)
// 优先使用 Gemini CLI 的会话标识(privileged-user-id + tmp 目录哈希)
sessionHash := extractGeminiCLISessionHash(c, body)
if sessionHash == "" {
sessionHash := ""
if cachedContentBinding == nil {
sessionHash = extractGeminiCLISessionHash(c, body)
}
if sessionHash == "" && cachedContentBinding == nil {
// Fallback: 使用通用的会话哈希生成逻辑(适用于其他客户端)
parsedReq, _ := service.ParseGatewayRequest(service.NewRequestBodyRef(body), domain.PlatformGemini)
if parsedReq != nil {
@@ -443,7 +466,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
var geminiPrefixHash string
var geminiSessionUUID string
var matchedDigestChain string
useDigestFallback := sessionBoundAccountID == 0
useDigestFallback := sessionBoundAccountID == 0 && cachedContentBinding == nil
if useDigestFallback {
// 解析 Gemini 请求体
@@ -507,7 +530,21 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
hasBoundSession := sessionKey != "" && sessionBoundAccountID > 0
cleanedForUnknownBinding := false
fs := NewFailoverState(h.maxAccountSwitchesGemini, hasBoundSession)
maxAccountSwitches := h.maxAccountSwitchesGemini
var cachedContentExclusions map[int64]struct{}
if cachedContentBinding != nil {
// 缓存只存在于绑定账号:排除分组内其余账号,失败不换号。
maxAccountSwitches = 0
sessionBoundAccountID = cachedContentBinding.AccountID
c.Request = c.Request.WithContext(service.WithGeminiCachedContentBound(c.Request.Context()))
cachedContentExclusions, err = h.gatewayService.GeminiExplicitCacheExclusions(c.Request.Context(), apiKey.GroupID, cachedContentBinding.AccountID)
if err != nil {
reqLog.Warn("gemini.cached_content.list_accounts_failed", zap.Error(err))
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
return
}
}
fs := NewFailoverState(maxAccountSwitches, hasBoundSession)
// 单账号分组提前设置 SingleAccountRetry 标记,让 Service 层首次 503 就不设模型限流标记。
// 避免单账号分组收到 503 (MODEL_CAPACITY_EXHAUSTED) 时设 29s 限流,导致后续请求连续快速失败。
@@ -517,14 +554,38 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
}
for {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, fs.FailedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
excludedAccountIDs := fs.FailedAccountIDs
if cachedContentExclusions != nil {
excludedAccountIDs = mergeAccountIDSets(cachedContentExclusions, fs.FailedAccountIDs)
}
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(c.Request.Context(), apiKey.GroupID, sessionKey, modelName, excludedAccountIDs, "", int64(0)) // Gemini 不使用会话限制
if err == nil && cachedContentBinding != nil && selection.Account.ID != cachedContentBinding.AccountID {
if selection.Acquired && selection.ReleaseFunc != nil {
selection.ReleaseFunc()
}
err = service.ErrNoAvailableAccounts
}
if err != nil {
if failoverClientGone(c) {
reqLog.Info("gemini.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if cachedContentBinding != nil {
if fs.LastFailoverErr != nil {
h.handleGeminiCachedContentFailoverExhausted(c, fs.LastFailoverErr)
return
}
reqLog.Info("gemini.cached_content.bound_account_unavailable", zap.Int64("account_id", cachedContentBinding.AccountID), zap.Error(err))
markOpsRoutingCapacityLimited(c)
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
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)
}
@@ -631,6 +692,11 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
accountReleaseFunc()
}
reqLog.Debug("gemini.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
if cachedContentBinding != nil {
markOpsRoutingCapacityLimited(c)
googleError(c, http.StatusServiceUnavailable, geminiCachedContentUnavailableMessage)
return
}
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
reqLog.Warn("gemini.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
markOpsRoutingCapacityLimited(c)
@@ -684,6 +750,10 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
case FailoverContinue:
continue
case FailoverExhausted:
if cachedContentBinding != nil {
h.handleGeminiCachedContentFailoverExhausted(c, fs.LastFailoverErr)
return
}
h.handleGeminiFailoverExhausted(c, fs.LastFailoverErr)
return
case FailoverCanceled:
@@ -809,7 +879,8 @@ func (h *GatewayHandler) handleGeminiFailoverExhausted(c *gin.Context, failoverE
c.Set(service.OpsSkipPassthroughKey, true)
}
googleError(c, respCode, msg)
// 按透传规则改写的是上游错误,不登记本地错误类型。
googleErrorWithType(c, respCode, "", "", msg)
return
}
}
@@ -844,7 +915,21 @@ type pathParseError struct{ msg string }
func (e *pathParseError) Error() string { return e.msg }
// googleError 以 Google 错误格式回写网关产生的错误,并按状态码向 ops 登记错误类型:
// 4xx 为客户端请求问题,其余状态码不登记(按平台错误统计)。
func googleError(c *gin.Context, status int, message string) {
googleErrorWithType(c, status, googleClientErrorType(status), "", message)
}
// googlePlatformError 以 Google 错误格式回写由平台侧原因(如管理员未完成配置)导致的错误,ops 按平台错误统计。
func googlePlatformError(c *gin.Context, status int, message string) {
googleErrorWithType(c, status, "", "", message)
}
// googleErrorWithType 以 Google 错误格式回写错误,并向 ops 登记错误类型与错误码(Google 错误体没有 type 字段),
// 与 Claude / OpenAI 格式错误同口径分类;errType 为空时不登记。
func googleErrorWithType(c *gin.Context, status int, errType, code, message string) {
service.SetOpsLocalErrorType(c, errType, code)
c.JSON(status, gin.H{
"error": gin.H{
"code": status,
@@ -854,11 +939,27 @@ func googleError(c *gin.Context, status int, message string) {
})
}
// googleClientErrorType 返回客户端请求类 4xx 状态码对应的 ops 错误类型;其余状态码返回空串。
func googleClientErrorType(status int) string {
switch status {
case http.StatusBadRequest, http.StatusRequestEntityTooLarge:
return "invalid_request_error"
case http.StatusUnauthorized:
return "authentication_error"
case http.StatusForbidden:
return "permission_error"
case http.StatusNotFound:
return "not_found_error"
default:
return ""
}
}
// googleConcurrencyError 以 Google 错误格式回写并发槽获取失败,状态码与文案
// 沿用 concurrencyErrorResponse 的统一映射(客户端断开为 499)。
func googleConcurrencyError(c *gin.Context, err error, slotType string) {
status, _, _, message := concurrencyErrorResponse(err, slotType)
googleError(c, status, message)
status, errType, code, message := concurrencyErrorResponse(err, slotType)
googleErrorWithType(c, status, errType, code, message)
}
func writeUpstreamResponse(c *gin.Context, res *service.UpstreamHTTPResult) {
@@ -970,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
}
@@ -109,7 +109,7 @@ func TestGeminiV1BetaHandler_ListModelsAntigravityFallback(t *testing.T) {
expectedBehavior string
}{
{
name: "有Gemini账户-调用ForwardAIStudioGET",
name: "有Gemini账户-调用ForwardListModels",
hasGeminiAccount: true,
hasAntigravity: false,
expectedBehavior: "forward_to_upstream",
@@ -155,7 +155,7 @@ func TestGeminiV1BetaHandler_GetModelAntigravityFallback(t *testing.T) {
expectedBehavior string
}{
{
name: "有Gemini账户-调用ForwardAIStudioGET",
name: "有Gemini账户-调用ForwardGetModel",
hasGeminiAccount: true,
hasAntigravity: false,
expectedBehavior: "forward_to_upstream",
@@ -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())
})
}
@@ -0,0 +1,83 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type geminiVertexModelsTokenCache struct{}
func (geminiVertexModelsTokenCache) GetAccessToken(context.Context, string) (string, error) {
return "ya29.vertex-token", nil
}
func (geminiVertexModelsTokenCache) SetAccessToken(context.Context, string, string, time.Duration) error {
return nil
}
func (geminiVertexModelsTokenCache) DeleteAccessToken(context.Context, string) error {
return nil
}
func (geminiVertexModelsTokenCache) AcquireRefreshLock(context.Context, string, time.Duration) (bool, error) {
return false, nil
}
func (geminiVertexModelsTokenCache) ReleaseRefreshLock(context.Context, string) error {
return nil
}
// 分组里只有 Agent Platform (Vertex) 服务账号时,模型列表与模型详情接口返回 Vertex 上的 Gemini 模型。
func TestGeminiV1BetaModelsEndpoints_VertexServiceAccountOnlyGroup(t *testing.T) {
groupID := int64(8)
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{groupID: {{
ID: 18,
Platform: service.PlatformGemini,
Type: service.AccountTypeServiceAccount,
Credentials: map[string]any{
"location": "global",
"service_account_json": map[string]any{
"type": "service_account",
"project_id": "proj",
"private_key": "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n",
"client_email": "svc@proj.iam.gserviceaccount.com",
},
},
}}}}}
upstream := &geminiMixedModelsUpstream{status: http.StatusOK, body: `{"publisherModels":[{"name":"publishers/google/models/gemini-3.8-flash"}]}`}
tokenProvider := service.NewGeminiTokenProvider(nil, geminiVertexModelsTokenCache{}, nil)
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, tokenProvider, nil, upstream, nil, &config.Config{})}
apiKey := &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini}}
t.Run("list", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
h.GeminiV1BetaListModels(c)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.JSONEq(t, `{"models":[{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent","streamGenerateContent"]}]}`, rec.Body.String())
})
t.Run("get", func(t *testing.T) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models/gemini-3.8-flash", nil)
c.Params = gin.Params{{Key: "model", Value: "gemini-3.8-flash"}}
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
h.GeminiV1BetaGetModel(c)
require.Equal(t, http.StatusOK, rec.Code, rec.Body.String())
require.JSONEq(t, `{"name":"models/gemini-3.8-flash","supportedGenerationMethods":["generateContent","streamGenerateContent"]}`, rec.Body.String())
})
}
@@ -0,0 +1,92 @@
package handler
import (
"encoding/json"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestToLocalizedModelPlazaGroups(t *testing.T) {
visible := []service.PlazaGroup{
{
ID: 1, Name: "标准", Description: "号池", Platform: "anthropic",
I18n: service.GroupI18n{"en": {Name: "Standard", Description: "Pro + Max pool"}},
},
{ID: 2, Name: "无译文", Description: "原描述", Platform: "openai"},
}
english := toLocalizedModelPlazaGroups(visible, nil, "en")
require.Equal(t, "Standard", english[0].Name)
require.Equal(t, "Pro + Max pool", english[0].Description)
require.Equal(t, "无译文", english[1].Name)
require.Equal(t, "原描述", english[1].Description)
chinese := toLocalizedModelPlazaGroups(visible, nil, "zh")
require.Equal(t, "标准", chinese[0].Name)
require.Equal(t, "号池", chinese[0].Description)
// service 层对象不被改写,同一批分组可继续按别的语言输出
require.Equal(t, "标准", visible[0].Name)
raw, err := json.Marshal(english[0])
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
require.NotContains(t, decoded, "i18n")
}
func TestLocalizeUserAvailableGroups(t *testing.T) {
refs := []service.AvailableGroupRef{
{ID: 1, Name: "标准", Platform: "anthropic", I18n: service.GroupI18n{"en": {Name: "Standard"}}},
{ID: 2, Name: "无译文", Platform: "anthropic"},
}
allowed := map[int64]struct{}{1: {}, 2: {}}
english := filterUserVisibleGroups(refs, allowed)
localizeUserAvailableGroups(english, "en")
require.Equal(t, "Standard", english[0].Name)
require.Equal(t, "无译文", english[1].Name)
chinese := filterUserVisibleGroups(refs, allowed)
localizeUserAvailableGroups(chinese, "zh")
require.Equal(t, "标准", chinese[0].Name)
raw, err := json.Marshal(english[0])
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
require.NotContains(t, decoded, "i18n")
}
// 用户侧 handler 输出带分组的 DTO 时必须经过 Localized,否则分组名称与描述会
// 停留在原字段,并把译文表原样带给用户。管理端(handler/admin)不在此列。
func TestUserFacingGroupDTOsAreLocalized(t *testing.T) {
mapperCall := regexp.MustCompile(`dto\.(APIKey|UserSubscription|RedeemCode|UsageLog|Group)FromService[A-Za-z]*\(`)
files, err := filepath.Glob("*.go")
require.NoError(t, err)
checked := 0
for _, file := range files {
if strings.HasSuffix(file, "_test.go") {
continue
}
source, err := os.ReadFile(file)
require.NoError(t, err)
for i, line := range strings.Split(string(source), "\n") {
if !mapperCall.MatchString(line) {
continue
}
checked++
require.Containsf(t, line, ".Localized(",
"%s:%d outputs a group DTO without Localized(Accept-Language)", file, i+1)
}
}
require.NotZero(t, checked, "expected to find user-facing group DTO call sites")
}
@@ -151,10 +151,10 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
visible := filterPlazaVisibleGroups(groups, allowedGroups, restrictPublicGroups)
out := make([]modelPlazaGroup, 0, len(visible))
for i := range visible {
out = append(out, toModelPlazaGroupDTO(&visible[i], userRates))
}
// 广场可匿名访问,分组文案随请求语言变化,提示中间缓存按语言区分。
// 追加而非覆盖:CORS 中间件已为具体来源写入 Vary: Origin。
c.Writer.Header().Add("Vary", "Accept-Language")
out := toLocalizedModelPlazaGroups(visible, userRates, c.GetHeader("Accept-Language"))
response.Success(c, modelPlazaResponse{
Description: rt.Description,
Groups: out,
@@ -185,6 +185,17 @@ func filterPlazaVisibleGroups(
return visible
}
// toLocalizedModelPlazaGroups 转换可见分组,并把名称与描述换成请求首选语言的版本。
func toLocalizedModelPlazaGroups(visible []service.PlazaGroup, userRates map[int64]float64, acceptLanguage string) []modelPlazaGroup {
out := make([]modelPlazaGroup, 0, len(visible))
for i := range visible {
group := toModelPlazaGroupDTO(&visible[i], userRates)
group.Name, group.Description = visible[i].I18n.Localize(acceptLanguage, group.Name, group.Description)
out = append(out, group)
}
return out
}
// toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。
func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup {
models := make([]modelPlazaModel, 0, len(g.Models))
@@ -3,11 +3,14 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -262,3 +265,43 @@ func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) {
require.Equal(t, int64(42), visible[0].ID)
}
}
type plazaVarySettingRepo struct{ service.SettingRepository }
func (plazaVarySettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
return map[string]string{service.SettingKeyModelPlazaEnabled: "true"}, nil
}
type plazaVaryChannelRepo struct{ service.ChannelRepository }
func (plazaVaryChannelRepo) ListAll(context.Context) ([]service.Channel, error) { return nil, nil }
type plazaVaryGroupRepo struct{ service.GroupRepository }
func (plazaVaryGroupRepo) ListActive(context.Context) ([]service.Group, error) { return nil, nil }
func TestModelPlazaHandler_VaryKeepsCORSOrigin(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewModelPlazaHandler(
service.NewModelPlazaService(plazaVaryChannelRepo{}, plazaVaryGroupRepo{}, nil, nil, nil),
nil,
service.NewSettingService(plazaVarySettingRepo{}, &config.Config{}),
)
r := gin.New()
r.Use(middleware.CORS(config.CORSConfig{AllowedOrigins: []string{"https://a.example", "https://b.example"}}))
r.GET("/api/v1/model-plaza", h.Get)
for _, origin := range []string{"https://a.example", "https://b.example"} {
req := httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil)
req.Header.Set("Origin", origin)
req.Header.Set("Accept-Language", "en")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.Equal(t, origin, w.Header().Get("Access-Control-Allow-Origin"))
// 共享缓存需同时按来源与语言区分,否则 A 来源的响应可能被复用给 B。
require.Equal(t, []string{"Origin", "Accept-Language"}, w.Header().Values("Vary"))
}
}
@@ -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
@@ -1183,6 +1183,14 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
}
}
// Google 格式错误体没有 type 字段:按网关回写时登记的错误类型补齐。
if errType, code := service.OpsLocalErrorType(c); errType != "" && !parsed.StreamFailure &&
(parsed.ErrorType == "" || parsed.ErrorType == "api_error") {
parsed.ErrorType = errType
if code != "" {
parsed.Code = code
}
}
normalizedType := normalizeOpsErrorType(parsed.ErrorType, parsed.Code)
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, normalizedType, parsed.Message, parsed.Code, status)
@@ -2214,6 +2222,11 @@ func classifyOpsSeverity(errType string, status int) string {
func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status int) (phase string, isBusinessLimited bool, errorOwner string, errorSource string) {
phase = classifyOpsPhase(errType, message, code)
if service.IsOpsRequestScopedError(c) {
// 失败由请求内容决定:只按错误类型分类,上游错误上下文不参与归因。
isBusinessLimited = classifyOpsIsBusinessLimited(errType, phase, code, status, message)
return phase, isBusinessLimited, classifyOpsErrorOwner(phase, message), classifyOpsErrorSource(phase, message)
}
routingCapacityLimited := isOpsRoutingCapacityLimited(c)
clientBusinessLimited := service.HasOpsClientBusinessLimited(c)
localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration
@@ -0,0 +1,135 @@
package handler
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// recordGoogleFormatOpsError 让 handle 以 Google 错误格式回写错误,返回 ops 中间件记录的错误日志。
func recordGoogleFormatOpsError(t *testing.T, handle gin.HandlerFunc) *service.OpsInsertErrorLogInput {
t.Helper()
setupOpsErrorLogTestQueue(t, 1)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1beta/cachedContents", handle)
router.ServeHTTP(httptest.NewRecorder(), httptest.NewRequest(http.MethodPost, "/v1beta/cachedContents", nil))
require.Equal(t, int64(1), OpsErrorLogQueueLength())
return (<-opsErrorLogQueue).entry
}
func TestOpsErrorLoggerMiddleware_GoogleClientErrorsClassifiedAsClient(t *testing.T) {
for _, tc := range []struct {
status int
errType string
phase string
}{
{status: http.StatusBadRequest, errType: "invalid_request_error", phase: "request"},
{status: http.StatusRequestEntityTooLarge, errType: "invalid_request_error", phase: "request"},
{status: http.StatusUnauthorized, errType: "authentication_error", phase: "auth"},
{status: http.StatusForbidden, errType: "permission_error", phase: "request"},
{status: http.StatusNotFound, errType: "not_found_error", phase: "request"},
} {
t.Run(http.StatusText(tc.status), func(t *testing.T) {
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
googleError(c, tc.status, "client request problem")
})
require.Equal(t, tc.status, entry.StatusCode)
require.Equal(t, tc.errType, entry.ErrorType)
require.Equal(t, tc.phase, entry.ErrorPhase)
require.Equal(t, "client", entry.ErrorOwner)
require.Equal(t, "client_request", entry.ErrorSource)
require.Equal(t, "P3", entry.Severity)
})
}
}
func TestOpsErrorLoggerMiddleware_GooglePlatformErrorStaysPlatform(t *testing.T) {
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
googlePlatformError(c, http.StatusBadRequest, "Explicit context caching is not available for model m: storage pricing is not configured")
})
require.Equal(t, "api_error", entry.ErrorType)
require.Equal(t, "internal", entry.ErrorPhase)
require.Equal(t, "platform", entry.ErrorOwner)
require.Equal(t, "gateway", entry.ErrorSource)
}
func TestOpsErrorLoggerMiddleware_GoogleServerErrorsNotTypedByStatus(t *testing.T) {
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
googleError(c, http.StatusInternalServerError, "Failed to persist cached content")
})
require.Equal(t, "api_error", entry.ErrorType)
require.Equal(t, "platform", entry.ErrorOwner)
}
func TestOpsErrorLoggerMiddleware_GoogleBillingErrorMatchesClaudeFormat(t *testing.T) {
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
status, code, message, _ := billingErrorDetails(service.ErrInsufficientBalance)
googleErrorWithType(c, status, code, "", message)
})
require.Equal(t, "billing_error", entry.ErrorType)
require.Equal(t, "request", entry.ErrorPhase)
require.Equal(t, "client", entry.ErrorOwner)
require.True(t, entry.IsBusinessLimited)
}
func TestOpsErrorLoggerMiddleware_GoogleConcurrencyErrorClassifiedAsRequest(t *testing.T) {
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
googleConcurrencyError(c, &ConcurrencyError{SlotType: "user"}, "user")
})
require.Equal(t, http.StatusTooManyRequests, entry.StatusCode)
require.Equal(t, "rate_limit_error", entry.ErrorType)
require.Equal(t, "request", entry.ErrorPhase)
require.Equal(t, "client", entry.ErrorOwner)
}
func TestOpsErrorLoggerMiddleware_GoogleErrorAfterUpstreamFailureKeepsUpstreamAttribution(t *testing.T) {
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
service.SetOpsUpstreamError(c, http.StatusBadRequest, "upstream rejected", "")
googleError(c, http.StatusBadRequest, "upstream rejected")
})
require.Equal(t, "upstream", entry.ErrorPhase)
require.Equal(t, "provider", entry.ErrorOwner)
require.Equal(t, "upstream_http", entry.ErrorSource)
}
func TestOpsErrorLoggerMiddleware_RequestScopedUpstreamErrorClassifiedAsClient(t *testing.T) {
entry := recordGoogleFormatOpsError(t, func(c *gin.Context) {
service.SetOpsUpstreamError(c, http.StatusBadRequest, "Invalid resource state for cache content 8079767076522164224.", "")
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
Platform: service.PlatformGemini, AccountID: 173, UpstreamStatusCode: http.StatusBadRequest, Kind: "http_error",
Message: "Invalid resource state for cache content 8079767076522164224.",
}})
service.MarkOpsRequestScopedError(c, "permission_error")
c.Data(http.StatusForbidden, "application/json", []byte(service.GeminiCachedContentNotFoundResponse))
})
require.Equal(t, http.StatusForbidden, entry.StatusCode)
require.Equal(t, "permission_error", entry.ErrorType)
require.Equal(t, "request", entry.ErrorPhase)
require.Equal(t, "client", entry.ErrorOwner)
require.Equal(t, "client_request", entry.ErrorSource)
require.False(t, entry.IsBusinessLimited)
require.NotNil(t, entry.UpstreamStatusCode, "上游原始错误保留为明细")
require.Equal(t, http.StatusBadRequest, *entry.UpstreamStatusCode)
}
func TestSetOpsLocalErrorTypeClearsRequestScopedMark(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
service.MarkOpsRequestScopedError(c, "permission_error")
require.True(t, service.IsOpsRequestScopedError(c))
service.SetOpsLocalErrorType(c, "", "")
require.False(t, service.IsOpsRequestScopedError(c))
errType, code := service.OpsLocalErrorType(c)
require.Empty(t, errType)
require.Empty(t, code)
}
+2 -2
View File
@@ -78,7 +78,7 @@ func (h *PaymentHandler) GetPlans(c *gin.Context) {
gi := groupInfo[p.GroupID]
result = append(result, planWithPlatform{
ID: int64(p.ID), GroupID: p.GroupID,
GroupPlatform: gi.Platform, GroupName: gi.Name,
GroupPlatform: gi.Platform, GroupName: gi.I18n.LocalizeName(c.GetHeader("Accept-Language"), gi.Name),
RateMultiplier: gi.RateMultiplier, PeakRateEnabled: gi.PeakRateEnabled,
PeakStart: gi.PeakStart, PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier,
Name: p.Name, Description: p.Description, Price: p.Price, OriginalPrice: p.OriginalPrice,
@@ -126,7 +126,7 @@ func (h *PaymentHandler) GetCheckoutInfo(c *gin.Context) {
gi := groupInfo[p.GroupID]
planList = append(planList, checkoutPlan{
ID: int64(p.ID), GroupID: p.GroupID,
GroupPlatform: gi.Platform, GroupName: gi.Name,
GroupPlatform: gi.Platform, GroupName: gi.I18n.LocalizeName(c.GetHeader("Accept-Language"), gi.Name),
RateMultiplier: gi.RateMultiplier,
PeakRateEnabled: gi.PeakRateEnabled, PeakStart: gi.PeakStart,
PeakEnd: gi.PeakEnd, PeakRateMultiplier: gi.PeakRateMultiplier,
+3 -3
View File
@@ -59,7 +59,7 @@ func (h *RedeemHandler) Redeem(c *gin.Context) {
return
}
response.Success(c, dto.RedeemCodeFromService(result))
response.Success(c, dto.RedeemCodeFromService(result).Localized(c.GetHeader("Accept-Language")))
}
// GetHistory returns the user's redemption history
@@ -98,7 +98,7 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) {
}
out := make([]dto.RedeemCode, 0, len(codes))
for i := range codes {
out = append(out, *dto.RedeemCodeFromService(&codes[i]))
out = append(out, *dto.RedeemCodeFromService(&codes[i]).Localized(c.GetHeader("Accept-Language")))
}
response.Paginated(c, out, result.Total, page, pageSize)
return
@@ -115,7 +115,7 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) {
out := make([]dto.RedeemCode, 0, len(codes))
for i := range codes {
out = append(out, *dto.RedeemCodeFromService(&codes[i]))
out = append(out, *dto.RedeemCodeFromService(&codes[i]).Localized(c.GetHeader("Accept-Language")))
}
response.Success(c, out)
}
@@ -100,9 +100,14 @@ func googleSecurityAuditError(c *gin.Context, decision *securityaudit.Decision)
return
}
if decision.Legacy != nil && decision.Legacy.Blocked {
googleError(c, securityAuditStatus(decision), securityAuditMessage(decision))
googleErrorWithType(c, securityAuditStatus(decision), securityAuditErrorCode(decision), "", securityAuditMessage(decision))
return
}
errType := "api_error"
if decision.Kind == securityaudit.DecisionBlock {
errType = "permission_error"
}
service.SetOpsLocalErrorType(c, errType, securityAuditErrorCode(decision))
status := securityAuditStatus(decision)
googleStatus := googleapi.HTTPStatusToGoogleStatus(status)
if status == http.StatusServiceUnavailable {
@@ -24,6 +24,7 @@ func TestPromptAuditGatePrecedesAccountBillingAndUpstreamSideEffects(t *testing.
{file: "gateway_handler_chat_completions.go", function: "ChatCompletions", auditToken: "checkSecurityAudit"},
{file: "gateway_handler_responses.go", function: "Responses", auditToken: "checkSecurityAudit"},
{file: "gemini_v1beta_handler.go", function: "GeminiV1BetaModels", auditToken: "checkSecurityAudit"},
{file: "gemini_cached_contents_handler.go", function: "GeminiCachedContentsCreate", auditToken: "checkSecurityAudit"},
{file: "openai_gateway_handler.go", function: "Responses", auditToken: "checkSecurityAudit"},
{file: "openai_gateway_handler.go", function: "Messages", auditToken: "checkSecurityAudit"},
{file: "openai_chat_completions.go", function: "ChatCompletions", auditToken: "checkSecurityAudit"},
@@ -59,7 +59,7 @@ func (h *SubscriptionHandler) List(c *gin.Context) {
out := make([]dto.UserSubscription, 0, len(subscriptions))
for i := range subscriptions {
out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i]))
out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i]).Localized(c.GetHeader("Accept-Language")))
}
response.Success(c, out)
}
@@ -81,7 +81,7 @@ func (h *SubscriptionHandler) GetActive(c *gin.Context) {
out := make([]dto.UserSubscription, 0, len(subscriptions))
for i := range subscriptions {
out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i]))
out = append(out, *dto.UserSubscriptionFromService(&subscriptions[i]).Localized(c.GetHeader("Accept-Language")))
}
response.Success(c, out)
}
@@ -110,8 +110,11 @@ func (h *SubscriptionHandler) GetProgress(c *gin.Context) {
// Skip subscriptions with errors
continue
}
if sub.Group != nil {
progress.GroupName = sub.Group.I18n.LocalizeName(c.GetHeader("Accept-Language"), progress.GroupName)
}
result = append(result, SubscriptionProgressInfo{
Subscription: dto.UserSubscriptionFromService(sub),
Subscription: dto.UserSubscriptionFromService(sub).Localized(c.GetHeader("Accept-Language")),
Progress: progress,
})
}
@@ -150,7 +153,7 @@ func (h *SubscriptionHandler) GetSummary(c *gin.Context) {
// Add group info if preloaded
if sub.Group != nil {
item.GroupName = sub.Group.Name
item.GroupName = sub.Group.I18n.LocalizeName(c.GetHeader("Accept-Language"), sub.Group.Name)
if sub.Group.DailyLimitUSD != nil {
item.DailyLimitUSD = *sub.Group.DailyLimitUSD
}
+2 -2
View File
@@ -254,7 +254,7 @@ func (h *UsageHandler) List(c *gin.Context) {
out := make([]dto.UsageLog, 0, len(records))
for i := range records {
out = append(out, *dto.UsageLogFromService(&records[i]))
out = append(out, *dto.UsageLogFromService(&records[i]).Localized(c.GetHeader("Accept-Language")))
}
response.Paginated(c, out, result.Total, page, pageSize)
}
@@ -400,7 +400,7 @@ func (h *UsageHandler) GetByID(c *gin.Context) {
return
}
response.Success(c, dto.UsageLogFromService(record))
response.Success(c, dto.UsageLogFromService(record).Localized(c.GetHeader("Accept-Language")))
}
// Stats handles getting usage statistics
+2
View File
@@ -113,11 +113,13 @@ func ProvideGatewayHandler(
cfg *config.Config,
settingService *service.SettingService,
coordinator *securityaudit.Coordinator,
geminiCachedContentService *service.GeminiCachedContentService,
) *GatewayHandler {
h := NewGatewayHandler(gatewayService, openAIGatewayService, geminiCompatService, antigravityGatewayService,
userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool,
errorPassthroughService, contentModerationService, userMsgQueueService, cfg, settingService)
h.securityAuditCoordinator = coordinator
h.geminiCachedContentService = geminiCachedContentService
return h
}
+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")
}
}
+6 -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.
@@ -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)
}
@@ -1940,6 +1940,72 @@ func TestSonnet55SignedThinkingResponsesRoundTrip(t *testing.T) {
require.Equal(t, "tool_use", blocks[2].Type)
}
func TestHaiku55ResponsesThinkingAndSampling(t *testing.T) {
for _, effort := range []string{"", "low", "medium", "high", "xhigh", "max", "none"} {
req := &ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: effort}}
out, err := ResponsesToAnthropicRequest(req)
require.NoError(t, err, effort)
require.Zero(t, out.Thinking.BudgetTokens)
if effort == "none" {
require.Equal(t, "disabled", out.Thinking.Type)
require.Equal(t, "low", out.OutputConfig.Effort)
} else {
require.Equal(t, "adaptive", out.Thinking.Type)
if effort == "" {
effort = "medium"
}
require.Equal(t, effort, out.OutputConfig.Effort)
}
}
// Haiku 5.5 accepts forced tool_choice, unlike opus/sonnet 5.5.
for _, choice := range []string{`"required"`, `{"type":"function","name":"lookup"}`} {
out, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), ToolChoice: json.RawMessage(choice)})
require.NoError(t, err, choice)
require.NotEmpty(t, out.ToolChoice)
}
temperature := 0.7
_, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature})
require.ErrorContains(t, err, "temperature")
for _, topP := range []float64{0.5, 0.995, 1} {
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), TopP: &topP})
require.ErrorContains(t, err, "top_p", "top_p=%v", topP)
}
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Reasoning: &ResponsesReasoning{Effort: "minimal"}})
require.ErrorContains(t, err, "reasoning effort")
temperature = 1
topP := 0.99
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature})
require.NoError(t, err)
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), TopP: &topP})
require.NoError(t, err)
_, err = ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: json.RawMessage(`"hello"`), Temperature: &temperature, TopP: &topP})
require.ErrorContains(t, err, "together")
}
func TestHaiku55SignedThinkingResponsesRoundTrip(t *testing.T) {
block := AnthropicContentBlock{Type: "thinking", Thinking: "", Signature: "signed-haiku-block"}
response := AnthropicToResponsesResponse(&AnthropicResponse{Model: "claude-haiku-5-5", Content: []AnthropicContentBlock{block, {Type: "text", Text: "progress"}, {Type: "tool_use", ID: "toolu_1", Name: "lookup", Input: json.RawMessage(`{}`)}}})
require.Len(t, response.Output, 3)
require.NotEmpty(t, response.Output[0].EncryptedContent)
require.Equal(t, "message", response.Output[1].Type)
raw, err := json.Marshal(response.Output)
require.NoError(t, err)
var items []ResponsesInputItem
require.NoError(t, json.Unmarshal(raw, &items))
items = append(items, ResponsesInputItem{Type: "function_call_output", CallID: response.Output[2].CallID, Output: "ok"})
raw, err = json.Marshal(items)
require.NoError(t, err)
converted, err := ResponsesToAnthropicRequest(&ResponsesRequest{Model: "claude-haiku-5-5", Input: raw})
require.NoError(t, err)
require.Len(t, converted.Messages, 2)
var blocks []AnthropicContentBlock
require.NoError(t, json.Unmarshal(converted.Messages[0].Content, &blocks))
require.Equal(t, block, blocks[0])
require.Equal(t, "text", blocks[1].Type)
require.Equal(t, "tool_use", blocks[2].Type)
}
func TestGPT6ChatSamplingAndCacheFields(t *testing.T) {
temperature := 0.7
for _, model := range []string{"gpt-6-sol", "gpt-6-luna"} {
@@ -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
}
@@ -53,7 +53,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
for _, block := range resp.Content {
switch block.Type {
case "thinking", "redacted_thinking":
if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && (block.Signature != "" || block.Data != "") {
if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model) || claude.IsHaiku55(resp.Model)) && (block.Signature != "" || block.Data != "") {
item := ResponsesOutput{Type: "reasoning", ID: generateItemID(), EncryptedContent: encodeAnthropicThinking(block)}
if block.Thinking != "" {
item.Summary = []ResponsesSummary{{Type: "summary_text", Text: block.Thinking}}
@@ -72,7 +72,7 @@ func AnthropicToResponsesResponse(resp *AnthropicResponse) *ResponsesResponse {
})
}
case "text":
if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model)) && block.Text != "" {
if (claude.IsOpus55(resp.Model) || claude.IsSonnet55(resp.Model) || claude.IsHaiku55(resp.Model)) && block.Text != "" {
outputs = append(outputs, ResponsesOutput{Type: "message", ID: generateItemID(), Role: "assistant", Status: "completed", Content: []ResponsesContentPart{{Type: "output_text", Text: block.Text}}})
continue
}
@@ -285,7 +285,7 @@ func ResponsesEventToSSE(evt ResponsesStreamEvent) (string, error) {
func anthToResHandleMessageStart(evt *AnthropicStreamEvent, state *AnthropicEventToResponsesState) []ResponsesStreamEvent {
if evt.Message != nil {
state.ResponseID = evt.Message.ID
state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) || claude.IsSonnet55(evt.Message.Model)
state.PreserveThinkingSignatures = state.PreserveThinkingSignatures || claude.IsOpus55(evt.Message.Model) || claude.IsSonnet55(evt.Message.Model) || claude.IsHaiku55(evt.Message.Model)
if state.Model == "" {
state.Model = evt.Message.Model
}
@@ -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
}
@@ -16,7 +16,8 @@ import (
func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, error) {
isOpus55 := claude.IsOpus55(req.Model)
isSonnet55 := claude.IsSonnet55(req.Model)
system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, isOpus55 || isSonnet55)
isHaiku55 := claude.IsHaiku55(req.Model)
system, messages, err := convertResponsesInputToAnthropic(req.Instructions, req.Input, isOpus55 || isSonnet55 || isHaiku55)
if err != nil {
return nil, err
}
@@ -56,11 +57,13 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro
out.ToolChoice = tc
}
// The 5.5 models reject manual thinking and forced tool use. Sonnet 5.5
// additionally supports between_tools to disable up-front thinking.
// The 5.5 models reject manual thinking. Opus/Sonnet 5.5 additionally reject
// forced tool use; Haiku 5.5 accepts it (starts with the tool call, no thinking
// block). Sonnet 5.5 supports between_tools to disable up-front thinking;
// Haiku 5.5 uses disabled (at high effort or below) instead.
// Resolve the upstream model before conversion: client aliases need not
// identify a Claude model.
if isOpus55 || isSonnet55 {
if isOpus55 || isSonnet55 || isHaiku55 {
var choice struct {
Type string `json:"type"`
}
@@ -69,7 +72,7 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro
return nil, fmt.Errorf("invalid tool_choice: %w", err)
}
}
if choice.Type == "any" || choice.Type == "tool" {
if (choice.Type == "any" || choice.Type == "tool") && !isHaiku55 {
return nil, fmt.Errorf("%s does not support forced tool_choice; use auto or none", req.Model)
}
effort := "medium"
@@ -82,6 +85,17 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro
return nil, fmt.Errorf("claude-sonnet-5-5 does not support non-default top_p")
}
}
if isHaiku55 {
if req.Temperature != nil && *req.Temperature != 1 {
return nil, fmt.Errorf("claude-haiku-5-5 does not support non-default temperature")
}
if req.TopP != nil && *req.TopP != 0.99 {
return nil, fmt.Errorf("claude-haiku-5-5 does not support non-default top_p")
}
if req.Temperature != nil && req.TopP != nil {
return nil, fmt.Errorf("claude-haiku-5-5 does not allow temperature and top_p together; omit both")
}
}
if req.Reasoning != nil && req.Reasoning.Effort != "" {
effort = req.Reasoning.Effort
}
@@ -96,6 +110,16 @@ func ResponsesToAnthropicRequest(req *ResponsesRequest) (*AnthropicRequest, erro
out.OutputConfig.Effort = "low"
return out, nil
}
if isHaiku55 && effort == "none" {
// Haiku 5.5's lowest thinking mode is disabled (valid at high
// effort or below); low keeps token spend minimal like Sonnet above.
out.Thinking = &AnthropicThinking{Type: "disabled"}
if out.OutputConfig == nil {
out.OutputConfig = &AnthropicOutputConfig{}
}
out.OutputConfig.Effort = "low"
return out, nil
}
switch effort {
case "low", "medium", "high", "xhigh", "max":
default:
+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
+6
View File
@@ -223,6 +223,12 @@ var DefaultModels = []Model{
DisplayName: "Claude Haiku 4.5",
CreatedAt: "2025-10-01T00:00:00Z",
},
{
ID: "claude-haiku-5-5",
Type: "model",
DisplayName: "Claude Haiku 5.5",
CreatedAt: "2026-10-07T00:00:00Z",
},
}
// DefaultModelIDs 返回默认模型的 ID 列表
@@ -43,3 +43,16 @@ func TestDefaultModelsContainsSonnet55(t *testing.T) {
}
t.Fatal("claude-sonnet-5-5 missing from DefaultModels")
}
func TestDefaultModelsContainsHaiku55(t *testing.T) {
t.Parallel()
for _, model := range DefaultModels {
if model.ID == "claude-haiku-5-5" {
require.Equal(t, "Claude Haiku 5.5", model.DisplayName)
require.Equal(t, "2026-10-07T00:00:00Z", model.CreatedAt)
return
}
}
t.Fatal("claude-haiku-5-5 missing from DefaultModels")
}
@@ -27,6 +27,7 @@ var effortFamilies = []struct {
{family: "claude-opus-4-5", levels: effortLowMediumHigh},
{family: "claude-opus-5-5", levels: effortLowMediumHighXHighMax},
{family: "claude-opus-5", levels: effortLowMediumHighXHighMax},
{family: "claude-haiku-5-5", levels: effortLowMediumHighXHighMax},
}
// EffortLevelsForModel returns the output_config.effort values accepted by a
@@ -51,6 +52,11 @@ func IsSonnet55(model string) bool {
return normalizeEffortModelID(model) == "claude-sonnet-5-5"
}
// IsHaiku55 identifies the fixed Haiku 5.5 ID after provider/local suffix normalization.
func IsHaiku55(model string) bool {
return normalizeEffortModelID(model) == "claude-haiku-5-5"
}
func normalizeEffortModelID(model string) string {
id := strings.ToLower(strings.TrimSpace(model))
id = strings.TrimPrefix(id, "models/")
@@ -70,6 +76,9 @@ func normalizeEffortModelID(model string) string {
if id == "claude-sonnet-5.5" {
id = "claude-sonnet-5-5"
}
if id == "claude-haiku-5.5" {
id = "claude-haiku-5-5"
}
if mapped, ok := ModelIDReverseOverrides[id]; ok {
id = mapped
}
@@ -18,6 +18,9 @@ func TestEffortLevelsForModel(t *testing.T) {
{model: "claude-opus-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "anthropic/claude-opus-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "claude-haiku-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "anthropic/claude-haiku-5.5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "us.anthropic.claude-haiku-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "us.anthropic.claude-sonnet-5-5", want: []string{"low", "medium", "high", "xhigh", "max"}},
{model: "claude-opus-4-5-20251101", want: []string{"low", "medium", "high"}},
{model: "claude-haiku-4-5-20251001", want: nil},
@@ -57,3 +60,19 @@ func TestIsSonnet55(t *testing.T) {
require.False(t, IsSonnet55(model), model)
}
}
func TestIsHaiku55(t *testing.T) {
t.Parallel()
for _, model := range []string{
"claude-haiku-5-5",
"anthropic/claude-haiku-5.5",
"anthropic.claude-haiku-5-5",
"us.anthropic.claude-haiku-5-5",
"global.anthropic.claude-haiku-5-5-thinking",
} {
require.True(t, IsHaiku55(model), model)
}
for _, model := range []string{"claude-haiku-5", "claude-haiku-4-5", "claude-haiku-4-5-20251001", "claude-sonnet-5-5", "claude-opus-5-5"} {
require.False(t, IsHaiku55(model), model)
}
}
+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
+3 -2
View File
@@ -857,7 +857,7 @@ func (r *apiKeyRepository) ResetRateLimitWindows(ctx context.Context, id int64)
// GetRateLimitData returns the current rate limit usage and window start times for an API key.
func (r *apiKeyRepository) GetRateLimitData(ctx context.Context, id int64) (result *service.APIKeyRateLimitData, err error) {
rows, err := r.sql.QueryContext(ctx, `
SELECT usage_5h, usage_1d, usage_7d, window_5h_start, window_1d_start, window_7d_start
SELECT quota_used, usage_5h, usage_1d, usage_7d, window_5h_start, window_1d_start, window_7d_start
FROM api_keys
WHERE id = $1 AND deleted_at IS NULL`,
id)
@@ -873,7 +873,7 @@ func (r *apiKeyRepository) GetRateLimitData(ctx context.Context, id int64) (resu
return nil, service.ErrAPIKeyNotFound
}
data := &service.APIKeyRateLimitData{}
if err := rows.Scan(&data.Usage5h, &data.Usage1d, &data.Usage7d, &data.Window5hStart, &data.Window1dStart, &data.Window7dStart); err != nil {
if err := rows.Scan(&data.QuotaUsed, &data.Usage5h, &data.Usage1d, &data.Usage7d, &data.Window5hStart, &data.Window1dStart, &data.Window7dStart); err != nil {
return nil, err
}
return data, rows.Err()
@@ -979,6 +979,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
ID: g.ID,
Name: g.Name,
Description: derefString(g.Description),
I18n: g.I18n,
Platform: g.Platform,
RateMultiplier: g.RateMultiplier,
IsExclusive: g.IsExclusive,
@@ -464,6 +464,10 @@ func (s *APIKeyRepoSuite) TestIncrementQuotaUsed_Basic() {
newQuota, err = s.repo.IncrementQuotaUsed(s.ctx, key.ID, 2.5)
s.Require().NoError(err, "IncrementQuotaUsed second")
s.Require().Equal(4.0, newQuota, "第二次递增后应为 4.0")
data, err := s.repo.GetRateLimitData(s.ctx, key.ID)
s.Require().NoError(err, "GetRateLimitData")
s.Require().Equal(4.0, data.QuotaUsed, "用量读取带回总额度已用量")
}
func (s *APIKeyRepoSuite) TestIncrementQuotaUsed_NotFound() {
@@ -25,6 +25,20 @@ func billingInflightKeys(userID int64) (string, string) {
fmt.Sprintf("%s{%d}", billingInflightAmtKeyPrefix, userID)
}
// 订阅 / API Key 维度在途预留(确定金额费用):
// - billing:inflight_scope:{scope} ZSET member=requestID score=过期时间(ms)
// - billing:inflight_scope_amt:{scope} HASH field=requestID value=预留金额(USD)
//
// 过期清理与释放语义与余额维度相同,不续期。
const (
billingInflightScopeKeyPrefix = "billing:inflight_scope:"
billingInflightScopeAmtKeyPrefix = "billing:inflight_scope_amt:"
)
func billingInflightScopeKeys(scope string) (string, string) {
return billingInflightScopeKeyPrefix + "{" + scope + "}", billingInflightScopeAmtKeyPrefix + "{" + scope + "}"
}
var (
// KEYS: [1]=zset [2]=hash
// ARGV: [1]=now_ms [2]=expire_at_ms [3]=member [4]=amount [5]=balance [6]=key_ttl_ms
@@ -85,6 +99,28 @@ var (
redis.call('HDEL', KEYS[2], ARGV[1])
return 1
`)
// KEYS: [1]=zset [2]=hash
// ARGV: [1]=now_ms [2]=expire_at_ms [3]=member [4]=amount [5]=key_ttl_ms
// 先清理已过期成员,再登记本次预留;返回登记前其他在途预留的合计(string),是否放行由调用方按权威用量判定。
reserveInflightScopeScript = redis.NewScript(`
local now = tonumber(ARGV[1])
local expired = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', now)
if #expired > 0 then
redis.call('ZREMRANGEBYSCORE', KEYS[1], '-inf', now)
redis.call('HDEL', KEYS[2], unpack(expired))
end
local vals = redis.call('HVALS', KEYS[2])
local sum = 0
for i = 1, #vals do
sum = sum + (tonumber(vals[i]) or 0)
end
redis.call('ZADD', KEYS[1], tonumber(ARGV[2]), ARGV[3])
redis.call('HSET', KEYS[2], ARGV[3], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[5])
redis.call('PEXPIRE', KEYS[2], ARGV[5])
return tostring(sum)
`)
)
// ReserveInflightBalance 实现 service.InflightBalanceReservationCache。
@@ -138,7 +174,35 @@ func (c *billingCache) RenewInflightBalance(ctx context.Context, userID int64, r
return n == 1, nil
}
// ReserveInflightScope 实现 service.InflightScopeReservationCache。
func (c *billingCache) ReserveInflightScope(ctx context.Context, scope, requestID string, amount float64, ttl time.Duration) (float64, error) {
zkey, hkey := billingInflightScopeKeys(scope)
now := time.Now().UnixMilli()
ttlMs := ttl.Milliseconds()
if ttlMs <= 0 {
ttlMs = 1
}
res, err := reserveInflightScopeScript.Run(ctx, c.rdb, []string{zkey, hkey},
now,
now+ttlMs,
requestID,
strconv.FormatFloat(amount, 'f', -1, 64),
ttlMs,
).Text()
if err != nil {
return 0, err
}
return strconv.ParseFloat(res, 64)
}
// ReleaseInflightScope 实现 service.InflightScopeReservationCache。
func (c *billingCache) ReleaseInflightScope(ctx context.Context, scope, requestID string) error {
zkey, hkey := billingInflightScopeKeys(scope)
return releaseInflightBalanceScript.Run(ctx, c.rdb, []string{zkey, hkey}, requestID).Err()
}
var (
_ service.InflightBalanceReservationCache = (*billingCache)(nil)
_ service.InflightBalanceReservationRenewer = (*billingCache)(nil)
_ service.InflightScopeReservationCache = (*billingCache)(nil)
)
@@ -285,3 +285,72 @@ func TestInflightReservation_RenewDoesNotResurrectExpiredMember(t *testing.T) {
require.NoError(t, err)
require.False(t, exists)
}
func TestInflightScopeReservation_ReturnsOthersAndReleases(t *testing.T) {
mr, cache, _ := newInflightTestEnv(t, true, 60)
ctx := context.Background()
others, err := cache.ReserveInflightScope(ctx, "apikey:7", "r1", 3, time.Minute)
require.NoError(t, err)
require.Zero(t, others)
others, err = cache.ReserveInflightScope(ctx, "apikey:7", "r2", 2, time.Minute)
require.NoError(t, err)
require.InDelta(t, 3, others, 1e-9, "返回登记前其他在途预留的合计")
others, err = cache.ReserveInflightScope(ctx, "sub:7", "r3", 5, time.Minute)
require.NoError(t, err)
require.Zero(t, others, "不同维度互不影响")
require.NoError(t, cache.ReleaseInflightScope(ctx, "apikey:7", "r1"))
require.NoError(t, cache.ReleaseInflightScope(ctx, "apikey:7", "r1"))
others, err = cache.ReserveInflightScope(ctx, "apikey:7", "r4", 1, time.Minute)
require.NoError(t, err)
require.InDelta(t, 2, others, 1e-9)
mr.FastForward(2 * time.Minute)
others, err = cache.ReserveInflightScope(ctx, "apikey:7", "r5", 1, time.Minute)
require.NoError(t, err)
require.Zero(t, others, "过期预留不再计入")
}
func TestReserveFixedCharge_ConcurrentChargesAdmitOnlyWhatAPIKeyQuotaCovers(t *testing.T) {
_, cache, svc := newInflightTestEnv(t, true, 60)
ctx := context.Background()
user := &service.User{ID: 42}
apiKey := &service.APIKey{ID: 9, UserID: user.ID, Quota: 1.0}
require.NoError(t, cache.SetUserBalance(ctx, user.ID, 100))
const n = 20
var admitted atomic.Int32
reservations := make(chan *service.InflightReservation, n)
var wg sync.WaitGroup
start := make(chan struct{})
for i := 0; i < n; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start
res, err := svc.ReserveFixedCharge(ctx, user, apiKey, nil, nil, 0.3)
if err != nil {
if !errors.Is(err, service.ErrAPIKeyQuotaExhausted) {
t.Errorf("unexpected error: %v", err)
}
return
}
admitted.Add(1)
reservations <- res
}()
}
close(start)
wg.Wait()
close(reservations)
require.Equal(t, int32(3), admitted.Load(), "Key 额度 1.0、每笔 0.3:同时在途的不超过 3 笔")
for res := range reservations {
res.Release()
}
zkey, _ := billingInflightScopeKeys("apikey:9")
remaining, err := cache.rdb.ZCard(ctx, zkey).Result()
require.NoError(t, err)
require.Zero(t, remaining)
require.Equal(t, int64(0), inflightCount(t, cache, user.ID))
}
@@ -0,0 +1,205 @@
package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type geminiCachedContentSQLExecutor interface {
ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
}
type geminiCachedContentRepository struct {
sql geminiCachedContentSQLExecutor
}
func NewGeminiCachedContentRepository(db *sql.DB) service.GeminiCachedContentRepository {
return &geminiCachedContentRepository{sql: db}
}
const geminiCachedContentColumns = `id, public_id, user_id, api_key_id, group_id, account_id, upstream_name, model,
upstream_model, display_name, total_token_count, expire_time, channel_usage, created_at, updated_at`
func (r *geminiCachedContentRepository) Create(ctx context.Context, record *service.GeminiCachedContent) error {
channelUsage, err := json.Marshal(record.ChannelUsage)
if err != nil {
return err
}
row := r.sql.QueryRowContext(ctx, `
INSERT INTO gemini_cached_contents (public_id, user_id, api_key_id, group_id, account_id, upstream_name, model,
upstream_model, display_name, total_token_count, expire_time, channel_usage)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, created_at, updated_at`,
record.PublicID, record.UserID, record.APIKeyID, record.GroupID, record.AccountID, record.UpstreamName,
record.Model, record.UpstreamModel, record.DisplayName, record.TotalTokenCount, record.ExpireTime, channelUsage)
if err := row.Scan(&record.ID, &record.CreatedAt, &record.UpdatedAt); err != nil {
return translatePersistenceError(err, nil, service.ErrGeminiCachedContentExists)
}
return nil
}
func (r *geminiCachedContentRepository) GetForOwner(ctx context.Context, apiKeyID int64, publicID string) (*service.GeminiCachedContent, error) {
record, err := scanGeminiCachedContent(r.sql.QueryRowContext(ctx, `SELECT `+geminiCachedContentColumns+`
FROM gemini_cached_contents
WHERE public_id = $1 AND api_key_id = $2 AND deleted_at IS NULL`, publicID, apiKeyID))
if err != nil {
return nil, translatePersistenceError(err, service.ErrGeminiCachedContentNotFound, nil)
}
return record, nil
}
func (r *geminiCachedContentRepository) ListForOwner(ctx context.Context, apiKeyID, groupID int64, activeAt time.Time, beforeID int64, limit int) ([]*service.GeminiCachedContent, error) {
if limit <= 0 {
limit = service.GeminiCachedContentListDefaultPageSize
}
rows, err := r.sql.QueryContext(ctx, `SELECT `+geminiCachedContentColumns+`
FROM gemini_cached_contents
WHERE api_key_id = $1 AND group_id = $2 AND deleted_at IS NULL AND expire_time > $3 AND ($4 <= 0 OR id < $4)
ORDER BY id DESC
LIMIT $5`, apiKeyID, groupID, activeAt, beforeID, limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var out []*service.GeminiCachedContent
for rows.Next() {
record, err := scanGeminiCachedContent(rows)
if err != nil {
return nil, err
}
out = append(out, record)
}
return out, rows.Err()
}
func (r *geminiCachedContentRepository) BeginMutation(ctx context.Context, id int64, baseline time.Time, token string, lease time.Duration) (bool, error) {
result, err := r.sql.ExecContext(ctx, `UPDATE gemini_cached_contents
SET mutation_token = $2, mutation_lease_until = NOW() + make_interval(secs => $3::double precision)
WHERE id = $1 AND deleted_at IS NULL AND mutation_token IS NULL AND expire_time = $4`,
id, token, lease.Seconds(), baseline)
if err != nil {
return false, err
}
affected, err := result.RowsAffected()
if err != nil {
return false, err
}
if affected > 0 {
return true, nil
}
var exists bool
if err := r.sql.QueryRowContext(ctx, `SELECT EXISTS (
SELECT 1 FROM gemini_cached_contents WHERE id = $1 AND deleted_at IS NULL)`, id).Scan(&exists); err != nil {
return false, err
}
if !exists {
return false, service.ErrGeminiCachedContentNotFound
}
return false, nil
}
func (r *geminiCachedContentRepository) TakeOverStaleMutation(ctx context.Context, id int64, token string, lease time.Duration) (*service.GeminiCachedContent, error) {
record, err := scanGeminiCachedContent(r.sql.QueryRowContext(ctx, `UPDATE gemini_cached_contents
SET mutation_token = $2, mutation_lease_until = NOW() + make_interval(secs => $3::double precision)
WHERE id = $1 AND deleted_at IS NULL AND mutation_token IS NOT NULL AND mutation_lease_until < NOW()
RETURNING `+geminiCachedContentColumns, id, token, lease.Seconds()))
if errors.Is(err, sql.ErrNoRows) {
return nil, nil
}
if err != nil {
return nil, err
}
return record, nil
}
func (r *geminiCachedContentRepository) CommitMutation(ctx context.Context, id int64, token string, expireTime time.Time) error {
result, err := r.sql.ExecContext(ctx, `UPDATE gemini_cached_contents
SET expire_time = $3, updated_at = NOW(), mutation_token = NULL, mutation_lease_until = NULL
WHERE id = $1 AND deleted_at IS NULL AND mutation_token = $2`, id, token, expireTime)
if err != nil {
return err
}
affected, err := result.RowsAffected()
if err != nil {
return err
}
if affected == 0 {
return service.ErrGeminiCachedContentMutationConflict
}
return nil
}
func (r *geminiCachedContentRepository) ReleaseMutation(ctx context.Context, id int64, token string) error {
_, err := r.sql.ExecContext(ctx, `UPDATE gemini_cached_contents
SET mutation_token = NULL, mutation_lease_until = NULL
WHERE id = $1 AND mutation_token = $2`, id, token)
return err
}
func (r *geminiCachedContentRepository) ListStaleMutations(ctx context.Context, limit int) ([]int64, error) {
rows, err := r.sql.QueryContext(ctx, `SELECT id FROM gemini_cached_contents
WHERE mutation_token IS NOT NULL AND deleted_at IS NULL AND mutation_lease_until < NOW()
ORDER BY mutation_lease_until
LIMIT $1`, limit)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
var ids []int64
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
return nil, err
}
ids = append(ids, id)
}
return ids, rows.Err()
}
func (r *geminiCachedContentRepository) SoftDelete(ctx context.Context, id int64) error {
_, err := r.sql.ExecContext(ctx, `UPDATE gemini_cached_contents
SET deleted_at = NOW(), updated_at = NOW()
WHERE id = $1 AND deleted_at IS NULL`, id)
return err
}
type geminiCachedContentScanner interface {
Scan(dest ...any) error
}
func scanGeminiCachedContent(row geminiCachedContentScanner) (*service.GeminiCachedContent, error) {
var record service.GeminiCachedContent
var channelUsage []byte
if err := row.Scan(
&record.ID,
&record.PublicID,
&record.UserID,
&record.APIKeyID,
&record.GroupID,
&record.AccountID,
&record.UpstreamName,
&record.Model,
&record.UpstreamModel,
&record.DisplayName,
&record.TotalTokenCount,
&record.ExpireTime,
&channelUsage,
&record.CreatedAt,
&record.UpdatedAt,
); err != nil {
return nil, err
}
if len(channelUsage) > 0 {
if err := json.Unmarshal(channelUsage, &record.ChannelUsage); err != nil {
return nil, err
}
}
return &record, nil
}
@@ -0,0 +1,120 @@
//go:build integration
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestGeminiCachedContentRepository_Lifecycle(t *testing.T) {
ctx := context.Background()
tx := testTx(t)
repo := &geminiCachedContentRepository{sql: tx}
now := time.Now().UTC().Truncate(time.Microsecond)
suffix := time.Now().Format("150405.000000")
apiKeyID := int64(910001)
newRecord := func(publicID string, keyID, groupID int64, expire time.Time) *service.GeminiCachedContent {
return &service.GeminiCachedContent{
PublicID: publicID + suffix, UserID: 1, APIKeyID: keyID, GroupID: groupID, AccountID: 99,
UpstreamName: "cachedContents/up" + publicID, Model: "gemini-3.8-flash", UpstreamModel: "models/gemini-3.8-flash",
DisplayName: "label", TotalTokenCount: 2730, ExpireTime: expire,
ChannelUsage: service.ChannelUsageFields{ChannelID: 7, OriginalModel: "gemini-pro", ChannelMappedModel: "gemini-3.8-flash", BillingModelSource: "requested"},
}
}
first := newRecord("a", apiKeyID, 42, now.Add(time.Hour))
require.NoError(t, repo.Create(ctx, first))
require.Positive(t, first.ID)
require.False(t, first.CreatedAt.IsZero())
_, err := tx.ExecContext(ctx, `SAVEPOINT gemini_cached_content_duplicate`)
require.NoError(t, err)
require.ErrorIs(t, repo.Create(ctx, newRecord("a", apiKeyID, 42, now.Add(time.Hour))), service.ErrGeminiCachedContentExists)
_, err = tx.ExecContext(ctx, `ROLLBACK TO SAVEPOINT gemini_cached_content_duplicate`)
require.NoError(t, err)
second := newRecord("b", apiKeyID, 42, now.Add(time.Hour))
require.NoError(t, repo.Create(ctx, second))
require.NoError(t, repo.Create(ctx, newRecord("expired", apiKeyID, 42, now.Add(-time.Minute))))
require.NoError(t, repo.Create(ctx, newRecord("othergroup", apiKeyID, 43, now.Add(time.Hour))))
require.NoError(t, repo.Create(ctx, newRecord("otherkey", apiKeyID+1, 42, now.Add(time.Hour))))
got, err := repo.GetForOwner(ctx, apiKeyID, first.PublicID)
require.NoError(t, err)
require.Equal(t, "cachedContents/upa", got.UpstreamName)
require.Equal(t, int64(2730), got.TotalTokenCount)
require.True(t, got.ExpireTime.Equal(first.ExpireTime))
require.Equal(t, first.ChannelUsage, got.ChannelUsage, "创建时的渠道计费口径随记录持久化")
_, err = repo.GetForOwner(ctx, apiKeyID+1, first.PublicID)
require.ErrorIs(t, err, service.ErrGeminiCachedContentNotFound)
list, err := repo.ListForOwner(ctx, apiKeyID, 42, now, 0, 10)
require.NoError(t, err)
require.Len(t, list, 2)
require.Equal(t, second.PublicID, list[0].PublicID)
page, err := repo.ListForOwner(ctx, apiKeyID, 42, now, list[0].ID, 10)
require.NoError(t, err)
require.Len(t, page, 1)
require.Equal(t, first.PublicID, page[0].PublicID)
lease := 2 * time.Minute
acquired, err := repo.BeginMutation(ctx, first.ID, first.ExpireTime, "t1", lease)
require.NoError(t, err)
require.True(t, acquired)
acquired, err = repo.BeginMutation(ctx, first.ID, first.ExpireTime, "t2", lease)
require.NoError(t, err)
require.False(t, acquired, "有进行中的修改时不能再登记")
taken, err := repo.TakeOverStaleMutation(ctx, first.ID, "t2", lease)
require.NoError(t, err)
require.Nil(t, taken, "租约未过期不能接管")
require.ErrorIs(t, repo.CommitMutation(ctx, first.ID, "t2", now), service.ErrGeminiCachedContentMutationConflict)
newExpire := now.Add(3 * time.Hour)
require.NoError(t, repo.CommitMutation(ctx, first.ID, "t1", newExpire))
got, err = repo.GetForOwner(ctx, apiKeyID, first.PublicID)
require.NoError(t, err)
require.True(t, got.ExpireTime.Equal(newExpire))
require.ErrorIs(t, repo.CommitMutation(ctx, first.ID, "t1", now), service.ErrGeminiCachedContentMutationConflict, "提交后修改意图已清除")
acquired, err = repo.BeginMutation(ctx, first.ID, first.ExpireTime, "t3", lease)
require.NoError(t, err)
require.False(t, acquired, "到期时间已被其他修改改变")
acquired, err = repo.BeginMutation(ctx, first.ID, newExpire, "t3", lease)
require.NoError(t, err)
require.True(t, acquired)
stale, err := repo.ListStaleMutations(ctx, 10)
require.NoError(t, err)
require.NotContains(t, stale, first.ID)
_, err = tx.ExecContext(ctx, `UPDATE gemini_cached_contents SET mutation_lease_until = NOW() - INTERVAL '1 second' WHERE id = $1`, first.ID)
require.NoError(t, err)
stale, err = repo.ListStaleMutations(ctx, 10)
require.NoError(t, err)
require.Contains(t, stale, first.ID)
taken, err = repo.TakeOverStaleMutation(ctx, first.ID, "t4", lease)
require.NoError(t, err)
require.NotNil(t, taken)
require.True(t, taken.ExpireTime.Equal(newExpire), "接管返回修改前的到期时间")
require.ErrorIs(t, repo.CommitMutation(ctx, first.ID, "t3", now), service.ErrGeminiCachedContentMutationConflict, "被接管后原持有者不能提交")
require.NoError(t, repo.ReleaseMutation(ctx, first.ID, "t3"))
acquired, err = repo.BeginMutation(ctx, first.ID, newExpire, "t5", lease)
require.NoError(t, err)
require.False(t, acquired, "原持有者释放不影响接管者")
require.NoError(t, repo.ReleaseMutation(ctx, first.ID, "t4"))
stale, err = repo.ListStaleMutations(ctx, 10)
require.NoError(t, err)
require.NotContains(t, stale, first.ID)
require.NoError(t, repo.SoftDelete(ctx, first.ID))
_, err = repo.GetForOwner(ctx, apiKeyID, first.PublicID)
require.ErrorIs(t, err, service.ErrGeminiCachedContentNotFound)
_, err = repo.BeginMutation(ctx, first.ID, newExpire, "t6", lease)
require.ErrorIs(t, err, service.ErrGeminiCachedContentNotFound)
list, err = repo.ListForOwner(ctx, apiKeyID, 42, now, 0, 10)
require.NoError(t, err)
require.Len(t, list, 1)
}
+33
View File
@@ -167,6 +167,10 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
// 设置支持的模型系列(始终设置,空数组表示不限制)
builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes)
if groupIn.I18n != nil {
builder = builder.SetI18n(groupIn.I18n)
}
created, err := builder.Save(ctx)
if err != nil {
return translatePersistenceError(err, nil, service.ErrGroupExists)
@@ -431,6 +435,12 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
// 处理 SupportedModelScopes(始终设置,空数组表示不限制)
builder = builder.SetSupportedModelScopes(groupIn.SupportedModelScopes)
i18n := groupIn.I18n
if i18n == nil {
i18n = service.GroupI18n{}
}
builder = builder.SetI18n(i18n)
updated, err := builder.Save(ctx)
if err != nil {
return translatePersistenceError(err, service.ErrGroupNotFound, service.ErrGroupExists)
@@ -957,6 +967,29 @@ func (r *groupRepository) deleteCascade(ctx context.Context, id int64, requireEm
if _, err := txClient.Group.Delete().Where(group.IDEQ(id)).Exec(ctx); err != nil {
return nil, err
}
// Lock and validate the text value before using JSONB operators. A legacy
// malformed setting must not prevent deletion or be silently replaced.
var moderationValue string
err = scanSingleRow(ctx, exec, `SELECT value FROM settings WHERE key = $1 FOR UPDATE`,
[]any{service.SettingKeyContentModerationConfig}, &moderationValue)
if err != nil && !errors.Is(err, sql.ErrNoRows) {
return nil, err
}
// Keep the moderation scope in sync in the same transaction. Updating the
// setting row in SQL also serializes concurrent group deletions across nodes.
if err == nil && json.Valid([]byte(moderationValue)) {
if _, err := exec.ExecContext(ctx, `UPDATE settings
SET value = jsonb_set(value::jsonb, '{group_ids}',
(SELECT COALESCE(jsonb_agg(group_id ORDER BY ord), '[]'::jsonb)
FROM jsonb_array_elements(value::jsonb->'group_ids') WITH ORDINALITY AS ids(group_id, ord)
WHERE group_id <> to_jsonb($2::bigint)))::text,
updated_at = NOW()
WHERE key = $1 AND jsonb_typeof(value::jsonb->'group_ids') = 'array'
AND value::jsonb->'group_ids' @> jsonb_build_array($2::bigint)`,
service.SettingKeyContentModerationConfig, id); err != nil {
return nil, err
}
}
if tx != nil {
if err := tx.Commit(); err != nil {
@@ -5,7 +5,9 @@ package repository
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"testing"
"time"
@@ -49,6 +51,55 @@ func TestGroupRepoSuite(t *testing.T) {
suite.Run(t, new(GroupRepoSuite))
}
func TestModerationConfigWriteRejectsGroupDeletedAfterValidation(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := newGroupRepositoryWithSQL(client, integrationDB)
settings := &settingRepository{client: client}
group := &service.Group{Name: "moderation-concurrent-delete", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
require.NoError(t, repo.Create(ctx, group))
t.Cleanup(func() { _, _ = integrationDB.ExecContext(ctx, "DELETE FROM groups WHERE id=$1", group.ID) })
key := service.SettingKeyContentModerationConfig
previous, previousErr := settings.GetValue(ctx, key)
t.Cleanup(func() {
if previousErr == nil {
_ = settings.Set(ctx, key, previous)
} else {
_ = settings.Delete(ctx, key)
}
})
require.NoError(t, settings.Set(ctx, key, `{"all_groups":false,"group_ids":[]}`))
// Hold the deleter's group lock while the config writer starts with an ID
// that was valid before deletion. It must recheck after this transaction commits.
tx, err := integrationDB.BeginTx(ctx, nil)
require.NoError(t, err)
defer func() { _ = tx.Rollback() }()
var id int64
require.NoError(t, tx.QueryRowContext(ctx, "SELECT id FROM groups WHERE id=$1 FOR UPDATE", group.ID).Scan(&id))
finished := make(chan error, 1)
go func() {
finished <- settings.SetContentModerationConfig(ctx, `{"all_groups":false,"group_ids":[`+fmt.Sprint(group.ID)+`]}`, []int64{group.ID})
}()
select {
case err := <-finished:
t.Fatalf("config write passed a locked group before deletion: %v", err)
case <-time.After(100 * time.Millisecond):
}
_, err = tx.ExecContext(ctx, "UPDATE groups SET deleted_at=NOW() WHERE id=$1", group.ID)
require.NoError(t, err)
require.NoError(t, tx.Commit())
select {
case err := <-finished:
require.ErrorIs(t, err, service.ErrGroupNotFound)
case <-time.After(5 * time.Second):
t.Fatal("config write did not finish after deletion")
}
stored, err := settings.GetValue(ctx, key)
require.NoError(t, err)
require.JSONEq(t, `{"all_groups":false,"group_ids":[]}`, stored)
}
// --- Create / GetByID / Update / Delete ---
func (s *GroupRepoSuite) TestCreate() {
@@ -201,6 +252,48 @@ func (s *GroupRepoSuite) TestUpdate() {
s.Require().Equal("updated", got.Name)
}
func (s *GroupRepoSuite) TestI18n_RoundTrip() {
group := &service.Group{
Name: "i18n-roundtrip",
Platform: service.PlatformAnthropic,
RateMultiplier: 1.0,
Status: service.StatusActive,
SubscriptionType: service.SubscriptionTypeStandard,
I18n: service.GroupI18n{
"en": {Name: "Standard", Description: "Pro + Max pool"},
},
}
s.Require().NoError(s.repo.Create(s.ctx, group))
got, err := s.repo.GetByID(s.ctx, group.ID)
s.Require().NoError(err)
s.Require().Equal(group.I18n, got.I18n)
got.I18n = service.GroupI18n{"zh": {Description: "号池"}}
s.Require().NoError(s.repo.Update(s.ctx, got))
got, err = s.repo.GetByID(s.ctx, group.ID)
s.Require().NoError(err)
s.Require().Equal(service.GroupI18n{"zh": {Description: "号池"}}, got.I18n)
// 未携带译文的写入落库为空对象,而不是 JSON null。
got.I18n = nil
s.Require().NoError(s.repo.Update(s.ctx, got))
var stored string
s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT i18n::text FROM groups WHERE id = $1", []any{group.ID}, &stored))
s.Require().Equal("{}", stored)
plain := &service.Group{
Name: "i18n-default",
Platform: service.PlatformAnthropic,
RateMultiplier: 1.0,
Status: service.StatusActive,
SubscriptionType: service.SubscriptionTypeStandard,
}
s.Require().NoError(s.repo.Create(s.ctx, plain))
s.Require().NoError(scanSingleRow(s.ctx, s.tx, "SELECT i18n::text FROM groups WHERE id = $1", []any{plain.ID}, &stored))
s.Require().Equal("{}", stored)
}
func (s *GroupRepoSuite) TestGetByID_PreservesMessagesDispatchModelConfig() {
group := &service.Group{
Name: "openai-dispatch",
@@ -257,8 +350,14 @@ func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyRejectsGroupWithNonDeletedAccou
_, err := s.tx.ExecContext(s.ctx, "INSERT INTO account_groups (account_id, group_id, priority, created_at) VALUES ($1, $2, 1, NOW())", accountID, group.ID)
s.Require().NoError(err)
config, marshalErr := json.Marshal(map[string]any{"group_ids": []int64{group.ID}, "all_groups": false})
s.Require().NoError(marshalErr)
s.Require().NoError(NewSettingRepository(s.tx.Client()).Set(s.ctx, service.SettingKeyContentModerationConfig, string(config)))
_, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID)
s.Require().ErrorIs(err, service.ErrGroupNotEmpty)
stored, readErr := NewSettingRepository(s.tx.Client()).GetValue(s.ctx, service.SettingKeyContentModerationConfig)
s.Require().NoError(readErr)
s.Require().JSONEq(string(config), stored)
_, err = s.repo.GetByID(s.ctx, group.ID)
s.Require().NoError(err)
var bindings int
@@ -276,6 +375,76 @@ func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyDeletesEmptyGroup() {
s.Require().ErrorIs(err, service.ErrGroupNotFound)
}
func (s *GroupRepoSuite) TestDeleteCascadeRemovesOnlySelectedModerationGroup() {
for _, guarded := range []bool{false, true} {
name := map[bool]string{false: "normal", true: "guarded"}[guarded]
s.Run(name, func() {
deleted := &service.Group{Name: "moderation-deleted-" + name, Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
kept := &service.Group{Name: "moderation-kept-" + name, Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
s.Require().NoError(s.repo.Create(s.ctx, deleted))
s.Require().NoError(s.repo.Create(s.ctx, kept))
config := map[string]any{"group_ids": []int64{kept.ID, deleted.ID}, "all_groups": false, "mode": "observe"}
raw, err := json.Marshal(config)
s.Require().NoError(err)
s.Require().NoError(NewSettingRepository(s.tx.Client()).Set(s.ctx, service.SettingKeyContentModerationConfig, string(raw)))
if guarded {
_, err = s.repo.DeleteCascadeIfEmpty(s.ctx, deleted.ID)
} else {
_, err = s.repo.DeleteCascade(s.ctx, deleted.ID)
}
s.Require().NoError(err)
stored, err := NewSettingRepository(s.tx.Client()).GetValue(s.ctx, service.SettingKeyContentModerationConfig)
s.Require().NoError(err)
var got struct {
GroupIDs []int64 `json:"group_ids"`
Mode string `json:"mode"`
}
s.Require().NoError(json.Unmarshal([]byte(stored), &got))
s.Require().Equal([]int64{kept.ID}, got.GroupIDs)
s.Require().Equal("observe", got.Mode)
})
}
}
func (s *GroupRepoSuite) TestDeleteCascadeUnknownGroupPreservesModerationConfig() {
const original = `{"all_groups":false,"group_ids":[123,456],"mode":"observe"}`
s.Require().NoError(NewSettingRepository(s.tx.Client()).Set(s.ctx, service.SettingKeyContentModerationConfig, original))
for _, guarded := range []bool{false, true} {
var err error
if guarded {
_, err = s.repo.DeleteCascadeIfEmpty(s.ctx, -1)
} else {
_, err = s.repo.DeleteCascade(s.ctx, -1)
}
s.Require().ErrorIs(err, service.ErrGroupNotFound)
stored, readErr := NewSettingRepository(s.tx.Client()).GetValue(s.ctx, service.SettingKeyContentModerationConfig)
s.Require().NoError(readErr)
s.Require().JSONEq(original, stored)
}
}
func (s *GroupRepoSuite) TestDeleteCascadePreservesMalformedModerationConfig() {
for _, guarded := range []bool{false, true} {
name := map[bool]string{false: "normal", true: "guarded"}[guarded]
s.Run(name, func() {
group := &service.Group{Name: "malformed-moderation-" + name, Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
s.Require().NoError(s.repo.Create(s.ctx, group))
const malformed = `{"group_ids":[oops]}`
s.Require().NoError(NewSettingRepository(s.tx.Client()).Set(s.ctx, service.SettingKeyContentModerationConfig, malformed))
var err error
if guarded {
_, err = s.repo.DeleteCascadeIfEmpty(s.ctx, group.ID)
} else {
_, err = s.repo.DeleteCascade(s.ctx, group.ID)
}
s.Require().NoError(err)
stored, err := NewSettingRepository(s.tx.Client()).GetValue(s.ctx, service.SettingKeyContentModerationConfig)
s.Require().NoError(err)
s.Require().Equal(malformed, stored)
})
}
}
func (s *GroupRepoSuite) TestDeleteCascadeIfEmptyIgnoresBindingsToSoftDeletedAccounts() {
group := &service.Group{Name: "guarded-soft-deleted-account", Platform: service.PlatformAnthropic, RateMultiplier: 1, Status: service.StatusActive, SubscriptionType: service.SubscriptionTypeStandard}
s.Require().NoError(s.repo.Create(s.ctx, group))
@@ -956,7 +956,11 @@ func filterSchedulerCredentials(credentials map[string]any) map[string]any {
}
// Candidate-list admission evaluates the account override before hydrating
// the full account. Dropping it silently falls back to the platform threshold.
keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type", "account_scheduling_threshold"}
keys := []string{"model_mapping", "compact_model_mapping", "api_key", "project_id", "oauth_type", "plan_type", "account_scheduling_threshold",
// Gemini 显式缓存的候选过滤(GeminiExplicitCacheExclusions)读本投影判定账号是否支持显式缓存:
// 官方地址、Vertex 认证方式与上游网关的显式缓存声明。缺这几个键时只能按类型粗筛,
// 不支持的账号要逐个选中后才被剔除。
"base_url", "auth_mode", service.GeminiExplicitCacheUpstreamCredentialKey, service.GeminiExplicitCacheUpstreamMaxTTLCredentialKey}
filtered := make(map[string]any)
for _, key := range keys {
if value, ok := credentials[key]; ok && value != nil {
@@ -20,6 +20,23 @@ func TestFilterSchedulerCredentialsKeepsSubscriptionPlanType(t *testing.T) {
require.NotContains(t, filtered, "refresh_token")
}
func TestFilterSchedulerCredentialsKeepsGeminiExplicitCacheKeys(t *testing.T) {
filtered := filterSchedulerCredentials(map[string]any{
"api_key": "k",
"base_url": "https://gw.example.com",
"auth_mode": "apikey",
"service_account_json": "secret",
service.GeminiExplicitCacheUpstreamCredentialKey: true,
service.GeminiExplicitCacheUpstreamMaxTTLCredentialKey: 3600,
})
require.Equal(t, "https://gw.example.com", filtered["base_url"])
require.Equal(t, "apikey", filtered["auth_mode"])
require.Equal(t, true, filtered[service.GeminiExplicitCacheUpstreamCredentialKey])
require.Equal(t, 3600, filtered[service.GeminiExplicitCacheUpstreamMaxTTLCredentialKey])
require.NotContains(t, filtered, "service_account_json")
}
func TestSchedulerMetadataAccountKeepsOpenAISubscriptionIdentity(t *testing.T) {
account := service.Account{
ID: 24,
@@ -2,6 +2,7 @@ package repository
import (
"context"
"errors"
"time"
"github.com/Wei-Shaw/sub2api/ent"
@@ -9,6 +10,34 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
// SetContentModerationConfig holds live group rows until the setting is written.
// Deletion takes FOR UPDATE on the group before cleaning this setting, so both
// operations follow the same lock order: groups, then settings.
func (r *settingRepository) SetContentModerationConfig(ctx context.Context, value string, groupIDs []int64) error {
tx, err := r.client.Tx(ctx)
if err != nil && !errors.Is(err, ent.ErrTxStarted) {
return err
}
client := r.client
if err == nil {
defer func() { _ = tx.Rollback() }()
client = tx.Client()
}
if err := lockLiveGroups(ctx, client, groupIDs); err != nil {
return err
}
now := time.Now()
if err := client.Setting.Create().SetKey(service.SettingKeyContentModerationConfig).
SetValue(value).SetUpdatedAt(now).OnConflictColumns(setting.FieldKey).
UpdateNewValues().Exec(ctx); err != nil {
return err
}
if tx != nil {
return tx.Commit()
}
return nil
}
type settingRepository struct {
client *ent.Client
}
+1
View File
@@ -82,6 +82,7 @@ var ProviderSet = wire.NewSet(
NewUsageLogRepository,
NewUsageBillingRepository,
NewBatchImageRepository,
NewGeminiCachedContentRepository,
NewIdempotencyRepository,
NewUsageCleanupRepository,
NewDashboardAggregationRepository,
@@ -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)
+14
View File
@@ -349,6 +349,12 @@ func RegisterGatewayRoutes(
gemini.GET("/models/:model", h.Gateway.GeminiV1BetaGetModel)
// Gin treats ":" as a param marker, but Gemini uses "{model}:{action}" in the same segment.
gemini.POST("/models/*modelAction", h.Gateway.GeminiV1BetaModels)
// 显式上下文缓存:网关自管资源 ID 与账号绑定,list/get 只读本地记录。
gemini.POST("/cachedContents", h.Gateway.GeminiCachedContentsCreate)
gemini.GET("/cachedContents", h.Gateway.GeminiCachedContentsList)
gemini.GET("/cachedContents/:cacheID", h.Gateway.GeminiCachedContentsGet)
gemini.PATCH("/cachedContents/:cacheID", h.Gateway.GeminiCachedContentsPatch)
gemini.DELETE("/cachedContents/:cacheID", h.Gateway.GeminiCachedContentsDelete)
}
// OpenAI Responses API(不带v1前缀的别名)— auto-route based on group platform
@@ -580,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) {
@@ -47,6 +47,7 @@ func TestEveryGatewayPOSTRouteIsClassifiedForPromptAuditCoverage(t *testing.T) {
"/videos/edits": {"grok_media.go"},
"/videos/extensions": {"grok_media.go"},
"/models/*modelAction": {"gemini_v1beta_handler.go"},
"/cachedContents": {"gemini_cached_contents_handler.go"},
"/tts": {"grok_audio.go"},
"/web_search": {"gateway_web_search.go"},
"/x_search": {"gateway_web_search.go"},

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