Compare commits

..
330 Commits
Author SHA1 Message Date
Wesley Liddick e803e3851c Merge pull request #5559 from seng1e/fix/scheduled-backup-leader-lock
fix(backup): 定时备份加 leader 锁,避免多实例重复备份
2026-08-13 09:24:11 +08:00
Wesley Liddick 5912ae960c Merge pull request #5543 from luckydududu/fix/invalidate-channel-cache-on-group-platform-change
fix(group): 改分组 platform 后失效渠道缓存(否则最长 10 分钟按旧平台计价)
2026-08-13 09:24:00 +08:00
Wesley Liddick e77b4b260e Merge pull request #5504 from fengshao1227/fix/responses-probe-inconclusive-keeps-unknown
fix(openai): 探测响应未跑完时不再落标为「上游不支持 Responses」
2026-08-13 09:23:50 +08:00
Wesley Liddick 4853662f99 Merge pull request #5540 from luckydududu/fix/channel-pricing-model-name-normalization
fix(channel): 定价冲突检测与定价缓存键的归一化对齐(#4754 修复残留)
2026-08-13 09:12:41 +08:00
Wesley Liddick 0fa577a19c Merge pull request #5571 from IanShaw027/feat/grok-jwt-tier-and-4.6
feat(grok): 以 JWT tier 识别订阅档位并接入 grok-4.6,修正徽章滞后、未知模型零计费与搜索/Voice 边界
2026-08-13 09:08:35 +08:00
IanShaw027 b61e4bcc45 fix: 对齐 JWT 常量 gofmt,并补全 available groups 契约字段
golangci-lint 要求 const 块按类型对齐;分组 DTO 新增
long_context_pricing_enabled 后,契约夹具未同步导致比较失败。
2026-08-13 08:55:56 +08:00
IanShaw027 0ae151a238 fix: 徽章改读 grok_usage_snapshot,增量刷新比较 Grok 快照
后端写入 grok_usage_snapshot,列表却读无 writer 的 grok_quota_snapshot;
增量刷新不比 Grok 快照,档位更新后行不替换。

- 正式快照优先 usage,quota 仅兜底;计划字段只收非空字符串
- buildGrokUsageRefreshKey 接入自动刷新
- 注明网关 Extra 写入依赖每请求独立 Account 副本
2026-08-13 08:38:10 +08:00
IanShaw027 b830bc14d6 fix: 长上下文默认保持开启,并与 OpenAI 账号开关取交集
迁移默认 false 会让存量分组静默丢掉 ≥200k 阶梯与渠道多档价。
OpenAI 路径传入 Group 后,账号级 openai_long_context_billing_enabled 被顶掉。

- 列默认 TRUE,并对存量行回填
- 分组价卡不再手填 token 区间,勾选走官方/预设阶梯
- 分组开关与账号开关 AND;价卡 JSON 损坏时打 warn
2026-08-13 08:38:10 +08:00
IanShaw027 8c4c3c09ce fix: 未登记的 Grok 文本模型回退 grok-4.5 价卡
新文本 ID 在闭集价卡上 fail-closed,请求成功而用量记 0。

- grok / grok-N* / build / composer 未登记别名继承 4.5 价
- imagine/voice/web-search/speech 等非文本族不回退
2026-08-13 08:38:10 +08:00
IanShaw027 678eb22a40 fix: Realtime 仅在观察到音频后计费,并修正标志位求值顺序
原先 elapsed>0 就出账,握手失败也会扣费;随后用 audioObserved,
但又在同一个 return 里先 Load 再收 errCh,标志位恒为 false,会话全部漏计。

- 先等中继结束再读 audioObserved
- 无音频或零时长不出账;每次连接独立 request id
2026-08-13 08:38:10 +08:00
IanShaw027 c4d883b8da feat: Chat 与 Responses 往返保留 x_search,并补 sources 抽取
Chat Completions 的 {"type":"x_search"} 会被 apicompat 丢掉,独立
/x_search 又没带 include 与结构化提示,主路径容易返回空结果仍计费。

- Chat↔Responses 保留 x_search 过滤字段与 tool_choice
- declared 只注册实际存活的 x_search,web_search 选择项仍丢弃
- 上游补 include x_search_call.action.sources 与结构化输出提示
2026-08-13 08:37:57 +08:00
IanShaw027 0de6d7e9ba feat: 新增独立 /x_search,走原生 x_search 并沿用搜索计费
Grok 分组原先只有 /web_search,无法带 handle/日期过滤,也无法走
xAI 的 x_search tool。

- POST /x_search(仅 Grok 分组),复用 web_search 的审计、failover 与按次计费
- 上游 Responses 强制 x_search;计费模型记为 grok-x-search
2026-08-13 07:49:19 +08:00
IanShaw027 363cc4994b fix: SuperGrokPro 用 4.5 窗口区分 Heavy,容量抖动只封单模型
JWT 的 SuperGrokPro 同时覆盖 SuperGrok 与 Heavy,账单月额度又滞后,
账号会误升/误降。multi-agent 容量抖动还会把整号提出调度。

- CanonicalGrokPlan:明确 JWT 优先;模糊档仅采新鲜的 grok-4.5 Responses 窗口
- 配额快照写入 plan_from_45_responses 时间戳,过期信号不用
- engine_overloaded 对 multi-agent 只封当前模型 0.5s–5min
2026-08-13 07:49:14 +08:00
IanShaw027 f3d9491071 feat: 分组支持逐模型定价,并可关闭长上下文阶梯
运营需要按分组覆盖渠道/内置价,且部分套餐不应自动吃 200k 倍率。
原先只能改渠道价卡,分组侧只剩 Voice 三列。

- groups 新增 model_pricing / long_context_pricing_enabled,解析链改为 Group → Channel → 内置
- 关闭长上下文时 token 模型只取最低档;video 按秒计费可写进同一价卡
- 回退价对齐官方卡:4.5 缓存 $0.30、4.3/imagine/audio/search 默认值一并校正
2026-08-13 07:49:08 +08:00
IanShaw027 69648476d4 fix: 账号徽章与用量格按实时档位展示,避免账单滞后误判
订阅失效刷新后凭证已是 free,但残留的 monthly_limit / usage_percent
仍会把用量格锁在付费 7d/30d,徽章也优先信滞后的 billing.plan。

- 凭证 subscription_tier 优先于账单/配额快照
- 用量格先看实时档位,再回退账单指标;Lite 仍走付费条
- 徽章补齐 SuperGrok Lite / Plus / X Basic
2026-08-13 01:23:54 +08:00
IanShaw027 a04ce49016 feat: 新增 grok-4.6 目录、官方定价与请求路径支持
官方目录价:<200k 为 $2 / 缓存读取 $0.50 / $6,≥200k 全部 2 倍。
原先没有独立价卡,/models 宣称可配 reasoning 但请求路径会剥掉 effort。

- 目录与别名接入 grok-4.6 / grok-4.6-latest,默认文本模型仍为 grok-4.5
- 独立回退价卡含缓存读取价与 200k 长上下文倍率
- 保留 reasoning_effort;Chat→Responses 的 cache/vision 桥接同样识别 4.6
2026-08-13 01:23:45 +08:00
IanShaw027 bb9e74285e feat: 从 JWT tier 识别 Grok 订阅档位,刷新后覆盖失效订阅
Grok Build access token 带有数字 tier claim(0=free、1=supergrok、5=heavy、6=lite),
原先只解 email/sub/team_id,refresh 还会把旧档位抄回去,订阅失效后一直显示 Heavy。

- 解码 JWT tier,并归一化 display / header 别名(含 free-tier、SuperGrok Lite)
- 仅从 access token 读取档位;新 JWT 覆盖已存凭证,AT 无 claim 时保留旧值
- 用量与 free-cache 判定以当前 AT 为准,账单月额度仅作降级
2026-08-13 01:23:37 +08:00
seng1eandClaude Opus 4.8 bba6a55e0f fix(backup): 定时备份加 leader 锁,避免多实例重复备份
仓库里所有周期任务都用 tryAcquireSingletonLeaderLock 选主、只让一个实例跑
(ops:*、dashboard:aggregation、subscription:expiry、payment:order:expiry 等),
唯独定时备份 runScheduledBackup 没接这套锁,每个实例都自己跑一遍。

多实例部署下同一时刻:
- 同一个库被 N 次 pg_dump;
- 内存峰值 ×N —— 归档整个读进内存再上传,内存吃紧的节点会直接 OOM;
- 上传的是同一个带时间戳的 key,N 份互相覆盖,白干一场连多余副本都留不下。

实测 3 节点:每天 backup_records 里三条一模一样的记录(同 key、同大小)。

改动:把 BackupService 接进和其它任务一样的 tryAcquireSingletonLeaderLock
(key=backup:scheduled:leader)。只锁定时备份,手动备份(CreateBackup/
StartBackup)不锁;无协调后端(cache/db 均 nil)时不加锁照常跑,单机/单测
行为不变;锁 TTL 35m > 备份自身 30m context 上限,防止大库 dump 中途锁过期。
wire_gen.go 由 wire 重新生成。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-12 21:36:41 +08:00
shaw 5935e674a8 chore: update sponsors 2026-08-12 19:17:04 +08:00
github-actions[bot] ef4f99f292 chore: sync VERSION to 0.1.175 [skip ci] 2026-08-12 11:07:43 +00:00
Wesley Liddick 93c32fa1a2 Merge pull request #5553 from Wei-Shaw/feat/codex-fingerprint-convergence
feat: Codex OAuth 设备指纹收敛
2026-08-12 18:52:01 +08:00
shaw 04f8cdb194 fix: 修复 golangci-lint errcheck 和 gofmt 格式问题 2026-08-12 18:20:54 +08:00
shaw c0ab3a00ea feat: Codex OAuth 设备指纹收敛,减少上游可见的设备数和会话数
多人共享同一 OAuth 账号时,各用户 Codex 客户端携带各自不同的 installation_id/session_id/thread_id,
上游据此判定设备数和会话数并限制配额。本功能将这些标识改写为账号级恒定值。

四档策略(账号级 extra 字段 codex_fingerprint_mode):
- off: 不做任何收敛,原样透传
- device: 仅收敛 installation_id
- session(默认): 收敛 installation_id + session_id,thread_id 按客户端原始 session 派生
- full: 收敛所有标识(installation_id + session_id + thread_id)

改写覆盖 6 个指纹载体:x-codex-turn-metadata 头(JSON 内部字段)、x-codex-window-id、
x-codex-installation-id、x-client-request-id、session-id/session_id/thread-id、
请求体 client_metadata。头和体共享同一份预计算 IDs 确保 turn_id 等随机字段一致。
2026-08-12 17:57:50 +08:00
Lucky 814ecfba7c fix(group): invalidate the channel cache when a group's platform changes
The channel cache holds a groupID -> platform map with a 10 minute TTL, and
only channel Create/Update/Delete call invalidateCache(). Changing a group's
platform through the admin API therefore leaves the cache pointing at the old
platform for up to 10 minutes.

Channel pricing, model mapping and the model whitelist are all matched per
platform, so during that window the lookups silently miss: pricing falls back
to the global LiteLLM price list, renames stop applying and the whitelist
stops restricting. Nothing is logged.

Inject a narrow ChannelCacheInvalidator into the admin service (same shape as
the existing APIKeyAuthCacheInvalidator) and call it from UpdateGroup only when
the platform actually changed. The dependency is optional -- when it is nil the
cache simply rebuilds on TTL expiry, as before.
2026-08-12 03:22:06 +00:00
Lucky bd404c16f2 fix(channel): align pricing conflict detection with the pricing cache key
`validateNoConflictingModels` used `toModelEntry`, which only lowercases,
while `expandPricingToCache` keys the cache with
`normalizeChannelPricingModelName` (lowercase + TrimSpace + `.` -> `-`
for `claude-*`). Two pricing entries that the validator considers distinct
therefore collapse onto the same cache key, and the one written later
silently overwrites the other.

Add `toPricingModelEntry`, which reuses `normalizeChannelPricingModelName`,
and use it for pricing conflict detection. `toModelEntry` is left untouched
for model *mapping* validation, because `expandMappingToCache` keys the
mapping cache with plain `strings.ToLower` -- normalizing mappings the same
way would reject configurations that the cache keeps separate.

This completes the fix for #4754, which added the normalization to the
lookup side only.
2026-08-12 02:17:09 +00:00
Wesley Liddick 4ec9ceec4a Merge pull request #5508 from pcmid/fix/show-security-audit-menu-in-simple-mode
fix(frontend): show security audit menu in simple mode
2026-08-12 10:01:20 +08:00
Wesley Liddick 46cbb7187b Merge pull request #5531 from SamizuHM/fix/openai-compat-nested-data-usage
fix(openai-compat): parse usage from nested data envelopes
2026-08-12 10:01:09 +08:00
Wesley Liddick 80acae16fa Merge pull request #5525 from Fool0ntheHill/codex/fix-openai-visible-ttft
fix(openai): record Responses TTFT on visible output
2026-08-12 09:59:52 +08:00
Wesley Liddick 19c6007a22 Merge pull request #5342 from wucm667/fix/issue-5340-ws-v2-terminal-ttft
fix(openai-ws): exclude terminal events from TTFT
2026-08-12 09:59:43 +08:00
Wesley Liddick 0ed1a9f22a Merge pull request #5514 from wucm667/fix/issue-5510-cyber-policy-audit-scope
fix(audit): scope cyber policy events
2026-08-12 09:58:32 +08:00
Wesley Liddick a29fce4a61 Merge pull request #5511 from wucm667/fix/pr-5234-ws-audit-logging
fix(security-audit): restore websocket audit logs
2026-08-12 09:58:24 +08:00
Wesley Liddick 1225437099 Merge pull request #5502 from pyt111/codex/fix-account-stats-service-tier
fix: 修复 service tier 账号成本统计
2026-08-12 09:58:16 +08:00
Wesley Liddick 5192abb6b5 Merge pull request #5503 from fengshao1227/fix/openai-html-403-not-account-penalty
fix(openai): 上游 HTML 403 不再被当成账号级错误处罚账号
2026-08-12 09:58:08 +08:00
Wesley Liddick 40aae11888 Merge pull request #5513 from wucm667/fix/issue-5506-gemini-exclusive-minimum
fix(gemini): normalize exclusive minimum tool schemas
2026-08-12 09:57:52 +08:00
Wesley Liddick 177bf30867 Merge pull request #5527 from creamtea47/codex/ops-memory-capacity-display
fix: 优化运营监控内存容量显示
2026-08-12 09:57:45 +08:00
Wesley Liddick d76c1af759 Merge pull request #5535 from feitianbubu/fix/account-scheduling-threshold-i18n-nesting
fix(i18n): move account scheduling threshold keys out of status block
2026-08-12 09:54:47 +08:00
wucm667 da283854f6 chore: retry CI after registry timeout 2026-08-12 02:25:00 +08:00
feitianbubu 670b03f7e7 fix(i18n): move account scheduling threshold keys out of status block 2026-08-12 00:10:34 +08:00
SamizuHM a163742fc9 fix(openai): preserve usage path precedence 2026-08-11 22:01:47 +08:00
SamizuHM 04dc540b23 fix(openai): parse nested data usage envelopes 2026-08-11 18:15:31 +08:00
NellPoi 943f09d357 fix: 优化运营监控内存容量显示 2026-08-11 17:34:58 +08:00
Fool0ntheHill 900194fab2 fix(openai): 修正 Responses 可见输出 TTFT 2026-08-11 16:31:09 +08:00
wucm667 662444774f test(gemini): check cleaned schema assertions 2026-08-11 16:19:18 +08:00
wucm667 e24cb99b79 fix(openai-ws): retain no-delta TTFT fallback 2026-08-11 16:01:34 +08:00
Wesley Liddick 1e618dbc29 Merge pull request #5054 from wucm667/fix/issue-5029-openai-passthrough-pool-auth-retry
fix(openai): retry pool auth failures before failover
2026-08-11 14:21:45 +08:00
shaw a3bbf35cbd Merge branch 'main' into fix/issue-5029-openai-passthrough-pool-auth-retry
Resolve conflict in backend/internal/handler/openai_gateway_handler_test.go.

main and this branch each appended a passthrough upstream stub plus a test at
the same two insertion points:

  main   openAIHTTPPassthroughSSERateLimitUpstream
         TestOpenAIResponses_APIKeyPassthroughSSERateLimitUsesConfiguredPoolRetry
  branch openAIHTTPPassthroughAuthFailoverUpstream
         TestOpenAIResponses_APIKeyPassthroughPoolAuthFailureRetriesThenSwitchesToHealthyAccount

Both sides are kept verbatim; the only edit is giving each stub its own
calls() body instead of sharing the trailing one. No assertion was changed.

openai_gateway_passthrough.go and openai_oauth_passthrough_test.go merged
automatically.
2026-08-11 14:09:07 +08:00
Wesley Liddick caa1abb13a Merge pull request #5404 from wucm667/fix/issue-5400-oauth-image-stream-error
fix(openai): fail over OAuth image stream errors
2026-08-11 14:04:42 +08:00
Wesley Liddick 574dfad2dc Merge pull request #5488 from wucm667/fix/issue-5482-stale-codex-threshold
fix(openai): skip stale and reset Codex snapshots in scheduling threshold evaluator
2026-08-11 14:00:33 +08:00
Wesley Liddick bc0a6a7039 Merge pull request #5480 from scp-planet/fix/admin-usage-request-id-column
修复管理端使用记录请求 ID 列显示 / Restore admin usage request ID column visibility
2026-08-11 14:00:07 +08:00
Wesley Liddick 6876477371 Merge pull request #5304 from wucm667/fix/issue-5302-chat-reasoning-alias
fix(apicompat): accept chat reasoning alias
2026-08-11 13:59:49 +08:00
Wesley Liddick b918874f81 Merge pull request #5403 from cyhhao/fix/codex-capacity-exponential-backoff
fix(openai): back off capacity retries exponentially
2026-08-11 13:58:06 +08:00
Wesley Liddick 20a2d12dde Merge pull request #5415 from Yuxin-Qiao/fix/openai-responses-empty-completed-failover
fix(openai): fail over empty response.completed streams instead of recording 0/0 success
2026-08-11 13:53:43 +08:00
Wesley Liddick ca9e2b48ee Merge pull request #5413 from Yuxin-Qiao/fix/openai-responses-reasoning-item-id
fix(openai): strip invalid reasoning item IDs in API key passthrough
2026-08-11 13:53:05 +08:00
Wesley Liddick e499a54a9d Merge pull request #5449 from hongheshan-svg/docs/fix-stale-go-golangci-versions
docs: sync Go 1.26.5 / golangci-lint v2.9 versions with CI
2026-08-11 13:52:35 +08:00
wucm667 6564d376e5 fix(audit): scope cyber policy events 2026-08-11 13:05:56 +08:00
wucm667 c8d9af6ce1 fix(gemini): normalize exclusive minimum tool schemas 2026-08-11 12:34:51 +08:00
wucm667 2d9920ba7d fix(security-audit): restore websocket audit logs 2026-08-11 11:56:46 +08:00
pcmid 0d7b6ae64c fix(frontend): show security audit menu in simple mode
The security audit group (Risk Control + Prompt Audit) was hidden from
the sidebar via `hideInSimpleMode`, but nothing else in the stack
restricts it in simple mode:

- `router/index.ts` simple-mode `restrictedPaths` does not cover
  `/admin/risk-control` or `/admin/prompt-audit`
- the `/risk-control` and `/prompt-audit` admin route groups have no
  `RunMode` gate, and neither does `internal/securityaudit/` nor
  `service/content_moderation.go`
- `SettingsView.vue` renders the risk control toggle together with a
  `<router-link to="/admin/risk-control">` shortcut, and the settings
  page stays visible in simple mode

The feature is therefore fully usable in simple mode and already
reachable through the settings page — only the sidebar entry was
missing. Drop the flag so the menu matches actual behaviour.

The group remains gated by `risk_control_enabled` (opt-in, default
false) through `featureFlag: flagRiskControl`.
2026-08-11 11:27:49 +08:00
li fd9ce53281 fix(openai): 探测响应未跑完时不再落标为「上游不支持 Responses」
ProbeOpenAIAPIKeyResponsesSupport 的 2xx 分支靠「output 里有没有 function_call」
判定上游工具能力,但这只在响应真的跑完时成立。以下两种响应会被误判并落标:

- status=incomplete 且 incomplete_details.reason=max_output_tokens:探测请求自己
  只给了 512 的输出预算,推理型模型可能把预算全烧在 reasoning 上,还没轮到
  function_call 就被截断。
- status=failed:HTTP 200 携带的失败响应(上游瞬时故障)。

这个标记的代价很高:探测只在账号创建/更新时跑一次,写进
accounts.extra.openai_responses_supported 后不会自动重探,网关会长期把该账号的
/v1/responses 请求改走 /v1/chat/completions。对 Codex 客户端意味着 prompt 缓存
前缀被协议转换打散,issue #5371 实测缓存命中率从 ~85% 掉到 ~17%、成本涨 3.5 倍,
持续 32 小时无人察觉,最后是账号余额耗尽被停调度才自己弹回去。

同一个函数对非 2xx 早已写明「上游偶发故障不应误判为不支持」并保守返回 true,
2xx 分支缺的正是同一份保守。本次新增 responsesProbeVerdictIsConclusive:判据不
成立时保持 unknown 不写标记,与网络层失败、响应体读取失败的处理一致;网关按
「现状即证据」继续走 Responses。

status=completed 却只回 reasoning 的上游(火山方舟 coding/v3 × kimi-k2.6,即探测
最初要抓的目标)不受影响,仍落标为不支持。

另:落标为不支持时补一条 slog.Warn,让这次会长期改变成本结构的降级可被运维看到。

Fixes #5371
2026-08-11 10:46:50 +08:00
li 12abb54700 fix(openai): 上游 HTML 403 不再被当成账号级错误处罚账号
上游代理 / CDN 在请求到达 OpenAI API 之前拦下时,回的是 HTML 403 页面而不是
{"error":{...}} 结构化错误。这类响应描述的是「这条链路 / 这个端点被挡了」,
不构成账号凭据或权限失效的证据。

但 handleOpenAI403 不区分响应形态,一律按账号级 403 处理:

- 首次即 SetTempUnschedulable,账号 10 分钟不可调度;
- 连续 openAI403DisableThreshold(3) 次直接 SetError 永久禁用账号;
- 403 又在 failover 状态集里,同一个坏请求会被逐个账号重放。

结果是一个请求级错误被放大成整组账号下线。issue #5334 给出的触发方式是
POST /v1/responses/not-exist —— 该路径能通过只做结构校验的 guardResponsesSubpath,
转发后拿回 HTML 403,任何持有 API Key 的调用方重复几次即可打穿一个分组。
附带问题:整张 HTML 页面会被拼进账号错误信息写库并显示在管理端。

仓库对这类响应早有明确口径,只是没有应用到这条路径:

- openai_gateway_count_tokens.go 的 isOpenAIOAuthInputTokensUnsupported 已把
  「HTML 403 page without a structured error」按端点级响应处理;
- shouldApplyOpenAIAlphaSearchAccountErrorSideEffects 的既定不变式是
  端点级错误只换号、不写账号错误状态。

本次复用现成的 isHTMLResponse,在 handleOpenAI403 入口跳过账号处罚:不递增
连续 403 计数、不设临时不可调度、不永久禁用。failover 行为不变 —— 换一个走
不同代理的账号仍有可能成功。

Fixes #5334
2026-08-11 10:18:20 +08:00
pyt111 9261dd7734 [verified] fix: apply service-tier pricing to account cost 2026-08-11 09:55:19 +08:00
Wesley Liddick 0f73203e35 Merge pull request #5277 from Pluviobyte/agent/fix-grok-missing-usage-billing
fix: reject Grok responses without billable usage
2026-08-11 09:28:43 +08:00
Wesley Liddick e2d9034d3d Merge pull request #5327 from fengshao1227/fix/fingerprint-user-agent-validation
fix(identity): validate user-agent before persisting account fingerprint
2026-08-11 09:28:27 +08:00
Wesley Liddick ba4bd0c0b4 Merge pull request #5481 from fengshao1227/fix/responses-deterministic-400-passthrough
fix(openai): 原生 Responses 路径不再把上游确定性 400 归一成可重试 502
2026-08-11 09:27:34 +08:00
Wesley Liddick 61acf29125 Merge pull request #5501 from wucm667/fix/issue-5500-unset-scheduling-thresholds
fix(settings): cache unset scheduling thresholds
2026-08-11 09:27:22 +08:00
wucm667 3e1674a060 fix(settings): cache unset scheduling thresholds 2026-08-11 04:34:49 +08:00
Wesley Liddick 0b3fe95afd Merge pull request #5439 from pigzwy/feat/response-model-billing
feat(billing): support safe billing by upstream response model
2026-08-10 19:47:22 +08:00
anya e5b325e481 fix(billing): harden response-model billing admission
Three guards on the response_model billing basis, all scoped to the opt-in
channel mode so existing channels are unaffected.

1. Per-unit billing gate was stale. Audio (AudioUsage) and the search
   surcharge (SearchCount) reached the billing paths after this branch was
   cut; both are priced per unit rather than per token, so they must be
   excluded like image/video/web-search already are. Audio pricing ignores
   the model entirely, so the previous code "adopted" a basis switch that
   changed nothing and emitted a misleading audit log for it.

2. Never zero out a billable request. A catalog entry whose token prices are
   explicitly 0 still passes the identified-pricing gate (TokenPricingAbsent
   only means both prices are missing), so an upstream could declare a free
   model name and drop the bill to zero. Reject a zero (or negative)
   recomputation whenever the baseline was billable; an already-zero baseline
   is unaffected.

3. Never cross from channel pricing to the global table. Channel pricing
   matches exact keys and prefix wildcards and does not strip date suffixes,
   while the global table's identified lookup does. Upstreams routinely
   declare dated model IDs (claude-opus-4-5-20251101), so allowing a
   cross-source comparison would silently bypass an administrator's channel
   markup on essentially every request. Admins who want a downgrade target
   discounted can price it explicitly on the channel.

Also skip the recomputation entirely when the declared model equals the
baseline: it is provably the same cost and only burned a pricing resolve.

The identified-pricing helpers now return whether the model resolved to
channel pricing so the third guard costs no extra resolve.
2026-08-10 19:11:47 +08:00
shaw 33351c7bc7 fix(billing): gofmt channel.go and drop the redundant response-model hint
- channel.go: 常量块里插入注释后 gofmt 会把 BillingModelSourceResponse 单独成组,
  原写法沿用了上一组的对齐空格,CI 的 gofmt 检查因此失败
  (internal/service/channel.go:45: File is not properly formatted)。
- 撤掉渠道表单里新加的那条提示:说明本就多余,且"只降不升"只在"相对基线收费"
  这个口径下成立,容易被读成"上游返回更贵的模型也不会多收",反而误导。
2026-08-10 18:45:16 +08:00
shaw b689e5b401 fix(billing): harden response-model billing and repair its test fixtures
按上游响应模型计费的准入过宽、且自带用例必然失败,本次一并修复。

严格化准入
- 新增 PricingService.GetIdentifiedModelPricing / BillingService.HasIdentifiedTokenPricing:
  只接受价格表中能被确定性识别的条目(精确名、已知拼写变体、去掉日期版本后缀),
  不再接受 getFallbackPricing / matchByModelFamily 按子串猜出的系列兜底价。
  此前上游只要自报一个含 "haiku" 的编造名字就能被判定"已定价",把账单压到最便宜的
  系列价(实测 claude-opus-4.8 基线 $0.0019250 → $0.0000963,20 倍少收)。
  GetModelPricing 的对外行为不变,仅把前三步查找抽成共用函数。
- 图片 / 视频 / 网页搜索请求不再走响应模型覆盖:这些路径按张、按秒、按次定价,
  与准入检查所验的 token 价不是同一套价格表。
- 准入判断抽成 responseModelBillingDeclaration,两条计费主干共用同一套规则。

正确性与可观测性
- 去掉 recordUsageCore 中 billingModel 的无效赋值(ineffassign 已启用,会让
  golangci-lint 直接失败),改由日志表达实际生效的计费基准。
- 补 cost != nil 守卫,与 OpenAI 侧及本文件既有写法对齐。
- 每次实际生效的基准切换记一条 billing.response_model_applied,少收可审计。
- 修正 upstreamResponseModelObserver 上"冲突仅用于诊断、永不影响计费"的过期注释。

测试
- gpt-5.1 与 gpt-5.5 实际共用同一条 gpt-5.4 价格,夹具"价格必须不同"的前置断言
  必然失败,OpenAI 侧 3 个用例(含 4 个子用例)从未跑通;改用 gpt-5.4-nano /
  gpt-5.5。Anthropic 侧 claude-opus-4 不是价格表精确条目,改用 claude-opus-4.8。
- 夹具增加"必须可被确定性识别"的前置断言,避免用例被更靠前的门挡掉而失去判别力。
- 新增:准入规则表驱动用例、可识别性判定用例、编造家族名在两条主干上均被拒的用例。

前端
- 选择该模式时提示"计费基准以上游自报模型为准,只降不升,仅对可信上游启用"。
2026-08-10 18:45:15 +08:00
pigzwy 9096492b55 feat(billing): support safe upstream response model billing 2026-08-10 18:45:14 +08:00
wucm667 3d3aee2e72 fix(openai): skip stale and reset Codex snapshots in scheduling threshold evaluator 2026-08-10 14:27:04 +08:00
li 591d47fb9b fix(openai): 原生 Responses 路径不再把上游确定性 400 归一成可重试 502
上游返回 400(invalid_function_parameters、missing_required_parameter 等)时,
`/v1/responses` 原生路径把它包成 `502 upstream_error / "Upstream request failed"`,
真实的 message/code/param 全部丢弃。

502 属于可重试类,下游网关会把这个永远不会成功的请求反复重放(issue #5479 实测
30 个失败请求被放大成 60 次上游调用),客户端也拿不到「哪个字段非法」的线索。

同一个 service 上的兄弟路径早已做对:
- `handleCompatErrorResponse`(ChatCompletions / Anthropic)回真实状态码 +
  invalid_request_error + 真实 message;
- `/v1/images/generations` 还额外透传 code/param。

原生 Responses 是唯一漏掉的一条,本次对齐。

改动只放行 400:走到该分支说明 `shouldFailoverOpenAIUpstreamResponse` 已判定这个
400 不可 failover,即 server_is_overloaded / at capacity 这类可重试的 400 不会到达。
401/402/403(运营方凭据问题)、404/405(可能是 base_url 配错)、429 一律维持原状。

Fixes #5479
2026-08-10 11:25:46 +08:00
scp-planet 5350b3d98e fix(usage): restore request ID column visibility 2026-08-10 10:54:39 +08:00
Wesley Liddick 10a4c6e3ad Merge pull request #5234 from wucm667/fix/issue-5230-deduplicate-latest-turn-audit
fix(security-audit): deduplicate websocket turn audits
2026-08-10 10:53:17 +08:00
Wesley Liddick f3c7a1a8c4 Merge pull request #5295 from wucm667/fix/issue-5289-streaming-upstream-error
fix: emit response.failed when compact keepalive commits headers but no SSE payload
2026-08-10 10:52:49 +08:00
Wesley Liddick f19e3816d0 Merge pull request #5316 from wucm667/fix/issue-5313-legacy-scheduler-diagnostics
fix(scheduler): diagnose legacy OpenAI exclusions
2026-08-10 10:52:14 +08:00
Wesley Liddick 30d0405388 Merge pull request #5464 from wucm667/fix/issue-5455-api-key-input-validation
fix(api-key): validate quota and expiry inputs
2026-08-10 10:52:04 +08:00
Wesley Liddick 5deeb7ef15 Merge pull request #5376 from wucm667/fix/issue-5369-composite-image-permission
fix(admin): enable image generation permission for Composite groups
2026-08-10 10:51:54 +08:00
Wesley Liddick 895e8247af Merge pull request #5472 from wucm667/fix/issue-5468-openai-threshold-percent
fix(openai): preserve Codex usage percentages for scheduling thresholds
2026-08-10 10:51:43 +08:00
Wesley Liddick 2d0976ac2c Merge pull request #5475 from fengshao1227/fix/oauth-personal-subscription-expiry
fix(openai-oauth): 个人订阅到期时间不再被 POID workspace 的 entitlement 覆盖
2026-08-10 10:23:05 +08:00
Wesley Liddick 4667f9012f Merge pull request #5477 from Wei-Shaw/revert-5394-fix/issue-5388-risk-control-fail-closed
Revert "fix(risk-control): 风控后端异常时阻断提示词请求"
2026-08-10 10:00:02 +08:00
Wesley Liddick af6928a268 Revert "fix(risk-control): 风控后端异常时阻断提示词请求" 2026-08-10 09:59:42 +08:00
li 358e4a89a1 fix(openai-oauth): 个人订阅到期时间不再被 POID workspace 的 entitlement 覆盖
enrichTokenInfo 里 plan_type 与 subscription_expires_at 的取值口径不一致:

- plan_type 有 shouldApplyChatGPTAccountInfoPlanType 护着(#3641),
  id_token 里的个人套餐优先,accounts/check 只在个人值为空时补位;
- subscription_expires_at 却是无条件覆盖。

accounts/check 是多账号/工作区端点,命中的记录由 access_token JWT 的 poid 决定。
当 poid 指向默认 Personal workspace、而 chatgpt_account_id 指向个人 ChatGPT 账号时
(两者是不同标识),拿到的 entitlement.expires_at 描述的是 workspace 权益。
配上被保护下来的个人 plan_type,账号页就显示成「个人 Pro + workspace 到期时间」。

代码里本来已经有正确的来源——用个人 chatgpt_account_id 查
/backend-api/subscriptions 的 active_until——但它被
`if TrimSpace(SubscriptionExpiresAt) == ""` 挡着,workspace 值一旦写进去就永远不触发。

改为让两个字段始终描述同一份订阅:

- 套餐取自 accounts/check 时(id_token 没带 chatgpt_plan_type),到期时间跟着取
  同一条记录,行为不变;
- 套餐保留了 id_token 的个人值时,只有该记录确实属于个人账号才用它的
  entitlement.expires_at;不属于就跳过,并强制回落到个人订阅端点。

为此给 ChatGPTAccountInfo 补 AccountID:优先读 account.account_id,缺失时退回
accounts 的 map key(key 可能是 "default" 这类别名)。两侧任一缺 ID 时判定为
无法区分并沿用旧行为,poid == chatgpt_account_id 的单人账号完全不受影响。

Fixes #5459
2026-08-10 09:29:14 +08:00
Wesley Liddick b5e83156d9 Merge pull request #5394 from wucm667/fix/issue-5388-risk-control-fail-closed
fix(risk-control): 风控后端异常时阻断提示词请求
2026-08-10 08:51:46 +08:00
Wesley Liddick fddc806db2 Merge pull request #5458 from lyen1688/feat/backup-large-file-parts
完善大文件备份分卷上传与恢复
2026-08-10 08:36:08 +08:00
wucm667 99b31067f7 fix(openai): preserve Codex usage percentages in scheduling threshold 2026-08-10 04:29:08 +08:00
wucm667 f5c108c836 fix(api-key): validate quota and expiry inputs 2026-08-09 22:42:51 +08:00
lyen1688 bbc8b6e906 完善大文件备份分卷上传与恢复 2026-08-09 20:58:07 +08:00
hongheshan-svg 118c1e8e72 docs: sync Go 1.26.5 / golangci-lint v2.9 versions with CI
DEV_GUIDE.md and the three READMEs still advertise Go 1.25.7 and
golangci-lint v2.7, but CI has since moved on:

- backend/go.mod declares go 1.26.5, and backend-ci.yml / release.yml /
  security-scan.yml all resolve the toolchain via
  `go-version-file: backend/go.mod` and then hard-assert
  `go version | grep -q 'go1.26.5'`.
- backend-ci.yml pins golangci-lint to v2.9.

So a contributor following DEV_GUIDE.md installs a linter two minor
versions behind CI (different findings locally vs. in CI) and expects a
Go version that the workflow's own assertion step rejects.

Update all ten stale references, and note in the CI section which files
the Go version assertion lives in so future bumps don't miss one.

Docs only, no code or workflow changes.
2026-08-09 18:10:46 +08:00
github-actions[bot] 48eb3766d2 chore: sync VERSION to 0.1.173 [skip ci] 2026-08-09 08:26:22 +00:00
Wesley Liddick 29009f0b2e Merge pull request #5446 from Wei-Shaw/feat/email-domain-quota-switch
feat: 邮箱域名限量注册增加独立开关(默认关闭)
2026-08-09 16:09:17 +08:00
shaw 563a72ca73 feat: add default-off switch for email domain registration quota
PR #5423 relaxed the email suffix whitelist: once a whitelist is
configured, non-whitelisted registrable domains are each allowed to
register one account. That behavior activated unconditionally.

Add registration_email_domain_quota_enabled (default false) to gate it:

- Off (default): restore pre-#5423 strict whitelist semantics — with a
  non-empty whitelist, non-whitelisted domains are rejected with
  EMAIL_SUFFIX_NOT_ALLOWED; the register/verify views restore the
  client-side whitelist pre-check and allowed-domain hint.
- On: keep #5423 behavior — one account per non-whitelisted registrable
  domain (EMAIL_DOMAIN_REGISTRATION_LIMIT).
- Empty whitelist keeps allowing all domains in both states.

Gating lives in validateRegistrationEmailQuota and (as a race-safety
backstop) createUserWithRegistrationEmailGuard; the repository-level
domain lock + in-tx recheck is unchanged. The admin update field is
*bool (omitted = keep current) so stale full-payload saves cannot
silently flip the switch. Email binding and OAuth auto-signup keep
their strict policy, and pending-OAuth bind-login for existing
accounts is unaffected because the handler resolves existing emails
before the quota check.

Frontend adds the toggle to admin settings (zh/en copy; whitelist hint
restored to strict wording, quota wording moved to the new toggle) and
exposes the flag via public settings + SSR injection payload.

Tests: #5423 quota tests now enable the switch explicitly; new
default-off regression tests cover register/send-code/async/pending
OAuth/OIDC create-account plus both register views; API contract JSON
and the injection drift guard are updated.
2026-08-09 15:53:40 +08:00
Wesley Liddick f2da30bcd9 Merge pull request #5423 from lyen1688/feat/email-domain-registration-quota
完善邮箱域名注册额度策略
2026-08-09 15:16:26 +08:00
Wesley Liddick 7821c4005e Merge pull request #5424 from fengshao1227/fix/gemini-native-image-billing
fix(gemini): 原生生图按上游实际回吐的图片张数计费,修复自定义模型名下生图记 $0
2026-08-09 15:02:08 +08:00
Wesley Liddick c5bda8b8e4 Merge pull request #5416 from fengshao1227/fix/images-apikey-detach-upstream-context
fix(openai): 非流式生图脱钩上游 context,客户端断开不再导致图已出却不扣费
2026-08-09 14:55:28 +08:00
Wesley Liddick a1073843ac Merge pull request #5437 from Brisbanehuang/codex/upstream-response-model-audit-followup
perf(usage): 优化上游响应模型观察热路径
2026-08-09 14:55:15 +08:00
Wesley Liddick 7a113fb7a3 Merge pull request #5352 from feeeei/main
fix(gemini): 修复Gemini池模式时,依然被 429 response 触发账户限流问题
2026-08-09 14:55:02 +08:00
Wesley Liddick 909cbb9b12 Merge pull request #5356 from IanShaw027/feat/channel-monitor-v2-ops-ui
feat(channel-monitor-v2): 新增被动渠道监控 V2
2026-08-09 12:25:37 +08:00
shaw d92edc01be Merge origin/main into feat/channel-monitor-v2-ops-ui
Resolves three conflicts, all of the "both branches appended to the same
block" shape. Every one is resolved as a union of both sides; nothing from
either parent is dropped.

- handler/admin/setting_handler_update.go: keep ChannelMonitorHideThroughput
  (V2) alongside GrokDefaultTextModel / GrokCrossClientModelMapEnabled /
  GrokDefaultBaseURLMode (#5408). UpdateSettings writes every key on each
  save, so dropping either side would reset those settings to zero values.
- service/domain_constants.go: keep SettingKeyChannelMonitorHideThroughput
  and the three SettingKeyGrok* constants.
- repository/migrations_runner.go: keep the 195 checksum rule (V2) and the
  218/219/220 rules (#5408).
2026-08-09 12:11:35 +08:00
Wesley Liddick fb0475656c Merge pull request #5408 from IanShaw027/feat/grok-complete-integration
feat(grok): 完善 Grok 平台集成 — 授权、模型映射、媒体/Voice/搜索计费与调度门禁
2026-08-09 11:31:09 +08:00
Brisbanehuang 6e34fb09c9 perf(usage): optimize upstream response model observation 2026-08-08 22:04:49 -04:00
IanShaw027 5315896b30 fix(grok): align free soft-gate tests with async fail-open cache
Treat cacheTTL=0 as non-expiring known entries, always store negative
refresh markers, and update sticky getSchedulableAccount tests to expect
first-hit fail-open then block after background stats warm.
2026-08-08 23:38:50 +08:00
IanShaw027 a54a4b674b fix(grok): clear golangci errcheck and gofmt on free-quota path
Check the sync.Map type assertion in free-quota refresh coalescing, and
gofmt migration checksum rules plus prompt-audit route map alignment.
2026-08-08 22:52:31 +08:00
li b6eb6c1efa fix(gemini): 原生生图按上游实际回吐的图片张数计费
/v1beta/models/{model}:generateContent 与 Anthropic→Gemini 兼容路径的
ImageCount 只由 isImageGenerationModel(originalModel) 决定,而该白名单是
按 Google 官方模型名精确/前缀匹配写死的(antigravity_image_test.go 里
明确断言 my-gemini-3-pro-image-test 这类自定义名返回 false)。

GeminiMessagesCompatService 服务的却主要是 API Key + 自定义模型映射的账号:
客户端请求名和 GetMappedModel 后的上游名都可能是站长自取的别名,白名单必然
判不出来 → ImageCount=0 → calculateRecordUsageCost 里 `if result.ImageCount > 0`
的按次计费分支整条不触发 → 生图请求全部记 $0(issue #5358)。

改为优先按上游响应里真实的 inlineData 图片 part 计数:
- 新增请求级计数器,挂在 gin.Context 上,与既有的
  upstreamResponseModelObserver 同一批调用点取解包后的响应体;
- 取「单个 payload 内的最大值」而非累加:Gemini 兼容上游的 SSE 分片可能是
  累积式的(computeGeminiTextDelta 正是为此存在),逐 chunk 累加会把同一张图
  重复计费。max 保证累积式流与非流式都得到真实张数,增量式多图流最差退化到 1,
  与改动前同值,不构成回退;
- 每次 Forward 开头重置计数器,避免 failover 复用同一个 gin.Context 时
  把失败账号已回吐的图叠加到成功账号账单上;
- 响应里数不出图时(fileData 引用式回图等)退回原有模型名启发式,并额外认
  映射后的上游模型名,与 shouldSkipCodexPlanGatedImageModelCooldown 同时取
  requestedModel / modelKey 的口径一致。

inlineData / inline_data 两种字段风格都认(官方 SDK 与部分中转回 snake_case),
只统计带 base64 数据且 MIME 为图片的 part。

Fixes #5358
2026-08-08 21:36:18 +08:00
lyen1688 4999231d61 修复邮箱域名注册额度策略 2026-08-08 21:05:20 +08:00
feeeei cbc2a3dd46 修复池模式 Gemini 账号被 429 打上账号级限流
- 429 的标记点在重试循环内,先于 CheckErrorPolicy 执行,池模式豁免只能落在
  handleGeminiUpstreamError 自身;否则一次上游 429 会把账号锁到 PST 午夜,
  即便重试已经成功返回客户端
- 判定条件与 HandleUpstreamError 对齐:自定义错误码优先级高于池模式;
  401/403/529 仍委派给 RateLimitService,临时不可调度规则不受影响
- chat completions 路径的策略分发改为只有 None / Matched 才处理账号状态,
  与 messages 兼容层的 switch 一致,ErrorPolicySkipped 不再漏进来
2026-08-08 16:51:48 +08:00
li cbf2be05a3 fix(openai): 非流式生图脱钩上游 context,客户端断开不再导致图已出却不扣费
forwardOpenAIImagesAPIKey 走的是 detachStreamUpstreamContext(ctx, parsed.Stream),
该函数在非流式时原样返回请求 context(gateway_usage_billing.go:488)。于是客户端
中途断开会连带取消已经在出图的上游调用:上游那边图已生成并计费,网关这边拿到
context canceled,记 502,result 为 nil,handler 的 result.ImageCount > 0 兜底
无法命中,本次请求不产生扣费。

生图是长耗时(数十秒)且上游侧已产生实际成本的操作,与普通非流式 chat 不同:
提前取消并不能省下上游开销,只会丢掉已付费的产出。

同一端点的 OAuth 分支 forwardOpenAIImagesOAuth(openai_images_responses.go:1690)
以及同属媒体生成的 grok_media.go 本来就用无条件脱钩的 detachUpstreamContext,
OpenAI 网关侧 13 个转发点里只有这一处是例外。这里对齐。

Fixes #5411
2026-08-08 15:24:34 +08:00
IanShaw027 e91b494168 fix(test): stabilize OpenAI streaming preamble keepalive assertion
Keepalive is gated on downstream idle, not upstream tick cadence. The old
fixture wrote progress events every 250ms and could finish without a true
1s idle window on loaded CI runners, so ":\n\n" never appeared. Pause after
preamble long enough for the keepalive ticker before completing the stream.
2026-08-08 15:00:33 +08:00
IanShaw027 04d9eeaf07 fix(channel-monitor-v2): clear CI lint and align trend axis with range zoom
Fix golangci unused/gofmt on the gentle-backfill path. Plot matrix and
line-chart X axes on the selected [requested_start, requested_end)
window (empty slots while backfill lags), and let plain mouse-wheel zoom
narrow the visible interval so pulse blocks grow wider.
2026-08-08 14:46:35 +08:00
IanShaw027 7eb1310701 fix(grok): close free-by-default billing and related review blockers
H1/H2: bill search and voice with code defaults when group prices are
nil (explicit 0 remains free); bump API key auth snapshot to v19 and
refresh incomplete media/search/audio projections.

M1–M6: free-quota soft gate fails open on cache miss with background
refresh and 60s default TTL; correct password_auth config docs; default
cross-client model map to true (→ grok-4.5); audit /tts and /web_search;
exclude composite from migration 220 video-price clears; never let a
search surcharge mask token pricing failures.
2026-08-08 14:39:22 +08:00
Yuxin Qiao 280c1c8623 fix(openai): fail over empty response.completed streams instead of recording 0/0 success
A Responses stream that ends with response.completed but carries no output,
no usage and no error (only response.created + response.completed observed)
is a silent upstream refusal. It was previously treated as a successful
turn, recording 0 input / 0 output tokens without triggering failover, so
clients saw an empty reply and usage was never billed.

Detect the empty terminal event in both the standard and passthrough
streaming paths while nothing has been written to the client, and return an
UpstreamFailoverError so the next account is tried; when failover is
exhausted the client gets an explicit Responses-format error instead of an
empty success. Streams with any semantic output, usage or error are
untouched. Fixes #5009.
2026-08-08 14:35:10 +08:00
Yuxin Qiao 9f31df3fa8 fix(openai): strip invalid reasoning item IDs in API key passthrough
OpenAI upstream requires reasoning input item ids to begin with 'rs' and
rejects replayed item_* ids with 400 ("Expected an ID that begins with
'rs'."). shouldStripOpenAIResponsesInputItemID handled message (msg) and
tool call (fc) ids but not reasoning items, so long Codex sessions restored
through the OpenAI API key passthrough path failed with a converted 502.

Strip non-rs reasoning ids while preserving valid rs_* ids, mirroring the
existing message/tool-call handling. Fixes #5410.
2026-08-08 14:34:53 +08:00
IanShaw027 825f9c78d5 fix(channel-monitor-v2): default mode v1 and gentle adaptive backfill
Default channel_monitor_mode to v1 (opt-in V2) so upgrades keep active
probes; existing explicit v2 rows are left alone via ON CONFLICT DO NOTHING
plus migration checksum compatibility for already-applied 195.

V2 first-enable backfill no longer compresses ticks to 5s or uses 24h
chunks. Each tick does recent overlap plus at most one historical chunk
with depth-based ceilings (2h/4h/6h), adaptive grow/shrink, and failure
backoff. Error request_id dedup is bounded by a 90-minute lookback so
ops_error_logs is not scanned for full history.

Also align hide_throughput parse default with privacy-preserving public
runtime (missing key → true).
2026-08-08 13:59:11 +08:00
IanShaw027 cec922d335 fix(grok): clear golangci-lint findings on complete-integration branch
Check Close/CloseNow errors, drop unused helpers and dead constants,
lowercase ST1005 error strings, and stop discarding unwrap status as an
unused assignment so CI golangci-lint passes.
2026-08-08 12:56:40 +08:00
IanShaw027 a4faa015a7 fix(deps): bump nanoid 3.3.16 -> 3.3.17 for GHSA-2v37-7h3g-55p8
Match upstream lockfile pin so frontend-security audit gate passes.
2026-08-08 12:50:53 +08:00
IanShaw027 3c22aeeb3d fix(channel-monitor-v2): default hide_throughput true for privacy parity
Align InitializeDefaultSettings and parseSettings with the public-settings
path and migration 206 so admin and user views agree when the key is unset.
2026-08-08 12:49:39 +08:00
IanShaw027 1f58e25ab3 Merge upstream/main into feat/grok-complete-integration
冲突集中在 chat completions / messages 两条 Responses 转发路径:
upstream 给 OpenAIForwardResult 增加了 UpstreamResponseModel 与
UpstreamResponseModelConflict(配套 beginUpstreamResponseModelObservation
观测器),本分支在同样位置把返回值改成了具名变量以便挂 Grok 原生搜索计数。
两侧不互斥,合并结果同时保留上游的响应模型观测字段与 Grok SearchCount 逻辑。

frontend/pnpm-lock.yaml 取 upstream 版本:package.json 与 upstream 完全一致,
本地差异只是 pnpm install 的重解析噪音。
2026-08-08 11:12:56 +08:00
IanShaw027 07b46e93e4 fix(grok): 修正 voice 路由推导、视频价归一化与门禁缓存驻留
- custom-voices endpoint 改由匹配到的路由模板推导(c.FullPath())。
  原实现按请求 URL 字面后缀判 /audio,voice_id 恰为 "audio" 时
  GET /custom-voices/audio 会被改写成 custom-voices/audio/audio,
  把档案查询变成音频下载。补 voice_id="audio" 的路由推导测试。

- NormalizeVideoModelPrices 不再把无法识别的分辨率静默折算成 480p:
  新增 LookupVideoBillingResolution 报告未知档位,配置解析路径丢弃并告警,
  运行时计费仍走 OrDefault 兜底。model/tier 两层遍历改为排序遍历,
  多个别名收敛到同一 family 时结果不再随 Go map 顺序漂移,冲突单价告警。

- grok free quota 软门禁缓存新增过期淘汰。条目按 account_id 键控且只写不删,
  账号下线后会驻留至进程结束;淘汰只挂在已受 TTL 约束的查询路径上,
  不影响缓存命中热路径。

- 迁移 220 清空非 Grok 分组视频价前先落快照表 groups_video_price_backup_220,
  原 UPDATE 不可回滚。

- 简化 grok_search_count 两处等价冗余的事件类型分支。
2026-08-08 11:02:22 +08:00
Wesley Liddick cc67b1aca1 Merge pull request #5406 from bestony/fix/openai-oauth-routing-hints
fix(openai): forward OAuth routing hints
2026-08-08 10:58:53 +08:00
shaw 8ad0a5ff52 fix(deps): bump nanoid 3.3.16 -> 3.3.17 to clear GHSA-2v37-7h3g-55p8 audit gate 2026-08-08 10:48:42 +08:00
IanShaw027 b7112d596f fix(keys): grok-4.5 上下文窗口按 500k 对齐并修正账号测试 i18n mock
- UseKeyModal 中 grok-4.5 的 context_window / model_context_window /
  OpenCode 模型目录统一改为 500000,与 xAI 实际上下文一致
- UseKeyModal.spec 的 OpenCode 目录断言同步为 500000
- AccountTestModal.spec 补齐 imagePreviewAlt 的 i18n mock,
  跟上组件把 alt 文案迁到 i18n 之后的行为
2026-08-08 10:40:40 +08:00
IanShaw027 b717ed9d0d feat(keys): complete Grok Build sample with endpoints/auth/session/features
Expand Use Key Grok CLI config.toml to include production-oriented sections
from working gateway setups: full [endpoints], preferred_method=api_key,
image_description, auto_compact threshold, and Imagine image/video feature
overrides with guided comments.
2026-08-08 10:33:45 +08:00
IanShaw027 f3bac4619e feat(keys): expand Grok client samples and tune free soft-gate default
Ship Use Key templates that match Grok Build / Codex best practice: env
vars + multi-model config.toml with api_backend=responses, env_key over
hardcoded secrets, and clearer shell/path guidance for Claude/Codex/OpenCode.

Also set free_quota_token_limit default to 500k (24h soft-gate), clean up
personal-dev-only comments, and keep billing test fixtures aligned.
2026-08-08 10:26:16 +08:00
IanShaw027 2e857b20b8 chore(grok): 从 PR 中移除进度文档与 lockfile 噪音
- 删除 CHANNEL_MONITOR_V2_DESIGN.md(属于其它分支的设计草稿)
- 删除 backend/docs/GROK_INTEGRATION_PROGRESS.md(开发期进度记录,不属于产物)
- 还原 frontend/pnpm-lock.yaml 至 upstream/main(package.json 无变化,
  差异全部来自本地 pnpm install 重解析)
2026-08-08 10:00:56 +08:00
IanShaw027 b7863650ec fix(usage): prefer explicit video billing mode over image_count
Reuse shared getDisplayBillingMode so user usage rows with billing_mode=video
are not mislabeled as image when image_count is non-zero.
2026-08-08 09:45:12 +08:00
IanShaw027 bdeb13021a fix(admin): i18n account-test media uploads and shared file pickers
Replace native file-input labels with translated choose-file buttons, localize
upload errors and previews, and fix ImageUpload defaults for zh/en.
2026-08-08 09:45:12 +08:00
IanShaw027 e01ce90d47 fix(grok): harden voice request ids, video pending, and search pricing alerts
Mint durable grok_audio/grok_realtime usage ids, avoid CLI headers on api.x.ai
voice, retry video pending store and fail-closed when snapshot is missing without
status duration, align pure-video ImageCount tests, and escalate unset search
price_per_1k to error-level logs.
2026-08-08 09:45:12 +08:00
IanShaw027 12db0f906a fix(grok): drop account-test ZDR path and align media CLI headers
Remove optional upload_url / fake connectivity-only success from admin video
tests. Stamp Grok CLI headers only on the CLI proxy so OAuth media against
api.x.ai can complete and preview video like the gateway path.
2026-08-08 09:45:12 +08:00
IanShaw027 8005335742 fix(channel-monitor-v2): align admin error details dedup 2026-08-08 09:00:15 +08:00
IanShaw027 64d1ebe4a5 fix(channel-monitor-v2): close dedup and migration privacy gaps 2026-08-08 08:59:18 +08:00
IanShaw027 9f3ee38d4e fix(channel-monitor-v2): preserve migration checksums and privacy defaults 2026-08-08 08:55:59 +08:00
IanShaw027 77e39fe608 docs(grok): note custom-voices coverage and free 24h soft-gate scope
Document voice CRUD/audio surface area and clarify free-window local soft-gate
semantics versus upstream metering.
2026-08-08 08:48:38 +08:00
IanShaw027 85fb776151 test(frontend): mock getLiveCapability and align rollback timeout
Stub GroupsView live capability API in unit tests and expect the longer
system rollback request timeout; refresh pnpm-lock after dependency resolve.
2026-08-08 08:48:38 +08:00
IanShaw027 6345b048d1 style(admin): color paid Grok and OpenAI plan badges
Keep free muted gray; SuperGrok cyan, Heavy purple, and distinct colors for
OpenAI Plus/Team/Pro so paid subscriptions stand out in the account list.
2026-08-08 08:48:38 +08:00
IanShaw027 e1d6600eb2 fix(admin): simplify Grok usage UI for free 24h and paid windows
Show free accounts only the rolling 24h soft-gate bar; paid accounts show
7d/30d and prepaid money. Drop the always-visible manual probe chip from the
usage cell in favor of scheduled recovery and usage load.
2026-08-08 08:48:38 +08:00
IanShaw027 8399a30417 fix(grok): treat free-usage and billing exhaustion as recoverable
Classify free-usage bodies during account tests without quarantining content
policy, and mark billing/spending-limit refresh failures as transient so
accounts stay probe-eligible.
2026-08-08 08:48:38 +08:00
IanShaw027 35faaa6d21 feat(grok): register custom-voices CRUD and audio download gateway routes
Forward list/get/patch/delete and reference-audio paths with safe path segment
encoding, method passthrough, and empty-body GET/DELETE handling.
2026-08-08 08:48:38 +08:00
IanShaw027 85b65284ec fix(grok): set async video duration_ms from create accept to done discovery
Store CreatedAt on pending billing at video create and use wall-clock E2E
latency when status/content first observes official done+video.url, so usage
logs no longer record only the single poll hop.
2026-08-08 08:48:38 +08:00
IanShaw027 0d98176c59 fix(channel-monitor-v2): correct aggregation privacy and backfill 2026-08-08 01:59:44 +08:00
IanShaw027 165b072908 fix(grok): gateway media/voice routing, models, and status UI polish
Align gateway Grok media/voice paths and model lists, harden upstream failure
and quota handling, clear non-Grok video generation config migration, and polish
temp-unsched/status indicators with model whitelist updates.
2026-08-08 01:07:27 +08:00
IanShaw027 2526a04226 fix(admin): empty web-search config on missing setting and reset dialog scroll
Return a disabled empty web-search-emulation config when the setting key is
absent, and reset BaseDialog body scroll on open for long modals.
2026-08-08 01:07:27 +08:00
IanShaw027 68faeac837 fix(grok): restore base URL resolution and operator settings wiring
Honor account GetGrokBaseURLOr policy for official vs custom endpoints,
and wire settings resolution used by responses/chat URL builders.
2026-08-08 01:07:19 +08:00
IanShaw027 6d632eec45 fix(grok): tighten OAuth SSO flow and hide password login
Require oauth state/redirect consistency, fail closed on missing proxy,
and remove password login from create/reauth UI (admin-only password path stays off by default).
2026-08-08 01:07:19 +08:00
IanShaw027 d0767eab9d feat(grok): admin account test modes with real media preview
Add mode-first connectivity probes for text/image/video/search/tts/stt/realtime,
standalone voice and web-search paths, media upload options, and in-browser
image/audio/video preview (including ZDR-safe b64 images and edit validation).
2026-08-08 01:07:08 +08:00
wucm667 9763765ebf fix(openai): fail over OAuth image stream errors 2026-08-08 01:05:29 +08:00
cyh 74fcdf3d42 fix(openai): preserve exponential capacity backoff 2026-08-08 01:02:02 +08:00
github-actions[bot] 68d8f122e4 chore: sync VERSION to 0.1.172 [skip ci] 2026-08-07 15:39:12 +00:00
Wesley Liddick 155c494964 Merge pull request #5399 from fengshao1227/fix/responses-anthropic-invalid-content-blocks
fix(apicompat): Responses→Anthropic 转换不再发出上游会拒收的 content block
2026-08-07 23:21:59 +08:00
Wesley Liddick 8991574873 Merge pull request #5345 from puppywang/fix/oauth-pending-account-takeover
fix(security): block OAuth account takeover via pending exchange
2026-08-07 23:20:22 +08:00
Wesley Liddick 8f7b0a314d Merge pull request #5398 from Wei-Shaw/fix/openai-capacity-shed-stream-recovery
fix(gateway): 流内降载错误恢复 pre-output failover 并对客户端改写为可重试错误码
2026-08-07 23:11:20 +08:00
li 64090de664 fix(apicompat): Responses→Anthropic 转换不再发出上游会拒收的 content block
convertResponsesInputToAnthropic 的 default 分支把未知 item 的 content 逐字
透传成 Anthropic user 消息,Responses 专有的分片类型会原样进入上游请求体。
最典型的是工具执行后回放的 reasoning item:带 content 数组时,reasoning_text
块直接发给 Anthropic,上游回 400 Request body format invalid,而该 item 会一直
留在会话历史里,导致此后每一轮都继续失败。

同时修正两处会产出空内容消息的路径——Anthropic 拒收空内容消息与空白 text 块:
分片全部不可识别时,user 消息退化成 content:""、assistant 消息退化成单个空
text 块。

改动:
- type=reasoning 显式跳过。Anthropic 无法摄入 OpenAI reasoning:encrypted_content
  不透明,thinking 重放需要上游签发的 signature。Codex 常见形态(只带 summary +
  encrypted_content)本来就会被丢弃,这里让带 content 的形态行为一致。
- default 分支改走 convertResponsesUserToAnthropicContent 白名单转换,保留其中
  可识别的文本/图片,丢弃其余分片。
- user / assistant 分支在转换结果为空内容或纯空白文本时跳过该消息。

Fixes #5329
2026-08-07 23:09:03 +08:00
Wesley Liddick de904c433e Merge pull request #5396 from Brisbanehuang/codex/upstream-response-model-audit
feat(usage): 增加上游响应模型审计
2026-08-07 23:00:46 +08:00
shaw 14a27f1960 test(gateway): 校准 error 帧边界 flush 期望至 pre-output failover 新契约
可重试类 error 帧按设计不再算客户端输出(保留在 attempt 缓冲中,
为随后的 response.failed 保住 pre-output failover),因此不在自身
边界单独 flush,而是与终止帧一起出站(1 次 flush)。原用例按旧行为
断言 2 次 flush 导致 CI 失败。

同时补充不可重试类(invalid_request)error 帧仍在边界 flush 的对照
用例,锁住两类错误帧的行为区分。
2026-08-07 22:55:42 +08:00
shaw c33c3208e3 fix(gateway): 流内降载错误恢复 pre-output failover 并对客户端改写为可重试错误码
上游容量降载的真实序列是「event: error → event: response.failed」。此前
{"type":"error"} 帧被当作首个客户端输出立即 flush,clientOutputStarted 被固化,
随后的 response.failed 永远进不了 pre-output failover 分支,降载错误被原样转发;
Codex CLI 对 server_is_overloaded/slow_down 按闭集判致命,直接终止会话并提示
"Selected model is at capacity. Please try a different model."。

修复:
- 可重试类 error 帧不再算客户端输出,恢复既有的同账号重试+切号链路;
  不可重试类(content_policy/invalid_request 等)维持原样转发,保留上游错误细节
- 必须转发给客户端时(流中途已有输出 / WS 桥接),把 server_is_overloaded、
  slow_down 改写为致命集之外的 server_error,触发 Codex 内置退避重试;
  错误消息原样保留,rate_limit_exceeded 等其他错误码一律不动
- 监控、计费与账号状态判定均基于改写前的原始事件
2026-08-07 22:42:33 +08:00
白宦成 de349187d9 fix(openai): harden priority routing hints 2026-08-07 21:47:54 +08:00
白宦成 815035fcc9 fix(openai): send OAuth routing hints 2026-08-07 21:47:53 +08:00
白宦成 915cc7e7bd fix(openai): stop injecting legacy beta on OAuth responses 2026-08-07 21:47:53 +08:00
Brisbanehuang db0bff82c7 feat(usage): audit upstream response models
(cherry picked from commit 839036224f795c8ee5dc6718a2a14372a45eea44)
2026-08-07 09:40:11 -04:00
Wesley Liddick e88fc52ce6 Merge pull request #5383 from fengshao1227/fix/responses-tool-parameters-null-type
fix(openai): 修正 Responses 工具 Schema 中显式为 null 的 parameters.type
2026-08-07 21:06:10 +08:00
Wesley Liddick 045b620d0d Merge pull request #5391 from fengshao1227/fix/codex-plan-gated-image-model-no-cooldown
fix(ratelimit): 图片模型被 Codex 文本端点拒绝时不再写模型冷却
2026-08-07 21:05:58 +08:00
Wesley Liddick e79429b03f Merge pull request #5392 from fengshao1227/fix/grok-405-failover-and-cooldown
fix(grok): 405 纳入 failover 与临时踢号,修复 Grok 会话粘性锁死
2026-08-07 21:05:46 +08:00
li 02fbcbe3ad fix(ratelimit): 守卫按端点来源门控,并与冷却键对齐模型口径
上一版守卫只看模型类型,不区分请求从哪个端点进来。OAuth 账号的 /v1/images/*
上游同样是 Codex Responses(openai_images_responses.go → handleOpenAIImagesErrorResponse
→ handleOpenAIAccountUpstreamError → HandleUpstreamModelNotFound),所以专用生图
端点也会命中 plan-gated 分支。账号确实不具备生图能力时跳过冷却,会让调度层失去
唯一的刹车:每个请求都完整走一遍号池,对上游形成无上界的 400 放大。

改动:
- 新增 ctxkey.OpenAIImagesEndpoint 与 WithOpenAIImagesEndpoint /
  OpenAIImagesEndpointFromContext,在 handler/openai_images.go 入口置位;
  与 OpenAIImageGenerationIntent 区分——后者在 /v1/responses 带图片模型时也会置位。
- 守卫下移到 modelKey 计算之后,抽成 shouldSkipCodexPlanGatedImageModelCooldown,
  仅在 plan-gated 分支、且非 /v1/images/* 入站时生效。
- 同时判断 requestedModel 与最终 modelKey:冷却键走 account.GetMappedModel,
  账号可以把文本别名映射到 gpt-image-*,只判请求模型会漏掉这种形态。
2026-08-07 20:49:39 +08:00
li b5d9fd21b0 fix(ratelimit): 图片模型被 Codex 文本端点拒绝时不再写模型冷却
gpt-image-* 被误发到 /v1/chat/completions 或 /v1/responses 时,Codex 会稳定
返回 400 codex_plan_gated_model。HandleUpstreamModelNotFound 把它当成账号能力
缺失,给每个账号写 30 分钟 model_rate_limits 冷却。failover 走完整个号池后,
池内每个账号都冷却了该图片模型,于是后续走对端点的 /v1/images/generations
请求也一并 503 no available accounts。

在 codex plan-gated 分支里对图片模型提前返回:仍然 failover 掉本次尝试,
但跳过 SetModelRateLimit 写入,不把号池对正确的生图请求整体下线。

Fixes #4828
2026-08-07 20:41:34 +08:00
li 146b8b6684 revert(grok): 撤回 405 账号级踢号,只保留 failover 分类
tempUnscheduleGrok 是账号级、全端点、全模型的下线,而 handleGrokAccountUpstreamError
有 8 个调用点(raw chat / Grok 媒体 / CC 管线 / WS HTTP 桥接 / responses),
/v1/responses 的 405 只证明该端点缺失,账号在其他端点上仍然健康。

仓库里已有相反的既定不变式:shouldApplyOpenAIAlphaSearchAccountErrorSideEffects
把 404/405 显式排除在账号状态写入之外,注释写明「端点不存在只说明该上游不支持
独立搜索,账号本身健康」。

failover 分类器加 405 单独就能解开 sticky 锁死(调度器选号时会即时改绑 sticky),
账号级冷却不是必需的。「405 不踢号但记住端点能力」按端点级负缓存另行处理。
2026-08-07 20:41:23 +08:00
li a071b27b41 fix(grok): 405 纳入 failover 与临时踢号,修复 Grok 会话粘性锁死
Grok 分组里若有账号的自定义 base_url 只实现 /v1/chat/completions,对
/v1/responses 固定返回 405,会话 sticky 到该账号后就永久锁死:

1. shouldFailoverUpstreamError 不含 405,请求不进换号循环
2. handleGrokAccountUpstreamError 的 switch 不含 405,账号不进临时不可调度
3. 账号仍可调度,sticky 不清理,客户端自动重连继续绑同一个坏号

改动两处:
- shouldFailoverUpstreamError 增加 405,让请求可换号,sticky 随之更新
- handleGrokAccountUpstreamError 增加 405 → 30 分钟冷却,与 403 同级

Fixes #4668
2026-08-07 20:37:56 +08:00
li 379db19141 perf(openai): 工具 Schema 净化改为单次拼接,去掉逐路径全量重写
sjson.SetBytes 在 opts 为 nil 时 optimistic=false、inplace=false,每次调用都要
重扫整个文档并新建缓冲区拷贝,命中 N 处就是 N 次全量拷贝。/v1/responses 的 body
上限是 gateway.max_body_size(默认 256MB),构造请求能塞进百万级命中,会被放大
成 TB 级 memcpy。

改为收集 gjson 给出的绝对字节偏移后一次性拼接:全程一次分配、一次拷贝。
偏移不可用(Index 为 0)或与 body 对不上时跳过该处,而不是猜位置拼出损坏的 JSON。

实测(2000 处命中):旧写法 17740 次分配,新写法 18 次。
2026-08-07 20:37:44 +08:00
li f3c94d2099 fix(openai): 修正 Responses 工具 Schema 中显式为 null 的 parameters.type
Codex Desktop 内置的 automation_update 工具会带 parameters.type = null,
OpenAI 直接回 400 invalid_function_parameters,而网关把该状态归一成可重试的
502 upstream_error。该工具定义还会沉进多轮会话历史,之后每一轮都继续失败,
同一份坏 Schema 在账号池里被反复重放。

在 OpenAIGatewayService.Forward 分流到 passthrough / Codex transform /
原生 ChatCompletions 之前,统一把显式为 null 的 parameters.type 补成
"object",覆盖顶层 tools[] 与多轮历史里 input[].tools[] 的嵌套工具定义。

只修正显式 null:缺失 type 的 Schema 本身合法,补写会收窄客户端语义,
因此保持原样。

Fixes #5364
2026-08-07 20:30:44 +08:00
wucm667 e01c917a97 fix(risk-control): block prompts when risk control backend fails
- Change loadRuntimeSnapshot failure from fail-open to fail-closed
- Return block decision with 500 status when config cannot be loaded
- Add regression test for snapshot failure scenario
- Fixes #5388
2026-08-07 20:29:17 +08:00
Wesley Liddick b8c0c00091 Merge pull request #5384 from fengshao1227/fix/ops-system-log-sink-flush-backoff
fix(ops): 系统日志落库失败后退避重试,避免拖垮数据库连接池
2026-08-07 20:20:20 +08:00
Wesley Liddick bd9b12bbcb Merge pull request #5380 from Wei-Shaw/fix/subscription-daily-midnight-reset
fix(subscription): 恢复日额度每天 0 点重置,修复手动重置后刷新时刻漂移
2026-08-07 19:49:08 +08:00
IanShaw027 72a56f862c fix(grok): free 500k + 对齐 personal-dev tier/时间窗
- soft-gate 默认额度 500k tokens / 滚动 24h / 95%(门禁 475k)
- soft-gate 仅显式 free OAuth(subscription_tier/plan_type == free)
- isKnownGrokFreeAccount 按 personal-dev(usage% 为 paid 证据、仅 credentials tier)
- 调度阈值仅 grok_sched header quota 窗,去掉 billing 7d/30d 候选
2026-08-07 18:52:53 +08:00
li e687ca3e9d fix(ops): 系统日志落库失败后退避重试,避免拖垮数据库连接池
OpsSystemLogSink 的 flush 没有任何失败退避:ticker 每 1 秒触发一次,每次都
带 5 秒超时打一批 COPY,失败后立刻在下一个 tick 再来一次。远程 PostgreSQL 上
COPY 被 context 取消会导致协议失步、连接被服务端 FATAL 终止,于是这条循环
变成持续的「占用连接 → 超时取消 → 销毁重建」;在 max_open 较小的连接池里,
日志通道会长期占满连接,业务侧最终报 Billing 503。

改为连续失败后指数退避(2s 起,翻倍,封顶 60s):

- 退避窗口内直接丢弃当前批次并计入 dropped_count,不再触达数据库;
- 任意一次成功立即清空失败计数与抑制窗口;
- 失败日志随退避天然收敛为每个窗口至多一条,并附带 failures/backoff。

Fixes #5265
2026-08-07 18:45:05 +08:00
IanShaw027 8f5657b91c fix(grok): 收窄 free 推断 — 仅月度探测,勿把周度 OK 当 free
- isKnownGrokFreeAccount 不再因 weekly StatusOK 推断 free
- 修复周度付费快照被 media soft-block 的回归
- 外部 OpenAI token 对比测试在 401 时 skip
2026-08-07 18:34:55 +08:00
IanShaw027 3a415e6d04 fix(grok): 五轮评审 — messages SearchCount 与 OpenAI free-gate
- /v1/messages Grok:SSE/buffered 路径累计 SearchCount
- OpenAI 遗留 sticky/list 应用 free soft-gate(advanced 关闭时)
- 单测:OpenAI getSchedulableAccount free-gate + messages 计数器
2026-08-07 18:23:28 +08:00
IanShaw027 3ae94df72b fix(grok): 四轮评审 — SearchCount 接线与 sticky free-gate
- forwardGrokResponses 传播 stream/JSON 的 SearchCount 与 ImageCount
- Chat 桥接 SSE/buffered 对 Grok 累计 SearchCount(附加费)
- getSchedulableAccount sticky 应用 free soft-gate
- 无 call_id 时用合成 key 去重,避免 SSE ~2× 超扣
- 单测:JSON/SSE 接线、sticky free-gate、no-id dedup
2026-08-07 18:15:08 +08:00
IanShaw027 d7c9e7167b fix(grok): 三轮评审 — 流式 Search 去重与调度/计费加固
- P0: 直播 SSE SearchCount 跨事件 call_id 去重,避免 ~2× 附加费
- SearchCount/Audio/WebSearchCalls 走 mandatory usage task
- web_search:uuid 等 forced request_id 优先于 client/local
- Sanitize 始终剥离 cookie;ApplyOAuth 清 grok_needs_reauth_at
- free 判定:paid 证据压过陈旧 free 凭据
- Gateway 列表应用 free soft-gate;token/body-read 可 failover
- web_search 重试支持 WaitPlan 获取;周 PeriodEnd 不再回填月 end
2026-08-07 17:58:23 +08:00
shaw 99b357083e fix(subscription): restore midnight daily quota reset
日额度窗口恢复日历日语义(配置时区每天 0 点刷新),修复 v0.1.170 引入的回归:
- 手动重置配额后日窗口锚点漂移到重置时刻,此后 0 点不再刷新
- 新建/续费订阅日窗口按购买或首用时刻滚动,而非 0 点刷新

日窗口三处锚点(激活/手动重置/续期)统一写入当天 0 点,自动重置改为跨日历日
边界触发;存量非 0 点锚点在下一个 0 点自愈,无需数据库迁移。周/月窗口保持
期限对齐滚动语义(含到期约束)不变,不回归 issue #5051 的月额度翻倍问题。
日卡一次性额度豁免逻辑保留。
2026-08-07 17:42:51 +08:00
IanShaw027 245d069602 fix(grok): 二轮评审残留 — Search 叠加计费与 fail-closed 安全
- SearchCost 叠加 token(openai/gateway),未定价 warn
- Token URL 校验失败回落 DefaultTokenURL,禁止 Effective 旁路
- free 判定收窄 paidSignal(仅 plan/月额度),usage% 不否决 free
- web_search: mandatory 计费、uuid request_id、上游 failover 重选账号
- 调度阈值:7d/30d 不跨期 until + 48h stale 可选跳过
- SanitizeStoredCredentials 接入 create/update/bulk/SSO/ApplyOAuth
- ApplyOAuth 成功清除 grok_needs_reauth;SSO 允许 header_override_enabled
- VideoModelPrices 视为媒体定价完整;realtime 正常关闭仍计费
2026-08-07 17:40:21 +08:00
IanShaw027 ccf7ba3ba7 fix(grok): P2 分层清理、主对话 SearchCount 与调度 7d/30d
- DoGrokNativeResponsesJSON 去 gin.Context,UA 固定 CLI 身份
- GrokOAuthService 去掉 redis 直依赖,session store 由 wire 注入
- 主对话/流式响应统计 web_search_call/x_search/tool_search 写入 SearchCount
- Grok 调度阈值候选并入官方 weekly/monthly billing %
2026-08-07 17:21:01 +08:00
IanShaw027 7a81468282 fix(grok): 按 review 优先级修复计费漏扣、auth 投影与 free 门禁
P0: content 与 status 共用 claim 计费;稳定 grok-video request_id;
auth 热路径投影 video_model_prices。
P1: claim 失败释放可重试;视频绑定 TTL≥24h;SSO 凭据白名单与脱敏;
Token URL 校验;free soft-gate 默认 1M 并统一 free 判定;
Voice 预检余额;STT 抗低报;Realtime 失败不计费;search 未定价告警。
2026-08-07 17:15:43 +08:00
IanShaw027 93a04567d4 style(grok): 清理搜索处理器末尾空行 2026-08-07 17:03:48 +08:00
IanShaw027 918b121878 docs(grok): 补充 personal-dev 能力覆盖矩阵 2026-08-07 17:02:33 +08:00
IanShaw027 be5e4226d4 test(grok): 补齐模型映射与调度设置契约 2026-08-07 17:01:20 +08:00
IanShaw027 5bb206ee99 test(grok): 补齐音频与搜索计价接口契约 2026-08-07 16:56:53 +08:00
IanShaw027 856a96a217 test(grok): 对齐配额模型同步与 CLI 身份断言 2026-08-07 16:55:36 +08:00
IanShaw027 7b93f4a689 docs(grok): 更新完整整合审查与排除清单 2026-08-07 16:47:25 +08:00
IanShaw027 61b975b5dd test(grok): 对齐跨客户端映射与路由审计契约 2026-08-07 16:45:25 +08:00
IanShaw027 769f1c2def fix(grok): 防止 OAuth 客户端缺失导致崩溃 2026-08-07 16:38:26 +08:00
IanShaw027 21d0905c78 fix(grok): 按官方 status=done + video.url 计费并对齐字段
官方完成响应为 status=done、video.url、video.duration、顶层 model;
分辨率仅在创建请求中声明,status 无此字段故回退 create 快照/默认 480p。
拒绝 completed 等非官方状态与仅 URL 的宽松判定。
2026-08-07 16:36:15 +08:00
IanShaw027 962da308cc fix(grok): 限制导入探测队列并去重任务 2026-08-07 16:36:12 +08:00
IanShaw027 a3aae134ee fix(grok): 加固 SSO 凭证与 Cookie 隔离 2026-08-07 16:34:47 +08:00
IanShaw027 e549931cf4 fix(grok): 避免刷新时无效清理重认证标记 2026-08-07 16:31:12 +08:00
IanShaw027 5199a610af test(grok): 恢复媒体资格失败关闭契约 2026-08-07 16:30:55 +08:00
IanShaw027 b59a3df5b5 fix(grok): 视频在 status 返回 video.url 后再按上游参数计费
创建/generations 仅绑定账号并缓存 create-time 定价快照;轮询到
video.url / download_url 等成功 URL 时一次性扣费。时长/分辨率/模型
优先取上游 status 字段,缺失则回退创建快照,再套默认 8s/480p。
Redis SetNX + request_id 去重,避免重复轮询双扣。
2026-08-07 16:29:57 +08:00
IanShaw027 25d2b03e90 fix(grok): 加固 OAuth 会话共享与一次性消费 2026-08-07 16:29:48 +08:00
Wesley Liddick 32e4de7942 Merge pull request #5231 from fengshao1227/fix/upstream-dial-timeout
fix(upstream): set explicit TCP dial timeout on upstream transports
2026-08-07 16:24:53 +08:00
wucm667 9b54b46b06 fix(admin): enable image generation permission for Composite groups
- Add 'composite' to imagePricingPlatforms set in groupsImagePricing.ts
- Allow Composite groups to toggle allow_image_generation in admin UI
- Backend already supports allow_image_generation field for all platforms
- Add test case to verify Composite platform is included

Fixes #5369
2026-08-07 16:22:37 +08:00
Wesley Liddick 8f55e0a7cd Merge pull request #5267 from wucm667/fix/issue-5264-model-plaza-composite
fix(model-plaza): show Composite group models
2026-08-07 16:17:52 +08:00
Wesley Liddick 5ec11885d1 Merge pull request #5307 from lenzhang/fix/ws-prewarm-continuation
fix(ws): allow Codex prewarm continuation
2026-08-07 16:15:11 +08:00
Wesley Liddick c8af48d769 Merge pull request #5315 from wucm667/fix/issue-5312-ops-custom-error-range
fix(ops): preserve custom range in error lists
2026-08-07 16:15:00 +08:00
Wesley Liddick dce0f6b4e3 Merge pull request #5337 from wucm667/fix/issue-5331-oauth-count-tokens-fallback
fix: avoid cooling OAuth accounts for HTML count token errors
2026-08-07 16:14:48 +08:00
Wesley Liddick 22ef761c27 Merge pull request #5309 from wucm667/fix/issue-5308-antigravity-gemini36
fix(antigravity): support Gemini 3.6 Flash models
2026-08-07 16:14:37 +08:00
Wesley Liddick 865002fd2c Merge pull request #5317 from wucm667/fix/issue-5314-grok-video-task-id
fix(grok): bind video owners for task_id receipts
2026-08-07 16:14:25 +08:00
IanShaw027 a061a758f4 fix(grok): 修复调度阈值初始化与刷新测试回归 2026-08-07 16:10:31 +08:00
IanShaw027 2a256150f7 docs: 补充 channel-monitor v2 摘要并忽略根目录 .vite 缓存
落地运维向 CHANNEL_MONITOR_V2_DESIGN.md(完整设计稿仍在 gitignore 的 docs/* 中);
将仓库根 .vite/ 加入忽略,避免 vitest 结果误提交。
2026-08-07 15:56:49 +08:00
IanShaw027 79df1647d4 feat(grok): 账单绝对金额、调度阈值 UI、搜索/Voice 计费与 /v1/web_search
- BillingSummary 输出 prepaid/monthly_used/on_demand 绝对金额,并映射官方 7d/30d 进度条
- 账号编辑页支持 credentials.account_scheduling_threshold 覆盖;Settings 文案细化
- groups.search_price_per_1k + 管理端/缓存全链路;SearchCount/AudioUsage 请求级计费
- Voice TTS/STT/Realtime 成功路径 RecordUsage;独立 /v1/web_search 原生 Grok 搜索
2026-08-07 15:52:55 +08:00
IanShaw027 99ad01f6de feat(grok): 网关 Voice TTS/STT/Realtime 与分组音频定价
- 中继 xAI Voice HTTP(/tts /stt /custom-voices)与 Realtime WS(/realtime)
  仅 Grok 分组可用;账号选调度沿用 chat 能力,不依赖创作中心
- Voice URL 固定走 api.x.ai(CLI proxy base 自动回落)
- 分组级 audio_realtime / TTS / STT 单价:schema + migration 218 +
  admin API + GroupsView 表单与 i18n(无创作中心 UI)
2026-08-07 15:08:39 +08:00
IanShaw027 a397724f69 refactor(grok): 移除未接入的免费恢复状态 2026-08-07 15:03:04 +08:00
IanShaw027 2e20ea4c87 fix(grok): 保持模型级免费额度隔离 2026-08-07 14:55:41 +08:00
IanShaw027 7c62382d04 feat(grok): 吸收调度阈值、配额解析、批量用量与 CLI 身份
从 personal-dev 取优并接线到 feat/grok-complete-integration:

- 调度阈值:grok_sched_* 写入、ApplyAccountSchedulingThreshold、
  account_scheduling_thresholds 设置与 Settings UI、网关过滤
- 配额头:x-rate-limit 别名、相对秒 reset、tier/entitlement 扩展
- 批量 usage:GetUsageBatch + POST /usage/batch + AccountsView 合并加载
- CLI 身份:xai.cli_identity 统一 pin/UA;service 层 applyGrokCLIHeaders
  与 transport 最终改写对齐;media eligibility 模块落地
- UsageCell:月度 cents→USD 与 30d 进度条展示

保留既有 failure taxonomy / sticky / free recovery 等 HEAD cools。
2026-08-07 14:53:18 +08:00
IanShaw027 0316994c52 fix(grok): 修正授权与模型映射边界 2026-08-07 14:50:02 +08:00
IanShaw027 2c8836e40b fix(grok): 完善按模型视频价格矩阵 2026-08-07 14:42:00 +08:00
IanShaw027 eb2e73df3d feat(grok): 补齐管理端模型映射设置 2026-08-07 14:22:15 +08:00
IanShaw027 ff4bb3bd2c fix(grok): 修复授权文案的i18n占位符 2026-08-07 14:18:06 +08:00
IanShaw027 d0930c4bdb fix(grok): 完善密码与SSO授权能力控制 2026-08-07 14:13:07 +08:00
IanShaw027 2413441b5f feat(grok): P2 模型额度软封、spending reauth、粘连分轨与观测模型
- free-usage 带模型名时按账号+模型软封,其它模型仍可调度
- spending-limit 使用 24h 冷却并标记 needs_reauth(extra + SetError)
- sticky affinity 与 upstream prompt_cache_key 分轨,按 model 隔离粘连
- free-usage recovery 轻量提示窗口;配额查询后异步同步 /v1/models 快照
2026-08-07 14:06:21 +08:00
IanShaw027 cda6de44da fix(grok): 运行时映射设置即时失效账号缓存 2026-08-07 13:59:32 +08:00
IanShaw027 ec9e733606 feat(grok): stream idle 换号、team+model 冷却与 RT 刷新打散
- Grok 流默认启用上游读空闲超时,首包前返回可 failover 错误并短冷却
- 同 team_id 账号在 429/free-usage 后对同模型共享进程内冷却,粘连与选号均过滤
- TokenRefreshService 已覆盖 Grok;NeedsRefresh 增加按账号稳定 jitter 防 stampede
2026-08-07 13:56:38 +08:00
IanShaw027 dfca6246eb refactor(grok): 将视频模型价纳入 Ent 持久化 2026-08-07 13:55:58 +08:00
IanShaw027 e12e0dc1a6 feat(grok): 对齐 free-usage/empty 失败分类与 sticky/ReAuth 体验
从 grokcli 吸收 body-first 失败分类(free-usage/empty/billing),
接到现有 temp_unsched 与 failover,保留 content-policy 与 pool_mode。
Grok 粘连/缓存在无显式 session 时可用 previous_response_id;
ReAuth 预填 email---- 并默认密码 tab(密码不落库)。
2026-08-07 13:47:09 +08:00
IanShaw027 9bc99e8b6e fix(grok): 防止加权粘连回退绕过免费额度门禁 2026-08-07 13:44:56 +08:00
IanShaw027 e9aaa325fa fix(grok): 让跨客户端消息映射服从配置开关 2026-08-07 13:43:02 +08:00
IanShaw027 43391de925 fix(grok): 打通按模型视频价格计费链路 2026-08-07 13:41:34 +08:00
IanShaw027 8989d25ed5 docs(grok): 记录 ReAuth 密码/SSO 入口 2026-08-07 13:23:03 +08:00
IanShaw027 91f5b00679 feat(grok): ReAuth 弹窗支持 SSO、密码与 RT 重新授权
管理端重新授权对 Grok 展示 SSO Cookie、邮箱密码与 RT 入口;
校验成功后对现有账号 applyOAuthCredentials,不走批量建号。
密码/SSO 仅用于授权 API,经 buildCredentials 写入 OAuth 凭据。
2026-08-07 13:23:03 +08:00
IanShaw027 48fc076ce1 docs(grok): 记录创建账号密码登录 UI 接入 2026-08-07 13:18:59 +08:00
IanShaw027 9b7ea3aabf feat(grok): 创建账号流程接入邮箱密码登录
在 OAuth 授权流中增加 email----password 入口,CreateAccount 批量调用
authorizePassword 后经 buildCredentials 建号;密码与 raw SSO 不落库。
SSO Cookie 批量导入路径保持不变。
2026-08-07 13:18:59 +08:00
IanShaw027 be925d9a42 docs(grok): 更新完整整合进度(阶段 4–6) 2026-08-07 13:08:23 +08:00
IanShaw027 0a28c99aae feat(grok): 管理端补齐 SSO/密码授权前端入口
新增 sso-token 与 password API 封装及 composable 方法;buildCredentials
丢弃 sso/password 字段且不再强行固定 base_url,交由系统 CLI/API 模式选择主机。
2026-08-07 13:08:22 +08:00
IanShaw027 451abc3aa5 feat(grok): 加固媒体计费门控并补齐分组按模型价输入
生成类媒体仅在存在真实 image/video 计费单元时写 usage,避免空结果误扣费。
Create/Update 分组输入支持 video_model_prices,与仓储 JSONB 字段打通。
本阶段不引入假 voice 路由(上游音频路径依赖更广协议层)。
2026-08-07 13:08:22 +08:00
IanShaw027 ba58e74b33 feat(grok): free 档本地用量软门禁与支付失败临时下线
对明确 free 的 OAuth 账号在调度路径应用可配置用量窗口门禁(统计失败 fail-open)。
402 payment required / 消费上限类 403 继续临时移出调度,管理端探测不走门禁。
2026-08-07 13:04:05 +08:00
IanShaw027 eb6c9663e7 feat(grok): 视频按模型族配置每秒单价,并补齐管理端映射设置
分组新增 video_model_prices(JSONB);计费优先模型×分辨率覆盖,
其次旧分辨率三列,最后官方按模型族默认价。同步补齐 admin 设置
中的 grok_default_text_model / 跨客户端映射开关,并保持
grok-imagine-video-1.5 请求模型 identity 不被静默改写。
2026-08-07 12:44:08 +08:00
IanShaw027 370bdcf695 feat(grok): 补齐密码登录与 SSO 校验,统一 OAuth 凭证形态
在 main 既有 SSO→Build 批量导入之上增加 sso-token 校验与账号密码授权。
密码仅用于换取 SSO 再转 Build OAuth,明文与 raw SSO 均不落库。
2026-08-07 12:38:49 +08:00
IanShaw027 74249b8fed feat(grok): 模型目录与可配置映射,默认禁止跨厂商暗默改写
补齐 Grok/Imagine 官方模型与别名,并支持运行时默认文本模型。
默认 model_mapping 仅含 Grok 家族;新增 grok_default_text_model 与
grok_cross_client_model_map_enabled,仅在显式开启时将 gpt/claude/codex
等客户端模型名映射到默认文本模型,避免非 Grok 请求被静默改成 grok-4.5。
2026-08-07 12:31:14 +08:00
IanShaw027 4a9459d607 fix(channel-monitor-v2): 修复 CI gofmt 与 public settings 注入契约
对齐 gofmt,并在 PublicSettingsInjectionPayload 补齐 channel_monitor_mode,
使 SSR 注入字段与 dto.PublicSettings 一致,修复 schema drift 单测失败。
2026-08-07 12:02:27 +08:00
IanShaw027 59b5ac5458 feat(channel-monitor-v2): 渠道监控展示首次聚合进度
在用户端监控页显示 bootstrap 进度横幅与百分比,完成后自动消失;
bootstrap 期间加快轮询,空态文案与进度联动。
2026-08-07 11:53:25 +08:00
IanShaw027 d2d259cb7c feat(channel-monitor-v2): 首次启用静默回填 90m–30d 并修正覆盖判定
从 watermark 恢复 backfill 游标,优先补齐 90m/24h/7d/30d 并在
coverage.bootstrap 暴露进度;coverage_start 跟回填游标而非最早错误时间;
历史起点已覆盖即 coverage_complete,不再因尾部 bucket 对齐滞后误报部分覆盖。
2026-08-07 11:53:25 +08:00
Rain 8ea68bd689 fix grok chat usage guard parity 2026-08-07 11:36:34 +08:00
IanShaw027 4245cb41f4 fix(channel-monitor-v2): 修复 CI golangci-lint 与 admin settings 契约测试
处理 rows.Close errcheck、gofmt、空 recover 分支 staticcheck,并同步
GET /api/v1/admin/settings 契约期望中的 mode 与 hide_throughput 字段。
2026-08-07 11:21:13 +08:00
IanShaw027 242f2b78c2 feat(channel-monitor-v2): 统一展示成功率、首 Token、每秒 Token 与缓存率
矩阵摘要列补齐每秒 Token(TPM÷60)与缓存率;KPI/模型表/用户榜同步为
成功率·首 Token·每秒 Token·缓存率,吞吐相关列仍受 hide_throughput 控制。
2026-08-07 11:10:55 +08:00
IanShaw027 d2d91ff9a8 feat(channel-monitor-v2): 补齐管理端配置、隐藏吞吐开关与测试
管理端 V2 配置面板与系统设置中的模式/隐藏 RPM·TPM 开关,纳入关键
vitest 与 Makefile 前端关键路径,覆盖门控与展示行为。
2026-08-07 11:05:32 +08:00
IanShaw027 d8c5024cee feat(channel-monitor-v2): 实现用户端 Ops 风格被动监控界面
提供筛选/矩阵/趋势/排行等组件与 en/zh 文案,按 mode 切换 V1/V2 壳层,
并统一 useI18n 避免语言包未挂载时键路径裸露。
2026-08-07 11:05:32 +08:00
IanShaw027 a5beecb92a feat(channel-monitor-v2): 接入模式开关、路由门控与依赖注入
注册 admin/user 路由与 feature/mode 守卫,串联 Wire DI 与设置读写,
公开 channel_monitor_mode 与 hide_throughput 等运行时标志。
2026-08-07 11:05:32 +08:00
IanShaw027 ead73264c7 feat(channel-monitor-v2): 实现被动聚合、分层保留与只读 API
基于 usage_logs/ops_error_logs 做分钟聚合与 5m/1h/12h/1d rollup,提供
snapshot/matrix/errors/users 等接口;V1 在 mode=v2 时停用主动探测,
并对用户端按需脱敏绝对量与 RPM/TPM。
2026-08-07 11:05:32 +08:00
IanShaw027 a58048ac32 feat(channel-monitor-v2): 添加被动监控聚合表与模式相关迁移
引入 V2 事实表/固定 rollup、模式设置、忽略错误类、健康阈值、权限与
默认忽略/缓存阈值,以及用户端隐藏吞吐速率的系统设置键。
2026-08-07 11:05:32 +08:00
Wesley Liddick 93367b6db4 Merge pull request #5351 from Wei-Shaw/fix/codex-tui-default-identity
fix(openai): default OAuth identity to codex-tui
2026-08-07 10:17:00 +08:00
shaw b6e53c9320 fix(frontend): align Codex UA setting copy 2026-08-07 10:06:28 +08:00
shaw dbb42881c0 fix(openai): default OAuth identity to codex-tui 2026-08-07 09:56:03 +08:00
Wesley Liddick e4b0e1b66b Merge pull request #5338 from Wei-Shaw/fix/tencent-captcha-region-csp
修复腾讯验证码区域适配、重置与 CSP 加载
2026-08-07 08:37:50 +08:00
Puppywang 02e50cc22d fix(security): block OAuth account takeover via pending exchange 2026-08-07 03:09:57 +08:00
wucm667 ab326c96eb fix(openai-ws): exclude terminal events from TTFT 2026-08-07 00:25:37 +08:00
shaw 287a9f386b fix: 修复腾讯验证码票据过期与区域切换 2026-08-06 21:27:06 +08:00
shaw 8e102b3a0f fix: 完善腾讯验证码区域适配与 CSP 白名单
修复国内站和国际站 SDK 构造、验证容器、票据重置及动态资源加载问题,并补充认证流程回归测试。
2026-08-06 20:34:37 +08:00
wucm667 e93f6b9953 fix: avoid cooling OAuth accounts for HTML count token errors 2026-08-06 20:31:56 +08:00
shaw a19c9f8d8a chore: update sponsors 2026-08-06 19:31:17 +08:00
shaw a1936d42db chore: update sponsors 2026-08-06 19:30:44 +08:00
li fe2c265c91 fix(identity): validate user-agent before persisting account fingerprint
IdentityService 把客户端 User-Agent 作为账号级持久身份缓存(Redis
fingerprint:<accountID>),覆盖规则是"只升不降"的纯数值版本比较,且对 UA 内容
不做任何合法性校验:

- userAgentVersionRegex 不锚定,能从 claude-cli/999.0.0-local 解析出 999.0.0
- isNewerVersion 只比数值 + 产品名,999.0.0 恒大于任何真实版本
- fingerprintTTL 7 天配合每 24 小时懒续期,活跃账号上的指纹近乎永不过期
- IdentityCache 只有 Get/Set,管理端也没有重置入口

一个本地构建客户端发出 claude-cli/999.0.0-local 即可把账号身份永久钉死。此后
该账号所有上游请求在 HTTP 头与请求体 cc_version 两处都声称这个不存在的版本,
被上游判为非正版客户端并返回不带限流重置头的 429;无重置头又落到 5 秒兜底冷却,
账号池收缩后对外表现为 503 风暴。一条 failover 请求即可连带毒化多个账号——
指纹写入发生在上游调用之前,三次上游尝试全失败也照样毒化。

改动:

- 新增 isAcceptableFingerprintUserAgent,校验 <product>/<major>.<minor>.<patch>
  后必须紧跟空白或结束(拒绝 -local/-dev/+build 等本地构建后缀),并对 claude-cli
  的主版本号相对 claude.CLICurrentVersion 设上限(挡掉 999 这类哨兵版本)。
  非 claude-cli 产品只校验形态,避免误伤其他合法客户端。
- 创建与升级两条路径共用该校验。只在 isNewerVersion 处加校验不够:
  createFingerprintFromHeaders 首次创建时同样会原样保存畸形 UA,删键恢复后
  账号可被同一客户端立即再次毒化。
- 读取时自愈:缓存中已是畸形 UA 时(本次加固之前写入的),用本次请求的合法 UA
  或默认指纹替换并回写。否则存量已中招的账号仍然只能靠手工删 Redis 键恢复。
- 仅在真正阻止了一次写入时记录被拒 UA,便于定位污染源,同时避免被毒化客户端的
  高频重试刷屏。

Fixes #5254
2026-08-06 15:01:28 +08:00
shaw c123caddd4 chore: update sponsors 2026-08-06 14:22:46 +08:00
Wesley Liddick e08aee49ed Merge pull request #5266 from shentry/fix/transient-streak-rate-dependence
fix(openai): keep transient failure streak from resetting on sparse traffic
2026-08-06 14:11:49 +08:00
Wesley Liddick c9e60d1f26 Merge pull request #5031 from keaipiao/fix/easypay-error-utf8
fix(payment): preserve UTF-8 in EasyPay errors
2026-08-06 14:10:41 +08:00
Wesley Liddick 47c03c75d8 Merge pull request #5232 from fengshao1227/fix/billing-quantize-monetary-scale
fix(billing): quantize usage billing amounts to the NUMERIC(20,8) scale
2026-08-06 14:00:04 +08:00
wucm667 5b7a64334f fix(grok): support task IDs for video owner binding 2026-08-06 04:30:10 +08:00
wucm667 df249ee5d5 fix(scheduler): diagnose legacy OpenAI exclusions 2026-08-06 02:54:16 +08:00
wucm667 82ccc187cd fix(ops): preserve custom range in error lists 2026-08-06 02:26:42 +08:00
wucm667 ce14983133 fix(antigravity): support Gemini 3.6 Flash models 2026-08-05 22:35:01 +08:00
必通 fc5a1b78d2 fix(ws): allow Codex prewarm continuation 2026-08-05 21:26:37 +08:00
wucm667 8aa425d22f fix(apicompat): accept chat reasoning alias 2026-08-05 18:25:58 +08:00
Rain ba92d70422 fix grok usage guard for compatible accounts 2026-08-05 17:59:20 +08:00
wucm667 2f109e74ca fix: emit response.failed when compact keepalive commits headers but no SSE payload
When OpenAI Responses streaming has compact keepalive commit 200 headers
but no semantic SSE event has been written, upstream 4xx errors were
silently swallowed, causing clients to hang until timeout.

The fix distinguishes between 'headers committed' and 'meaningful SSE
output written'. Compact keepalive may commit headers for heartbeat
purposes without writing actual SSE events. In that case, the Responses
stream still needs its protocol-correct terminal response.failed event.

Preserves existing behavior when a complete upstream response or
meaningful SSE payload was already communicated, and preserves image
keepalive semantics.

Fixes #5289
2026-08-05 16:28:16 +08:00
Rain 5c52fa93d5 fix grok missing usage billing bypass 2026-08-05 09:49:25 +08:00
shaw 00b8596176 chore: update sponsors 2026-08-04 21:55:34 +08:00
shaw c5e046b7d7 chore: update sponsors 2026-08-04 21:54:50 +08:00
github-actions[bot] aac53afe0e chore: sync VERSION to 0.1.171 [skip ci] 2026-08-04 13:41:47 +00:00
Wesley Liddick f0e7a9c7a2 Merge pull request #5223 from feeeei/main
feat(aliyun-captcha): 人机验证增加阿里云验证码 2.0
2026-08-04 21:16:43 +08:00
feeeei 26e0a89323 人机验证增加阿里云验证码 2.0
沿用腾讯天御验证码引入的多服务商模型:aliyun_captcha_enabled 作为独立
开关,与 Cloudflare Turnstile、腾讯天御三方互斥(保存校验 + 运行时
CAPTCHA_PROVIDER_CONFLICT)。后台「安全与认证」合并为单张人机验证卡片:
总开关 + 服务商单选(Turnstile / 腾讯天御 / 阿里云),选中即启用该家并
关闭其它,落库仍是三个独立开关键,由前端映射保证互斥。

阿里云侧同时支持 aliyun 中国站与国际站(alibabacloud.com):两站前端脚本、
region 取值与服务端 API 完全一致,仅账号与 AccessKey 相互独立,因此由
「服务地域」决定线路即可——中国内地走 captcha.cn-shanghai.aliyuncs.com,
非中国内地(新加坡)走 captcha.ap-southeast-1.aliyuncs.com,AccessKey
取自持有该实例的账号,无需在配置中区分站点。

- AliyunCaptchaService 对称 TencentCaptchaService:服务端校验走官方 SDK
  VerifyIntelligentCaptcha,调用异常按 fail-closed 拦截,与 Turnstile
  网络错误行为对称;保存设置时真实探测 AK/SK 有效性
- 保护面对齐腾讯扩展入口:VerifyTencentCaptchaIfEnabled 通用化为
  VerifyActionCaptchaIfEnabled,OAuth 登录启动、passkey 登录在阿里云
  启用时同样拦截;Turnstile 维持既有覆盖不扩大
- 前端 AliyunCaptchaWidget 为表单内预验证按钮(popup 模式),同时暴露
  verify() 供 OAuth 启动、passkey 等动作入口程序化弹窗;未预验证直接
  提交时弹窗兜底。SDK 按钮绑定异步完成,弹窗未出现前按 tick 重试触发,
  并轮询弹窗可见性识别用户关闭
- captchaVerifyParam 复用 turnstile_token 请求字段提交;公开设置下发
  aliyun_captcha_enabled / scene_id / prefix / region
- CSP 放行验证码 CDN:script-src/style-src 加 *.alicdn.com
2026-08-04 20:57:15 +08:00
Wesley Liddick d431c57f2e Merge pull request #5268 from Wei-Shaw/fix/pending-oauth-captcha-submit-gate
fix(auth-ui): 第三方 OAuth 建号提交前必须持有验证码票据
2026-08-04 20:43:01 +08:00
shaw 635a27189f fix(auth-ui): gate pending OAuth account creation on a captcha proof
#5261 added a captcha check to POST /auth/oauth/pending/create-account, but
the shared create-account form only gates its send-code button on the
Turnstile token — the submit button's disabled condition never included it.

Turnstile tokens are single-use, so handleSendCode resets the widget in its
finally block and clears the token. Submitting inside the window before the
widget re-solves omits turnstile_token from the payload, and the backend
answers ErrTurnstileVerificationFailed (turnstile_service.go:65). With an
interaction-required challenge the widget does not re-solve on its own, so
the failure persists until the user notices and verifies again — while the
button stays enabled and says nothing.

Gate the submit button on the token, mirroring the send-code button and
EmailVerifyView, which already gates its pending-OAuth submit the same way.
handleSubmit repeats the check because implicit form submission (Enter in a
text input) bypasses the button's disabled state.

The Tencent path is unaffected: handleSubmit already acquires a fresh proof
per submit, and turnstile_enabled is false in that configuration.

Verified with the component spec (11 passed) and vue-tsc --noEmit (clean).
2026-08-04 20:29:53 +08:00
wucm667 d3b5703b55 fix(model-plaza): show composite group models 2026-08-04 19:14:28 +08:00
shentryandClaude Opus 5 7d38e67120 fix(openai): keep transient failure streak from resetting on sparse traffic
The account+model transient breaker reset its failure streak whenever the
gap since the previous failure exceeded a one-minute window. That made the
breaker's sensitivity a function of request rate rather than upstream
health: a gateway called less often than once a minute never advanced past
streak 1, where the cooldown is zero, so a consistently broken account was
never blocked. Every request re-selected it, paid a full upstream attempt,
and only then failed over to a healthy account.

Observed on a low-traffic deployment: two accounts returning 500 and 503
stayed in rotation indefinitely, logging `failure_streak: 1, cooldown_ms: 0`
on every request and adding ~750ms to each one before a working account was
reached.

The streak is already cleared on success — recordSuccess deletes the entry,
and every OpenAI handler reports the schedule result — so the time-based
reset is not needed to recover a healthy account. Keep a TTL purely to bound
the map for account+model pairs that stopped being used, and raise it well
above the cooldowns so it no longer doubles as a streak reset.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-04 17:59:16 +08:00
Wesley Liddick 8b3fe664dc Merge pull request #5261 from lyen1688/feat/tencent-captcha-gate
新增腾讯天御验证码认证门禁
2026-08-04 16:39:55 +08:00
Wesley Liddick a4d263f62f Merge pull request #5224 from wucm667/fix/issue-5190-lock-subscription-renewal
fix(subscription): serialize concurrent renewals
2026-08-04 16:28:52 +08:00
Wesley Liddick ae81dfd933 Merge pull request #5177 from r266-tech/fix-nonpassthrough-write-context
fix(openai-ws): preserve terminal event on lease loss
2026-08-04 16:28:22 +08:00
Wesley Liddick 35cab3c814 Merge pull request #5258 from feeeei/fix/model_plaza
fix(model-plaza): Model Plaza image model price display is inconsistent with the actual price
2026-08-04 16:27:53 +08:00
Wesley Liddick 770e35b474 Merge pull request #5040 from coo1white/upstream-pr/grok-cli-0.2.114
fix(grok): bump pinned Grok CLI version to 0.2.114
2026-08-04 16:16:20 +08:00
Wesley Liddick 846dd310a3 Merge pull request #5158 from neboyang/feat/claude-oauth-authorize-url
fix(oauth): use claude.com/cai authorize endpoint
2026-08-04 16:15:50 +08:00
Wesley Liddick 9b4575e434 Merge pull request #5243 from wucm667/feat/issue-5240-dashboard-username
fix(dashboard): show usernames in spending ranking
2026-08-04 16:15:21 +08:00
Wesley Liddick 1f4cfc44c1 Merge pull request #5226 from spongehah/codex/fix-prompt-audit-output-text
fix(prompt-audit): parse responses output text
2026-08-04 16:15:04 +08:00
lyen1688 e592c5f9e0 新增腾讯天御验证码认证门禁 2026-08-04 15:09:29 +08:00
Wesley Liddick 9fd7e76231 Merge pull request #5233 from Wei-Shaw/fix/codex-identity-enforcement
fix(codex): 强制统一出站身份并让客户端版本号跟随官方发布
2026-08-04 14:50:44 +08:00
feeeei 785b61d424 修复模型广场图片模型价格展示与实收口径不一致
图片计费模型的广场展示价按实收口径计算:档位单价取
分组图片价 > 渠道档位价 > 渠道默认按次价;分组开启生图
独立倍率时实付倍率取独立倍率,不取分组/专属倍率。
2026-08-04 13:48:19 +08:00
shaw 2d3e845205 perf(codex): 版本同步主路径改用 /releases/latest 并保留列表回退
自动同步原本每次拉 `/releases?per_page=30`,实测响应 10,017,686 字节——该仓库
预发布极密集,30 条里只有 2 条稳定版(0.145.0 已排到第 26 位),页大小是为了
「窗口里至少有一条稳定版」而定的,不能靠调小来省流量。

改为主路径走 `/releases/latest`(实测约 0.3MB):该端点本身排除 draft 与
prerelease,直接给出最新正式发布,不受预发布密度影响。复用端口上已有的
FetchLatestRelease,不新增任何 HTTP 代码,代理配置、User-Agent、可选 token
与跨主机重定向剥离 Authorization 的行为全部沿用。

保留列表扫描作为回退:latest 是跨 tag 家族按 published_at 取的,若同仓库其他
组件(如 rusty-v8-*)某天发布正式 release 而成为 latest,主路径会被 rust-v
前缀过滤挡掉,此时必须扫一页才能继续跟随,否则版本号会静默停更。

两条路径共用 latestCodexStableReleaseVersion 的同一套过滤(前缀 / draft /
prerelease / 版本号形态),语义不会分叉;单条 latest 也要过版本号校验——仓库里
确实存在 rust-vv0.99.0-alpha.8 这类畸形 tag。只向前推进、抓取失败保持既有值
两条不变式未改动。

测试覆盖:主路径命中时不再拉列表页、四种拿不到(异家族 / 预发布 / 抓取失败 /
空对象)都回退、畸形 tag 被主路径拒绝后由回退兜底、两路径皆失败时保值。
2026-08-04 10:51:04 +08:00
wucm667 80af44cad7 [verified] fix(dashboard): show usernames in spending ranking 2026-08-04 00:43:05 +08:00
shaw c899c8cf37 fix(codex): 管理员配置的 UA 只贡献指纹,版本段一律用生效版本重建
面板「OpenAI Codex UA」此前只在客户端 UA 是浏览器型时才生效,本分支把它提升为
所有 OAuth 出站的规范身份来源,作用域扩大了一个数量级。而它的旧 placeholder 原文
就是 codex_cli_rs/0.144.1 (...):照抄填写过的存量部署会被永久钉在 0.144.1 上——
该值不低于上游门槛 0.144.0,version 头也随之变成 0.144.1,绕过版本自动同步,
稳定落在上游优先降载的那一侧,且面板、日志、审计都不提示。账号级自定义 UA
(credentials.user_agent)有完全相同的陷阱。

改为:管理员配置的 UA 只贡献客户端名与 OS / 架构 / 终端指纹(这是该输入框唯一
不可替代的价值),版本段一律用当前生效版本重建。存量的陈旧配置无需迁移即自愈,
UA 与 version 头从此由构造保证同源,原先「覆写 UA 陈旧时二者不一致」的次优解消失。

- 新增 openai.SetCodexUserAgentVersion:重建首段版本声明,OS / 架构 / 终端指纹
  原样保留;尾部官方客户端标识组 (name; version) 与首段同源,一并更新,避免拼出
  首段声明新版本、尾部仍是旧版本的自相矛盾身份;非官方括号组(如 OS 组)不动。
- resolveCodexOutboundIdentity 收敛为单一funnel:生效版本只从规范身份取,
  候选 UA 的版本段不再参与判定。
- GetOpenAICodexCanonicalUserAgent 重建面板 UA 的版本段;原「面板值等于兜底常量
  视同未填」的特例随之成为恒等变换,删除。
- 补齐 4 处静默丢弃账号级自定义 UA 的出站路径(WS 握手、账号测试 x2、用量探针):
  它们先写 customUA 再调不带 override 的收口,赋值随即被覆盖,既是行为不一致
  也是死代码;账号测试尤其要紧——注释写着「与真实转发一致」而实际不一致。
- 补测试:SetCodexUserAgentVersion / CodexUserAgentVersion 包内用例、陈旧面板 UA
  与账号 UA 的版本重建回归、WS 握手尊重账号级 UA。
2026-08-03 21:50:49 +08:00
zhiyu 4c4ff36380 perf(codex): 版本同步间隔改为 6 小时并加启动防抖
- 同步间隔 3h → 6h:客户端版本是天级变化,6 小时足够跟上,
  对 GitHub 的调用降到每天 4 次。
- 新增启动防抖:借同步设置行自身的 UpdatedAt 判断,若同步值仍在一个周期内
  则跳过启动同步。频繁重启、滚动发布或崩溃重启原本会把「启动即同步」
  放大成对 GitHub 的连续请求;首次部署尚无同步值时不受影响。
- 补测试:启动防抖两个方向,以及版本比较按段取数字的回归
  (字典序会把 0.99.0 判为大于 0.146.0,同时让「取最大值」与
  「只向前推进」两处判错)。
2026-08-03 20:55:37 +08:00
wucm667 c418fd522f [verified] fix(security-audit): deduplicate websocket turn audits 2026-08-03 20:35:56 +08:00
zhiyu 1e08c4c56f test(server): 补齐 settings 契约用例的 Codex 版本号字段
新增的三个设置键会出现在 GET /api/v1/admin/settings 响应里,
契约用例的期望 JSON 需同步,否则 -tags=unit 下断言失败。
2026-08-03 20:26:50 +08:00
zhiyu 2eb24814fe fix(codex): 强制统一出站身份并让客户端版本号跟随官方发布
上游 /backend-api/codex 在容量紧张时按客户端身份分优先级降载,被降载的请求
HTTP 200 后立刻推流内 server_is_overloaded。此前网关对配不出官方身份的客户端
整体回退到硬编码的 codex_cli_rs/0.144.1(落后官方 4 个发布),这些请求稳定
落在被优先丢弃的一侧。

- 强制统一出口:所有 OAuth 出站的 User-Agent / originator / version 一律改写
  为网关规范身份,客户端自报身份不参与构造;HTTP / 透传 / WS / alpha-search /
  探针全覆盖。compat 桥接故意删除 originator 的路径保持 no-op。
- 版本号收敛为单一来源,运行时优先级为面板覆写 → 自动同步值 → 内置常量;
  UA 与 version 头同源派生,不再各自硬编码。
- 新增 3 小时自动同步官方客户端最新稳定版,面板可关闭,无需为跟版本而发版。
- 流内 server_is_overloaded / slow_down 改为先在同账号有界重试再切号,并标记为
  请求级瞬时故障,不再据此临时封禁账号。
- 移除被取代的降载身份黑名单、浏览器 UA 兜底及其辅助函数。
2026-08-03 20:14:58 +08:00
li e2652eb853 fix(billing): quantize usage billing amounts to the NUMERIC(20,8) scale
同一笔 ActualCost 会被分别写入两条方向相反的 SQL:

    balance    = balance - $1      -- 存剩余额度,舍入的是"减法结果"
    quota_used = quota_used + $1   -- 存累计用量,舍入的是"加法结果"

两列都是 NUMERIC(20,8),PostgreSQL 按 half-away-from-zero 舍入运算结果。
金额在第 9 位落到 half 边界时,两侧朝相反方向舍入:

    10 输入 token × 0.00000125 + 5 输出 token × 0.00001000 = 0.0000625
    × 1.25(分组倍率) = 0.000078125

    balance:    10000 - 0.000078125 = 9999.999921875 → 9999.99992188  delta 0.00007812
    quota_used:     0 + 0.000078125 =     0.000078125 →     0.00007813  delta 0.00007813

余额少扣、API Key 配额多记,单次相差 1e-8 且随请求量线性累积(1000 次 ≈ 1e-5 USD),
余额、Key 配额与用量记录无法精确对账,只能靠 epsilon 比较勉强吻合。

修复:在参数进入 SQL 之前,把命令中的全部金额统一量化到 8 位小数
(half-away-from-zero,与 PostgreSQL NUMERIC 一致)。两条语句拿到的是
同一个已落在 8 位刻度上的金额,存储阶段不再发生舍入,delta 精确相等。

- 走 shopspring/decimal 而非 math.Round(v*1e8)/1e8:后者在乘除中引入额外
  二进制误差,边界值可能被推到错误的一侧
- 量化排在指纹计算之后:指纹是请求幂等键,保持由原始金额派生,
  避免升级前后同一 request_id 的重试算出不同指纹而被判为 fingerprint conflict

Fixes #5229
2026-08-03 20:00:32 +08:00
li 66ad405dd6 fix(upstream): set explicit TCP dial timeout on upstream transports
http.Transport 未设置 DialContext 时使用零值 net.Dialer(Timeout=0),
DNS 解析与 TCP 握手没有任何上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。
ResponseHeaderTimeout 只约束连接建立之后等待响应头的阶段,覆盖不到建连。

上游域名被解析到 443 不可达的 IP 时(DNS 污染 / 路由异常),单个账号就要卡满
内核超时;而多账号故障转移是串行的,一次请求会阻塞数分钟且不写中间错误,
客户端表现为"长时间无首字后直接结束"。

- buildUpstreamTransport 显式设置 DialContext(10s)与 TLSHandshakeTimeout(10s)
- proxyutil SOCKS5 分支的 forward dialer 由 proxy.Direct(零值 dialer,同样无超时)
  换成带 10s 超时的 net.Dialer;该分支会覆盖 Transport.DialContext,
  调用方设置的建连超时对它无效,必须在此补上

Fixes #5151
2026-08-03 19:59:58 +08:00
spongehah 1b04e03cc4 fix(prompt-audit): parse responses output text 2026-08-03 17:38:04 +08:00
wucm667 2be047d1cd [verified] test(subscription): update unit repository stubs 2026-08-03 17:22:18 +08:00
wucm667 db725a775a [verified] fix(subscription): serialize concurrent renewals 2026-08-03 16:59:05 +08:00
Wesley Liddick 825ca7b1fc Merge pull request #5183 from rick147/codex/feat-openai-reset-credit-cache
feat(openai): refresh reset credit state after quota reset
2026-08-03 16:01:10 +08:00
shaw 54a2bcfd15 fix(openai): harden reset-credit refresh and account recovery
Review follow-ups on the reset-credit caching flow:

- Recover account state BEFORE (and independently of) the reset-credit
  display cache. A failed cache refresh could previously abort the run and
  leave the account rate-limited — the very reason the credit was spent
  (#3672 / #3740). The recovered account row is now returned even when the
  cache refresh fails.
- Run the post-reset bookkeeping on a detached, time-boxed context and give
  the panel reset call a larger timeout. A client abort no longer strands a
  consumed (non-refundable) credit with an unrecovered account, and the
  chained upstream calls can no longer exceed the client timeout and invite a
  retry that spends a second credit.
- Persist the reset-credit snapshot through POST /accounts/:id/quota/refresh
  instead of a side-effecting GET flag, so the write is covered by the audit
  middleware. A rejected snapshot write now degrades to cache_persisted=false
  instead of turning a successful upstream read into a 502 that left the card
  without a credit count and the reset button permanently disabled.
- Reject snapshots whose positive count carries no expiration entries, and
  drop expired credits (clamping the count) when rehydrating, so a stale
  cache can no longer light up the reset button.
- Keep nil quota / rate-limit services nil in the handler's interface fields;
  storing a nil *Service made the "not enabled" guards non-nil.
- Time-box the usage-refresh suppression and reuse handleAccountUpdated so the
  patched row also enters the auto-refresh silent window.
2026-08-03 14:40:55 +08:00
Wesley Liddick 27e8f69a9e Merge pull request #5171 from heathermhuang/codex/composite-reasoning-policy
feat(composite): enforce reasoning effort policy
2026-08-03 11:28:38 +08:00
Wesley Liddick 684ab20a0b Merge pull request #5164 from wucm667/fix/issue-5099-messages-temp-failover
fix(openai): fail over Messages temporary account errors
2026-08-03 11:28:16 +08:00
Wesley Liddick 0173830df6 Merge pull request #5193 from wucm667/fix/issue-5187-stripe-refund-idempotency
fix(payment): make Stripe refunds idempotent
2026-08-03 11:28:05 +08:00
Wesley Liddick 724565e4aa Merge pull request #5199 from wucm667/fix/issue-5191-refund-balance-force
fix(payment): require force for insufficient refund balance
2026-08-03 11:27:52 +08:00
Wesley Liddick a03de418c8 Merge pull request #5192 from luckydududu/up/admin-refund-require-force
fix(admin-ui): 退款 require_force 前端接线,补上无法完成的退款场景
2026-08-03 11:27:37 +08:00
Wesley Liddick 61ebdbdd43 Merge pull request #5200 from mrlitong/fix/auth-refresh-race
fix(auth): prevent refresh token races across tabs
2026-08-03 10:46:36 +08:00
Wesley Liddick a1f1a0cc6b Merge pull request #5194 from wucm667/fix/issue-5189-persist-unsettled-usage
fix(billing): retain usage logs on billing failure
2026-08-03 10:27:30 +08:00
Wesley Liddick 954d44c19c Merge pull request #5198 from Wei-Shaw/fix/codex-originator-load-shed
fix(codex): 归一化降载 originator,缓解 Codex 账号频繁过载不可用
2026-08-03 09:59:34 +08:00
litongtongxue@gmail.com 38081ef72e fix(auth): prevent refresh token rotation races 2026-08-02 08:13:15 -07:00
wucm667 3c20f9a666 [verified] fix(payment): require force for insufficient refund balance 2026-08-02 23:11:17 +08:00
shaw e1b76e2245 fix(codex): normalize load-shed originators to avoid upstream capacity shedding
上游 /backend-api/codex 按 Originator 头分桶调度容量:落在降载桶的请求即使返回
HTTP 200,也会立刻推 SSE `event: error`(code=server_is_overloaded)并以
response.failed 收尾。2026-07-29 起 codex-tui 落入降载桶,codex_cli_rs 正常——
判定因子是 originator 而非 User-Agent(codex_cli_rs 配 curl UA 亦可正常返回)。

网关会把该错误判定为瞬时上游故障并冷却账号,对外表现为 Codex 账号频繁过载不可用:
server_is_overloaded → isOpenAITransientProcessingError →
shouldCooldownOpenAITransientUpstreamError → 账号冷却 → 客户端 503。

本项目有三处降载身份来源:浏览器 UA 兜底的默认 UA、客户端透传的真实 TUI 身份、
以及指纹缓存注入探针的 UA。

修复收口在 enforceCodexIdentityHeaders——HTTP / 透传 / WS 握手 / compat 桥接 /
探针 / PAT / 模型列表 / alpha-search 八条出站路径共用的唯一纯函数收口点:

- 新增 NormalizeCodexClientIdentityToCLI,把降载桶身份改写为 codex_cli_rs,
  只替换身份段并裁掉尾部 (name; version) 客户端标识组,保留版本 / OS / 架构 /
  终端指纹;改写后 originator 与 UA 首段仍然配套,不破坏 #3901 的配对不变式,
  且改写幂等。
- DefaultOpenAICodexUserAgent 从 TUI 身份改为 CLI 身份(浏览器兜底路径上最大的
  降载身份来源)。
- 管理端 Codex UA 的 placeholder / hint 原本在把管理员往降载桶引导,一并修正。

新增 gateway.disable_codex_originator_normalization(默认 false,即归一化开启),
供上游调整分桶后回滚。该开关经 NewOpenAIGatewayService 发布为进程级快照,故必须
保持反义命名:正向命名的 Go 零值 false 会让未经 viper 加载而手工构造的 Config
静默关掉全局保护,viper.SetDefault 救不了这条路径。已加用例钉住该属性。

降载桶集合是上游容量策略快照而非协议常量,上游调整分桶后需同步修订。
2026-08-02 23:00:12 +08:00
rick147 f970bd48c9 fix: stop scheduler work after request cancellation 2026-08-02 21:32:31 +08:00
rick147 a0802f00b6 feat: cache OpenAI reset credit details 2026-08-02 21:32:31 +08:00
wucm667 0b9f40e230 fix(billing): retain usage logs on billing failure 2026-08-02 20:50:13 +08:00
wucm667 0b26acac07 fix(payment): make Stripe refunds idempotent 2026-08-02 20:27:44 +08:00
github-actions[bot] 7e2e9ba050 chore: sync VERSION to 0.1.170 [skip ci] 2026-08-02 10:46:21 +00:00
Lucky 2a42833c4c fix(admin-ui): wire require_force into the refund dialog
AdminRefundDialog already implements a `requireForce` prop that renders the
force checkbox and blocks submit until it is checked, but AdminOrdersView
never consumed `require_force` from the refund response nor bound the prop,
so the checkbox could never render. Admins hitting a require-force case (for
example a user who spent their balance after requesting the refund) only saw
an error toast and had no way to complete the refund from the UI.

Keep the dialog open when the backend answers `require_force`, surface the
checkbox and the backend warning, and reset the flag whenever the dialog is
opened or closed.

Verified with `npm run typecheck` (vue-tsc --noEmit, clean).
2026-08-02 10:31:15 +00:00
r266-tech 30d2589ef0 fix(openai-ws): preserve terminal event on lease loss 2026-08-02 11:47:59 +08:00
Heatherm Huang d60f4e442b feat(composite): enforce reasoning effort policy 2026-08-01 21:15:44 +08:00
wucm667 ddf4c6fd81 fix(openai): fail over Messages temporary account errors 2026-08-01 18:45:33 +08:00
neboyang ff7ab2308d test(oauth): lock AuthorizeURL to claude.com/cai endpoint 2026-08-01 15:03:59 +08:00
neboyang 0204ce285e fix(oauth): use claude.com/cai authorize endpoint
Align Claude OAuth authorize URL with Claude Code CLI
(https://claude.com/cai/oauth/authorize).
2026-08-01 14:55:09 +08:00
wucm667 7045f89de4 fix(openai): retry pool auth failures before failover 2026-07-29 18:47:07 +08:00
NickandClaude Opus 5 b74cb7891c fix(grok): bump pinned Grok CLI version to 0.2.114
`cli-chat-proxy.grok.com` turns away requests that do not name a
supported client version — the failure mode #3952 was opened for. The
pin has sat at 0.2.93 since 2026-07-17, while https://x.ai/cli/stable
and `npm view @xai-official/grok version` both report 0.2.114.

The version was written out in three places, which is how they came to
drift:

- `repository/http_upstream.go` (OAuth traffic through the CLI proxy)
- `pkg/xai/billing.go` (billing and quota probes)
- `service/openai_gateway_grok.go` (gateway request headers)

`xai.CLIClientVersion` is now the single source, and the other two build
their own client identity from it, so the next bump is one line.
`internal/pkg/xai` is a leaf package both layers already import, so this
adds no cycle. `CLIUserAgent` derives from the same constant.

Checked against the real 0.2.114 binary rather than the version feed
alone: the `x-grok-client-version` header name, the `xai-grok-cli`
token-auth value, and the version string all match what the CLI sends.

The pinned value is also the floor an operator override must clear, so
some fixtures carried a meaning relative to it rather than a fixed
number. Those were moved, not search-and-replaced:

- the "valid override" case now uses 0.2.115-alpha.1; at 0.2.95-alpha.1
  it would fall below the new floor, be dropped, and stop testing
  acceptance at all
- the prerelease-at-the-minimum case tracks the pin to 0.2.114-beta.1,
  or it becomes a copy of the below-minimum case
- three of the five malformed-semver entries sat below the new floor and
  would have been turned away for being old, not for being malformed

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-29 13:31:18 +07:00
keaipiao e3e033bb35 fix(payment): preserve UTF-8 in EasyPay errors 2026-07-28 11:46:12 -07:00
682 changed files with 61646 additions and 4283 deletions
+3
View File
@@ -143,3 +143,6 @@ docs/*
frontend/coverage/
aicodex
output/
# Vitest / Vite cache at repo root
.vite/
+4 -4
View File
@@ -34,8 +34,8 @@
### 开发工具
```bash
# golangci-lint v2.7
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.7
# golangci-lint(CI 用 v2.9,本地建议装同一版以免版本差异带来的噪音)
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.9
# pnpm (前端包管理)
npm install -g pnpm
@@ -47,13 +47,13 @@ npm install -g pnpm
| Workflow | 触发条件 | 检查内容 |
|----------|----------|----------|
| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.7 |
| **backend-ci.yml** | push, pull_request | 单元测试 + 集成测试 + golangci-lint v2.9 |
| **security-scan.yml** | push, pull_request, 每周一 | govulncheck + gosec + pnpm audit |
| **release.yml** | tag `v*` | 构建发布(PR 不触发) |
### CI 要求
- Go 版本必须是 **1.25.7**
- Go 版本必须是 **1.26.5**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.26.5'`。升级 Go 时要同时改 `backend/go.mod` 和 `backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,否则 CI 会在版本校验步骤直接失败。
- 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml`
### 本地测试命令
+8 -1
View File
@@ -1,12 +1,19 @@
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
FRONTEND_CRITICAL_VITEST := \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
src/views/auth/__tests__/WechatCallbackView.spec.ts \
src/views/user/__tests__/PaymentView.spec.ts \
src/views/user/__tests__/PaymentResultView.spec.ts \
src/views/user/__tests__/ChannelStatusView.mode.spec.ts \
src/components/user/profile/__tests__/ProfileInfoCard.spec.ts \
src/views/admin/__tests__/SettingsView.spec.ts
src/views/admin/__tests__/SettingsView.spec.ts \
src/features/channel-monitor-v2/__tests__/designSystem.structure.spec.ts \
src/features/channel-monitor-v2/__tests__/monitorFormat.spec.ts \
src/features/channel-monitor-v2/__tests__/monitorZoom.spec.ts
# 一键编译前后端
build: build-backend build-frontend
+24 -19
View File
@@ -4,7 +4,7 @@
# Sub2API
[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/)
[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
@@ -50,7 +50,7 @@ Please read the following carefully before using this project:
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> to enjoy a permanent 5% discount on all recharges.</td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
</tr>
<tr>
@@ -58,11 +58,6 @@ Please read the following carefully before using this project:
<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>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
<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>
@@ -102,16 +97,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td>Thanks to <a href="https://anpin.ai">anpin.ai</a> for sponsoring this project! anpin.ai is a premium AI relay service platform dedicated to advancing AI accessibility. With an advanced technical architecture and globally distributed deployment, it provides users with a direct high-speed channel to the world's top-tier large language models.<br>
Self-built primary account pool: 1-3s ultra-fast response, supports channel-partner distribution<br>
Extreme stability: multi-line intelligent routing + redundant backup system, ensuring year-round high-availability operation;<br>
Model authenticity: no content intervention or secondary filtering — experience the purest, most powerful native model capabilities.<br>
1:1 top-up, enterprise-grade service with invoicing available. Anpin AI is not just a relay — it's your secure, reliable, and efficient bridge to the frontier world of intelligence.
</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.
@@ -169,6 +154,26 @@ Model authenticity: no content intervention or secondary filtering — experienc
<td>Thank you to LanoX AI for sponsoring this project! <a href="https://lanox.ai/?c=6">LanoX AI</a> provides stable, cost-effective global model access services for developers, teams, and enterprises. 🎁 New User Benefits — Claim millions of free tokens, plus 500+ free models for easier low-cost testing, validation, and deployment 🧠 Global Leading Models — GPT · Claude · Gemini · Qwen · Grok... 🎬 Multimodal Creation — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ Enterprise-Grade Reliability — High availability 💎 native capability output 💎 no intelligence degradation 💎 no model mixing 💎 transparent usage and billing 💎 💰 Lower API Costs — Top-tier models from as low as 10% of official pricing, with clear documentation, simple integration, invoicing support, and enterprise-scale batch usage 🏢 Enterprise Choice — Ideal for AI products, Agents, content platforms, and R&D teams with high-volume model usage</td>
</tr>
<tr>
<td width="180"><a href="https://www.rapidproxy.io/?ref=sub2api"><img src="assets/partners/logos/rapidproxy.jpg" alt="RapidProxy" width="150"></a></td>
<td><a href="https://www.rapidproxy.io/?ref=sub2api">RapidProxy</a> is a data collection proxy solution built for developers, providing stable and reliable residential proxy services. With 90M+ global residential IPs and 200+ country coverage, intelligent rotation, and precise geo-targeting, it helps projects such as web scraping, AI data training, SEO monitoring, and e-commerce data analysis break through access restrictions and improve data collection efficiency. It supports mainstream automation frameworks such as Playwright, Selenium, and Puppeteer, with prices as low as $0.65/GB — <a href="https://www.rapidproxy.io/?ref=sub2api">start your free test now</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://hao.ai"><img src="assets/partners/logos/haoai.png" alt="hao.ai" width="150"></a></td>
<td><a href="https://hao.ai">hao.ai</a> is a high-speed, stable unified large-model API gateway for developers and teams. With a single API Key and a unified interface, you can access mainstream models such as GPT, Claude, and xAI Grok, with compatibility for common protocols and SDKs including OpenAI and Anthropic. The platform provides model routing, failover, team management, and complete request logs, with model prices as low as 15% of official reference pricing, helping users build AI applications more simply, more reliably, and at lower cost.</td>
</tr>
<tr>
<td width="180"><a href="https://www.swiftproxy.net/?ref=sub2api"><img src="assets/partners/logos/swiftprox.png" alt="Swiftproxy" width="150"></a></td>
<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>
</table>
## Overview
@@ -201,7 +206,7 @@ Community projects that extend or integrate with Sub2API:
| Component | Technology |
|-----------|------------|
| Backend | Go 1.25.7, Gin, Ent |
| Backend | Go 1.26.5, Gin, Ent |
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
| Database | PostgreSQL 15+ |
| Cache/Queue | Redis 7+ |
@@ -747,7 +752,7 @@ The Grok OAuth flow uses PKCE and does not require committing private secrets. T
| `XAI_OAUTH_AUTHORIZE_URL` | `https://auth.x.ai/oauth2/authorize` |
| `XAI_OAUTH_TOKEN_URL` | `https://auth.x.ai/oauth2/token` |
| `XAI_BASE_URL` | `https://api.x.ai/v1`; runtime-diagnostics override (account `base_url` controls request forwarding) |
| `XAI_GROK_CLI_VERSION` | `0.2.93`; optional override for the client identity sent to `cli-chat-proxy.grok.com` |
| `XAI_GROK_CLI_VERSION` | `0.2.114`; optional override for the client identity sent to `cli-chat-proxy.grok.com`. The pinned value is also the floor: an override below it is dropped |
Administrators can create Grok OAuth or API-key accounts from the dashboard. OAuth authorization and reauthorization are also available through the admin API:
+23 -18
View File
@@ -4,7 +4,7 @@
# Sub2API
[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/)
[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
@@ -51,7 +51,7 @@
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> 注册,可享受所有充值永久 95 折优惠。</td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
</tr>
<tr>
@@ -59,11 +59,6 @@
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
</tr>
<tr>
<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>
@@ -105,16 +100,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td>感谢 <a href="https://anpin.ai">anpin.ai</a> 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。<br>
自建一手号池:1-3S超快响应 支持同行分发<br>
极致稳定:多线智能路由 + 冗余备份系统,确保服务全年无休、高可用运行;<br>
模型真实性:不做任何内容干预与二次过滤,让您体验到最纯粹、最强大的原生模型能力。<br>
充值1:1 企业级服务可开票,安品Ai不只是中转站,更是您连接前沿智能世界的安全、可靠、高效桥梁
</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>即可开始体验,轻松构建更稳定、高效的自动化工作流。
@@ -172,6 +157,26 @@
<td>感谢 LanoX AI 对本项目的赞助!<a href="https://lanox.ai/?c=6">LanoX AI</a> 为开发者、团队与企业提供稳定、高性价比的全球模型接入服务。 🎁 新用户福利 — 免费领取 百万 Token ,更有500+ 免费模型 — 低成本测试、验证、部署更轻松 🧠 全球主流模型 — GPT · Claude · Gemini · Qwen · Grok... 🎬 多模态创作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ 企业级稳定服务 — 高可用💎原生能力输出💎不降智💎不混模💎调用与计费透明💎 💰 更低调用成本 — 顶级模型低至官方价 1 折起,文档清晰、接入简单、支持开票与企业批量调用 🏢 企业优选 — 适用于 AI 产品、Agent、内容平台、研发团队批量调用</td>
</tr>
<tr>
<td width="180"><a href="https://www.rapidproxy.io/?ref=sub2api"><img src="assets/partners/logos/rapidproxy.jpg" alt="RapidProxy" width="150"></a></td>
<td><a href="https://www.rapidproxy.io/?ref=sub2api">RapidProxy</a> 是面向开发者的数据采集代理解决方案,提供稳定可靠的住宅代理服务。通过 9000 万+全球住宅 IP和 200+国家覆盖、智能轮换机制和精准地区定位能力,帮助爬虫、AI 数据训练、SEO 监控、电商数据分析等项目突破访问限制,提高数据采集效率。支持 Playwright、Selenium、Puppeteer 等主流自动化框架,价格低至 $0.65/GB,<a href="https://www.rapidproxy.io/?ref=sub2api">立即免费测试吧</a>。</td>
</tr>
<tr>
<td width="180"><a href="https://hao.ai"><img src="assets/partners/logos/haoai.png" alt="hao.ai" width="150"></a></td>
<td><a href="https://hao.ai">hao.ai</a> 是面向开发者与团队的高速、稳定大模型统一 API 网关。通过一个 API Key 和统一接口,即可接入 GPT、Claude、xAI Grok 等主流模型,兼容 OpenAI、Anthropic 等常用协议与 SDK。平台提供模型路由、故障回退、团队管理及完整调用日志,模型价格低至官方参考价的 1.5 折,帮助用户更简单、更稳定、更低成本地构建 AI 应用。</td>
</tr>
<tr>
<td width="180"><a href="https://www.swiftproxy.net/?ref=sub2api"><img src="assets/partners/logos/swiftprox.png" alt="Swiftproxy" width="150"></a></td>
<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>
</table>
## 项目概述
@@ -203,7 +208,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
| 组件 | 技术 |
|------|------|
| 后端 | Go 1.25.7, Gin, Ent |
| 后端 | Go 1.26.5, Gin, Ent |
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ |
+22 -17
View File
@@ -4,7 +4,7 @@
# Sub2API
[![Go](https://img.shields.io/badge/Go-1.25.7-00ADD8.svg)](https://golang.org/)
[![Go](https://img.shields.io/badge/Go-1.26.5-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
@@ -58,11 +58,6 @@
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
</tr>
<tr>
<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>
@@ -104,16 +99,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td><a href="https://anpin.ai">anpin.ai</a> のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。<br>
自社構築の一次アカウントプール:1-3秒の超高速応答、同業者への配布に対応<br>
極めて高い安定性:マルチライン・インテリジェントルーティング + 冗長バックアップシステムにより、年中無休の高可用性運用を確保;<br>
モデルの真正性:一切のコンテンツ介入や二次フィルタリングを行わず、最も純粋で強力なネイティブモデル能力を体験できます。<br>
チャージ 1:1、エンタープライズグレードサービスは請求書発行可能。安品 AI は単なる中継所ではなく、最先端のインテリジェント世界へと繋がる安全・信頼性・高効率の橋渡し役です。
</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>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
@@ -171,6 +156,26 @@
<td>本プロジェクトをご支援いただいている <a href="https://lanox.ai/?c=6">LanoX AI</a> に感謝します!LanoX AI は、開発者、チーム、企業向けに、安定性とコストパフォーマンスに優れたグローバルモデル接続サービスを提供しています。 🎁 新規ユーザー特典 — 数百万 Token を無料で獲得可能。さらに 500+ の無料モデルで、低コストなテスト、検証、デプロイをより簡単に 🧠 世界の主要モデル — GPT · Claude · Gemini · Qwen · Grok... 🎬 マルチモーダル制作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ エンタープライズ級の安定性 — 高可用性 💎 ネイティブ能力の出力 💎 性能劣化なし 💎 モデル混在なし 💎 利用量と課金が透明 💎 💰 より低い利用コスト — トップモデルを公式価格の 1 割から利用可能。明確なドキュメント、簡単な接続、請求書発行、企業向け一括利用に対応 🏢 企業に最適 — AI プロダクト、Agent、コンテンツプラットフォーム、大量利用する開発チームに最適</td>
</tr>
<tr>
<td width="180"><a href="https://www.rapidproxy.io/?ref=sub2api"><img src="assets/partners/logos/rapidproxy.jpg" alt="RapidProxy" width="150"></a></td>
<td><a href="https://www.rapidproxy.io/?ref=sub2api">RapidProxy</a> は開発者向けのデータ収集プロキシソリューションであり、安定して信頼できる住宅用プロキシサービスを提供します。9,000 万以上のグローバル住宅 IP と 200 以上の国・地域のカバー、インテリジェントなローテーション機構、精密な地域ターゲティング機能により、クローラー、AI データ学習、SEO モニタリング、EC データ分析などのプロジェクトがアクセス制限を突破し、データ収集の効率を高めます。Playwright、Selenium、Puppeteer などの主要な自動化フレームワークに対応し、料金は $0.65/GB から。<a href="https://www.rapidproxy.io/?ref=sub2api">今すぐ無料でお試しください</a>。</td>
</tr>
<tr>
<td width="180"><a href="https://hao.ai"><img src="assets/partners/logos/haoai.png" alt="hao.ai" width="150"></a></td>
<td><a href="https://hao.ai">hao.ai</a> は、開発者とチーム向けの高速で安定した大規模モデル統合 API ゲートウェイです。1 つの API Key と統一されたインターフェースで、GPT、Claude、xAI Grok などの主要モデルに接続でき、OpenAI や Anthropic などの一般的なプロトコルと SDK に対応しています。プラットフォームはモデルルーティング、フェイルオーバー、チーム管理、完全な呼び出しログを提供し、モデル価格は公式参考価格の 1.5 割から。よりシンプルに、より安定して、より低コストに AI アプリケーションを構築できます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.swiftproxy.net/?ref=sub2api"><img src="assets/partners/logos/swiftprox.png" alt="Swiftproxy" width="150"></a></td>
<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>
</table>
## 概要
@@ -202,7 +207,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
| コンポーネント | 技術 |
|-----------|------------|
| バックエンド | Go 1.25.7, Gin, Ent |
| バックエンド | Go 1.26.5, Gin, Ent |
| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
| データベース | PostgreSQL 15+ |
| キャッシュ/キュー | Redis 7+ |
Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 102 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 744 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.169
0.1.175
+13 -1
View File
@@ -88,6 +88,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
@@ -109,6 +110,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
@@ -236,6 +238,10 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
@@ -314,7 +320,13 @@ func provideCleanup(
}
return nil
}},
{"ChannelMonitorRunner", func() error {
{"ChannelMonitorV2Aggregator", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorRunner != nil {
channelMonitorRunner.Stop()
}
+32 -11
View File
@@ -57,6 +57,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
emailService := service.NewEmailService(settingRepository, emailCache)
turnstileVerifier := repository.NewTurnstileVerifier()
turnstileService := service.NewTurnstileService(settingService, turnstileVerifier)
tencentCaptchaVerifier := repository.NewTencentCaptchaVerifier()
tencentCaptchaService := service.NewTencentCaptchaService(settingService, tencentCaptchaVerifier)
aliyunCaptchaVerifier := repository.NewAliyunCaptchaVerifier()
aliyunCaptchaService := service.NewAliyunCaptchaService(settingService, aliyunCaptchaVerifier)
emailQueueService := service.ProvideEmailQueueService(emailService)
promoCodeRepository := repository.NewPromoCodeRepository(client)
billingCache := repository.NewBillingCache(redisClient)
@@ -78,7 +82,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
subscriptionService := service.NewSubscriptionService(groupRepository, userSubscriptionRepository, billingCacheService, client, configConfig)
affiliateRepository := repository.NewAffiliateRepository(client, db)
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
authService := service.ProvideAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, tencentCaptchaService, aliyunCaptchaService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
@@ -145,7 +149,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
openAITokenProvider := service.ProvideOpenAITokenProvider(accountRepository, geminiTokenCache, openAIOAuthService, oAuthRefreshAPI)
grokOAuthClient := repository.NewGrokOAuthClient()
grokOAuthService := service.NewGrokOAuthService(proxyRepository, grokOAuthClient)
grokOAuthService := service.ProvideGrokOAuthService(proxyRepository, grokOAuthClient, configConfig, redisClient)
grokTokenProvider := service.ProvideGrokTokenProvider(accountRepository, geminiTokenCache, grokOAuthService, oAuthRefreshAPI, tempUnschedCache)
openAIGatewayService := service.NewOpenAIGatewayService(accountRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, httpUpstream, deferredService, openAITokenProvider, grokTokenProvider, modelPricingResolver, channelService, balanceNotifyService, settingService, serviceUserPlatformQuotaRepository)
geminiOAuthClient := repository.NewGeminiOAuthClient(configConfig)
@@ -170,8 +174,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
announcementService := service.NewAnnouncementService(announcementRepository, announcementReadRepository, userRepository, userSubscriptionRepository)
announcementHandler := handler.NewAnnouncementHandler(announcementService)
channelMonitorRepository := repository.NewChannelMonitorRepository(client, db)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor)
channelMonitorService := service.ProvideChannelMonitorService(channelMonitorRepository, secretEncryptor, settingService)
channelMonitorUserHandler := handler.NewChannelMonitorUserHandler(channelMonitorService, settingService)
channelMonitorV2Repository := repository.NewChannelMonitorV2Repository(db)
channelMonitorV2Service := service.ProvideChannelMonitorV2Service(channelMonitorV2Repository, settingService)
channelMonitorV2Handler := handler.NewChannelMonitorV2Handler(channelMonitorV2Service)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
@@ -182,18 +189,18 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository)
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
usageCache := service.NewUsageCache()
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
@@ -201,12 +208,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
dbDumper := repository.NewPgDumper(configConfig)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
imageStorageFactory := repository.ProvideImageStorageFactory()
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService)
oAuthHandler := admin.NewOAuthHandler(oAuthService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService)
openAIOAuthHandler := admin.NewOpenAIOAuthHandler(openAIOAuthService, adminService, openAIQuotaService, rateLimitService)
geminiOAuthHandler := admin.NewGeminiOAuthHandler(geminiOAuthService)
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
@@ -222,7 +229,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
registry := payment.ProvideRegistry()
defaultLoadBalancer := payment.ProvideDefaultLoadBalancer(client, encryptionKey)
paymentService := service.ProvidePaymentService(client, registry, defaultLoadBalancer, redeemService, subscriptionService, paymentConfigService, userRepository, groupRepository, affiliateService, notificationEmailService)
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService)
settingHandler := handler.ProvideAdminSettingHandler(settingService, emailService, turnstileService, aliyunCaptchaService, opsService, paymentConfigService, paymentService, userAttributeService, notificationEmailService, totpService, userService)
opsHandler := admin.NewOpsHandler(opsService)
updateCache := repository.NewUpdateCache(redisClient)
gitHubReleaseClient := repository.ProvideGitHubReleaseClient(configConfig)
@@ -304,7 +311,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
@@ -320,14 +327,16 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
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, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, 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, auditLogService, promptService)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -371,6 +380,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
usageCleanup *service.UsageCleanupService,
@@ -392,6 +402,7 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
@@ -518,6 +529,10 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
}},
{"ProxyExpiryService", func() error {
proxyExpiry.Stop()
return nil
@@ -596,6 +611,12 @@ func provideCleanup(
}
return nil
}},
{"ChannelMonitorV2Aggregator", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorRunner != nil {
channelMonitorRunner.Stop()
+3
View File
@@ -40,6 +40,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil,
)
accountExpirySvc := service.NewAccountExpiryService(nil, time.Second)
codexVersionSyncSvc := service.NewOpenAICodexVersionSyncService(nil, nil, nil, time.Second)
proxyExpirySvc := service.NewProxyExpiryService(nil, time.Second)
subscriptionExpirySvc := service.NewSubscriptionExpiryService(nil, time.Second)
pricingSvc := service.NewPricingService(cfg, nil)
@@ -65,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
schedulerSnapshotSvc,
tokenRefreshSvc,
accountExpirySvc,
codexVersionSyncSvc,
proxyExpirySvc,
subscriptionExpirySvc,
&service.UsageCleanupService{},
@@ -86,6 +88,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // channelMonitorV2Aggregator
nil, // quotaFlusher
nil, // upstreamBillingProbe
nil, // ollamaCloudUsage
+96 -3
View File
@@ -85,8 +85,22 @@ type Group struct {
VideoPrice720p *float64 `json:"video_price_720p,omitempty"`
// VideoPrice1080p holds the value of the "video_price_1080p" field.
VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"`
// 按模型族和分辨率覆盖视频每秒价格
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
// Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)
WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"`
// 搜索工具价格 per 1000 calls(web_search 等)
SearchPricePer1k *float64 `json:"search_price_per_1k,omitempty"`
// Voice realtime 每分钟价格(USD)
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"`
// TTS 每百万字符价格(USD)
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"`
// STT 每小时价格(USD)
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"`
// 是否按上下文长度应用模型阶梯价格
LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"`
// 分组逐模型定价;优先级高于渠道和内置定价
ModelPricing json.RawMessage `json:"model_pricing,omitempty"`
// 是否仅允许 Claude Code 客户端
ClaudeCodeOnly bool `json:"claude_code_only,omitempty"`
// 非 Claude Code 请求降级使用的分组 ID
@@ -235,11 +249,11 @@ func (*Group) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
values[i] = new([]byte)
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
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.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
values[i] = new(sql.NullFloat64)
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
values[i] = new(sql.NullInt64)
@@ -478,6 +492,14 @@ func (_m *Group) assignValues(columns []string, values []any) error {
_m.VideoPrice1080p = new(float64)
*_m.VideoPrice1080p = value.Float64
}
case group.FieldVideoModelPrices:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field video_model_prices", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.VideoModelPrices); err != nil {
return fmt.Errorf("unmarshal field video_model_prices: %w", err)
}
}
case group.FieldWebSearchPricePerCall:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field web_search_price_per_call", values[i])
@@ -485,6 +507,48 @@ func (_m *Group) assignValues(columns []string, values []any) error {
_m.WebSearchPricePerCall = new(float64)
*_m.WebSearchPricePerCall = value.Float64
}
case group.FieldSearchPricePer1k:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field search_price_per_1k", values[i])
} else if value.Valid {
_m.SearchPricePer1k = new(float64)
*_m.SearchPricePer1k = value.Float64
}
case group.FieldAudioRealtimePricePerMin:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field audio_realtime_price_per_min", values[i])
} else if value.Valid {
_m.AudioRealtimePricePerMin = new(float64)
*_m.AudioRealtimePricePerMin = value.Float64
}
case group.FieldAudioTtsPricePerMillionChars:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field audio_tts_price_per_million_chars", values[i])
} else if value.Valid {
_m.AudioTtsPricePerMillionChars = new(float64)
*_m.AudioTtsPricePerMillionChars = value.Float64
}
case group.FieldAudioSttPricePerHour:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field audio_stt_price_per_hour", values[i])
} else if value.Valid {
_m.AudioSttPricePerHour = new(float64)
*_m.AudioSttPricePerHour = value.Float64
}
case group.FieldLongContextPricingEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field long_context_pricing_enabled", values[i])
} else if value.Valid {
_m.LongContextPricingEnabled = value.Bool
}
case group.FieldModelPricing:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field model_pricing", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.ModelPricing); err != nil {
return fmt.Errorf("unmarshal field model_pricing: %w", err)
}
}
case group.FieldClaudeCodeOnly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field claude_code_only", values[i])
@@ -822,11 +886,40 @@ func (_m *Group) String() string {
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("video_model_prices=")
builder.WriteString(fmt.Sprintf("%v", _m.VideoModelPrices))
builder.WriteString(", ")
if v := _m.WebSearchPricePerCall; v != nil {
builder.WriteString("web_search_price_per_call=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.SearchPricePer1k; v != nil {
builder.WriteString("search_price_per_1k=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.AudioRealtimePricePerMin; v != nil {
builder.WriteString("audio_realtime_price_per_min=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.AudioTtsPricePerMillionChars; v != nil {
builder.WriteString("audio_tts_price_per_million_chars=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.AudioSttPricePerHour; v != nil {
builder.WriteString("audio_stt_price_per_hour=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("long_context_pricing_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.LongContextPricingEnabled))
builder.WriteString(", ")
builder.WriteString("model_pricing=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelPricing))
builder.WriteString(", ")
builder.WriteString("claude_code_only=")
builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly))
builder.WriteString(", ")
+56
View File
@@ -82,8 +82,22 @@ const (
FieldVideoPrice720p = "video_price_720p"
// FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database.
FieldVideoPrice1080p = "video_price_1080p"
// FieldVideoModelPrices holds the string denoting the video_model_prices field in the database.
FieldVideoModelPrices = "video_model_prices"
// FieldWebSearchPricePerCall holds the string denoting the web_search_price_per_call field in the database.
FieldWebSearchPricePerCall = "web_search_price_per_call"
// FieldSearchPricePer1k holds the string denoting the search_price_per_1k field in the database.
FieldSearchPricePer1k = "search_price_per_1k"
// FieldAudioRealtimePricePerMin holds the string denoting the audio_realtime_price_per_min field in the database.
FieldAudioRealtimePricePerMin = "audio_realtime_price_per_min"
// FieldAudioTtsPricePerMillionChars holds the string denoting the audio_tts_price_per_million_chars field in the database.
FieldAudioTtsPricePerMillionChars = "audio_tts_price_per_million_chars"
// FieldAudioSttPricePerHour holds the string denoting the audio_stt_price_per_hour field in the database.
FieldAudioSttPricePerHour = "audio_stt_price_per_hour"
// FieldLongContextPricingEnabled holds the string denoting the long_context_pricing_enabled field in the database.
FieldLongContextPricingEnabled = "long_context_pricing_enabled"
// FieldModelPricing holds the string denoting the model_pricing field in the database.
FieldModelPricing = "model_pricing"
// FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database.
FieldClaudeCodeOnly = "claude_code_only"
// FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database.
@@ -234,7 +248,14 @@ var Columns = []string{
FieldVideoPrice480p,
FieldVideoPrice720p,
FieldVideoPrice1080p,
FieldVideoModelPrices,
FieldWebSearchPricePerCall,
FieldSearchPricePer1k,
FieldAudioRealtimePricePerMin,
FieldAudioTtsPricePerMillionChars,
FieldAudioSttPricePerHour,
FieldLongContextPricingEnabled,
FieldModelPricing,
FieldClaudeCodeOnly,
FieldFallbackGroupID,
FieldFallbackGroupIDOnInvalidRequest,
@@ -341,6 +362,16 @@ var (
DefaultVideoRateIndependent bool
// DefaultVideoRateMultiplier holds the default value on creation for the "video_rate_multiplier" field.
DefaultVideoRateMultiplier float64
// SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save.
SearchPricePer1kValidator func(float64) error
// AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save.
AudioRealtimePricePerMinValidator func(float64) error
// AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save.
AudioTtsPricePerMillionCharsValidator func(float64) error
// AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
AudioSttPricePerHourValidator func(float64) error
// DefaultLongContextPricingEnabled holds the default value on creation for the "long_context_pricing_enabled" field.
DefaultLongContextPricingEnabled bool
// DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field.
DefaultClaudeCodeOnly bool
// DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field.
@@ -561,6 +592,31 @@ func ByWebSearchPricePerCall(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldWebSearchPricePerCall, opts...).ToFunc()
}
// BySearchPricePer1k orders the results by the search_price_per_1k field.
func BySearchPricePer1k(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldSearchPricePer1k, opts...).ToFunc()
}
// ByAudioRealtimePricePerMin orders the results by the audio_realtime_price_per_min field.
func ByAudioRealtimePricePerMin(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAudioRealtimePricePerMin, opts...).ToFunc()
}
// ByAudioTtsPricePerMillionChars orders the results by the audio_tts_price_per_million_chars field.
func ByAudioTtsPricePerMillionChars(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAudioTtsPricePerMillionChars, opts...).ToFunc()
}
// ByAudioSttPricePerHour orders the results by the audio_stt_price_per_hour field.
func ByAudioSttPricePerHour(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAudioSttPricePerHour, opts...).ToFunc()
}
// ByLongContextPricingEnabled orders the results by the long_context_pricing_enabled field.
func ByLongContextPricingEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLongContextPricingEnabled, opts...).ToFunc()
}
// ByClaudeCodeOnly orders the results by the claude_code_only field.
func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc()
+255
View File
@@ -225,6 +225,31 @@ func WebSearchPricePerCall(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v))
}
// SearchPricePer1k applies equality check predicate on the "search_price_per_1k" field. It's identical to SearchPricePer1kEQ.
func SearchPricePer1k(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v))
}
// AudioRealtimePricePerMin applies equality check predicate on the "audio_realtime_price_per_min" field. It's identical to AudioRealtimePricePerMinEQ.
func AudioRealtimePricePerMin(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v))
}
// AudioTtsPricePerMillionChars applies equality check predicate on the "audio_tts_price_per_million_chars" field. It's identical to AudioTtsPricePerMillionCharsEQ.
func AudioTtsPricePerMillionChars(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioTtsPricePerMillionChars, v))
}
// AudioSttPricePerHour applies equality check predicate on the "audio_stt_price_per_hour" field. It's identical to AudioSttPricePerHourEQ.
func AudioSttPricePerHour(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v))
}
// LongContextPricingEnabled applies equality check predicate on the "long_context_pricing_enabled" field. It's identical to LongContextPricingEnabledEQ.
func LongContextPricingEnabled(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldLongContextPricingEnabled, v))
}
// ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ.
func ClaudeCodeOnly(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
@@ -1765,6 +1790,16 @@ func VideoPrice1080pNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p))
}
// VideoModelPricesIsNil applies the IsNil predicate on the "video_model_prices" field.
func VideoModelPricesIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldVideoModelPrices))
}
// VideoModelPricesNotNil applies the NotNil predicate on the "video_model_prices" field.
func VideoModelPricesNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldVideoModelPrices))
}
// WebSearchPricePerCallEQ applies the EQ predicate on the "web_search_price_per_call" field.
func WebSearchPricePerCallEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v))
@@ -1815,6 +1850,226 @@ func WebSearchPricePerCallNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldWebSearchPricePerCall))
}
// SearchPricePer1kEQ applies the EQ predicate on the "search_price_per_1k" field.
func SearchPricePer1kEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v))
}
// SearchPricePer1kNEQ applies the NEQ predicate on the "search_price_per_1k" field.
func SearchPricePer1kNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldSearchPricePer1k, v))
}
// SearchPricePer1kIn applies the In predicate on the "search_price_per_1k" field.
func SearchPricePer1kIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldSearchPricePer1k, vs...))
}
// SearchPricePer1kNotIn applies the NotIn predicate on the "search_price_per_1k" field.
func SearchPricePer1kNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldSearchPricePer1k, vs...))
}
// SearchPricePer1kGT applies the GT predicate on the "search_price_per_1k" field.
func SearchPricePer1kGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldSearchPricePer1k, v))
}
// SearchPricePer1kGTE applies the GTE predicate on the "search_price_per_1k" field.
func SearchPricePer1kGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldSearchPricePer1k, v))
}
// SearchPricePer1kLT applies the LT predicate on the "search_price_per_1k" field.
func SearchPricePer1kLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldSearchPricePer1k, v))
}
// SearchPricePer1kLTE applies the LTE predicate on the "search_price_per_1k" field.
func SearchPricePer1kLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldSearchPricePer1k, v))
}
// SearchPricePer1kIsNil applies the IsNil predicate on the "search_price_per_1k" field.
func SearchPricePer1kIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldSearchPricePer1k))
}
// SearchPricePer1kNotNil applies the NotNil predicate on the "search_price_per_1k" field.
func SearchPricePer1kNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldSearchPricePer1k))
}
// AudioRealtimePricePerMinEQ applies the EQ predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinNEQ applies the NEQ predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinIn applies the In predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldAudioRealtimePricePerMin, vs...))
}
// AudioRealtimePricePerMinNotIn applies the NotIn predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldAudioRealtimePricePerMin, vs...))
}
// AudioRealtimePricePerMinGT applies the GT predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinGTE applies the GTE predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinLT applies the LT predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinLTE applies the LTE predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinIsNil applies the IsNil predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldAudioRealtimePricePerMin))
}
// AudioRealtimePricePerMinNotNil applies the NotNil predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldAudioRealtimePricePerMin))
}
// AudioTtsPricePerMillionCharsEQ applies the EQ predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsNEQ applies the NEQ predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsIn applies the In predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldAudioTtsPricePerMillionChars, vs...))
}
// AudioTtsPricePerMillionCharsNotIn applies the NotIn predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldAudioTtsPricePerMillionChars, vs...))
}
// AudioTtsPricePerMillionCharsGT applies the GT predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsGTE applies the GTE predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsLT applies the LT predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsLTE applies the LTE predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsIsNil applies the IsNil predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldAudioTtsPricePerMillionChars))
}
// AudioTtsPricePerMillionCharsNotNil applies the NotNil predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldAudioTtsPricePerMillionChars))
}
// AudioSttPricePerHourEQ applies the EQ predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourNEQ applies the NEQ predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourIn applies the In predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldAudioSttPricePerHour, vs...))
}
// AudioSttPricePerHourNotIn applies the NotIn predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldAudioSttPricePerHour, vs...))
}
// AudioSttPricePerHourGT applies the GT predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourGTE applies the GTE predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourLT applies the LT predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourLTE applies the LTE predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourIsNil applies the IsNil predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldAudioSttPricePerHour))
}
// AudioSttPricePerHourNotNil applies the NotNil predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldAudioSttPricePerHour))
}
// LongContextPricingEnabledEQ applies the EQ predicate on the "long_context_pricing_enabled" field.
func LongContextPricingEnabledEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldLongContextPricingEnabled, v))
}
// LongContextPricingEnabledNEQ applies the NEQ predicate on the "long_context_pricing_enabled" field.
func LongContextPricingEnabledNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldLongContextPricingEnabled, v))
}
// ModelPricingIsNil applies the IsNil predicate on the "model_pricing" field.
func ModelPricingIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldModelPricing))
}
// ModelPricingNotNil applies the NotNil predicate on the "model_pricing" field.
func ModelPricingNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldModelPricing))
}
// ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field.
func ClaudeCodeOnlyEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
+618
View File
@@ -4,6 +4,7 @@ package ent
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
@@ -483,6 +484,12 @@ func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate {
return _c
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (_c *GroupCreate) SetVideoModelPrices(v map[string]map[string]float64) *GroupCreate {
_c.mutation.SetVideoModelPrices(v)
return _c
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (_c *GroupCreate) SetWebSearchPricePerCall(v float64) *GroupCreate {
_c.mutation.SetWebSearchPricePerCall(v)
@@ -497,6 +504,82 @@ func (_c *GroupCreate) SetNillableWebSearchPricePerCall(v *float64) *GroupCreate
return _c
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (_c *GroupCreate) SetSearchPricePer1k(v float64) *GroupCreate {
_c.mutation.SetSearchPricePer1k(v)
return _c
}
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
func (_c *GroupCreate) SetNillableSearchPricePer1k(v *float64) *GroupCreate {
if v != nil {
_c.SetSearchPricePer1k(*v)
}
return _c
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (_c *GroupCreate) SetAudioRealtimePricePerMin(v float64) *GroupCreate {
_c.mutation.SetAudioRealtimePricePerMin(v)
return _c
}
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
func (_c *GroupCreate) SetNillableAudioRealtimePricePerMin(v *float64) *GroupCreate {
if v != nil {
_c.SetAudioRealtimePricePerMin(*v)
}
return _c
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (_c *GroupCreate) SetAudioTtsPricePerMillionChars(v float64) *GroupCreate {
_c.mutation.SetAudioTtsPricePerMillionChars(v)
return _c
}
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
func (_c *GroupCreate) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupCreate {
if v != nil {
_c.SetAudioTtsPricePerMillionChars(*v)
}
return _c
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (_c *GroupCreate) SetAudioSttPricePerHour(v float64) *GroupCreate {
_c.mutation.SetAudioSttPricePerHour(v)
return _c
}
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
func (_c *GroupCreate) SetNillableAudioSttPricePerHour(v *float64) *GroupCreate {
if v != nil {
_c.SetAudioSttPricePerHour(*v)
}
return _c
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_c *GroupCreate) SetLongContextPricingEnabled(v bool) *GroupCreate {
_c.mutation.SetLongContextPricingEnabled(v)
return _c
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_c *GroupCreate) SetNillableLongContextPricingEnabled(v *bool) *GroupCreate {
if v != nil {
_c.SetLongContextPricingEnabled(*v)
}
return _c
}
// SetModelPricing sets the "model_pricing" field.
func (_c *GroupCreate) SetModelPricing(v json.RawMessage) *GroupCreate {
_c.mutation.SetModelPricing(v)
return _c
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate {
_c.mutation.SetClaudeCodeOnly(v)
@@ -980,6 +1063,10 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultVideoRateMultiplier
_c.mutation.SetVideoRateMultiplier(v)
}
if _, ok := _c.mutation.LongContextPricingEnabled(); !ok {
v := group.DefaultLongContextPricingEnabled
_c.mutation.SetLongContextPricingEnabled(v)
}
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
v := group.DefaultClaudeCodeOnly
_c.mutation.SetClaudeCodeOnly(v)
@@ -1155,6 +1242,29 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.VideoRateMultiplier(); !ok {
return &ValidationError{Name: "video_rate_multiplier", err: errors.New(`ent: missing required field "Group.video_rate_multiplier"`)}
}
if v, ok := _c.mutation.SearchPricePer1k(); ok {
if err := group.SearchPricePer1kValidator(v); err != nil {
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
}
}
if v, ok := _c.mutation.AudioRealtimePricePerMin(); ok {
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
}
}
if v, ok := _c.mutation.AudioTtsPricePerMillionChars(); ok {
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
}
}
if v, ok := _c.mutation.AudioSttPricePerHour(); ok {
if err := group.AudioSttPricePerHourValidator(v); err != nil {
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
}
}
if _, ok := _c.mutation.LongContextPricingEnabled(); !ok {
return &ValidationError{Name: "long_context_pricing_enabled", err: errors.New(`ent: missing required field "Group.long_context_pricing_enabled"`)}
}
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)}
}
@@ -1378,10 +1488,38 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
_node.VideoPrice1080p = &value
}
if value, ok := _c.mutation.VideoModelPrices(); ok {
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
_node.VideoModelPrices = value
}
if value, ok := _c.mutation.WebSearchPricePerCall(); ok {
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
_node.WebSearchPricePerCall = &value
}
if value, ok := _c.mutation.SearchPricePer1k(); ok {
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
_node.SearchPricePer1k = &value
}
if value, ok := _c.mutation.AudioRealtimePricePerMin(); ok {
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
_node.AudioRealtimePricePerMin = &value
}
if value, ok := _c.mutation.AudioTtsPricePerMillionChars(); ok {
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
_node.AudioTtsPricePerMillionChars = &value
}
if value, ok := _c.mutation.AudioSttPricePerHour(); ok {
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
_node.AudioSttPricePerHour = &value
}
if value, ok := _c.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
_node.LongContextPricingEnabled = value
}
if value, ok := _c.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
_node.ModelPricing = value
}
if value, ok := _c.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
_node.ClaudeCodeOnly = value
@@ -2156,6 +2294,24 @@ func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert {
return u
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (u *GroupUpsert) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsert {
u.Set(group.FieldVideoModelPrices, v)
return u
}
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
func (u *GroupUpsert) UpdateVideoModelPrices() *GroupUpsert {
u.SetExcluded(group.FieldVideoModelPrices)
return u
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (u *GroupUpsert) ClearVideoModelPrices() *GroupUpsert {
u.SetNull(group.FieldVideoModelPrices)
return u
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (u *GroupUpsert) SetWebSearchPricePerCall(v float64) *GroupUpsert {
u.Set(group.FieldWebSearchPricePerCall, v)
@@ -2180,6 +2336,132 @@ func (u *GroupUpsert) ClearWebSearchPricePerCall() *GroupUpsert {
return u
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (u *GroupUpsert) SetSearchPricePer1k(v float64) *GroupUpsert {
u.Set(group.FieldSearchPricePer1k, v)
return u
}
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
func (u *GroupUpsert) UpdateSearchPricePer1k() *GroupUpsert {
u.SetExcluded(group.FieldSearchPricePer1k)
return u
}
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
func (u *GroupUpsert) AddSearchPricePer1k(v float64) *GroupUpsert {
u.Add(group.FieldSearchPricePer1k, v)
return u
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (u *GroupUpsert) ClearSearchPricePer1k() *GroupUpsert {
u.SetNull(group.FieldSearchPricePer1k)
return u
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (u *GroupUpsert) SetAudioRealtimePricePerMin(v float64) *GroupUpsert {
u.Set(group.FieldAudioRealtimePricePerMin, v)
return u
}
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
func (u *GroupUpsert) UpdateAudioRealtimePricePerMin() *GroupUpsert {
u.SetExcluded(group.FieldAudioRealtimePricePerMin)
return u
}
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
func (u *GroupUpsert) AddAudioRealtimePricePerMin(v float64) *GroupUpsert {
u.Add(group.FieldAudioRealtimePricePerMin, v)
return u
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (u *GroupUpsert) ClearAudioRealtimePricePerMin() *GroupUpsert {
u.SetNull(group.FieldAudioRealtimePricePerMin)
return u
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsert) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsert {
u.Set(group.FieldAudioTtsPricePerMillionChars, v)
return u
}
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
func (u *GroupUpsert) UpdateAudioTtsPricePerMillionChars() *GroupUpsert {
u.SetExcluded(group.FieldAudioTtsPricePerMillionChars)
return u
}
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsert) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsert {
u.Add(group.FieldAudioTtsPricePerMillionChars, v)
return u
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsert) ClearAudioTtsPricePerMillionChars() *GroupUpsert {
u.SetNull(group.FieldAudioTtsPricePerMillionChars)
return u
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (u *GroupUpsert) SetAudioSttPricePerHour(v float64) *GroupUpsert {
u.Set(group.FieldAudioSttPricePerHour, v)
return u
}
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
func (u *GroupUpsert) UpdateAudioSttPricePerHour() *GroupUpsert {
u.SetExcluded(group.FieldAudioSttPricePerHour)
return u
}
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
func (u *GroupUpsert) AddAudioSttPricePerHour(v float64) *GroupUpsert {
u.Add(group.FieldAudioSttPricePerHour, v)
return u
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (u *GroupUpsert) ClearAudioSttPricePerHour() *GroupUpsert {
u.SetNull(group.FieldAudioSttPricePerHour)
return u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsert) SetLongContextPricingEnabled(v bool) *GroupUpsert {
u.Set(group.FieldLongContextPricingEnabled, v)
return u
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsert) UpdateLongContextPricingEnabled() *GroupUpsert {
u.SetExcluded(group.FieldLongContextPricingEnabled)
return u
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsert) SetModelPricing(v json.RawMessage) *GroupUpsert {
u.Set(group.FieldModelPricing, v)
return u
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelPricing() *GroupUpsert {
u.SetExcluded(group.FieldModelPricing)
return u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsert) ClearModelPricing() *GroupUpsert {
u.SetNull(group.FieldModelPricing)
return u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert {
u.Set(group.FieldClaudeCodeOnly, v)
@@ -3157,6 +3439,27 @@ func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne {
})
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (u *GroupUpsertOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetVideoModelPrices(v)
})
}
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateVideoModelPrices() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoModelPrices()
})
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (u *GroupUpsertOne) ClearVideoModelPrices() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoModelPrices()
})
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (u *GroupUpsertOne) SetWebSearchPricePerCall(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -3185,6 +3488,153 @@ func (u *GroupUpsertOne) ClearWebSearchPricePerCall() *GroupUpsertOne {
})
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (u *GroupUpsertOne) SetSearchPricePer1k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetSearchPricePer1k(v)
})
}
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
func (u *GroupUpsertOne) AddSearchPricePer1k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddSearchPricePer1k(v)
})
}
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateSearchPricePer1k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateSearchPricePer1k()
})
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (u *GroupUpsertOne) ClearSearchPricePer1k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearSearchPricePer1k()
})
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (u *GroupUpsertOne) SetAudioRealtimePricePerMin(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetAudioRealtimePricePerMin(v)
})
}
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
func (u *GroupUpsertOne) AddAudioRealtimePricePerMin(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddAudioRealtimePricePerMin(v)
})
}
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateAudioRealtimePricePerMin() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioRealtimePricePerMin()
})
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (u *GroupUpsertOne) ClearAudioRealtimePricePerMin() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioRealtimePricePerMin()
})
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertOne) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetAudioTtsPricePerMillionChars(v)
})
}
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertOne) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddAudioTtsPricePerMillionChars(v)
})
}
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateAudioTtsPricePerMillionChars() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioTtsPricePerMillionChars()
})
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertOne) ClearAudioTtsPricePerMillionChars() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioTtsPricePerMillionChars()
})
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (u *GroupUpsertOne) SetAudioSttPricePerHour(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetAudioSttPricePerHour(v)
})
}
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
func (u *GroupUpsertOne) AddAudioSttPricePerHour(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddAudioSttPricePerHour(v)
})
}
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateAudioSttPricePerHour() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioSttPricePerHour()
})
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (u *GroupUpsertOne) ClearAudioSttPricePerHour() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioSttPricePerHour()
})
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsertOne) SetLongContextPricingEnabled(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetLongContextPricingEnabled(v)
})
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateLongContextPricingEnabled() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateLongContextPricingEnabled()
})
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsertOne) SetModelPricing(v json.RawMessage) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetModelPricing(v)
})
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelPricing() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelPricing()
})
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsertOne) ClearModelPricing() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearModelPricing()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -4379,6 +4829,27 @@ func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk {
})
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (u *GroupUpsertBulk) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetVideoModelPrices(v)
})
}
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateVideoModelPrices() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoModelPrices()
})
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (u *GroupUpsertBulk) ClearVideoModelPrices() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoModelPrices()
})
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (u *GroupUpsertBulk) SetWebSearchPricePerCall(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
@@ -4407,6 +4878,153 @@ func (u *GroupUpsertBulk) ClearWebSearchPricePerCall() *GroupUpsertBulk {
})
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (u *GroupUpsertBulk) SetSearchPricePer1k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetSearchPricePer1k(v)
})
}
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
func (u *GroupUpsertBulk) AddSearchPricePer1k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddSearchPricePer1k(v)
})
}
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateSearchPricePer1k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateSearchPricePer1k()
})
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (u *GroupUpsertBulk) ClearSearchPricePer1k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearSearchPricePer1k()
})
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (u *GroupUpsertBulk) SetAudioRealtimePricePerMin(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetAudioRealtimePricePerMin(v)
})
}
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
func (u *GroupUpsertBulk) AddAudioRealtimePricePerMin(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddAudioRealtimePricePerMin(v)
})
}
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateAudioRealtimePricePerMin() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioRealtimePricePerMin()
})
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (u *GroupUpsertBulk) ClearAudioRealtimePricePerMin() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioRealtimePricePerMin()
})
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertBulk) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetAudioTtsPricePerMillionChars(v)
})
}
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertBulk) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddAudioTtsPricePerMillionChars(v)
})
}
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateAudioTtsPricePerMillionChars() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioTtsPricePerMillionChars()
})
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertBulk) ClearAudioTtsPricePerMillionChars() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioTtsPricePerMillionChars()
})
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (u *GroupUpsertBulk) SetAudioSttPricePerHour(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetAudioSttPricePerHour(v)
})
}
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
func (u *GroupUpsertBulk) AddAudioSttPricePerHour(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddAudioSttPricePerHour(v)
})
}
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateAudioSttPricePerHour() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioSttPricePerHour()
})
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (u *GroupUpsertBulk) ClearAudioSttPricePerHour() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioSttPricePerHour()
})
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsertBulk) SetLongContextPricingEnabled(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetLongContextPricingEnabled(v)
})
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateLongContextPricingEnabled() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateLongContextPricingEnabled()
})
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsertBulk) SetModelPricing(v json.RawMessage) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetModelPricing(v)
})
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelPricing() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelPricing()
})
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsertBulk) ClearModelPricing() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearModelPricing()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+457
View File
@@ -4,6 +4,7 @@ package ent
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
@@ -640,6 +641,18 @@ func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate {
return _u
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (_u *GroupUpdate) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdate {
_u.mutation.SetVideoModelPrices(v)
return _u
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (_u *GroupUpdate) ClearVideoModelPrices() *GroupUpdate {
_u.mutation.ClearVideoModelPrices()
return _u
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (_u *GroupUpdate) SetWebSearchPricePerCall(v float64) *GroupUpdate {
_u.mutation.ResetWebSearchPricePerCall()
@@ -667,6 +680,146 @@ func (_u *GroupUpdate) ClearWebSearchPricePerCall() *GroupUpdate {
return _u
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (_u *GroupUpdate) SetSearchPricePer1k(v float64) *GroupUpdate {
_u.mutation.ResetSearchPricePer1k()
_u.mutation.SetSearchPricePer1k(v)
return _u
}
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableSearchPricePer1k(v *float64) *GroupUpdate {
if v != nil {
_u.SetSearchPricePer1k(*v)
}
return _u
}
// AddSearchPricePer1k adds value to the "search_price_per_1k" field.
func (_u *GroupUpdate) AddSearchPricePer1k(v float64) *GroupUpdate {
_u.mutation.AddSearchPricePer1k(v)
return _u
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (_u *GroupUpdate) ClearSearchPricePer1k() *GroupUpdate {
_u.mutation.ClearSearchPricePer1k()
return _u
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (_u *GroupUpdate) SetAudioRealtimePricePerMin(v float64) *GroupUpdate {
_u.mutation.ResetAudioRealtimePricePerMin()
_u.mutation.SetAudioRealtimePricePerMin(v)
return _u
}
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableAudioRealtimePricePerMin(v *float64) *GroupUpdate {
if v != nil {
_u.SetAudioRealtimePricePerMin(*v)
}
return _u
}
// AddAudioRealtimePricePerMin adds value to the "audio_realtime_price_per_min" field.
func (_u *GroupUpdate) AddAudioRealtimePricePerMin(v float64) *GroupUpdate {
_u.mutation.AddAudioRealtimePricePerMin(v)
return _u
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (_u *GroupUpdate) ClearAudioRealtimePricePerMin() *GroupUpdate {
_u.mutation.ClearAudioRealtimePricePerMin()
return _u
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdate) SetAudioTtsPricePerMillionChars(v float64) *GroupUpdate {
_u.mutation.ResetAudioTtsPricePerMillionChars()
_u.mutation.SetAudioTtsPricePerMillionChars(v)
return _u
}
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupUpdate {
if v != nil {
_u.SetAudioTtsPricePerMillionChars(*v)
}
return _u
}
// AddAudioTtsPricePerMillionChars adds value to the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdate) AddAudioTtsPricePerMillionChars(v float64) *GroupUpdate {
_u.mutation.AddAudioTtsPricePerMillionChars(v)
return _u
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdate) ClearAudioTtsPricePerMillionChars() *GroupUpdate {
_u.mutation.ClearAudioTtsPricePerMillionChars()
return _u
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (_u *GroupUpdate) SetAudioSttPricePerHour(v float64) *GroupUpdate {
_u.mutation.ResetAudioSttPricePerHour()
_u.mutation.SetAudioSttPricePerHour(v)
return _u
}
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableAudioSttPricePerHour(v *float64) *GroupUpdate {
if v != nil {
_u.SetAudioSttPricePerHour(*v)
}
return _u
}
// AddAudioSttPricePerHour adds value to the "audio_stt_price_per_hour" field.
func (_u *GroupUpdate) AddAudioSttPricePerHour(v float64) *GroupUpdate {
_u.mutation.AddAudioSttPricePerHour(v)
return _u
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (_u *GroupUpdate) ClearAudioSttPricePerHour() *GroupUpdate {
_u.mutation.ClearAudioSttPricePerHour()
return _u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_u *GroupUpdate) SetLongContextPricingEnabled(v bool) *GroupUpdate {
_u.mutation.SetLongContextPricingEnabled(v)
return _u
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdate {
if v != nil {
_u.SetLongContextPricingEnabled(*v)
}
return _u
}
// SetModelPricing sets the "model_pricing" field.
func (_u *GroupUpdate) SetModelPricing(v json.RawMessage) *GroupUpdate {
_u.mutation.SetModelPricing(v)
return _u
}
// AppendModelPricing appends value to the "model_pricing" field.
func (_u *GroupUpdate) AppendModelPricing(v json.RawMessage) *GroupUpdate {
_u.mutation.AppendModelPricing(v)
return _u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (_u *GroupUpdate) ClearModelPricing() *GroupUpdate {
_u.mutation.ClearModelPricing()
return _u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate {
_u.mutation.SetClaudeCodeOnly(v)
@@ -1304,6 +1457,26 @@ func (_u *GroupUpdate) check() error {
return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)}
}
}
if v, ok := _u.mutation.SearchPricePer1k(); ok {
if err := group.SearchPricePer1kValidator(v); err != nil {
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
}
}
if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
}
}
if v, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
}
}
if v, ok := _u.mutation.AudioSttPricePerHour(); ok {
if err := group.AudioSttPricePerHourValidator(v); err != nil {
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
}
}
if v, ok := _u.mutation.DefaultMappedModel(); ok {
if err := group.DefaultMappedModelValidator(v); err != nil {
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
@@ -1506,6 +1679,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.VideoPrice1080pCleared() {
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
}
if value, ok := _u.mutation.VideoModelPrices(); ok {
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
}
if _u.mutation.VideoModelPricesCleared() {
_spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON)
}
if value, ok := _u.mutation.WebSearchPricePerCall(); ok {
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
}
@@ -1515,6 +1694,56 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.WebSearchPricePerCallCleared() {
_spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64)
}
if value, ok := _u.mutation.SearchPricePer1k(); ok {
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedSearchPricePer1k(); ok {
_spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
}
if _u.mutation.SearchPricePer1kCleared() {
_spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioRealtimePricePerMin(); ok {
_spec.AddField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
}
if _u.mutation.AudioRealtimePricePerMinCleared() {
_spec.ClearField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioTtsPricePerMillionChars(); ok {
_spec.AddField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
}
if _u.mutation.AudioTtsPricePerMillionCharsCleared() {
_spec.ClearField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioSttPricePerHour(); ok {
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioSttPricePerHour(); ok {
_spec.AddField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
}
if _u.mutation.AudioSttPricePerHourCleared() {
_spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64)
}
if value, ok := _u.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedModelPricing(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, group.FieldModelPricing, value)
})
}
if _u.mutation.ModelPricingCleared() {
_spec.ClearField(group.FieldModelPricing, field.TypeJSON)
}
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
@@ -2533,6 +2762,18 @@ func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne {
return _u
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (_u *GroupUpdateOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdateOne {
_u.mutation.SetVideoModelPrices(v)
return _u
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (_u *GroupUpdateOne) ClearVideoModelPrices() *GroupUpdateOne {
_u.mutation.ClearVideoModelPrices()
return _u
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (_u *GroupUpdateOne) SetWebSearchPricePerCall(v float64) *GroupUpdateOne {
_u.mutation.ResetWebSearchPricePerCall()
@@ -2560,6 +2801,146 @@ func (_u *GroupUpdateOne) ClearWebSearchPricePerCall() *GroupUpdateOne {
return _u
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (_u *GroupUpdateOne) SetSearchPricePer1k(v float64) *GroupUpdateOne {
_u.mutation.ResetSearchPricePer1k()
_u.mutation.SetSearchPricePer1k(v)
return _u
}
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableSearchPricePer1k(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetSearchPricePer1k(*v)
}
return _u
}
// AddSearchPricePer1k adds value to the "search_price_per_1k" field.
func (_u *GroupUpdateOne) AddSearchPricePer1k(v float64) *GroupUpdateOne {
_u.mutation.AddSearchPricePer1k(v)
return _u
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (_u *GroupUpdateOne) ClearSearchPricePer1k() *GroupUpdateOne {
_u.mutation.ClearSearchPricePer1k()
return _u
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (_u *GroupUpdateOne) SetAudioRealtimePricePerMin(v float64) *GroupUpdateOne {
_u.mutation.ResetAudioRealtimePricePerMin()
_u.mutation.SetAudioRealtimePricePerMin(v)
return _u
}
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableAudioRealtimePricePerMin(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetAudioRealtimePricePerMin(*v)
}
return _u
}
// AddAudioRealtimePricePerMin adds value to the "audio_realtime_price_per_min" field.
func (_u *GroupUpdateOne) AddAudioRealtimePricePerMin(v float64) *GroupUpdateOne {
_u.mutation.AddAudioRealtimePricePerMin(v)
return _u
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (_u *GroupUpdateOne) ClearAudioRealtimePricePerMin() *GroupUpdateOne {
_u.mutation.ClearAudioRealtimePricePerMin()
return _u
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdateOne) SetAudioTtsPricePerMillionChars(v float64) *GroupUpdateOne {
_u.mutation.ResetAudioTtsPricePerMillionChars()
_u.mutation.SetAudioTtsPricePerMillionChars(v)
return _u
}
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetAudioTtsPricePerMillionChars(*v)
}
return _u
}
// AddAudioTtsPricePerMillionChars adds value to the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdateOne) AddAudioTtsPricePerMillionChars(v float64) *GroupUpdateOne {
_u.mutation.AddAudioTtsPricePerMillionChars(v)
return _u
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdateOne) ClearAudioTtsPricePerMillionChars() *GroupUpdateOne {
_u.mutation.ClearAudioTtsPricePerMillionChars()
return _u
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (_u *GroupUpdateOne) SetAudioSttPricePerHour(v float64) *GroupUpdateOne {
_u.mutation.ResetAudioSttPricePerHour()
_u.mutation.SetAudioSttPricePerHour(v)
return _u
}
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableAudioSttPricePerHour(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetAudioSttPricePerHour(*v)
}
return _u
}
// AddAudioSttPricePerHour adds value to the "audio_stt_price_per_hour" field.
func (_u *GroupUpdateOne) AddAudioSttPricePerHour(v float64) *GroupUpdateOne {
_u.mutation.AddAudioSttPricePerHour(v)
return _u
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (_u *GroupUpdateOne) ClearAudioSttPricePerHour() *GroupUpdateOne {
_u.mutation.ClearAudioSttPricePerHour()
return _u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_u *GroupUpdateOne) SetLongContextPricingEnabled(v bool) *GroupUpdateOne {
_u.mutation.SetLongContextPricingEnabled(v)
return _u
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetLongContextPricingEnabled(*v)
}
return _u
}
// SetModelPricing sets the "model_pricing" field.
func (_u *GroupUpdateOne) SetModelPricing(v json.RawMessage) *GroupUpdateOne {
_u.mutation.SetModelPricing(v)
return _u
}
// AppendModelPricing appends value to the "model_pricing" field.
func (_u *GroupUpdateOne) AppendModelPricing(v json.RawMessage) *GroupUpdateOne {
_u.mutation.AppendModelPricing(v)
return _u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (_u *GroupUpdateOne) ClearModelPricing() *GroupUpdateOne {
_u.mutation.ClearModelPricing()
return _u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne {
_u.mutation.SetClaudeCodeOnly(v)
@@ -3210,6 +3591,26 @@ func (_u *GroupUpdateOne) check() error {
return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)}
}
}
if v, ok := _u.mutation.SearchPricePer1k(); ok {
if err := group.SearchPricePer1kValidator(v); err != nil {
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
}
}
if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
}
}
if v, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
}
}
if v, ok := _u.mutation.AudioSttPricePerHour(); ok {
if err := group.AudioSttPricePerHourValidator(v); err != nil {
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
}
}
if v, ok := _u.mutation.DefaultMappedModel(); ok {
if err := group.DefaultMappedModelValidator(v); err != nil {
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
@@ -3429,6 +3830,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if _u.mutation.VideoPrice1080pCleared() {
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
}
if value, ok := _u.mutation.VideoModelPrices(); ok {
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
}
if _u.mutation.VideoModelPricesCleared() {
_spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON)
}
if value, ok := _u.mutation.WebSearchPricePerCall(); ok {
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
}
@@ -3438,6 +3845,56 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if _u.mutation.WebSearchPricePerCallCleared() {
_spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64)
}
if value, ok := _u.mutation.SearchPricePer1k(); ok {
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedSearchPricePer1k(); ok {
_spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
}
if _u.mutation.SearchPricePer1kCleared() {
_spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioRealtimePricePerMin(); ok {
_spec.AddField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
}
if _u.mutation.AudioRealtimePricePerMinCleared() {
_spec.ClearField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioTtsPricePerMillionChars(); ok {
_spec.AddField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
}
if _u.mutation.AudioTtsPricePerMillionCharsCleared() {
_spec.ClearField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioSttPricePerHour(); ok {
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioSttPricePerHour(); ok {
_spec.AddField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
}
if _u.mutation.AudioSttPricePerHourCleared() {
_spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64)
}
if value, ok := _u.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedModelPricing(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, group.FieldModelPricing, value)
})
}
if _u.mutation.ModelPricingCleared() {
_spec.ClearField(group.FieldModelPricing, field.TypeJSON)
}
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
+24 -15
View File
@@ -928,7 +928,14 @@ var (
{Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "video_model_prices", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "web_search_price_per_call", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "search_price_per_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "audio_realtime_price_per_min", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "audio_tts_price_per_million_chars", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "audio_stt_price_per_hour", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "long_context_pricing_enabled", Type: field.TypeBool, Default: true},
{Name: "model_pricing", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "claude_code_only", Type: field.TypeBool, Default: false},
{Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true},
{Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true},
@@ -985,7 +992,7 @@ var (
{
Name: "group_sort_order",
Unique: false,
Columns: []*schema.Column{GroupsColumns[42]},
Columns: []*schema.Column{GroupsColumns[49]},
},
{
Name: "idx_groups_duplicate_operation_id_active",
@@ -1621,6 +1628,8 @@ var (
{Name: "model", Type: field.TypeString, Size: 100},
{Name: "requested_model", Type: field.TypeString, Nullable: true, Size: 100},
{Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100},
{Name: "upstream_response_model", Type: field.TypeString, Nullable: true, Size: 200},
{Name: "upstream_model_mismatch", Type: field.TypeBool, Nullable: true},
{Name: "channel_id", Type: field.TypeInt64, Nullable: true},
{Name: "model_mapping_chain", Type: field.TypeString, Nullable: true, Size: 500},
{Name: "billing_tier", Type: field.TypeString, Nullable: true, Size: 50},
@@ -1671,31 +1680,31 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "usage_logs_api_keys_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[41]},
Columns: []*schema.Column{UsageLogsColumns[43]},
RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_accounts_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[42]},
Columns: []*schema.Column{UsageLogsColumns[44]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_groups_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[43]},
Columns: []*schema.Column{UsageLogsColumns[45]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "usage_logs_users_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[44]},
Columns: []*schema.Column{UsageLogsColumns[46]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_user_subscriptions_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[45]},
Columns: []*schema.Column{UsageLogsColumns[47]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull,
},
@@ -1704,32 +1713,32 @@ var (
{
Name: "usagelog_user_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[44]},
Columns: []*schema.Column{UsageLogsColumns[46]},
},
{
Name: "usagelog_api_key_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[41]},
Columns: []*schema.Column{UsageLogsColumns[43]},
},
{
Name: "usagelog_account_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[42]},
Columns: []*schema.Column{UsageLogsColumns[44]},
},
{
Name: "usagelog_group_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[43]},
Columns: []*schema.Column{UsageLogsColumns[45]},
},
{
Name: "usagelog_subscription_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[45]},
Columns: []*schema.Column{UsageLogsColumns[47]},
},
{
Name: "usagelog_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[42]},
},
{
Name: "usagelog_model",
@@ -1749,17 +1758,17 @@ var (
{
Name: "usagelog_user_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[44], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[46], UsageLogsColumns[42]},
},
{
Name: "usagelog_api_key_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[41], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[42]},
},
{
Name: "usagelog_group_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[45], UsageLogsColumns[42]},
},
},
}
+793 -2
View File
@@ -21896,8 +21896,20 @@ type GroupMutation struct {
addvideo_price_720p *float64
video_price_1080p *float64
addvideo_price_1080p *float64
video_model_prices *map[string]map[string]float64
web_search_price_per_call *float64
addweb_search_price_per_call *float64
search_price_per_1k *float64
addsearch_price_per_1k *float64
audio_realtime_price_per_min *float64
addaudio_realtime_price_per_min *float64
audio_tts_price_per_million_chars *float64
addaudio_tts_price_per_million_chars *float64
audio_stt_price_per_hour *float64
addaudio_stt_price_per_hour *float64
long_context_pricing_enabled *bool
model_pricing *json.RawMessage
appendmodel_pricing json.RawMessage
claude_code_only *bool
fallback_group_id *int64
addfallback_group_id *int64
@@ -23722,6 +23734,55 @@ func (m *GroupMutation) ResetVideoPrice1080p() {
delete(m.clearedFields, group.FieldVideoPrice1080p)
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (m *GroupMutation) SetVideoModelPrices(value map[string]map[string]float64) {
m.video_model_prices = &value
}
// VideoModelPrices returns the value of the "video_model_prices" field in the mutation.
func (m *GroupMutation) VideoModelPrices() (r map[string]map[string]float64, exists bool) {
v := m.video_model_prices
if v == nil {
return
}
return *v, true
}
// OldVideoModelPrices returns the old "video_model_prices" 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) OldVideoModelPrices(ctx context.Context) (v map[string]map[string]float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldVideoModelPrices is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldVideoModelPrices requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldVideoModelPrices: %w", err)
}
return oldValue.VideoModelPrices, nil
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (m *GroupMutation) ClearVideoModelPrices() {
m.video_model_prices = nil
m.clearedFields[group.FieldVideoModelPrices] = struct{}{}
}
// VideoModelPricesCleared returns if the "video_model_prices" field was cleared in this mutation.
func (m *GroupMutation) VideoModelPricesCleared() bool {
_, ok := m.clearedFields[group.FieldVideoModelPrices]
return ok
}
// ResetVideoModelPrices resets all changes to the "video_model_prices" field.
func (m *GroupMutation) ResetVideoModelPrices() {
m.video_model_prices = nil
delete(m.clearedFields, group.FieldVideoModelPrices)
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (m *GroupMutation) SetWebSearchPricePerCall(f float64) {
m.web_search_price_per_call = &f
@@ -23792,6 +23853,387 @@ func (m *GroupMutation) ResetWebSearchPricePerCall() {
delete(m.clearedFields, group.FieldWebSearchPricePerCall)
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (m *GroupMutation) SetSearchPricePer1k(f float64) {
m.search_price_per_1k = &f
m.addsearch_price_per_1k = nil
}
// SearchPricePer1k returns the value of the "search_price_per_1k" field in the mutation.
func (m *GroupMutation) SearchPricePer1k() (r float64, exists bool) {
v := m.search_price_per_1k
if v == nil {
return
}
return *v, true
}
// OldSearchPricePer1k returns the old "search_price_per_1k" 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) OldSearchPricePer1k(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldSearchPricePer1k is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldSearchPricePer1k requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldSearchPricePer1k: %w", err)
}
return oldValue.SearchPricePer1k, nil
}
// AddSearchPricePer1k adds f to the "search_price_per_1k" field.
func (m *GroupMutation) AddSearchPricePer1k(f float64) {
if m.addsearch_price_per_1k != nil {
*m.addsearch_price_per_1k += f
} else {
m.addsearch_price_per_1k = &f
}
}
// AddedSearchPricePer1k returns the value that was added to the "search_price_per_1k" field in this mutation.
func (m *GroupMutation) AddedSearchPricePer1k() (r float64, exists bool) {
v := m.addsearch_price_per_1k
if v == nil {
return
}
return *v, true
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (m *GroupMutation) ClearSearchPricePer1k() {
m.search_price_per_1k = nil
m.addsearch_price_per_1k = nil
m.clearedFields[group.FieldSearchPricePer1k] = struct{}{}
}
// SearchPricePer1kCleared returns if the "search_price_per_1k" field was cleared in this mutation.
func (m *GroupMutation) SearchPricePer1kCleared() bool {
_, ok := m.clearedFields[group.FieldSearchPricePer1k]
return ok
}
// ResetSearchPricePer1k resets all changes to the "search_price_per_1k" field.
func (m *GroupMutation) ResetSearchPricePer1k() {
m.search_price_per_1k = nil
m.addsearch_price_per_1k = nil
delete(m.clearedFields, group.FieldSearchPricePer1k)
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (m *GroupMutation) SetAudioRealtimePricePerMin(f float64) {
m.audio_realtime_price_per_min = &f
m.addaudio_realtime_price_per_min = nil
}
// AudioRealtimePricePerMin returns the value of the "audio_realtime_price_per_min" field in the mutation.
func (m *GroupMutation) AudioRealtimePricePerMin() (r float64, exists bool) {
v := m.audio_realtime_price_per_min
if v == nil {
return
}
return *v, true
}
// OldAudioRealtimePricePerMin returns the old "audio_realtime_price_per_min" 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) OldAudioRealtimePricePerMin(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAudioRealtimePricePerMin is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAudioRealtimePricePerMin requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAudioRealtimePricePerMin: %w", err)
}
return oldValue.AudioRealtimePricePerMin, nil
}
// AddAudioRealtimePricePerMin adds f to the "audio_realtime_price_per_min" field.
func (m *GroupMutation) AddAudioRealtimePricePerMin(f float64) {
if m.addaudio_realtime_price_per_min != nil {
*m.addaudio_realtime_price_per_min += f
} else {
m.addaudio_realtime_price_per_min = &f
}
}
// AddedAudioRealtimePricePerMin returns the value that was added to the "audio_realtime_price_per_min" field in this mutation.
func (m *GroupMutation) AddedAudioRealtimePricePerMin() (r float64, exists bool) {
v := m.addaudio_realtime_price_per_min
if v == nil {
return
}
return *v, true
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (m *GroupMutation) ClearAudioRealtimePricePerMin() {
m.audio_realtime_price_per_min = nil
m.addaudio_realtime_price_per_min = nil
m.clearedFields[group.FieldAudioRealtimePricePerMin] = struct{}{}
}
// AudioRealtimePricePerMinCleared returns if the "audio_realtime_price_per_min" field was cleared in this mutation.
func (m *GroupMutation) AudioRealtimePricePerMinCleared() bool {
_, ok := m.clearedFields[group.FieldAudioRealtimePricePerMin]
return ok
}
// ResetAudioRealtimePricePerMin resets all changes to the "audio_realtime_price_per_min" field.
func (m *GroupMutation) ResetAudioRealtimePricePerMin() {
m.audio_realtime_price_per_min = nil
m.addaudio_realtime_price_per_min = nil
delete(m.clearedFields, group.FieldAudioRealtimePricePerMin)
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (m *GroupMutation) SetAudioTtsPricePerMillionChars(f float64) {
m.audio_tts_price_per_million_chars = &f
m.addaudio_tts_price_per_million_chars = nil
}
// AudioTtsPricePerMillionChars returns the value of the "audio_tts_price_per_million_chars" field in the mutation.
func (m *GroupMutation) AudioTtsPricePerMillionChars() (r float64, exists bool) {
v := m.audio_tts_price_per_million_chars
if v == nil {
return
}
return *v, true
}
// OldAudioTtsPricePerMillionChars returns the old "audio_tts_price_per_million_chars" 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) OldAudioTtsPricePerMillionChars(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAudioTtsPricePerMillionChars is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAudioTtsPricePerMillionChars requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAudioTtsPricePerMillionChars: %w", err)
}
return oldValue.AudioTtsPricePerMillionChars, nil
}
// AddAudioTtsPricePerMillionChars adds f to the "audio_tts_price_per_million_chars" field.
func (m *GroupMutation) AddAudioTtsPricePerMillionChars(f float64) {
if m.addaudio_tts_price_per_million_chars != nil {
*m.addaudio_tts_price_per_million_chars += f
} else {
m.addaudio_tts_price_per_million_chars = &f
}
}
// AddedAudioTtsPricePerMillionChars returns the value that was added to the "audio_tts_price_per_million_chars" field in this mutation.
func (m *GroupMutation) AddedAudioTtsPricePerMillionChars() (r float64, exists bool) {
v := m.addaudio_tts_price_per_million_chars
if v == nil {
return
}
return *v, true
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (m *GroupMutation) ClearAudioTtsPricePerMillionChars() {
m.audio_tts_price_per_million_chars = nil
m.addaudio_tts_price_per_million_chars = nil
m.clearedFields[group.FieldAudioTtsPricePerMillionChars] = struct{}{}
}
// AudioTtsPricePerMillionCharsCleared returns if the "audio_tts_price_per_million_chars" field was cleared in this mutation.
func (m *GroupMutation) AudioTtsPricePerMillionCharsCleared() bool {
_, ok := m.clearedFields[group.FieldAudioTtsPricePerMillionChars]
return ok
}
// ResetAudioTtsPricePerMillionChars resets all changes to the "audio_tts_price_per_million_chars" field.
func (m *GroupMutation) ResetAudioTtsPricePerMillionChars() {
m.audio_tts_price_per_million_chars = nil
m.addaudio_tts_price_per_million_chars = nil
delete(m.clearedFields, group.FieldAudioTtsPricePerMillionChars)
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (m *GroupMutation) SetAudioSttPricePerHour(f float64) {
m.audio_stt_price_per_hour = &f
m.addaudio_stt_price_per_hour = nil
}
// AudioSttPricePerHour returns the value of the "audio_stt_price_per_hour" field in the mutation.
func (m *GroupMutation) AudioSttPricePerHour() (r float64, exists bool) {
v := m.audio_stt_price_per_hour
if v == nil {
return
}
return *v, true
}
// OldAudioSttPricePerHour returns the old "audio_stt_price_per_hour" 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) OldAudioSttPricePerHour(ctx context.Context) (v *float64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAudioSttPricePerHour is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAudioSttPricePerHour requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAudioSttPricePerHour: %w", err)
}
return oldValue.AudioSttPricePerHour, nil
}
// AddAudioSttPricePerHour adds f to the "audio_stt_price_per_hour" field.
func (m *GroupMutation) AddAudioSttPricePerHour(f float64) {
if m.addaudio_stt_price_per_hour != nil {
*m.addaudio_stt_price_per_hour += f
} else {
m.addaudio_stt_price_per_hour = &f
}
}
// AddedAudioSttPricePerHour returns the value that was added to the "audio_stt_price_per_hour" field in this mutation.
func (m *GroupMutation) AddedAudioSttPricePerHour() (r float64, exists bool) {
v := m.addaudio_stt_price_per_hour
if v == nil {
return
}
return *v, true
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (m *GroupMutation) ClearAudioSttPricePerHour() {
m.audio_stt_price_per_hour = nil
m.addaudio_stt_price_per_hour = nil
m.clearedFields[group.FieldAudioSttPricePerHour] = struct{}{}
}
// AudioSttPricePerHourCleared returns if the "audio_stt_price_per_hour" field was cleared in this mutation.
func (m *GroupMutation) AudioSttPricePerHourCleared() bool {
_, ok := m.clearedFields[group.FieldAudioSttPricePerHour]
return ok
}
// ResetAudioSttPricePerHour resets all changes to the "audio_stt_price_per_hour" field.
func (m *GroupMutation) ResetAudioSttPricePerHour() {
m.audio_stt_price_per_hour = nil
m.addaudio_stt_price_per_hour = nil
delete(m.clearedFields, group.FieldAudioSttPricePerHour)
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (m *GroupMutation) SetLongContextPricingEnabled(b bool) {
m.long_context_pricing_enabled = &b
}
// LongContextPricingEnabled returns the value of the "long_context_pricing_enabled" field in the mutation.
func (m *GroupMutation) LongContextPricingEnabled() (r bool, exists bool) {
v := m.long_context_pricing_enabled
if v == nil {
return
}
return *v, true
}
// OldLongContextPricingEnabled returns the old "long_context_pricing_enabled" 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) OldLongContextPricingEnabled(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldLongContextPricingEnabled is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldLongContextPricingEnabled requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldLongContextPricingEnabled: %w", err)
}
return oldValue.LongContextPricingEnabled, nil
}
// ResetLongContextPricingEnabled resets all changes to the "long_context_pricing_enabled" field.
func (m *GroupMutation) ResetLongContextPricingEnabled() {
m.long_context_pricing_enabled = nil
}
// SetModelPricing sets the "model_pricing" field.
func (m *GroupMutation) SetModelPricing(jm json.RawMessage) {
m.model_pricing = &jm
m.appendmodel_pricing = nil
}
// ModelPricing returns the value of the "model_pricing" field in the mutation.
func (m *GroupMutation) ModelPricing() (r json.RawMessage, exists bool) {
v := m.model_pricing
if v == nil {
return
}
return *v, true
}
// OldModelPricing returns the old "model_pricing" 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) OldModelPricing(ctx context.Context) (v json.RawMessage, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldModelPricing is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldModelPricing requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldModelPricing: %w", err)
}
return oldValue.ModelPricing, nil
}
// AppendModelPricing adds jm to the "model_pricing" field.
func (m *GroupMutation) AppendModelPricing(jm json.RawMessage) {
m.appendmodel_pricing = append(m.appendmodel_pricing, jm...)
}
// AppendedModelPricing returns the list of values that were appended to the "model_pricing" field in this mutation.
func (m *GroupMutation) AppendedModelPricing() (json.RawMessage, bool) {
if len(m.appendmodel_pricing) == 0 {
return nil, false
}
return m.appendmodel_pricing, true
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (m *GroupMutation) ClearModelPricing() {
m.model_pricing = nil
m.appendmodel_pricing = nil
m.clearedFields[group.FieldModelPricing] = struct{}{}
}
// ModelPricingCleared returns if the "model_pricing" field was cleared in this mutation.
func (m *GroupMutation) ModelPricingCleared() bool {
_, ok := m.clearedFields[group.FieldModelPricing]
return ok
}
// ResetModelPricing resets all changes to the "model_pricing" field.
func (m *GroupMutation) ResetModelPricing() {
m.model_pricing = nil
m.appendmodel_pricing = nil
delete(m.clearedFields, group.FieldModelPricing)
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (m *GroupMutation) SetClaudeCodeOnly(b bool) {
m.claude_code_only = &b
@@ -25097,7 +25539,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, 55)
fields := make([]string, 0, 62)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -25197,9 +25639,30 @@ func (m *GroupMutation) Fields() []string {
if m.video_price_1080p != nil {
fields = append(fields, group.FieldVideoPrice1080p)
}
if m.video_model_prices != nil {
fields = append(fields, group.FieldVideoModelPrices)
}
if m.web_search_price_per_call != nil {
fields = append(fields, group.FieldWebSearchPricePerCall)
}
if m.search_price_per_1k != nil {
fields = append(fields, group.FieldSearchPricePer1k)
}
if m.audio_realtime_price_per_min != nil {
fields = append(fields, group.FieldAudioRealtimePricePerMin)
}
if m.audio_tts_price_per_million_chars != nil {
fields = append(fields, group.FieldAudioTtsPricePerMillionChars)
}
if m.audio_stt_price_per_hour != nil {
fields = append(fields, group.FieldAudioSttPricePerHour)
}
if m.long_context_pricing_enabled != nil {
fields = append(fields, group.FieldLongContextPricingEnabled)
}
if m.model_pricing != nil {
fields = append(fields, group.FieldModelPricing)
}
if m.claude_code_only != nil {
fields = append(fields, group.FieldClaudeCodeOnly)
}
@@ -25337,8 +25800,22 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.VideoPrice720p()
case group.FieldVideoPrice1080p:
return m.VideoPrice1080p()
case group.FieldVideoModelPrices:
return m.VideoModelPrices()
case group.FieldWebSearchPricePerCall:
return m.WebSearchPricePerCall()
case group.FieldSearchPricePer1k:
return m.SearchPricePer1k()
case group.FieldAudioRealtimePricePerMin:
return m.AudioRealtimePricePerMin()
case group.FieldAudioTtsPricePerMillionChars:
return m.AudioTtsPricePerMillionChars()
case group.FieldAudioSttPricePerHour:
return m.AudioSttPricePerHour()
case group.FieldLongContextPricingEnabled:
return m.LongContextPricingEnabled()
case group.FieldModelPricing:
return m.ModelPricing()
case group.FieldClaudeCodeOnly:
return m.ClaudeCodeOnly()
case group.FieldFallbackGroupID:
@@ -25456,8 +25933,22 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldVideoPrice720p(ctx)
case group.FieldVideoPrice1080p:
return m.OldVideoPrice1080p(ctx)
case group.FieldVideoModelPrices:
return m.OldVideoModelPrices(ctx)
case group.FieldWebSearchPricePerCall:
return m.OldWebSearchPricePerCall(ctx)
case group.FieldSearchPricePer1k:
return m.OldSearchPricePer1k(ctx)
case group.FieldAudioRealtimePricePerMin:
return m.OldAudioRealtimePricePerMin(ctx)
case group.FieldAudioTtsPricePerMillionChars:
return m.OldAudioTtsPricePerMillionChars(ctx)
case group.FieldAudioSttPricePerHour:
return m.OldAudioSttPricePerHour(ctx)
case group.FieldLongContextPricingEnabled:
return m.OldLongContextPricingEnabled(ctx)
case group.FieldModelPricing:
return m.OldModelPricing(ctx)
case group.FieldClaudeCodeOnly:
return m.OldClaudeCodeOnly(ctx)
case group.FieldFallbackGroupID:
@@ -25740,6 +26231,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetVideoPrice1080p(v)
return nil
case group.FieldVideoModelPrices:
v, ok := value.(map[string]map[string]float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetVideoModelPrices(v)
return nil
case group.FieldWebSearchPricePerCall:
v, ok := value.(float64)
if !ok {
@@ -25747,6 +26245,48 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetWebSearchPricePerCall(v)
return nil
case group.FieldSearchPricePer1k:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetSearchPricePer1k(v)
return nil
case group.FieldAudioRealtimePricePerMin:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAudioRealtimePricePerMin(v)
return nil
case group.FieldAudioTtsPricePerMillionChars:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAudioTtsPricePerMillionChars(v)
return nil
case group.FieldAudioSttPricePerHour:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAudioSttPricePerHour(v)
return nil
case group.FieldLongContextPricingEnabled:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetLongContextPricingEnabled(v)
return nil
case group.FieldModelPricing:
v, ok := value.(json.RawMessage)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetModelPricing(v)
return nil
case group.FieldClaudeCodeOnly:
v, ok := value.(bool)
if !ok {
@@ -25953,6 +26493,18 @@ func (m *GroupMutation) AddedFields() []string {
if m.addweb_search_price_per_call != nil {
fields = append(fields, group.FieldWebSearchPricePerCall)
}
if m.addsearch_price_per_1k != nil {
fields = append(fields, group.FieldSearchPricePer1k)
}
if m.addaudio_realtime_price_per_min != nil {
fields = append(fields, group.FieldAudioRealtimePricePerMin)
}
if m.addaudio_tts_price_per_million_chars != nil {
fields = append(fields, group.FieldAudioTtsPricePerMillionChars)
}
if m.addaudio_stt_price_per_hour != nil {
fields = append(fields, group.FieldAudioSttPricePerHour)
}
if m.addfallback_group_id != nil {
fields = append(fields, group.FieldFallbackGroupID)
}
@@ -26013,6 +26565,14 @@ func (m *GroupMutation) AddedField(name string) (ent.Value, bool) {
return m.AddedVideoPrice1080p()
case group.FieldWebSearchPricePerCall:
return m.AddedWebSearchPricePerCall()
case group.FieldSearchPricePer1k:
return m.AddedSearchPricePer1k()
case group.FieldAudioRealtimePricePerMin:
return m.AddedAudioRealtimePricePerMin()
case group.FieldAudioTtsPricePerMillionChars:
return m.AddedAudioTtsPricePerMillionChars()
case group.FieldAudioSttPricePerHour:
return m.AddedAudioSttPricePerHour()
case group.FieldFallbackGroupID:
return m.AddedFallbackGroupID()
case group.FieldFallbackGroupIDOnInvalidRequest:
@@ -26153,6 +26713,34 @@ func (m *GroupMutation) AddField(name string, value ent.Value) error {
}
m.AddWebSearchPricePerCall(v)
return nil
case group.FieldSearchPricePer1k:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddSearchPricePer1k(v)
return nil
case group.FieldAudioRealtimePricePerMin:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddAudioRealtimePricePerMin(v)
return nil
case group.FieldAudioTtsPricePerMillionChars:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddAudioTtsPricePerMillionChars(v)
return nil
case group.FieldAudioSttPricePerHour:
v, ok := value.(float64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddAudioSttPricePerHour(v)
return nil
case group.FieldFallbackGroupID:
v, ok := value.(int64)
if !ok {
@@ -26239,9 +26827,27 @@ func (m *GroupMutation) ClearedFields() []string {
if m.FieldCleared(group.FieldVideoPrice1080p) {
fields = append(fields, group.FieldVideoPrice1080p)
}
if m.FieldCleared(group.FieldVideoModelPrices) {
fields = append(fields, group.FieldVideoModelPrices)
}
if m.FieldCleared(group.FieldWebSearchPricePerCall) {
fields = append(fields, group.FieldWebSearchPricePerCall)
}
if m.FieldCleared(group.FieldSearchPricePer1k) {
fields = append(fields, group.FieldSearchPricePer1k)
}
if m.FieldCleared(group.FieldAudioRealtimePricePerMin) {
fields = append(fields, group.FieldAudioRealtimePricePerMin)
}
if m.FieldCleared(group.FieldAudioTtsPricePerMillionChars) {
fields = append(fields, group.FieldAudioTtsPricePerMillionChars)
}
if m.FieldCleared(group.FieldAudioSttPricePerHour) {
fields = append(fields, group.FieldAudioSttPricePerHour)
}
if m.FieldCleared(group.FieldModelPricing) {
fields = append(fields, group.FieldModelPricing)
}
if m.FieldCleared(group.FieldFallbackGroupID) {
fields = append(fields, group.FieldFallbackGroupID)
}
@@ -26301,9 +26907,27 @@ func (m *GroupMutation) ClearField(name string) error {
case group.FieldVideoPrice1080p:
m.ClearVideoPrice1080p()
return nil
case group.FieldVideoModelPrices:
m.ClearVideoModelPrices()
return nil
case group.FieldWebSearchPricePerCall:
m.ClearWebSearchPricePerCall()
return nil
case group.FieldSearchPricePer1k:
m.ClearSearchPricePer1k()
return nil
case group.FieldAudioRealtimePricePerMin:
m.ClearAudioRealtimePricePerMin()
return nil
case group.FieldAudioTtsPricePerMillionChars:
m.ClearAudioTtsPricePerMillionChars()
return nil
case group.FieldAudioSttPricePerHour:
m.ClearAudioSttPricePerHour()
return nil
case group.FieldModelPricing:
m.ClearModelPricing()
return nil
case group.FieldFallbackGroupID:
m.ClearFallbackGroupID()
return nil
@@ -26420,9 +27044,30 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldVideoPrice1080p:
m.ResetVideoPrice1080p()
return nil
case group.FieldVideoModelPrices:
m.ResetVideoModelPrices()
return nil
case group.FieldWebSearchPricePerCall:
m.ResetWebSearchPricePerCall()
return nil
case group.FieldSearchPricePer1k:
m.ResetSearchPricePer1k()
return nil
case group.FieldAudioRealtimePricePerMin:
m.ResetAudioRealtimePricePerMin()
return nil
case group.FieldAudioTtsPricePerMillionChars:
m.ResetAudioTtsPricePerMillionChars()
return nil
case group.FieldAudioSttPricePerHour:
m.ResetAudioSttPricePerHour()
return nil
case group.FieldLongContextPricingEnabled:
m.ResetLongContextPricingEnabled()
return nil
case group.FieldModelPricing:
m.ResetModelPricing()
return nil
case group.FieldClaudeCodeOnly:
m.ResetClaudeCodeOnly()
return nil
@@ -43356,6 +44001,8 @@ type UsageLogMutation struct {
model *string
requested_model *string
upstream_model *string
upstream_response_model *string
upstream_model_mismatch *bool
channel_id *int64
addchannel_id *int64
model_mapping_chain *string
@@ -43805,6 +44452,104 @@ func (m *UsageLogMutation) ResetUpstreamModel() {
delete(m.clearedFields, usagelog.FieldUpstreamModel)
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (m *UsageLogMutation) SetUpstreamResponseModel(s string) {
m.upstream_response_model = &s
}
// UpstreamResponseModel returns the value of the "upstream_response_model" field in the mutation.
func (m *UsageLogMutation) UpstreamResponseModel() (r string, exists bool) {
v := m.upstream_response_model
if v == nil {
return
}
return *v, true
}
// OldUpstreamResponseModel returns the old "upstream_response_model" field's value of the UsageLog entity.
// If the UsageLog 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 *UsageLogMutation) OldUpstreamResponseModel(ctx context.Context) (v *string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUpstreamResponseModel is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldUpstreamResponseModel requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldUpstreamResponseModel: %w", err)
}
return oldValue.UpstreamResponseModel, nil
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (m *UsageLogMutation) ClearUpstreamResponseModel() {
m.upstream_response_model = nil
m.clearedFields[usagelog.FieldUpstreamResponseModel] = struct{}{}
}
// UpstreamResponseModelCleared returns if the "upstream_response_model" field was cleared in this mutation.
func (m *UsageLogMutation) UpstreamResponseModelCleared() bool {
_, ok := m.clearedFields[usagelog.FieldUpstreamResponseModel]
return ok
}
// ResetUpstreamResponseModel resets all changes to the "upstream_response_model" field.
func (m *UsageLogMutation) ResetUpstreamResponseModel() {
m.upstream_response_model = nil
delete(m.clearedFields, usagelog.FieldUpstreamResponseModel)
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (m *UsageLogMutation) SetUpstreamModelMismatch(b bool) {
m.upstream_model_mismatch = &b
}
// UpstreamModelMismatch returns the value of the "upstream_model_mismatch" field in the mutation.
func (m *UsageLogMutation) UpstreamModelMismatch() (r bool, exists bool) {
v := m.upstream_model_mismatch
if v == nil {
return
}
return *v, true
}
// OldUpstreamModelMismatch returns the old "upstream_model_mismatch" field's value of the UsageLog entity.
// If the UsageLog 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 *UsageLogMutation) OldUpstreamModelMismatch(ctx context.Context) (v *bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldUpstreamModelMismatch is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldUpstreamModelMismatch requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldUpstreamModelMismatch: %w", err)
}
return oldValue.UpstreamModelMismatch, nil
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (m *UsageLogMutation) ClearUpstreamModelMismatch() {
m.upstream_model_mismatch = nil
m.clearedFields[usagelog.FieldUpstreamModelMismatch] = struct{}{}
}
// UpstreamModelMismatchCleared returns if the "upstream_model_mismatch" field was cleared in this mutation.
func (m *UsageLogMutation) UpstreamModelMismatchCleared() bool {
_, ok := m.clearedFields[usagelog.FieldUpstreamModelMismatch]
return ok
}
// ResetUpstreamModelMismatch resets all changes to the "upstream_model_mismatch" field.
func (m *UsageLogMutation) ResetUpstreamModelMismatch() {
m.upstream_model_mismatch = nil
delete(m.clearedFields, usagelog.FieldUpstreamModelMismatch)
}
// SetChannelID sets the "channel_id" field.
func (m *UsageLogMutation) SetChannelID(i int64) {
m.channel_id = &i
@@ -46001,7 +46746,7 @@ func (m *UsageLogMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *UsageLogMutation) Fields() []string {
fields := make([]string, 0, 45)
fields := make([]string, 0, 47)
if m.user != nil {
fields = append(fields, usagelog.FieldUserID)
}
@@ -46023,6 +46768,12 @@ func (m *UsageLogMutation) Fields() []string {
if m.upstream_model != nil {
fields = append(fields, usagelog.FieldUpstreamModel)
}
if m.upstream_response_model != nil {
fields = append(fields, usagelog.FieldUpstreamResponseModel)
}
if m.upstream_model_mismatch != nil {
fields = append(fields, usagelog.FieldUpstreamModelMismatch)
}
if m.channel_id != nil {
fields = append(fields, usagelog.FieldChannelID)
}
@@ -46159,6 +46910,10 @@ func (m *UsageLogMutation) Field(name string) (ent.Value, bool) {
return m.RequestedModel()
case usagelog.FieldUpstreamModel:
return m.UpstreamModel()
case usagelog.FieldUpstreamResponseModel:
return m.UpstreamResponseModel()
case usagelog.FieldUpstreamModelMismatch:
return m.UpstreamModelMismatch()
case usagelog.FieldChannelID:
return m.ChannelID()
case usagelog.FieldModelMappingChain:
@@ -46258,6 +47013,10 @@ func (m *UsageLogMutation) OldField(ctx context.Context, name string) (ent.Value
return m.OldRequestedModel(ctx)
case usagelog.FieldUpstreamModel:
return m.OldUpstreamModel(ctx)
case usagelog.FieldUpstreamResponseModel:
return m.OldUpstreamResponseModel(ctx)
case usagelog.FieldUpstreamModelMismatch:
return m.OldUpstreamModelMismatch(ctx)
case usagelog.FieldChannelID:
return m.OldChannelID(ctx)
case usagelog.FieldModelMappingChain:
@@ -46392,6 +47151,20 @@ func (m *UsageLogMutation) SetField(name string, value ent.Value) error {
}
m.SetUpstreamModel(v)
return nil
case usagelog.FieldUpstreamResponseModel:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetUpstreamResponseModel(v)
return nil
case usagelog.FieldUpstreamModelMismatch:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetUpstreamModelMismatch(v)
return nil
case usagelog.FieldChannelID:
v, ok := value.(int64)
if !ok {
@@ -46949,6 +47722,12 @@ func (m *UsageLogMutation) ClearedFields() []string {
if m.FieldCleared(usagelog.FieldUpstreamModel) {
fields = append(fields, usagelog.FieldUpstreamModel)
}
if m.FieldCleared(usagelog.FieldUpstreamResponseModel) {
fields = append(fields, usagelog.FieldUpstreamResponseModel)
}
if m.FieldCleared(usagelog.FieldUpstreamModelMismatch) {
fields = append(fields, usagelog.FieldUpstreamModelMismatch)
}
if m.FieldCleared(usagelog.FieldChannelID) {
fields = append(fields, usagelog.FieldChannelID)
}
@@ -47023,6 +47802,12 @@ func (m *UsageLogMutation) ClearField(name string) error {
case usagelog.FieldUpstreamModel:
m.ClearUpstreamModel()
return nil
case usagelog.FieldUpstreamResponseModel:
m.ClearUpstreamResponseModel()
return nil
case usagelog.FieldUpstreamModelMismatch:
m.ClearUpstreamModelMismatch()
return nil
case usagelog.FieldChannelID:
m.ClearChannelID()
return nil
@@ -47106,6 +47891,12 @@ func (m *UsageLogMutation) ResetField(name string) error {
case usagelog.FieldUpstreamModel:
m.ResetUpstreamModel()
return nil
case usagelog.FieldUpstreamResponseModel:
m.ResetUpstreamResponseModel()
return nil
case usagelog.FieldUpstreamModelMismatch:
m.ResetUpstreamModelMismatch()
return nil
case usagelog.FieldChannelID:
m.ResetChannelID()
return nil
+72 -48
View File
@@ -1117,80 +1117,100 @@ func init() {
groupDescVideoRateMultiplier := groupFields[26].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()
// 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()
// 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()
// 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()
// 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()
// 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[31].Descriptor()
groupDescClaudeCodeOnly := groupFields[38].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[35].Descriptor()
groupDescModelRoutingEnabled := groupFields[42].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[36].Descriptor()
groupDescMcpXMLInject := groupFields[43].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[37].Descriptor()
groupDescSupportedModelScopes := groupFields[44].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[38].Descriptor()
groupDescSortOrder := groupFields[45].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[39].Descriptor()
groupDescAllowMessagesDispatch := groupFields[46].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[40].Descriptor()
groupDescAllowLive := groupFields[47].Descriptor()
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[41].Descriptor()
groupDescRequireOauthOnly := groupFields[48].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[42].Descriptor()
groupDescRequirePrivacySet := groupFields[49].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[43].Descriptor()
groupDescDefaultMappedModel := groupFields[50].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[44].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[51].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
groupDescModelsListConfig := groupFields[45].Descriptor()
groupDescModelsListConfig := groupFields[52].Descriptor()
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
groupDescRpmLimit := groupFields[46].Descriptor()
groupDescRpmLimit := groupFields[53].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[47].Descriptor()
groupDescMaxReasoningEffort := groupFields[54].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)
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
groupDescReasoningEffortMappings := groupFields[48].Descriptor()
groupDescReasoningEffortMappings := groupFields[55].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[49].Descriptor()
groupDescProfitControlEnabled := groupFields[56].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[50].Descriptor()
groupDescProfitMinMargin := groupFields[57].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[51].Descriptor()
groupDescProfitSafetyBuffer := groupFields[58].Descriptor()
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
@@ -1982,124 +2002,128 @@ func init() {
usagelogDescUpstreamModel := usagelogFields[6].Descriptor()
// usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error)
// usagelogDescUpstreamResponseModel is the schema descriptor for upstream_response_model field.
usagelogDescUpstreamResponseModel := usagelogFields[7].Descriptor()
// usagelog.UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
usagelog.UpstreamResponseModelValidator = usagelogDescUpstreamResponseModel.Validators[0].(func(string) error)
// usagelogDescModelMappingChain is the schema descriptor for model_mapping_chain field.
usagelogDescModelMappingChain := usagelogFields[8].Descriptor()
usagelogDescModelMappingChain := usagelogFields[10].Descriptor()
// usagelog.ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
usagelog.ModelMappingChainValidator = usagelogDescModelMappingChain.Validators[0].(func(string) error)
// usagelogDescBillingTier is the schema descriptor for billing_tier field.
usagelogDescBillingTier := usagelogFields[9].Descriptor()
usagelogDescBillingTier := usagelogFields[11].Descriptor()
// usagelog.BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
usagelog.BillingTierValidator = usagelogDescBillingTier.Validators[0].(func(string) error)
// usagelogDescBillingMode is the schema descriptor for billing_mode field.
usagelogDescBillingMode := usagelogFields[10].Descriptor()
usagelogDescBillingMode := usagelogFields[12].Descriptor()
// usagelog.BillingModeValidator is a validator for the "billing_mode" field. It is called by the builders before save.
usagelog.BillingModeValidator = usagelogDescBillingMode.Validators[0].(func(string) error)
// usagelogDescInputTokens is the schema descriptor for input_tokens field.
usagelogDescInputTokens := usagelogFields[13].Descriptor()
usagelogDescInputTokens := usagelogFields[15].Descriptor()
// usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field.
usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int)
// usagelogDescOutputTokens is the schema descriptor for output_tokens field.
usagelogDescOutputTokens := usagelogFields[14].Descriptor()
usagelogDescOutputTokens := usagelogFields[16].Descriptor()
// usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field.
usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int)
// usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field.
usagelogDescCacheCreationTokens := usagelogFields[15].Descriptor()
usagelogDescCacheCreationTokens := usagelogFields[17].Descriptor()
// usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field.
usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int)
// usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field.
usagelogDescCacheReadTokens := usagelogFields[16].Descriptor()
usagelogDescCacheReadTokens := usagelogFields[18].Descriptor()
// usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field.
usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int)
// usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field.
usagelogDescCacheCreation5mTokens := usagelogFields[17].Descriptor()
usagelogDescCacheCreation5mTokens := usagelogFields[19].Descriptor()
// usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field.
usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int)
// usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field.
usagelogDescCacheCreation1hTokens := usagelogFields[18].Descriptor()
usagelogDescCacheCreation1hTokens := usagelogFields[20].Descriptor()
// usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field.
usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int)
// usagelogDescInputCost is the schema descriptor for input_cost field.
usagelogDescInputCost := usagelogFields[19].Descriptor()
usagelogDescInputCost := usagelogFields[21].Descriptor()
// usagelog.DefaultInputCost holds the default value on creation for the input_cost field.
usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64)
// usagelogDescOutputCost is the schema descriptor for output_cost field.
usagelogDescOutputCost := usagelogFields[20].Descriptor()
usagelogDescOutputCost := usagelogFields[22].Descriptor()
// usagelog.DefaultOutputCost holds the default value on creation for the output_cost field.
usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64)
// usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field.
usagelogDescCacheCreationCost := usagelogFields[21].Descriptor()
usagelogDescCacheCreationCost := usagelogFields[23].Descriptor()
// usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field.
usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64)
// usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field.
usagelogDescCacheReadCost := usagelogFields[22].Descriptor()
usagelogDescCacheReadCost := usagelogFields[24].Descriptor()
// usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field.
usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64)
// usagelogDescTotalCost is the schema descriptor for total_cost field.
usagelogDescTotalCost := usagelogFields[23].Descriptor()
usagelogDescTotalCost := usagelogFields[25].Descriptor()
// usagelog.DefaultTotalCost holds the default value on creation for the total_cost field.
usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64)
// usagelogDescActualCost is the schema descriptor for actual_cost field.
usagelogDescActualCost := usagelogFields[24].Descriptor()
usagelogDescActualCost := usagelogFields[26].Descriptor()
// usagelog.DefaultActualCost holds the default value on creation for the actual_cost field.
usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64)
// usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field.
usagelogDescRateMultiplier := usagelogFields[25].Descriptor()
usagelogDescRateMultiplier := usagelogFields[27].Descriptor()
// usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64)
// usagelogDescLongContextBillingApplied is the schema descriptor for long_context_billing_applied field.
usagelogDescLongContextBillingApplied := usagelogFields[26].Descriptor()
usagelogDescLongContextBillingApplied := usagelogFields[28].Descriptor()
// usagelog.DefaultLongContextBillingApplied holds the default value on creation for the long_context_billing_applied field.
usagelog.DefaultLongContextBillingApplied = usagelogDescLongContextBillingApplied.Default.(bool)
// usagelogDescBillingType is the schema descriptor for billing_type field.
usagelogDescBillingType := usagelogFields[28].Descriptor()
usagelogDescBillingType := usagelogFields[30].Descriptor()
// usagelog.DefaultBillingType holds the default value on creation for the billing_type field.
usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8)
// usagelogDescStream is the schema descriptor for stream field.
usagelogDescStream := usagelogFields[29].Descriptor()
usagelogDescStream := usagelogFields[31].Descriptor()
// usagelog.DefaultStream holds the default value on creation for the stream field.
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
// usagelogDescUserAgent is the schema descriptor for user_agent field.
usagelogDescUserAgent := usagelogFields[32].Descriptor()
usagelogDescUserAgent := usagelogFields[34].Descriptor()
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
// usagelogDescIPAddress is the schema descriptor for ip_address field.
usagelogDescIPAddress := usagelogFields[33].Descriptor()
usagelogDescIPAddress := usagelogFields[35].Descriptor()
// usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save.
usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error)
// usagelogDescImageCount is the schema descriptor for image_count field.
usagelogDescImageCount := usagelogFields[34].Descriptor()
usagelogDescImageCount := usagelogFields[36].Descriptor()
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
// usagelogDescImageSize is the schema descriptor for image_size field.
usagelogDescImageSize := usagelogFields[35].Descriptor()
usagelogDescImageSize := usagelogFields[37].Descriptor()
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
// usagelogDescImageInputSize is the schema descriptor for image_input_size field.
usagelogDescImageInputSize := usagelogFields[36].Descriptor()
usagelogDescImageInputSize := usagelogFields[38].Descriptor()
// usagelog.ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save.
usagelog.ImageInputSizeValidator = usagelogDescImageInputSize.Validators[0].(func(string) error)
// usagelogDescImageOutputSize is the schema descriptor for image_output_size field.
usagelogDescImageOutputSize := usagelogFields[37].Descriptor()
usagelogDescImageOutputSize := usagelogFields[39].Descriptor()
// usagelog.ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save.
usagelog.ImageOutputSizeValidator = usagelogDescImageOutputSize.Validators[0].(func(string) error)
// usagelogDescImageSizeSource is the schema descriptor for image_size_source field.
usagelogDescImageSizeSource := usagelogFields[38].Descriptor()
usagelogDescImageSizeSource := usagelogFields[40].Descriptor()
// usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save.
usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error)
// usagelogDescVideoCount is the schema descriptor for video_count field.
usagelogDescVideoCount := usagelogFields[40].Descriptor()
usagelogDescVideoCount := usagelogFields[42].Descriptor()
// usagelog.DefaultVideoCount holds the default value on creation for the video_count field.
usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int)
// usagelogDescVideoResolution is the schema descriptor for video_resolution field.
usagelogDescVideoResolution := usagelogFields[41].Descriptor()
usagelogDescVideoResolution := usagelogFields[43].Descriptor()
// usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save.
usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error)
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
usagelogDescCacheTTLOverridden := usagelogFields[43].Descriptor()
usagelogDescCacheTTLOverridden := usagelogFields[45].Descriptor()
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
// usagelogDescCreatedAt is the schema descriptor for created_at field.
usagelogDescCreatedAt := usagelogFields[44].Descriptor()
usagelogDescCreatedAt := usagelogFields[46].Descriptor()
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
userMixin := schema.User{}.Mixin()
+41
View File
@@ -1,6 +1,8 @@
package schema
import (
"encoding/json"
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/internal/domain"
@@ -148,12 +150,51 @@ func (Group) Fields() []ent.Field {
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
field.JSON("video_model_prices", map[string]map[string]float64{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("按模型族和分辨率覆盖视频每秒价格"),
field.Float("web_search_price_per_call").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)"),
// 搜索/工具调用显式定价(per 1k calls),用于 Grok web_search 等。
field.Float("search_price_per_1k").
Optional().
Nillable().
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("搜索工具价格 per 1000 calls(web_search 等)"),
// Grok Voice 显式定价(realtime / TTS / STT),不按文本 RateMultiplier。
field.Float("audio_realtime_price_per_min").
Optional().
Nillable().
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("Voice realtime 每分钟价格(USD)"),
field.Float("audio_tts_price_per_million_chars").
Optional().
Nillable().
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("TTS 每百万字符价格(USD)"),
field.Float("audio_stt_price_per_hour").
Optional().
Nillable().
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("STT 每小时价格(USD)"),
field.Bool("long_context_pricing_enabled").
Default(true).
Comment("是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价"),
field.JSON("model_pricing", json.RawMessage{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("分组逐模型定价;优先级高于渠道和内置定价"),
// Claude Code 客户端限制 (added by migration 029)
field.Bool("claude_code_only").
Default(false).
+11
View File
@@ -53,6 +53,17 @@ func (UsageLog) Fields() []ent.Field {
MaxLen(100).
Optional().
Nillable(),
// UpstreamResponseModel stores the model name declared by the upstream
// response before any protocol conversion or client-facing rewrite.
field.String("upstream_response_model").
MaxLen(200).
Optional().
Nillable(),
// UpstreamModelMismatch is tri-state: NULL means the upstream response did
// not declare a model (or predates this field); false/true means observed.
field.Bool("upstream_model_mismatch").
Optional().
Nillable(),
field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"),
field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"),
field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"),
+30 -2
View File
@@ -37,6 +37,10 @@ type UsageLog struct {
RequestedModel *string `json:"requested_model,omitempty"`
// UpstreamModel holds the value of the "upstream_model" field.
UpstreamModel *string `json:"upstream_model,omitempty"`
// UpstreamResponseModel holds the value of the "upstream_response_model" field.
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
// UpstreamModelMismatch holds the value of the "upstream_model_mismatch" field.
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
// 渠道 ID
ChannelID *int64 `json:"channel_id,omitempty"`
// 模型映射链
@@ -198,13 +202,13 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case usagelog.FieldImageSizeBreakdown:
values[i] = new([]byte)
case usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
case usagelog.FieldUpstreamModelMismatch, usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
values[i] = new(sql.NullBool)
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
values[i] = new(sql.NullFloat64)
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds:
values[i] = new(sql.NullInt64)
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldUpstreamResponseModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime)
@@ -273,6 +277,20 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
_m.UpstreamModel = new(string)
*_m.UpstreamModel = value.String
}
case usagelog.FieldUpstreamResponseModel:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field upstream_response_model", values[i])
} else if value.Valid {
_m.UpstreamResponseModel = new(string)
*_m.UpstreamResponseModel = value.String
}
case usagelog.FieldUpstreamModelMismatch:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field upstream_model_mismatch", values[i])
} else if value.Valid {
_m.UpstreamModelMismatch = new(bool)
*_m.UpstreamModelMismatch = value.Bool
}
case usagelog.FieldChannelID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field channel_id", values[i])
@@ -606,6 +624,16 @@ func (_m *UsageLog) String() string {
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.UpstreamResponseModel; v != nil {
builder.WriteString("upstream_response_model=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.UpstreamModelMismatch; v != nil {
builder.WriteString("upstream_model_mismatch=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.ChannelID; v != nil {
builder.WriteString("channel_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
+18
View File
@@ -28,6 +28,10 @@ const (
FieldRequestedModel = "requested_model"
// FieldUpstreamModel holds the string denoting the upstream_model field in the database.
FieldUpstreamModel = "upstream_model"
// FieldUpstreamResponseModel holds the string denoting the upstream_response_model field in the database.
FieldUpstreamResponseModel = "upstream_response_model"
// FieldUpstreamModelMismatch holds the string denoting the upstream_model_mismatch field in the database.
FieldUpstreamModelMismatch = "upstream_model_mismatch"
// FieldChannelID holds the string denoting the channel_id field in the database.
FieldChannelID = "channel_id"
// FieldModelMappingChain holds the string denoting the model_mapping_chain field in the database.
@@ -163,6 +167,8 @@ var Columns = []string{
FieldModel,
FieldRequestedModel,
FieldUpstreamModel,
FieldUpstreamResponseModel,
FieldUpstreamModelMismatch,
FieldChannelID,
FieldModelMappingChain,
FieldBillingTier,
@@ -222,6 +228,8 @@ var (
RequestedModelValidator func(string) error
// UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
UpstreamModelValidator func(string) error
// UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
UpstreamResponseModelValidator func(string) error
// ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
ModelMappingChainValidator func(string) error
// BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
@@ -327,6 +335,16 @@ func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc()
}
// ByUpstreamResponseModel orders the results by the upstream_response_model field.
func ByUpstreamResponseModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamResponseModel, opts...).ToFunc()
}
// ByUpstreamModelMismatch orders the results by the upstream_model_mismatch field.
func ByUpstreamModelMismatch(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamModelMismatch, opts...).ToFunc()
}
// ByChannelID orders the results by the channel_id field.
func ByChannelID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldChannelID, opts...).ToFunc()
+105
View File
@@ -90,6 +90,16 @@ func UpstreamModel(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v))
}
// UpstreamResponseModel applies equality check predicate on the "upstream_response_model" field. It's identical to UpstreamResponseModelEQ.
func UpstreamResponseModel(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
}
// UpstreamModelMismatch applies equality check predicate on the "upstream_model_mismatch" field. It's identical to UpstreamModelMismatchEQ.
func UpstreamModelMismatch(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
}
// ChannelID applies equality check predicate on the "channel_id" field. It's identical to ChannelIDEQ.
func ChannelID(v int64) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
@@ -615,6 +625,101 @@ func UpstreamModelContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v))
}
// UpstreamResponseModelEQ applies the EQ predicate on the "upstream_response_model" field.
func UpstreamResponseModelEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelNEQ applies the NEQ predicate on the "upstream_response_model" field.
func UpstreamResponseModelNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelIn applies the In predicate on the "upstream_response_model" field.
func UpstreamResponseModelIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldUpstreamResponseModel, vs...))
}
// UpstreamResponseModelNotIn applies the NotIn predicate on the "upstream_response_model" field.
func UpstreamResponseModelNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldUpstreamResponseModel, vs...))
}
// UpstreamResponseModelGT applies the GT predicate on the "upstream_response_model" field.
func UpstreamResponseModelGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelGTE applies the GTE predicate on the "upstream_response_model" field.
func UpstreamResponseModelGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelLT applies the LT predicate on the "upstream_response_model" field.
func UpstreamResponseModelLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelLTE applies the LTE predicate on the "upstream_response_model" field.
func UpstreamResponseModelLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelContains applies the Contains predicate on the "upstream_response_model" field.
func UpstreamResponseModelContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelHasPrefix applies the HasPrefix predicate on the "upstream_response_model" field.
func UpstreamResponseModelHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelHasSuffix applies the HasSuffix predicate on the "upstream_response_model" field.
func UpstreamResponseModelHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelIsNil applies the IsNil predicate on the "upstream_response_model" field.
func UpstreamResponseModelIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamResponseModel))
}
// UpstreamResponseModelNotNil applies the NotNil predicate on the "upstream_response_model" field.
func UpstreamResponseModelNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamResponseModel))
}
// UpstreamResponseModelEqualFold applies the EqualFold predicate on the "upstream_response_model" field.
func UpstreamResponseModelEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelContainsFold applies the ContainsFold predicate on the "upstream_response_model" field.
func UpstreamResponseModelContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamResponseModel, v))
}
// UpstreamModelMismatchEQ applies the EQ predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchEQ(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
}
// UpstreamModelMismatchNEQ applies the NEQ predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchNEQ(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamModelMismatch, v))
}
// UpstreamModelMismatchIsNil applies the IsNil predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamModelMismatch))
}
// UpstreamModelMismatchNotNil applies the NotNil predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamModelMismatch))
}
// ChannelIDEQ applies the EQ predicate on the "channel_id" field.
func ChannelIDEQ(v int64) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
+161
View File
@@ -85,6 +85,34 @@ func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate {
return _c
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_c *UsageLogCreate) SetUpstreamResponseModel(v string) *UsageLogCreate {
_c.mutation.SetUpstreamResponseModel(v)
return _c
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableUpstreamResponseModel(v *string) *UsageLogCreate {
if v != nil {
_c.SetUpstreamResponseModel(*v)
}
return _c
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_c *UsageLogCreate) SetUpstreamModelMismatch(v bool) *UsageLogCreate {
_c.mutation.SetUpstreamModelMismatch(v)
return _c
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogCreate {
if v != nil {
_c.SetUpstreamModelMismatch(*v)
}
return _c
}
// SetChannelID sets the "channel_id" field.
func (_c *UsageLogCreate) SetChannelID(v int64) *UsageLogCreate {
_c.mutation.SetChannelID(v)
@@ -788,6 +816,11 @@ func (_c *UsageLogCreate) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _c.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _c.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -950,6 +983,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value)
_node.UpstreamModel = &value
}
if value, ok := _c.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
_node.UpstreamResponseModel = &value
}
if value, ok := _c.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
_node.UpstreamModelMismatch = &value
}
if value, ok := _c.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
_node.ChannelID = &value
@@ -1327,6 +1368,42 @@ func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert {
return u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsert) SetUpstreamResponseModel(v string) *UsageLogUpsert {
u.Set(usagelog.FieldUpstreamResponseModel, v)
return u
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateUpstreamResponseModel() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldUpstreamResponseModel)
return u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsert) ClearUpstreamResponseModel() *UsageLogUpsert {
u.SetNull(usagelog.FieldUpstreamResponseModel)
return u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsert) SetUpstreamModelMismatch(v bool) *UsageLogUpsert {
u.Set(usagelog.FieldUpstreamModelMismatch, v)
return u
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateUpstreamModelMismatch() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldUpstreamModelMismatch)
return u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsert) ClearUpstreamModelMismatch() *UsageLogUpsert {
u.SetNull(usagelog.FieldUpstreamModelMismatch)
return u
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsert) SetChannelID(v int64) *UsageLogUpsert {
u.Set(usagelog.FieldChannelID, v)
@@ -2162,6 +2239,48 @@ func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne {
})
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsertOne) SetUpstreamResponseModel(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamResponseModel(v)
})
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateUpstreamResponseModel() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamResponseModel()
})
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsertOne) ClearUpstreamResponseModel() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamResponseModel()
})
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsertOne) SetUpstreamModelMismatch(v bool) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamModelMismatch(v)
})
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateUpstreamModelMismatch() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamModelMismatch()
})
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsertOne) ClearUpstreamModelMismatch() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamModelMismatch()
})
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsertOne) SetChannelID(v int64) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
@@ -3276,6 +3395,48 @@ func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk {
})
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsertBulk) SetUpstreamResponseModel(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamResponseModel(v)
})
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateUpstreamResponseModel() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamResponseModel()
})
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsertBulk) ClearUpstreamResponseModel() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamResponseModel()
})
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsertBulk) SetUpstreamModelMismatch(v bool) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamModelMismatch(v)
})
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateUpstreamModelMismatch() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamModelMismatch()
})
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsertBulk) ClearUpstreamModelMismatch() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamModelMismatch()
})
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsertBulk) SetChannelID(v int64) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
+114
View File
@@ -142,6 +142,46 @@ func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate {
return _u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_u *UsageLogUpdate) SetUpstreamResponseModel(v string) *UsageLogUpdate {
_u.mutation.SetUpstreamResponseModel(v)
return _u
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdate {
if v != nil {
_u.SetUpstreamResponseModel(*v)
}
return _u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (_u *UsageLogUpdate) ClearUpstreamResponseModel() *UsageLogUpdate {
_u.mutation.ClearUpstreamResponseModel()
return _u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_u *UsageLogUpdate) SetUpstreamModelMismatch(v bool) *UsageLogUpdate {
_u.mutation.SetUpstreamModelMismatch(v)
return _u
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdate {
if v != nil {
_u.SetUpstreamModelMismatch(*v)
}
return _u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (_u *UsageLogUpdate) ClearUpstreamModelMismatch() *UsageLogUpdate {
_u.mutation.ClearUpstreamModelMismatch()
return _u
}
// SetChannelID sets the "channel_id" field.
func (_u *UsageLogUpdate) SetChannelID(v int64) *UsageLogUpdate {
_u.mutation.ResetChannelID()
@@ -1016,6 +1056,11 @@ func (_u *UsageLogUpdate) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _u.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -1108,6 +1153,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.UpstreamModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
}
if _u.mutation.UpstreamResponseModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
}
if _u.mutation.UpstreamModelMismatchCleared() {
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
}
if value, ok := _u.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
}
@@ -1599,6 +1656,46 @@ func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne {
return _u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_u *UsageLogUpdateOne) SetUpstreamResponseModel(v string) *UsageLogUpdateOne {
_u.mutation.SetUpstreamResponseModel(v)
return _u
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetUpstreamResponseModel(*v)
}
return _u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (_u *UsageLogUpdateOne) ClearUpstreamResponseModel() *UsageLogUpdateOne {
_u.mutation.ClearUpstreamResponseModel()
return _u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_u *UsageLogUpdateOne) SetUpstreamModelMismatch(v bool) *UsageLogUpdateOne {
_u.mutation.SetUpstreamModelMismatch(v)
return _u
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdateOne {
if v != nil {
_u.SetUpstreamModelMismatch(*v)
}
return _u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (_u *UsageLogUpdateOne) ClearUpstreamModelMismatch() *UsageLogUpdateOne {
_u.mutation.ClearUpstreamModelMismatch()
return _u
}
// SetChannelID sets the "channel_id" field.
func (_u *UsageLogUpdateOne) SetChannelID(v int64) *UsageLogUpdateOne {
_u.mutation.ResetChannelID()
@@ -2486,6 +2583,11 @@ func (_u *UsageLogUpdateOne) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _u.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -2595,6 +2697,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if _u.mutation.UpstreamModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
}
if _u.mutation.UpstreamResponseModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
}
if _u.mutation.UpstreamModelMismatchCleared() {
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
}
if value, ok := _u.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
}
+11
View File
@@ -5,6 +5,9 @@ go 1.26.5
require (
entgo.io/ent v0.14.5
github.com/DATA-DOG/go-sqlmock v1.5.2
github.com/alibabacloud-go/captcha-20230305 v1.1.3
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13
github.com/alibabacloud-go/tea v1.3.13
github.com/alicebob/miniredis/v2 v2.38.0
github.com/alitto/pond/v2 v2.6.2
github.com/andybalholm/brotli v1.2.0
@@ -35,6 +38,8 @@ require (
github.com/spf13/viper v1.18.2
github.com/stretchr/testify v1.11.1
github.com/stripe/stripe-go/v85 v85.0.0
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0
github.com/testcontainers/testcontainers-go/modules/redis v0.40.0
github.com/tidwall/gjson v1.18.0
@@ -60,6 +65,10 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/agext/levenshtein v1.2.3 // indirect
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 // indirect
github.com/alibabacloud-go/debug v1.0.1 // indirect
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 // indirect
github.com/aliyun/credentials-go v1.4.5 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.8 // indirect
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.18.18 // indirect
@@ -81,6 +90,7 @@ require (
github.com/bytedance/sonic v1.9.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
github.com/containerd/errdefs/pkg v0.3.0 // indirect
github.com/containerd/log v0.1.0 // indirect
@@ -165,6 +175,7 @@ require (
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tjfoc/gmsm v1.4.1 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
+239 -2
View File
@@ -1,5 +1,6 @@
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9 h1:E0wvcUXTkgyN4wy4LGtNzMNGMytJN8afmIWXJVMi4cc=
ariga.io/atlas v0.32.1-0.20250325101103-175b25e1c1b9/go.mod h1:Oe1xWPuu5q9LzyrWfbZmEZxFYeu4BHTyzfjeW2aZp/w=
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
entgo.io/ent v0.14.5 h1:Rj2WOYJtCkWyFo6a+5wB3EfBRP0rnx1fMk6gGA0UUe4=
@@ -8,6 +9,7 @@ github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8af
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 h1:UQHMgLO+TxOElx5B5HZ4hJQsoJ/PvUvKRhJHDQXO8P8=
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/DATA-DOG/go-sqlmock v1.5.2 h1:OcvFkGmslmlZibjAjaHm3L//6LiuBgolP7OputlJIzU=
github.com/DATA-DOG/go-sqlmock v1.5.2/go.mod h1:88MAG/4G7SMwSE3CeA0ZKzrT5CiOU3OJ+JlNzwDqpNU=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
@@ -16,10 +18,56 @@ github.com/agext/levenshtein v1.2.3 h1:YB2fHEn0UJagG8T1rrWknE3ZQzWM06O8AMAatNn7l
github.com/agext/levenshtein v1.2.3/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw=
github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6 h1:eIf+iGJxdU4U9ypaUfbtOWCsZSbTb8AUHvyPrxu6mAA=
github.com/alibabacloud-go/alibabacloud-gateway-pop v0.0.6/go.mod h1:4EUIoxs/do24zMOGGqYVWgw0s9NtiylnJglOeEB5UJo=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.4/go.mod h1:sCavSAvdzOjul4cEqeVtvlSaSScfNsTQ+46HwlTL1hc=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5 h1:zE8vH9C7JiZLNJJQ5OwjU9mSi4T9ef9u3BURT6LCLC8=
github.com/alibabacloud-go/alibabacloud-gateway-spi v0.0.5/go.mod h1:tWnyE9AjF8J8qqLk645oUmVUnFybApTQWklQmi5tY6g=
github.com/alibabacloud-go/captcha-20230305 v1.1.3 h1:0Aobw12m3x28aeDMPjwjXsfF8MuLvRjlQ4Hhoy5hFOY=
github.com/alibabacloud-go/captcha-20230305 v1.1.3/go.mod h1:ydzBIN2OiM7eeQPpAFyBrv1H5TY1MtUP2rQig44C4UQ=
github.com/alibabacloud-go/darabonba-array v0.1.0 h1:vR8s7b1fWAQIjEjWnuF0JiKsCvclSRTfDzZHTYqfufY=
github.com/alibabacloud-go/darabonba-array v0.1.0/go.mod h1:BLKxr0brnggqOJPqT09DFJ8g3fsDshapUD3C3aOEFaI=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2 h1:1uJGrbsGEVqWcWxrS9MyC2NG0Ax+GpOM5gtupki31XE=
github.com/alibabacloud-go/darabonba-encode-util v0.0.2/go.mod h1:JiW9higWHYXm7F4PKuMgEUETNZasrDM6vqVr/Can7H8=
github.com/alibabacloud-go/darabonba-map v0.0.2 h1:qvPnGB4+dJbJIxOOfawxzF3hzMnIpjmafa0qOTp6udc=
github.com/alibabacloud-go/darabonba-map v0.0.2/go.mod h1:28AJaX8FOE/ym8OUFWga+MtEzBunJwQGceGQlvaPGPc=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13 h1:Q00FU3H94Ts0ZIHDmY+fYGgB7dV9D/YX6FGsgorQPgw=
github.com/alibabacloud-go/darabonba-openapi/v2 v2.1.13/go.mod h1:lxFGfobinVsQ49ntjpgWghXmIF0/Sm4+wvBJ1h5RtaE=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7 h1:UzCnKvsjPFzApvODDNEYqBHMFt1w98wC7FOo0InLyxg=
github.com/alibabacloud-go/darabonba-signature-util v0.0.7/go.mod h1:oUzCYV2fcCH797xKdL6BDH8ADIHlzrtKVjeRtunBNTQ=
github.com/alibabacloud-go/darabonba-string v1.0.2 h1:E714wms5ibdzCqGeYJ9JCFywE5nDyvIXIIQbZVFkkqo=
github.com/alibabacloud-go/darabonba-string v1.0.2/go.mod h1:93cTfV3vuPhhEwGGpKKqhVW4jLe7tDpo3LUM0i0g6mA=
github.com/alibabacloud-go/debug v0.0.0-20190504072949-9472017b5c68/go.mod h1:6pb/Qy8c+lqua8cFpEy7g39NRRqOWc3rOwAy8m5Y2BY=
github.com/alibabacloud-go/debug v1.0.0/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/debug v1.0.1 h1:MsW9SmUtbb1Fnt3ieC6NNZi6aEwrXfDksD4QA6GSbPg=
github.com/alibabacloud-go/debug v1.0.1/go.mod h1:8gfgZCCAC3+SCzjWtY053FrOcd4/qlH6IHTI4QyICOc=
github.com/alibabacloud-go/endpoint-util v1.1.0 h1:r/4D3VSw888XGaeNpP994zDUaxdgTSHBbVfZlzf6b5Q=
github.com/alibabacloud-go/endpoint-util v1.1.0/go.mod h1:O5FuCALmCKs2Ff7JFJMudHs0I5EBgecXXxZRyswlEjE=
github.com/alibabacloud-go/openapi-util v0.1.0 h1:0z75cIULkDrdEhkLWgi9tnLe+KhAFE/r5Pb3312/eAY=
github.com/alibabacloud-go/openapi-util v0.1.0/go.mod h1:sQuElr4ywwFRlCCberQwKRFhRzIyG4QTP/P4y1CJ6Ws=
github.com/alibabacloud-go/tea v1.1.0/go.mod h1:IkGyUSX4Ba1V+k4pCtJUc6jDpZLFph9QMy2VUPTwukg=
github.com/alibabacloud-go/tea v1.1.7/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.8/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.11/go.mod h1:/tmnEaQMyb4Ky1/5D+SE1BAsa5zj/KeGOFfwYm3N/p4=
github.com/alibabacloud-go/tea v1.1.17/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.1.20/go.mod h1:nXxjm6CIFkBhwW4FQkNrolwbfon8Svy6cujmKFUq98A=
github.com/alibabacloud-go/tea v1.2.2/go.mod h1:CF3vOzEMAG+bR4WOql8gc2G9H3EkH3ZLAQdpmpXMgwk=
github.com/alibabacloud-go/tea v1.3.13 h1:WhGy6LIXaMbBM6VBYcsDCz6K/TPsT1Ri2hPmmZffZ94=
github.com/alibabacloud-go/tea v1.3.13/go.mod h1:A560v/JTQ1n5zklt2BEpurJzZTI8TUT+Psg2drWlxRg=
github.com/alibabacloud-go/tea-utils v1.3.1 h1:iWQeRzRheqCMuiF3+XkfybB3kTgUXkXX+JMrqfLeB2I=
github.com/alibabacloud-go/tea-utils v1.3.1/go.mod h1:EI/o33aBfj3hETm4RLiAxF/ThQdSngxrpF8rKUDJjPE=
github.com/alibabacloud-go/tea-utils/v2 v2.0.5/go.mod h1:dL6vbUT35E4F4bFTHL845eUloqaerYBYPsdWR2/jhe4=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7 h1:WDx5qW3Xa5ZgJ1c8NfqJkF6w+AU5wB8835UdhPr6Ax0=
github.com/alibabacloud-go/tea-utils/v2 v2.0.7/go.mod h1:qxn986l+q33J5VkialKMqT/TTs3E+U9MJpd001iWQ9I=
github.com/alicebob/miniredis/v2 v2.38.0 h1:nZAzCR+Lj+Vxk4ZXzm2NuKq2O33RXj1XxJ2e2uP9jiw=
github.com/alicebob/miniredis/v2 v2.38.0/go.mod h1:TcL7YfarKPGDAthEtl5NBeHZfeUQj6OXMm/+iu5cLMM=
github.com/alitto/pond/v2 v2.6.2 h1:Sphe40g0ILeM1pA2c2K+Th0DGU+pt0A/Kprr+WB24Pw=
github.com/alitto/pond/v2 v2.6.2/go.mod h1:xkjYEgQ05RSpWdfSd1nM3OVv7TBhLdy7rMp3+2Nq+yE=
github.com/aliyun/credentials-go v1.1.2/go.mod h1:ozcZaMR5kLM7pwtCMEpVmQ242suV6qTJya2bDq4X1Tw=
github.com/aliyun/credentials-go v1.3.1/go.mod h1:8jKYhQuDawt8x2+fusqa1Y6mPxemTsBEN04dgcAcYz0=
github.com/aliyun/credentials-go v1.3.6/go.mod h1:1LxUuX7L5YrZUWzBrRyk0SwSdH4OmPrib8NVePL3fxM=
github.com/aliyun/credentials-go v1.4.5 h1:O76WYKgdy1oQYYiJkERjlA2dxGuvLRrzuO2ScrtGWSk=
github.com/aliyun/credentials-go v1.4.5/go.mod h1:Jm6d+xIgwJVLVWT561vy67ZRP4lPTQxMbEYRuT2Ti1U=
github.com/andybalholm/brotli v1.2.0 h1:ukwgCxwYrmACq68yiUqwIWnGY0cTPox/M94sVwToPjQ=
github.com/andybalholm/brotli v1.2.0/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
@@ -77,11 +125,16 @@ github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311 h1:qSGYFH7+jGhDF8vLC+iwCD4WpbV1EBDSzWkJODFLams=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/clbanning/mxj/v2 v2.7.0 h1:WA/La7UGCanFe5NpHF0Q3DNtnCsVoxbPKuyBNHWRyME=
github.com/clbanning/mxj/v2 v2.7.0/go.mod h1:hNiWqW14h+kc+MdF9C6/YoRfjEJoR3ou6tn/Qo+ve2s=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coder/websocket v1.8.14 h1:9L0p0iKiNOibykf283eHkKUHHrpG7f65OE3BhhO7v9g=
github.com/coder/websocket v1.8.14/go.mod h1:NX3SzP+inril6yawo5CQXx8+fk145lPDC6pumgx0mVg=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -120,6 +173,9 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw=
github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -163,25 +219,45 @@ github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba h1:qJEJcuLzH5KDR0gKc0zcktin6KSAwL7+jWKBYceddTc=
github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:EFYHy8/1y2KfgTAsx7Luu7NGhoxtuVHnNo8jE7FikKc=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
github.com/google/wire v0.7.0/go.mod h1:n6YbUQD9cPKTnHXEBN2DXlOp/mVADhVErcMFb0v3J18=
github.com/gopherjs/gopherjs v0.0.0-20181017120253-0766667cb4d1/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gopherjs/gopherjs v0.0.0-20200217142428-fce0ec30dd00/go.mod h1:wJfORRmW1u3UXTncJ5qlYoELFm8eSnnEO6hX4iZ3EWY=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
@@ -205,8 +281,10 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/jtolds/gls v4.20.0+incompatible/go.mod h1:QJZ7F/aHp+rZTRtaJ1ow/lLfFfVYBRgL+9YlvaHOwJU=
github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCyc/Ib3bDTKd379tNMpB/7/H5TjM2Y9QJ5THLbE=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
@@ -215,6 +293,8 @@ github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZX
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
@@ -232,6 +312,8 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -259,12 +341,17 @@ github.com/moby/term v0.5.0/go.mod h1:8FzsFHVUBGZdbDsJw/ot+X+d5HLUbvklYLJ9uGfcI3
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v0.0.0-20180701023420-4b7aa43c6742/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.1/go.mod h1:bx2lNnkwVCuqBIxFjflWJWanXIb3RllmbCylyMrvgv0=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -286,6 +373,7 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
@@ -298,6 +386,8 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -322,6 +412,9 @@ github.com/smartwalle/ngx v1.1.0 h1:q8nANgWSPRGeI/u+ixBoA4mf68DrUq6vZ+n9L5UKv9I=
github.com/smartwalle/ngx v1.1.0/go.mod h1:mx/nz2Pk5j+RBs7t6u6k22MPiBG/8CtOMpCnALIG8Y0=
github.com/smartwalle/nsign v1.0.9 h1:8poAgG7zBd8HkZy9RQDwasC6XZvJpDGQWSjzL2FZL6E=
github.com/smartwalle/nsign v1.0.9/go.mod h1:eY6I4CJlyNdVMP+t6z1H6Jpd4m5/V+8xi44ufSTxXgc=
github.com/smartystreets/assertions v0.0.0-20180927180507-b2de0cb4f26d/go.mod h1:OnSkiWE9lh6wB0YB77sQom3nweQdgAjqCqsofrRNTgc=
github.com/smartystreets/assertions v1.1.0/go.mod h1:tcbTF8ujkAEcZ8TElKY+i30BzYlVhC/LOxJk7iOWnoo=
github.com/smartystreets/goconvey v1.6.4/go.mod h1:syvi0/a8iFYH4r/RixwvyeAJjdLS9QV7WQ/tjFTllLA=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spaolacci/murmur3 v1.1.0 h1:7c1g84S4BPRrfL5Xrdp6fOJ206sU9y293DDHaoy0bLI=
@@ -330,16 +423,20 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
github.com/spf13/viper v1.18.2/go.mod h1:EKmWIqdnk5lOcmR72yw6hS+8OPYcwD0jteitLMVB+yk=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.2.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
@@ -353,6 +450,10 @@ github.com/stripe/stripe-go/v85 v85.0.0 h1:HMlFJXW6I/9WvkeSAtj8V7dI5pzeDu4gS1Taq
github.com/stripe/stripe-go/v85 v85.0.0/go.mod h1:5P+HGFenpWgak27T5Is6JMsmDfUC1yJnjhhmquz7kXw=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52 h1:bPz4h9cPAD2psXNdVNHZUM/V13P05rtXoFIFn1IIPoA=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/captcha v1.3.52/go.mod h1:KDlcSxrt2pw9nenvXpw/VHipuokbA0j2iRXV+2gF5j8=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52 h1:agyo5WB5bclK346U0Y4G40c//eA5qZbtBVGdp3HhuK8=
github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common v1.3.52/go.mod h1:r5r4xbfxSaeR04b166HGsBa/R4U3SueirEUpXGuw+Q0=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/testcontainers/testcontainers-go/modules/postgres v0.40.0 h1:s2bIayFXlbDFexo96y+htn7FzuhpXLYJNnIuglNKqOk=
@@ -372,6 +473,9 @@ github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru
github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tjfoc/gmsm v1.3.2/go.mod h1:HaUcFuY0auTiaHB9MHFGCPx5IaLhTUd2atbCFBQXn9w=
github.com/tjfoc/gmsm v1.4.1 h1:aMe1GlZb+0bLjn+cKTPEvvn9oUEBlJitaZiiBwsbgho=
github.com/tjfoc/gmsm v1.4.1/go.mod h1:j4INPkHWMrhJb38G+J6W4Tw0AbuN8Thu3PbdVYhVcTE=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -386,6 +490,9 @@ github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.30/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
github.com/yuin/gopher-lua v1.1.1/go.mod h1:GBR0iDaNXjAgGg9zfCvksxSRnQx76gclCIb7kdAd1Pw=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
@@ -429,59 +536,189 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191219195013-becbf705a915/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200510223506-06a226fb4e37/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20201012173705-84dcc777aaee/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
golang.org/x/crypto v0.14.0/go.mod h1:MVFd36DqK4CsrnJYDkBA3VC4m2GkXAM0PvzMCn4JQf4=
golang.org/x/crypto v0.18.0/go.mod h1:R0j02AL6hcrfOiy9T4ZYp/rcWeMxM3L6QYxlOuEG1mg=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200506145744-7e3656a0809f/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.17.0/go.mod h1:NxSsAGuq816PNPmqtQdLE42eU2Fs7NoRIZrHJAlaCOE=
golang.org/x/net v0.20.0/go.mod h1:z8BVo6PvndSri0LbOE3hAn0apkU+1YvI6E70E9jsnvY=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20200317015054-43a5402ce75a/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.13.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
golang.org/x/term v0.13.0/go.mod h1:LTmsnFJwVN6bCy1rVCoS+qHT1HhALEFxKncY3WNNh4U=
golang.org/x/term v0.16.0/go.mod h1:yn7UURbUtPyrVJPGPq404EukNFxcm/foM+bV/bfcDsY=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200509030707-2212a7e161a5/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/ini.v1 v1.56.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
gopkg.in/natefinch/lumberjack.v2 v2.2.1 h1:bBRl1b0OH9s/DuPhuXpNl+VtCaJXFZ5/uEFST95x9zc=
gopkg.in/natefinch/lumberjack.v2 v2.2.1/go.mod h1:YD8tP3GAjkrDg1eZH7EGmyESg/lsYskCTPBJVb9jqSc=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis=
modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.30.1 h1:4r4U1J6Fhj98NKfSjnPUN7Ze2c6MnAdL0hWw6+LrJpc=
+78 -1
View File
@@ -32,7 +32,7 @@ const (
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
const DefaultCSPPolicy = "default-src 'self'; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://static.cloudflareinsights.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https:; frame-src https://challenges.cloudflare.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// UMQ(用户消息队列)模式常量
const (
@@ -907,6 +907,18 @@ type GatewayConfig struct {
// ForceCodexCLI: 强制将 OpenAI `/v1/responses` 请求按 Codex CLI 处理。
// 用于网关未透传/改写 User-Agent 时的兼容兜底(默认关闭,避免影响其他客户端)。
ForceCodexCLI bool `mapstructure:"force_codex_cli"`
// DisableCodexIdentityEnforcement: 关闭「强制统一 Codex 出站身份」。上游 /backend-api/codex
// 在容量紧张时按客户端身份分优先级降载,被降载的请求会拿到 HTTP 200 + 流内
// server_is_overloaded,该次请求失败。默认强制统一出口:所有 OAuth 出站的
// User-Agent / originator / version 都改写为网关规范身份,确保没有请求带着第三方或陈旧身份
// 出站。置 true 后退回「仅按最终 User-Agent 配对 originator」的收口语义,供上游策略变动时回滚。
//
// 取反义命名是为了让零值安全:该开关会发布为进程级快照,未经 viper 加载而手工构造的
// Config(测试、工具)其零值必须落在「强制统一开启」这一侧,否则会静默丢掉这层保护。
DisableCodexIdentityEnforcement bool `mapstructure:"disable_codex_identity_enforcement"`
// DisableCodexOriginatorNormalization: 已废弃,等价于 DisableCodexIdentityEnforcement。
// 保留以兼容既有配置文件;加载时会折叠进新键,不要在新代码里直接读取。
DisableCodexOriginatorNormalization bool `mapstructure:"disable_codex_originator_normalization"`
// CodexImageGenerationBridgeEnabled: 是否为 Codex `/v1/responses` 自动注入 image_generation 工具和桥接指令。
// 默认关闭,避免纯文本 Codex 请求被意外改写;显式携带 image_generation 工具的请求仍按分组能力转发。
CodexImageGenerationBridgeEnabled bool `mapstructure:"codex_image_generation_bridge_enabled"`
@@ -1010,6 +1022,39 @@ type GatewayConfig struct {
// UserMessageQueue: 用户消息串行队列配置
// 对 role:"user" 的真实用户消息实施账号级串行化 + RPM 自适应延迟
UserMessageQueue UserMessageQueueConfig `mapstructure:"user_message_queue"`
// Grok: Grok/xAI gateway scheduling and free-tier soft-gate settings.
Grok GatewayGrokConfig `mapstructure:"grok"`
}
// GatewayGrokConfig holds Grok-specific gateway scheduling knobs.
//
// Free-quota soft gate keys (gateway.grok.*):
// - free_quota_soft_gate_enabled: enable local rolling-window scheduling guard for
// OAuth accounts whose subscription_tier/plan_type is explicitly "free".
// Default true is safe only because free-tier detection is strict (unknown/paid fail open).
// - free_quota_token_limit: nominal rolling-window token allowance.
// - free_quota_soft_gate_percent: stop new scheduling before the nominal limit (1-100).
// - free_quota_window_hours: local usage rolling window length in hours.
// - free_quota_stats_cache_seconds: cache TTL for free-tier usage stats
// (hot path never blocks on DB; misses fail open and refresh in background).
type GatewayGrokConfig struct {
// PasswordAuthEnabled controls the optional password-to-SSO OAuth flow.
// It defaults to false and must be explicitly enabled by the operator.
// When true, POST /admin/grok/oauth/password is functional (not ignored).
PasswordAuthEnabled bool `mapstructure:"password_auth_enabled"`
// FreeQuotaSoftGateEnabled enables a local rolling-window scheduling guard
// for explicitly free Grok OAuth accounts only.
FreeQuotaSoftGateEnabled bool `mapstructure:"free_quota_soft_gate_enabled"`
// FreeQuotaTokenLimit is the nominal rolling-window allowance.
FreeQuotaTokenLimit int64 `mapstructure:"free_quota_token_limit"`
// FreeQuotaSoftGatePercent stops new scheduling before the nominal limit.
FreeQuotaSoftGatePercent int `mapstructure:"free_quota_soft_gate_percent"`
// FreeQuotaWindowHours controls the local rolling usage window.
FreeQuotaWindowHours int `mapstructure:"free_quota_window_hours"`
// FreeQuotaStatsCacheSeconds is the soft-gate stats cache TTL. Hot path never
// waits on usage_logs; misses fail open and refresh asynchronously.
FreeQuotaStatsCacheSeconds int `mapstructure:"free_quota_stats_cache_seconds"`
}
type GatewayLiveConfig struct {
@@ -1766,6 +1811,13 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
cfg.Gateway.ForcedCodexInstructionsTemplate = string(content)
}
// 兼容旧键 gateway.disable_codex_originator_normalization:语义已被
// disable_codex_identity_enforcement 取代(身份改写升级为强制统一出口),
// 任一为 true 即关闭强制统一。
if cfg.Gateway.DisableCodexOriginatorNormalization {
cfg.Gateway.DisableCodexIdentityEnforcement = true
}
// 兼容旧键 gateway.openai_ws.sticky_previous_response_ttl_seconds。
// 新键未配置(<=0)时回退旧键;新键优先。
if cfg.Gateway.OpenAIWS.StickyResponseIDTTLSeconds <= 0 && cfg.Gateway.OpenAIWS.StickyPreviousResponseTTLSeconds > 0 {
@@ -2217,6 +2269,8 @@ func setDefaults() {
viper.SetDefault("gateway.max_account_switches", 10)
viper.SetDefault("gateway.max_account_switches_gemini", 3)
viper.SetDefault("gateway.force_codex_cli", false)
viper.SetDefault("gateway.disable_codex_identity_enforcement", false)
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
@@ -2288,6 +2342,15 @@ func setDefaults() {
viper.SetDefault("gateway.openai_proxy_stream_circuit.failure_threshold", 2)
viper.SetDefault("gateway.openai_proxy_stream_circuit.window_seconds", 60)
viper.SetDefault("gateway.openai_proxy_stream_circuit.ttl_seconds", 600)
// Grok free-tier local soft gate (scheduler-only; admin QueryQuota does not use this).
// Enabled by default because free detection requires an explicit free tier marker.
viper.SetDefault("gateway.grok.free_quota_soft_gate_enabled", true)
viper.SetDefault("gateway.grok.password_auth_enabled", false)
// Free soft-gate nominal limit: 500k tokens / rolling 24h (operator policy).
viper.SetDefault("gateway.grok.free_quota_token_limit", int64(500_000))
viper.SetDefault("gateway.grok.free_quota_soft_gate_percent", 95)
viper.SetDefault("gateway.grok.free_quota_window_hours", 24)
viper.SetDefault("gateway.grok.free_quota_stats_cache_seconds", 60)
viper.SetDefault("gateway.image_concurrency.enabled", false)
viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
@@ -3497,6 +3560,20 @@ func (c *Config) Validate() error {
if c.Concurrency.PingInterval < 5 || c.Concurrency.PingInterval > 30 {
return fmt.Errorf("concurrency.ping_interval must be between 5-30 seconds")
}
if c.Gateway.Grok.FreeQuotaSoftGateEnabled {
if c.Gateway.Grok.FreeQuotaTokenLimit <= 0 {
return fmt.Errorf("gateway.grok.free_quota_token_limit must be positive")
}
if c.Gateway.Grok.FreeQuotaSoftGatePercent < 1 || c.Gateway.Grok.FreeQuotaSoftGatePercent > 100 {
return fmt.Errorf("gateway.grok.free_quota_soft_gate_percent must be between 1 and 100")
}
if c.Gateway.Grok.FreeQuotaWindowHours <= 0 {
return fmt.Errorf("gateway.grok.free_quota_window_hours must be positive")
}
}
if c.Gateway.Grok.FreeQuotaStatsCacheSeconds < 0 {
return fmt.Errorf("gateway.grok.free_quota_stats_cache_seconds must be non-negative")
}
if err := ValidateDingTalkConfig(c.DingTalk); err != nil {
return fmt.Errorf("dingtalk_connect: %w", err)
}
+13
View File
@@ -538,6 +538,19 @@ func TestLoadOpenAICompactModelFromEnv(t *testing.T) {
require.Equal(t, "gpt-5.3-codex", cfg.Gateway.OpenAICompactModel)
}
func TestLoadDefaultGrokFreeQuotaSoftGate(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
require.False(t, cfg.Gateway.Grok.PasswordAuthEnabled)
require.True(t, cfg.Gateway.Grok.FreeQuotaSoftGateEnabled)
require.Equal(t, int64(500_000), cfg.Gateway.Grok.FreeQuotaTokenLimit)
require.Equal(t, 95, cfg.Gateway.Grok.FreeQuotaSoftGatePercent)
require.Equal(t, 24, cfg.Gateway.Grok.FreeQuotaWindowHours)
require.Equal(t, 60, cfg.Gateway.Grok.FreeQuotaStatsCacheSeconds)
}
func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
resetViperWithJWTSecret(t)
+6
View File
@@ -117,6 +117,12 @@ var DefaultAntigravityModelMapping = map[string]string{
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
// Gemini 3.1 image preview 映射
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
// Gemini 3.6 Flash tiered models
"gemini-3.6-flash": "gemini-3.6-flash",
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
// 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",
@@ -65,6 +65,14 @@ func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-high", "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
@@ -1065,6 +1065,10 @@ type TestAccountRequest struct {
ModelID string `json:"model_id"`
Prompt string `json:"prompt"`
Mode string `json:"mode"`
// Optional media for Grok (and future) real generation tests.
// ImageDataURL / AudioDataURL are data:<mime>;base64,... payloads.
ImageDataURL string `json:"image_data_url"`
AudioDataURL string `json:"audio_data_url"`
}
type SyncFromCRSRequest struct {
@@ -1094,8 +1098,13 @@ func (h *AccountHandler) Test(c *gin.Context) {
// Allow empty body, model_id is optional
_ = c.ShouldBindJSON(&req)
opts := service.AccountTestOptions{
ImageDataURL: req.ImageDataURL,
AudioDataURL: req.AudioDataURL,
}
// Use AccountTestService to test the account with SSE streaming
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode); err != nil {
if err := h.accountTestService.TestAccountConnection(c, accountID, req.ModelID, req.Prompt, req.Mode, opts); err != nil {
// Error already sent via SSE, just log
return
}
@@ -1415,6 +1424,9 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
return
}
// Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
updatedAccount, err := h.adminService.UpdateAccount(ctx, accountID, &service.UpdateAccountInput{
Type: req.Type,
Credentials: req.Credentials,
@@ -1442,6 +1454,20 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
}
}
// Successful re-auth clears the soft spending-limit reauth flag for Grok.
if existing.Platform == service.PlatformGrok {
if clearErr := h.adminService.UpdateAccountExtra(ctx, accountID, map[string]any{
"grok_needs_reauth": false,
"grok_needs_reauth_reason": "",
"grok_needs_reauth_at": "",
}); clearErr != nil {
slog.Warn("apply_oauth_credentials.clear_grok_reauth_failed",
"account_id", accountID,
"err", clearErr,
)
}
}
if cleared, clearErr := h.adminService.ClearAccountError(ctx, accountID); clearErr != nil {
slog.Warn("apply_oauth_credentials.clear_error_failed",
"account_id", accountID,
@@ -2422,6 +2448,11 @@ type BatchTodayStatsRequest struct {
AccountIDs []int64 `json:"account_ids" binding:"required"`
}
type BatchUsageRequest struct {
AccountIDs []int64 `json:"account_ids" binding:"required"`
Force bool `json:"force"`
}
// GetBatchTodayStats 批量获取多个账号的今日统计。
// POST /api/v1/admin/accounts/today-stats/batch
func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) {
@@ -2468,6 +2499,36 @@ func (h *AccountHandler) GetBatchTodayStats(c *gin.Context) {
response.Success(c, payload)
}
// GetBatchUsage 批量获取多个账号的 current usage。
// POST /api/v1/admin/accounts/usage/batch
func (h *AccountHandler) GetBatchUsage(c *gin.Context) {
var req BatchUsageRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
accountIDs := normalizeInt64IDList(req.AccountIDs)
if len(accountIDs) == 0 {
response.Success(c, gin.H{
"usage": map[string]any{},
"errors": map[string]string{},
})
return
}
usageByAccount, errorsByAccount, err := h.accountUsageService.GetUsageBatch(c.Request.Context(), accountIDs, req.Force)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{
"usage": usageByAccount,
"errors": errorsByAccount,
})
}
// SetSchedulableRequest represents the request body for setting schedulable status
type SetSchedulableRequest struct {
Schedulable bool `json:"schedulable"`
@@ -144,7 +144,7 @@ func TestApplyOAuthCredentialsRejectsMalformedOpenAILongContextBillingBeforeMuta
func TestOpenAIOAuthCodexPATBoundaryRejectsMalformedOpenAILongContextBillingValueBeforeTokenValidation(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil)
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil)
router := gin.New()
router.Use(gin.Recovery())
router.POST("/openai/create-from-codex-pat", handler.CreateAccountFromCodexPAT)
@@ -153,12 +153,12 @@ func (h *BackupHandler) GetDownloadURL(c *gin.Context) {
response.BadRequest(c, "backup ID is required")
return
}
url, err := h.backupService.GetBackupDownloadURL(c.Request.Context(), backupID)
download, err := h.backupService.GetBackupDownloadURL(c.Request.Context(), backupID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"url": url})
response.Success(c, download)
}
// ─── 恢复操作(需要重新输入管理员密码) ───
@@ -33,7 +33,7 @@ type createChannelRequest struct {
GroupIDs []int64 `json:"group_ids"`
ModelPricing []channelModelPricingRequest `json:"model_pricing"`
ModelMapping map[string]map[string]string `json:"model_mapping"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped response_model"`
RestrictModels bool `json:"restrict_models"`
Features string `json:"features"`
FeaturesConfig map[string]any `json:"features_config"`
@@ -48,7 +48,7 @@ type updateChannelRequest struct {
GroupIDs *[]int64 `json:"group_ids"`
ModelPricing *[]channelModelPricingRequest `json:"model_pricing"`
ModelMapping map[string]map[string]string `json:"model_mapping"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped response_model"`
RestrictModels *bool `json:"restrict_models"`
Features *string `json:"features"`
FeaturesConfig map[string]any `json:"features_config"`
@@ -64,6 +64,18 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
return startTime, endTime
}
func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return nil, nil
}
value, err := strconv.ParseBool(raw)
if err != nil {
return nil, err
}
return &value, nil
}
// GetStats handles getting dashboard statistics
// GET /api/v1/admin/dashboard/stats
func (h *DashboardHandler) GetStats(c *gin.Context) {
@@ -200,6 +212,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -249,8 +262,13 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get usage trend")
return
@@ -277,6 +295,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -330,8 +349,13 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType)
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get model statistics")
return
@@ -355,6 +379,7 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -401,8 +426,13 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
return
}
}
upstreamModelMismatch, err := parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get group statistics")
return
@@ -19,11 +19,26 @@ type dashboardUsageRepoCapture struct {
trendStream *bool
modelRequestType *int16
modelStream *bool
trendMismatch *bool
modelMismatch *bool
groupMismatch *bool
rankingLimit int
ranking []usagestats.UserSpendingRankingItem
rankingTotal float64
}
func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
ctx context.Context,
startTime, endTime time.Time,
granularity string,
filters usagestats.UsageLogFilters,
) ([]usagestats.TrendDataPoint, error) {
s.trendRequestType = filters.RequestType
s.trendStream = filters.Stream
s.trendMismatch = filters.UpstreamModelMismatch
return []usagestats.TrendDataPoint{}, nil
}
func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
ctx context.Context,
startTime, endTime time.Time,
@@ -39,6 +54,27 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
return []usagestats.TrendDataPoint{}, nil
}
func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource(
ctx context.Context,
startTime, endTime time.Time,
filters usagestats.UsageLogFilters,
source string,
) ([]usagestats.ModelStat, error) {
s.modelRequestType = filters.RequestType
s.modelStream = filters.Stream
s.modelMismatch = filters.UpstreamModelMismatch
return []usagestats.ModelStat{}, nil
}
func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters(
ctx context.Context,
startTime, endTime time.Time,
filters usagestats.UsageLogFilters,
) ([]usagestats.GroupStat, error) {
s.groupMismatch = filters.UpstreamModelMismatch
return []usagestats.GroupStat{}, nil
}
func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
ctx context.Context,
startTime, endTime time.Time,
@@ -73,6 +109,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng
router := gin.New()
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
router.GET("/admin/dashboard/models", handler.GetModelStats)
router.GET("/admin/dashboard/groups", handler.GetGroupStats)
router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking)
return router
}
@@ -171,6 +208,46 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
}
func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) {
resetDashboardReadCachesForTest()
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?upstream_model_mismatch=true",
"/admin/dashboard/models?upstream_model_mismatch=true",
"/admin/dashboard/groups?upstream_model_mismatch=true",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, path)
}
require.NotNil(t, repo.trendMismatch)
require.True(t, *repo.trendMismatch)
require.NotNil(t, repo.modelMismatch)
require.True(t, *repo.modelMismatch)
require.NotNil(t, repo.groupMismatch)
require.True(t, *repo.groupMismatch)
}
func TestDashboardModelAuditFilterRejectsInvalidBoolean(t *testing.T) {
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?upstream_model_mismatch=invalid",
"/admin/dashboard/models?upstream_model_mismatch=invalid",
"/admin/dashboard/groups?upstream_model_mismatch=invalid",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, path)
}
}
func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
repo := &dashboardUsageRepoCapture{
@@ -18,30 +18,32 @@ var (
)
type dashboardTrendCacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
type dashboardModelGroupCacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
ModelSource string `json:"model_source,omitempty"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
ModelSource string `json:"model_source,omitempty"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
type dashboardEntityTrendCacheKey struct {
@@ -84,22 +86,28 @@ func (h *DashboardHandler) getUsageTrendCached(
requestType *int16,
stream *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.TrendDataPoint, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardTrendCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
Model: model, RequestType: requestType, Stream: stream, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
})
if err != nil {
return nil, hit, err
@@ -116,21 +124,27 @@ func (h *DashboardHandler) getModelStatsCached(
requestType *int16,
stream *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.ModelStat, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
ModelSource: usagestats.NormalizeModelSource(modelSource),
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
ModelSource: usagestats.NormalizeModelSource(modelSource),
RequestType: requestType,
Stream: stream,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, modelSource)
return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
RequestType: requestType, Stream: stream, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
}, modelSource)
})
if err != nil {
return nil, hit, err
@@ -146,20 +160,26 @@ func (h *DashboardHandler) getGroupStatsCached(
requestType *int16,
stream *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.GroupStat, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
RequestType: requestType, Stream: stream, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
})
if err != nil {
return nil, hit, err
@@ -37,34 +37,36 @@ type dashboardSnapshotV2Response struct {
}
type dashboardSnapshotV2Filters struct {
UserID int64
APIKeyID int64
AccountID int64
GroupID int64
Model string
RequestType *int16
Stream *bool
BillingType *int8
UserID int64
APIKeyID int64
AccountID int64
GroupID int64
Model string
RequestType *int16
Stream *bool
BillingType *int8
UpstreamModelMismatch *bool
}
type dashboardSnapshotV2CacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
IncludeStats bool `json:"include_stats"`
IncludeTrend bool `json:"include_trend"`
IncludeModels bool `json:"include_models"`
IncludeGroups bool `json:"include_groups"`
IncludeUsersTrend bool `json:"include_users_trend"`
UsersTrendLimit int `json:"users_trend_limit"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
IncludeStats bool `json:"include_stats"`
IncludeTrend bool `json:"include_trend"`
IncludeModels bool `json:"include_models"`
IncludeGroups bool `json:"include_groups"`
IncludeUsersTrend bool `json:"include_users_trend"`
UsersTrendLimit int `json:"users_trend_limit"`
}
func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
@@ -93,23 +95,24 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
}
keyRaw, _ := json.Marshal(dashboardSnapshotV2CacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
IncludeStats: includeStats,
IncludeTrend: includeTrend,
IncludeModels: includeModels,
IncludeGroups: includeGroups,
IncludeUsersTrend: includeUsersTrend,
UsersTrendLimit: usersTrendLimit,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
UpstreamModelMismatch: filters.UpstreamModelMismatch,
IncludeStats: includeStats,
IncludeTrend: includeTrend,
IncludeModels: includeModels,
IncludeGroups: includeGroups,
IncludeUsersTrend: includeUsersTrend,
UsersTrendLimit: usersTrendLimit,
})
cacheKey := string(keyRaw)
@@ -184,6 +187,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.RequestType,
filters.Stream,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get usage trend")
@@ -204,6 +208,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.RequestType,
filters.Stream,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get model statistics")
@@ -223,6 +228,7 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.RequestType,
filters.Stream,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get group statistics")
@@ -299,5 +305,13 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
filters.BillingType = &bt
}
if mismatchStr := strings.TrimSpace(c.Query("upstream_model_mismatch")); mismatchStr != "" {
value, err := strconv.ParseBool(mismatchStr)
if err != nil {
return nil, err
}
filters.UpstreamModelMismatch = &value
}
return filters, nil
}
@@ -13,6 +13,7 @@ import (
const (
grokImportProbeConcurrency = 3
grokImportProbeTimeout = 25 * time.Second
grokImportProbeQueueLimit = 64
)
type grokImportProber interface {
@@ -27,6 +28,8 @@ type grokImportProbeTask struct {
type grokImportProbeScheduler struct {
mu sync.Mutex
queue []grokImportProbeTask
pending map[int64]struct{}
inFlight map[int64]struct{}
concurrency int
workers int
maxWorkers int
@@ -48,6 +51,8 @@ func newGrokImportProbeScheduler(concurrency int, timeout time.Duration) *grokIm
return &grokImportProbeScheduler{
concurrency: concurrency,
timeout: timeout,
pending: make(map[int64]struct{}),
inFlight: make(map[int64]struct{}),
}
}
@@ -60,7 +65,21 @@ func (s *grokImportProbeScheduler) schedule(prober grokImportProber, account *se
}
s.mu.Lock()
if _, exists := s.pending[account.ID]; exists {
s.mu.Unlock()
return
}
if _, exists := s.inFlight[account.ID]; exists {
s.mu.Unlock()
return
}
if len(s.queue) >= grokImportProbeQueueLimit {
s.mu.Unlock()
slog.Debug("grok_import_active_probe_dropped", "account_id", account.ID, "reason", "queue_full")
return
}
s.queue = append(s.queue, grokImportProbeTask{prober: prober, accountID: account.ID})
s.pending[account.ID] = struct{}{}
if s.workers < s.concurrency {
s.workers++
if s.workers > s.maxWorkers {
@@ -78,6 +97,7 @@ func (s *grokImportProbeScheduler) worker() {
return
}
s.run(task.prober, task.accountID)
s.finish(task.accountID)
}
}
@@ -94,9 +114,17 @@ func (s *grokImportProbeScheduler) nextTask() (grokImportProbeTask, bool) {
if len(s.queue) == 0 {
s.queue = nil
}
delete(s.pending, task.accountID)
s.inFlight[task.accountID] = struct{}{}
return task, true
}
func (s *grokImportProbeScheduler) finish(accountID int64) {
s.mu.Lock()
delete(s.inFlight, accountID)
s.mu.Unlock()
}
func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64) {
defer func() {
if recovered := recover(); recovered != nil {
@@ -108,8 +136,6 @@ func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64)
}
}()
// Queue time is intentionally excluded: every imported account is probed,
// while this timeout only bounds the actual upstream probe execution.
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
defer cancel()
result, err := prober.QueryQuota(ctx, accountID)
@@ -4,6 +4,7 @@ package admin
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -62,6 +63,10 @@ func (grokImportOAuthClientStub) RefreshToken(context.Context, string, string, s
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
}
func (grokImportOAuthClientStub) LoginWithPassword(context.Context, string, string, string) (*service.GrokPasswordLoginResult, error) {
return nil, errors.New("unexpected password login")
}
func (grokImportOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
}
@@ -142,7 +142,7 @@ func TestGrokImportProbeSchedulerProbesSingleAccountOnce(t *testing.T) {
}
func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.T) {
const taskCount = 100
const taskCount = 50
release := make(chan struct{})
scheduler := newGrokImportProbeScheduler(3, time.Second)
prober := newGrokImportProbeStub(taskCount)
@@ -156,7 +156,7 @@ func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.
awaitGrokProbeSignal(t, prober.started)
}
snapshot := snapshotGrokImportProbeScheduler(scheduler)
require.Equal(t, 97, snapshot.queued)
require.Equal(t, taskCount-3, snapshot.queued)
require.Equal(t, 3, snapshot.workers)
require.Equal(t, 3, snapshot.maxWorkers)
select {
@@ -182,6 +182,55 @@ func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.
require.Equal(t, 3, snapshot.maxWorkers)
}
func TestGrokImportProbeSchedulerDeduplicatesPendingAndInFlightAccounts(t *testing.T) {
scheduler := newGrokImportProbeScheduler(1, time.Second)
prober := newGrokImportProbeStub(2)
release := make(chan struct{})
prober.block = release
account := newGrokOAuthImportAccount(501)
queued := newGrokOAuthImportAccount(502)
scheduler.schedule(prober, account)
require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.started))
scheduler.schedule(prober, account)
scheduler.schedule(prober, queued)
scheduler.schedule(prober, queued)
scheduler.mu.Lock()
require.Len(t, scheduler.queue, 1)
require.Contains(t, scheduler.inFlight, int64(501))
require.Contains(t, scheduler.pending, int64(502))
scheduler.mu.Unlock()
close(release)
require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.done))
require.Equal(t, int64(502), awaitGrokProbeSignal(t, prober.done))
calls, _, _ := prober.snapshot()
require.Equal(t, 1, calls[501])
require.Equal(t, 1, calls[502])
}
func TestGrokImportProbeSchedulerBoundsPendingQueue(t *testing.T) {
scheduler := newGrokImportProbeScheduler(1, time.Second)
prober := newGrokImportProbeStub(grokImportProbeQueueLimit + 1)
release := make(chan struct{})
prober.block = release
scheduler.schedule(prober, newGrokOAuthImportAccount(600))
require.Equal(t, int64(600), awaitGrokProbeSignal(t, prober.started))
for id := int64(601); id < 601+grokImportProbeQueueLimit+10; id++ {
scheduler.schedule(prober, newGrokOAuthImportAccount(id))
}
scheduler.mu.Lock()
require.Len(t, scheduler.queue, grokImportProbeQueueLimit)
scheduler.mu.Unlock()
close(release)
for i := 0; i < grokImportProbeQueueLimit+1; i++ {
awaitGrokProbeSignal(t, prober.done)
}
}
func TestGrokImportProbeSchedulerTimeoutCancelsProbe(t *testing.T) {
neverRelease := make(chan struct{})
scheduler := newGrokImportProbeScheduler(1, 20*time.Millisecond)
@@ -47,6 +47,10 @@ type GrokGenerateAuthURLRequest struct {
RedirectURI string `json:"redirect_uri"`
}
func (h *GrokOAuthHandler) GetCapabilities(c *gin.Context) {
response.Success(c, h.grokOAuthService.GetCapabilities())
}
func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) {
var req GrokGenerateAuthURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -95,6 +99,17 @@ type GrokRefreshTokenRequest struct {
ProxyID *int64 `json:"proxy_id"`
}
type GrokSSOTokenRequest struct {
SSOToken string `json:"sso_token"`
ProxyID *int64 `json:"proxy_id"`
}
type GrokPasswordAuthorizeRequest struct {
Email string `json:"email"`
Password string `json:"password"`
ProxyID *int64 `json:"proxy_id"`
}
func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
var req GrokRefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -113,9 +128,15 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
var proxyURL string
if req.ProxyID != nil {
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
if err == nil && proxy != nil {
proxyURL = proxy.URL()
if err != nil {
response.ErrorFrom(c, err)
return
}
if proxy == nil {
response.BadRequest(c, "GROK_OAUTH_PROXY_NOT_FOUND: proxy not found")
return
}
proxyURL = proxy.URL()
}
tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID)
if err != nil {
@@ -125,6 +146,38 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
response.Success(c, tokenInfo)
}
// ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens.
// Response contains OAuth token info only — never echoes sso_token.
func (h *GrokOAuthHandler) ValidateSSOToken(c *gin.Context) {
var req GrokSSOTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.grokOAuthService.ValidateSSOToken(c.Request.Context(), req.SSOToken, req.ProxyID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}
// AuthorizePassword exchanges email/password for Build OAuth tokens via SSO conversion.
// Response never includes password or raw sso_token.
func (h *GrokOAuthHandler) AuthorizePassword(c *gin.Context) {
var req GrokPasswordAuthorizeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.grokOAuthService.AuthorizePassword(c.Request.Context(), req.Email, req.Password, req.ProxyID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}
func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -412,11 +465,38 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
// 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与
// RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。
func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any {
credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built)
// Only merge operator config from the request — never free-form secrets
// (password / sso_token / cookie / etc.) into stored credentials.
allowedReqKeys := map[string]struct{}{
"base_url": {}, "model_mapping": {},
"header_override": {}, "header_overrides": {}, "header_override_enabled": {},
"custom_headers": {},
}
ops := map[string]any{}
for k, v := range reqCredentials {
if _, ok := allowedReqKeys[k]; !ok {
continue
}
if service.IsSensitiveCredentialKey(k) {
continue
}
ops[k] = v
}
credentials := service.MergeCredentials(ops, built)
// Strip any sensitive keys that might have slipped in via older callers.
for k := range credentials {
if service.IsSensitiveCredentialKey(k) {
// Keep only keys produced by BuildAccountCredentials (tokens).
if k == "access_token" || k == "refresh_token" || k == "id_token" {
continue
}
delete(credentials, k)
}
}
if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" {
credentials["base_url"] = strings.TrimSpace(reqBaseURL)
}
return credentials
return service.SanitizeStoredCredentials(service.PlatformGrok, credentials)
}
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
@@ -4,6 +4,7 @@ package admin
import (
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
@@ -12,6 +13,7 @@ import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
@@ -128,14 +130,22 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
require.Contains(t, rec.Body.String(), `"snapshot":`)
require.Contains(t, rec.Body.String(), `"headers_observed":true`)
require.NotContains(t, rec.Body.String(), "access-token")
require.Eventually(t, func() bool {
upstream.mu.Lock()
defer upstream.mu.Unlock()
return len(upstream.requests) == 4
}, time.Second, 10*time.Millisecond)
upstream.mu.Lock()
requests := append([]*http.Request(nil), upstream.requests...)
bodies := append([][]byte(nil), upstream.bodies...)
upstream.mu.Unlock()
require.Len(t, requests, 3)
require.Len(t, requests, 4)
responsesProbeSeen := false
modelsSyncSeen := false
for i, upstreamReq := range requests {
require.Equal(t, "Bearer access-token", upstreamReq.Header.Get("Authorization"))
if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/responses" {
responsesProbeSeen = true
require.Equal(t, "application/json, text/event-stream", upstreamReq.Header.Get("Accept"))
require.Contains(t, string(bodies[i]), `"model":"grok-4.5"`)
require.Contains(t, string(bodies[i]), `"input":"hi"`)
@@ -143,7 +153,12 @@ func TestGrokOAuthHandlerQueryQuotaProbesUpstream(t *testing.T) {
require.NotContains(t, string(bodies[i]), `"max_output_tokens"`)
require.NotContains(t, string(bodies[i]), `"store"`)
}
if upstreamReq.URL.String() == xai.DefaultCLIBaseURL+"/models" {
modelsSyncSeen = true
}
}
require.True(t, responsesProbeSeen)
require.True(t, modelsSyncSeen)
require.NotNil(t, repo.updates[42])
}
@@ -189,6 +204,84 @@ func TestGrokOAuthHandlerRuntimeSanityDoesNotExposeSecrets(t *testing.T) {
require.NotContains(t, rec.Body.String(), "client-secret-like-value")
}
type grokOAuthHandlerClient struct{}
func (c *grokOAuthHandlerClient) ExchangeCode(context.Context, string, string, string, string, string) (*xai.TokenResponse, error) {
return nil, errors.New("unexpected exchange")
}
func (c *grokOAuthHandlerClient) RefreshToken(context.Context, string, string, string) (*xai.TokenResponse, error) {
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
}
func (c *grokOAuthHandlerClient) LoginWithPassword(_ context.Context, email, _ string, _ string) (*service.GrokPasswordLoginResult, error) {
return &service.GrokPasswordLoginResult{
Email: email,
SSOToken: "sso-from-password",
}, nil
}
func (c *grokOAuthHandlerClient) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
}
func TestGrokOAuthHandlerValidateSSOTokenReturnsTokenInfo(t *testing.T) {
gin.SetMode(gin.TestMode)
oauthClient := &grokOAuthHandlerClient{}
oauthService := service.NewGrokOAuthService(nil, oauthClient)
defer oauthService.Stop()
handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
router := gin.New()
router.POST("/api/v1/admin/grok/oauth/sso-token", handler.ValidateSSOToken)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/sso-token", strings.NewReader(`{"sso_token":"sso-token"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"access_token":"access-token"`)
require.NotContains(t, rec.Body.String(), `"sso_token"`)
}
func TestGrokOAuthHandlerAuthorizePasswordReturnsTokenInfoWithoutPassword(t *testing.T) {
gin.SetMode(gin.TestMode)
oauthClient := &grokOAuthHandlerClient{}
cfg := &config.Config{}
cfg.Gateway.Grok.PasswordAuthEnabled = true
oauthService := service.NewGrokOAuthService(nil, oauthClient, cfg)
defer oauthService.Stop()
handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
router := gin.New()
router.POST("/api/v1/admin/grok/oauth/password", handler.AuthorizePassword)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/grok/oauth/password", strings.NewReader(`{"email":"user@example.com","password":"super-secret"}`))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"access_token":"access-token"`)
require.NotContains(t, rec.Body.String(), "super-secret")
}
func TestGrokOAuthHandlerPasswordCapabilityDefaultsToDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
oauthService := service.NewGrokOAuthService(nil, &grokOAuthHandlerClient{})
defer oauthService.Stop()
handler := NewGrokOAuthHandler(oauthService, nil, nil, nil)
router := gin.New()
router.GET("/api/v1/admin/grok/oauth/capabilities", handler.GetCapabilities)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/api/v1/admin/grok/oauth/capabilities", nil))
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"password_auth_enabled":false`)
}
func TestGrokSSOImportExpiryUsesTokenExpiryWithoutRefreshToken(t *testing.T) {
tokenExpiry := time.Now().Add(6 * time.Hour).Unix()
expiresAt, autoPause := grokSSOImportExpiry(nil, nil, &service.GrokTokenInfo{
@@ -266,14 +359,12 @@ func TestGrokSSOImportCredentialsDefaultsToOfficialBaseURL(t *testing.T) {
require.Equal(t, "at-2", credentials["access_token"])
}
func TestGrokSSOImportWorkerRecoversPanic(t *testing.T) {
func TestGrokSSOImportWorkerHandlesMissingOAuthService(t *testing.T) {
h := &GrokOAuthHandler{}
result := h.safeCreateAccountFromSSOToken(context.Background(), GrokSSOToOAuthRequest{}, "token", 2, 3)
// Without a service, createAccountFromSSOToken would panic on nil service access.
// Recovery must convert that into a failed item and keep the worker alive.
require.False(t, result.created)
require.Equal(t, 2, result.item.Index)
require.Contains(t, result.item.Error, "internal worker panic")
require.Contains(t, result.item.Error, "GROK_OAUTH_CLIENT_NOT_CONFIGURED")
}
func TestGrokOAuthHandlerReconcileDefaultsToDryRun(t *testing.T) {
+97 -69
View File
@@ -96,41 +96,48 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
// CreateGroupRequest represents create group request
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
ModelPricing []service.ChannelModelPricing `json:"model_pricing"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
AllowImageGeneration bool `json:"allow_image_generation"`
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
AllowImageGeneration bool `json:"allow_image_generation"`
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
SearchPricePer1k *float64 `json:"search_price_per_1k"`
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled bool `json:"model_routing_enabled"`
@@ -157,42 +164,49 @@ type CreateGroupRequest struct {
// UpdateGroupRequest represents update group request
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
LongContextPricingEnabled *bool `json:"long_context_pricing_enabled"`
ModelPricing *[]service.ChannelModelPricing `json:"model_pricing"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
AllowImageGeneration *bool `json:"allow_image_generation"`
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
ImageRateIndependent *bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent *bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled *bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled *bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
ClaudeCodeOnly *bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
AllowImageGeneration *bool `json:"allow_image_generation"`
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
ImageRateIndependent *bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent *bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled *bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled *bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
SearchPricePer1k *float64 `json:"search_price_per_1k"`
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
ClaudeCodeOnly *bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled *bool `json:"model_routing_enabled"`
@@ -498,6 +512,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
LongContextPricingEnabled: req.LongContextPricingEnabled,
ModelPricing: req.ModelPricing,
AllowImageGeneration: req.AllowImageGeneration,
AllowBatchImageGeneration: req.AllowBatchImageGeneration,
ImageRateIndependent: req.ImageRateIndependent,
@@ -519,7 +535,12 @@ func (h *GroupHandler) Create(c *gin.Context) {
VideoPrice480P: req.VideoPrice480P,
VideoPrice720P: req.VideoPrice720P,
VideoPrice1080P: req.VideoPrice1080P,
VideoModelPrices: req.VideoModelPrices,
WebSearchPricePerCall: req.WebSearchPricePerCall,
SearchPricePer1k: req.SearchPricePer1k,
AudioRealtimePricePerMin: req.AudioRealtimePricePerMin,
AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars,
AudioSTTPricePerHour: req.AudioSttPricePerHour,
ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
@@ -620,6 +641,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
LongContextPricingEnabled: req.LongContextPricingEnabled,
ModelPricing: req.ModelPricing,
AllowImageGeneration: req.AllowImageGeneration,
AllowBatchImageGeneration: req.AllowBatchImageGeneration,
ImageRateIndependent: req.ImageRateIndependent,
@@ -641,7 +664,12 @@ func (h *GroupHandler) Update(c *gin.Context) {
VideoPrice480P: req.VideoPrice480P,
VideoPrice720P: req.VideoPrice720P,
VideoPrice1080P: req.VideoPrice1080P,
VideoModelPrices: req.VideoModelPrices,
WebSearchPricePerCall: req.WebSearchPricePerCall,
SearchPricePer1k: req.SearchPricePer1k,
AudioRealtimePricePerMin: req.AudioRealtimePricePerMin,
AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars,
AudioSTTPricePerHour: req.AudioSttPricePerHour,
ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
@@ -1,6 +1,9 @@
package admin
import (
"context"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
@@ -17,7 +20,61 @@ import (
type OpenAIOAuthHandler struct {
openaiOAuthService *service.OpenAIOAuthService
adminService service.AdminService
quotaService *service.OpenAIQuotaService
quotaService openAIQuotaService
rateLimitService openAIAccountStateRecoverer
}
type openAIQuotaService interface {
QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
}
type openAIAccountStateRecoverer interface {
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
}
const (
openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed"
openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed"
openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed"
)
// openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the
// (non-refundable) reset credit has already been consumed upstream. The whole
// request must stay comfortably inside the panel HTTP client timeout, otherwise
// the browser aborts a mutation that already succeeded and the operator retries
// it — spending a second credit.
const openAIQuotaResetPostProcessTimeout = 8 * time.Second
type openAIQuotaResetResponse struct {
service.OpenAIQuotaResetResult
Quota *service.OpenAIQuotaUsage `json:"quota,omitempty"`
Account *dto.Account `json:"account,omitempty"`
CacheRefreshed bool `json:"cache_refreshed"`
AccountStateRecovered bool `json:"account_state_recovered"`
WarningCode string `json:"warning_code,omitempty"`
}
// openAIQuotaRefreshResponse is the reset-credit-persisting variant of the quota
// query. The usage payload is embedded so the shape stays identical to the plain
// query; cache_persisted reports whether the snapshot write succeeded, because a
// failed display-cache write must never discard a successful upstream read.
type openAIQuotaRefreshResponse struct {
service.OpenAIQuotaUsage
CachePersisted bool `json:"cache_persisted"`
}
// openAIQuotaResetPostProcessContext detaches the post-reset bookkeeping from the
// client connection. The credit is already spent at that point, so account-state
// recovery must complete even if the operator closes the tab (mirrors
// systemUpdateContext, added for the same reason in #4504).
func openAIQuotaResetPostProcessContext(ctx context.Context) (context.Context, context.CancelFunc) {
base := context.Background()
if ctx != nil {
base = context.WithoutCancel(ctx)
}
return context.WithTimeout(base, openAIQuotaResetPostProcessTimeout)
}
func oauthPlatformFromPath(c *gin.Context) string {
@@ -29,12 +86,22 @@ func NewOpenAIOAuthHandler(
openaiOAuthService *service.OpenAIOAuthService,
adminService service.AdminService,
quotaService *service.OpenAIQuotaService,
rateLimitService *service.RateLimitService,
) *OpenAIOAuthHandler {
return &OpenAIOAuthHandler{
h := &OpenAIOAuthHandler{
openaiOAuthService: openaiOAuthService,
adminService: adminService,
quotaService: quotaService,
}
// Assign through explicit nil checks: storing a nil *Service in an interface
// field yields a non-nil interface, which would silently defeat the
// `== nil` capability guards below and panic instead of returning 400.
if quotaService != nil {
h.quotaService = quotaService
}
if rateLimitService != nil {
h.rateLimitService = rateLimitService
}
return h
}
// OpenAIGenerateAuthURLRequest represents the request for generating OpenAI auth URL
@@ -420,6 +487,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
response.BadRequest(c, "openai quota service is not enabled")
return
}
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
@@ -428,6 +496,47 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
response.Success(c, usage)
}
// RefreshQuota queries the rate-limit / quota usage AND persists the reset-credit
// snapshot so the card can be rehydrated without an upstream round-trip.
// POST /api/v1/admin/openai/accounts/:id/quota/refresh
//
// It is a POST (not a GET with a side-effect flag) because it writes account
// state: the audit middleware only records mutating verbs, so a persisting GET
// would mutate the database without an audit trail.
func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h.quotaService == nil {
response.BadRequest(c, "openai quota service is not enabled")
return
}
usage, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if usage == nil {
response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result")
return
}
refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage}
// A failed snapshot write leaves the previous cache intact — report it as a
// partial success instead of discarding the usage payload we just fetched,
// which would leave the card without a credit count at all.
if err := h.quotaService.CacheResetCreditsSnapshot(c.Request.Context(), accountID, usage.RateLimitResetCredits); err != nil {
slog.Warn("openai_quota_reset_credit_cache_persist_failed", "account_id", accountID, "error", err)
response.Success(c, refreshResponse)
return
}
refreshResponse.CachePersisted = true
response.Success(c, refreshResponse)
}
// CreateShadowRequest is the request body for CreateShadow.
type CreateShadowRequest struct {
Name string `json:"name"`
@@ -482,5 +591,63 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
if result == nil {
response.Error(c, http.StatusInternalServerError, "openai quota reset returned an empty result")
return
}
resetResponse := openAIQuotaResetResponse{OpenAIQuotaResetResult: *result}
postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context())
defer cancelPost()
// Step 1 — unblocking the account is the whole point of consuming a credit
// (#3672 / #3740), so it runs FIRST and is never gated on the display cache.
// Recovery is DB-only and leaves the manual `schedulable` switch untouched.
if h.rateLimitService == nil {
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
response.Success(c, resetResponse)
return
}
if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{
InvalidateToken: true,
}); err != nil {
// Recovery failures are almost always storage-level; the remaining steps
// share that dependency, so stop here instead of compounding the failure.
slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err)
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
response.Success(c, resetResponse)
return
}
resetResponse.AccountStateRecovered = true
// Step 2 — refresh the reset-credit display cache. A failure here is reported
// but must not hide the recovered account row produced by step 3.
usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID)
switch {
case usageErr != nil || usage == nil:
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr)
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
default:
if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil {
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
} else {
resetResponse.Quota = usage
resetResponse.CacheRefreshed = true
}
}
// Step 3 — hand back the post-recovery account row so the list drops the
// stale rate-limit badge without waiting for the next poll.
account, err := h.adminService.GetAccount(postCtx, accountID)
if err != nil {
slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err)
if resetResponse.WarningCode == "" {
resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed
}
response.Success(c, resetResponse)
return
}
resetResponse.Account = dto.AccountFromService(account)
response.Success(c, resetResponse)
}
@@ -0,0 +1,486 @@
//go:build unit
package admin
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type openAIQuotaWorkflowStub struct {
resetResult *service.OpenAIQuotaResetResult
resetErr error
queryResult *service.OpenAIQuotaUsage
queryErr error
cacheErr error
resetCalls int
queryCalls int
cacheCalls int
queryCtxErr error
cacheCtxErr error
}
func (s *openAIQuotaWorkflowStub) ResetCredit(context.Context, int64) (*service.OpenAIQuotaResetResult, error) {
s.resetCalls++
return s.resetResult, s.resetErr
}
func (s *openAIQuotaWorkflowStub) QueryUsage(ctx context.Context, _ int64) (*service.OpenAIQuotaUsage, error) {
s.queryCalls++
s.queryCtxErr = ctx.Err()
return s.queryResult, s.queryErr
}
func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context, _ int64, _ *service.OpenAIRateLimitResetCredits) error {
s.cacheCalls++
s.cacheCtxErr = ctx.Err()
return s.cacheErr
}
type openAIAccountStateRecovererStub struct {
err error
calls int
accountID int64
lastOptions service.AccountRecoveryOptions
lastCtxErr error
}
func (s *openAIAccountStateRecovererStub) RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error) {
s.calls++
s.accountID = accountID
s.lastOptions = options
s.lastCtxErr = ctx.Err()
return &service.SuccessfulTestRecoveryResult{}, s.err
}
type openAIResetAdminServiceStub struct {
service.AdminService
account *service.Account
err error
calls int
}
func (s *openAIResetAdminServiceStub) GetAccount(context.Context, int64) (*service.Account, error) {
s.calls++
return s.account, s.err
}
type openAIQuotaResetEnvelope struct {
Code int `json:"code"`
Data openAIQuotaResetResponse `json:"data"`
}
type openAIQuotaRefreshEnvelope struct {
Code int `json:"code"`
Data openAIQuotaRefreshResponse `json:"data"`
}
func performOpenAIQuotaResetRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaResetEnvelope) {
t.Helper()
return performOpenAIQuotaResetRequestWithContext(t, handler, nil)
}
// performOpenAIQuotaResetRequestWithContext drives the reset endpoint, optionally
// with an already-canceled request context (client disconnect simulation).
func performOpenAIQuotaResetRequestWithContext(t *testing.T, handler *OpenAIOAuthHandler, ctx context.Context) (int, openAIQuotaResetEnvelope) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/v1/admin/openai/accounts/:id/reset-quota", handler.ResetQuota)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/reset-quota", nil)
if ctx != nil {
request = request.WithContext(ctx)
}
router.ServeHTTP(recorder, request)
var envelope openAIQuotaResetEnvelope
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
return recorder.Code, envelope
}
func performOpenAIQuotaRefreshRequest(t *testing.T, handler *OpenAIOAuthHandler) (int, openAIQuotaRefreshEnvelope) {
t.Helper()
gin.SetMode(gin.TestMode)
router := gin.New()
router.POST("/api/v1/admin/openai/accounts/:id/quota/refresh", handler.RefreshQuota)
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodPost, "/api/v1/admin/openai/accounts/42/quota/refresh", nil)
router.ServeHTTP(recorder, request)
var envelope openAIQuotaRefreshEnvelope
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &envelope))
return recorder.Code, envelope
}
func successfulOpenAIQuotaWorkflowStub() *openAIQuotaWorkflowStub {
return &openAIQuotaWorkflowStub{
resetResult: &service.OpenAIQuotaResetResult{
Code: "success",
WindowsReset: 1,
},
queryResult: &service.OpenAIQuotaUsage{
FetchedAt: 123,
RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{
AvailableCount: 0,
Credits: []service.OpenAIRateLimitResetCreditDetail{},
},
},
}
}
func recoveredAccountStub() *openAIResetAdminServiceStub {
return &openAIResetAdminServiceStub{account: &service.Account{
ID: 42,
Name: "recovered",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Schedulable: false,
}}
}
func TestOpenAIResetQuota_ResetFailureStopsWorkflow(t *testing.T) {
quota := &openAIQuotaWorkflowStub{resetErr: errors.New("upstream reset failed")}
recoverer := &openAIAccountStateRecovererStub{}
handler := &OpenAIOAuthHandler{
adminService: &openAIResetAdminServiceStub{},
quotaService: quota,
rateLimitService: recoverer,
}
status, _ := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusInternalServerError, status)
require.Equal(t, 1, quota.resetCalls)
require.Zero(t, quota.queryCalls)
require.Zero(t, quota.cacheCalls)
require.Zero(t, recoverer.calls)
}
// Account-state recovery is the reason the credit was spent (#3672 / #3740), so it
// must run before — and independently of — the reset-credit display cache.
func TestOpenAIResetQuota_RecoversAccountStateBeforeRefreshingCache(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
recoverer := &openAIAccountStateRecovererStub{}
adminService := recoveredAccountStub()
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
rateLimitService: recoverer,
}
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Empty(t, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.True(t, envelope.Data.CacheRefreshed)
require.NotNil(t, envelope.Data.Quota)
require.NotNil(t, envelope.Data.Account)
require.Equal(t, int64(42), envelope.Data.Account.ID)
require.False(t, envelope.Data.Account.Schedulable, "manual scheduling switch must not be flipped")
require.Equal(t, int64(42), recoverer.accountID)
require.True(t, recoverer.lastOptions.InvalidateToken)
require.Equal(t, 1, quota.resetCalls)
require.Equal(t, 1, quota.queryCalls)
require.Equal(t, 1, quota.cacheCalls)
require.Equal(t, 1, recoverer.calls)
require.Equal(t, 1, adminService.calls)
}
func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
recoverer := &openAIAccountStateRecovererStub{err: errors.New("recovery failed")}
adminService := recoveredAccountStub()
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
rateLimitService: recoverer,
}
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.False(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
require.Nil(t, envelope.Data.Account)
require.Equal(t, 1, recoverer.calls)
require.Zero(t, quota.queryCalls)
require.Zero(t, quota.cacheCalls)
require.Zero(t, adminService.calls)
}
func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
adminService := recoveredAccountStub()
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
}
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.False(t, envelope.Data.AccountStateRecovered)
require.Zero(t, quota.queryCalls)
require.Zero(t, adminService.calls)
}
// A failed cache refresh must not hide the recovered account row: the operator
// still needs the list to drop the stale rate-limit badge.
func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
quota.queryResult = nil
quota.queryErr = errors.New("upstream query failed")
recoverer := &openAIAccountStateRecovererStub{}
adminService := recoveredAccountStub()
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
rateLimitService: recoverer,
}
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
require.NotNil(t, envelope.Data.Account)
require.Equal(t, 1, quota.queryCalls)
require.Zero(t, quota.cacheCalls)
require.Equal(t, 1, recoverer.calls)
require.Equal(t, 1, adminService.calls)
}
func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
quota.cacheErr = errors.New("cache write failed")
recoverer := &openAIAccountStateRecovererStub{}
adminService := recoveredAccountStub()
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
rateLimitService: recoverer,
}
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
require.NotNil(t, envelope.Data.Account)
require.Equal(t, 1, quota.cacheCalls)
require.Equal(t, 1, adminService.calls)
}
func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
recoverer := &openAIAccountStateRecovererStub{}
adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")}
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
rateLimitService: recoverer,
}
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.CacheRefreshed)
require.True(t, envelope.Data.AccountStateRecovered)
require.NotNil(t, envelope.Data.Quota)
require.Nil(t, envelope.Data.Account)
require.Equal(t, 1, adminService.calls)
}
// The first (most actionable) failure wins so the UI never downgrades a cache
// problem into a cosmetic "could not reload the row" message.
func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
quota.cacheErr = errors.New("cache write failed")
recoverer := &openAIAccountStateRecovererStub{}
adminService := &openAIResetAdminServiceStub{err: errors.New("account refresh failed")}
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
rateLimitService: recoverer,
}
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.Nil(t, envelope.Data.Account)
}
// The credit is non-refundable once consumed, so post-reset bookkeeping must
// survive a client disconnect instead of leaving the account rate-limited.
func TestOpenAIResetQuota_PostProcessingSurvivesClientCancellation(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
recoverer := &openAIAccountStateRecovererStub{}
adminService := recoveredAccountStub()
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
rateLimitService: recoverer,
}
ctx, cancel := context.WithCancel(context.Background())
cancel()
status, _ := performOpenAIQuotaResetRequestWithContext(t, handler, ctx)
require.Equal(t, http.StatusOK, status)
require.Equal(t, 1, recoverer.calls)
require.NoError(t, recoverer.lastCtxErr, "recovery must not inherit the canceled client context")
require.NoError(t, quota.queryCtxErr)
require.NoError(t, quota.cacheCtxErr)
require.Equal(t, 1, adminService.calls)
}
func TestOpenAIRefreshQuota_PersistsSnapshot(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
handler := &OpenAIOAuthHandler{
adminService: &openAIResetAdminServiceStub{},
quotaService: quota,
}
status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.True(t, envelope.Data.CachePersisted)
require.Equal(t, int64(123), envelope.Data.FetchedAt)
require.Equal(t, 1, quota.queryCalls)
require.Equal(t, 1, quota.cacheCalls)
require.Zero(t, quota.resetCalls)
}
// A rejected snapshot write must never discard the usage payload: otherwise the
// card loses its credit count and the reset button stays disabled forever.
func TestOpenAIRefreshQuota_PersistFailureStillReturnsUsage(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
quota.queryResult = &service.OpenAIQuotaUsage{
FetchedAt: 456,
RateLimitResetCredits: &service.OpenAIRateLimitResetCredits{
AvailableCount: 2,
},
}
quota.cacheErr = errors.New("expiration details unavailable")
handler := &OpenAIOAuthHandler{
adminService: &openAIResetAdminServiceStub{},
quotaService: quota,
}
status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.False(t, envelope.Data.CachePersisted)
require.Equal(t, int64(456), envelope.Data.FetchedAt)
require.NotNil(t, envelope.Data.RateLimitResetCredits)
require.Equal(t, 2, envelope.Data.RateLimitResetCredits.AvailableCount)
require.Equal(t, 1, quota.cacheCalls)
}
// An empty-but-successful upstream read must not be dereferenced blindly.
func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) {
t.Run("refresh reports an internal error", func(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
quota.queryResult = nil
handler := &OpenAIOAuthHandler{
adminService: &openAIResetAdminServiceStub{},
quotaService: quota,
}
status, envelope := performOpenAIQuotaRefreshRequest(t, handler)
require.Equal(t, http.StatusInternalServerError, status)
require.False(t, envelope.Data.CachePersisted)
require.Zero(t, quota.cacheCalls)
})
t.Run("reset degrades to a cache warning", func(t *testing.T) {
quota := successfulOpenAIQuotaWorkflowStub()
quota.queryResult = nil
recoverer := &openAIAccountStateRecovererStub{}
adminService := recoveredAccountStub()
handler := &OpenAIOAuthHandler{
adminService: adminService,
quotaService: quota,
rateLimitService: recoverer,
}
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.NotNil(t, envelope.Data.Account)
require.Zero(t, quota.cacheCalls)
})
}
func TestOpenAIRefreshQuota_QueryFailureIsReported(t *testing.T) {
quota := &openAIQuotaWorkflowStub{queryErr: errors.New("upstream query failed")}
handler := &OpenAIOAuthHandler{
adminService: &openAIResetAdminServiceStub{},
quotaService: quota,
}
status, _ := performOpenAIQuotaRefreshRequest(t, handler)
require.Equal(t, http.StatusInternalServerError, status)
require.Equal(t, 1, quota.queryCalls)
require.Zero(t, quota.cacheCalls)
}
// Storing a nil *Service in an interface field would make the capability guards
// non-nil and panic on the first call; the constructor must keep them nil.
func TestNewOpenAIOAuthHandlerKeepsNilQuotaCapabilitiesGuarded(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewOpenAIOAuthHandler(nil, newStubAdminService(), nil, nil)
require.Nil(t, handler.quotaService)
require.Nil(t, handler.rateLimitService)
router := gin.New()
router.Use(gin.Recovery())
router.GET("/openai/accounts/:id/quota", handler.QueryQuota)
router.POST("/openai/accounts/:id/quota/refresh", handler.RefreshQuota)
router.POST("/openai/accounts/:id/reset-quota", handler.ResetQuota)
for _, tc := range []struct {
method string
path string
}{
{http.MethodGet, "/openai/accounts/42/quota"},
{http.MethodPost, "/openai/accounts/42/quota/refresh"},
{http.MethodPost, "/openai/accounts/42/reset-quota"},
} {
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(tc.method, tc.path, nil))
require.Equal(t, http.StatusBadRequest, recorder.Code, "%s %s", tc.method, tc.path)
}
}
@@ -20,7 +20,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &stubAdminService{}
h := NewOpenAIOAuthHandler(nil, stub, nil)
h := NewOpenAIOAuthHandler(nil, stub, nil, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
@@ -54,7 +54,7 @@ func TestCreateShadow_ReturnsCreatedShadow(t *testing.T) {
func TestCreateShadow_InvalidID(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
@@ -72,7 +72,7 @@ func TestCreateShadow_ServiceError(t *testing.T) {
gin.SetMode(gin.TestMode)
stub := &stubAdminService{createSparkShadowErr: errors.New("database unavailable")}
h := NewOpenAIOAuthHandler(nil, stub, nil)
h := NewOpenAIOAuthHandler(nil, stub, nil, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
@@ -91,7 +91,7 @@ func TestCreateShadow_ServiceError(t *testing.T) {
func TestCreateShadow_BadBody(t *testing.T) {
gin.SetMode(gin.TestMode)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil)
h := NewOpenAIOAuthHandler(nil, &stubAdminService{}, nil, nil)
router := gin.New()
router.POST("/api/v1/admin/accounts/:id/shadow", h.CreateShadow)
@@ -54,6 +54,7 @@ type SettingHandler struct {
settingService *service.SettingService
emailService *service.EmailService
turnstileService *service.TurnstileService
aliyunCaptchaService *service.AliyunCaptchaService
opsService *service.OpsService
paymentConfigService *service.PaymentConfigService
paymentService *service.PaymentService
@@ -82,6 +83,12 @@ func (h *SettingHandler) SetNotificationEmailService(notificationEmailService *s
h.notificationEmailService = notificationEmailService
}
// SetAliyunCaptchaService attaches the Aliyun captcha credential validator without
// changing the constructor signature used by existing unit tests.
func (h *SettingHandler) SetAliyunCaptchaService(aliyunCaptchaService *service.AliyunCaptchaService) {
h.aliyunCaptchaService = aliyunCaptchaService
}
// SetStepUpDeps attaches the services backing the step-up switch preconditions
// (enable requires the acting admin to have TOTP enabled; disable is itself a
// step-up gated operation), without changing the constructor signature used by
@@ -129,6 +136,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
FrontendURL: settings.FrontendURL,
@@ -156,6 +164,18 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
TurnstileSecretKeyConfigured: settings.TurnstileSecretKeyConfigured,
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
TencentCaptchaAppID: settings.TencentCaptchaAppID,
TencentCaptchaAppSecretKeyConfigured: settings.TencentCaptchaAppSecretKeyConfigured,
TencentCaptchaCloudSecretIDConfigured: settings.TencentCaptchaCloudSecretIDConfigured,
TencentCaptchaCloudSecretKeyConfigured: settings.TencentCaptchaCloudSecretKeyConfigured,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: settings.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecretConfigured: settings.AliyunCaptchaAccessKeySecretConfigured,
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
APIKeyACLTrustForwardedIP: settings.APIKeyACLTrustForwardedIP,
ForwardedClientIPHeaders: settings.ForwardedClientIPHeaders,
LinuxDoConnectEnabled: settings.LinuxDoConnectEnabled,
@@ -279,6 +299,9 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
EnableClientDatelineNormalization: settings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: settings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: settings.OpenAICodexUserAgent,
OpenAICodexClientVersion: settings.OpenAICodexClientVersion,
OpenAICodexClientVersionSynced: settings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: settings.OpenAICodexVersionAutoSyncEnabled,
MinCodexVersion: settings.MinCodexVersion,
MaxCodexVersion: settings.MaxCodexVersion,
CodexCLIOnlyBlacklist: settings.CodexCLIOnlyBlacklist,
@@ -348,7 +371,13 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentAlipayMobilePrecreateDeepLink: paymentCfg.AlipayMobilePrecreateDeepLink,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
GrokDefaultTextModel: settings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
@@ -358,7 +387,8 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AffiliateEnabled: settings.AffiliateEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
AccountSchedulingThresholds: settings.AccountSchedulingThresholds,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
}
// OpenAI fast policy (stored under a dedicated setting key)
@@ -41,6 +41,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if !equalStringSlice(before.RegistrationEmailSuffixWhitelist, after.RegistrationEmailSuffixWhitelist) {
changed = append(changed, "registration_email_suffix_whitelist")
}
if before.RegistrationEmailDomainQuotaEnabled != after.RegistrationEmailDomainQuotaEnabled {
changed = append(changed, "registration_email_domain_quota_enabled")
}
if before.PromoCodeEnabled != after.PromoCodeEnabled {
changed = append(changed, "promo_code_enabled")
}
@@ -107,6 +110,42 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if req.TurnstileSecretKey != "" {
changed = append(changed, "turnstile_secret_key")
}
if before.TencentCaptchaEnabled != after.TencentCaptchaEnabled {
changed = append(changed, "tencent_captcha_enabled")
}
if before.TencentCaptchaAppID != after.TencentCaptchaAppID {
changed = append(changed, "tencent_captcha_app_id")
}
if req.TencentCaptchaAppSecretKey != "" {
changed = append(changed, "tencent_captcha_app_secret_key")
}
if req.TencentCaptchaCloudSecretID != "" {
changed = append(changed, "tencent_captcha_cloud_secret_id")
}
if req.TencentCaptchaCloudSecretKey != "" {
changed = append(changed, "tencent_captcha_cloud_secret_key")
}
if before.TencentCaptchaRegion != after.TencentCaptchaRegion {
changed = append(changed, "tencent_captcha_region")
}
if before.AliyunCaptchaEnabled != after.AliyunCaptchaEnabled {
changed = append(changed, "aliyun_captcha_enabled")
}
if before.AliyunCaptchaAccessKeyID != after.AliyunCaptchaAccessKeyID {
changed = append(changed, "aliyun_captcha_access_key_id")
}
if req.AliyunCaptchaAccessKeySecret != "" {
changed = append(changed, "aliyun_captcha_access_key_secret")
}
if before.AliyunCaptchaSceneID != after.AliyunCaptchaSceneID {
changed = append(changed, "aliyun_captcha_scene_id")
}
if before.AliyunCaptchaPrefix != after.AliyunCaptchaPrefix {
changed = append(changed, "aliyun_captcha_prefix")
}
if before.AliyunCaptchaRegion != after.AliyunCaptchaRegion {
changed = append(changed, "aliyun_captcha_region")
}
if before.APIKeyACLTrustForwardedIP != after.APIKeyACLTrustForwardedIP {
changed = append(changed, "api_key_acl_trust_forwarded_ip")
}
@@ -443,6 +482,12 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.OpenAICodexUserAgent != after.OpenAICodexUserAgent {
changed = append(changed, "openai_codex_user_agent")
}
if before.OpenAICodexClientVersion != after.OpenAICodexClientVersion {
changed = append(changed, "openai_codex_client_version")
}
if before.OpenAICodexVersionAutoSyncEnabled != after.OpenAICodexVersionAutoSyncEnabled {
changed = append(changed, "openai_codex_version_auto_sync_enabled")
}
if before.PaymentVisibleMethodAlipaySource != after.PaymentVisibleMethodAlipaySource {
changed = append(changed, "payment_visible_method_alipay_source")
}
@@ -556,6 +601,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if !equalPlatformQuotaSettings(before.DefaultPlatformQuotas, after.DefaultPlatformQuotas) {
changed = append(changed, service.SettingKeyDefaultPlatformQuotas)
}
if !equalAccountSchedulingThresholds(before.AccountSchedulingThresholds, after.AccountSchedulingThresholds) {
changed = append(changed, service.SettingKeyAccountSchedulingThresholds)
}
changed = appendAuthSourceDefaultChanges(changed, beforeAuthSourceDefaults, afterAuthSourceDefaults)
return changed
}
@@ -769,6 +817,27 @@ func slotOf(s *service.DefaultPlatformQuotaSetting, win string) *float64 {
}
// equalPlatformQuotaSettings reports whether two platform-quota maps are identical across all allowed slots.
func equalAccountSchedulingThresholds(before, after map[string]int) bool {
for _, platform := range service.AllowedSchedulingThresholdPlatforms {
beforeValue := 100
if before != nil {
if value, ok := before[platform]; ok {
beforeValue = value
}
}
afterValue := 100
if after != nil {
if value, ok := after[platform]; ok {
afterValue = value
}
}
if beforeValue != afterValue {
return false
}
}
return true
}
func equalPlatformQuotaSettings(before, after map[string]*service.DefaultPlatformQuotaSetting) bool {
for _, platform := range service.AllowedQuotaPlatforms {
b := before[platform]
@@ -65,3 +65,124 @@ func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) {
require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom])
}
func TestUpdateSettingsGrokDefaultBaseURLModeIsWritable(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyGrokDefaultBaseURLMode: service.GrokDefaultBaseURLModeCLI,
})
rec := doUpdateSettings(t, h, map[string]any{
"grok_default_base_url_mode": service.GrokDefaultBaseURLModeEUWest1,
}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.GrokDefaultBaseURLModeEUWest1, repo.values[service.SettingKeyGrokDefaultBaseURLMode])
}
func TestUpdateSettingsRejectsTwoCaptchaProviders(t *testing.T) {
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTurnstileEnabled: "true",
service.SettingKeyTurnstileSiteKey: "site-key",
service.SettingKeyTurnstileSecretKey: "turnstile-secret",
})
rec := doUpdateSettings(t, h, map[string]any{
"turnstile_enabled": true,
"turnstile_site_key": "site-key",
"turnstile_secret_key": "turnstile-secret",
"tencent_captcha_enabled": true,
"tencent_captcha_app_id": "123456789",
"tencent_captcha_app_secret_key": "app-secret",
"tencent_captcha_cloud_secret_id": "cloud-secret-id",
"tencent_captcha_cloud_secret_key": "cloud-secret-key",
}, nil)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), "cannot be enabled at the same time")
}
func TestUpdateSettingsRequiresFourTencentCaptchaCredentialsWhenEnabled(t *testing.T) {
h, _ := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{
"tencent_captcha_enabled": true,
"tencent_captcha_app_id": "123456789",
}, nil)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), "AppSecretKey")
}
func TestUpdateSettingsRetainsStoredTencentCaptchaCredentialsWhenInputsEmpty(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret",
service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id",
service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key",
})
rec := doUpdateSettings(t, h, map[string]any{
"tencent_captcha_enabled": true,
"tencent_captcha_app_id": "123456789",
"tencent_captcha_app_secret_key": "",
"tencent_captcha_cloud_secret_id": "",
"tencent_captcha_cloud_secret_key": "",
}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "stored-app-secret", repo.values[service.SettingKeyTencentCaptchaAppSecretKey])
require.Equal(t, "stored-cloud-secret-id", repo.values[service.SettingKeyTencentCaptchaCloudSecretID])
require.Equal(t, "stored-cloud-secret-key", repo.values[service.SettingKeyTencentCaptchaCloudSecretKey])
}
// 天御站点决定前端加载哪个 SDK 与服务端打哪个接入点,两端必须一致。
// 部分载荷把它重置回中国站,会让已配国际站的部署在下一次任意保存后整体失效。
func TestUpdateSettingsPartialPayloadKeepsTencentCaptchaRegion(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
})
rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionINTL,
repo.values[service.SettingKeyTencentCaptchaRegion])
}
func TestUpdateSettingsNormalizesUnknownTencentCaptchaRegion(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaRegion: service.TencentCaptchaRegionINTL,
})
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "sgp"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionCN,
repo.values[service.SettingKeyTencentCaptchaRegion],
"未知站点必须落回中国站,不能写入无法识别的值")
}
func TestUpdateSettingsWritesTencentCaptchaRegionWhenSent(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{})
rec := doUpdateSettings(t, h, map[string]any{"tencent_captcha_region": "intl"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, service.TencentCaptchaRegionINTL,
repo.values[service.SettingKeyTencentCaptchaRegion])
}
func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *testing.T) {
h, _ := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeyTencentCaptchaEnabled: "true",
service.SettingKeyTencentCaptchaAppID: "123456789",
service.SettingKeyTencentCaptchaAppSecretKey: "stored-app-secret",
service.SettingKeyTencentCaptchaCloudSecretID: "stored-cloud-secret-id",
service.SettingKeyTencentCaptchaCloudSecretKey: "stored-cloud-secret-key",
})
rec := doUpdateSettings(t, h, map[string]any{
"tencent_captcha_app_id": "not-a-number",
}, nil)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), "positive integer")
}
@@ -64,6 +64,23 @@ func TestDiffSettings_NoChangeWhenEqual(t *testing.T) {
}
}
func TestSettingsAuditRequestDoesNotInheritStoredTencentSecrets(t *testing.T) {
req := UpdateSettingsRequest{
TencentCaptchaAppSecretKey: " ",
TencentCaptchaCloudSecretID: "\t",
TencentCaptchaCloudSecretKey: "\n",
}
auditReq := settingsAuditRequest(req)
req.TencentCaptchaAppSecretKey = "stored-app-secret"
req.TencentCaptchaCloudSecretID = "stored-secret-id"
req.TencentCaptchaCloudSecretKey = "stored-secret-key"
require.Empty(t, auditReq.TencentCaptchaAppSecretKey)
require.Empty(t, auditReq.TencentCaptchaCloudSecretID)
require.Empty(t, auditReq.TencentCaptchaCloudSecretKey)
}
func TestDiffSettings_DetectsCompactHomeChange(t *testing.T) {
changed := diffSettings(
&service.SystemSettings{},
@@ -7,6 +7,7 @@ import (
"log/slog"
"net/http"
"reflect"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -22,22 +23,23 @@ import (
// UpdateSettingsRequest 更新设置请求
type UpdateSettingsRequest struct {
// 注册设置
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值)
SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值)
StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值)
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"`
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
RegistrationEmailDomainQuotaEnabled *bool `json:"registration_email_domain_quota_enabled"` // 非白名单域名限量注册开关(省略=保持现值)
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值)
SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值)
StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值)
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementDocuments []dto.LoginAgreementDocument `json:"login_agreement_documents"`
// 邮件服务设置
SMTPHost string `json:"smtp_host"`
@@ -53,6 +55,22 @@ type UpdateSettingsRequest struct {
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecretKey string `json:"turnstile_secret_key"`
// 腾讯天御验证码设置
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaAppSecretKey string `json:"tencent_captcha_app_secret_key"`
TencentCaptchaCloudSecretID string `json:"tencent_captcha_cloud_secret_id"`
TencentCaptchaCloudSecretKey string `json:"tencent_captcha_cloud_secret_key"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
// 阿里云验证码 2.0 设置
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"`
AliyunCaptchaAccessKeySecret string `json:"aliyun_captcha_access_key_secret"`
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
// API Key IP 访问控制设置
APIKeyACLTrustForwardedIP *bool `json:"api_key_acl_trust_forwarded_ip"`
ForwardedClientIPHeaders *[]string `json:"forwarded_client_ip_headers"`
@@ -236,6 +254,8 @@ type UpdateSettingsRequest struct {
EnableClientDatelineNormalization *bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion *string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent *string `json:"openai_codex_user_agent"`
OpenAICodexClientVersion *string `json:"openai_codex_client_version"`
OpenAICodexVersionAutoSyncEnabled *bool `json:"openai_codex_version_auto_sync_enabled"`
// codex_cli_only 加固(global-only)
MinCodexVersion string `json:"min_codex_version"`
@@ -308,8 +328,15 @@ type UpdateSettingsRequest struct {
PaymentAlipayMobilePrecreateDeepLink *bool `json:"payment_alipay_mobile_precreate_deep_link"`
// Channel Monitor feature switch
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
ChannelMonitorMode *string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
// Grok model mapping policy
GrokDefaultTextModel *string `json:"grok_default_text_model"`
GrokCrossClientModelMapEnabled *bool `json:"grok_cross_client_model_map_enabled"`
GrokDefaultBaseURLMode *string `json:"grok_default_base_url_mode"`
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
@@ -335,6 +362,9 @@ type UpdateSettingsRequest struct {
// 系统全局 platform quota 默认值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。
DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas"`
// 各平台账号自动停调阈值(整体替换语义:nil = 不修改,non-nil = 整体覆盖)。
AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds"`
// auth-source 层 platform quota 覆盖(override 语义:nil = 不修改,non-nil = 整体覆盖该 source 的 quota 配置)。
AuthSourceEmailPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_email_platform_quotas"`
AuthSourceLinuxDoPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"auth_source_default_linuxdo_platform_quotas"`
@@ -436,6 +466,14 @@ func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSe
return omitted
}
func settingsAuditRequest(req UpdateSettingsRequest) UpdateSettingsRequest {
req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey)
req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID)
req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey)
req.AliyunCaptchaAccessKeySecret = strings.TrimSpace(req.AliyunCaptchaAccessKeySecret)
return req
}
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
var sentFields map[string]json.RawMessage
if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil {
@@ -447,6 +485,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
auditReq := settingsAuditRequest(req)
omitted := omittedSettingKeys(sentFields)
previousSettings, err := h.settingService.GetAllSettings(c.Request.Context())
@@ -474,6 +513,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.PasskeyEnabled != nil {
passkeyEnabled = *req.PasskeyEnabled
}
registrationEmailDomainQuotaEnabled := previousSettings.RegistrationEmailDomainQuotaEnabled
if req.RegistrationEmailDomainQuotaEnabled != nil {
registrationEmailDomainQuotaEnabled = *req.RegistrationEmailDomainQuotaEnabled
}
if passkeyEnabled {
configured, _, _ := h.settingService.PasskeyConfiguration()
if !configured {
@@ -563,6 +606,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
req.SMTPPassword = strings.TrimSpace(req.SMTPPassword)
req.SMTPFrom = strings.TrimSpace(req.SMTPFrom)
req.SMTPFromName = strings.TrimSpace(req.SMTPFromName)
req.TencentCaptchaAppID = strings.TrimSpace(req.TencentCaptchaAppID)
req.TencentCaptchaAppSecretKey = strings.TrimSpace(req.TencentCaptchaAppSecretKey)
req.TencentCaptchaCloudSecretID = strings.TrimSpace(req.TencentCaptchaCloudSecretID)
req.TencentCaptchaCloudSecretKey = strings.TrimSpace(req.TencentCaptchaCloudSecretKey)
if req.SMTPPort <= 0 {
req.SMTPPort = 587
}
@@ -584,6 +631,43 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
req.SMTPUseTLS = previousSettings.SMTPUseTLS
}
turnstileEnabled := req.TurnstileEnabled
if _, sent := sentFields["turnstile_enabled"]; !sent {
turnstileEnabled = previousSettings.TurnstileEnabled
}
tencentCaptchaEnabled := req.TencentCaptchaEnabled
if _, sent := sentFields["tencent_captcha_enabled"]; !sent {
tencentCaptchaEnabled = previousSettings.TencentCaptchaEnabled
}
aliyunCaptchaEnabled := req.AliyunCaptchaEnabled
if _, sent := sentFields["aliyun_captcha_enabled"]; !sent {
aliyunCaptchaEnabled = previousSettings.AliyunCaptchaEnabled
}
enabledCaptchaProviders := 0
for _, enabled := range []bool{turnstileEnabled, tencentCaptchaEnabled, aliyunCaptchaEnabled} {
if enabled {
enabledCaptchaProviders++
}
}
if enabledCaptchaProviders > 1 {
response.BadRequest(c, "Multiple captcha providers (Cloudflare Turnstile / Tencent Captcha / Aliyun Captcha) cannot be enabled at the same time")
return
}
// 阿里云地域 normalize:未发送保留已存值,非法值一律按中国内地落库
if _, sent := sentFields["aliyun_captcha_region"]; !sent {
req.AliyunCaptchaRegion = previousSettings.AliyunCaptchaRegion
}
if req.AliyunCaptchaRegion != service.AliyunCaptchaRegionSGP {
req.AliyunCaptchaRegion = service.AliyunCaptchaRegionCN
}
// 天御站点 normalize:未发送保留已存值,非法值一律按中国站落库
if _, sent := sentFields["tencent_captcha_region"]; !sent {
req.TencentCaptchaRegion = previousSettings.TencentCaptchaRegion
}
if req.TencentCaptchaRegion != service.TencentCaptchaRegionINTL {
req.TencentCaptchaRegion = service.TencentCaptchaRegionCN
}
// Turnstile 参数验证
if req.TurnstileEnabled {
// 检查必填字段
@@ -611,6 +695,83 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
if tencentCaptchaEnabled {
if _, sent := sentFields["tencent_captcha_app_id"]; !sent {
req.TencentCaptchaAppID = previousSettings.TencentCaptchaAppID
}
appID, err := strconv.ParseUint(req.TencentCaptchaAppID, 10, 64)
if err != nil || appID == 0 {
response.BadRequest(c, "Tencent Captcha CaptchaAppId must be a positive integer when enabled")
return
}
if req.TencentCaptchaAppSecretKey == "" {
req.TencentCaptchaAppSecretKey = previousSettings.TencentCaptchaAppSecretKey
}
if req.TencentCaptchaCloudSecretID == "" {
req.TencentCaptchaCloudSecretID = previousSettings.TencentCaptchaCloudSecretID
}
if req.TencentCaptchaCloudSecretKey == "" {
req.TencentCaptchaCloudSecretKey = previousSettings.TencentCaptchaCloudSecretKey
}
if req.TencentCaptchaAppSecretKey == "" {
response.BadRequest(c, "Tencent Captcha AppSecretKey is required when enabled")
return
}
if req.TencentCaptchaCloudSecretID == "" {
response.BadRequest(c, "Tencent Cloud SecretId is required when Tencent Captcha is enabled")
return
}
if req.TencentCaptchaCloudSecretKey == "" {
response.BadRequest(c, "Tencent Cloud SecretKey is required when Tencent Captcha is enabled")
return
}
}
// 阿里云验证码 2.0 参数验证
if aliyunCaptchaEnabled {
if _, sent := sentFields["aliyun_captcha_scene_id"]; !sent {
req.AliyunCaptchaSceneID = previousSettings.AliyunCaptchaSceneID
}
if _, sent := sentFields["aliyun_captcha_prefix"]; !sent {
req.AliyunCaptchaPrefix = previousSettings.AliyunCaptchaPrefix
}
if _, sent := sentFields["aliyun_captcha_access_key_id"]; !sent {
req.AliyunCaptchaAccessKeyID = previousSettings.AliyunCaptchaAccessKeyID
}
if req.AliyunCaptchaSceneID == "" {
response.BadRequest(c, "Aliyun Captcha Scene ID is required when enabled")
return
}
if req.AliyunCaptchaPrefix == "" {
response.BadRequest(c, "Aliyun Captcha Prefix is required when enabled")
return
}
if req.AliyunCaptchaAccessKeyID == "" {
response.BadRequest(c, "Aliyun Captcha AccessKey ID is required when enabled")
return
}
// 如果未提供 AccessKey Secret,使用已保存的值(留空保留当前值)
if req.AliyunCaptchaAccessKeySecret == "" {
if previousSettings.AliyunCaptchaAccessKeySecret == "" {
response.BadRequest(c, "Aliyun Captcha AccessKey Secret is required when enabled")
return
}
req.AliyunCaptchaAccessKeySecret = previousSettings.AliyunCaptchaAccessKeySecret
}
// 凭证任一变化时真实调用一次阿里云校验(避免配置错误导致无法登录)
credentialsChanged := previousSettings.AliyunCaptchaAccessKeyID != req.AliyunCaptchaAccessKeyID ||
previousSettings.AliyunCaptchaAccessKeySecret != req.AliyunCaptchaAccessKeySecret ||
previousSettings.AliyunCaptchaSceneID != req.AliyunCaptchaSceneID ||
previousSettings.AliyunCaptchaRegion != req.AliyunCaptchaRegion
if credentialsChanged {
if err := h.aliyunCaptchaService.ValidateCredentials(c.Request.Context(), req.AliyunCaptchaAccessKeyID, req.AliyunCaptchaAccessKeySecret, req.AliyunCaptchaSceneID, req.AliyunCaptchaRegion); err != nil {
response.ErrorFrom(c, err)
return
}
}
}
// TOTP 双因素认证参数验证
// 只有手动配置了加密密钥才允许启用 TOTP 功能
if req.TotpEnabled && !previousSettings.TotpEnabled {
@@ -1276,6 +1437,15 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
return
}
}
if req.OpenAICodexClientVersion != nil {
// 该值会被拼进出站 User-Agent 与 version 头,必须是合法版本号;空串表示跟随自动同步。
normalized := strings.TrimSpace(*req.OpenAICodexClientVersion)
if normalized != "" && service.NormalizeCodexClientVersion(normalized) == "" {
response.Error(c, http.StatusBadRequest, "openai_codex_client_version must be empty or a valid version (e.g. 0.146.0)")
return
}
req.OpenAICodexClientVersion = &normalized
}
// codex_cli_only 加固:最低/最高 Codex 版本(空=禁用,或合法 semver;max>=min)
if req.MinCodexVersion != "" && !semverPattern.MatchString(req.MinCodexVersion) {
@@ -1321,34 +1491,48 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
settings := &service.SystemSettings{
// 系统全局 platform quota 默认值(整体替换语义)
DefaultPlatformQuotas: req.DefaultPlatformQuotas,
DefaultPlatformQuotas: req.DefaultPlatformQuotas,
AccountSchedulingThresholds: req.AccountSchedulingThresholds,
RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled,
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
PasskeyEnabled: passkeyEnabled,
SessionBindingEnabled: sessionBindingEnabled,
StepUpEnabled: stepUpEnabled,
AuditLogRetentionDays: req.AuditLogRetentionDays,
LoginAgreementEnabled: req.LoginAgreementEnabled,
LoginAgreementMode: loginAgreementMode,
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocuments,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPUsername: req.SMTPUsername,
SMTPPassword: req.SMTPPassword,
SMTPFrom: req.SMTPFrom,
SMTPFromName: req.SMTPFromName,
SMTPUseTLS: req.SMTPUseTLS,
TurnstileEnabled: req.TurnstileEnabled,
TurnstileSiteKey: req.TurnstileSiteKey,
TurnstileSecretKey: req.TurnstileSecretKey,
RegistrationEnabled: req.RegistrationEnabled,
EmailVerifyEnabled: req.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: req.RegistrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: registrationEmailDomainQuotaEnabled,
PromoCodeEnabled: req.PromoCodeEnabled,
PasswordResetEnabled: req.PasswordResetEnabled,
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
PasskeyEnabled: passkeyEnabled,
SessionBindingEnabled: sessionBindingEnabled,
StepUpEnabled: stepUpEnabled,
AuditLogRetentionDays: req.AuditLogRetentionDays,
LoginAgreementEnabled: req.LoginAgreementEnabled,
LoginAgreementMode: loginAgreementMode,
LoginAgreementUpdatedAt: loginAgreementUpdatedAt,
LoginAgreementDocuments: loginAgreementDocuments,
SMTPHost: req.SMTPHost,
SMTPPort: req.SMTPPort,
SMTPUsername: req.SMTPUsername,
SMTPPassword: req.SMTPPassword,
SMTPFrom: req.SMTPFrom,
SMTPFromName: req.SMTPFromName,
SMTPUseTLS: req.SMTPUseTLS,
TurnstileEnabled: req.TurnstileEnabled,
TurnstileSiteKey: req.TurnstileSiteKey,
TurnstileSecretKey: req.TurnstileSecretKey,
TencentCaptchaEnabled: req.TencentCaptchaEnabled,
TencentCaptchaAppID: req.TencentCaptchaAppID,
TencentCaptchaAppSecretKey: req.TencentCaptchaAppSecretKey,
TencentCaptchaCloudSecretID: req.TencentCaptchaCloudSecretID,
TencentCaptchaCloudSecretKey: req.TencentCaptchaCloudSecretKey,
TencentCaptchaRegion: req.TencentCaptchaRegion,
AliyunCaptchaEnabled: req.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: req.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecret: req.AliyunCaptchaAccessKeySecret,
AliyunCaptchaSceneID: req.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: req.AliyunCaptchaPrefix,
AliyunCaptchaRegion: req.AliyunCaptchaRegion,
APIKeyACLTrustForwardedIP: func() bool {
if req.APIKeyACLTrustForwardedIP != nil {
return *req.APIKeyACLTrustForwardedIP
@@ -1555,6 +1739,20 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.OpenAICodexUserAgent
}(),
OpenAICodexClientVersion: func() string {
if req.OpenAICodexClientVersion != nil {
return *req.OpenAICodexClientVersion
}
return previousSettings.OpenAICodexClientVersion
}(),
// 同步值由自动同步任务独占写入,面板保存时原样带回,避免被清空。
OpenAICodexClientVersionSynced: previousSettings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: func() bool {
if req.OpenAICodexVersionAutoSyncEnabled != nil {
return *req.OpenAICodexVersionAutoSyncEnabled
}
return previousSettings.OpenAICodexVersionAutoSyncEnabled
}(),
MinCodexVersion: strings.TrimSpace(req.MinCodexVersion),
MaxCodexVersion: strings.TrimSpace(req.MaxCodexVersion),
CodexCLIOnlyBlacklist: strings.TrimSpace(req.CodexCLIOnlyBlacklist),
@@ -1673,12 +1871,42 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.ChannelMonitorEnabled
}(),
ChannelMonitorMode: func() string {
if req.ChannelMonitorMode != nil {
return *req.ChannelMonitorMode
}
return previousSettings.ChannelMonitorMode
}(),
ChannelMonitorDefaultIntervalSeconds: func() int {
if req.ChannelMonitorDefaultIntervalSeconds != nil {
return *req.ChannelMonitorDefaultIntervalSeconds
}
return previousSettings.ChannelMonitorDefaultIntervalSeconds
}(),
ChannelMonitorHideThroughput: func() bool {
if req.ChannelMonitorHideThroughput != nil {
return *req.ChannelMonitorHideThroughput
}
return previousSettings.ChannelMonitorHideThroughput
}(),
GrokDefaultTextModel: func() string {
if req.GrokDefaultTextModel != nil {
return *req.GrokDefaultTextModel
}
return previousSettings.GrokDefaultTextModel
}(),
GrokCrossClientModelMapEnabled: func() bool {
if req.GrokCrossClientModelMapEnabled != nil {
return *req.GrokCrossClientModelMapEnabled
}
return previousSettings.GrokCrossClientModelMapEnabled
}(),
GrokDefaultBaseURLMode: func() string {
if req.GrokDefaultBaseURLMode != nil {
return strings.TrimSpace(*req.GrokDefaultBaseURLMode)
}
return previousSettings.GrokDefaultBaseURLMode
}(),
AvailableChannelsEnabled: func() bool {
if req.AvailableChannelsEnabled != nil {
return *req.AvailableChannelsEnabled
@@ -1844,7 +2072,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
}
h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, req)
h.auditSettingsUpdate(c, previousSettings, settings, previousAuthSourceDefaults, authSourceDefaults, auditReq)
// 重新获取设置返回
updatedSettings, err := h.settingService.GetAllSettings(c.Request.Context())
@@ -1880,6 +2108,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
RegistrationEnabled: updatedSettings.RegistrationEnabled,
EmailVerifyEnabled: updatedSettings.EmailVerifyEnabled,
RegistrationEmailSuffixWhitelist: updatedSettings.RegistrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: updatedSettings.RegistrationEmailDomainQuotaEnabled,
PromoCodeEnabled: updatedSettings.PromoCodeEnabled,
PasswordResetEnabled: updatedSettings.PasswordResetEnabled,
FrontendURL: updatedSettings.FrontendURL,
@@ -1907,6 +2136,18 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
TurnstileEnabled: updatedSettings.TurnstileEnabled,
TurnstileSiteKey: updatedSettings.TurnstileSiteKey,
TurnstileSecretKeyConfigured: updatedSettings.TurnstileSecretKeyConfigured,
TencentCaptchaEnabled: updatedSettings.TencentCaptchaEnabled,
TencentCaptchaAppID: updatedSettings.TencentCaptchaAppID,
TencentCaptchaAppSecretKeyConfigured: updatedSettings.TencentCaptchaAppSecretKeyConfigured,
TencentCaptchaCloudSecretIDConfigured: updatedSettings.TencentCaptchaCloudSecretIDConfigured,
TencentCaptchaCloudSecretKeyConfigured: updatedSettings.TencentCaptchaCloudSecretKeyConfigured,
TencentCaptchaRegion: updatedSettings.TencentCaptchaRegion,
AliyunCaptchaEnabled: updatedSettings.AliyunCaptchaEnabled,
AliyunCaptchaAccessKeyID: updatedSettings.AliyunCaptchaAccessKeyID,
AliyunCaptchaAccessKeySecretConfigured: updatedSettings.AliyunCaptchaAccessKeySecretConfigured,
AliyunCaptchaSceneID: updatedSettings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: updatedSettings.AliyunCaptchaPrefix,
AliyunCaptchaRegion: updatedSettings.AliyunCaptchaRegion,
APIKeyACLTrustForwardedIP: updatedSettings.APIKeyACLTrustForwardedIP,
ForwardedClientIPHeaders: updatedSettings.ForwardedClientIPHeaders,
LinuxDoConnectEnabled: updatedSettings.LinuxDoConnectEnabled,
@@ -2027,6 +2268,9 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
EnableClientDatelineNormalization: updatedSettings.EnableClientDatelineNormalization,
AntigravityUserAgentVersion: updatedSettings.AntigravityUserAgentVersion,
OpenAICodexUserAgent: updatedSettings.OpenAICodexUserAgent,
OpenAICodexClientVersion: updatedSettings.OpenAICodexClientVersion,
OpenAICodexClientVersionSynced: updatedSettings.OpenAICodexClientVersionSynced,
OpenAICodexVersionAutoSyncEnabled: updatedSettings.OpenAICodexVersionAutoSyncEnabled,
MinCodexVersion: updatedSettings.MinCodexVersion,
MaxCodexVersion: updatedSettings.MaxCodexVersion,
CodexCLIOnlyBlacklist: updatedSettings.CodexCLIOnlyBlacklist,
@@ -2095,7 +2339,13 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentAlipayMobilePrecreateDeepLink: updatedPaymentCfg.AlipayMobilePrecreateDeepLink,
ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled,
ChannelMonitorMode: updatedSettings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
@@ -2108,6 +2358,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
RiskControlEnabled: updatedSettings.RiskControlEnabled,
CyberSessionBlockEnabled: updatedSettings.CyberSessionBlockEnabled,
CyberSessionBlockTTLSeconds: updatedSettings.CyberSessionBlockTTLSeconds,
AccountSchedulingThresholds: updatedSettings.AccountSchedulingThresholds,
AllowUserViewErrorRequests: updatedSettings.AllowUserViewErrorRequests,
}
if fastPolicy, err := h.settingService.GetOpenAIFastPolicySettings(c.Request.Context()); err != nil {
+48 -26
View File
@@ -143,6 +143,16 @@ func (h *UsageHandler) List(c *gin.Context) {
billingType = &bt
}
var upstreamModelMismatch *bool
if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
value, err := strconv.ParseBool(raw)
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
upstreamModelMismatch = &value
}
// Parse date range
var startTime, endTime *time.Time
userTZ := c.Query("timezone") // Get user's timezone from request
@@ -173,20 +183,21 @@ func (h *UsageHandler) List(c *gin.Context) {
SortOrder: c.DefaultQuery("sort_order", "desc"),
}
filters := usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestID: requestID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
StartTime: startTime,
EndTime: endTime,
ExactTotal: exactTotal,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestID: requestID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
UpstreamModelMismatch: upstreamModelMismatch,
StartTime: startTime,
EndTime: endTime,
ExactTotal: exactTotal,
}
records, result, err := h.usageService.ListWithFilters(c.Request.Context(), params, filters)
@@ -276,6 +287,16 @@ func (h *UsageHandler) Stats(c *gin.Context) {
billingType = &bt
}
var upstreamModelMismatch *bool
if raw := strings.TrimSpace(c.Query("upstream_model_mismatch")); raw != "" {
value, err := strconv.ParseBool(raw)
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
upstreamModelMismatch = &value
}
// Parse date range
userTZ := c.Query("timezone")
now := timezone.NowInUserLocation(userTZ)
@@ -315,18 +336,19 @@ func (h *UsageHandler) Stats(c *gin.Context) {
// Build filters and call GetStatsWithFilters
filters := usagestats.UsageLogFilters{
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
StartTime: &startTime,
EndTime: &endTime,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
BillingMode: billingMode,
UpstreamModelMismatch: upstreamModelMismatch,
StartTime: &startTime,
EndTime: &endTime,
}
var stats *usagestats.UsageStats
@@ -11,17 +11,18 @@ import (
var usageStatsCache = newSnapshotCache(30 * time.Second)
type usageStatsCacheKeyData struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
BillingMode string `json:"billing_mode"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
BillingMode string `json:"billing_mode"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
@@ -34,17 +35,18 @@ func usageStatsCacheKey(filters usagestats.UsageLogFilters) string {
end = filters.EndTime.UTC().Format(time.RFC3339)
}
return mustMarshalDashboardCacheKey(usageStatsCacheKeyData{
StartTime: start,
EndTime: end,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
BillingMode: filters.BillingMode,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
StartTime: start,
EndTime: end,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
BillingMode: filters.BillingMode,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
UpstreamModelMismatch: filters.UpstreamModelMismatch,
})
}
@@ -3,6 +3,8 @@ package handler
import (
"context"
"errors"
"math"
"strconv"
"strings"
"time"
@@ -62,6 +64,43 @@ type UpdateAPIKeyRequest struct {
ResetRateLimitUsage *bool `json:"reset_rate_limit_usage"` // 重置限速用量
}
func validAPIKeyLimit(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 }
func validateAPIKeyCreateRequest(req CreateAPIKeyRequest) error {
if req.Quota != nil && !validAPIKeyLimit(*req.Quota) {
return errors.New("invalid quota")
}
if req.RateLimit5h != nil && !validAPIKeyLimit(*req.RateLimit5h) {
return errors.New("invalid rate_limit_5h")
}
if req.RateLimit1d != nil && !validAPIKeyLimit(*req.RateLimit1d) {
return errors.New("invalid rate_limit_1d")
}
if req.RateLimit7d != nil && !validAPIKeyLimit(*req.RateLimit7d) {
return errors.New("invalid rate_limit_7d")
}
if req.ExpiresInDays != nil && *req.ExpiresInDays <= 0 {
return errors.New("invalid expires_in_days")
}
return nil
}
func validateAPIKeyUpdateRequest(req UpdateAPIKeyRequest) error {
if req.Quota != nil && !validAPIKeyLimit(*req.Quota) {
return errors.New("invalid quota")
}
if req.RateLimit5h != nil && !validAPIKeyLimit(*req.RateLimit5h) {
return errors.New("invalid rate_limit_5h")
}
if req.RateLimit1d != nil && !validAPIKeyLimit(*req.RateLimit1d) {
return errors.New("invalid rate_limit_1d")
}
if req.RateLimit7d != nil && !validAPIKeyLimit(*req.RateLimit7d) {
return errors.New("invalid rate_limit_7d")
}
return nil
}
// List handles listing user's API keys with pagination
// GET /api/v1/api-keys
func (h *APIKeyHandler) List(c *gin.Context) {
@@ -152,6 +191,10 @@ func (h *APIKeyHandler) Create(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := validateAPIKeyCreateRequest(req); err != nil {
response.BadRequest(c, "Invalid request: numeric limits must be finite and non-negative, and expires_in_days must be greater than zero")
return
}
svcReq := service.CreateAPIKeyRequest{
Name: req.Name,
@@ -203,6 +246,10 @@ func (h *APIKeyHandler) Update(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := validateAPIKeyUpdateRequest(req); err != nil {
response.BadRequest(c, "Invalid request: numeric limits must be finite and non-negative")
return
}
svcReq := service.UpdateAPIKeyRequest{
IPWhitelist: req.IPWhitelist,
@@ -0,0 +1,43 @@
//go:build unit
package handler
import (
"math"
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateAPIKeyCreateRequest(t *testing.T) {
zero, large, negative, nan, inf := 0.0, 1e100, -1.0, math.NaN(), math.Inf(1)
positiveDays, zeroDays, negativeDays := 1, 0, -1
require.NoError(t, validateAPIKeyCreateRequest(CreateAPIKeyRequest{}))
require.NoError(t, validateAPIKeyCreateRequest(CreateAPIKeyRequest{Quota: &zero, RateLimit5h: &large, ExpiresInDays: &positiveDays}))
for _, req := range []CreateAPIKeyRequest{
{Quota: &negative},
{Quota: &nan},
{RateLimit5h: &inf},
{RateLimit1d: &negative},
{RateLimit7d: &negative},
{ExpiresInDays: &zeroDays},
{ExpiresInDays: &negativeDays},
} {
require.Error(t, validateAPIKeyCreateRequest(req))
}
}
func TestValidateAPIKeyUpdateRequest(t *testing.T) {
zero, large, negative, nan, inf := 0.0, 1e100, -1.0, math.NaN(), math.Inf(-1)
require.NoError(t, validateAPIKeyUpdateRequest(UpdateAPIKeyRequest{Quota: &zero, RateLimit7d: &large}))
for _, req := range []UpdateAPIKeyRequest{
{Quota: &negative},
{RateLimit5h: &nan},
{RateLimit1d: &inf},
{RateLimit7d: &negative},
} {
require.Error(t, validateAPIKeyUpdateRequest(req))
}
}
@@ -0,0 +1,93 @@
//go:build unit
package handler
import (
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestAuthRequestsBindTencentCaptchaProof(t *testing.T) {
const payload = `{"email":"user@example.com","password":"secret-123","tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`
tests := []struct {
name string
decode func([]byte) service.CaptchaProof
}{
{
name: "登录",
decode: func(raw []byte) service.CaptchaProof {
var req LoginRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "注册",
decode: func(raw []byte) service.CaptchaProof {
var req RegisterRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "发送邮箱验证码",
decode: func(raw []byte) service.CaptchaProof {
var req SendVerifyCodeRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "忘记密码",
decode: func(raw []byte) service.CaptchaProof {
var req ForgotPasswordRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth启动",
decode: func(raw []byte) service.CaptchaProof {
var req oauthStartCaptchaRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "Passkey登录",
decode: func(raw []byte) service.CaptchaProof {
var req passkeyBeginLoginRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth待处理账号发送邮箱验证码",
decode: func(raw []byte) service.CaptchaProof {
var req sendPendingOAuthVerifyCodeRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
{
name: "OAuth待处理账号创建",
decode: func(raw []byte) service.CaptchaProof {
var req createPendingOAuthAccountRequest
require.NoError(t, json.Unmarshal(raw, &req))
return captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
proof := test.decode([]byte(payload))
require.Equal(t, "ticket-value", proof.TencentTicket)
require.Equal(t, "@rand-value", proof.TencentRandstr)
})
}
}
@@ -113,6 +113,9 @@ func clearDingTalkCookie(c *gin.Context, name string, secure bool) {
// DingTalkOAuthStart 启动 DingTalk Connect OAuth 登录流程。
// GET /api/v1/auth/oauth/dingtalk/start?redirect=/dashboard&intent=login
func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
if !h.requireActionCaptchaForOAuthLoginStart(c) {
return
}
cfg, err := h.getDingTalkOAuthConfig(c.Request.Context())
if err != nil {
frontendCB := dingTalkOAuthDefaultFrontendCB
@@ -165,7 +168,7 @@ func (h *AuthHandler) DingTalkOAuthStart(c *gin.Context) {
return
}
c.Redirect(http.StatusFound, authURL)
respondOAuthStart(c, authURL)
}
// ─── buildDingTalkAuthorizeURL ─────────────────────────────────────────────
+4 -1
View File
@@ -59,6 +59,9 @@ func (h *AuthHandler) CompleteGoogleOAuthRegistration(c *gin.Context) {
}
func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
if !h.requireActionCaptchaForOAuthLoginStart(c) {
return
}
cfg, err := h.getEmailOAuthConfig(c.Request.Context(), provider)
if err != nil {
response.ErrorFrom(c, err)
@@ -90,7 +93,7 @@ func (h *AuthHandler) emailOAuthStart(c *gin.Context, provider string) {
response.ErrorFrom(c, infraerrors.InternalServer("OAUTH_BUILD_URL_FAILED", "failed to build oauth authorization url").WithCause(err))
return
}
c.Redirect(http.StatusFound, authURL)
respondOAuthStart(c, authURL)
}
func (h *AuthHandler) emailOAuthCallback(c *gin.Context, provider string) {
+39 -22
View File
@@ -48,19 +48,23 @@ func NewAuthHandler(cfg *config.Config, authService *service.AuthService, userSe
// RegisterRequest represents the registration request payload
type RegisterRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
VerifyCode string `json:"verify_code"`
TurnstileToken string `json:"turnstile_token"`
PromoCode string `json:"promo_code"` // 注册优惠码
InvitationCode string `json:"invitation_code"` // 邀请码
AffCode string `json:"aff_code"` // 邀请返利码
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required,min=6"`
VerifyCode string `json:"verify_code"`
TurnstileToken string `json:"turnstile_token"`
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
PromoCode string `json:"promo_code"` // 注册优惠码
InvitationCode string `json:"invitation_code"` // 邀请码
AffCode string `json:"aff_code"` // 邀请返利码
}
// SendVerifyCodeRequest 发送验证码请求
type SendVerifyCodeRequest struct {
Email string `json:"email" binding:"required,email"`
TurnstileToken string `json:"turnstile_token"`
Email string `json:"email" binding:"required,email"`
TurnstileToken string `json:"turnstile_token"`
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
}
// SendVerifyCodeResponse 发送验证码响应
@@ -71,9 +75,19 @@ type SendVerifyCodeResponse struct {
// LoginRequest represents the login request payload
type LoginRequest struct {
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
TurnstileToken string `json:"turnstile_token"`
Email string `json:"email" binding:"required,email"`
Password string `json:"password" binding:"required"`
TurnstileToken string `json:"turnstile_token"`
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
}
func captchaProof(turnstileToken, tencentTicket, tencentRandstr string) service.CaptchaProof {
return service.CaptchaProof{
TurnstileToken: turnstileToken,
TencentTicket: tencentTicket,
TencentRandstr: tencentRandstr,
}
}
// AuthResponse 认证响应格式(匹配前端期望)
@@ -169,8 +183,9 @@ func (h *AuthHandler) Register(c *gin.Context) {
return
}
// Turnstile 验证(邮箱验证码注册场景避免重复校验一次性 token)
if err := h.authService.VerifyTurnstileForRegister(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c), req.VerifyCode); err != nil {
// 验证当前启用的验证码(邮箱验证码注册场景避免重复校验一次性票据)
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
if err := h.authService.VerifyCaptchaForRegister(c.Request.Context(), proof, ip.GetClientIP(c), req.VerifyCode); err != nil {
response.ErrorFrom(c, err)
return
}
@@ -201,8 +216,8 @@ func (h *AuthHandler) SendVerifyCode(c *gin.Context) {
return
}
// Turnstile 验证
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
response.ErrorFrom(c, err)
return
}
@@ -228,8 +243,8 @@ func (h *AuthHandler) Login(c *gin.Context) {
return
}
// Turnstile 验证
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
response.ErrorFrom(c, err)
return
}
@@ -573,8 +588,10 @@ func (h *AuthHandler) ValidateInvitationCode(c *gin.Context) {
// ForgotPasswordRequest 忘记密码请求
type ForgotPasswordRequest struct {
Email string `json:"email" binding:"required,email"`
TurnstileToken string `json:"turnstile_token"`
Email string `json:"email" binding:"required,email"`
TurnstileToken string `json:"turnstile_token"`
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
}
// ForgotPasswordResponse 忘记密码响应
@@ -591,8 +608,8 @@ func (h *AuthHandler) ForgotPassword(c *gin.Context) {
return
}
// Turnstile 验证
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
response.ErrorFrom(c, err)
return
}
@@ -82,6 +82,9 @@ func (e *linuxDoTokenExchangeError) Error() string {
// LinuxDoOAuthStart 启动 LinuxDo Connect OAuth 登录流程。
// GET /api/v1/auth/oauth/linuxdo/start?redirect=/dashboard
func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
if !h.requireActionCaptchaForOAuthLoginStart(c) {
return
}
cfg, err := h.getLinuxDoOAuthConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
@@ -147,7 +150,7 @@ func (h *AuthHandler) LinuxDoOAuthStart(c *gin.Context) {
return
}
c.Redirect(http.StatusFound, authURL)
respondOAuthStart(c, authURL)
}
// LinuxDoOAuthCallback 处理 OAuth 回调:创建/登录用户,然后重定向到前端。
@@ -0,0 +1,50 @@
package handler
import (
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type oauthStartCaptchaRequest struct {
// TurnstileToken 承载阿里云验证码的 captchaVerifyParam(复用既有请求字段名)
TurnstileToken string `json:"turnstile_token"`
TencentCaptchaTicket string `json:"tencent_captcha_ticket"`
TencentCaptchaRandstr string `json:"tencent_captcha_randstr"`
}
type oauthStartResponse struct {
AuthorizeURL string `json:"authorize_url"`
}
func (h *AuthHandler) requireActionCaptchaForOAuthLoginStart(c *gin.Context) bool {
if strings.HasSuffix(strings.TrimRight(c.Request.URL.Path, "/"), "/bind/start") {
return true
}
var req oauthStartCaptchaRequest
if c.Request.Method == http.MethodPost {
_ = c.ShouldBindJSON(&req)
}
if err := h.authService.VerifyActionCaptchaIfEnabled(c.Request.Context(), service.CaptchaProof{
TurnstileToken: req.TurnstileToken,
TencentTicket: req.TencentCaptchaTicket,
TencentRandstr: req.TencentCaptchaRandstr,
}, ip.GetClientIP(c)); err != nil {
response.ErrorFrom(c, err)
return false
}
return true
}
func respondOAuthStart(c *gin.Context, authorizeURL string) {
if c.Request.Method == http.MethodPost {
response.Success(c, oauthStartResponse{AuthorizeURL: authorizeURL})
return
}
c.Redirect(http.StatusFound, authorizeURL)
}
@@ -0,0 +1,178 @@
//go:build unit
package handler
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type oauthCaptchaSettingRepo struct {
values map[string]string
}
func (r *oauthCaptchaSettingRepo) Get(context.Context, string) (*service.Setting, error) {
return nil, service.ErrSettingNotFound
}
func (r *oauthCaptchaSettingRepo) GetValue(_ context.Context, key string) (string, error) {
value, ok := r.values[key]
if !ok {
return "", service.ErrSettingNotFound
}
return value, nil
}
func (r *oauthCaptchaSettingRepo) Set(context.Context, string, string) error { return nil }
func (r *oauthCaptchaSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
values := make(map[string]string, len(keys))
for _, key := range keys {
if value, ok := r.values[key]; ok {
values[key] = value
}
}
return values, nil
}
func (r *oauthCaptchaSettingRepo) SetMultiple(context.Context, map[string]string) error {
return nil
}
func (r *oauthCaptchaSettingRepo) GetAll(context.Context) (map[string]string, error) {
return r.values, nil
}
func (r *oauthCaptchaSettingRepo) Delete(context.Context, string) error { return nil }
type oauthCaptchaVerifier struct {
calls int
proof service.TencentCaptchaProof
}
func (v *oauthCaptchaVerifier) VerifyTicket(_ context.Context, _ service.TencentCaptchaCredentials, proof service.TencentCaptchaProof, _ string) (*service.TencentCaptchaVerifyResponse, error) {
v.calls++
v.proof = proof
return &service.TencentCaptchaVerifyResponse{CaptchaCode: 1}, nil
}
func newOAuthCaptchaTestHandler(enabled bool) (*AuthHandler, *oauthCaptchaVerifier) {
values := map[string]string{}
if enabled {
values = map[string]string{
service.SettingKeyTencentCaptchaEnabled: "true",
service.SettingKeyTencentCaptchaAppID: "123456789",
service.SettingKeyTencentCaptchaAppSecretKey: "app-secret",
service.SettingKeyTencentCaptchaCloudSecretID: "cloud-secret-id",
service.SettingKeyTencentCaptchaCloudSecretKey: "cloud-secret-key",
}
}
cfg := &config.Config{}
settings := service.NewSettingService(&oauthCaptchaSettingRepo{values: values}, cfg)
verifier := &oauthCaptchaVerifier{}
authService := service.NewAuthService(nil, nil, nil, nil, cfg, settings, nil, nil, nil, nil, nil, nil, nil)
authService.SetTencentCaptchaService(service.NewTencentCaptchaService(settings, verifier))
return &AuthHandler{authService: authService, settingSvc: settings, cfg: cfg}, verifier
}
func oauthStartHandlers() map[string]func(*AuthHandler, *gin.Context) {
return map[string]func(*AuthHandler, *gin.Context){
"github": func(h *AuthHandler, c *gin.Context) { h.GitHubOAuthStart(c) },
"google": func(h *AuthHandler, c *gin.Context) { h.GoogleOAuthStart(c) },
"linuxdo": func(h *AuthHandler, c *gin.Context) { h.LinuxDoOAuthStart(c) },
"dingtalk": func(h *AuthHandler, c *gin.Context) { h.DingTalkOAuthStart(c) },
"wechat": func(h *AuthHandler, c *gin.Context) { h.WeChatOAuthStart(c) },
"oidc": func(h *AuthHandler, c *gin.Context) { h.OIDCOAuthStart(c) },
}
}
func TestOAuthStartGetRejectsAnonymousLoginWhenTencentEnabledWithoutSideEffects(t *testing.T) {
gin.SetMode(gin.TestMode)
for provider, start := range oauthStartHandlers() {
t.Run(provider, func(t *testing.T) {
handler, verifier := newOAuthCaptchaTestHandler(true)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/"+provider+"/start?intent=bind_current_user", nil)
start(handler, c)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
require.Empty(t, recorder.Header().Get("Location"))
require.Empty(t, recorder.Header().Values("Set-Cookie"))
require.Zero(t, verifier.calls)
})
}
}
func TestOAuthStartPostReturnsAuthorizeURLAfterTencentVerification(t *testing.T) {
gin.SetMode(gin.TestMode)
for provider := range oauthStartHandlers() {
t.Run(provider, func(t *testing.T) {
handler, verifier := newOAuthCaptchaTestHandler(true)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/oauth/"+provider+"/start",
strings.NewReader(`{"tencent_captcha_ticket":"ticket-value","tencent_captcha_randstr":"@rand-value"}`),
)
c.Request.Header.Set("Content-Type", "application/json")
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
respondOAuthStart(c, "https://provider.example/authorize")
require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"authorize_url":"https://provider.example/authorize"`)
require.Equal(t, 1, verifier.calls)
require.Equal(t, service.TencentCaptchaProof{Ticket: "ticket-value", Randstr: "@rand-value"}, verifier.proof)
})
}
}
func TestOAuthStartPostRequiresTencentProofWhenEnabled(t *testing.T) {
gin.SetMode(gin.TestMode)
for provider := range oauthStartHandlers() {
t.Run(provider, func(t *testing.T) {
handler, verifier := newOAuthCaptchaTestHandler(true)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/"+provider+"/start", strings.NewReader(`{}`))
c.Request.Header.Set("Content-Type", "application/json")
require.False(t, handler.requireActionCaptchaForOAuthLoginStart(c))
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Contains(t, recorder.Body.String(), "TENCENT_CAPTCHA_VERIFICATION_FAILED")
require.Zero(t, verifier.calls)
})
}
}
func TestOAuthBindingPathRemainsOutsideTencentGate(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := &AuthHandler{}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/oidc/bind/start", nil)
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestOAuthStartGetRemainsCompatibleWhenTencentDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
handler, verifier := newOAuthCaptchaTestHandler(false)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/auth/oauth/github/start", nil)
require.True(t, handler.requireActionCaptchaForOAuthLoginStart(c))
respondOAuthStart(c, "https://provider.example/authorize")
require.Equal(t, http.StatusFound, recorder.Code)
require.Equal(t, "https://provider.example/authorize", recorder.Header().Get("Location"))
require.Zero(t, verifier.calls)
}
@@ -66,20 +66,25 @@ type bindPendingOAuthLoginRequest struct {
}
type createPendingOAuthAccountRequest struct {
Email string `json:"email" binding:"required,email"`
VerifyCode string `json:"verify_code,omitempty"`
Password string `json:"password" binding:"required,min=6"`
InvitationCode string `json:"invitation_code,omitempty"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
Email string `json:"email" binding:"required,email"`
VerifyCode string `json:"verify_code,omitempty"`
Password string `json:"password" binding:"required,min=6"`
TurnstileToken string `json:"turnstile_token,omitempty"`
TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
InvitationCode string `json:"invitation_code,omitempty"`
AffCode string `json:"aff_code,omitempty"`
AdoptDisplayName *bool `json:"adopt_display_name,omitempty"`
AdoptAvatar *bool `json:"adopt_avatar,omitempty"`
}
type sendPendingOAuthVerifyCodeRequest struct {
Email string `json:"email" binding:"required,email"`
TurnstileToken string `json:"turnstile_token,omitempty"`
PendingAuthToken string `json:"pending_auth_token,omitempty"`
PendingOAuthToken string `json:"pending_oauth_token,omitempty"`
Email string `json:"email" binding:"required,email"`
TurnstileToken string `json:"turnstile_token,omitempty"`
TencentCaptchaTicket string `json:"tencent_captcha_ticket,omitempty"`
TencentCaptchaRandstr string `json:"tencent_captcha_randstr,omitempty"`
PendingAuthToken string `json:"pending_auth_token,omitempty"`
PendingOAuthToken string `json:"pending_oauth_token,omitempty"`
}
func (r bindPendingOAuthLoginRequest) adoptionDecision() oauthAdoptionDecisionRequest {
@@ -564,7 +569,8 @@ func (h *AuthHandler) SendPendingOAuthVerifyCode(c *gin.Context) {
return
}
if err := h.authService.VerifyTurnstile(c.Request.Context(), req.TurnstileToken, ip.GetClientIP(c)); err != nil {
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
response.ErrorFrom(c, err)
return
}
@@ -1754,6 +1760,11 @@ func (h *AuthHandler) createPendingOAuthAccount(c *gin.Context, provider string)
response.ErrorFrom(c, err)
return
}
proof := captchaProof(req.TurnstileToken, req.TencentCaptchaTicket, req.TencentCaptchaRandstr)
if err := h.authService.VerifyCaptcha(c.Request.Context(), proof, ip.GetClientIP(c)); err != nil {
response.ErrorFrom(c, err)
return
}
tokenPair, user, err := h.authService.RegisterOAuthEmailAccount(
c.Request.Context(),
@@ -1987,6 +1998,21 @@ func (h *AuthHandler) ExchangePendingOAuthCompletion(c *gin.Context) {
response.Success(c, payload)
return
}
// ─── 安全修复(账号接管 0day)────────────────────────────────────────────
// 非终态 session(如 choose_account_action_required)的 TargetUserID 可能来自
// 攻击者提交的他人邮箱:createPendingOAuthAccount / SendPendingOAuthVerifyCode
// 发现邮箱已存在时会把本 session 指向该邮箱用户,全程无密码、无邮箱验证码、
// 无账号所有权证明。若此时带着 adoption decision 继续执行,下方的
// applyPendingOAuthAdoption 会把本 OAuth identity 直接绑定到 TargetUserID,
// 攻击者随后再次 OAuth 登录即被系统识别为受害者本人(完整账号接管)。
// 只有两类 session 允许在此处执行 adoption/binding:
// 1. canIssueTokenPair == true —— 登录终态,identity 已安全绑定该用户;
// 2. intent == bind_current_user —— 已登录用户主动发起绑定(绑定目标来自登录态 cookie)。
// 其余状态一律只返回 payload,不绑定、不消费 session。
if !canIssueTokenPair && !strings.EqualFold(strings.TrimSpace(session.Intent), oauthIntentBindCurrentUser) {
response.Success(c, payload)
return
}
if !adoptionDecision.hasDecision() {
adoptionRequired, _ := payload["adoption_required"].(bool)
if adoptionRequired {
@@ -910,6 +910,92 @@ func TestExchangePendingOAuthCompletionRejectsDisabledTargetUser(t *testing.T) {
require.Nil(t, storedSession.ConsumedAt)
}
func TestExchangePendingOAuthCompletionChoiceStateDoesNotBindIdentity(t *testing.T) {
// 回归测试:复刻"补邮箱/创建账户"路径的账号接管 0day。
// 攻击者用自己的 OAuth 账号登录后,在 create-account 步骤提交受害者邮箱,
// 后端发现邮箱已存在会把 pending session 转入 choice 状态并指向受害者
// (TargetUserID=受害者、无密码/验证码证明)。此时带 adoption decision 调
// exchange 绝不能把 OAuth identity 绑定到受害者账号。
handler, client := newOAuthPendingFlowTestHandler(t, false)
ctx := context.Background()
victim, err := client.User.Create().
SetEmail("victim@example.com").
SetUsername("victim-user").
SetPasswordHash("hash").
SetRole(service.RoleUser).
SetStatus(service.StatusActive).
Save(ctx)
require.NoError(t, err)
session, err := client.PendingAuthSession.Create().
SetSessionToken("choice-state-attack-session-token").
SetIntent("login").
SetProviderType("linuxdo").
SetProviderKey("linuxdo").
SetProviderSubject("attacker-subject-123").
SetTargetUserID(victim.ID).
SetResolvedEmail(victim.Email).
SetBrowserSessionKey("choice-state-attack-browser-session-key").
SetUpstreamIdentityClaims(map[string]any{
"username": "attacker_linuxdo_user",
"suggested_display_name": "Attacker Display Name",
"suggested_avatar_url": "https://cdn.example/attacker.png",
}).
SetLocalFlowState(map[string]any{
oauthCompletionResponseKey: map[string]any{
"step": oauthPendingChoiceStep,
"adoption_required": true,
"force_email_on_signup": true,
"email_binding_required": true,
"existing_account_bindable": true,
"email": victim.Email,
"resolved_email": victim.Email,
"redirect": "/dashboard",
},
}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"adopt_display_name":true,"adopt_avatar":true}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/pending/exchange", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("choice-state-attack-browser-session-key")})
ginCtx.Request = req
handler.ExchangePendingOAuthCompletion(ginCtx)
require.Equal(t, http.StatusOK, recorder.Code)
data := decodeJSONResponseData(t, recorder)
require.NotContains(t, data, "access_token")
require.Equal(t, oauthPendingChoiceStep, data["step"])
// 攻击者的 OAuth identity 绝不能绑定到受害者账号
identityCount, err := client.AuthIdentity.Query().
Where(
authidentity.ProviderTypeEQ("linuxdo"),
authidentity.ProviderKeyEQ("linuxdo"),
authidentity.ProviderSubjectEQ("attacker-subject-123"),
).
Count(ctx)
require.NoError(t, err)
require.Zero(t, identityCount)
// 受害者资料不得被 adoption 篡改
storedVictim, err := client.User.Get(ctx, victim.ID)
require.NoError(t, err)
require.Equal(t, "victim-user", storedVictim.Username)
// session 不得被消费(攻击者无法进入下一环)
storedSession, err := client.PendingAuthSession.Get(ctx, session.ID)
require.NoError(t, err)
require.Nil(t, storedSession.ConsumedAt)
}
func TestNormalizePendingOAuthCompletionResponseScrubsLegacyTokenPayload(t *testing.T) {
payload := normalizePendingOAuthCompletionResponse(map[string]any{
"access_token": "legacy-access-token",
@@ -1374,7 +1460,7 @@ func TestCreateOIDCOAuthAccountExistingEmailNormalizesLegacySpacingAndCase(t *te
require.Equal(t, "owner@example.com", storedSession.ResolvedEmail)
}
func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t *testing.T) {
func TestCreateOIDCOAuthAccountRejectsSecondEmailOutsideRegistrationSuffixWhitelist(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: &oauthPendingFlowEmailCacheStub{
@@ -1387,10 +1473,19 @@ func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t
},
},
settingValues: map[string]string{
service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
service.SettingKeyRegistrationEmailDomainQuotaEnabled: "true",
},
})
ctx := context.Background()
_, err := client.User.Create().
SetEmail("existing@gmail.com").
SetUsername("existing-gmail-user").
SetPasswordHash("hash").
SetRole(service.RoleUser).
SetStatus(service.StatusActive).
Save(ctx)
require.NoError(t, err)
session, err := client.PendingAuthSession.Create().
SetSessionToken("suffix-whitelist-session-token").
@@ -1417,6 +1512,60 @@ func TestCreateOIDCOAuthAccountRejectsEmailOutsideRegistrationSuffixWhitelist(t
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
payload := decodeJSONBody(t, recorder)
require.Equal(t, "EMAIL_DOMAIN_REGISTRATION_LIMIT", payload["reason"])
count, err := client.User.Query().Where(dbuser.EmailEQ("foo@gmail.com")).Count(ctx)
require.NoError(t, err)
require.Zero(t, count)
}
// 域名限量注册开关默认关闭:白名单外域名保持 PR5423 之前的严格拒绝语义,
// 即使该域名下还没有任何账户也不放行。
func TestCreateOIDCOAuthAccountRejectsEmailOutsideWhitelistWhenQuotaDisabled(t *testing.T) {
handler, client := newOAuthPendingFlowTestHandlerWithDependencies(t, oauthPendingFlowTestHandlerOptions{
emailVerifyEnabled: true,
emailCache: &oauthPendingFlowEmailCacheStub{
verificationCodes: map[string]*service.VerificationCodeData{
"foo@gmail.com": {
Code: "135790",
CreatedAt: time.Now().UTC(),
ExpiresAt: time.Now().UTC().Add(15 * time.Minute),
},
},
},
settingValues: map[string]string{
service.SettingKeyRegistrationEmailSuffixWhitelist: `["@qq.com"]`,
},
})
ctx := context.Background()
session, err := client.PendingAuthSession.Create().
SetSessionToken("suffix-strict-session-token").
SetIntent("login").
SetProviderType("oidc").
SetProviderKey("https://issuer.example").
SetProviderSubject("oidc-suffix-strict-123").
SetBrowserSessionKey("suffix-strict-browser-session-key").
SetUpstreamIdentityClaims(map[string]any{
"username": "oidc_user",
}).
SetExpiresAt(time.Now().UTC().Add(10 * time.Minute)).
Save(ctx)
require.NoError(t, err)
body := bytes.NewBufferString(`{"email":"foo@gmail.com","verify_code":"135790","password":"secret-123"}`)
recorder := httptest.NewRecorder()
ginCtx, _ := gin.CreateTestContext(recorder)
req := httptest.NewRequest(http.MethodPost, "/api/v1/auth/oauth/oidc/create-account", body)
req.Header.Set("Content-Type", "application/json")
req.AddCookie(&http.Cookie{Name: oauthPendingSessionCookieName, Value: encodeCookieValue(session.SessionToken)})
req.AddCookie(&http.Cookie{Name: oauthPendingBrowserCookieName, Value: encodeCookieValue("suffix-strict-browser-session-key")})
ginCtx.Request = req
handler.CreateOIDCOAuthAccount(ginCtx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
payload := decodeJSONBody(t, recorder)
require.Equal(t, "EMAIL_SUFFIX_NOT_ALLOWED", payload["reason"])
@@ -2947,6 +3096,8 @@ type oauthPendingFlowUserRepo struct {
options oauthPendingFlowUserRepoOptions
}
var _ service.RegistrationEmailDomainRepository = (*oauthPendingFlowUserRepo)(nil)
type oauthPendingFlowUserRepoOptions struct {
rejectDeleteWhileAuthIdentityExists bool
}
@@ -2989,6 +3140,35 @@ func (r *oauthPendingFlowUserRepo) CreateWithEmailAliasGuard(ctx context.Context
return r.Create(ctx, user)
}
func (r *oauthPendingFlowUserRepo) CountUsersByEmailDomain(ctx context.Context, domain string) (int, error) {
domain = service.NormalizeRegistrationEmailDomain(domain)
if domain == "" {
return 0, nil
}
emails, err := r.client.User.Query().Select(dbuser.FieldEmail).Strings(ctx)
if err != nil {
return 0, err
}
count := 0
for _, email := range emails {
if service.RegistrationEmailDomain(email) == domain {
count++
}
}
return count, nil
}
func (r *oauthPendingFlowUserRepo) CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, user *service.User, domain string) error {
count, err := r.CountUsersByEmailDomain(ctx, domain)
if err != nil {
return err
}
if count > 0 {
return service.ErrEmailDomainRegistrationLimit
}
return r.CreateWithEmailAliasGuard(ctx, user)
}
func (r *oauthPendingFlowUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) {
entity, err := r.client.User.Get(ctx, id)
if err != nil {
+4 -1
View File
@@ -115,6 +115,9 @@ type oidcJWK struct {
// OIDCOAuthStart 启动通用 OIDC OAuth 登录流程。
// GET /api/v1/auth/oauth/oidc/start?redirect=/dashboard
func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
if !h.requireActionCaptchaForOAuthLoginStart(c) {
return
}
cfg, err := h.getOIDCOAuthConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
@@ -190,7 +193,7 @@ func (h *AuthHandler) OIDCOAuthStart(c *gin.Context) {
return
}
c.Redirect(http.StatusFound, authURL)
respondOAuthStart(c, authURL)
}
// OIDCOAuthCallback 处理 OIDC 回调:校验 id_token、创建/登录用户并重定向到前端。
@@ -96,6 +96,9 @@ type wechatPaymentOAuthContext struct {
// WeChatOAuthStart starts the WeChat OAuth login flow and stores the short-lived
// browser cookies required by the rebuild pending-auth bridge.
func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
if !h.requireActionCaptchaForOAuthLoginStart(c) {
return
}
cfg, err := h.getWeChatOAuthConfig(c.Request.Context(), c.Query("mode"), c)
if err != nil {
response.ErrorFrom(c, err)
@@ -145,7 +148,7 @@ func (h *AuthHandler) WeChatOAuthStart(c *gin.Context) {
return
}
c.Redirect(http.StatusFound, authURL)
respondOAuthStart(c, authURL)
}
// WeChatOAuthCallback exchanges the code with WeChat, resolves openid/unionid,
@@ -35,7 +35,8 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool {
if h.settingService == nil {
return true
}
return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).Enabled
runtime := h.settingService.GetChannelMonitorRuntime(c.Request.Context())
return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1
}
// --- Response ---
@@ -0,0 +1,204 @@
package handler
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type ChannelMonitorV2Handler struct {
service *service.ChannelMonitorV2Service
}
func NewChannelMonitorV2Handler(svc *service.ChannelMonitorV2Service) *ChannelMonitorV2Handler {
return &ChannelMonitorV2Handler{service: svc}
}
// channelMonitorV2IsAdmin is true when the request already passed admin auth
// (shared Dimensions/Errors handlers serve both user and admin route groups).
func channelMonitorV2IsAdmin(c *gin.Context) bool {
role, ok := middleware.GetUserRoleFromContext(c)
return ok && role == service.RoleAdmin
}
func (h *ChannelMonitorV2Handler) GetConfig(c *gin.Context) {
cfg, err := h.service.GetConfig(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, cfg)
}
func (h *ChannelMonitorV2Handler) UpdateConfig(c *gin.Context) {
var input service.ChannelMonitorV2Config
if err := c.ShouldBindJSON(&input); err != nil {
response.BadRequest(c, "invalid channel monitor v2 config")
return
}
subject, ok := middleware.GetAuthSubjectFromContext(c)
if !ok || subject.UserID <= 0 {
response.Unauthorized(c, "user not found in context")
return
}
updated, err := h.service.UpdateConfig(c.Request.Context(), input, input.Version, subject.UserID)
if err != nil {
if errors.Is(err, service.ErrChannelMonitorV2ConfigConflict) {
response.Error(c, http.StatusConflict, err.Error())
return
}
if errors.Is(err, service.ErrChannelMonitorV2InvalidConfig) {
response.BadRequest(c, err.Error())
return
}
response.ErrorFrom(c, err)
return
}
response.Success(c, updated)
}
func (h *ChannelMonitorV2Handler) Dimensions(c *gin.Context) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
result, err := h.service.Dimensions(c.Request.Context(), filter)
if err != nil {
response.ErrorFrom(c, err)
return
}
// Admin and user share this handler; only non-admin responses strip volume.
if !channelMonitorV2IsAdmin(c) {
service.RedactChannelMonitorV2Dimensions(result)
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) Snapshot(c *gin.Context) { h.snapshot(c, false) }
func (h *ChannelMonitorV2Handler) AdminSnapshot(c *gin.Context) { h.snapshot(c, true) }
func (h *ChannelMonitorV2Handler) Models(c *gin.Context) { h.models(c, false) }
func (h *ChannelMonitorV2Handler) AdminModels(c *gin.Context) { h.models(c, true) }
func (h *ChannelMonitorV2Handler) Matrix(c *gin.Context) { h.matrix(c, false) }
func (h *ChannelMonitorV2Handler) AdminMatrix(c *gin.Context) { h.matrix(c, true) }
func (h *ChannelMonitorV2Handler) Users(c *gin.Context) { h.users(c, false) }
func (h *ChannelMonitorV2Handler) AdminUsers(c *gin.Context) { h.users(c, true) }
func (h *ChannelMonitorV2Handler) snapshot(c *gin.Context, admin bool) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
result, err := h.service.Snapshot(c.Request.Context(), filter, admin)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) models(c *gin.Context, admin bool) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
result, err := h.service.Models(c.Request.Context(), filter, admin)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) matrix(c *gin.Context, admin bool) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
groupBy, err := service.ParseChannelMonitorV2GroupBy(c.Query("group_by"))
if err != nil {
response.BadRequest(c, err.Error())
return
}
result, err := h.service.Matrix(c.Request.Context(), filter, groupBy, admin)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) Errors(c *gin.Context) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
result, err := h.service.ErrorsForViewer(c.Request.Context(), filter, channelMonitorV2IsAdmin(c))
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) users(c *gin.Context, admin bool) {
filter, ok := h.parseFilter(c)
if !ok {
return
}
subject, exists := middleware.GetAuthSubjectFromContext(c)
if !exists {
response.Error(c, http.StatusUnauthorized, "user not found in context")
return
}
result, err := h.service.Users(c.Request.Context(), filter, subject.UserID, admin)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *ChannelMonitorV2Handler) parseFilter(c *gin.Context) (service.ChannelMonitorV2Filter, bool) {
groups, err := parseChannelMonitorV2GroupIDs(queryList(c, "group_id"))
if err != nil {
response.BadRequest(c, "invalid group_id")
return service.ChannelMonitorV2Filter{}, false
}
filter, err := h.service.ParseFilter(c.Query("range"), queryList(c, "platform"), queryList(c, "model"), groups)
if err != nil {
response.BadRequest(c, err.Error())
return service.ChannelMonitorV2Filter{}, false
}
return filter, true
}
func queryList(c *gin.Context, key string) []string {
values := c.QueryArray(key)
result := make([]string, 0, len(values))
for _, value := range values {
for _, part := range strings.Split(value, ",") {
if part = strings.TrimSpace(part); part != "" {
result = append(result, part)
}
}
}
return result
}
func parseChannelMonitorV2GroupIDs(values []string) ([]int64, error) {
result := make([]int64, 0, len(values))
for _, value := range values {
id, err := strconv.ParseInt(value, 10, 64)
if err != nil || id <= 0 {
return nil, errors.New("invalid group id")
}
result = append(result, id)
}
return result, nil
}
@@ -0,0 +1,35 @@
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"
)
func TestChannelMonitorV2QueryListSupportsRepeatedAndCommaValues(t *testing.T) {
c, _ := gin.CreateTestContext(nil)
c.Request = httptest.NewRequest("GET", "/?platform=openai,grok&platform=anthropic", nil)
require.Equal(t, []string{"openai", "grok", "anthropic"}, queryList(c, "platform"))
}
func TestChannelMonitorV2GroupByQueryDefaultsAndRejectsInvalid(t *testing.T) {
groupBy, err := service.ParseChannelMonitorV2GroupBy("")
require.NoError(t, err)
require.Equal(t, service.ChannelMonitorV2GroupByPlatformGroup, groupBy)
_, err = service.ParseChannelMonitorV2GroupBy("invalid")
require.Error(t, err)
}
func TestChannelMonitorV2MatrixHandlerRejectsInvalidGroupBy(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, "/channel-monitor-v2/matrix?group_by=invalid", nil)
h := NewChannelMonitorV2Handler(service.NewChannelMonitorV2Service(nil))
h.Matrix(c)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
@@ -1,9 +1,12 @@
package handler
import (
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
)
func ensureCompositeTargetPlatform(c *gin.Context, apiKey *service.APIKey, model string) {
@@ -55,3 +58,42 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
}
return apiKey.Group.Platform
}
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
if apiKey == nil || apiKey.Group == nil {
return "", nil, false
}
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
return "", nil, false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
return "", nil, false
}
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
}
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return body, false
}
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
}
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
if c == nil || c.Request == nil {
return
}
// The Messages bridge synthesizes a default OpenAI effort when
// output_config.effort is omitted. Bind the group policy only for an
// explicit client value so the ceiling does not alter that default.
effort := gjson.GetBytes(body, "output_config.effort")
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
return
}
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return
}
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
}
@@ -77,6 +77,46 @@ func TestCompositeTargetPlatformResolvedAllowsConcreteGroupWithoutResolution(t *
require.True(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
}
func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
gin.SetMode(gin.TestMode)
group := &service.Group{
Platform: service.PlatformComposite,
MaxReasoningEffort: "medium",
ReasoningEffortMappings: []service.ReasoningEffortMapping{
{From: "max", To: "xhigh"},
},
}
apiKey := &service.APIKey{Group: group}
body := []byte(`{"reasoning":{"effort":"max"}}`)
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
omittedCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
require.False(t, changed)
require.Equal(t, body, omitted)
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
require.False(t, changed)
require.Equal(t, body, got)
}
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
+9
View File
@@ -149,6 +149,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
ProfitControlEnabled: g.ProfitControlEnabled,
ProfitMinMargin: g.ProfitMinMargin,
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
ModelPricing: g.ModelPricing,
ModelRouting: g.ModelRouting,
ModelRoutingEnabled: g.ModelRoutingEnabled,
MCPXMLInject: g.MCPXMLInject,
@@ -184,6 +185,7 @@ func groupFromServiceBase(g *service.Group) Group {
DailyLimitUSD: g.DailyLimitUSD,
WeeklyLimitUSD: g.WeeklyLimitUSD,
MonthlyLimitUSD: g.MonthlyLimitUSD,
LongContextPricingEnabled: g.LongContextPricingEnabled,
AllowImageGeneration: g.AllowImageGeneration,
AllowBatchImageGeneration: g.AllowBatchImageGeneration,
ImageRateIndependent: g.ImageRateIndependent,
@@ -202,7 +204,12 @@ func groupFromServiceBase(g *service.Group) Group {
VideoPrice480P: g.VideoPrice480P,
VideoPrice720P: g.VideoPrice720P,
VideoPrice1080P: g.VideoPrice1080P,
VideoModelPrices: g.VideoModelPrices,
WebSearchPricePerCall: g.WebSearchPricePerCall,
SearchPricePer1k: g.SearchPricePer1k,
AudioRealtimePricePerMin: g.AudioRealtimePricePerMin,
AudioTtsPricePerMillionChars: g.AudioTTSPricePerMillionChars,
AudioSttPricePerHour: g.AudioSTTPricePerHour,
ClaudeCodeOnly: g.ClaudeCodeOnly,
FallbackGroupID: g.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest,
@@ -705,6 +712,8 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
return &AdminUsageLog{
UsageLog: usageLog,
UpstreamModel: l.UpstreamModel,
UpstreamResponseModel: l.UpstreamResponseModel,
UpstreamModelMismatch: l.UpstreamModelMismatch,
ChannelID: l.ChannelID,
ModelMappingChain: l.ModelMappingChain,
BillingTier: l.BillingTier,
@@ -110,11 +110,15 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
t.Parallel()
upstreamModel := "claude-sonnet-4-20250514"
upstreamResponseModel := "claude-sonnet-4-20250513"
upstreamModelMismatch := true
log := &service.UsageLog{
RequestID: "req_4",
Model: upstreamModel,
RequestedModel: "claude-sonnet-4",
UpstreamModel: &upstreamModel,
RequestID: "req_4",
Model: upstreamModel,
RequestedModel: "claude-sonnet-4",
UpstreamModel: &upstreamModel,
UpstreamResponseModel: &upstreamResponseModel,
UpstreamModelMismatch: &upstreamModelMismatch,
}
userDTO := UsageLogFromService(log)
@@ -126,10 +130,14 @@ func TestUsageLogFromService_UsesRequestedModelAndKeepsUpstreamAdminOnly(t *test
userJSON, err := json.Marshal(userDTO)
require.NoError(t, err)
require.NotContains(t, string(userJSON), "upstream_model")
require.NotContains(t, string(userJSON), "upstream_response_model")
require.NotContains(t, string(userJSON), "upstream_model_mismatch")
adminJSON, err := json.Marshal(adminDTO)
require.NoError(t, err)
require.Contains(t, string(adminJSON), `"upstream_model":"claude-sonnet-4-20250514"`)
require.Contains(t, string(adminJSON), `"upstream_response_model":"claude-sonnet-4-20250513"`)
require.Contains(t, string(adminJSON), `"upstream_model_mismatch":true`)
}
func TestUsageLogFromService_KeepsUserBillingAndIPWithoutAdminCostFields(t *testing.T) {
+110 -74
View File
@@ -27,26 +27,27 @@ type CustomEndpoint struct {
// SystemSettings represents the admin settings API response payload.
type SystemSettings struct {
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
PasskeyEnabled bool `json:"passkey_enabled"`
PasskeyConfigured bool `json:"passkey_configured"`
PasskeyRPID string `json:"passkey_rp_id"`
PasskeyRPOrigins []string `json:"passkey_rp_origins"`
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
PasskeyEnabled bool `json:"passkey_enabled"`
PasskeyConfigured bool `json:"passkey_configured"`
PasskeyRPID string `json:"passkey_rp_id"`
PasskeyRPOrigins []string `json:"passkey_rp_origins"`
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
SMTPHost string `json:"smtp_host"`
SMTPPort int `json:"smtp_port"`
@@ -56,11 +57,23 @@ type SystemSettings struct {
SMTPFromName string `json:"smtp_from_name"`
SMTPUseTLS bool `json:"smtp_use_tls"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"`
ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
TurnstileSecretKeyConfigured bool `json:"turnstile_secret_key_configured"`
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaAppSecretKeyConfigured bool `json:"tencent_captcha_app_secret_key_configured"`
TencentCaptchaCloudSecretIDConfigured bool `json:"tencent_captcha_cloud_secret_id_configured"`
TencentCaptchaCloudSecretKeyConfigured bool `json:"tencent_captcha_cloud_secret_key_configured"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaAccessKeyID string `json:"aliyun_captcha_access_key_id"`
AliyunCaptchaAccessKeySecretConfigured bool `json:"aliyun_captcha_access_key_secret_configured"`
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
APIKeyACLTrustForwardedIP bool `json:"api_key_acl_trust_forwarded_ip"`
ForwardedClientIPHeaders []string `json:"forwarded_client_ip_headers"`
LinuxDoConnectEnabled bool `json:"linuxdo_connect_enabled"`
LinuxDoConnectClientID string `json:"linuxdo_connect_client_id"`
@@ -199,6 +212,9 @@ type SystemSettings struct {
EnableClientDatelineNormalization bool `json:"enable_client_dateline_normalization"`
AntigravityUserAgentVersion string `json:"antigravity_user_agent_version"`
OpenAICodexUserAgent string `json:"openai_codex_user_agent"`
OpenAICodexClientVersion string `json:"openai_codex_client_version"`
OpenAICodexClientVersionSynced string `json:"openai_codex_client_version_synced"`
OpenAICodexVersionAutoSyncEnabled bool `json:"openai_codex_version_auto_sync_enabled"`
// codex_cli_only 加固
MinCodexVersion string `json:"min_codex_version"`
@@ -285,8 +301,15 @@ type SystemSettings struct {
AccountQuotaNotifyEmails []NotifyEmailEntry `json:"account_quota_notify_emails"`
// Channel Monitor feature switch
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
GrokDefaultTextModel string `json:"grok_default_text_model"`
GrokCrossClientModelMapEnabled bool `json:"grok_cross_client_model_map_enabled"`
GrokDefaultBaseURLMode string `json:"grok_default_base_url_mode"`
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
@@ -312,6 +335,9 @@ type SystemSettings struct {
// 系统全局默认平台配额(key = platform,nil/缺省 = 不限制)
DefaultPlatformQuotas map[string]*service.DefaultPlatformQuotaSetting `json:"default_platform_quotas,omitempty"`
// 系统全局账号自动停调阈值(key = platform,100 = disabled)
AccountSchedulingThresholds map[string]int `json:"account_scheduling_thresholds,omitempty"`
// 允许终端用户在用量页查看自己的失败请求
AllowUserViewErrorRequests bool `json:"allow_user_view_error_requests"`
}
@@ -322,51 +348,59 @@ type DefaultSubscriptionSetting struct {
}
type PublicSettings struct {
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
PasskeyEnabled bool `json:"passkey_enabled"`
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementRevision string `json:"login_agreement_revision"`
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
CompactHomeEnabled bool `json:"compact_home_enabled"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
RegistrationEnabled bool `json:"registration_enabled"`
EmailVerifyEnabled bool `json:"email_verify_enabled"`
ForceEmailOnThirdPartySignup bool `json:"force_email_on_third_party_signup"`
RegistrationEmailSuffixWhitelist []string `json:"registration_email_suffix_whitelist"`
RegistrationEmailDomainQuotaEnabled bool `json:"registration_email_domain_quota_enabled"`
PromoCodeEnabled bool `json:"promo_code_enabled"`
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
PasskeyEnabled bool `json:"passkey_enabled"`
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
LoginAgreementRevision string `json:"login_agreement_revision"`
LoginAgreementDocuments []LoginAgreementDocument `json:"login_agreement_documents"`
TurnstileEnabled bool `json:"turnstile_enabled"`
TurnstileSiteKey string `json:"turnstile_site_key"`
TencentCaptchaEnabled bool `json:"tencent_captcha_enabled"`
TencentCaptchaAppID string `json:"tencent_captcha_app_id"`
TencentCaptchaRegion string `json:"tencent_captcha_region"`
AliyunCaptchaEnabled bool `json:"aliyun_captcha_enabled"`
AliyunCaptchaSceneID string `json:"aliyun_captcha_scene_id"`
AliyunCaptchaPrefix string `json:"aliyun_captcha_prefix"`
AliyunCaptchaRegion string `json:"aliyun_captcha_region"`
SiteName string `json:"site_name"`
SiteLogo string `json:"site_logo"`
SiteSubtitle string `json:"site_subtitle"`
APIBaseURL string `json:"api_base_url"`
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
CompactHomeEnabled bool `json:"compact_home_enabled"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
TableDefaultPageSize int `json:"table_default_page_size"`
TablePageSizeOptions []int `json:"table_page_size_options"`
CustomMenuItems []CustomMenuItem `json:"custom_menu_items"`
CustomEndpoints []CustomEndpoint `json:"custom_endpoints"`
DingTalkOAuthEnabled bool `json:"dingtalk_oauth_enabled"`
LinuxDoOAuthEnabled bool `json:"linuxdo_oauth_enabled"`
WeChatOAuthEnabled bool `json:"wechat_oauth_enabled"`
WeChatOAuthOpenEnabled bool `json:"wechat_oauth_open_enabled"`
WeChatOAuthMPEnabled bool `json:"wechat_oauth_mp_enabled"`
WeChatOAuthMobileEnabled bool `json:"wechat_oauth_mobile_enabled"`
OIDCOAuthEnabled bool `json:"oidc_oauth_enabled"`
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
// 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。
// 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。
ServerTimezone string `json:"server_timezone"`
@@ -376,8 +410,10 @@ type PublicSettings struct {
BalanceLowNotifyThreshold float64 `json:"balance_low_notify_threshold"`
BalanceLowNotifyRechargeURL string `json:"balance_low_notify_recharge_url"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorEnabled bool `json:"channel_monitor_enabled"`
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
+20 -8
View File
@@ -96,10 +96,11 @@ type Group struct {
IsExclusive bool `json:"is_exclusive"`
Status string `json:"status"`
SubscriptionType string `json:"subscription_type"`
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
SubscriptionType string `json:"subscription_type"`
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
// 图片生成计费配置(仅 antigravity 平台使用)
AllowImageGeneration bool `json:"allow_image_generation"`
@@ -121,8 +122,14 @@ type Group struct {
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
// VideoModelPrices 可选按模型族×分辨率覆盖视频每秒单价 (USD/s)。
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
// Codex alpha/search 网页搜索单次价格(USD/次);null 表示使用默认价 0.01
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
SearchPricePer1k *float64 `json:"search_price_per_1k"`
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
// Claude Code 客户端限制
ClaudeCodeOnly bool `json:"claude_code_only"`
@@ -158,9 +165,10 @@ type AdminGroup struct {
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。
// 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出
// 运营方的上游成本上限,属于内部经营信息,不得下放到 dto.Group。
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin float64 `json:"profit_min_margin"`
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin float64 `json:"profit_min_margin"`
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
ModelPricing []service.ChannelModelPricing `json:"model_pricing"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
@@ -555,6 +563,10 @@ type AdminUsageLog struct {
// UpstreamModel is the actual model sent to the upstream provider after mapping.
// Omitted when no mapping was applied (requested model was used as-is).
UpstreamModel *string `json:"upstream_model,omitempty"`
// UpstreamResponseModel is the raw model declared by the upstream response.
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
// UpstreamModelMismatch is nil when the upstream did not declare a model.
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
// ChannelID 渠道 ID
ChannelID *int64 `json:"channel_id,omitempty"`
+21 -1
View File
@@ -36,6 +36,9 @@ const (
maxSameAccountRetries = 3
// sameAccountRetryDelay 同账号重试间隔
sameAccountRetryDelay = 500 * time.Millisecond
// maxRequestScopedRetryDelay 限制请求级瞬时错误的指数退避上限,避免高重试配置
// 将单次请求拖入分钟级等待。
maxRequestScopedRetryDelay = 8 * time.Second
// singleAccountBackoffDelay 单账号分组 503 退避重试固定延时。
// Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s),
// Handler 层只需短暂间隔后重新进入 Service 层即可。
@@ -52,6 +55,21 @@ const (
// 语义上等同于「无可用账号」:候选账号都不满足分组的利润约束。
const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control"
func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration {
if failoverErr == nil || !failoverErr.RequestScopedTransient || retryCount <= 1 {
return sameAccountRetryDelay
}
delay := sameAccountRetryDelay
for i := 1; i < retryCount; i++ {
if delay >= maxRequestScopedRetryDelay/2 {
return maxRequestScopedRetryDelay
}
delay *= 2
}
return delay
}
// FailoverState 跨循环迭代共享的 failover 状态
type FailoverState struct {
SwitchCount int
@@ -149,13 +167,15 @@ func (s *FailoverState) HandleFailoverError(
// 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。
if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit {
s.SameAccountRetryCount[accountID]++
retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID])
logger.FromContext(ctx).Warn("gateway.failover_same_account_retry",
zap.Int64("account_id", accountID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("same_account_retry_count", s.SameAccountRetryCount[accountID]),
zap.Int("same_account_retry_max", retryLimit),
zap.Duration("retry_delay", retryDelay),
)
if !sleepWithContext(ctx, sameAccountRetryDelay) {
if !sleepWithContext(ctx, retryDelay) {
return FailoverCanceled
}
return FailoverContinue
@@ -31,6 +31,35 @@ func (m *mockTempUnscheduler) TempUnscheduleRetryableError(_ context.Context, ac
m.calls = append(m.calls, tempUnscheduleCall{accountID: accountID, failoverErr: failoverErr})
}
func TestSameAccountRetryDelayFor(t *testing.T) {
capacityErr := &service.UpstreamFailoverError{RequestScopedTransient: true}
for _, tc := range []struct {
name string
retryCount int
want time.Duration
}{
{name: "first retry", retryCount: 1, want: 500 * time.Millisecond},
{name: "second retry", retryCount: 2, want: time.Second},
{name: "third retry", retryCount: 3, want: 2 * time.Second},
{name: "fourth retry", retryCount: 4, want: 4 * time.Second},
{name: "fifth retry", retryCount: 5, want: 8 * time.Second},
{name: "capped retry", retryCount: 10, want: 8 * time.Second},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, sameAccountRetryDelayFor(capacityErr, tc.retryCount))
})
}
t.Run("non request scoped errors keep fixed delay", func(t *testing.T) {
require.Equal(t, 500*time.Millisecond, sameAccountRetryDelayFor(&service.UpstreamFailoverError{}, 10))
})
t.Run("nil error keeps fixed delay", func(t *testing.T) {
require.Equal(t, 500*time.Millisecond, sameAccountRetryDelayFor(nil, 10))
})
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
+28 -1
View File
@@ -1249,7 +1249,7 @@ func writeGrokModelsList(c *gin.Context, modelIDs []string) {
func grokModelSupportsConfigurableReasoning(modelID string) bool {
switch strings.ToLower(strings.TrimSpace(modelID)) {
case "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1":
case "grok-4.6", "grok-4.6-latest", "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1":
return true
default:
return false
@@ -2416,6 +2416,33 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv
task(ctx)
}
// submitMandatoryUsageRecordTask never silently drops billing work on pool overflow.
func (h *GatewayHandler) submitMandatoryUsageRecordTask(parent context.Context, task service.UsageRecordTask) {
if task == nil {
return
}
task = wrapUsageRecordTaskContext(parent, task)
if h.usageRecordWorkerPool != nil {
if mode := h.usageRecordWorkerPool.Submit(task); !mode.Dropped() {
return
}
logger.L().With(
zap.String("component", "handler.gateway.usage"),
).Warn("gateway.usage_record_task_mandatory_sync_fallback")
}
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
defer func() {
if recovered := recover(); recovered != nil {
logger.L().With(
zap.String("component", "handler.gateway.usage"),
zap.Any("panic", recovered),
).Error("gateway.usage_record_task_panic_recovered")
}
}()
task(ctx)
}
// getUserMsgQueueMode 获取当前请求的 UMQ 模式
// 返回 "serialize" | "throttle" | ""
func (h *GatewayHandler) getUserMsgQueueMode(account *service.Account, parsed *service.ParsedRequest) string {
@@ -0,0 +1,548 @@
package handler
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/websearch"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/tidwall/gjson"
"go.uber.org/zap"
)
const (
defaultGrokWebSearchResults = 5
maxGrokWebSearchResults = 20
)
func (h *GatewayHandler) WebSearch(c *gin.Context) {
isXSearch := c.GetBool("grok_x_search_endpoint")
var req grokStandaloneSearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error",
"message": err.Error(),
}})
return
}
query := strings.TrimSpace(req.Query)
if query == "" {
query = strings.TrimSpace(req.Input)
}
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error",
"message": "query is required",
}})
return
}
req.Query = query
maxResults := 0
if req.MaxResults != nil {
maxResults = *req.MaxResults
}
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
searchModel := resolveGrokStandaloneSearchModel()
searchLabel := "web_search"
if isXSearch {
searchLabel = "x_search"
}
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": gin.H{
"type": "authentication_error",
"message": "API key required",
}})
return
}
if apiKey.Group == nil || apiKey.Group.Platform != "grok" {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error",
"message": searchLabel + " is only supported for grok groups",
}})
return
}
// Billing eligibility (same as other requests)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": message}})
return
}
subject, _ := middleware2.GetAuthSubjectFromContext(c)
reqLog := requestLogger(c, "handler.gateway.web_search")
// Audit user search query before upstream Grok web_search traffic.
auditBody, _ := json.Marshal(map[string]any{
"messages": []map[string]any{{
"role": "user", "content": req.Query,
}},
})
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, searchModel, auditBody); decision != nil && !decision.AllowNextStage {
status := decision.HTTPStatus
if status == 0 {
status = http.StatusForbidden
}
code := decision.ErrorCode
if code == "" {
code = "content_policy_violation"
}
msg := decision.ClientMessage
if msg == "" {
msg = "Request blocked by content policy"
}
c.JSON(status, gin.H{"error": gin.H{"type": code, "message": msg}})
return
}
// Use exactly the same scheduling as other requests (SelectAccountWithLoadAwareness handles load, rate limit, sticky, etc.)
groupID := apiKey.GroupID
if groupID == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error",
"message": "group required",
}})
return
}
failedAccounts := make(map[int64]struct{})
var account *service.Account
var accountReleaseFunc func()
var nativeResp *websearch.SearchResponse
var providerName string
var err error
// Acquire + release holder for the whole handler (including failover retries).
defer func() {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
}()
// First attempt + up to 3 failover accounts (max 4 total).
for attempt := 0; attempt < 4; attempt++ {
selected, selectErr := h.gatewayService.SelectAccountWithLoadAwareness(
c.Request.Context(), groupID, "", searchModel, failedAccounts, "", 0,
)
if selectErr != nil {
if attempt == 0 {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
"type": "scheduling_error",
"message": selectErr.Error(),
}})
return
}
break
}
if selected == nil || selected.Account == nil {
if attempt == 0 {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
"type": "scheduling_error",
"message": "No available accounts",
}})
return
}
break
}
release, acquireOK, acquireErr := h.acquireWebSearchAccountSlot(c, selected)
if !acquireOK {
// First hop: surface concurrency errors; later hops try another account.
if attempt == 0 && acquireErr != nil {
h.handleConcurrencyError(c, acquireErr, "account", false)
return
}
failedAccounts[selected.Account.ID] = struct{}{}
continue
}
account = selected.Account
accountReleaseFunc = release
if isXSearch {
nativeResp, providerName, err = h.doGrokNativeXSearch(c.Request.Context(), c, account, req, searchModel, maxResults)
} else {
nativeResp, providerName, err = h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, maxResults, searchModel)
}
if err == nil {
break
}
var failoverErr *service.UpstreamFailoverError
if !errors.As(err, &failoverErr) || !failoverErr.ShouldRetryNextAccount() {
break
}
failedAccounts[account.ID] = struct{}{}
if accountReleaseFunc != nil {
accountReleaseFunc()
accountReleaseFunc = nil
}
account = nil
}
if err != nil || nativeResp == nil {
msg := "web search failed"
if err != nil {
msg = err.Error()
}
c.JSON(http.StatusBadGateway, gin.H{"error": gin.H{"type": "web_search_error", "message": msg}})
return
}
if account == nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": gin.H{
"type": "scheduling_error",
"message": "No available accounts",
}})
return
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
requestPayloadHash := service.HashUsageRequestPayload([]byte(req.Query))
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
// Request IDs are billing idempotency keys, so they must be unique per invocation.
// Query/IP/UA hashes would collapse repeated identical searches into one charge.
searchRequestID := searchLabel + ":" + uuid.NewString()
if apiKey.Group != nil {
if p := apiKey.Group.GetSearchPricePer1k(); p != nil && *p == 0 {
logger.L().With(
zap.String("component", "handler.gateway.web_search"),
zap.Int64("group_id", apiKey.Group.ID),
).Info("gateway.web_search.search_price_per_1k_explicit_free")
}
}
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: &service.ForwardResult{
RequestID: searchRequestID,
Model: "grok-" + strings.ReplaceAll(searchLabel, "_", "-"),
SearchCount: 1,
Duration: 0,
},
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
}); err != nil {
logger.L().With(
zap.String("component", "handler.gateway.web_search"),
zap.Int64("user_id", apiKey.User.ID),
zap.Int64("api_key_id", apiKey.ID),
zap.Int64("account_id", account.ID),
).Error("gateway.web_search.record_usage_failed", zap.Error(err))
}
})
c.JSON(http.StatusOK, gin.H{
"query": req.Query,
"results": nativeResp.Results,
"provider": providerName,
"max_results": maxResults,
})
}
// acquireWebSearchAccountSlot resolves an immediate slot or WaitPlan wait.
// On failure returns (nil, false, err); err is non-nil for concurrency acquire
// failures so the first hop can map them to HTTP. Wait-queue full returns
// (nil, false, nil) so failover can try another account.
func (h *GatewayHandler) acquireWebSearchAccountSlot(
c *gin.Context,
selected *service.AccountSelectionResult,
) (release func(), ok bool, acquireErr error) {
if selected == nil || selected.Account == nil {
return nil, false, nil
}
if selected.Acquired {
return selected.ReleaseFunc, true, nil
}
if selected.WaitPlan == nil || h.concurrencyHelper == nil {
return nil, false, nil
}
account := selected.Account
accountWaitCounted := false
canWait, waitErr := h.concurrencyHelper.IncrementAccountWaitCount(c.Request.Context(), account.ID, selected.WaitPlan.MaxWaiting)
if waitErr != nil {
logger.L().Warn("gateway.web_search.account_wait_counter_increment_failed",
zap.Int64("account_id", account.ID),
zap.Error(waitErr),
)
// Best-effort wait without counter (same as first-hop legacy path).
} else if !canWait {
return nil, false, nil
} else {
accountWaitCounted = true
}
releaseWait := func() {
if accountWaitCounted {
h.concurrencyHelper.DecrementAccountWaitCount(c.Request.Context(), account.ID)
accountWaitCounted = false
}
}
streamStarted := false
slotRelease, err := h.concurrencyHelper.AcquireAccountSlotWithWaitTimeout(
c,
account.ID,
selected.WaitPlan.MaxConcurrency,
selected.WaitPlan.Timeout,
false,
&streamStarted,
)
releaseWait()
if err != nil {
return nil, false, err
}
return slotRelease, true, nil
}
// doGrokNativeWebSearch executes web search using the Grok account's native capability
// by calling the responses endpoint with web_search tool, then normalizes sources to unified format.
func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Context, account *service.Account, query string, maxResults int, model string) (*websearch.SearchResponse, string, error) {
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
// Build a minimal responses request that triggers Grok web search tool.
// Ask for structured metadata because xAI action.sources commonly contains URLs only.
searchBody := map[string]any{
"model": xai.ResolveDefaultTextModel(model),
"input": buildGrokWebSearchPrompt(query, maxResults),
"tools": []map[string]any{{"type": "web_search"}},
"include": []string{"web_search_call.action.sources"},
"store": false,
"stream": false,
}
bodyBytes, _ := json.Marshal(searchBody)
respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes)
if err != nil {
return nil, "", err
}
// Extract sources from Grok responses output.
// Prefer web_search_call.action.sources (standardized), fallback to annotations or text links.
results := extractGrokWebSearchSources(respBytes, maxResults)
return &websearch.SearchResponse{
Results: results,
Query: query,
}, "grok-native", nil
}
func (h *GatewayHandler) doGrokNativeXSearch(ctx context.Context, c *gin.Context, account *service.Account, req grokStandaloneSearchRequest, model string, maxResults int) (*websearch.SearchResponse, string, error) {
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
bodyBytes, err := buildGrokXSearchResponsesBody(req, model)
if err != nil {
return nil, "", err
}
respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes)
if err != nil {
return nil, "", err
}
results := extractGrokWebSearchSources(respBytes, maxResults)
return &websearch.SearchResponse{
Results: results,
Query: req.Query,
}, "grok-native", nil
}
func normalizeGrokWebSearchMaxResults(maxResults int) int {
if maxResults <= 0 {
return defaultGrokWebSearchResults
}
if maxResults > maxGrokWebSearchResults {
return maxGrokWebSearchResults
}
return maxResults
}
func buildGrokWebSearchPrompt(query string, maxResults int) string {
return fmt.Sprintf(`Search the web for the user query below. Return ONLY valid JSON with this exact shape: {"results":[{"url":"https://...","title":"page title","snippet":"concise factual summary"}]}. Return at most %d unique results. Every URL must be an actual web_search source. Populate a non-empty title and snippet for every result. Do not wrap the JSON in markdown.
User query:
%s`, normalizeGrokWebSearchMaxResults(maxResults), query)
}
// extractGrokWebSearchSources returns model-enriched results only when their URLs
// are present in the actual web_search sources, then falls back to raw sources.
func extractGrokWebSearchSources(body []byte, maxResults int) []websearch.SearchResult {
if len(body) == 0 || !gjson.ValidBytes(body) {
return nil
}
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
sources := make(map[string]websearch.SearchResult)
var sourceOrder []string
addSource := func(rawURL, title, snippet string) {
key, ok := normalizeGrokWebSearchURL(rawURL)
if !ok {
return
}
result, exists := sources[key]
if !exists {
result.URL = strings.TrimSpace(rawURL)
sourceOrder = append(sourceOrder, key)
}
if result.Title == "" {
result.Title = usableGrokWebSearchTitle(title, result.URL)
}
if result.Snippet == "" {
result.Snippet = strings.TrimSpace(snippet)
}
sources[key] = result
}
output := gjson.GetBytes(body, "output")
output.ForEach(func(_, item gjson.Result) bool {
callType := item.Get("type").String()
if callType == "web_search_call" || callType == "x_search_call" {
sources := item.Get("action.sources")
if sources.IsArray() {
sources.ForEach(func(_, src gjson.Result) bool {
addSource(src.Get("url").String(), src.Get("title").String(), src.Get("snippet").String())
return true
})
}
}
if item.Get("type").String() == "message" {
item.Get("content").ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() != "output_text" {
return true
}
part.Get("annotations").ForEach(func(_, ann gjson.Result) bool {
if ann.Get("type").String() == "url_citation" || ann.Get("type").String() == "web" {
addSource(ann.Get("url").String(), ann.Get("title").String(), "")
}
return true
})
return true
})
}
return true
})
var out []websearch.SearchResult
seen := make(map[string]bool)
output.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() != "message" {
return true
}
item.Get("content").ForEach(func(_, part gjson.Result) bool {
if part.Get("type").String() != "output_text" || len(out) >= maxResults {
return true
}
for _, result := range parseGrokWebSearchStructuredResults(part.Get("text").String()) {
key, ok := normalizeGrokWebSearchURL(result.URL)
if !ok || seen[key] {
continue
}
source, allowed := sources[key]
if !allowed {
continue
}
seen[key] = true
result.URL = source.URL
result.Title = usableGrokWebSearchTitle(result.Title, result.URL)
if result.Title == "" {
result.Title = source.Title
}
result.Snippet = strings.TrimSpace(result.Snippet)
if result.Snippet == "" {
result.Snippet = source.Snippet
}
out = append(out, result)
if len(out) >= maxResults {
break
}
}
return true
})
return len(out) < maxResults
})
for _, key := range sourceOrder {
if len(out) >= maxResults {
break
}
if seen[key] {
continue
}
result := sources[key]
if result.Title == "" {
result.Title = grokWebSearchTitleFromURL(result.URL)
}
seen[key] = true
out = append(out, result)
}
return out
}
func parseGrokWebSearchStructuredResults(text string) []websearch.SearchResult {
text = strings.TrimSpace(text)
start := strings.IndexByte(text, '{')
end := strings.LastIndexByte(text, '}')
if start < 0 || end < start {
return nil
}
var payload struct {
Results []websearch.SearchResult `json:"results"`
}
if err := json.Unmarshal([]byte(text[start:end+1]), &payload); err != nil {
return nil
}
return payload.Results
}
func normalizeGrokWebSearchURL(rawURL string) (string, bool) {
u, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
return "", false
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
u.Fragment = ""
if u.Path == "" {
u.Path = "/"
}
return u.String(), true
}
func usableGrokWebSearchTitle(title, rawURL string) string {
title = strings.TrimSpace(title)
if title == "" || title == rawURL {
return ""
}
if _, err := strconv.Atoi(title); err == nil {
return ""
}
return title
}
func grokWebSearchTitleFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil || u.Host == "" {
return rawURL
}
return strings.TrimPrefix(strings.ToLower(u.Host), "www.")
}
+341
View File
@@ -0,0 +1,341 @@
package handler
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
)
// GrokRealtime exposes xAI's native Voice Realtime WebSocket.
// Only Grok-platform API keys may use this endpoint.
func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
if c == nil || c.Request == nil || !isOpenAIWSUpgradeRequest(c.Request) {
h.errorResponse(c, http.StatusUpgradeRequired, "invalid_request_error", "WebSocket upgrade required (Upgrade: websocket)")
return
}
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Realtime API is not supported for this platform")
return
}
if !h.ensureResponsesDependencies(c, nil) {
return
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
c.Request.Context(),
apiKey.GroupID,
"",
"",
"grok-4.5",
nil,
service.OpenAIUpstreamTransportHTTPSSE,
// Grok only advertises chat_completions + media capabilities on HEAD.
service.OpenAIEndpointCapabilityChatCompletions,
false,
false,
false,
service.PlatformGrok,
)
if err != nil || selection == nil || selection.Account == nil {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
return
}
var streamStarted bool
reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime")
release, slotStatus := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, true, &streamStarted, reqLog)
if slotStatus != openAISlotAcquireOK {
return
}
defer release()
token, _, err := h.gatewayService.GetRequestCredential(c.Request.Context(), c, selection.Account)
if err != nil {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok credential unavailable")
return
}
conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
if err != nil {
return
}
defer func() { _ = conn.CloseNow() }()
model := c.Query("model")
if strings.TrimSpace(model) == "" {
model = "grok-voice-latest"
}
started := time.Now()
audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model)
elapsed := time.Since(started)
if proxyErr != nil {
reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr))
if !isExpectedGrokRealtimeClose(proxyErr) {
_ = conn.Close(coderws.StatusInternalError, "upstream realtime websocket failed")
return
}
}
if result := grokRealtimeBillingResult(model, elapsed, audioObserved); result != nil {
h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result)
}
}
func grokRealtimeBillingResult(model string, elapsed time.Duration, audioObserved bool) *service.OpenAIForwardResult {
if !audioObserved || elapsed <= 0 {
return nil
}
return &service.OpenAIForwardResult{
RequestID: service.StableGrokRealtimeBillingRequestID(""),
Model: model,
Duration: elapsed,
AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()},
}
}
func isExpectedGrokRealtimeClose(err error) bool {
if err == nil {
return true
}
switch coderws.CloseStatus(err) {
case coderws.StatusNormalClosure, coderws.StatusGoingAway,
coderws.StatusNoStatusRcvd, coderws.StatusAbnormalClosure:
return true
default:
return false
}
}
// GrokVoice handles xAI Voice HTTP endpoints. endpoint is "tts", "stt", or "custom-voices".
func (h *OpenAIGatewayHandler) GrokVoice(c *gin.Context, endpoint string) {
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformGrok {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Voice API is not supported for this platform")
return
}
if !h.ensureResponsesDependencies(c, nil) {
return
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
if err := h.billingCacheService.CheckBillingEligibility(c.Request.Context(), apiKey.User, apiKey, apiKey.Group, subscription, service.QuotaPlatform(c.Request.Context(), apiKey)); err != nil {
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
body, err := readGrokVoiceGatewayBody(c)
if err != nil {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
if endpoint == "tts" {
subject, _ := middleware2.GetAuthSubjectFromContext(c)
reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
// TTS bodies use {"input":"..."} (and variants). Normalize to chat messages so
// content moderation extractors see the spoken text.
auditBody := body
if input := extractGrokTTSInputText(body); input != "" {
if b, err := json.Marshal(map[string]any{
"messages": []map[string]any{{"role": "user", "content": input}},
}); err == nil {
auditBody = b
}
}
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, "grok-4.5", auditBody); decision != nil && !decision.AllowNextStage {
h.openAISecurityAuditError(c, decision)
return
}
}
contentType := c.GetHeader("Content-Type")
if strings.TrimSpace(contentType) == "" {
contentType = "application/json"
}
failed := map[int64]struct{}{}
var last *service.UpstreamFailoverError
reqLog := requestLogger(c, "handler.openai_gateway.grok_voice", zap.String("endpoint", endpoint))
selectionModel := "grok-4.5"
for attempts := 0; attempts < 4; attempts++ {
selection, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
c.Request.Context(),
apiKey.GroupID,
"",
"",
selectionModel,
failed,
service.OpenAIUpstreamTransportHTTPSSE,
service.OpenAIEndpointCapabilityChatCompletions,
false,
false,
false,
service.PlatformGrok,
)
if selectErr != nil || selection == nil || selection.Account == nil {
if last != nil {
h.handleFailoverExhausted(c, last, false)
} else {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
}
return
}
account := selection.Account
var started bool
release, status := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, false, &started, reqLog)
if status == openAISlotAcquireProfitVetoed {
failed[account.ID] = struct{}{}
continue
}
if status != openAISlotAcquireOK {
// Failed already wrote error response (or transient reject).
if status == openAISlotAcquireFailed && len(failed) == 0 {
// Slot path wrote the response; stop.
return
}
failed[account.ID] = struct{}{}
continue
}
result, forwardErr := func() (*service.OpenAIForwardResult, error) {
defer release()
return h.gatewayService.ForwardGrokVoice(c.Request.Context(), c, account, endpoint, body, contentType)
}()
if forwardErr == nil {
h.recordGrokVoiceUsage(c, apiKey, account, subscription, endpoint, body, result)
return
}
var failoverErr *service.UpstreamFailoverError
if errors.As(forwardErr, &failoverErr) && failoverErr.ShouldRetryNextAccount() {
failed[account.ID] = struct{}{}
last = failoverErr
continue
}
// Non-failover errors: handleGrokMediaErrorResponse / transport already wrote response.
return
}
if last != nil {
h.handleFailoverExhausted(c, last, false)
}
}
// recordGrokVoiceUsage bills TTS/STT/realtime via group audio prices when AudioUsage is set.
func (h *OpenAIGatewayHandler) recordGrokVoiceUsage(
c *gin.Context,
apiKey *service.APIKey,
account *service.Account,
subscription *service.UserSubscription,
endpoint string,
body []byte,
result *service.OpenAIForwardResult,
) {
if h == nil || c == nil || apiKey == nil || account == nil || result == nil {
return
}
if result.AudioUsage == nil {
return
}
// Ensure forced durable request ids even if callers forget (realtime/tts/stt money path).
if mode := strings.TrimSpace(result.AudioUsage.Mode); mode == "realtime" {
result.RequestID = service.StableGrokRealtimeBillingRequestID(result.RequestID)
} else {
result.RequestID = service.StableGrokAudioBillingRequestID(result.RequestID)
}
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
sessionID := service.ExtractClientSessionID(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
if requestPayloadHash == "" {
requestPayloadHash = service.HashUsageRequestPayload([]byte(endpoint))
}
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
model := strings.TrimSpace(result.Model)
if model == "" {
model = endpoint
}
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, service.ChannelMappingResult{}, model, result.UpstreamModel),
}); err != nil {
logger.L().With(
zap.String("component", "handler.openai_gateway.grok_voice"),
zap.Int64("user_id", apiKey.User.ID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
zap.String("endpoint", endpoint),
zap.Int64("account_id", account.ID),
).Error("grok_voice.record_usage_failed", zap.Error(err))
}
})
}
func readGrokVoiceGatewayBody(c *gin.Context) ([]byte, error) {
if c == nil || c.Request == nil {
return nil, errors.New("request body is required")
}
if c.Request.Body == nil {
if c.Request.Method == http.MethodGet || c.Request.Method == http.MethodDelete {
return nil, nil
}
return nil, errors.New("request body is required")
}
return io.ReadAll(c.Request.Body)
}
// extractGrokTTSInputText pulls the primary spoken text from a TTS JSON body.
func extractGrokTTSInputText(body []byte) string {
if len(body) == 0 {
return ""
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return ""
}
for _, key := range []string{"input", "text", "prompt"} {
if v, ok := payload[key]; ok {
if s, ok := v.(string); ok {
return strings.TrimSpace(s)
}
}
}
return ""
}
@@ -0,0 +1,52 @@
//go:build unit
package handler
import (
"testing"
"time"
coderws "github.com/coder/websocket"
)
func TestIsExpectedGrokRealtimeClose(t *testing.T) {
for _, status := range []coderws.StatusCode{
coderws.StatusNormalClosure,
coderws.StatusGoingAway,
coderws.StatusNoStatusRcvd,
coderws.StatusAbnormalClosure,
} {
if !isExpectedGrokRealtimeClose(coderws.CloseError{Code: status}) {
t.Fatalf("status %v should be treated as an expected session close", status)
}
}
if isExpectedGrokRealtimeClose(coderws.CloseError{Code: coderws.StatusPolicyViolation}) {
t.Fatal("policy violations must not be treated as billable normal closes")
}
}
func TestGrokRealtimeBillingResultRequiresObservedAudio(t *testing.T) {
if grokRealtimeBillingResult("grok-voice-latest", time.Second, false) != nil {
t.Fatal("a session without observed audio must not be billed")
}
if grokRealtimeBillingResult("grok-voice-latest", 0, true) != nil {
t.Fatal("zero-duration sessions must not be billed")
}
}
func TestGrokRealtimeBillingResultUsesForcedUniqueID(t *testing.T) {
first := grokRealtimeBillingResult("grok-voice-latest", 90*time.Second, true)
second := grokRealtimeBillingResult("grok-voice-latest", 90*time.Second, true)
if first == nil || second == nil {
t.Fatal("observed audio sessions should be billable")
}
if first.RequestID == "" {
t.Fatalf("unexpected billing request ID %q", first.RequestID)
}
if first.RequestID == second.RequestID {
t.Fatal("independent realtime connections must not share a billing request ID")
}
if first.AudioUsage == nil || first.AudioUsage.Mode != "realtime" || first.AudioUsage.DurationOrUnits != 1.5 {
t.Fatalf("unexpected audio usage: %#v", first.AudioUsage)
}
}
+203 -4
View File
@@ -188,6 +188,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
var oauth429FailoverState service.OpenAIOAuth429FailoverState
mediaEligibilityRejected := false
switchCount := 0
videoCreateStartedAt := ""
if isGrokVideoCreateEndpoint(endpoint) {
videoCreateStartedAt = service.GrokVideoPendingCreatedAtNow()
}
maxAccountSwitches := h.maxAccountSwitches
if maxAccountSwitches <= 0 {
maxAccountSwitches = 3
@@ -406,7 +410,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, result), true, nil)
if endpoint.IsGenerationRequest() && strings.TrimSpace(result.ResponseID) != "" {
if isGrokVideoCreateEndpoint(endpoint) && strings.TrimSpace(result.ResponseID) != "" {
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, result.ResponseID, subject.UserID, apiKey.ID, account.ID,
); err != nil {
@@ -416,8 +420,44 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
zap.Error(err),
)
}
// Defer billing until status polling observes video.url. Persist create-time
// model/duration/resolution so status can still price if upstream omits them.
// Retry once: missing pending causes silent underpricing (status omits resolution).
pending := service.GrokVideoPendingBilling{
Model: requestModel,
BillingModel: firstNonEmptyString(result.BillingModel, requestModel),
UpstreamModel: result.UpstreamModel,
VideoResolution: result.VideoResolution,
VideoDurationSeconds: result.VideoDurationSeconds,
OriginalModel: clientRequestedModel(c, requestModel),
// Wall-clock start for usage duration_ms: create accepted → first done discovery.
CreatedAt: videoCreateStartedAt,
}
if err := h.gatewayService.StoreGrokVideoPendingBilling(requestCtx, result.ResponseID, subject.UserID, apiKey.ID, pending); err != nil {
reqLog.Warn("grok_media.store_video_pending_billing_failed_retrying",
zap.Int64("account_id", account.ID),
zap.String("request_id", result.ResponseID),
zap.Error(err),
)
if err2 := h.gatewayService.StoreGrokVideoPendingBilling(requestCtx, result.ResponseID, subject.UserID, apiKey.ID, pending); err2 != nil {
// Response body may already be committed; completion path will fail-closed
// when pending is still missing and status cannot price duration.
reqLog.Error("grok_media.store_video_pending_billing_failed",
zap.Int64("account_id", account.ID),
zap.String("request_id", result.ResponseID),
zap.Error(err2),
)
}
}
}
if shouldRecordGrokMediaUsage(endpoint, requestModel) {
// Status poll OR content download can observe official done+video.url.
// Both paths share the same claim key so the customer is charged once.
if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent {
taskID := strings.TrimSpace(requestID)
if billResult := prepareGrokVideoCompletionBilling(requestCtx, h, reqLog, apiKey, subject, taskID, result); billResult != nil {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, taskID)
}
} else if shouldRecordGrokMediaUsage(endpoint, requestModel, result) {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, result, requestModel, body, requestID)
}
reqLog.Debug("grok_media.request_completed",
@@ -459,8 +499,147 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul
return account.GetMappedModel(routingModel)
}
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string) bool {
return endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) != ""
func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool {
switch endpoint {
case service.GrokMediaEndpointVideosGenerations,
service.GrokMediaEndpointVideosEdits,
service.GrokMediaEndpointVideosExtensions:
return true
default:
return false
}
}
// shouldRecordGrokMediaUsage gates usage writes for immediate (image) generation.
// Async video create never bills here — status polling does on official
// status=done with video.url (docs.x.ai Video Generation).
// Status/content polls, empty model, and failed generations with zero billable
// image units never bill via this helper.
func shouldRecordGrokMediaUsage(endpoint service.GrokMediaEndpoint, requestModel string, result *service.OpenAIForwardResult) bool {
if result == nil {
return false
}
if isGrokVideoCreateEndpoint(endpoint) || endpoint.IsVideoLookupRequest() {
return false
}
if !endpoint.IsGenerationRequest() || strings.TrimSpace(requestModel) == "" {
return false
}
return result.ImageCount > 0
}
// prepareGrokVideoCompletionBilling claims one-shot billing for official done+video.url
// observations (status poll or content download). Duration/model prefer status body;
// resolution uses create-time request (status response does not document resolution).
func prepareGrokVideoCompletionBilling(
ctx context.Context,
h *OpenAIGatewayHandler,
reqLog *zap.Logger,
apiKey *service.APIKey,
subject middleware2.AuthSubject,
taskRequestID string,
statusResult *service.OpenAIForwardResult,
) *service.OpenAIForwardResult {
if h == nil || h.gatewayService == nil || apiKey == nil || statusResult == nil {
return nil
}
// Forward already set VideoCount only when status=done && video.url (official).
if statusResult.VideoCount <= 0 {
return nil
}
taskRequestID = strings.TrimSpace(firstNonEmptyString(taskRequestID, statusResult.ResponseID))
if taskRequestID == "" {
return nil
}
// Load create-time snapshot before claim so we can fail-closed without burning the claim
// when Redis lost pending and status cannot price the job.
pending, loadErr := h.gatewayService.LoadGrokVideoPendingBilling(ctx, taskRequestID, subject.UserID, apiKey.ID)
if loadErr != nil {
reqLog.Warn("grok_media.video_pending_billing_load_failed", zap.String("request_id", taskRequestID), zap.Error(loadErr))
}
if pending == nil {
// Status omits resolution; without pending we would silently default to 480p and underbill.
// Allow billing only when official status carries duration (still may default resolution).
if statusResult.VideoDurationSeconds <= 0 {
reqLog.Error("grok_media.video_billing_skipped_missing_pending",
zap.String("request_id", taskRequestID),
zap.String("reason", "no create-time snapshot and status has no video.duration"),
)
return nil
}
reqLog.Error("grok_media.video_billing_without_pending",
zap.String("request_id", taskRequestID),
zap.Int("status_duration_seconds", statusResult.VideoDurationSeconds),
zap.String("note", "resolution falls back to default 480p; investigate pending store failures"),
)
}
claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, taskRequestID, subject.UserID, apiKey.ID)
if err != nil {
reqLog.Warn("grok_media.video_billing_claim_failed", zap.String("request_id", taskRequestID), zap.Error(err))
return nil
}
if !claimed {
reqLog.Debug("grok_media.video_billing_already_claimed", zap.String("request_id", taskRequestID))
return nil
}
// Re-merge with pending: resolution is request-only; model/duration fill gaps.
merged := *statusResult
if pending != nil {
if strings.TrimSpace(merged.Model) == "" {
merged.Model = firstNonEmptyString(pending.BillingModel, pending.Model, pending.OriginalModel)
}
if strings.TrimSpace(merged.BillingModel) == "" {
merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model, merged.Model)
}
if strings.TrimSpace(merged.UpstreamModel) == "" {
merged.UpstreamModel = pending.UpstreamModel
}
// Official status omits resolution — always prefer create request.
if strings.TrimSpace(pending.VideoResolution) != "" {
merged.VideoResolution = pending.VideoResolution
}
if merged.VideoDurationSeconds <= 0 {
merged.VideoDurationSeconds = pending.VideoDurationSeconds
}
if strings.TrimSpace(merged.ResponseID) == "" {
merged.ResponseID = taskRequestID
}
}
if strings.TrimSpace(merged.Model) == "" {
merged.Model = "grok-imagine-video"
}
if strings.TrimSpace(merged.BillingModel) == "" {
merged.BillingModel = merged.Model
}
// Always force durable task id so usage_billing_dedup survives multi-poll +
// context-local request ids (do not prefer empty-only fill).
merged.RequestID = service.StableGrokVideoBillingRequestID(firstNonEmptyString(merged.ResponseID, taskRequestID))
merged.ResponseID = firstNonEmptyString(merged.ResponseID, taskRequestID)
merged.VideoCount = 1
// Pure video: do not keep legacy ImageCount (avoids image-path heuristics).
merged.ImageCount = 0
// Official default resolution is 480p when the create request omitted it.
merged.VideoResolution = service.NormalizeVideoBillingResolutionOrDefault(merged.VideoResolution)
// Official default duration is 8s when neither status nor create provided it.
merged.VideoDurationSeconds = service.NormalizeVideoBillingDurationSecondsOrDefault(merged.VideoDurationSeconds)
// E2E latency for async video: create accept → this discovery of done+url.
// Bill on discovery (status/content), not after further client polls; duration
// must not be only the single discovery hop (~hundreds of ms).
if pending != nil {
if e2e := service.GrokVideoE2EDuration(pending.CreatedAt, time.Now()); e2e > 0 {
merged.Duration = e2e
}
}
return &merged
}
func firstNonEmptyString(values ...string) string {
for _, v := range values {
if s := strings.TrimSpace(v); s != "" {
return s
}
}
return ""
}
func recordGrokMediaUsage(
@@ -493,6 +672,18 @@ func recordGrokMediaUsage(
OriginalModel: clientRequestedModel(c, requestModel),
ChannelMappedModel: requestModel,
}
// Async video: force durable task request id and release claim if billing fails.
videoTaskID := ""
if result != nil && result.VideoCount > 0 {
videoTaskID = strings.TrimSpace(firstNonEmptyString(requestID, result.ResponseID))
if stable := service.StableGrokVideoBillingRequestID(firstNonEmptyString(result.ResponseID, requestID)); stable != "" {
result.RequestID = stable
}
// Prefer task id hash for payload fingerprint stability across status/content.
if len(body) == 0 && videoTaskID != "" {
payloadForHash = []byte(videoTaskID)
}
}
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
@@ -510,6 +701,14 @@ func recordGrokMediaUsage(
SessionID: sessionID,
ChannelUsageFields: channelUsageFields,
}); err != nil {
if videoTaskID != "" {
if releaseErr := h.gatewayService.ReleaseGrokVideoBilling(ctx, videoTaskID, subject.UserID, apiKey.ID); releaseErr != nil {
reqLog.Warn("grok_media.video_billing_claim_release_failed",
zap.String("request_id", videoTaskID),
zap.Error(releaseErr),
)
}
}
logger.L().With(
zap.String("component", "handler.openai_gateway.grok_media"),
zap.Int64("user_id", subject.UserID),
+16 -4
View File
@@ -3,6 +3,7 @@ package handler
import (
"context"
"errors"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -41,13 +42,13 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) {
want: true,
},
{
name: "video generation records usage",
name: "video generation defers usage until status",
endpoint: service.GrokMediaEndpointVideosGenerations,
model: "grok-imagine-video-1.5",
want: true,
want: false,
},
{
name: "video status skips empty model usage",
name: "video status skips immediate helper (status path claims separately)",
endpoint: service.GrokMediaEndpointVideoStatus,
model: "",
want: false,
@@ -68,7 +69,18 @@ func TestShouldRecordGrokMediaUsage(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model))
// Nil result must never bill.
require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, nil))
// Immediate helper only bills image generation (async video bills on status).
result := &service.OpenAIForwardResult{ImageCount: 1, VideoCount: 0}
if tt.endpoint.IsGenerationRequest() && !isGrokVideoCreateEndpoint(tt.endpoint) && strings.TrimSpace(tt.model) != "" {
require.Equal(t, tt.want, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, result))
} else {
require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, result))
}
// Zero billable units never bill even for generation + model.
empty := &service.OpenAIForwardResult{}
require.False(t, shouldRecordGrokMediaUsage(tt.endpoint, tt.model, empty))
})
}
}
+1
View File
@@ -53,6 +53,7 @@ type Handlers struct {
Subscription *SubscriptionHandler
Announcement *AnnouncementHandler
ChannelMonitor *ChannelMonitorUserHandler
ChannelMonitorV2 *ChannelMonitorV2Handler
Admin *AdminHandlers
Gateway *GatewayHandler
OpenAIGateway *OpenAIGatewayHandler
+31 -25
View File
@@ -54,19 +54,23 @@ type modelPlazaModel struct {
// modelPlazaGroup 广场分组条目(白名单字段)。
type modelPlazaGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
Models []modelPlazaModel `json:"models"`
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
// 不取分组/用户专属倍率。
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
Models []modelPlazaModel `json:"models"`
}
// modelPlazaResponse 广场页响应。
@@ -164,18 +168,20 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
})
}
dto := modelPlazaGroup{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
Models: models,
ID: g.ID,
Name: g.Name,
Description: g.Description,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
ImageRateIndependent: g.ImageRateIndependent,
ImageRateMultiplier: g.ImageRateMultiplier,
Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
@@ -91,6 +91,7 @@ func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
"id", "name", "description", "platform", "subscription_type",
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
"image_rate_independent", "image_rate_multiplier",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
@@ -80,10 +80,8 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
return
}
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed {
body = cappedBody
}
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
body = cappedBody
}
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
@@ -290,16 +288,18 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
sameAccountRetryCount[account.ID]++
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
reqLog.Warn("openai_chat_completions.pool_mode_same_account_retry",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("retry_limit", retryLimit),
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
zap.Duration("retry_delay", retryDelay),
)
select {
case <-c.Request.Context().Done():
return
case <-time.After(sameAccountRetryDelay):
case <-time.After(retryDelay):
}
continue
}
@@ -313,10 +313,8 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
return
}
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
if cappedBody, changed := service.ApplyOpenAIReasoningEffortPolicy(body, apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings); changed {
body = cappedBody
}
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
body = cappedBody
}
reqStream, ok := parseOpenAICompatibleStream(body)
@@ -612,16 +610,18 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
sameAccountRetryCount[account.ID]++
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
reqLog.Warn("openai.pool_mode_same_account_retry",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("retry_limit", retryLimit),
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
zap.Duration("retry_delay", retryDelay),
)
select {
case <-c.Request.Context().Done():
return
case <-time.After(sameAccountRetryDelay):
case <-time.After(retryDelay):
}
continue
}
@@ -951,6 +951,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
h.anthropicErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
return
}
bindOpenAIReasoningEffortPolicyForMessagesRequest(c, apiKey, body)
routingModel := service.NormalizeOpenAICompatRequestedModel(reqModel)
preferredMappedModel := resolveOpenAIMessagesDispatchMappedModel(apiKey, reqModel)
reqStream := gjson.GetBytes(body, "stream").Bool()
@@ -1159,16 +1160,18 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
sameAccountRetryCount[account.ID]++
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
reqLog.Warn("openai_messages.pool_mode_same_account_retry",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("retry_limit", retryLimit),
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
zap.Duration("retry_delay", retryDelay),
)
select {
case <-c.Request.Context().Done():
return
case <-time.After(sameAccountRetryDelay):
case <-time.After(retryDelay):
}
continue
}
@@ -1613,7 +1616,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
reqLog.Info("openai.websocket_ingress_started")
clientIP := ip.GetClientIP(c)
userAgent := strings.TrimSpace(c.GetHeader("User-Agent"))
ctx := c.Request.Context()
clientLifecycleCtx := c.Request.Context()
ctx := clientLifecycleCtx
maxIngressConnections := 0
if h.cfg != nil {
maxIngressConnections = h.cfg.Gateway.OpenAIWS.MaxIngressConnectionsPerAPIKey
@@ -2007,12 +2011,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
zap.Int("candidate_count", scheduleDecision.CandidateCount),
)
maxReasoningEffort := ""
var reasoningEffortMappings []service.ReasoningEffortMapping
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformOpenAI {
maxReasoningEffort = apiKey.Group.MaxReasoningEffort
reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings
}
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
var requestPayloadHash string
// Passthrough rejects overlapping response.create frames, so one immutable
// turn-tagged slot preserves the exact mapping used for the in-flight request.
@@ -2023,10 +2022,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// openAIWSTurnPricing 的注释——绝不能用建连时刻初始化。
var turnPricing openAIWSTurnPricing
hooks := &service.OpenAIWSIngressHooks{
ClientLifecycleContext: clientLifecycleCtx,
InitialRequestModel: reqModel,
MaxReasoningEffort: maxReasoningEffort,
ReasoningEffortMappings: reasoningEffortMappings,
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
c.Set(securityAuditWSTurnContextKey, turn)
if turn == 1 {
return nil
}
@@ -2419,7 +2420,9 @@ func (h *OpenAIGatewayHandler) submitUsageRecordTask(parent context.Context, tas
}
func (h *OpenAIGatewayHandler) submitOpenAIUsageRecordTask(parent context.Context, result *service.OpenAIForwardResult, task service.UsageRecordTask) {
if result != nil && result.ImageCount > 0 {
// Money-critical bills never drop on pool overflow: media, search surcharge, voice.
if result != nil && (result.ImageCount > 0 || result.VideoCount > 0 ||
result.SearchCount > 0 || result.WebSearchCalls > 0 || result.AudioUsage != nil) {
h.submitMandatoryUsageRecordTask(parent, task)
return
}
@@ -2700,7 +2703,11 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
imageKeepalivePaddingOnly = adjustedSize < 0
imageKeepaliveResponseWritten = adjustedSize >= 0
}
if service.IsResponseCommitted(c) || (!compactKeepaliveCommitted && imageKeepaliveResponseWritten) {
compactKeepaliveHasMeaningfulOutput := compactKeepaliveCommitted && service.OpenAICompactKeepaliveAdjustedWrittenSize(c) > 0
// Compact keepalive may have committed 200 headers without writing a
// semantic SSE event. In that case the Responses stream still needs its
// protocol-correct terminal response.failed event.
if (service.IsResponseCommitted(c) && (!compactKeepaliveCommitted || compactKeepaliveHasMeaningfulOutput)) || (!compactKeepaliveCommitted && imageKeepaliveResponseWritten) {
return false
}
if c.Writer.Written() && !imageKeepalivePaddingOnly {
@@ -17,6 +17,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/config"
pkghttputil "github.com/Wei-Shaw/sub2api/internal/pkg/httputil"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
coderws "github.com/coder/websocket"
@@ -334,6 +335,26 @@ func TestOpenAIEnsureForwardErrorResponse_AfterDeltaAppendsSingleValidResponseFa
require.Equal(t, 1, errorEvents)
}
func TestOpenAIEnsureForwardErrorResponse_CompactKeepaliveOnlyWritesResponseFailed(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
service.MarkOpenAICompactClientStream(c)
stop := service.StartOpenAICompactSSEKeepalive(c, 5*time.Millisecond)
defer stop()
before := service.OpenAICompactKeepaliveAdjustedWrittenSize(c)
require.Eventually(t, c.Writer.Written, time.Second, time.Millisecond)
require.Equal(t, before, service.OpenAICompactKeepaliveAdjustedWrittenSize(c))
h := &OpenAIGatewayHandler{}
require.True(t, h.ensureForwardErrorResponse(c, false))
require.Equal(t, http.StatusOK, w.Code)
require.Contains(t, w.Body.String(), "event: response.failed\n")
require.NotContains(t, w.Body.String(), "event: error\n")
}
func TestOpenAIEnsureForwardErrorResponse_ImageJSONKeepaliveWritesSingleJSONFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
@@ -643,6 +664,9 @@ func TestResolveOpenAIMessagesDispatchMappedModel(t *testing.T) {
})
t.Run("grok_group_maps_claude_cli_model_to_grok_default", func(t *testing.T) {
original := xai.RuntimeModelMappingOptions()
t.Cleanup(func() { xai.SetRuntimeModelMappingOptions(original) })
xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{EnableCrossClientMap: true})
apiKey := &service.APIKey{
Group: &service.Group{
Platform: service.PlatformGrok,
@@ -1812,6 +1836,37 @@ func (u *openAIHTTPPassthroughFailoverUpstream) calls() []int64 {
return append([]int64(nil), u.accountIDs...)
}
type openAIHTTPPassthroughAuthFailoverUpstream struct {
service.HTTPUpstream
mu sync.Mutex
accountIDs []int64
statusCode int
}
func (u *openAIHTTPPassthroughAuthFailoverUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
u.mu.Lock()
u.accountIDs = append(u.accountIDs, accountID)
u.mu.Unlock()
if accountID == 9911 {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"id":"resp_healthy","object":"response","model":"gpt-5.2","status":"completed","usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`)),
}, nil
}
return &http.Response{
StatusCode: u.statusCode,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"upstream credential rejected"}}`)),
}, nil
}
func (u *openAIHTTPPassthroughAuthFailoverUpstream) calls() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
return append([]int64(nil), u.accountIDs...)
}
type openAIHTTPPassthroughSSERateLimitUpstream struct {
service.HTTPUpstream
mu sync.Mutex
@@ -2046,6 +2101,108 @@ func TestOpenAIResponses_APIKeyPassthroughPool5xxRetriesThenExhaustsMaxSwitches(
require.Equal(t, "Upstream service temporarily unavailable", gjson.GetBytes(rec.Body.Bytes(), "error.message").String())
}
func TestOpenAIResponses_APIKeyPassthroughPoolAuthFailureRetriesThenSwitchesToHealthyAccount(t *testing.T) {
tests := []struct {
name string
statusCode int
}{
{name: "401", statusCode: http.StatusUnauthorized},
{name: "403", statusCode: http.StatusForbidden},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(4203)
accounts := []service.Account{
{
ID: 9910, Name: "pool-api-key", Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Priority: 1,
Credentials: map[string]any{
"api_key": "sk-pool",
"base_url": "https://api.example.test",
"pool_mode": true,
"pool_mode_retry_count": float64(1),
"pool_mode_retry_status_codes": []any{float64(tt.statusCode)},
},
Extra: map[string]any{"openai_passthrough": true},
},
{
ID: 9911, Name: "fallback-api-key", Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Priority: 2,
Credentials: map[string]any{
"api_key": "sk-fallback",
"base_url": "https://api.example.test",
},
Extra: map[string]any{"openai_passthrough": true},
},
}
cfg := &config.Config{RunMode: config.RunModeSimple}
cfg.Default.RateMultiplier = 1
cfg.Security.URLAllowlist.Enabled = false
cfg.Gateway.MaxAccountSwitches = 1
accountRepo := &openAIWSFailoverHandlerAccountRepoStub{accounts: accounts}
upstream := &openAIHTTPPassthroughAuthFailoverUpstream{statusCode: tt.statusCode}
rateLimitSvc := service.NewRateLimitService(accountRepo, nil, cfg, nil, nil)
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billingCacheSvc.Stop)
gatewaySvc := service.NewOpenAIGatewayService(
accountRepo,
nil,
nil,
nil,
nil,
nil,
nil,
cfg,
nil,
nil,
service.NewBillingService(cfg, nil),
rateLimitSvc,
billingCacheSvc,
upstream,
&service.DeferredService{},
nil,
nil,
nil,
nil,
nil,
nil,
nil,
)
h := NewOpenAIGatewayHandler(
gatewaySvc,
service.NewConcurrencyService(nil),
billingCacheSvc,
service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg),
nil,
nil,
nil,
nil,
cfg,
)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", strings.NewReader(`{"model":"gpt-5.2","input":"hello","stream":false}`))
c.Request.Header.Set("Content-Type", "application/json")
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
ID: 1803, GroupID: &groupID,
User: &service.User{ID: 1703, Status: service.StatusActive},
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, Status: service.StatusActive},
})
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 1703, Concurrency: 0})
h.Responses(c)
require.Equal(t, []int64{9910, 9910, 9911}, upstream.calls())
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "resp_healthy", gjson.GetBytes(rec.Body.Bytes(), "id").String())
})
}
}
func TestOpenAIResponses_APIKeyPassthroughSSERateLimitUsesConfiguredPoolRetry(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(4204)
+1 -1
View File
@@ -144,7 +144,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
}
sessionHash := h.gatewayService.GenerateExplicitSessionHash(c, body)
requestCtx := service.WithOpenAIImageGenerationIntent(c.Request.Context())
requestCtx := service.WithOpenAIImagesEndpoint(service.WithOpenAIImageGenerationIntent(c.Request.Context()))
maxAccountSwitches := h.maxAccountSwitches
switchCount := 0

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