Compare commits

..
Author SHA1 Message Date
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
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 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
白宦成 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
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
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
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
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
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
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
wucm667 ab326c96eb fix(openai-ws): exclude terminal events from TTFT 2026-08-07 00:25:37 +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
wucm667 df249ee5d5 fix(scheduler): diagnose legacy OpenAI exclusions 2026-08-06 02:54:16 +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
wucm667 c418fd522f [verified] fix(security-audit): deduplicate websocket turn audits 2026-08-03 20:35:56 +08:00
wucm667 7045f89de4 fix(openai): retry pool auth failures before failover 2026-07-29 18:47:07 +08:00
438 changed files with 40615 additions and 2611 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`
### 本地测试命令
+6 -1
View File
@@ -3,12 +3,17 @@
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
+2 -2
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/)
@@ -196,7 +196,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+ |
+2 -2
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/)
@@ -198,7 +198,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+ |
+2 -2
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/)
@@ -197,7 +197,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
| コンポーネント | 技術 |
|-----------|------------|
| バックエンド | Go 1.25.7, Gin, Ent |
| バックエンド | Go 1.26.5, Gin, Ent |
| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
| データベース | PostgreSQL 15+ |
| キャッシュ/キュー | Redis 7+ |
+1 -1
View File
@@ -1 +1 @@
0.1.171
0.1.173
+8 -1
View File
@@ -110,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,
@@ -319,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()
}
+17 -6
View File
@@ -149,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)
@@ -174,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)
@@ -193,11 +196,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
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)
@@ -308,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)
@@ -331,8 +334,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
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, openAICodexVersionSyncService, 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,
@@ -398,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,
@@ -606,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()
+1
View File
@@ -88,6 +88,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // channelMonitorV2Aggregator
nil, // quotaFlusher
nil, // upstreamBillingProbe
nil, // ollamaCloudUsage
+71 -2
View File
@@ -85,8 +85,18 @@ 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"`
// 是否仅允许 Claude Code 客户端
ClaudeCodeOnly bool `json:"claude_code_only,omitempty"`
// 非 Claude Code 请求降级使用的分组 ID
@@ -235,11 +245,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.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:
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 +488,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 +503,34 @@ 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.FieldClaudeCodeOnly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field claude_code_only", values[i])
@@ -822,11 +868,34 @@ 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("claude_code_only=")
builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly))
builder.WriteString(", ")
+43
View File
@@ -82,8 +82,18 @@ 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"
// 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 +244,12 @@ var Columns = []string{
FieldVideoPrice480p,
FieldVideoPrice720p,
FieldVideoPrice1080p,
FieldVideoModelPrices,
FieldWebSearchPricePerCall,
FieldSearchPricePer1k,
FieldAudioRealtimePricePerMin,
FieldAudioTtsPricePerMillionChars,
FieldAudioSttPricePerHour,
FieldClaudeCodeOnly,
FieldFallbackGroupID,
FieldFallbackGroupIDOnInvalidRequest,
@@ -341,6 +356,14 @@ 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
// 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 +584,26 @@ 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()
}
// ByClaudeCodeOnly orders the results by the claude_code_only field.
func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc()
+230
View File
@@ -225,6 +225,26 @@ 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))
}
// 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 +1785,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 +1845,206 @@ 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))
}
// ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field.
func ClaudeCodeOnlyEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
+482
View File
@@ -483,6 +483,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 +503,62 @@ 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
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate {
_c.mutation.SetClaudeCodeOnly(v)
@@ -1155,6 +1217,26 @@ 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.ClaudeCodeOnly(); !ok {
return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)}
}
@@ -1378,10 +1460,30 @@ 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.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
_node.ClaudeCodeOnly = value
@@ -2156,6 +2258,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 +2300,102 @@ 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
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert {
u.Set(group.FieldClaudeCodeOnly, v)
@@ -3157,6 +3373,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 +3422,118 @@ 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()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -4379,6 +4728,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 +4777,118 @@ 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()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+364
View File
@@ -640,6 +640,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 +679,114 @@ 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
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate {
_u.mutation.SetClaudeCodeOnly(v)
@@ -1304,6 +1424,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 +1646,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 +1661,42 @@ 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.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
@@ -2533,6 +2715,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 +2754,114 @@ 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
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne {
_u.mutation.SetClaudeCodeOnly(v)
@@ -3210,6 +3512,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 +3751,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 +3766,42 @@ 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.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
+6 -1
View File
@@ -928,7 +928,12 @@ 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: "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 +990,7 @@ var (
{
Name: "group_sort_order",
Unique: false,
Columns: []*schema.Column{GroupsColumns[42]},
Columns: []*schema.Column{GroupsColumns[47]},
},
{
Name: "idx_groups_duplicate_operation_id_active",
+502 -1
View File
@@ -21896,8 +21896,17 @@ 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
claude_code_only *bool
fallback_group_id *int64
addfallback_group_id *int64
@@ -23722,6 +23731,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 +23850,286 @@ 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)
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (m *GroupMutation) SetClaudeCodeOnly(b bool) {
m.claude_code_only = &b
@@ -25097,7 +25435,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, 60)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -25197,9 +25535,24 @@ 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.claude_code_only != nil {
fields = append(fields, group.FieldClaudeCodeOnly)
}
@@ -25337,8 +25690,18 @@ 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.FieldClaudeCodeOnly:
return m.ClaudeCodeOnly()
case group.FieldFallbackGroupID:
@@ -25456,8 +25819,18 @@ 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.FieldClaudeCodeOnly:
return m.OldClaudeCodeOnly(ctx)
case group.FieldFallbackGroupID:
@@ -25740,6 +26113,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 +26127,34 @@ 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.FieldClaudeCodeOnly:
v, ok := value.(bool)
if !ok {
@@ -25953,6 +26361,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 +26433,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 +26581,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 +26695,24 @@ 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.FieldFallbackGroupID) {
fields = append(fields, group.FieldFallbackGroupID)
}
@@ -26301,9 +26772,24 @@ 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.FieldFallbackGroupID:
m.ClearFallbackGroupID()
return nil
@@ -26420,9 +26906,24 @@ 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.FieldClaudeCodeOnly:
m.ResetClaudeCodeOnly()
return nil
+34 -18
View File
@@ -1117,80 +1117,96 @@ 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)
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
groupDescClaudeCodeOnly := groupFields[31].Descriptor()
groupDescClaudeCodeOnly := groupFields[36].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[40].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[41].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[42].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[43].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[44].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[45].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[46].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[47].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[48].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[49].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[50].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[51].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[52].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[53].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[54].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[55].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[56].Descriptor()
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
+32
View File
@@ -148,12 +148,44 @@ 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)"),
// Claude Code 客户端限制 (added by migration 029)
field.Bool("claude_code_only").
Default(false).
+10
View File
@@ -250,6 +250,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
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=
@@ -310,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=
@@ -346,6 +350,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
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=
@@ -380,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=
@@ -415,6 +423,8 @@ 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=
+56
View File
@@ -1022,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 {
@@ -2309,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)
@@ -3518,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)
@@ -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"`
@@ -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"`
@@ -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) {
+70 -50
View File
@@ -106,31 +106,36 @@ type CreateGroupRequest struct {
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
// 图片生成计费配置(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"`
@@ -168,31 +173,36 @@ type UpdateGroupRequest struct {
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
// 图片生成计费配置(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"`
@@ -519,7 +529,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,
@@ -641,7 +656,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,
@@ -136,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,
@@ -370,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,
@@ -380,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")
}
@@ -598,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
}
@@ -811,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]
@@ -66,6 +66,18 @@ 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",
@@ -23,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"`
@@ -327,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"`
@@ -354,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"`
@@ -502,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 {
@@ -1476,46 +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,
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,
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
@@ -1854,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
@@ -2061,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,
@@ -2291,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,
@@ -2304,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 {
@@ -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))
}
}
@@ -1460,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{
@@ -1473,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").
@@ -1503,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"])
@@ -3033,6 +3096,8 @@ type oauthPendingFlowUserRepo struct {
options oauthPendingFlowUserRepoOptions
}
var _ service.RegistrationEmailDomainRepository = (*oauthPendingFlowUserRepo)(nil)
type oauthPendingFlowUserRepoOptions struct {
rejectDeleteWhileAuthIdentityExists bool
}
@@ -3075,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 {
@@ -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)
}
+5
View File
@@ -202,7 +202,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,
+90 -76
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"`
@@ -300,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"`
@@ -327,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"`
}
@@ -337,58 +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"`
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"`
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"`
@@ -398,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"`
+7 -1
View File
@@ -121,8 +121,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"`
+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
// ---------------------------------------------------------------------------
@@ -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,509 @@
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) {
type webSearchReq struct {
Query string `json:"query" binding:"required"`
MaxResults int `json:"max_results"`
}
var req webSearchReq
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error",
"message": err.Error(),
}})
return
}
req.MaxResults = normalizeGrokWebSearchMaxResults(req.MaxResults)
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": "web search 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, xai.DefaultTextModel, 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, "", xai.DefaultTextModel, 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
nativeResp, providerName, err = h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, req.MaxResults)
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 := "web_search:" + 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-web-search",
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": req.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) (*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.DefaultTextModel,
"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 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 {
if item.Get("type").String() == "web_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.")
}
+338
View File
@@ -0,0 +1,338 @@
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()
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
}
}
// A relay normally returns a close error when either side closes normally.
// Those sessions still consumed upstream audio time and must be billed.
if elapsed > 0 {
result := &service.OpenAIForwardResult{
// One durable id per WS session so retries cannot collapse or double under client ids.
RequestID: service.StableGrokRealtimeBillingRequestID(""),
Model: model,
Duration: elapsed,
AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()},
}
h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result)
}
}
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,25 @@
//go:build unit
package handler
import (
"testing"
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")
}
}
+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
@@ -288,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
}
@@ -610,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
}
@@ -1158,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
}
@@ -2023,6 +2027,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
MaxReasoningEffort: maxReasoningEffort,
ReasoningEffortMappings: reasoningEffortMappings,
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
c.Set(securityAuditWSTurnContextKey, turn)
if turn == 1 {
return nil
}
@@ -2415,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
}
@@ -2696,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,6 +1,7 @@
package handler
import (
"crypto/sha256"
"net/http"
"strings"
@@ -12,6 +13,15 @@ import (
)
const securityAuditCompletedContextKey = "sub2api.security_audit.completed"
const securityAuditWSTurnContextKey = "sub2api.security_audit.ws_turn"
const securityAuditWSDedupeContextKey = "sub2api.security_audit.ws_dedupe"
type securityAuditWSDedupeEntry struct {
stage string
turn int
bodyHash [sha256.Size]byte
decision securityaudit.Decision
}
// cachesSecurityAuditCompletion reports whether a successful audit may be
// reused for the rest of the gin request. WebSocket turns share one Context
@@ -25,6 +35,15 @@ func cachesSecurityAuditCompletion(stage string) bool {
}
}
func isSecurityAuditWebSocketStage(stage string) bool {
switch strings.TrimSpace(stage) {
case "first_turn", "subsequent_turn":
return true
default:
return false
}
}
func (h *GatewayHandler) checkSecurityAudit(c *gin.Context, reqLog *zap.Logger, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol, model string, body []byte) *securityaudit.Decision {
if h == nil {
return nil
@@ -76,27 +95,68 @@ func runSecurityAudit(c *gin.Context, reqLog *zap.Logger, coordinator *securitya
return &decision
}
request := buildSecurityAuditRequest(c, apiKey, subject, protocol, model, body, stage)
if reqLog != nil {
reqLog.Info("security_audit.gateway_check_start",
zap.String("request_id", request.RequestID), zap.Int64("user_id", request.UserID),
zap.Int64("api_key_id", request.APIKeyID), zap.Int64p("group_id", request.GroupID),
zap.String("endpoint", request.Endpoint), zap.String("provider", request.Provider),
zap.String("protocol", request.Protocol), zap.String("model", request.Model), zap.String("stage", request.Stage),
zap.Int("body_bytes", len(body)))
if isSecurityAuditWebSocketStage(request.Stage) {
if turnNo, ok := securityAuditWSTurn(c); ok {
bodyHash := sha256.Sum256(body)
if cached, exists := c.Get(securityAuditWSDedupeContextKey); exists {
if entry, ok := cached.(securityAuditWSDedupeEntry); ok &&
entry.stage == request.Stage && entry.turn == turnNo && entry.bodyHash == bodyHash {
decision := entry.decision
logSecurityAuditDone(reqLog, request, decision, true)
return &decision
}
}
logSecurityAuditStart(reqLog, request, len(body), false)
decision := coordinator.Check(c.Request.Context(), request)
if decision.Kind == securityaudit.DecisionAllow {
c.Set(securityAuditWSDedupeContextKey, securityAuditWSDedupeEntry{
stage: request.Stage, turn: turnNo, bodyHash: bodyHash, decision: decision,
})
}
logSecurityAuditDone(reqLog, request, decision, false)
return &decision
}
}
logSecurityAuditStart(reqLog, request, len(body), false)
decision := coordinator.Check(c.Request.Context(), request)
if decision.AllowNextStage && cacheCompletion {
c.Set(securityAuditCompletedContextKey, true)
}
if reqLog != nil {
reqLog.Info("security_audit.gateway_check_done",
zap.String("request_id", request.RequestID), zap.String("decision", string(decision.Kind)),
zap.String("error_code", decision.ErrorCode), zap.Bool("allow_next_stage", decision.AllowNextStage),
zap.String("stage", request.Stage))
}
logSecurityAuditDone(reqLog, request, decision, false)
return &decision
}
func logSecurityAuditStart(reqLog *zap.Logger, request securityaudit.Request, bodyBytes int, cached bool) {
if reqLog == nil {
return
}
reqLog.Info("security_audit.gateway_check_start",
zap.String("request_id", request.RequestID), zap.Int64("user_id", request.UserID),
zap.Int64("api_key_id", request.APIKeyID), zap.Int64p("group_id", request.GroupID),
zap.String("endpoint", request.Endpoint), zap.String("provider", request.Provider),
zap.String("protocol", request.Protocol), zap.String("model", request.Model), zap.String("stage", request.Stage),
zap.Int("body_bytes", bodyBytes), zap.Bool("cached", cached))
}
func logSecurityAuditDone(reqLog *zap.Logger, request securityaudit.Request, decision securityaudit.Decision, cached bool) {
if reqLog == nil {
return
}
reqLog.Info("security_audit.gateway_check_done",
zap.String("request_id", request.RequestID), zap.String("decision", string(decision.Kind)),
zap.String("error_code", decision.ErrorCode), zap.Bool("allow_next_stage", decision.AllowNextStage),
zap.String("stage", request.Stage), zap.Bool("cached", cached))
}
func securityAuditWSTurn(c *gin.Context) (int, bool) {
turn, exists := c.Get(securityAuditWSTurnContextKey)
if !exists {
return 0, false
}
turnNo, ok := turn.(int)
return turnNo, ok
}
func buildSecurityAuditRequest(c *gin.Context, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol, model string, body []byte, stage string) securityaudit.Request {
legacy := buildContentModerationInput(c, apiKey, subject, protocol, model, body)
request := securityaudit.Request{
@@ -11,6 +11,8 @@ import (
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
func TestCachesSecurityAuditCompletionSkipsWebSocketStages(t *testing.T) {
@@ -18,6 +20,9 @@ func TestCachesSecurityAuditCompletionSkipsWebSocketStages(t *testing.T) {
require.True(t, cachesSecurityAuditCompletion(""))
require.False(t, cachesSecurityAuditCompletion("first_turn"))
require.False(t, cachesSecurityAuditCompletion("subsequent_turn"))
require.True(t, isSecurityAuditWebSocketStage("first_turn"))
require.True(t, isSecurityAuditWebSocketStage("subsequent_turn"))
require.False(t, isSecurityAuditWebSocketStage("http"))
}
func TestRunSecurityAuditDoesNotSkipSubsequentWebSocketTurns(t *testing.T) {
@@ -48,9 +53,119 @@ func TestRunSecurityAuditDoesNotSkipSubsequentWebSocketTurns(t *testing.T) {
require.Equal(t, int64(2), engine.enqueues.Load(), "subsequent WebSocket turns must be audited again")
}
func TestRunSecurityAuditDeduplicatesRepeatedPayloadWithinWebSocketTurn(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := &turnCountingEngine{mode: securityaudit.ModeBlocking}
coordinator := securityaudit.NewCoordinator(nil, engine)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
payload := []byte(`{"type":"response.create","response":{"input":"same turn"}}`)
c.Set(securityAuditWSTurnContextKey, 2)
first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
require.NotNil(t, first)
require.NotNil(t, second)
require.True(t, first.AllowNextStage)
require.True(t, second.AllowNextStage)
require.Equal(t, int64(1), engine.evaluates.Load())
// The cache holds only one successful same-turn result.
entry, exists := c.Get(securityAuditWSDedupeContextKey)
require.True(t, exists)
require.IsType(t, securityAuditWSDedupeEntry{}, entry)
c.Set(securityAuditWSTurnContextKey, 3)
runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
require.Equal(t, int64(2), engine.evaluates.Load())
}
func TestRunSecurityAuditDoesNotCacheFailedWebSocketDecision(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := &turnCountingEngine{
mode: securityaudit.ModeBlocking,
decisions: []*securityaudit.PromptDecision{
{Kind: securityaudit.DecisionUnavailable, AllowNextStage: false},
{Kind: securityaudit.DecisionAllow, AllowNextStage: true},
},
}
coordinator := securityaudit.NewCoordinator(nil, engine)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Set(securityAuditWSTurnContextKey, 2)
payload := []byte(`{"type":"response.create","response":{"input":"retry me"}}`)
first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
_, cachedAfterFailure := c.Get(securityAuditWSDedupeContextKey)
second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
require.False(t, first.AllowNextStage)
require.False(t, cachedAfterFailure)
require.True(t, second.AllowNextStage)
require.Equal(t, int64(2), engine.evaluates.Load())
}
func TestRunSecurityAuditDoesNotCacheFlaggedWebSocketDecision(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := &turnCountingEngine{
mode: securityaudit.ModeBlocking,
decisions: []*securityaudit.PromptDecision{
{Kind: securityaudit.DecisionFlag, AllowNextStage: true},
{Kind: securityaudit.DecisionAllow, AllowNextStage: true},
},
}
coordinator := securityaudit.NewCoordinator(nil, engine)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Set(securityAuditWSTurnContextKey, 2)
payload := []byte(`{"type":"response.create","response":{"input":"retry flagged"}}`)
first := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
_, cachedAfterFlag := c.Get(securityAuditWSDedupeContextKey)
second := runSecurityAudit(c, nil, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
require.Equal(t, securityaudit.DecisionFlag, first.Kind)
require.True(t, first.AllowNextStage)
require.False(t, cachedAfterFlag)
require.Equal(t, securityaudit.DecisionAllow, second.Kind)
require.Equal(t, int64(2), engine.evaluates.Load())
}
func TestRunSecurityAuditLogsWebSocketChecksAndCacheHits(t *testing.T) {
gin.SetMode(gin.TestMode)
engine := &turnCountingEngine{mode: securityaudit.ModeBlocking}
coordinator := securityaudit.NewCoordinator(nil, engine)
core, logs := observer.New(zap.InfoLevel)
reqLog := zap.New(core)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", nil)
c.Set(securityAuditWSTurnContextKey, 2)
payload := []byte(`{"type":"response.create","response":{"input":"same turn"}}`)
runSecurityAudit(c, reqLog, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
runSecurityAudit(c, reqLog, coordinator, nil, nil, middleware2.AuthSubject{UserID: 7}, "openai_responses", "gpt-test", payload, "subsequent_turn")
startLogs := logs.FilterMessage("security_audit.gateway_check_start").All()
require.Len(t, startLogs, 1)
require.Equal(t, false, startLogs[0].ContextMap()["cached"])
doneLogs := logs.FilterMessage("security_audit.gateway_check_done").All()
require.Len(t, doneLogs, 2)
require.Equal(t, false, doneLogs[0].ContextMap()["cached"])
require.Equal(t, true, doneLogs[1].ContextMap()["cached"])
require.Equal(t, "allow", doneLogs[1].ContextMap()["decision"])
require.Equal(t, "subsequent_turn", doneLogs[1].ContextMap()["stage"])
require.Equal(t, int64(1), engine.evaluates.Load())
}
type turnCountingEngine struct {
mode securityaudit.Mode
enqueues atomic.Int64
mode securityaudit.Mode
enqueues atomic.Int64
evaluates atomic.Int64
decisions []*securityaudit.PromptDecision
}
func (e *turnCountingEngine) EffectiveMode() securityaudit.Mode { return e.mode }
@@ -59,5 +174,9 @@ func (e *turnCountingEngine) Enqueue(context.Context, securityaudit.Request) err
return nil
}
func (e *turnCountingEngine) Evaluate(context.Context, securityaudit.Request) (*securityaudit.PromptDecision, error) {
call := e.evaluates.Add(1)
if int(call) <= len(e.decisions) {
return e.decisions[call-1], nil
}
return &securityaudit.PromptDecision{Kind: securityaudit.DecisionAllow, AllowNextStage: true}, nil
}
+60 -57
View File
@@ -44,66 +44,69 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
}
response.Success(c, dto.PublicSettings{
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
PasskeyEnabled: settings.PasskeyEnabled,
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
LoginAgreementRevision: settings.LoginAgreementRevision,
LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
TencentCaptchaAppID: settings.TencentCaptchaAppID,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
CompactHomeEnabled: settings.CompactHomeEnabled,
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: h.version,
ServerTimezone: timezone.Name(),
ServerUTCOffset: timezone.UTCOffset(),
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
RegistrationEnabled: settings.RegistrationEnabled,
EmailVerifyEnabled: settings.EmailVerifyEnabled,
ForceEmailOnThirdPartySignup: settings.ForceEmailOnThirdPartySignup,
RegistrationEmailSuffixWhitelist: settings.RegistrationEmailSuffixWhitelist,
RegistrationEmailDomainQuotaEnabled: settings.RegistrationEmailDomainQuotaEnabled,
PromoCodeEnabled: settings.PromoCodeEnabled,
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
PasskeyEnabled: settings.PasskeyEnabled,
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
LoginAgreementRevision: settings.LoginAgreementRevision,
LoginAgreementDocuments: publicLoginAgreementDocumentsToDTO(settings.LoginAgreementDocuments),
TurnstileEnabled: settings.TurnstileEnabled,
TurnstileSiteKey: settings.TurnstileSiteKey,
TencentCaptchaEnabled: settings.TencentCaptchaEnabled,
TencentCaptchaAppID: settings.TencentCaptchaAppID,
TencentCaptchaRegion: settings.TencentCaptchaRegion,
AliyunCaptchaEnabled: settings.AliyunCaptchaEnabled,
AliyunCaptchaSceneID: settings.AliyunCaptchaSceneID,
AliyunCaptchaPrefix: settings.AliyunCaptchaPrefix,
AliyunCaptchaRegion: settings.AliyunCaptchaRegion,
SiteName: settings.SiteName,
SiteLogo: settings.SiteLogo,
SiteSubtitle: settings.SiteSubtitle,
APIBaseURL: settings.APIBaseURL,
ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
CompactHomeEnabled: settings.CompactHomeEnabled,
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
TableDefaultPageSize: settings.TableDefaultPageSize,
TablePageSizeOptions: settings.TablePageSizeOptions,
CustomMenuItems: dto.ParseUserVisibleMenuItems(settings.CustomMenuItems),
CustomEndpoints: dto.ParseCustomEndpoints(settings.CustomEndpoints),
DingTalkOAuthEnabled: settings.DingTalkOAuthEnabled,
LinuxDoOAuthEnabled: settings.LinuxDoOAuthEnabled,
WeChatOAuthEnabled: settings.WeChatOAuthEnabled,
WeChatOAuthOpenEnabled: settings.WeChatOAuthOpenEnabled,
WeChatOAuthMPEnabled: settings.WeChatOAuthMPEnabled,
WeChatOAuthMobileEnabled: settings.WeChatOAuthMobileEnabled,
OIDCOAuthEnabled: settings.OIDCOAuthEnabled,
OIDCOAuthProviderName: settings.OIDCOAuthProviderName,
GitHubOAuthEnabled: settings.GitHubOAuthEnabled,
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
Version: h.version,
ServerTimezone: timezone.Name(),
ServerUTCOffset: timezone.UTCOffset(),
BalanceLowNotifyEnabled: settings.BalanceLowNotifyEnabled,
AccountQuotaNotifyEnabled: settings.AccountQuotaNotifyEnabled,
BalanceLowNotifyThreshold: settings.BalanceLowNotifyThreshold,
BalanceLowNotifyRechargeURL: settings.BalanceLowNotifyRechargeURL,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
@@ -189,3 +189,33 @@ func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_ImageResultUsesMandator
require.True(t, called.Load(), "image usage task must be mandatory when async submit is dropped")
}
func TestOpenAIGatewayHandlerSubmitOpenAIUsageRecordTask_SearchCountUsesMandatoryFallback(t *testing.T) {
pool := service.NewUsageRecordWorkerPoolWithOptions(service.UsageRecordWorkerPoolOptions{
WorkerCount: 1,
QueueSize: 1,
TaskTimeout: time.Second,
OverflowPolicy: "drop",
OverflowSamplePercent: 0,
AutoScaleEnabled: false,
})
t.Cleanup(pool.Stop)
h := &OpenAIGatewayHandler{usageRecordWorkerPool: pool}
block := make(chan struct{})
release := make(chan struct{})
pool.Submit(func(ctx context.Context) {
close(block)
<-release
})
<-block
pool.Submit(func(ctx context.Context) {})
var called atomic.Bool
h.submitOpenAIUsageRecordTask(context.Background(), &service.OpenAIForwardResult{SearchCount: 3}, func(ctx context.Context) {
called.Store(true)
})
close(release)
require.True(t, called.Load(), "search surcharge usage task must be mandatory when async submit is dropped")
}
+3
View File
@@ -175,6 +175,7 @@ func ProvideHandlers(
subscriptionHandler *SubscriptionHandler,
announcementHandler *AnnouncementHandler,
channelMonitorUserHandler *ChannelMonitorUserHandler,
channelMonitorV2Handler *ChannelMonitorV2Handler,
adminHandlers *AdminHandlers,
gatewayHandler *GatewayHandler,
openaiGatewayHandler *OpenAIGatewayHandler,
@@ -199,6 +200,7 @@ func ProvideHandlers(
Subscription: subscriptionHandler,
Announcement: announcementHandler,
ChannelMonitor: channelMonitorUserHandler,
ChannelMonitorV2: channelMonitorV2Handler,
Admin: adminHandlers,
Gateway: gatewayHandler,
OpenAIGateway: openaiGatewayHandler,
@@ -225,6 +227,7 @@ var ProviderSet = wire.NewSet(
NewSubscriptionHandler,
NewAnnouncementHandler,
NewChannelMonitorUserHandler,
NewChannelMonitorV2Handler,
ProvideGatewayHandler,
ProvideOpenAIGatewayHandler,
NewTotpHandler,
@@ -423,19 +423,20 @@ func ChatCompletionsResponseToAnthropic(resp *ChatCompletionsResponse, model str
// + the reasoning→thinking mapping in ResponsesToAnthropic.
func chatMessageToAnthropicBlocks(message ChatMessage) []AnthropicContentBlock {
var blocks []AnthropicContentBlock
reasoning := message.reasoningText()
if message.ReasoningContent != "" {
if reasoning != "" {
blocks = append(blocks, AnthropicContentBlock{
Type: "thinking",
Thinking: message.ReasoningContent,
Thinking: reasoning,
})
}
text := chatMessageContentText(message.Content)
// DeepSeek reasoning-only fallback: when there is no text and no tool calls,
// surface the reasoning content as visible text so the turn isn't empty.
if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 {
text = message.ReasoningContent
if text == "" && strings.TrimSpace(reasoning) != "" && len(message.ToolCalls) == 0 {
text = reasoning
}
if text != "" || len(message.ToolCalls) == 0 {
blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: text})
@@ -608,11 +609,12 @@ func ChatCompletionsChunkToAnthropicEvents(
for _, choice := range chunk.Choices {
// Reasoning content → thinking block.
if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" {
reasoning := choice.Delta.reasoningText()
if reasoning != nil && *reasoning != "" {
events = append(events, ensureCCAnthropicThinkingBlock(state)...)
events = append(events, ccAnthropicDelta(state, &AnthropicDelta{
Type: "thinking_delta",
Thinking: *choice.Delta.ReasoningContent,
Thinking: *reasoning,
})...)
}
@@ -0,0 +1,76 @@
package apicompat
import (
"encoding/json"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/require"
)
func readIssue5302Fixture(t *testing.T, name string, dst any) {
t.Helper()
payload, err := os.ReadFile(filepath.Join("testdata", "issue5302", name))
require.NoError(t, err)
require.NoError(t, json.Unmarshal(payload, dst))
}
func TestChatReasoningAlias_AnthropicNonStreaming(t *testing.T) {
var response ChatCompletionsResponse
readIssue5302Fixture(t, "nonstream_reasoning.json", &response)
out := ChatCompletionsResponseToAnthropic(&response, "claude-sonnet-4-20250514")
require.Len(t, out.Content, 2)
require.Equal(t, "thinking", out.Content[0].Type)
require.Equal(t, "fallback reasoning", out.Content[0].Thinking)
require.Equal(t, "final answer", out.Content[1].Text)
}
func TestChatReasoningAlias_AnthropicStreaming(t *testing.T) {
var chunk ChatCompletionsChunk
readIssue5302Fixture(t, "stream_reasoning.json", &chunk)
events := ChatCompletionsChunkToAnthropicEvents(&chunk, NewChatCompletionsToAnthropicStreamState("reasoning-model"))
var thinking string
for _, event := range events {
if event.Delta != nil && event.Delta.Type == "thinking_delta" {
thinking += event.Delta.Thinking
}
}
require.Equal(t, "streamed fallback", thinking)
}
func TestChatReasoningAlias_ResponsesSharedPaths(t *testing.T) {
var response ChatCompletionsResponse
readIssue5302Fixture(t, "nonstream_reasoning.json", &response)
nonStream := ChatCompletionsResponseToResponses(&response, "reasoning-model", nil, false, nil)
require.Len(t, nonStream.Output, 2)
require.Equal(t, "reasoning", nonStream.Output[0].Type)
require.Equal(t, "fallback reasoning", nonStream.Output[0].Summary[0].Text)
var chunk ChatCompletionsChunk
readIssue5302Fixture(t, "stream_reasoning.json", &chunk)
events := ChatCompletionsChunkToResponsesEvents(&chunk, NewChatCompletionsToResponsesStreamState("reasoning-model"))
var deltas []string
for _, event := range events {
if event.Type == "response.reasoning_summary_text.delta" {
deltas = append(deltas, event.Delta)
}
}
require.Equal(t, []string{"streamed fallback"}, deltas)
}
func TestChatReasoningAlias_ReasoningContentTakesPrecedence(t *testing.T) {
var chunk ChatCompletionsChunk
readIssue5302Fixture(t, "reasoning_content_precedence.json", &chunk)
events := ChatCompletionsChunkToResponsesEvents(&chunk, NewChatCompletionsToResponsesStreamState("reasoning-model"))
var deltas []string
for _, event := range events {
if event.Type == "response.reasoning_summary_text.delta" {
deltas = append(deltas, event.Delta)
}
}
require.Equal(t, []string{"preferred reasoning"}, deltas)
}
@@ -1053,20 +1053,21 @@ func ChatCompletionsResponseToResponses(resp *ChatCompletionsResponse, model str
func chatMessageToResponsesOutput(message ChatMessage, customTools map[string]bool, toolSearch bool, namespaceTools map[string]NamespacedToolName) []ResponsesOutput {
var outputs []ResponsesOutput
if message.ReasoningContent != "" {
reasoning := message.reasoningText()
if reasoning != "" {
outputs = append(outputs, ResponsesOutput{
Type: "reasoning",
ID: generateItemID(),
Summary: []ResponsesSummary{{
Type: "summary_text",
Text: message.ReasoningContent,
Text: reasoning,
}},
})
}
text := chatMessageContentText(message.Content)
if text == "" && strings.TrimSpace(message.ReasoningContent) != "" && len(message.ToolCalls) == 0 {
text = message.ReasoningContent
if text == "" && strings.TrimSpace(reasoning) != "" && len(message.ToolCalls) == 0 {
text = reasoning
}
if text != "" || len(message.ToolCalls) == 0 {
outputs = append(outputs, ResponsesOutput{
@@ -1328,13 +1329,14 @@ func ChatCompletionsChunkToResponsesEvents(
// (output_item.added + reasoning_summary_part.added) before the first
// delta, otherwise a strict client discards the delta. The leading
// empty-string reasoning delta upstreams send is filtered out.
if choice.Delta.ReasoningContent != nil && *choice.Delta.ReasoningContent != "" {
reasoning := choice.Delta.reasoningText()
if reasoning != nil && *reasoning != "" {
events = append(events, ensureChatReasoningItem(state)...)
_, _ = state.Reasoning.WriteString(*choice.Delta.ReasoningContent)
_, _ = state.Reasoning.WriteString(*reasoning)
events = append(events, chatToResponsesEvent(state, "response.reasoning_summary_text.delta", &ResponsesStreamEvent{
OutputIndex: state.ReasoningIndex,
SummaryIndex: 0,
Delta: *choice.Delta.ReasoningContent,
Delta: *reasoning,
ItemID: state.ReasoningItemID,
}))
}
@@ -0,0 +1,19 @@
{
"id": "chatcmpl-sanitized",
"object": "chat.completion",
"model": "reasoning-model",
"choices": [
{
"index": 0,
"message": {
"role": "assistant",
"content": "final answer",
"reasoning": "fallback reasoning",
"reasoning_details": [
{"type": "reasoning.text", "text": "fallback reasoning"}
]
},
"finish_reason": "stop"
}
]
}
@@ -0,0 +1,15 @@
{
"id": "chatcmpl-sanitized",
"object": "chat.completion.chunk",
"model": "reasoning-model",
"choices": [
{
"index": 0,
"delta": {
"reasoning_content": "preferred reasoning",
"reasoning": "fallback reasoning"
},
"finish_reason": null
}
]
}
@@ -0,0 +1,17 @@
{
"id": "chatcmpl-sanitized",
"object": "chat.completion.chunk",
"model": "reasoning-model",
"choices": [
{
"index": 0,
"delta": {
"reasoning": "streamed fallback",
"reasoning_details": [
{"type": "reasoning.text", "text": "streamed fallback"}
]
},
"finish_reason": null
}
]
}
+16
View File
@@ -651,6 +651,7 @@ type ChatMessage struct {
Role string `json:"role"` // "system" | "user" | "assistant" | "tool" | "function"
Content json.RawMessage `json:"content,omitempty"`
ReasoningContent string `json:"reasoning_content,omitempty"`
Reasoning string `json:"reasoning,omitempty"`
Name string `json:"name,omitempty"`
ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
ToolCallID string `json:"tool_call_id,omitempty"`
@@ -771,9 +772,24 @@ type ChatDelta struct {
Role string `json:"role,omitempty"`
Content *string `json:"content,omitempty"` // pointer: omit when not present, null vs "" matters
ReasoningContent *string `json:"reasoning_content,omitempty"`
Reasoning *string `json:"reasoning,omitempty"`
ToolCalls []ChatToolCall `json:"tool_calls,omitempty"`
}
func (m ChatMessage) reasoningText() string {
if m.ReasoningContent != "" {
return m.ReasoningContent
}
return m.Reasoning
}
func (d ChatDelta) reasoningText() *string {
if d.ReasoningContent != nil {
return d.ReasoningContent
}
return d.Reasoning
}
// ---------------------------------------------------------------------------
// Shared constants
// ---------------------------------------------------------------------------
+125
View File
@@ -0,0 +1,125 @@
// Package redissession provides a multi-instance OAuth session backend.
package redissession
import (
"context"
"encoding/json"
"errors"
"strings"
"time"
"github.com/redis/go-redis/v9"
)
var ErrNotConfigured = errors.New("redis session store not configured")
// Store persists JSON sessions and single-use markers under one namespace.
type Store struct {
rdb *redis.Client
prefix string
ttl time.Duration
}
func New(rdb *redis.Client, prefix string, ttl time.Duration) *Store {
if ttl <= 0 {
ttl = 30 * time.Minute
}
prefix = strings.TrimSpace(prefix)
if prefix == "" {
prefix = "oauth:session"
}
if !strings.HasSuffix(prefix, ":") {
prefix += ":"
}
return &Store{rdb: rdb, prefix: prefix, ttl: ttl}
}
func (s *Store) dataKey(id string) string { return s.prefix + strings.TrimSpace(id) }
func (s *Store) usedKey(id string) string { return s.prefix + "used:" + strings.TrimSpace(id) }
func (s *Store) Set(ctx context.Context, id string, value any) error {
if s == nil || s.rdb == nil {
return ErrNotConfigured
}
id = strings.TrimSpace(id)
if id == "" {
return errors.New("session id is required")
}
if ctx == nil {
ctx = context.Background()
}
raw, err := json.Marshal(value)
if err != nil {
return err
}
return s.rdb.Set(ctx, s.dataKey(id), raw, s.ttl).Err()
}
func (s *Store) Get(ctx context.Context, id string, dest any) (bool, error) {
if s == nil || s.rdb == nil {
return false, ErrNotConfigured
}
id = strings.TrimSpace(id)
if id == "" {
return false, nil
}
if ctx == nil {
ctx = context.Background()
}
raw, err := s.rdb.Get(ctx, s.dataKey(id)).Bytes()
if errors.Is(err, redis.Nil) {
return false, nil
}
if err != nil {
return false, err
}
if err := json.Unmarshal(raw, dest); err != nil {
return false, err
}
return true, nil
}
func (s *Store) Delete(ctx context.Context, id string) error {
if s == nil || s.rdb == nil {
return ErrNotConfigured
}
id = strings.TrimSpace(id)
if id == "" {
return nil
}
if ctx == nil {
ctx = context.Background()
}
return s.rdb.Del(ctx, s.dataKey(id), s.usedKey(id)).Err()
}
// TryConsume returns true only for the first claim while the session exists.
func (s *Store) TryConsume(ctx context.Context, id string) (bool, error) {
if s == nil || s.rdb == nil {
return false, ErrNotConfigured
}
id = strings.TrimSpace(id)
if id == "" {
return false, nil
}
if ctx == nil {
ctx = context.Background()
}
ttl := s.ttl
if remaining, err := s.rdb.TTL(ctx, s.dataKey(id)).Result(); err == nil && remaining > 0 {
ttl = remaining
}
ok, err := s.rdb.SetNX(ctx, s.usedKey(id), "1", ttl).Result()
if err != nil || !ok {
return ok, err
}
exists, err := s.rdb.Exists(ctx, s.dataKey(id)).Result()
if err != nil {
return false, err
}
if exists == 0 {
_ = s.rdb.Del(ctx, s.usedKey(id)).Err()
return false, nil
}
return true, nil
}
@@ -0,0 +1,40 @@
//go:build unit
package redissession
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestStoreRoundTripAndSingleUse(t *testing.T) {
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
t.Cleanup(func() { _ = rdb.Close() })
store := New(rdb, "oauth:test", time.Minute)
ctx := context.Background()
require.NoError(t, store.Set(ctx, "sid", map[string]string{"state": "state"}))
var got map[string]string
ok, err := store.Get(ctx, "sid", &got)
require.NoError(t, err)
require.True(t, ok)
require.Equal(t, "state", got["state"])
ok, err = store.TryConsume(ctx, "sid")
require.NoError(t, err)
require.True(t, ok)
ok, err = store.TryConsume(ctx, "sid")
require.NoError(t, err)
require.False(t, ok)
require.NoError(t, store.Delete(ctx, "sid"))
ok, err = store.Get(ctx, "sid", &got)
require.NoError(t, err)
require.False(t, ok)
}
+93 -27
View File
@@ -20,7 +20,9 @@ const (
// one bump here covers OAuth traffic and billing probes together.
// Keep in sync with https://x.ai/cli/stable.
CLIClientVersion = "0.2.114"
CLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
// billingCLIUserAgent is the legacy pager/shell UA used by billing probes.
// Distinct from CLIUserAgent() in cli_identity.go (workspace-style UA).
billingCLIUserAgent = "grok-pager/" + CLIClientVersion + " grok-shell/" + CLIClientVersion + " (macos; aarch64)"
BillingWeeklyPath = "/billing?format=credits"
BillingMonthlyPath = "/billing"
@@ -43,14 +45,22 @@ type BillingProductUsage struct {
}
// BillingConfig is the nested config object from /v1/billing responses.
// Weekly (`?format=credits`) and monthly (`/billing`) share this shape; absolute
// money fields typically appear on the credits (prepaid/on-demand) or monthly
// (limit/used) responses.
type BillingConfig struct {
CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"`
CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"`
ProductUsage []BillingProductUsage `json:"productUsage,omitempty"`
MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"`
Used json.RawMessage `json:"used,omitempty"`
BillingPeriodStart string `json:"billingPeriodStart,omitempty"`
BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"`
CurrentPeriod *BillingPeriod `json:"currentPeriod,omitempty"`
CreditUsagePercent *float64 `json:"creditUsagePercent,omitempty"`
ProductUsage []BillingProductUsage `json:"productUsage,omitempty"`
MonthlyLimit json.RawMessage `json:"monthlyLimit,omitempty"`
Used json.RawMessage `json:"used,omitempty"`
OnDemandCap json.RawMessage `json:"onDemandCap,omitempty"`
OnDemandUsed json.RawMessage `json:"onDemandUsed,omitempty"`
PrepaidBalance json.RawMessage `json:"prepaidBalance,omitempty"`
IsUnifiedBillingUser bool `json:"isUnifiedBillingUser,omitempty"`
TopUpMethod string `json:"topUpMethod,omitempty"`
BillingPeriodStart string `json:"billingPeriodStart,omitempty"`
BillingPeriodEnd string `json:"billingPeriodEnd,omitempty"`
}
// BillingPayload is the top-level body from /v1/billing.
@@ -65,6 +75,8 @@ type BillingProductSummary struct {
}
// BillingSummary is the merged weekly + monthly billing view.
// Cents fields remain the authoritative monthly numbers; dollar fields are the
// operator-facing absolute money view (prepaid / on-demand / monthly $).
type BillingSummary struct {
PeriodType string `json:"period_type,omitempty"` // weekly | monthly | unknown
UsagePercent *float64 `json:"usage_percent,omitempty"`
@@ -77,17 +89,26 @@ type BillingSummary struct {
BillingPeriodStart string `json:"billing_period_start,omitempty"`
BillingPeriodEnd string `json:"billing_period_end,omitempty"`
UsedPercent *float64 `json:"used_percent,omitempty"`
Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | ""
StatusCode int `json:"status_code,omitempty"`
WeeklyStatusCode int `json:"weekly_status_code,omitempty"`
MonthlyStatusCode int `json:"monthly_status_code,omitempty"`
Source string `json:"source,omitempty"`
FetchedAt string `json:"fetched_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"`
MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"`
Partial bool `json:"partial,omitempty"`
FailedWindows []string `json:"failed_windows,omitempty"`
// Absolute money (USD). Prepaid/on-demand come from credits probe as dollars.
// MonthlyLimit/MonthlyUsed are cents/100 for consistent $ display.
PrepaidBalance *float64 `json:"prepaid_balance,omitempty"`
MonthlyLimit *float64 `json:"monthly_limit,omitempty"`
MonthlyUsed *float64 `json:"monthly_used,omitempty"`
OnDemandCap *float64 `json:"on_demand_cap,omitempty"`
OnDemandUsed *float64 `json:"on_demand_used,omitempty"`
TopUpMethod string `json:"top_up_method,omitempty"`
IsUnifiedBillingUser bool `json:"is_unified_billing_user,omitempty"`
Plan string `json:"plan,omitempty"` // SuperGrok | SuperGrok Heavy | ""
StatusCode int `json:"status_code,omitempty"`
WeeklyStatusCode int `json:"weekly_status_code,omitempty"`
MonthlyStatusCode int `json:"monthly_status_code,omitempty"`
Source string `json:"source,omitempty"`
FetchedAt string `json:"fetched_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
WeeklyUpdatedAt string `json:"weekly_updated_at,omitempty"`
MonthlyUpdatedAt string `json:"monthly_updated_at,omitempty"`
Partial bool `json:"partial,omitempty"`
FailedWindows []string `json:"failed_windows,omitempty"`
}
// BuildBillingURL builds weekly or monthly billing URL against the CLI chat proxy.
@@ -127,7 +148,7 @@ func ApplyCLIBillingHeaders(req *http.Request, accessToken string) {
req.Header.Set("Content-Type", "application/json")
req.Header.Set(CLITokenAuthHeader, CLITokenAuthValue)
req.Header.Set(CLIClientVersionHeader, CLIClientVersion)
req.Header.Set("User-Agent", CLIUserAgent)
req.Header.Set("User-Agent", billingCLIUserAgent)
}
// ParseBillingPayload unmarshals a billing API response body.
@@ -152,18 +173,15 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary {
periodType := resolvePeriodType(period)
creditUsage := cloneFloat(config.CreditUsagePercent)
// Weekly period bounds must not fall back to monthly billing period ends —
// that would park accounts on a multi-week horizon when weekly UsagePercent
// is high (scheduler seven_day uses PeriodEnd).
periodStart := ""
periodEnd := ""
if period != nil {
periodStart = strings.TrimSpace(period.Start)
periodEnd = strings.TrimSpace(period.End)
}
if periodStart == "" {
periodStart = strings.TrimSpace(config.BillingPeriodStart)
}
if periodEnd == "" {
periodEnd = strings.TrimSpace(config.BillingPeriodEnd)
}
products := make([]BillingProductSummary, 0, len(config.ProductUsage))
for _, item := range config.ProductUsage {
@@ -179,6 +197,11 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary {
monthlyLimit := parseCentValue(config.MonthlyLimit)
used := parseCentValue(config.Used)
// Absolute money on credits responses is dollar-denominated ({"val": 12}).
// Monthly limit/used are cents (same as MonthlyLimitCents / UsedCents).
prepaid := parseCentValue(config.PrepaidBalance)
onDemandCap := parseCentValue(config.OnDemandCap)
onDemandUsed := parseCentValue(config.OnDemandUsed)
billingStart := strings.TrimSpace(config.BillingPeriodStart)
billingEnd := strings.TrimSpace(config.BillingPeriodEnd)
@@ -198,7 +221,7 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary {
usedPercent = &v
}
hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0
hasWeekly := creditUsage != nil || periodType == "weekly" || len(products) > 0 || prepaid != nil || onDemandCap != nil || onDemandUsed != nil
hasMonthly := monthlyLimit != nil || used != nil || (!hasWeekly && billingEnd != "")
if !hasWeekly && !hasMonthly {
return nil
@@ -228,6 +251,24 @@ func BuildBillingSummary(config *BillingConfig) *BillingSummary {
summary.BillingPeriodEnd = billingEnd
}
summary.UsedPercent = usedPercent
summary.PrepaidBalance = prepaid
if onDemandCap != nil {
summary.OnDemandCap = onDemandCap
}
if onDemandUsed != nil {
summary.OnDemandUsed = onDemandUsed
}
// Expose monthly cents as dollars for UI absolute rows.
if monthlyLimit != nil {
v := *monthlyLimit / 100
summary.MonthlyLimit = &v
}
if used != nil {
v := *used / 100
summary.MonthlyUsed = &v
}
summary.TopUpMethod = strings.TrimSpace(config.TopUpMethod)
summary.IsUnifiedBillingUser = config.IsUnifiedBillingUser
summary.Plan = resolvePlan(monthlyLimit)
return summary
}
@@ -257,6 +298,22 @@ func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK
out.PeriodStart = weekly.PeriodStart
out.PeriodEnd = weekly.PeriodEnd
out.ProductUsage = weekly.ProductUsage
// Absolute prepaid / on-demand usually ride the credits (weekly) response.
if weekly.PrepaidBalance != nil {
out.PrepaidBalance = weekly.PrepaidBalance
}
if weekly.OnDemandCap != nil {
out.OnDemandCap = weekly.OnDemandCap
}
if weekly.OnDemandUsed != nil {
out.OnDemandUsed = weekly.OnDemandUsed
}
if weekly.TopUpMethod != "" {
out.TopUpMethod = weekly.TopUpMethod
}
if weekly.IsUnifiedBillingUser {
out.IsUnifiedBillingUser = true
}
out.WeeklyUpdatedAt = now
}
if monthlyOK && monthly != nil {
@@ -269,6 +326,15 @@ func MergeBillingProbeResult(previous, weekly, monthly *BillingSummary, weeklyOK
out.BillingPeriodStart = monthly.BillingPeriodStart
out.BillingPeriodEnd = monthly.BillingPeriodEnd
out.UsedPercent = monthly.UsedPercent
out.MonthlyLimit = monthly.MonthlyLimit
out.MonthlyUsed = monthly.MonthlyUsed
// Monthly probe may also carry on-demand cap when credits omitted it.
if monthly.OnDemandCap != nil && out.OnDemandCap == nil {
out.OnDemandCap = monthly.OnDemandCap
}
if monthly.OnDemandUsed != nil && out.OnDemandUsed == nil {
out.OnDemandUsed = monthly.OnDemandUsed
}
out.Plan = monthly.Plan
out.MonthlyUpdatedAt = now
}
+36 -1
View File
@@ -48,7 +48,11 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) {
"config": {
"currentPeriod": {"type":"WEEKLY","start":"2026-07-09T03:25:00Z","end":"2026-07-16T03:25:00Z"},
"creditUsagePercent": 2.0,
"productUsage": [{"product":"Api","usagePercent":2.0}]
"productUsage": [{"product":"Api","usagePercent":2.0}],
"prepaidBalance": {"val": 12},
"onDemandCap": {"val": 100},
"onDemandUsed": {"val": 5},
"isUnifiedBillingUser": true
}
}`)
monthlyBody := []byte(`{
@@ -72,10 +76,16 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) {
require.Equal(t, "weekly", weekly.PeriodType)
require.InDelta(t, 2.0, *weekly.UsagePercent, 1e-9)
require.Equal(t, "Api", weekly.ProductUsage[0].Product)
require.InDelta(t, 12, *weekly.PrepaidBalance, 1e-9)
require.InDelta(t, 100, *weekly.OnDemandCap, 1e-9)
require.InDelta(t, 5, *weekly.OnDemandUsed, 1e-9)
require.True(t, weekly.IsUnifiedBillingUser)
require.Equal(t, "SuperGrok", monthly.Plan)
require.InDelta(t, 15000, *monthly.MonthlyLimitCents, 1e-9)
require.InDelta(t, 78, *monthly.UsedCents, 1e-9)
require.InDelta(t, 0.52, *monthly.UsedPercent, 1e-2)
require.InDelta(t, 150, *monthly.MonthlyLimit, 1e-9)
require.InDelta(t, 0.78, *monthly.MonthlyUsed, 1e-9)
merged := MergeBillingProbeResult(nil, weekly, monthly, true, true)
require.Equal(t, "weekly", merged.PeriodType)
@@ -83,6 +93,10 @@ func TestBuildBillingSummaryWeeklyAndMonthly(t *testing.T) {
require.Equal(t, "SuperGrok", merged.Plan)
require.InDelta(t, 15000, *merged.MonthlyLimitCents, 1e-9)
require.Equal(t, "2026-08-01T00:00:00Z", merged.BillingPeriodEnd)
require.InDelta(t, 12, *merged.PrepaidBalance, 1e-9)
require.InDelta(t, 100, *merged.OnDemandCap, 1e-9)
require.InDelta(t, 150, *merged.MonthlyLimit, 1e-9)
require.InDelta(t, 0.78, *merged.MonthlyUsed, 1e-9)
}
func TestParseCentValueBareNumber(t *testing.T) {
@@ -105,6 +119,27 @@ func TestBuildBillingSummaryMonthlyOnlyKeepsWeeklyUsageEmpty(t *testing.T) {
require.InDelta(t, 50, *summary.UsedPercent, 1e-9)
}
func TestBuildBillingSummaryWeeklyDoesNotInheritMonthlyPeriodEnd(t *testing.T) {
t.Parallel()
// Weekly usage without currentPeriod.end must not copy billingPeriodEnd (monthly).
payload, err := ParseBillingPayload([]byte(`{
"config": {
"creditUsagePercent": 95.0,
"productUsage": [{"product":"Api","usagePercent":95.0}],
"billingPeriodStart": "2026-07-01T00:00:00Z",
"billingPeriodEnd": "2026-08-01T00:00:00Z",
"monthlyLimit": {"val": 15000},
"used": {"val": 1000}
}
}`))
require.NoError(t, err)
summary := BuildBillingSummary(payload.Config)
require.NotNil(t, summary)
require.Equal(t, "weekly", summary.PeriodType)
require.Equal(t, "", summary.PeriodEnd)
require.Equal(t, "2026-08-01T00:00:00Z", summary.BillingPeriodEnd)
}
func TestMergeBillingProbeResultRetainsFailedWindow(t *testing.T) {
t.Parallel()
previous := &BillingSummary{
+79
View File
@@ -0,0 +1,79 @@
package xai
import (
"net/http"
"os"
"strings"
"golang.org/x/mod/semver"
)
// Fixed Grok Build / CLI-chat-proxy client identity.
// These values are intentionally pinned in-binary (not scraped from live CLI).
// Operators may bump the version via XAI_GROK_CLI_VERSION without a release.
const (
// CLIProxyHost is the hostname that requires the official CLI identity headers.
CLIProxyHost = "cli-chat-proxy.grok.com"
// CLIStableVersion is the known-good minimum client version accepted by cli-chat-proxy.
CLIStableVersion = "0.2.93"
// CLIVersionEnv is the optional operator override for CLIStableVersion.
CLIVersionEnv = "XAI_GROK_CLI_VERSION"
// CLITokenAuth is required by cli-chat-proxy for Grok Build OAuth tokens.
CLITokenAuth = "xai-grok-cli"
// CLIClientIdentifier is the x-grok-client-identifier value used by Grok shell/CLI.
CLIClientIdentifier = "grok-shell"
// CLIClientMode is used by billing / quota probes on the CLI surface.
CLIClientMode = "cli"
)
// ResolveCLIVersion returns a supported CLI client version.
// Empty or invalid overrides fall back to CLIClientVersion (the pinned
// preferred client pin in billing.go). CLIStableVersion is only the minimum
// accepted by IsSupportedCLIVersion, not the default identity we advertise.
func ResolveCLIVersion() string {
version := strings.TrimSpace(os.Getenv(CLIVersionEnv))
if !IsSupportedCLIVersion(version) {
return CLIClientVersion
}
return version
}
// IsSupportedCLIVersion reports whether version is a valid semver string at or
// above CLIStableVersion (prereleases below a higher release are rejected when
// they compare less than the stable pin).
func IsSupportedCLIVersion(version string) bool {
canonical := "v" + version
minimum := "v" + CLIStableVersion
return semver.IsValid(canonical) &&
semver.Canonical(canonical) == canonical &&
semver.Compare(canonical, minimum) >= 0
}
// CLIUserAgent builds the workspace-style User-Agent for a CLI client version.
func CLIUserAgent(version string) string {
if strings.TrimSpace(version) == "" {
version = CLIClientVersion
}
return "xai-grok-workspace/" + version
}
// ApplyCLIProxyHeaders stamps the fixed Grok CLI identity when the request
// targets cli-chat-proxy. Direct api.x.ai traffic is left unchanged.
func ApplyCLIProxyHeaders(req *http.Request) {
if req == nil || req.URL == nil || !strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), CLIProxyHost) {
return
}
if req.Header == nil {
req.Header = make(http.Header)
}
version := ResolveCLIVersion()
req.Header.Set("X-XAI-Token-Auth", CLITokenAuth)
req.Header.Set("x-grok-client-version", version)
req.Header.Set("x-grok-client-identifier", CLIClientIdentifier)
req.Header.Set("User-Agent", CLIUserAgent(version))
}
@@ -0,0 +1,67 @@
package xai
import (
"net/http"
"testing"
"github.com/stretchr/testify/require"
)
func TestResolveCLIVersionDefaultsToPinnedClientVersion(t *testing.T) {
t.Setenv(CLIVersionEnv, "")
// Default advertise pin is CLIClientVersion; CLIStableVersion is only the floor.
require.Equal(t, CLIClientVersion, ResolveCLIVersion())
require.True(t, IsSupportedCLIVersion(CLIClientVersion))
require.True(t, IsSupportedCLIVersion(CLIStableVersion))
}
func TestResolveCLIVersionAcceptsValidOverride(t *testing.T) {
t.Setenv(CLIVersionEnv, "0.2.95-alpha.1")
require.Equal(t, "0.2.95-alpha.1", ResolveCLIVersion())
}
func TestResolveCLIVersionRejectsUnsafeOrTooOld(t *testing.T) {
for _, version := range []string{
"0.2.92",
"0.2.93-beta.1",
"0.2.95\r\nX-Injected: true",
"0.2.093",
"0.3",
"1",
} {
t.Run(version, func(t *testing.T) {
t.Setenv(CLIVersionEnv, version)
require.Equal(t, CLIClientVersion, ResolveCLIVersion())
})
}
}
func TestApplyCLIProxyHeaders(t *testing.T) {
t.Setenv(CLIVersionEnv, "")
req, err := http.NewRequest(http.MethodPost, "https://cli-chat-proxy.grok.com/v1/responses", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
ApplyCLIProxyHeaders(req)
require.Equal(t, CLIClientVersion, req.Header.Get("x-grok-client-version"))
require.Equal(t, CLIClientIdentifier, req.Header.Get("x-grok-client-identifier"))
require.Equal(t, CLITokenAuth, req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, CLIUserAgent(CLIClientVersion), req.Header.Get("User-Agent"))
}
func TestApplyCLIProxyHeadersLeavesAPIHostUnchanged(t *testing.T) {
t.Setenv(CLIVersionEnv, "0.2.95")
req, err := http.NewRequest(http.MethodPost, "https://api.x.ai/v1/responses", nil)
require.NoError(t, err)
req.Header.Set("User-Agent", "sub2api-grok/1.0")
ApplyCLIProxyHeaders(req)
require.Empty(t, req.Header.Get("x-grok-client-version"))
require.Empty(t, req.Header.Get("x-grok-client-identifier"))
require.Empty(t, req.Header.Get("X-XAI-Token-Auth"))
require.Equal(t, "sub2api-grok/1.0", req.Header.Get("User-Agent"))
}
+269 -23
View File
@@ -1,28 +1,130 @@
package xai
import (
"strings"
"sync/atomic"
)
// runtimeMappingOpts holds operator-configured defaults applied when Grok
// accounts leave credentials.model_mapping empty. Updated from settings.
var runtimeMappingOpts atomic.Value // ModelMappingOptions
var runtimeMappingVersion atomic.Uint64
func init() {
runtimeMappingOpts.Store(ModelMappingOptions{})
runtimeMappingVersion.Store(1)
}
// SetRuntimeModelMappingOptions updates process-wide defaults used by
// DefaultModelMapping (e.g. after settings load). Safe for concurrent use.
func SetRuntimeModelMappingOptions(opts ModelMappingOptions) {
runtimeMappingOpts.Store(opts)
runtimeMappingVersion.Add(1)
}
// RuntimeModelMappingVersion changes whenever runtime mapping options change.
// Account-level caches include it so settings updates take effect without a restart.
func RuntimeModelMappingVersion() uint64 {
return runtimeMappingVersion.Load()
}
// RuntimeModelMappingOptions returns the last options set via SetRuntimeModelMappingOptions.
func RuntimeModelMappingOptions() ModelMappingOptions {
if v := runtimeMappingOpts.Load(); v != nil {
if opts, ok := v.(ModelMappingOptions); ok {
return opts
}
}
return ModelMappingOptions{}
}
// Model describes an xAI model in OpenAI-compatible /models shape.
type Model struct {
ID string `json:"id"`
Object string `json:"object"`
Type string `json:"type,omitempty"`
Created int64 `json:"created,omitempty"`
OwnedBy string `json:"owned_by"`
DisplayName string `json:"display_name,omitempty"`
}
// DefaultTextModel is the built-in fallback for empty model fields and Grok
// text aliases (e.g. "grok", "grok-latest"). Operators may override the runtime
// default via settings key grok_default_text_model.
const DefaultTextModel = "grok-4.5"
// Official Imagine model IDs (https://docs.x.ai/docs/models).
const (
DefaultImagineImageQualityModel = "grok-imagine-image-quality"
DefaultImagineImageFastModel = "grok-imagine-image"
DefaultImagineVideoModel = "grok-imagine-video"
DefaultImagineVideo15LegacyModel = "grok-imagine-video-1.5"
DefaultImagineVideo15Model = "grok-imagine-video-1.5-preview"
)
// ModelMappingOptions controls optional expansions of the default mapping.
// Cross-client wildcards (gpt-*/claude-*) default ON via settings
// grok_cross_client_model_map_enabled so Codex/Claude clients keep working
// against Grok groups (map to DefaultText / grok-4.5). Operators may disable.
type ModelMappingOptions struct {
// DefaultText is the target for empty models and optional cross-client maps.
// Empty → DefaultTextModel (grok-4.5).
DefaultText string
// EnableCrossClientMap merges gpt-*/codex-*/o*/claude-* → DefaultText.
EnableCrossClientMap bool
}
func (o ModelMappingOptions) defaultText() string {
if t := strings.TrimSpace(o.DefaultText); t != "" {
return t
}
return DefaultTextModel
}
var defaultModels = []Model{
{ID: "grok-4.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"},
{ID: "grok-4.3", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"},
{ID: "grok-build-0.1", Object: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"},
{ID: "grok-composer-2.5-fast", Object: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"},
{ID: "grok-4.20-0309-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"},
{ID: "grok-4.20-0309-non-reasoning", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"},
{ID: "grok-4.20-multi-agent-0309", Object: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"},
{ID: "grok-imagine", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine"},
{ID: "grok-imagine-image", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"},
{ID: "grok-imagine-image-quality", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"},
{ID: "grok-imagine-edit", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Edit"},
{ID: "grok-imagine-video", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"},
{ID: "grok-imagine-video-1.5", Object: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5"},
// Text
{ID: "grok-4.5", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.5"},
{ID: "grok-4.3", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.3"},
{ID: "grok-3-mini", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini"},
{ID: "grok-3-mini-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 3 Mini Fast"},
{ID: "grok-build-0.1", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Build 0.1"},
{ID: "grok-composer-2.5-fast", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Composer 2.5 Fast"},
{ID: "grok-4.20-0309-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Reasoning"},
{ID: "grok-4.20-0309-non-reasoning", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Non Reasoning"},
{ID: "grok-4.20-multi-agent-0309", Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok 4.20 Multi Agent"},
// Imagine
{ID: DefaultImagineImageQualityModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image Quality"},
{ID: DefaultImagineImageFastModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Image"},
{ID: DefaultImagineVideoModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video"},
{ID: DefaultImagineVideo15Model, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Preview"},
{ID: DefaultImagineVideo15LegacyModel, Object: "model", Type: "model", OwnedBy: "xai", DisplayName: "Grok Imagine Video 1.5 Legacy"},
}
// grokTextResponsesModelAliases is the source of truth for Grok text models
// accepted by the Responses path: client-facing / undated aliases → canonical
// upstream ID. Used by DefaultModelMapping and IsGrokTextResponsesModelID.
var grokTextResponsesModelAliases = map[string]string{
"grok": DefaultTextModel,
"grok-latest": DefaultTextModel,
"grok-4.5": DefaultTextModel,
"grok-4.5-latest": DefaultTextModel,
"grok-4.3": "grok-4.3",
"grok-4.3-latest": "grok-4.3",
"grok-3-mini": "grok-3-mini",
"grok-3-mini-fast": "grok-3-mini-fast",
"grok-build": "grok-build-0.1",
"grok-build-latest": DefaultTextModel,
"grok-build-0.1": "grok-build-0.1",
"grok-composer-2.5-fast": "grok-composer-2.5-fast",
"grok-composer": "grok-composer-2.5-fast",
"composer-2.5": "grok-composer-2.5-fast",
"grok-4.20-reasoning": "grok-4.20-0309-reasoning",
"grok-4.20-0309-reasoning": "grok-4.20-0309-reasoning",
"grok-4.20-non-reasoning": "grok-4.20-0309-non-reasoning",
"grok-4.20-0309-non-reasoning": "grok-4.20-0309-non-reasoning",
"grok-4.20-multi-agent": "grok-4.20-multi-agent-0309",
"grok-4.20-multi-agent-latest": "grok-4.20-multi-agent-0309",
"grok-4.20-multi-agent-0309": "grok-4.20-multi-agent-0309",
}
func DefaultModels() []Model {
@@ -40,19 +142,163 @@ func DefaultModelIDs() []string {
return ids
}
// DefaultModelMapping returns native Grok/Imagine identity + aliases, using
// runtime options (default text model / optional cross-client wildcards).
// Does NOT enable gpt-*/claude-* unless SetRuntimeModelMappingOptions enables them.
func DefaultModelMapping() map[string]string {
mapping := make(map[string]string, len(defaultModels)+5)
return ModelMappingWithOptions(RuntimeModelMappingOptions())
}
// ModelMappingWithOptions builds the default Grok mapping with optional
// cross-client wildcards and a configurable default text model.
func ModelMappingWithOptions(opts ModelMappingOptions) map[string]string {
defaultText := opts.defaultText()
mapping := make(map[string]string, len(defaultModels)+len(grokTextResponsesModelAliases)+48)
for _, model := range defaultModels {
mapping[model.ID] = model.ID
}
mapping["grok"] = "grok-4.5"
mapping["grok-latest"] = "grok-4.5"
mapping["grok-4.5-latest"] = "grok-4.5"
mapping["grok-build"] = "grok-build-0.1"
mapping["grok-build-latest"] = "grok-4.5"
mapping["grok-composer"] = "grok-composer-2.5-fast"
mapping["composer-2.5"] = "grok-composer-2.5-fast"
mapping["grok-4.20-reasoning"] = "grok-4.20-0309-reasoning"
mapping["grok-4.20-non-reasoning"] = "grok-4.20-0309-non-reasoning"
for alias, canonical := range grokTextResponsesModelAliases {
// Remap aliases that pointed at DefaultTextModel constant to runtime default.
if canonical == DefaultTextModel {
mapping[alias] = defaultText
} else {
mapping[alias] = canonical
}
}
// Imagine aliases / legacy IDs → official catalog.
mapping["grok-imagine"] = DefaultImagineImageQualityModel
mapping["grok-imagine-1"] = DefaultImagineImageQualityModel
// Backward-compatible client alias; xAI exposes image editing through the
// image-quality model rather than a separate grok-imagine-edit model.
mapping["grok-imagine-edit"] = DefaultImagineImageQualityModel
mapping["grok-imagine-image"] = DefaultImagineImageFastModel
mapping["grok-imagine-image-quality"] = DefaultImagineImageQualityModel
// Keep official IDs as identity so client-requested model strings are not
// rewritten on the wire (pricing still canonicalizes 1.5* via CanonicalImagineVideoModel).
mapping["grok-imagine-video"] = DefaultImagineVideoModel
mapping["grok-imagine-video-1.5"] = DefaultImagineVideo15LegacyModel
mapping["grok-imagine-video-1.5-preview"] = DefaultImagineVideo15Model
// Informal alias only:
mapping["grok-video-1.5"] = DefaultImagineVideo15Model
if opts.EnableCrossClientMap {
// Codex / OpenAI Responses client defaults (wildcard patterns).
mapping["gpt-*"] = defaultText
mapping["codex-*"] = defaultText
mapping["o1*"] = defaultText
mapping["o3*"] = defaultText
mapping["o4*"] = defaultText
// Claude Code defaults when operators intentionally enable bridging.
mapping["claude-*"] = defaultText
}
addGrokProviderPrefixedMappings(mapping)
return mapping
}
func addGrokProviderPrefixedMappings(mapping map[string]string) {
snapshot := make(map[string]string, len(mapping))
for key, value := range mapping {
snapshot[key] = value
}
for key, value := range snapshot {
if !isGrokNativeOrAlias(key) {
continue
}
for _, prefix := range []string{"xai/", "x-ai/", "grok/"} {
mapping[prefix+key] = value
}
}
}
func isGrokNativeOrAlias(model string) bool {
model = strings.ToLower(strings.TrimSpace(model))
if model == "" || strings.Contains(model, "*") {
return false
}
return strings.HasPrefix(model, "grok") ||
strings.HasPrefix(model, "imagine") ||
strings.HasPrefix(model, "composer")
}
// StripGrokProviderPrefix removes common provider prefixes accepted for
// xAI/Grok models, returning the native model ID.
func StripGrokProviderPrefix(model string) string {
trimmed := strings.TrimSpace(model)
lower := strings.ToLower(trimmed)
for _, prefix := range []string{"xai/", "x-ai/", "grok/"} {
if strings.HasPrefix(lower, prefix) {
return strings.TrimSpace(trimmed[len(prefix):])
}
}
return trimmed
}
// IsGrokModelID reports whether model looks like a native Grok/xAI model id
// (including aliases). Claude/OpenAI model names return false.
func IsGrokModelID(model string) bool {
normalized := strings.ToLower(StripGrokProviderPrefix(model))
if normalized == "" {
return false
}
if strings.HasPrefix(normalized, "grok") {
return true
}
if strings.HasPrefix(normalized, "imagine") {
return true
}
return false
}
// IsGrokTextResponsesModelID reports whether model is a known Grok text model
// for the Responses API. Imagine image/video and unknown custom ids return false.
func IsGrokTextResponsesModelID(model string) bool {
normalized := strings.ToLower(StripGrokProviderPrefix(model))
_, ok := grokTextResponsesModelAliases[normalized]
return ok
}
// ResolveGrokTextResponsesModelID canonicalizes a Grok text alias before upstream.
// empty or bare aliases that resolve via DefaultTextModel use defaultText when set.
func ResolveGrokTextResponsesModelID(model string, defaultText ...string) string {
fallback := DefaultTextModel
if len(defaultText) > 0 && strings.TrimSpace(defaultText[0]) != "" {
fallback = strings.TrimSpace(defaultText[0])
}
trimmed := strings.TrimSpace(model)
if trimmed == "" {
return fallback
}
normalized := strings.ToLower(StripGrokProviderPrefix(trimmed))
if canonical, ok := grokTextResponsesModelAliases[normalized]; ok {
if canonical == DefaultTextModel {
return fallback
}
return canonical
}
return StripGrokProviderPrefix(trimmed)
}
// ResolveDefaultTextModel returns defaultText (or DefaultTextModel) when model is empty.
func ResolveDefaultTextModel(model string, defaultText ...string) string {
if trimmed := strings.TrimSpace(model); trimmed != "" {
return trimmed
}
if len(defaultText) > 0 && strings.TrimSpace(defaultText[0]) != "" {
return strings.TrimSpace(defaultText[0])
}
return DefaultTextModel
}
// CanonicalImagineVideoModel normalizes video model ids for pricing tables.
// Legacy "grok-imagine-video-1.5" shares the 1.5 price family with preview.
func CanonicalImagineVideoModel(model string) string {
m := strings.ToLower(StripGrokProviderPrefix(model))
switch {
case m == "" || m == DefaultImagineVideoModel || m == "grok-imagine-video-preview":
return DefaultImagineVideoModel
case strings.HasPrefix(m, "grok-imagine-video-1.5") || m == "grok-video-1.5":
return DefaultImagineVideo15Model
default:
return m
}
}
+65
View File
@@ -0,0 +1,65 @@
package xai
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDefaultModelMappingExcludesCrossClientWildcards(t *testing.T) {
original := RuntimeModelMappingOptions()
t.Cleanup(func() { SetRuntimeModelMappingOptions(original) })
SetRuntimeModelMappingOptions(ModelMappingOptions{})
mapping := DefaultModelMapping()
require.Equal(t, "grok-4.5", mapping["grok"])
require.Equal(t, "grok-4.5", mapping["grok-latest"])
require.Equal(t, "grok-build-0.1", mapping["grok-build"])
require.Equal(t, DefaultTextModel, mapping["grok-build-latest"])
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"])
require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"])
require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"])
require.Equal(t, "grok-4.5", mapping["xai/grok"])
// Cross-vendor wildcards must stay opt-in.
_, hasGPT := mapping["gpt-*"]
_, hasClaude := mapping["claude-*"]
require.False(t, hasGPT)
require.False(t, hasClaude)
}
func TestModelMappingWithOptionsCrossClient(t *testing.T) {
t.Parallel()
mapping := ModelMappingWithOptions(ModelMappingOptions{
DefaultText: "grok-4.3",
EnableCrossClientMap: true,
})
require.Equal(t, "grok-4.3", mapping["grok"])
require.Equal(t, "grok-4.3", mapping["gpt-*"])
require.Equal(t, "grok-4.3", mapping["claude-*"])
require.Equal(t, "grok-4.3", mapping["codex-*"])
}
func TestCanonicalImagineVideoModel(t *testing.T) {
t.Parallel()
require.Equal(t, DefaultImagineVideoModel, CanonicalImagineVideoModel("grok-imagine-video"))
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("grok-imagine-video-1.5"))
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("grok-imagine-video-1.5-preview"))
require.Equal(t, DefaultImagineVideo15Model, CanonicalImagineVideoModel("xai/grok-video-1.5"))
require.Equal(t, "grok-imagine-video-2", CanonicalImagineVideoModel("grok-imagine-video-2"))
}
func TestIsGrokModelID(t *testing.T) {
t.Parallel()
require.True(t, IsGrokModelID("grok-4.5"))
require.True(t, IsGrokModelID("x-ai/grok-4.3"))
require.False(t, IsGrokModelID("gpt-5"))
require.False(t, IsGrokModelID("claude-sonnet-4"))
}
func TestResolveGrokTextResponsesModelID(t *testing.T) {
t.Parallel()
require.Equal(t, "grok-4.5", ResolveGrokTextResponsesModelID(""))
require.Equal(t, "grok-4.3", ResolveGrokTextResponsesModelID("grok", "grok-4.3"))
require.Equal(t, "grok-4.20-multi-agent-0309", ResolveGrokTextResponsesModelID("grok-4.20-multi-agent"))
}
+133 -17
View File
@@ -1,33 +1,40 @@
package xai
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"errors"
"fmt"
"log/slog"
"net/url"
"os"
"strings"
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/redissession"
"github.com/Wei-Shaw/sub2api/internal/util/logredact"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/redis/go-redis/v9"
)
const (
OAuthIssuer = "https://auth.x.ai"
DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration"
DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize"
DefaultTokenURL = OAuthIssuer + "/oauth2/token"
DefaultBaseURL = "https://api.x.ai/v1"
DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1"
DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828"
DefaultScope = "openid profile email offline_access grok-cli:access api:access"
DefaultRedirectURI = "http://127.0.0.1:56121/callback"
SessionTTL = 30 * time.Minute
OAuthIssuer = "https://auth.x.ai"
DiscoveryURL = OAuthIssuer + "/.well-known/openid-configuration"
DefaultAuthorizeURL = OAuthIssuer + "/oauth2/authorize"
DefaultTokenURL = OAuthIssuer + "/oauth2/token"
DefaultBaseURL = "https://api.x.ai/v1"
DefaultCLIBaseURL = "https://cli-chat-proxy.grok.com/v1"
DefaultUSEast1BaseURL = "https://us-east-1.api.x.ai/v1"
DefaultUSWest2BaseURL = "https://us-west-2.api.x.ai/v1"
DefaultEUWest1BaseURL = "https://eu-west-1.api.x.ai/v1"
DefaultClientID = "b1a00492-073a-47ea-816f-4c329264a828"
DefaultScope = "openid profile email offline_access grok-cli:access api:access"
DefaultRedirectURI = "http://127.0.0.1:56121/callback"
SessionTTL = 30 * time.Minute
EnvAuthorizeURL = "XAI_OAUTH_AUTHORIZE_URL"
EnvTokenURL = "XAI_OAUTH_TOKEN_URL"
@@ -56,32 +63,110 @@ type OAuthSession struct {
ProxyURL string `json:"proxy_url,omitempty"`
RedirectURI string `json:"redirect_uri"`
CreatedAt time.Time `json:"created_at"`
mu sync.Mutex
consumed bool
}
// SessionStore manages xAI OAuth sessions in memory.
func (s *OAuthSession) TryConsume() bool {
if s == nil {
return false
}
s.mu.Lock()
defer s.mu.Unlock()
if s.consumed {
return false
}
s.consumed = true
return true
}
// SessionStore manages xAI OAuth sessions with an optional Redis backend.
type SessionStore struct {
mu sync.RWMutex
sessions map[string]*OAuthSession
stopOnce sync.Once
stopCh chan struct{}
mu sync.RWMutex
sessions map[string]*OAuthSession
localOnly map[string]struct{}
stopOnce sync.Once
stopCh chan struct{}
remote *redissession.Store
}
type oauthSessionDTO struct {
State string `json:"state"`
CodeVerifier string `json:"code_verifier"`
CodeChallenge string `json:"code_challenge"`
ClientID string `json:"client_id,omitempty"`
Scope string `json:"scope,omitempty"`
ProxyURL string `json:"proxy_url,omitempty"`
RedirectURI string `json:"redirect_uri"`
CreatedAt time.Time `json:"created_at"`
}
func NewSessionStore() *SessionStore {
store := &SessionStore{
sessions: make(map[string]*OAuthSession),
stopCh: make(chan struct{}),
sessions: make(map[string]*OAuthSession),
localOnly: make(map[string]struct{}),
stopCh: make(chan struct{}),
}
go store.cleanup()
return store
}
func NewRedisSessionStore(rdb *redis.Client) *SessionStore {
store := NewSessionStore()
if rdb != nil {
store.remote = redissession.New(rdb, "oauth:session:xai", SessionTTL)
}
return store
}
func (s *SessionStore) Set(sessionID string, session *OAuthSession) {
if session == nil {
return
}
var remoteErr error
if s != nil && s.remote != nil {
remoteErr = s.remote.Set(context.Background(), sessionID, oauthSessionDTO{
State: session.State, CodeVerifier: session.CodeVerifier, CodeChallenge: session.CodeChallenge,
ClientID: session.ClientID, Scope: session.Scope, ProxyURL: session.ProxyURL,
RedirectURI: session.RedirectURI, CreatedAt: session.CreatedAt,
})
}
s.mu.Lock()
defer s.mu.Unlock()
s.sessions[sessionID] = session
if remoteErr != nil {
s.localOnly[sessionID] = struct{}{}
slog.Warn("xai oauth session Redis write failed; using process-local fallback", "error", remoteErr)
} else {
delete(s.localOnly, sessionID)
}
}
func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
if s.isLocalOnly(sessionID) {
return s.getMemory(sessionID)
}
if s != nil && s.remote != nil {
var dto oauthSessionDTO
ok, err := s.remote.Get(context.Background(), sessionID, &dto)
if err != nil || !ok || time.Since(dto.CreatedAt) > SessionTTL {
return nil, false
}
session := &OAuthSession{
State: dto.State, CodeVerifier: dto.CodeVerifier, CodeChallenge: dto.CodeChallenge,
ClientID: dto.ClientID, Scope: dto.Scope, ProxyURL: dto.ProxyURL,
RedirectURI: dto.RedirectURI, CreatedAt: dto.CreatedAt,
}
s.mu.Lock()
s.sessions[sessionID] = session
s.mu.Unlock()
return session, true
}
return s.getMemory(sessionID)
}
func (s *SessionStore) getMemory(sessionID string) (*OAuthSession, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
session, ok := s.sessions[sessionID]
@@ -95,9 +180,39 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
}
func (s *SessionStore) Delete(sessionID string) {
if s != nil && s.remote != nil {
_ = s.remote.Delete(context.Background(), sessionID)
}
s.mu.Lock()
defer s.mu.Unlock()
delete(s.sessions, sessionID)
delete(s.localOnly, sessionID)
}
func (s *SessionStore) TryConsumeSession(sessionID string) bool {
if s == nil {
return false
}
if s.isLocalOnly(sessionID) {
return s.tryConsumeMemory(sessionID)
}
if s.remote != nil {
ok, err := s.remote.TryConsume(context.Background(), sessionID)
return err == nil && ok
}
return s.tryConsumeMemory(sessionID)
}
func (s *SessionStore) isLocalOnly(sessionID string) bool {
s.mu.RLock()
defer s.mu.RUnlock()
_, ok := s.localOnly[sessionID]
return ok
}
func (s *SessionStore) tryConsumeMemory(sessionID string) bool {
session, ok := s.getMemory(sessionID)
return ok && session.TryConsume()
}
func (s *SessionStore) Stop() {
@@ -118,6 +233,7 @@ func (s *SessionStore) cleanup() {
for id, session := range s.sessions {
if time.Since(session.CreatedAt) > SessionTTL {
delete(s.sessions, id)
delete(s.localOnly, id)
}
}
s.mu.Unlock()
@@ -0,0 +1,35 @@
//go:build unit
package xai
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
)
func TestSessionStoreRedisFallbackIsLimitedToFailedWrites(t *testing.T) {
mr := miniredis.RunT(t)
client := redis.NewClient(&redis.Options{Addr: mr.Addr(), MaxRetries: -1})
t.Cleanup(func() { _ = client.Close() })
store := NewRedisSessionStore(client)
defer store.Stop()
session := func(state string) *OAuthSession { return &OAuthSession{State: state, CreatedAt: time.Now()} }
store.Set("remote", session("remote"))
require.NoError(t, store.remote.Delete(context.Background(), "remote"))
_, ok := store.Get("remote")
require.False(t, ok, "a remote miss must not revive the stale local copy")
mr.Close()
store.Set("local-only", session("local"))
got, ok := store.Get("local-only")
require.True(t, ok)
require.Equal(t, "local", got.State)
require.True(t, store.TryConsumeSession("local-only"))
require.False(t, store.TryConsumeSession("local-only"))
}
+20 -8
View File
@@ -255,6 +255,14 @@ func TestBuildResponsesURLWithValidatorUsesCallerPolicy(t *testing.T) {
require.Equal(t, "http://grok.example.test/v1/responses", target)
}
func TestValidateTrustedBaseURLAcceptsOfficialRegionalHosts(t *testing.T) {
for _, raw := range []string{DefaultUSEast1BaseURL, DefaultUSWest2BaseURL, DefaultEUWest1BaseURL} {
got, err := ValidateTrustedBaseURL(raw)
require.NoError(t, err, raw)
require.Equal(t, raw, got)
}
}
func TestBuildResponsesURLPreservesUnsafeOverrideCustomPath(t *testing.T) {
t.Setenv(EnvAllowUnsafeURLOverrides, "true")
@@ -338,8 +346,9 @@ func TestRuntimeSanityReportsInvalidOverridesWithoutSecrets(t *testing.T) {
}
func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) {
t.Parallel()
original := RuntimeModelMappingOptions()
t.Cleanup(func() { SetRuntimeModelMappingOptions(original) })
SetRuntimeModelMappingOptions(ModelMappingOptions{})
mapping := DefaultModelMapping()
require.Equal(t, "grok-4.5", mapping["grok"])
require.Equal(t, "grok-4.5", mapping["grok-latest"])
@@ -352,10 +361,13 @@ func TestDefaultModelMappingIncludesGrokAliases(t *testing.T) {
require.Equal(t, "grok-4.20-0309-reasoning", mapping["grok-4.20-reasoning"])
require.Equal(t, "grok-4.20-0309-non-reasoning", mapping["grok-4.20-non-reasoning"])
require.Equal(t, "grok-4.20-multi-agent-0309", mapping["grok-4.20-multi-agent-0309"])
require.Equal(t, "grok-imagine", mapping["grok-imagine"])
require.Equal(t, "grok-imagine-image", mapping["grok-imagine-image"])
require.Equal(t, "grok-imagine-image-quality", mapping["grok-imagine-image-quality"])
require.Equal(t, "grok-imagine-edit", mapping["grok-imagine-edit"])
require.Equal(t, "grok-imagine-video", mapping["grok-imagine-video"])
require.Equal(t, "grok-imagine-video-1.5", mapping["grok-imagine-video-1.5"])
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine"])
require.Equal(t, DefaultImagineImageFastModel, mapping["grok-imagine-image"])
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-image-quality"])
require.Equal(t, DefaultImagineImageQualityModel, mapping["grok-imagine-edit"])
require.Equal(t, DefaultImagineVideoModel, mapping["grok-imagine-video"])
require.Equal(t, DefaultImagineVideo15LegacyModel, mapping["grok-imagine-video-1.5"])
require.Equal(t, DefaultImagineVideo15Model, mapping["grok-imagine-video-1.5-preview"])
_, hasGPT := mapping["gpt-*"]
require.False(t, hasGPT, "cross-client wildcards must be opt-in")
}
+71 -7
View File
@@ -7,10 +7,14 @@ import (
"time"
)
const GrokFreeRolling24hTokenLimit int64 = 1_000_000
// GrokFreeRolling24hTokenLimit is the operator soft-gate nominal Free allowance
// (rolling 24h). Soft-gate default matches this; upstream header limits may
// still report historical 1M/2M Free snapshots.
const GrokFreeRolling24hTokenLimit int64 = 500_000
var grokFreeRolling24hTokenLimits = map[int64]struct{}{
GrokFreeRolling24hTokenLimit: {},
1_000_000: {}, // Observed Free limit variants.
2_000_000: {}, // Legacy Free limit observed before July 2026.
}
@@ -61,11 +65,27 @@ var quotaHeaderAllowlist = []string{
"x-ratelimit-limit-tokens",
"x-ratelimit-remaining-tokens",
"x-ratelimit-reset-tokens",
"x-rate-limit-limit-requests",
"x-rate-limit-remaining-requests",
"x-rate-limit-reset-requests",
"x-rate-limit-limit-tokens",
"x-rate-limit-remaining-tokens",
"x-rate-limit-reset-tokens",
"retry-after",
"x-subscription-tier",
"xai-subscription-tier",
"x-xai-subscription-tier",
"x-xai-user-tier",
"xai-user-tier",
"xai-tier",
"x-user-tier",
"x-plan-tier",
"x-subscription-plan",
"x-entitlement-status",
"xai-entitlement-status",
"x-xai-entitlement-status",
"x-xai-user-entitlement-status",
"x-user-entitlement-status",
}
func ParseQuotaHeaders(headers http.Header, statusCode int) *QuotaSnapshot {
@@ -95,8 +115,24 @@ func parseQuotaHeaders(headers http.Header, statusCode int, source string, keepE
if retryAfter := parseRetryAfter(headers.Get("retry-after")); retryAfter != nil {
snapshot.RetryAfterSeconds = retryAfter
}
snapshot.SubscriptionTier = firstHeader(headers, "xai-subscription-tier", "x-subscription-tier")
snapshot.EntitlementStatus = firstHeader(headers, "xai-entitlement-status", "x-entitlement-status")
snapshot.SubscriptionTier = firstHeader(headers,
"xai-subscription-tier",
"x-subscription-tier",
"x-xai-subscription-tier",
"x-xai-user-tier",
"xai-user-tier",
"xai-tier",
"x-user-tier",
"x-plan-tier",
"x-subscription-plan",
)
snapshot.EntitlementStatus = firstHeader(headers,
"xai-entitlement-status",
"x-entitlement-status",
"x-xai-entitlement-status",
"x-xai-user-entitlement-status",
"x-user-entitlement-status",
)
for _, name := range quotaHeaderAllowlist {
if value := strings.TrimSpace(headers.Get(name)); value != "" {
@@ -121,11 +157,23 @@ func parseQuotaHeaders(headers http.Header, statusCode int, source string, keepE
}
func parseQuotaWindow(headers http.Header, dimension string) *QuotaWindow {
limitHeader := firstHeader(headers,
"x-ratelimit-limit-"+dimension,
"x-rate-limit-limit-"+dimension,
)
remainingHeader := firstHeader(headers,
"x-ratelimit-remaining-"+dimension,
"x-rate-limit-remaining-"+dimension,
)
resetHeader := firstHeader(headers,
"x-ratelimit-reset-"+dimension,
"x-rate-limit-reset-"+dimension,
)
window := &QuotaWindow{
Limit: parseInt64Ptr(headers.Get("x-ratelimit-limit-" + dimension)),
Remaining: parseInt64Ptr(headers.Get("x-ratelimit-remaining-" + dimension)),
Limit: parseInt64Ptr(limitHeader),
Remaining: parseInt64Ptr(remainingHeader),
}
if reset := parseResetHeader(headers.Get("x-ratelimit-reset-" + dimension)); reset != nil {
if reset := parseResetHeader(resetHeader); reset != nil {
window.ResetUnix = reset
window.ResetAt = time.Unix(*reset, 0).UTC().Format(time.RFC3339)
}
@@ -141,11 +189,27 @@ func parseResetHeader(raw string) *int64 {
return nil
}
if value, err := strconv.ParseInt(raw, 10, 64); err == nil {
if value > 1_000_000_000_000 {
// xAI (and OpenAI-compatible upstreams) may express the reset as a
// millisecond epoch, a second epoch, or a *relative* number of seconds
// until reset (e.g. "60"). Disambiguate by magnitude, mirroring the
// Kiro reset parser, so a relative "60" is not misread as 1970-01-01.
switch {
case value >= 1_000_000_000_000: // milliseconds epoch → seconds
value = value / 1000
case value >= 1_000_000_000: // already a plausible unix-seconds epoch (>= 2001-09)
// keep as-is
default: // relative seconds from now
value = time.Now().Unix() + value
}
return &value
}
if duration, err := time.ParseDuration(raw); err == nil && duration > 0 {
if duration < time.Second {
duration = time.Second
}
value := time.Now().Add(duration).Unix()
return &value
}
if t, err := time.Parse(time.RFC3339, raw); err == nil {
value := t.Unix()
return &value
+101
View File
@@ -5,6 +5,7 @@ package xai
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
)
@@ -41,6 +42,104 @@ func TestParseQuotaHeaders(t *testing.T) {
require.NotContains(t, snapshot.Headers, "authorization")
}
func TestParseQuotaHeadersAcceptsXAITierAliases(t *testing.T) {
t.Parallel()
headers := http.Header{}
headers.Set("x-xai-user-tier", "supergrok-heavy")
headers.Set("x-xai-user-entitlement-status", "enabled")
snapshot := ParseQuotaHeaders(headers, http.StatusOK)
require.NotNil(t, snapshot)
require.True(t, snapshot.HeadersObserved)
require.Equal(t, "supergrok-heavy", snapshot.SubscriptionTier)
require.Equal(t, "enabled", snapshot.EntitlementStatus)
require.Equal(t, "supergrok-heavy", snapshot.Headers["x-xai-user-tier"])
require.Equal(t, "enabled", snapshot.Headers["x-xai-user-entitlement-status"])
}
func TestParseQuotaHeadersAcceptsRateLimitAliases(t *testing.T) {
t.Parallel()
headers := http.Header{}
headers.Set("x-rate-limit-limit-tokens", "500000")
headers.Set("x-rate-limit-remaining-tokens", "100")
headers.Set("x-rate-limit-reset-tokens", "1893456000")
snapshot := ParseQuotaHeaders(headers, http.StatusOK)
require.NotNil(t, snapshot)
require.NotNil(t, snapshot.Tokens)
require.Equal(t, int64(500000), *snapshot.Tokens.Limit)
require.Equal(t, int64(100), *snapshot.Tokens.Remaining)
require.Equal(t, int64(1893456000), *snapshot.Tokens.ResetUnix)
require.Contains(t, snapshot.Headers, "x-rate-limit-limit-tokens")
}
func TestParseResetHeaderRelativeSecondsNotMisreadAsEpoch(t *testing.T) {
t.Parallel()
headers := http.Header{}
// xAI may return the reset window as a relative number of seconds ("60").
// It must resolve to ~now+60s, NOT 1970-01-01 (epoch 60).
headers.Set("x-ratelimit-reset-requests", "60")
headers.Set("x-ratelimit-remaining-requests", "0")
before := time.Now().Unix()
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
require.NotNil(t, snapshot)
require.NotNil(t, snapshot.Requests)
require.NotNil(t, snapshot.Requests.ResetUnix)
got := *snapshot.Requests.ResetUnix
require.GreaterOrEqual(t, got, before+59)
require.LessOrEqual(t, got, time.Now().Unix()+61)
}
func TestParseResetHeaderDurationWindow(t *testing.T) {
t.Parallel()
headers := http.Header{}
headers.Set("x-ratelimit-reset-requests", "6m0s")
headers.Set("x-ratelimit-remaining-requests", "0")
before := time.Now().Unix()
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
require.NotNil(t, snapshot)
require.NotNil(t, snapshot.Requests)
require.NotNil(t, snapshot.Requests.ResetUnix)
got := *snapshot.Requests.ResetUnix
require.GreaterOrEqual(t, got, before+359)
require.LessOrEqual(t, got, time.Now().Unix()+361)
}
func TestParseResetHeaderSubsecondDurationCeilsToFutureSecond(t *testing.T) {
t.Parallel()
headers := http.Header{}
headers.Set("x-rate-limit-reset-tokens", "250ms")
headers.Set("x-rate-limit-remaining-tokens", "0")
before := time.Now().Unix()
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
require.NotNil(t, snapshot)
require.NotNil(t, snapshot.Tokens)
require.NotNil(t, snapshot.Tokens.ResetUnix)
require.GreaterOrEqual(t, *snapshot.Tokens.ResetUnix, before)
require.LessOrEqual(t, *snapshot.Tokens.ResetUnix, time.Now().Unix()+2)
}
func TestParseResetHeaderMillisecondsEpochNormalized(t *testing.T) {
t.Parallel()
headers := http.Header{}
headers.Set("x-ratelimit-reset-tokens", "1893456000000") // ms epoch
headers.Set("x-ratelimit-remaining-tokens", "0")
snapshot := ParseQuotaHeaders(headers, http.StatusTooManyRequests)
require.NotNil(t, snapshot)
require.NotNil(t, snapshot.Tokens)
require.Equal(t, int64(1893456000), *snapshot.Tokens.ResetUnix)
}
func TestParseQuotaHeadersReturnsNilForMissingHeaders(t *testing.T) {
t.Parallel()
@@ -66,6 +165,8 @@ func TestIsGrokFreeRolling24hTokenLimit(t *testing.T) {
t.Parallel()
require.True(t, IsGrokFreeRolling24hTokenLimit(GrokFreeRolling24hTokenLimit))
require.True(t, IsGrokFreeRolling24hTokenLimit(500_000))
require.True(t, IsGrokFreeRolling24hTokenLimit(1_000_000), "observed Free limit variants remain classifiable")
require.True(t, IsGrokFreeRolling24hTokenLimit(2_000_000), "legacy snapshots remain classifiable")
require.False(t, IsGrokFreeRolling24hTokenLimit(3_000_000))
}
+49 -19
View File
@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"sort"
"strconv"
@@ -25,6 +26,7 @@ const (
SSOConversionTimeout = 90 * time.Second
ssoMaxAuthBody = 2 << 20
ssoMaxTokenLength = 16 << 10
ssoDefaultUA = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
ssoDefaultTokenTTL = 6 * time.Hour
)
@@ -51,7 +53,7 @@ type SSODeviceOptions struct {
type ssoDeviceFlow struct {
client SSODeviceHTTPClient
userAgent string
cookies map[string]string
cookieJar http.CookieJar
sleep func(context.Context, time.Duration) error
}
@@ -80,11 +82,16 @@ func ConvertSSOToBuild(ctx context.Context, ssoToken string, opts *SSODeviceOpti
if sleep == nil {
sleep = sleepContext
}
jar, err := cookiejar.New(nil)
if err != nil {
return nil, err
}
seedSSOCookies(jar, ssoToken)
flow := &ssoDeviceFlow{
client: client,
userAgent: userAgent,
cookies: map[string]string{"sso": ssoToken, "sso-rw": ssoToken},
cookieJar: jar,
sleep: sleep,
}
return flow.convert(ctx)
@@ -253,7 +260,7 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur
request.Header.Set("Accept", "application/json, text/html;q=0.9, */*;q=0.8")
request.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
request.Header.Set("User-Agent", f.userAgent)
if cookie := f.cookieHeader(); cookie != "" {
if cookie := f.cookieHeader(request.URL); cookie != "" {
request.Header.Set("Cookie", cookie)
}
if currentForm != nil {
@@ -264,7 +271,7 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur
if err != nil {
return 0, currentURL, nil, err
}
f.captureCookies(response)
f.captureCookies(request.URL, response)
data, readErr := io.ReadAll(io.LimitReader(response.Body, ssoMaxAuthBody+1))
_ = response.Body.Close()
if readErr != nil {
@@ -298,30 +305,49 @@ func (f *ssoDeviceFlow) do(ctx context.Context, method, endpoint string, form ur
return 0, currentURL, nil, errors.New("xAI OAuth redirected too many times")
}
func (f *ssoDeviceFlow) captureCookies(response *http.Response) {
func seedSSOCookies(jar http.CookieJar, token string) {
if jar == nil {
return
}
for _, rawURL := range []string{SSOAccountsURL, OAuthIssuer + "/"} {
target, err := url.Parse(rawURL)
if err != nil {
continue
}
jar.SetCookies(target, []*http.Cookie{
{Name: "sso", Value: token, Path: "/", Secure: true, HttpOnly: true},
{Name: "sso-rw", Value: token, Path: "/", Secure: true, HttpOnly: true},
})
}
}
func (f *ssoDeviceFlow) captureCookies(requestURL *url.URL, response *http.Response) {
if f == nil || f.cookieJar == nil || requestURL == nil || response == nil {
return
}
cookies := make([]*http.Cookie, 0)
for _, cookie := range response.Cookies() {
name := strings.TrimSpace(cookie.Name)
value := strings.TrimSpace(cookie.Value)
if name == "" || len(name) > 128 || len(value) > 16384 || strings.ContainsAny(name+value, "\r\n\x00") {
continue
}
if cookie.MaxAge < 0 {
delete(f.cookies, name)
continue
}
f.cookies[name] = value
cookie.Name = name
cookie.Value = value
cookies = append(cookies, cookie)
}
f.cookieJar.SetCookies(requestURL, cookies)
}
func (f *ssoDeviceFlow) cookieHeader() string {
keys := make([]string, 0, len(f.cookies))
for key := range f.cookies {
keys = append(keys, key)
func (f *ssoDeviceFlow) cookieHeader(requestURL *url.URL) string {
if f == nil || f.cookieJar == nil || requestURL == nil {
return ""
}
sort.Strings(keys)
parts := make([]string, 0, len(keys))
for _, key := range keys {
parts = append(parts, key+"="+f.cookies[key])
cookies := f.cookieJar.Cookies(requestURL)
sort.Slice(cookies, func(i, j int) bool { return cookies[i].Name < cookies[j].Name })
parts := make([]string, 0, len(cookies))
for _, cookie := range cookies {
parts = append(parts, cookie.Name+"="+cookie.Value)
}
return strings.Join(parts, "; ")
}
@@ -363,7 +389,11 @@ func NormalizeSSOToken(value string) string {
}
func sanitizeSSOToken(value string) string {
return strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value))
value = strings.NewReplacer("\r", "", "\n", "", "\x00", "").Replace(strings.TrimSpace(value))
if len(value) > ssoMaxTokenLength {
return ""
}
return value
}
func DecodeJWTClaims(token string) map[string]any {
+29 -1
View File
@@ -6,6 +6,7 @@ import (
"context"
"io"
"net/http"
"net/http/cookiejar"
"net/url"
"strings"
"testing"
@@ -25,7 +26,7 @@ func (c *ssoDeviceFakeClient) Do(req *http.Request) (*http.Response, error) {
switch req.URL.String() {
case SSOAccountsURL:
require.Equal(c.t, http.MethodGet, req.Method)
return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"session=web-session; Path=/"}}, `{}`), nil
return ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {"session=web-session; Domain=x.ai; Path=/"}}, `{}`), nil
case SSODeviceURL:
require.Equal(c.t, http.MethodPost, req.Method)
values := readSSODeviceForm(c.t, req)
@@ -92,6 +93,33 @@ func TestNormalizeSSOTokenAcceptsCookieHeader(t *testing.T) {
require.Equal(t, "token-1", NormalizeSSOToken("Cookie: foo=bar; sso=token-1; sso-rw=token-2"))
require.Equal(t, "token-2", NormalizeSSOToken("sso-rw=token-2; foo=bar"))
require.Equal(t, "raw-token", NormalizeSSOToken(" raw-token ; ignored=1"))
require.Empty(t, NormalizeSSOToken(strings.Repeat("x", ssoMaxTokenLength+1)))
}
func TestSSODeviceCookieJarHonorsDomainAndPath(t *testing.T) {
jar, err := cookiejar.New(nil)
require.NoError(t, err)
flow := &ssoDeviceFlow{cookieJar: jar}
accountsURL, err := url.Parse("https://accounts.x.ai/")
require.NoError(t, err)
authURL, err := url.Parse("https://auth.x.ai/oauth2/device/verify")
require.NoError(t, err)
flow.captureCookies(accountsURL, ssoDeviceResponse(http.StatusOK, http.Header{"Set-Cookie": {
"host-only=accounts; Path=/",
"shared=all-xai; Domain=x.ai; Path=/",
"narrow=oauth-only; Domain=x.ai; Path=/oauth2",
}}, ""))
authCookies := flow.cookieHeader(authURL)
require.NotContains(t, authCookies, "host-only=accounts")
require.Contains(t, authCookies, "shared=all-xai")
require.Contains(t, authCookies, "narrow=oauth-only")
accountsCookies := flow.cookieHeader(accountsURL)
require.Contains(t, accountsCookies, "host-only=accounts")
require.Contains(t, accountsCookies, "shared=all-xai")
require.NotContains(t, accountsCookies, "narrow=oauth-only")
}
func ssoDeviceResponse(status int, header http.Header, body string) *http.Response {
@@ -190,7 +190,12 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group.FieldVideoPrice480p,
group.FieldVideoPrice720p,
group.FieldVideoPrice1080p,
group.FieldVideoModelPrices,
group.FieldWebSearchPricePerCall,
group.FieldSearchPricePer1k,
group.FieldAudioRealtimePricePerMin,
group.FieldAudioTtsPricePerMillionChars,
group.FieldAudioSttPricePerHour,
group.FieldClaudeCodeOnly,
group.FieldFallbackGroupID,
group.FieldFallbackGroupIDOnInvalidRequest,
@@ -969,7 +974,12 @@ func groupEntityToService(g *dbent.Group) *service.Group {
VideoPrice480P: g.VideoPrice480p,
VideoPrice720P: g.VideoPrice720p,
VideoPrice1080P: g.VideoPrice1080p,
VideoModelPrices: service.NormalizeVideoModelPrices(g.VideoModelPrices),
WebSearchPricePerCall: g.WebSearchPricePerCall,
SearchPricePer1k: g.SearchPricePer1k,
AudioRealtimePricePerMin: g.AudioRealtimePricePerMin,
AudioTTSPricePerMillionChars: g.AudioTtsPricePerMillionChars,
AudioSTTPricePerHour: g.AudioSttPricePerHour,
DefaultValidityDays: g.DefaultValidityDays,
ClaudeCodeOnly: g.ClaudeCodeOnly,
FallbackGroupID: g.FallbackGroupID,
@@ -19,6 +19,9 @@ func TestGroupEntityToService_PreservesMessagesDispatchModelConfig(t *testing.T)
RateMultiplier: 1,
AllowMessagesDispatch: true,
DefaultMappedModel: "gpt-5.4",
VideoModelPrices: map[string]map[string]float64{
service.VideoPriceFamilyGrokImagineVideo15: {service.VideoBillingResolution720P: 0.14},
},
MessagesDispatchModelConfig: service.OpenAIMessagesDispatchModelConfig{
OpusMappedModel: "gpt-5.4-nano",
SonnetMappedModel: "gpt-5.3-codex",
@@ -32,6 +35,7 @@ func TestGroupEntityToService_PreservesMessagesDispatchModelConfig(t *testing.T)
got := groupEntityToService(group)
require.NotNil(t, got)
require.Equal(t, group.MessagesDispatchModelConfig, got.MessagesDispatchModelConfig)
require.Equal(t, group.VideoModelPrices, got.VideoModelPrices)
}
func TestAPIKeyRepository_GetByKeyForAuth_PreservesMessagesDispatchModelConfig_SQLite(t *testing.T) {
@@ -5,6 +5,7 @@ import (
"context"
"fmt"
"io"
"os"
"path"
"time"
@@ -59,6 +60,34 @@ func (s *S3BackupStore) Upload(ctx context.Context, key string, body io.Reader,
return int64(len(data)), nil
}
func (s *S3BackupStore) UploadFile(ctx context.Context, key string, filePath string, contentType string) (int64, error) {
file, err := os.Open(filePath)
if err != nil {
return 0, fmt.Errorf("open upload file: %w", err)
}
defer func() { _ = file.Close() }()
info, err := file.Stat()
if err != nil {
return 0, fmt.Errorf("stat upload file: %w", err)
}
sizeBytes := info.Size()
finish := servertiming.ObserveDependency(ctx, "s3")
_, err = s.client.PutObject(ctx, &s3.PutObjectInput{
Bucket: &s.bucket,
Key: &key,
Body: file,
ContentLength: &sizeBytes,
ContentType: &contentType,
})
finish()
if err != nil {
return 0, fmt.Errorf("S3 PutObject file: %w", err)
}
return sizeBytes, nil
}
func (s *S3BackupStore) Download(ctx context.Context, key string) (io.ReadCloser, error) {
finish := servertiming.ObserveDependency(ctx, "s3")
result, err := s.client.GetObject(ctx, &s3.GetObjectInput{
@@ -0,0 +1,48 @@
//go:build unit
package repository
import (
"context"
"io"
"net/http"
"net/http/httptest"
"os"
"testing"
"github.com/stretchr/testify/require"
)
func TestS3BackupStore_UploadFile(t *testing.T) {
var received []byte
var receivedLength int64
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
require.Equal(t, http.MethodPut, r.Method)
receivedLength = r.ContentLength
var err error
received, err = io.ReadAll(r.Body)
require.NoError(t, err)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
client, err := newS3Client(context.Background(), s3ClientParams{
Endpoint: server.URL,
Region: "auto",
AccessKeyID: "test-ak",
SecretAccessKey: "test-sk",
ForcePathStyle: true,
})
require.NoError(t, err)
content := []byte("streamed backup payload")
filePath := t.TempDir() + "/part.gz"
require.NoError(t, os.WriteFile(filePath, content, 0o600))
store := &S3BackupStore{client: client, bucket: "backup-bucket"}
size, err := store.UploadFile(context.Background(), "backup/part-1", filePath, "application/octet-stream")
require.NoError(t, err)
require.Equal(t, int64(len(content)), size)
require.Equal(t, int64(len(content)), receivedLength)
require.Equal(t, content, received)
}
@@ -0,0 +1,454 @@
package repository
import (
"context"
"database/sql"
"fmt"
"time"
)
// Platform is derived from group/account (usage_logs has no provider column on upstream schema).
const channelMonitorV2PlatformSQL = `lower(` + usageLogEffectivePlatformExpr + `)`
const channelMonitorV2ModelSQL = `COALESCE(NULLIF(TRIM(ul.requested_model), ''), NULLIF(TRIM(ul.model), ''), 'unknown')`
// Tiered retention balances UI windows against storage:
//
// 1m facts → short (late writes + rebuild rollups)
// 5m/1h/12h/1d rollups → longer, aligned to 90m / 24h / 7d / 30d(+audit)
//
// Backfill may still write short-lived 1m rows for old windows so rollups can be
// built; prune at end of each recompute drops them past their TTL while rollups remain.
const (
channelMonitorV2RetentionUser1m = 3 * 24 * time.Hour
channelMonitorV2RetentionMetrics1m = 7 * 24 * time.Hour
channelMonitorV2RetentionError1m = 7 * 24 * time.Hour
channelMonitorV2RetentionHistogram1m = 7 * 24 * time.Hour
channelMonitorV2RetentionRollup5m = 7 * 24 * time.Hour // bucket_seconds=300
channelMonitorV2RetentionRollup1h = 30 * 24 * time.Hour // 3600
channelMonitorV2RetentionRollup12h = 45 * 24 * time.Hour // 43200
channelMonitorV2RetentionRollup1d = 90 * 24 * time.Hour // 86400
channelMonitorV2RetentionMax = channelMonitorV2RetentionRollup1d
)
// channelMonitorV2MaxRetention is the longest stored window (1d rollup). Used to
// clamp recompute/backfill so we never scan older than product history needs.
func channelMonitorV2MaxRetention() time.Duration {
return channelMonitorV2RetentionMax
}
func channelMonitorV2RetentionCutoff(now time.Time, retention time.Duration) time.Time {
return now.UTC().Truncate(time.Minute).Add(-retention)
}
type channelMonitorV2RetentionRule struct {
table string
retention time.Duration
bucketSeconds int // 0 = fact table (no bucket_seconds column)
}
// channelMonitorV2RetentionRules is ordered coarse→fine for predictable prune plans.
var channelMonitorV2RetentionRules = []channelMonitorV2RetentionRule{
{table: "channel_monitor_v2_user_metrics_1m", retention: channelMonitorV2RetentionUser1m},
{table: "channel_monitor_v2_metrics_1m", retention: channelMonitorV2RetentionMetrics1m},
{table: "channel_monitor_v2_error_metrics_1m", retention: channelMonitorV2RetentionError1m},
{table: "channel_monitor_v2_latency_histograms_1m", retention: channelMonitorV2RetentionHistogram1m},
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup5m, bucketSeconds: 300},
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup1h, bucketSeconds: 3600},
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup12h, bucketSeconds: 43200},
{table: "channel_monitor_v2_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
{table: "channel_monitor_v2_user_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
{table: "channel_monitor_v2_error_metrics_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
{table: "channel_monitor_v2_latency_histograms_rollup", retention: channelMonitorV2RetentionRollup1d, bucketSeconds: 86400},
}
func (r *channelMonitorV2Repository) pruneChannelMonitorV2Retention(ctx context.Context, tx *sql.Tx, now time.Time) error {
// During historical bootstrap, retain all 1m facts until the cursor reaches
// the oldest rollup boundary. Otherwise adjacent chunks would rebuild the
// same daily bucket from source rows already pruned by the prior chunk.
var backfillCursor time.Time
if err := tx.QueryRowContext(ctx, `SELECT backfill_cursor FROM channel_monitor_v2_watermarks WHERE id = 1`).Scan(&backfillCursor); err == nil && backfillCursor.After(channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMax)) {
return nil
}
for _, rule := range channelMonitorV2RetentionRules {
cutoff := channelMonitorV2RetentionCutoff(now, rule.retention)
var err error
if rule.bucketSeconds == 0 {
_, err = tx.ExecContext(ctx, fmt.Sprintf(`DELETE FROM %s WHERE bucket_start < $1`, rule.table), cutoff)
} else {
_, err = tx.ExecContext(ctx,
fmt.Sprintf(`DELETE FROM %s WHERE bucket_seconds = $1 AND bucket_start < $2`, rule.table),
rule.bucketSeconds, cutoff,
)
}
if err != nil {
return fmt.Errorf("prune %s (bucket_seconds=%d): %w", rule.table, rule.bucketSeconds, err)
}
}
return nil
}
func (r *channelMonitorV2Repository) RecomputeRange(ctx context.Context, start, end time.Time) (err error) {
start = start.UTC().Truncate(time.Minute)
end = end.UTC().Truncate(time.Minute)
now := time.Now().UTC().Truncate(time.Minute)
// Clamp to longest rollup TTL so backfill does not scan beyond product history.
maxCutoff := channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention())
if start.Before(maxCutoff) {
start = maxCutoff
}
if !start.Before(end) {
return nil
}
tx, err := r.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
if err != nil {
return err
}
defer func() {
if err != nil {
_ = tx.Rollback()
}
}()
// Idempotent window rewrite: drop existing facts/rollups in [start,end) then re-insert.
for _, table := range []string{
"channel_monitor_v2_latency_histograms_rollup",
"channel_monitor_v2_error_metrics_rollup",
"channel_monitor_v2_user_metrics_rollup",
"channel_monitor_v2_metrics_rollup",
"channel_monitor_v2_latency_histograms_1m",
"channel_monitor_v2_error_metrics_1m",
"channel_monitor_v2_user_metrics_1m",
"channel_monitor_v2_metrics_1m",
} {
if _, err = tx.ExecContext(ctx, fmt.Sprintf("DELETE FROM %s WHERE bucket_start >= $1 AND bucket_start < $2", table), start, end); err != nil {
return err
}
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2UsageMetricsSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL), start, end); err != nil {
return fmt.Errorf("aggregate channel monitor v2 usage: %w", err)
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2UserMetricsSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL), start, end); err != nil {
return fmt.Errorf("aggregate channel monitor v2 users: %w", err)
}
if _, err = tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2HistogramSQL, channelMonitorV2PlatformSQL, channelMonitorV2ModelSQL, channelMonitorV2HistogramBoundSQL("latency.value_ms")), start, end); err != nil {
return fmt.Errorf("aggregate channel monitor v2 histograms: %w", err)
}
if _, err = tx.ExecContext(ctx, channelMonitorV2ErrorAggregationSQL, start, end); err != nil {
return fmt.Errorf("aggregate channel monitor v2 errors: %w", err)
}
if err = r.recomputeFixedRollups(ctx, tx, start, end); err != nil {
return err
}
// Drop rows past per-tier TTL (1m short, coarse rollups long). Safe after rollup
// so a backfill chunk can build 1d rollups from temporary 1m rows then discard 1m.
if err = r.pruneChannelMonitorV2Retention(ctx, tx, now); err != nil {
return err
}
if _, err = tx.ExecContext(ctx, channelMonitorV2WatermarkSQL, start, end); err != nil {
return err
}
if err = tx.Commit(); err != nil {
return err
}
return nil
}
const channelMonitorV2UsageMetricsSQL = `
INSERT INTO channel_monitor_v2_metrics_1m (
bucket_start, platform, group_id, model, success_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
)
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s,
COUNT(DISTINCT COALESCE(NULLIF(ul.request_id, ''), 'usage:' || ul.id::text))
FILTER (WHERE COALESCE(ul.request_type, 0) NOT IN (4, 6) AND ` + usageLogSuccessFilterUL + `),
COALESCE(SUM(ul.input_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.output_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.cache_creation_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.cache_read_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0),
COUNT(ul.first_token_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `),
COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0),
COUNT(ul.duration_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), NOW()
FROM usage_logs ul
LEFT JOIN groups g ON g.id = ul.group_id
LEFT JOIN accounts a ON a.id = ul.account_id
WHERE ul.created_at >= $1 AND ul.created_at < $2
GROUP BY 1, 2, 3, 4`
const channelMonitorV2UserMetricsSQL = `
INSERT INTO channel_monitor_v2_user_metrics_1m (
bucket_start, platform, group_id, model, user_id, success_requests,
input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
)
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s, ul.user_id,
COUNT(DISTINCT COALESCE(NULLIF(ul.request_id, ''), 'usage:' || ul.id::text))
FILTER (WHERE COALESCE(ul.request_type, 0) NOT IN (4, 6) AND ` + usageLogSuccessFilterUL + `),
COALESCE(SUM(ul.input_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.output_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.cache_creation_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.cache_read_tokens) FILTER (WHERE ` + usageLogSuccessFilterUL + `), 0),
COALESCE(SUM(ul.first_token_ms) FILTER (WHERE ul.first_token_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0),
COUNT(ul.first_token_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `),
COALESCE(SUM(ul.duration_ms) FILTER (WHERE ul.duration_ms IS NOT NULL AND ` + usageLogSuccessFilterUL + `), 0),
COUNT(ul.duration_ms) FILTER (WHERE ` + usageLogSuccessFilterUL + `), NOW()
FROM usage_logs ul
LEFT JOIN groups g ON g.id = ul.group_id
LEFT JOIN accounts a ON a.id = ul.account_id
WHERE ul.created_at >= $1 AND ul.created_at < $2 AND ul.user_id IS NOT NULL
GROUP BY 1, 2, 3, 4, 5`
const channelMonitorV2HistogramSQL = `
INSERT INTO channel_monitor_v2_latency_histograms_1m (
bucket_start, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count
)
SELECT date_trunc('minute', ul.created_at), %s, COALESCE(ul.group_id, 0), %s,
audience.user_id, latency.metric, %s, COUNT(*)
FROM usage_logs ul
LEFT JOIN groups g ON g.id = ul.group_id
LEFT JOIN accounts a ON a.id = ul.account_id
CROSS JOIN LATERAL (VALUES (0::bigint), (ul.user_id)) audience(user_id)
CROSS JOIN LATERAL (VALUES ('ttft'::text, ul.first_token_ms), ('duration'::text, ul.duration_ms)) latency(metric, value_ms)
WHERE ul.created_at >= $1 AND ul.created_at < $2
AND audience.user_id IS NOT NULL AND latency.value_ms IS NOT NULL AND latency.value_ms >= 0
AND ` + usageLogSuccessFilterUL + `
GROUP BY 1, 2, 3, 4, 5, 6, 7`
func channelMonitorV2HistogramBoundSQL(column string) string {
return `CASE
WHEN ` + column + ` <= 50 THEN 50 WHEN ` + column + ` <= 100 THEN 100
WHEN ` + column + ` <= 250 THEN 250 WHEN ` + column + ` <= 500 THEN 500
WHEN ` + column + ` <= 1000 THEN 1000 WHEN ` + column + ` <= 2000 THEN 2000
WHEN ` + column + ` <= 3000 THEN 3000 WHEN ` + column + ` <= 5000 THEN 5000
WHEN ` + column + ` <= 8000 THEN 8000 WHEN ` + column + ` <= 10000 THEN 10000
WHEN ` + column + ` <= 15000 THEN 15000 WHEN ` + column + ` <= 30000 THEN 30000
WHEN ` + column + ` <= 60000 THEN 60000 WHEN ` + column + ` <= 120000 THEN 120000
WHEN ` + column + ` <= 300000 THEN 300000 WHEN ` + column + ` <= 600000 THEN 600000
ELSE 2147483647 END`
}
// Error dedup lookback: request_id branch is bounded by chunk start minus 90
// minutes so candidate_ids never forces a full-history scan of ops_error_logs.
const channelMonitorV2ErrorAggregationSQL = `
WITH dedup AS (
WITH candidate_ids AS MATERIALIZED (
SELECT DISTINCT request_id
FROM ops_error_logs
WHERE created_at >= $1 AND created_at < $2 AND NULLIF(request_id, '') IS NOT NULL
)
SELECT DISTINCT ON (COALESCE(NULLIF(request_id, ''), 'error:' || id::text))
date_trunc('minute', created_at) AS bucket_start,
lower(COALESCE(NULLIF(TRIM(platform), ''), 'unknown')) AS platform,
COALESCE(group_id, 0) AS group_id,
COALESCE(NULLIF(TRIM(requested_model), ''), NULLIF(TRIM(model), ''), 'unknown') AS model,
user_id, error_type, error_owner, COALESCE(status_code, 0) AS status_code,
COALESCE(upstream_status_code, 0) AS upstream_status_code,
lower(CONCAT_WS(' ', error_type, error_source, error_message, upstream_error_message, upstream_error_detail, error_body)) AS text,
(CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) > 0 ELSE FALSE END
OR error_owner = 'provider' OR upstream_status_code IS NOT NULL) AS upstream_affected,
CASE WHEN jsonb_typeof(upstream_errors) = 'array' THEN jsonb_array_length(upstream_errors) ELSE 0 END AS upstream_attempts
FROM ops_error_logs current_error
WHERE (
(NULLIF(current_error.request_id, '') IS NULL AND current_error.created_at >= $1 AND current_error.created_at < $2)
OR (
current_error.request_id IN (SELECT request_id FROM candidate_ids)
AND current_error.created_at >= $1 - INTERVAL '90 minutes'
AND current_error.created_at < $2
)
)
AND NOT current_error.is_count_tokens
AND (COALESCE(current_error.status_code, 0) >= 400 OR current_error.error_type = 'cyber_policy')
ORDER BY COALESCE(NULLIF(request_id, ''), 'error:' || id::text), created_at DESC, id DESC
), classified AS (
SELECT *, CASE
-- Keep in lockstep with service.ClassifyChannelMonitorV2Error needles.
WHEN error_type = 'cyber_policy' OR text LIKE ANY(ARRAY['%content policy%','%content_policy%','%safety policy%','%moderation%','%blocked keyword%']) THEN 'content_policy'
WHEN status_code = 401 OR upstream_status_code = 401 OR text LIKE ANY(ARRAY['%unauthorized%','%invalid api key%','%invalid_api_key%','%authentication%','%api_key_disabled%']) THEN 'authentication'
WHEN text LIKE ANY(ARRAY['%context window%','%context length%','%maximum prompt length%','%too many tokens%','%max_tokens%']) THEN 'context_limit'
WHEN text LIKE ANY(ARRAY['%failed to deserialize%','%missing required parameter%','%invalid request%','%invalid_request%','%tool_choice%']) THEN 'invalid_request'
WHEN text LIKE ANY(ARRAY['%does not support the requested model%','%not supported by any configured account%','%model not supported%','%unsupported model%']) THEN 'model_unsupported'
WHEN text LIKE ANY(ARRAY['%group not allowed%','%group_not_allowed%','%group access%']) THEN 'group_access'
WHEN text LIKE ANY(ARRAY['%run out of credits%','%insufficient balance%','%insufficient quota%','%subscription%','%quota exceeded%','%billing hard limit%']) THEN 'quota_or_balance'
WHEN text LIKE ANY(ARRAY['%no available accounts%','%no healthy account%','%no healthy upstream account%','%failover budget exhausted%','%account pool%']) THEN 'account_pool_unavailable'
WHEN status_code = 429 OR upstream_status_code = 429 OR text LIKE ANY(ARRAY['%rate limit%','%rate_limit%','%high demand%','%overloaded%','%concurrency limit%','%capacity%']) THEN 'rate_or_capacity'
WHEN status_code IN (408,504) OR text LIKE ANY(ARRAY['%timeout%','%deadline exceeded%','%error code: 524%','%gateway time-out%','%gateway timeout%']) THEN 'timeout'
WHEN text LIKE ANY(ARRAY['%transport%','%stream_read_error%','%connection reset%','%connection refused%','%tls%','%http2%','%missing terminal event%','%unexpected eof%']) THEN 'transport_or_stream'
WHEN status_code = 403 OR upstream_status_code = 403 THEN 'upstream_forbidden'
WHEN status_code = 404 OR upstream_status_code = 404 THEN 'not_found'
WHEN status_code = 499 OR text LIKE ANY(ARRAY['%client cancelled%','%client canceled%','%context canceled%']) THEN 'client_cancelled'
WHEN upstream_status_code >= 500 OR (error_owner = 'provider' AND status_code >= 500) THEN 'upstream_5xx'
WHEN status_code >= 500 OR error_type = 'internal' OR error_owner = 'system' THEN 'internal'
ELSE 'other' END AS category
FROM dedup
WHERE bucket_start >= $1 AND bucket_start < $2
), metric_rows AS (
INSERT INTO channel_monitor_v2_metrics_1m (bucket_start, platform, group_id, model, error_requests, upstream_affected_requests, upstream_attempt_count, computed_at)
SELECT bucket_start, platform, group_id, model, COUNT(*), COUNT(*) FILTER (WHERE upstream_affected), SUM(upstream_attempts), NOW()
FROM classified GROUP BY 1,2,3,4
ON CONFLICT (bucket_start, platform, group_id, model) DO UPDATE SET
error_requests = EXCLUDED.error_requests, upstream_affected_requests = EXCLUDED.upstream_affected_requests,
upstream_attempt_count = EXCLUDED.upstream_attempt_count, computed_at = NOW()
), user_rows AS (
INSERT INTO channel_monitor_v2_user_metrics_1m (bucket_start, platform, group_id, model, user_id, error_requests, computed_at)
SELECT bucket_start, platform, group_id, model, user_id, COUNT(*), NOW()
FROM classified WHERE user_id IS NOT NULL GROUP BY 1,2,3,4,5
ON CONFLICT (bucket_start, platform, group_id, model, user_id) DO UPDATE SET error_requests = EXCLUDED.error_requests, computed_at = NOW()
)
INSERT INTO channel_monitor_v2_error_metrics_1m (bucket_start, platform, group_id, model, error_category, taxonomy_version, error_requests)
SELECT bucket_start, platform, group_id, model, category, 1, COUNT(*) FROM classified GROUP BY 1,2,3,4,5
ON CONFLICT (bucket_start, platform, group_id, model, error_category, taxonomy_version)
DO UPDATE SET error_requests = EXCLUDED.error_requests`
// Floor matches channelMonitorV2RetentionMax (90d). Keep the INTERVAL literal in
// sync when changing channelMonitorV2RetentionRollup1d.
//
// Coverage starts track how far back recompute has walked ($1 = chunk start), not
// "min(source_log.created_at)". Using global min(ops_error_logs) pins
// error_coverage_start to the first real error forever and collapses UI windows
// when errors only exist in a recent slice (common on first upgrade).
const channelMonitorV2WatermarkSQL = `
INSERT INTO channel_monitor_v2_watermarks (id, usage_coverage_start, error_coverage_start, data_through, last_successful_at, backfill_cursor, updated_at)
VALUES (
1,
$1,
$1,
$2, NOW(), $1, NOW()
)
ON CONFLICT (id) DO UPDATE SET
usage_coverage_start = GREATEST(
date_trunc('minute', NOW()) - INTERVAL '90 days',
LEAST(COALESCE(channel_monitor_v2_watermarks.usage_coverage_start, EXCLUDED.usage_coverage_start), EXCLUDED.usage_coverage_start)
),
error_coverage_start = GREATEST(
date_trunc('minute', NOW()) - INTERVAL '90 days',
LEAST(COALESCE(channel_monitor_v2_watermarks.error_coverage_start, EXCLUDED.error_coverage_start), EXCLUDED.error_coverage_start)
),
data_through = GREATEST(COALESCE(channel_monitor_v2_watermarks.data_through, EXCLUDED.data_through), EXCLUDED.data_through),
last_successful_at = NOW(),
backfill_cursor = LEAST(COALESCE(channel_monitor_v2_watermarks.backfill_cursor, EXCLUDED.backfill_cursor), EXCLUDED.backfill_cursor),
updated_at = NOW()`
var channelMonitorV2FixedRollupSeconds = []int{300, 3600, 43200, 86400}
func (r *channelMonitorV2Repository) recomputeFixedRollups(ctx context.Context, tx *sql.Tx, start, end time.Time) error {
for _, seconds := range channelMonitorV2FixedRollupSeconds {
// Coarse buckets are immutable between boundaries during the normal
// trailing refresh. Historical backfills and boundary-crossing windows
// still rebuild them; this avoids repeatedly regrouping the full current
// day/user table every few minutes.
if seconds >= 43200 && sameFixedRollupBucket(start, end, seconds) {
continue
}
interval := fmt.Sprintf("%d seconds", seconds)
for _, table := range []string{
"channel_monitor_v2_latency_histograms_rollup",
"channel_monitor_v2_error_metrics_rollup",
"channel_monitor_v2_user_metrics_rollup",
"channel_monitor_v2_metrics_rollup",
} {
if _, err := tx.ExecContext(ctx, fmt.Sprintf(channelMonitorV2FixedRollupDeleteSQL, table), interval, seconds, start, end); err != nil {
return err
}
}
if _, err := tx.ExecContext(ctx, channelMonitorV2MetricsRollupSQL, interval, seconds, start, end); err != nil {
return fmt.Errorf("roll up channel monitor v2 metrics %ds: %w", seconds, err)
}
if _, err := tx.ExecContext(ctx, channelMonitorV2UserMetricsRollupSQL, interval, seconds, start, end); err != nil {
return fmt.Errorf("roll up channel monitor v2 user metrics %ds: %w", seconds, err)
}
if _, err := tx.ExecContext(ctx, channelMonitorV2HistogramRollupSQL, interval, seconds, start, end); err != nil {
return fmt.Errorf("roll up channel monitor v2 histograms %ds: %w", seconds, err)
}
if _, err := tx.ExecContext(ctx, channelMonitorV2ErrorRollupSQL, interval, seconds, start, end); err != nil {
return fmt.Errorf("roll up channel monitor v2 errors %ds: %w", seconds, err)
}
}
return nil
}
func sameFixedRollupBucket(start, end time.Time, seconds int) bool {
if !end.After(start) {
return true
}
interval := time.Duration(seconds) * time.Second
return start.Truncate(interval).Equal(end.Add(-time.Nanosecond).Truncate(interval))
}
const channelMonitorV2FixedRollupBoundsSQL = `
WITH bounds AS (
SELECT
date_bin($1::interval, $3::timestamptz, TIMESTAMPTZ '1970-01-01') AS start_at,
date_bin($1::interval, $4::timestamptz - INTERVAL '1 microsecond', TIMESTAMPTZ '1970-01-01') + $1::interval AS end_at
)`
const channelMonitorV2FixedRollupDeleteSQL = channelMonitorV2FixedRollupBoundsSQL + `
DELETE FROM %s
USING bounds
WHERE bucket_seconds = $2::integer
AND bucket_start >= bounds.start_at
AND bucket_start < bounds.end_at`
const channelMonitorV2MetricsRollupSQL = `
INSERT INTO channel_monitor_v2_metrics_rollup (
bucket_start, bucket_seconds, platform, group_id, model, success_requests, error_requests,
upstream_affected_requests, upstream_attempt_count, input_tokens, output_tokens,
cache_creation_tokens, cache_read_tokens, ttft_sum_ms, ttft_count, duration_sum_ms,
duration_count, computed_at
)
` + channelMonitorV2FixedRollupBoundsSQL + `
SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
platform, group_id, model, SUM(success_requests), SUM(error_requests),
SUM(upstream_affected_requests), SUM(upstream_attempt_count), SUM(input_tokens),
SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens),
SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW()
FROM channel_monitor_v2_metrics_1m m, bounds
WHERE m.bucket_start >= bounds.start_at AND m.bucket_start < bounds.end_at
GROUP BY 1, 2, 3, 4, 5`
const channelMonitorV2UserMetricsRollupSQL = `
INSERT INTO channel_monitor_v2_user_metrics_rollup (
bucket_start, bucket_seconds, platform, group_id, model, user_id, success_requests,
error_requests, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens,
ttft_sum_ms, ttft_count, duration_sum_ms, duration_count, computed_at
)
` + channelMonitorV2FixedRollupBoundsSQL + `
SELECT date_bin($1::interval, m.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
platform, group_id, model, user_id, SUM(success_requests), SUM(error_requests),
SUM(input_tokens), SUM(output_tokens), SUM(cache_creation_tokens), SUM(cache_read_tokens),
SUM(ttft_sum_ms), SUM(ttft_count), SUM(duration_sum_ms), SUM(duration_count), NOW()
FROM channel_monitor_v2_user_metrics_1m m, bounds
WHERE m.bucket_start >= bounds.start_at AND m.bucket_start < bounds.end_at
GROUP BY 1, 2, 3, 4, 5, 6`
const channelMonitorV2HistogramRollupSQL = `
INSERT INTO channel_monitor_v2_latency_histograms_rollup (
bucket_start, bucket_seconds, platform, group_id, model, user_id, metric, upper_bound_ms, sample_count
)
` + channelMonitorV2FixedRollupBoundsSQL + `
SELECT date_bin($1::interval, h.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
platform, group_id, model, user_id, metric, upper_bound_ms, SUM(sample_count)
FROM channel_monitor_v2_latency_histograms_1m h, bounds
WHERE h.bucket_start >= bounds.start_at AND h.bucket_start < bounds.end_at
GROUP BY 1, 2, 3, 4, 5, 6, 7, 8`
const channelMonitorV2ErrorRollupSQL = `
INSERT INTO channel_monitor_v2_error_metrics_rollup (
bucket_start, bucket_seconds, platform, group_id, model, error_category, taxonomy_version, error_requests
)
` + channelMonitorV2FixedRollupBoundsSQL + `
SELECT date_bin($1::interval, e.bucket_start, TIMESTAMPTZ '1970-01-01'), $2::integer,
platform, group_id, model, error_category, taxonomy_version, SUM(error_requests)
FROM channel_monitor_v2_error_metrics_1m e, bounds
WHERE e.bucket_start >= bounds.start_at AND e.bucket_start < bounds.end_at
GROUP BY 1, 2, 3, 4, 5, 6, 7`
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,288 @@
package repository
import (
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestChannelMonitorV2DisplayModelIsPlatformScoped(t *testing.T) {
cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{
{Platform: "openai", Enabled: true, Models: []string{"shared", "gpt-5"}},
{Platform: "grok", Enabled: true, Models: []string{"grok-4"}},
// Empty models list must NOT collapse everything into __other__.
{Platform: "anthropic", Enabled: true, Models: []string{}},
}}
require.Equal(t, "shared", channelMonitorV2DisplayModel(cfg, "openai", "shared"))
require.Equal(t, service.ChannelMonitorV2OtherModel, channelMonitorV2DisplayModel(cfg, "grok", "shared"))
require.Equal(t, "claude-sonnet-4", channelMonitorV2DisplayModel(cfg, "anthropic", "claude-sonnet-4"))
// Unconfigured platform still surfaces the real model name.
require.Equal(t, "gemini-2.5-pro", channelMonitorV2DisplayModel(cfg, "gemini", "gemini-2.5-pro"))
require.True(t, channelMonitorV2ModelSelected(service.ChannelMonitorV2Filter{Models: []string{service.ChannelMonitorV2OtherModel}}, cfg, "grok", "shared"))
}
func TestChannelMonitorV2MatrixDimensionKey(t *testing.T) {
cfg := service.ChannelMonitorV2Config{Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true, Models: []string{"gpt-5"}}}}
key := channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformGroupModel, cfg, "openai", 7, "gpt-5")
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", groupID: 7, model: "gpt-5"}, key)
key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatformModel, cfg, "openai", 7, "unlisted")
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai", model: service.ChannelMonitorV2OtherModel}, key)
key = channelMonitorV2MatrixDimensionKey(service.ChannelMonitorV2GroupByPlatform, cfg, "openai", 7, "gpt-5")
require.Equal(t, channelMonitorV2MatrixKey{platform: "openai"}, key)
}
func TestChannelMonitorV2HistogramPercentilesAreMergedFromCounts(t *testing.T) {
// 100 samples: 50@100, 40@500, 10@1000
// target = int64(total*p + 0.999999) truncates: p50→50, p90→90, p95→95
// cumulative hits: p50@100, p90@500 (50+40), p95@1000
histogram := map[int64]int64{100: 50, 500: 40, 1000: 10}
require.Equal(t, int64(100), *histPercentile(histogram, .5))
require.Equal(t, int64(500), *histPercentile(histogram, .9))
require.Equal(t, int64(1000), *histPercentile(histogram, .95))
require.Nil(t, histPercentile(nil, .95))
// latencyMetric exposes avg + p50 + p90 + p95
lat := latencyMetric(1000, 10, histogram)
require.NotNil(t, lat.AvgMs)
require.NotNil(t, lat.P50Ms)
require.NotNil(t, lat.P90Ms)
require.NotNil(t, lat.P95Ms)
require.Equal(t, int64(100), *lat.P50Ms)
require.Equal(t, int64(500), *lat.P90Ms)
require.Equal(t, int64(1000), *lat.P95Ms)
}
func TestChannelMonitorV2MetricIncludesSuccessRate(t *testing.T) {
acc := newMetricAccumulator()
acc.success, acc.errors = 80, 20
metric := acc.metric(1, false)
require.Equal(t, int64(100), metric.RequestCount)
require.InDelta(t, 0.8, metric.SuccessRate, 0.0001)
require.InDelta(t, 0.2, metric.ErrorRate, 0.0001)
require.Nil(t, metric.UpstreamAffectedRequests)
adminMetric := acc.metric(1, true)
require.NotNil(t, adminMetric.UpstreamAffectedRequests)
}
func TestChannelMonitorV2WhereUsesConfiguredScopeAndEmptyFilterMeansAllConfigured(t *testing.T) {
filter := service.ChannelMonitorV2Filter{Start: time.Unix(1, 0), End: time.Unix(2, 0)}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}, {Platform: "grok", Enabled: false}},
GroupIDs: []int64{3, 4},
}
where, args := channelMonitorV2Where(filter, cfg, "m")
require.Contains(t, where, "m.platform = ANY($3)")
require.Contains(t, where, "m.group_id = ANY($4)")
require.Len(t, args, 4)
}
func TestChannelMonitorV2WhereRejectsGroupFilterOutsideConfiguredScope(t *testing.T) {
filter := service.ChannelMonitorV2Filter{
Start: time.Unix(1, 0), End: time.Unix(2, 0), GroupIDs: []int64{9},
}
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{{Platform: "openai", Enabled: true}},
GroupIDs: []int64{3, 4},
}
where, args := channelMonitorV2Where(filter, cfg, "m")
require.Contains(t, where, "FALSE")
require.NotContains(t, where, "m.group_id = ANY")
require.Len(t, args, 3)
}
func TestChannelMonitorV2ErrorAggregationCountsFinalUserErrorsOnly(t *testing.T) {
query := strings.ToLower(channelMonitorV2ErrorAggregationSQL)
require.Contains(t, query, "not current_error.is_count_tokens")
require.Contains(t, query, "error_type = 'cyber_policy'")
require.Contains(t, query, "distinct on")
require.Contains(t, query, "candidate_ids")
require.Contains(t, query, "where bucket_start >= $1 and bucket_start < $2")
require.Contains(t, query, "upstream_affected_requests")
require.Contains(t, query, "jsonb_array_length(upstream_errors) > 0")
// request_id dedup must be time-bounded (no full-history scan).
require.Contains(t, query, "interval '90 minutes'")
require.Contains(t, query, "current_error.created_at >= $1 - interval '90 minutes'")
}
func TestChannelMonitorV2UsageSuccessExcludesCyberBillingRows(t *testing.T) {
for _, query := range []string{channelMonitorV2UsageMetricsSQL, channelMonitorV2UserMetricsSQL} {
require.Contains(t, query, "COALESCE(ul.request_type, 0) NOT IN (4, 6)")
require.Contains(t, query, "ul.actual_cost > 0")
}
require.Contains(t, channelMonitorV2PlatformSQL, "g.platform = 'composite'")
require.Contains(t, channelMonitorV2PlatformSQL, "a.platform")
require.Contains(t, channelMonitorV2HistogramSQL, "ul.actual_cost > 0")
}
func TestChannelMonitorV2RatesUseCoveredWindow(t *testing.T) {
start := time.Date(2026, 8, 1, 0, 0, 0, 0, time.UTC)
filter := service.ChannelMonitorV2Filter{Start: start, End: start.Add(24 * time.Hour)}
coverage := service.ChannelMonitorV2Coverage{CoverageStart: start.Add(6 * time.Hour), DataThrough: start.Add(18 * time.Hour)}
require.Equal(t, 12*60.0, channelMonitorV2CoveredMinutes(filter, coverage))
effective := channelMonitorV2CommonCoverageFilter(filter, coverage)
require.Equal(t, coverage.CoverageStart, effective.Start)
require.Equal(t, coverage.DataThrough, effective.End)
}
func TestChannelMonitorV2HistoryCoverageCompleteIgnoresTrailingLag(t *testing.T) {
start := time.Date(2026, 8, 7, 2, 20, 0, 0, time.UTC)
// History reaches the window start → complete even if data_through is behind filter.End.
require.True(t, channelMonitorV2HistoryCoverageComplete(start, start))
require.True(t, channelMonitorV2HistoryCoverageComplete(start.Add(-time.Hour), start))
// Backfill still short of the window start → incomplete.
require.False(t, channelMonitorV2HistoryCoverageComplete(start.Add(time.Hour), start))
require.False(t, channelMonitorV2HistoryCoverageComplete(time.Time{}, start))
}
func TestChannelMonitorV2TierRetentionPolicy(t *testing.T) {
require.Equal(t, 3*24*time.Hour, channelMonitorV2RetentionUser1m)
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionMetrics1m)
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionError1m)
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionHistogram1m)
require.Equal(t, 7*24*time.Hour, channelMonitorV2RetentionRollup5m)
require.Equal(t, 30*24*time.Hour, channelMonitorV2RetentionRollup1h)
require.Equal(t, 45*24*time.Hour, channelMonitorV2RetentionRollup12h)
require.Equal(t, 90*24*time.Hour, channelMonitorV2RetentionRollup1d)
require.Equal(t, channelMonitorV2RetentionRollup1d, channelMonitorV2MaxRetention())
require.Contains(t, channelMonitorV2WatermarkSQL, "INTERVAL '90 days'")
// Every fixed rollup second must appear with a retention rule.
wantSeconds := map[int]time.Duration{
300: channelMonitorV2RetentionRollup5m,
3600: channelMonitorV2RetentionRollup1h,
43200: channelMonitorV2RetentionRollup12h,
86400: channelMonitorV2RetentionRollup1d,
}
seen := map[int]time.Duration{}
for _, rule := range channelMonitorV2RetentionRules {
if rule.bucketSeconds == 0 {
require.True(t, rule.retention > 0)
continue
}
if prev, ok := seen[rule.bucketSeconds]; ok {
require.Equal(t, prev, rule.retention)
}
seen[rule.bucketSeconds] = rule.retention
}
for seconds, want := range wantSeconds {
got, ok := seen[seconds]
require.Truef(t, ok, "missing retention rule for bucket_seconds=%d", seconds)
require.Equal(t, want, got)
}
now := time.Date(2026, 8, 7, 12, 0, 0, 0, time.UTC)
require.Equal(t, now.Add(-7*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2RetentionMetrics1m))
require.Equal(t, now.Add(-90*24*time.Hour), channelMonitorV2RetentionCutoff(now, channelMonitorV2MaxRetention()))
}
func TestSameFixedRollupBucket(t *testing.T) {
start := time.Date(2026, 8, 7, 10, 0, 0, 0, time.UTC)
require.True(t, sameFixedRollupBucket(start, start.Add(10*time.Minute), 86400))
require.False(t, sameFixedRollupBucket(start, start.Add(15*time.Hour), 43200))
require.False(t, sameFixedRollupBucket(start, start.Add(24*time.Hour), 86400))
}
// Needles present in service.ClassifyChannelMonitorV2Error must appear in the
// aggregation SQL CASE so rollup categories match drilldown classification.
func TestChannelMonitorV2SQLTaxonomyContainsGoNeedles(t *testing.T) {
sql := channelMonitorV2ErrorAggregationSQL
needles := []string{
"blocked keyword",
"invalid_api_key",
"max_tokens",
"invalid_request",
"model not supported",
"billing hard limit",
"no healthy upstream account",
"rate_limit",
"gateway timeout",
"connection refused",
"unexpected eof",
}
for _, needle := range needles {
require.Containsf(t, strings.ToLower(sql), strings.ToLower(needle), "SQL taxonomy missing Go needle %q", needle)
}
}
func TestApplyIgnoredErrorsAdjustsRatesKeepsAbsoluteVolume(t *testing.T) {
m := service.ChannelMonitorV2Metric{
RequestCount: 100,
ErrorRequests: 20,
ErrorRate: 0.20,
SuccessRate: 0.80,
}
// Success absolute still 80 → success rate stays 0.80 even after ignoring 5 errors.
m.SuccessRequests = 80
applyIgnoredErrors(&m, 5)
require.Equal(t, int64(100), m.RequestCount)
require.Equal(t, int64(20), m.ErrorRequests)
require.InDelta(t, 0.15, m.ErrorRate, 0.0001)
require.InDelta(t, 0.80, m.SuccessRate, 0.0001)
// Clamp ignored > errors: scored error_rate → 0; success stays true ratio.
m2 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8}
applyIgnoredErrors(&m2, 99)
require.InDelta(t, 0.0, m2.ErrorRate, 0.0001)
require.InDelta(t, 0.8, m2.SuccessRate, 0.0001)
// No-op when ignored is zero
m3 := service.ChannelMonitorV2Metric{RequestCount: 10, ErrorRequests: 2, SuccessRequests: 8, ErrorRate: 0.2, SuccessRate: 0.8}
applyIgnoredErrors(&m3, 0)
require.InDelta(t, 0.2, m3.ErrorRate, 0.0001)
require.InDelta(t, 0.8, m3.SuccessRate, 0.0001)
}
func TestRedactChannelMonitorV2MetricZerosVolume(t *testing.T) {
// Service helper is in service package; covered there. Keep a smoke note that
// rates survive a manual zeroing of volume fields used by the UI contract.
m := service.ChannelMonitorV2Metric{
RequestCount: 100, ErrorRequests: 10, SuccessRequests: 90,
TokenCount: 1000, RPM: 5, TPM: 50, ErrorRate: 0.1, SuccessRate: 0.9, CacheRate: 0.4,
}
// Mimic redact: zero volume only
m.RequestCount, m.ErrorRequests, m.SuccessRequests, m.TokenCount = 0, 0, 0, 0
require.Equal(t, 0.1, m.ErrorRate)
require.Equal(t, 5.0, m.RPM)
}
func TestChannelMonitorV2CatalogFilterClearsMultiSelectDimensions(t *testing.T) {
start := time.Unix(1, 0)
end := time.Unix(2, 0)
filter := service.ChannelMonitorV2Filter{
Start: start, End: end, Bucket: time.Minute,
Platforms: []string{"openai"}, GroupIDs: []int64{3}, Models: []string{"gpt-5"},
}
catalog := channelMonitorV2CatalogFilter(filter)
require.Nil(t, catalog.Platforms)
require.Nil(t, catalog.GroupIDs)
require.Nil(t, catalog.Models)
// Time window / coverage-related fields remain.
require.Equal(t, start, catalog.Start)
require.Equal(t, end, catalog.End)
require.Equal(t, time.Minute, catalog.Bucket)
cfg := service.ChannelMonitorV2Config{
Platforms: []service.ChannelMonitorV2PlatformConfig{
{Platform: "openai", Enabled: true},
{Platform: "grok", Enabled: true},
},
GroupIDs: []int64{3, 4},
}
catalogWhere, catalogArgs := channelMonitorV2Where(catalog, cfg, "m")
_, metricArgs := channelMonitorV2Where(filter, cfg, "m")
// Catalog WHERE still applies config scope (enabled platforms + group allow-list).
require.Contains(t, catalogWhere, "m.platform = ANY")
require.Contains(t, catalogWhere, "m.group_id = ANY")
require.Len(t, catalogArgs, 4) // start, end, platforms, groups
require.Len(t, metricArgs, 4)
// Metrics WHERE is narrower once multi-select platforms/groups are applied.
require.NotEqual(t, catalogArgs, metricArgs)
// Group seeding without multi-select uses full config allow-list.
require.Equal(t, []int64{3, 4}, configuredChannelMonitorV2GroupIDs(catalog, cfg))
require.Equal(t, []int64{3}, configuredChannelMonitorV2GroupIDs(filter, cfg))
}
@@ -7,6 +7,7 @@ import (
"errors"
"fmt"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -64,6 +65,68 @@ func (c *gatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64
return c.rdb.Del(ctx, key).Err()
}
const (
grokVideoPendingBillingPrefix = "grok_video_pending:"
grokVideoBilledPrefix = "grok_video_billed:"
)
func (c *gatewayCache) SetGrokVideoPendingBilling(ctx context.Context, key string, payload []byte, ttl time.Duration) error {
if c == nil || c.rdb == nil {
return errors.New("gateway cache unavailable")
}
key = strings.TrimSpace(key)
if key == "" || len(payload) == 0 {
return errors.New("invalid grok video pending billing payload")
}
if ttl <= 0 {
ttl = 24 * time.Hour
}
return c.rdb.Set(ctx, grokVideoPendingBillingPrefix+key, payload, ttl).Err()
}
func (c *gatewayCache) GetGrokVideoPendingBilling(ctx context.Context, key string) ([]byte, error) {
if c == nil || c.rdb == nil {
return nil, errors.New("gateway cache unavailable")
}
key = strings.TrimSpace(key)
if key == "" {
return nil, errors.New("invalid grok video pending billing key")
}
val, err := c.rdb.Get(ctx, grokVideoPendingBillingPrefix+key).Bytes()
if err != nil {
if errors.Is(err, redis.Nil) {
return nil, nil
}
return nil, err
}
return val, nil
}
func (c *gatewayCache) ClaimGrokVideoBilled(ctx context.Context, key string, ttl time.Duration) (bool, error) {
if c == nil || c.rdb == nil {
return false, errors.New("gateway cache unavailable")
}
key = strings.TrimSpace(key)
if key == "" {
return false, errors.New("invalid grok video billed key")
}
if ttl <= 0 {
ttl = 48 * time.Hour
}
return c.rdb.SetNX(ctx, grokVideoBilledPrefix+key, "1", ttl).Result()
}
func (c *gatewayCache) ReleaseGrokVideoBilled(ctx context.Context, key string) error {
if c == nil || c.rdb == nil {
return errors.New("gateway cache unavailable")
}
key = strings.TrimSpace(key)
if key == "" {
return errors.New("invalid grok video billed key")
}
return c.rdb.Del(ctx, grokVideoBilledPrefix+key).Err()
}
// Compile-time assertion: gatewayCache must implement CyberSessionBlockStore.
var _ service.CyberSessionBlockStore = (*gatewayCache)(nil)
var _ service.LiveCallStore = (*gatewayCache)(nil)
@@ -1,10 +1,15 @@
package repository
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
@@ -20,8 +25,24 @@ type grokOAuthClient struct {
tokenURL string
}
const (
accountsBaseURL = "https://accounts.x.ai"
loginRPCEndpoint = accountsBaseURL + "/api/rpc"
turnstileWebsiteURL = accountsBaseURL
turnstileWebsiteKey = "0x4AAAAAAAhr9JGVDZbrZOo0"
yesCaptchaCreateTask = "https://api.yescaptcha.com/createTask"
yesCaptchaGetResult = "https://api.yescaptcha.com/getTaskResult"
)
func NewGrokOAuthClient() service.GrokOAuthClient {
return &grokOAuthClient{tokenURL: xai.EffectiveTokenURL()}
// Fail closed: never fall back to an unvalidated EffectiveTokenURL (env can
// point at an attacker host and steal code/refresh tokens).
tokenURL, err := xai.ValidatedTokenURL()
if err != nil || strings.TrimSpace(tokenURL) == "" {
// Official allowlisted endpoint only — never EffectiveTokenURL() (raw env).
tokenURL = xai.DefaultTokenURL
}
return &grokOAuthClient{tokenURL: tokenURL}
}
func (c *grokOAuthClient) ExchangeCode(ctx context.Context, code, codeVerifier, redirectURI, proxyURL, clientID string) (*xai.TokenResponse, error) {
@@ -90,6 +111,31 @@ func (c *grokOAuthClient) RefreshToken(ctx context.Context, refreshToken, proxyU
return &tokenResp, nil
}
// LoginWithPassword authenticates against accounts.x.ai and returns an ephemeral SSO cookie.
// Password and SSO must never be written to account credentials or logs.
func (c *grokOAuthClient) LoginWithPassword(ctx context.Context, email, password, proxyURL string) (*service.GrokPasswordLoginResult, error) {
turnstileToken, err := solveTurnstile(ctx)
if err != nil {
return nil, err
}
httpClient, err := createGrokHTTPClient(proxyURL, true)
if err != nil {
return nil, infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CLIENT_INIT_FAILED", "create HTTP client: %v", err)
}
cookieSetterURL, err := createGrokPasswordSession(ctx, httpClient, strings.TrimSpace(email), password, turnstileToken)
if err != nil {
return nil, err
}
ssoToken, err := extractGrokSSOToken(ctx, httpClient, cookieSetterURL)
if err != nil {
return nil, err
}
return &service.GrokPasswordLoginResult{
Email: strings.TrimSpace(email),
SSOToken: ssoToken,
}, nil
}
func (c *grokOAuthClient) ConvertSSOToBuild(ctx context.Context, ssoToken, proxyURL string) (*xai.TokenResponse, error) {
client, err := createGrokSSOHTTPClient(proxyURL)
if err != nil {
@@ -167,6 +213,20 @@ func grokOAuthStatusError(code, message string, resp *req.Response) error {
func grokOAuthHasExplicitEntitlementDenial(body string) bool {
lower := strings.ToLower(body)
// Billing exhaustion is recoverable. xAI may include a generic
// access_denied code alongside the quota message, so it must win over the
// entitlement marker during token refresh.
for _, phrase := range []string{
"spending limit",
"run out of credits",
"out of credits",
"credits exhausted",
"included free usage",
} {
if strings.Contains(lower, phrase) {
return false
}
}
compact := strings.NewReplacer(" ", "", "\n", "", "\r", "", "\t", "").Replace(lower)
for _, field := range []string{"error", "code", "reason"} {
for _, value := range []string{"access_denied", "entitlement_denied", "subscription_required", "no_active_subscription"} {
@@ -179,3 +239,199 @@ func grokOAuthHasExplicitEntitlementDenial(body string) bool {
strings.Contains(lower, "subscription required") ||
strings.Contains(lower, "no active grok subscription")
}
func createGrokHTTPClient(proxyURL string, noRedirect bool) (*http.Client, error) {
transport := &http.Transport{}
if strings.TrimSpace(proxyURL) != "" {
parsed, err := url.Parse(proxyURL)
if err != nil {
return nil, err
}
transport.Proxy = http.ProxyURL(parsed)
}
client := &http.Client{Timeout: 120 * time.Second, Transport: transport}
if noRedirect {
client.CheckRedirect = func(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
}
return client, nil
}
func solveTurnstile(ctx context.Context) (string, error) {
clientKey := strings.TrimSpace(os.Getenv("YESCAPTCHA_CLIENT_KEY"))
if clientKey == "" {
clientKey = strings.TrimSpace(os.Getenv("YESCAPTCHA_API_KEY"))
}
if clientKey == "" {
return "", infraerrors.New(http.StatusBadRequest, "GROK_OAUTH_CAPTCHA_KEY_REQUIRED", "yescaptcha client key is required for Grok password authorization")
}
createBody, err := json.Marshal(map[string]any{
"clientKey": clientKey,
"task": map[string]any{
"type": "TurnstileTaskProxyless",
"websiteURL": turnstileWebsiteURL,
"websiteKey": turnstileWebsiteKey,
},
})
if err != nil {
return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha create request failed: %v", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaCreateTask, bytes.NewReader(createBody))
if err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "build captcha create request failed: %v", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "create captcha task failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
var createResp struct {
ErrorID int `json:"errorId"`
TaskID string `json:"taskId"`
ErrorDescription string `json:"errorDescription"`
}
if err := json.NewDecoder(resp.Body).Decode(&createResp); err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "decode captcha create response failed: %v", err)
}
if createResp.ErrorID != 0 || strings.TrimSpace(createResp.TaskID) == "" {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha create failed: %s", createResp.ErrorDescription)
}
deadline := time.Now().Add(90 * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return "", ctx.Err()
case <-time.After(5 * time.Second):
}
body, err := json.Marshal(map[string]any{"clientKey": clientKey, "taskId": createResp.TaskID})
if err != nil {
return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_CAPTCHA_FAILED", "encode captcha poll request failed: %v", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, yesCaptchaGetResult, bytes.NewReader(body))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
continue
}
var pollResp struct {
ErrorID int `json:"errorId"`
Status string `json:"status"`
ErrorDescription string `json:"errorDescription"`
Solution struct {
Token string `json:"token"`
} `json:"solution"`
}
err = json.NewDecoder(resp.Body).Decode(&pollResp)
_ = resp.Body.Close()
if err != nil {
continue
}
if pollResp.ErrorID != 0 {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_CAPTCHA_FAILED", "captcha poll failed: %s", pollResp.ErrorDescription)
}
if pollResp.Status == "ready" && strings.TrimSpace(pollResp.Solution.Token) != "" {
return pollResp.Solution.Token, nil
}
}
return "", infraerrors.New(http.StatusGatewayTimeout, "GROK_OAUTH_CAPTCHA_TIMEOUT", "captcha solve timed out")
}
func createGrokPasswordSession(ctx context.Context, client *http.Client, email, password, turnstileToken string) (string, error) {
payload, err := json.Marshal(map[string]any{
"rpc": "createSession",
"req": map[string]any{
"createSessionRequest": map[string]any{
"credentials": map[string]any{
"case": "emailAndPassword",
"value": map[string]any{
"email": email,
"clearTextPassword": password,
},
},
},
"turnstileToken": turnstileToken,
},
})
if err != nil {
return "", infraerrors.Newf(http.StatusInternalServerError, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "encode password login request failed: %v", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, loginRPCEndpoint, bytes.NewReader(payload))
if err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build password login request failed: %v", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Origin", accountsBaseURL)
req.Header.Set("Referer", accountsBaseURL+"/sign-in?redirect=grok-com&email=true")
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Accept", "*/*")
resp, err := client.Do(req)
if err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login request failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login returned status %d: %s", resp.StatusCode, logredact.RedactText(string(body)))
}
var loginResp struct {
CookieSetterURL string `json:"cookieSetterUrl"`
Error string `json:"error"`
}
if err := json.Unmarshal(body, &loginResp); err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "decode password login response failed: %v", err)
}
if strings.TrimSpace(loginResp.Error) != "" {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login error: %s", logredact.RedactText(loginResp.Error))
}
if strings.TrimSpace(loginResp.CookieSetterURL) == "" {
return "", infraerrors.New(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "password login did not return cookieSetterUrl")
}
return loginResp.CookieSetterURL, nil
}
func extractGrokSSOToken(ctx context.Context, client *http.Client, cookieSetterURL string) (string, error) {
safeURL, err := validateGrokCookieSetterURL(cookieSetterURL)
if err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "invalid cookie setter url: %v", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, safeURL.String(), nil)
if err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "build cookie setter request: %v", err)
}
req.Header.Set("User-Agent", "Mozilla/5.0")
req.Header.Set("Accept", "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8")
req.Header.Set("Referer", accountsBaseURL+"/")
resp, err := client.Do(req)
if err != nil {
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "follow cookie setter url failed: %v", err)
}
defer func() { _ = resp.Body.Close() }()
for _, cookie := range resp.Header.Values("Set-Cookie") {
if token, ok := strings.CutPrefix(cookie, "sso="); ok {
if idx := strings.Index(token, ";"); idx > 0 {
token = token[:idx]
}
return strings.TrimSpace(token), nil
}
}
return "", infraerrors.Newf(http.StatusBadGateway, "GROK_OAUTH_PASSWORD_LOGIN_FAILED", "no sso cookie found in response (status=%d)", resp.StatusCode)
}
func validateGrokCookieSetterURL(rawURL string) (*url.URL, error) {
parsed, err := url.Parse(strings.TrimSpace(rawURL))
if err != nil {
return nil, err
}
if parsed.Scheme != "https" || !strings.EqualFold(parsed.Hostname(), "accounts.x.ai") {
return nil, fmt.Errorf("url must use https://accounts.x.ai")
}
if parsed.User != nil || parsed.Port() != "" || parsed.Fragment != "" || parsed.Opaque != "" {
return nil, fmt.Errorf("url contains disallowed authority or fragment components")
}
return parsed, nil
}
@@ -47,6 +47,8 @@ func TestGrokOAuthClientExchangeAndRefreshUseFormFields(t *testing.T) {
}
}))
defer server.Close()
// Tests inject a loopback token endpoint; allowlist requires unsafe override.
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
t.Setenv(xai.EnvTokenURL, server.URL)
client := NewGrokOAuthClient()
@@ -80,6 +82,7 @@ func TestGrokOAuthClientRefreshForbiddenClassifiesOnlyExplicitEntitlement(t *tes
_, _ = w.Write([]byte(tt.body))
}))
defer server.Close()
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
t.Setenv(xai.EnvTokenURL, server.URL)
client := NewGrokOAuthClient()
@@ -96,6 +99,7 @@ func TestGrokOAuthClientStatusErrorRedactsSensitiveResponseBody(t *testing.T) {
_, _ = w.Write([]byte(`{"error":"invalid_grant","access_token":"access-secret","refresh_token":"refresh-secret","code_verifier":"verifier-secret"}`))
}))
defer server.Close()
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "true")
t.Setenv(xai.EnvTokenURL, server.URL)
client := NewGrokOAuthClient()
@@ -118,4 +122,15 @@ func TestGrokOAuthEntitlementDenialRequiresExplicitEvidence(t *testing.T) {
require.True(t, grokOAuthHasExplicitEntitlementDenial(`{"message":"no active Grok subscription"}`))
require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"error":"forbidden","message":"request forbidden"}`))
require.False(t, grokOAuthHasExplicitEntitlementDenial(`<html>403 Forbidden</html>`))
require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"error":"access_denied","message":"You have run out of credits"}`))
require.False(t, grokOAuthHasExplicitEntitlementDenial(`{"code":"subscription_required","message":"included free usage exhausted"}`))
}
func TestNewGrokOAuthClient_UnvalidatedTokenURLFallsBackToDefault(t *testing.T) {
// Without unsafe overrides, a random env TokenURL must not be used (fail-closed).
t.Setenv(xai.EnvAllowUnsafeURLOverrides, "")
t.Setenv(xai.EnvTokenURL, "https://evil.example/oauth/token")
client := NewGrokOAuthClient().(*grokOAuthClient)
require.Equal(t, xai.DefaultTokenURL, client.tokenURL)
}
+26
View File
@@ -82,7 +82,12 @@ func createGroupRecord(ctx context.Context, client *dbent.Client, groupIn *servi
SetNillableVideoPrice480p(groupIn.VideoPrice480P).
SetNillableVideoPrice720p(groupIn.VideoPrice720P).
SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)).
SetNillableWebSearchPricePerCall(groupIn.WebSearchPricePerCall).
SetNillableSearchPricePer1k(groupIn.SearchPricePer1k).
SetNillableAudioRealtimePricePerMin(groupIn.AudioRealtimePricePerMin).
SetNillableAudioTtsPricePerMillionChars(groupIn.AudioTTSPricePerMillionChars).
SetNillableAudioSttPricePerHour(groupIn.AudioSTTPricePerHour).
SetDefaultValidityDays(groupIn.DefaultValidityDays).
SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
SetNillableFallbackGroupID(groupIn.FallbackGroupID).
@@ -254,6 +259,7 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
SetNillableVideoPrice480p(groupIn.VideoPrice480P).
SetNillableVideoPrice720p(groupIn.VideoPrice720P).
SetNillableVideoPrice1080p(groupIn.VideoPrice1080P).
SetVideoModelPrices(service.NormalizeVideoModelPrices(groupIn.VideoModelPrices)).
SetDefaultValidityDays(groupIn.DefaultValidityDays).
SetClaudeCodeOnly(groupIn.ClaudeCodeOnly).
SetModelRoutingEnabled(groupIn.ModelRoutingEnabled).
@@ -327,6 +333,26 @@ func (r *groupRepository) Update(ctx context.Context, groupIn *service.Group) er
} else {
builder = builder.ClearWebSearchPricePerCall()
}
if groupIn.SearchPricePer1k != nil {
builder = builder.SetSearchPricePer1k(*groupIn.SearchPricePer1k)
} else {
builder = builder.ClearSearchPricePer1k()
}
if groupIn.AudioRealtimePricePerMin != nil {
builder = builder.SetAudioRealtimePricePerMin(*groupIn.AudioRealtimePricePerMin)
} else {
builder = builder.ClearAudioRealtimePricePerMin()
}
if groupIn.AudioTTSPricePerMillionChars != nil {
builder = builder.SetAudioTtsPricePerMillionChars(*groupIn.AudioTTSPricePerMillionChars)
} else {
builder = builder.ClearAudioTtsPricePerMillionChars()
}
if groupIn.AudioSTTPricePerHour != nil {
builder = builder.SetAudioSttPricePerHour(*groupIn.AudioSTTPricePerHour)
} else {
builder = builder.ClearAudioSttPricePerHour()
}
// 处理 FallbackGroupID:nil 时清除,否则设置
if groupIn.FallbackGroupID != nil {
+15 -9
View File
@@ -23,6 +23,7 @@ import (
"github.com/andybalholm/brotli"
"github.com/klauspost/compress/zstd"
"golang.org/x/mod/semver"
"golang.org/x/net/http2"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -33,7 +34,6 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/xai"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"golang.org/x/mod/semver"
)
// 默认配置常量
@@ -85,12 +85,12 @@ const (
openAIHTTP2PingTimeout = 15 * time.Second
// The Grok CLI proxy rejects requests that do not identify a supported
// client version. Keep a known-good stable version in the binary while
// allowing operators to bump it without waiting for a Sub2API release.
grokCLIProxyHost = "cli-chat-proxy.grok.com"
// client version. Host/env/version pins live in package xai so service,
// billing, and transport layers advertise the same identity.
grokCLIProxyHost = xai.CLIProxyHost
grokOfficialAPIHost = "api.x.ai"
grokCLIStableVersion = xai.CLIClientVersion
grokCLIVersionOverride = "XAI_GROK_CLI_VERSION"
grokCLIStableVersion = xai.CLIClientVersion // preferred pin (not the minimum floor)
grokCLIVersionOverride = xai.CLIVersionEnv
grokFallbackBodyLimit = 64 << 10
)
@@ -450,6 +450,11 @@ type prefixedReadCloser struct {
// the final shared transport boundary. Keying this behavior to the exact CLI
// proxy host keeps direct api.x.ai traffic unchanged and automatically covers
// Responses, Chat Completions, media, quota probes, and account tests.
//
// Operator overrides must be >= CLIClientVersion (the preferred pin). Package
// xai.IsSupportedCLIVersion uses a lower floor (CLIStableVersion) for general
// validation; transport is stricter so we never silently advertise an older pin
// than the binary default.
func applyGrokCLIProxyHeaders(req *http.Request) {
if req == nil || req.URL == nil || !strings.EqualFold(strings.TrimSpace(req.URL.Hostname()), grokCLIProxyHost) {
return
@@ -461,14 +466,15 @@ func applyGrokCLIProxyHeaders(req *http.Request) {
if !isSupportedGrokCLIVersion(version) {
version = grokCLIStableVersion
}
req.Header.Set("X-XAI-Token-Auth", "xai-grok-cli")
req.Header.Set("X-XAI-Token-Auth", xai.CLITokenAuth)
req.Header.Set("x-grok-client-version", version)
req.Header.Set("User-Agent", "xai-grok-workspace/"+version)
req.Header.Set("x-grok-client-identifier", xai.CLIClientIdentifier)
req.Header.Set("User-Agent", xai.CLIUserAgent(version))
}
func isSupportedGrokCLIVersion(version string) bool {
canonical := "v" + version
minimum := "v" + grokCLIStableVersion
minimum := "v" + xai.CLIClientVersion
return semver.IsValid(canonical) &&
semver.Canonical(canonical) == canonical &&
semver.Compare(canonical, minimum) >= 0
@@ -83,6 +83,14 @@ var migrationChecksumCompatibilityRules = map[string]migrationChecksumCompatibil
"123_fix_legacy_auth_source_grant_on_signup_defaults.sql": newMigrationChecksumCompatibilityRule("2ce43c2cd89e9f9e1febd34a407ed9e84d177386c5544b6f02c1f58a21129f57", "6cd33422f215dcd1f486ab6f35c0ea5805d9ca69bb25906d94bc649156657145"),
"159_batch_image_foundation.sql": newMigrationChecksumCompatibilityRule("d902b70982025ec519749faf058aab7631e82c3f48167b9a4ae4db718eb72cce", "82da85b5d98e67a0507647b873a40373e84538e4adafdeed6767c0ac8b6570b2"),
"161_batch_image_pricing_snapshot.sql": newMigrationChecksumCompatibilityRule("4012af3e43636cb6af22e0176d59d1fcc70615c0f310194329461ae462c4fbd6", "96d915c9b7a6941ae99039e0ff3f1a61481eb9bddd933d11c6fadb2274554e87"),
// 195 originally seeded mode=v2; flipped to v1 (safe default / opt-in v2). Existing DBs
// that already applied the v2 seed keep their row and the historical checksum.
"195_channel_monitor_mode.sql": newMigrationChecksumCompatibilityRule("13f3792f3e3e53ee96e26415c884cf8062c77172824b54fcc9a8c0c2b1f185ec", "4c74fe33ef2274cc72e1bb49671e651274532c034b29f5b2982c2a4c88d101a6"),
// 220 originally cleared video prices for all non-grok platforms (including composite);
// composite is now preserved because it may route to Grok accounts.
"220_clear_non_grok_video_generation_config.sql": newMigrationChecksumCompatibilityRule("85e320b9ec64f2d3fcd8cf705b2b4e76a7b49f7a57140c14bff97f32691c818b", "3da48c8fdffe6390325f43d08b8e353e0a365df43d44a78dbbe655d0deb18402"),
"219_group_search_price_per_1k.sql": newMigrationChecksumCompatibilityRule("e86786ebcc3b14206fd2d321380a4e50e80cdadbfcf4962c639255e6a14008db", "df6ffd71b97e30ec2c8fe7b95e15783042dea58c553e32701ee7c42a5619af80"),
"218_group_audio_voice_pricing.sql": newMigrationChecksumCompatibilityRule("40ee9f3a2af0e0a5e99dabc878fd0fe98be1011f26bcfcefcac7197f7081f0e7", "c2a5e5b4ffd6968ad1c10593289fbc11192cdea19fec3ed9bce3a84eff9a8351"),
}
// ApplyMigrations 将嵌入的 SQL 迁移文件应用到指定的数据库。
+69 -3
View File
@@ -43,16 +43,27 @@ func newUserRepositoryWithSQL(client *dbent.Client, sqlq sqlExecutor) *userRepos
}
func (r *userRepository) Create(ctx context.Context, userIn *service.User) error {
return r.create(ctx, userIn, false)
return r.create(ctx, userIn, false, "")
}
// CreateWithEmailAliasGuard 见 service.UserRepository:在邮箱唯一性锁内复查收件箱身份,
// 供注册路径使用。
func (r *userRepository) CreateWithEmailAliasGuard(ctx context.Context, userIn *service.User) error {
return r.create(ctx, userIn, true)
return r.create(ctx, userIn, true, "")
}
func (r *userRepository) create(ctx context.Context, userIn *service.User, guardEmailAlias bool) error {
// CountUsersByEmailDomain 统计指定可注册主域名及其子域名下的未删除用户。
func (r *userRepository) CountUsersByEmailDomain(ctx context.Context, domain string) (int, error) {
return countUsersByEmailDomainWithClient(ctx, clientFromContext(ctx, r.client), domain)
}
// CreateWithEmailAliasGuardAndDomainLimit 串行化非白名单域名的注册请求,
// 并在用户写入的同一事务内复查域名额度。
func (r *userRepository) CreateWithEmailAliasGuardAndDomainLimit(ctx context.Context, userIn *service.User, domain string) error {
return r.create(ctx, userIn, true, normalizeEmailDomain(domain))
}
func (r *userRepository) create(ctx context.Context, userIn *service.User, guardEmailAlias bool, domainLimit string) error {
if userIn == nil {
return nil
}
@@ -84,6 +95,9 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard
// 别名变体的字面量不同,唯一索引无法兜底;用收件箱身份锁把同一收件箱的并发注册串行化。
lockKeys = append(lockKeys, emailAliasUniquenessLockKey(userIn.Email))
}
if domainLimit != "" {
lockKeys = append(lockKeys, registrationEmailDomainLockKey(domainLimit))
}
releaseEmailLock, err := lockRepositoryScopedKeys(
txCtx,
txClient,
@@ -95,6 +109,16 @@ func (r *userRepository) create(ctx context.Context, userIn *service.User, guard
}
defer releaseEmailLock()
if domainLimit != "" {
count, err := countUsersByEmailDomainWithClient(txCtx, txClient, domainLimit)
if err != nil {
return err
}
if count > 0 {
return service.ErrEmailDomainRegistrationLimit
}
}
if err := ensureNormalizedEmailAvailableWithClient(txCtx, txClient, 0, userIn.Email); err != nil {
return err
}
@@ -1232,6 +1256,48 @@ func normalizedEmailUniquenessLockKey(email string) string {
return "users:normalized-email:" + normalized
}
func registrationEmailDomainLockKey(domain string) string {
domain = normalizeEmailDomain(domain)
if domain == "" {
return ""
}
return "users:registration-email-domain:" + domain
}
func normalizeEmailDomain(domain string) string {
return service.NormalizeRegistrationEmailDomain(domain)
}
func countUsersByEmailDomainWithClient(ctx context.Context, client *dbent.Client, domain string) (int, error) {
client = clientFromContext(ctx, client)
domain = normalizeEmailDomain(domain)
if client == nil || domain == "" {
return 0, nil
}
return client.User.Query().Where(userEmailDomainPredicate(domain)).Count(ctx)
}
func userEmailDomainPredicate(domain string) predicate.User {
domain = normalizeEmailDomain(domain)
escapedDomain := escapeLikeWildcards(domain)
exactPattern := "%@" + escapedDomain
subdomainPattern := "%@%." + escapedDomain
return predicate.User(func(s *entsql.Selector) {
s.Where(entsql.P(func(b *entsql.Builder) {
b.WriteString("(RTRIM(LOWER(TRIM(").
Ident(s.C(dbuser.FieldEmail)).
WriteString(")), '.') LIKE ").
Arg(exactPattern).
WriteString(` ESCAPE '\' OR RTRIM(LOWER(TRIM(`).
Ident(s.C(dbuser.FieldEmail)).
WriteString(")), '.') LIKE ").
Arg(subdomainPattern).
WriteString(` ESCAPE '\'`).
WriteString(")")
}))
})
}
// emailAliasUniquenessLockKey 按收件箱身份(而非邮箱字面量)加锁,使同一收件箱的不同
// 别名变体在注册时互斥。
func emailAliasUniquenessLockKey(email string) string {
@@ -98,3 +98,58 @@ func TestUserRepositoryCreateWithEmailAliasGuard(t *testing.T) {
Status: service.StatusActive,
}))
}
func TestUserRepositoryCountUsersByEmailDomain(t *testing.T) {
repo, _ := newUserEntRepo(t)
ctx := context.Background()
active := &service.User{
Email: "first@custom.example",
Username: "first",
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
}
seedUserForAliasTest(t, repo, active.Email)
seedUserForAliasTest(t, repo, "other@sub.custom.example")
deleted := &service.User{
Email: "deleted@custom.example",
Username: "deleted",
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
}
require.NoError(t, repo.Create(ctx, deleted))
require.NoError(t, repo.Delete(ctx, deleted.ID))
count, err := repo.CountUsersByEmailDomain(ctx, "custom.example")
require.NoError(t, err)
require.Equal(t, 2, count)
}
func TestUserRepositoryCreateWithEmailAliasGuardAndDomainLimit(t *testing.T) {
repo, _ := newUserEntRepo(t)
ctx := context.Background()
seedUserForAliasTest(t, repo, "first@custom.example.")
err := repo.CreateWithEmailAliasGuardAndDomainLimit(ctx, &service.User{
Email: "second@custom.example",
Username: "second",
PasswordHash: "hash",
Role: service.RoleUser,
Status: service.StatusActive,
}, "custom.example")
require.ErrorIs(t, err, service.ErrEmailDomainRegistrationLimit)
}
func TestUserRepositoryCountUsersByEmailDomainEscapesLikeWildcards(t *testing.T) {
repo, _ := newUserEntRepo(t)
ctx := context.Background()
seedUserForAliasTest(t, repo, "first@foo_bar.com")
seedUserForAliasTest(t, repo, "other@fooxbar.com")
count, err := repo.CountUsersByEmailDomain(ctx, "foo_bar.com")
require.NoError(t, err)
require.Equal(t, 1, count)
}
@@ -4,6 +4,7 @@ package repository
import (
"context"
"errors"
"strings"
"sync"
"testing"
@@ -64,6 +65,44 @@ func (s *UserRepoSuite) mustCreateUser(u *service.User) *service.User {
return u
}
func (s *UserRepoSuite) TestCreateWithEmailAliasGuardAndDomainLimitConcurrent() {
domain := "race-" + strings.ToLower(strings.ReplaceAll(time.Now().Format("150405.000000000"), ".", "")) + ".example"
users := []*service.User{
{Email: "first@" + domain, PasswordHash: "hash", Role: service.RoleUser, Status: service.StatusActive},
{Email: "second@" + domain, PasswordHash: "hash", Role: service.RoleUser, Status: service.StatusActive},
}
errs := make(chan error, len(users))
var wg sync.WaitGroup
for _, user := range users {
wg.Add(1)
go func(user *service.User) {
defer wg.Done()
errs <- s.repo.CreateWithEmailAliasGuardAndDomainLimit(s.ctx, user, domain)
}(user)
}
wg.Wait()
close(errs)
var success, limited int
for err := range errs {
switch {
case err == nil:
success++
case errors.Is(err, service.ErrEmailDomainRegistrationLimit):
limited++
default:
s.Require().NoError(err)
}
}
s.Require().Equal(1, success)
s.Require().Equal(1, limited)
count, err := s.repo.CountUsersByEmailDomain(s.ctx, domain)
s.Require().NoError(err)
s.Require().Equal(1, count)
}
func (s *UserRepoSuite) mustCreateGroup(name string) *service.Group {
s.T().Helper()
+1
View File
@@ -98,6 +98,7 @@ var ProviderSet = wire.NewSet(
NewTLSFingerprintProfileRepository,
NewChannelRepository,
NewChannelMonitorRepository,
NewChannelMonitorV2Repository,
NewChannelMonitorRequestTemplateRepository,
NewContentModerationRepository,
NewAffiliateRepository,

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