Compare commits

...
Author SHA1 Message Date
shaw 0fcec63b68 test(openai): isolate visible TTFT fixture 2026-08-31 15:15:44 +08:00
shaw 8553c91c1a test(api): update settings contract fixture 2026-08-31 15:05:39 +08:00
shaw c66e700f05 fix(openai): move TTFT metric mode to admin settings 2026-08-31 14:52:07 +08:00
Wesley Liddick fdf9751c12 Merge pull request #6378 from Ritel-T/fix/codex-oauth-service-tier-billing
fix(openai): preserve Codex OAuth Fast billing tier
2026-08-31 13:56:00 +08:00
Wesley Liddick 0678b24d58 Merge pull request #6283 from okbexx/feat/usage-compaction-kind
feat(usage): track native compaction requests
2026-08-31 11:19:37 +08:00
Wesley Liddick b369fbca15 Merge pull request #6395 from lylelllll/fix/account-expiry-timezone
fix(frontend): clarify local timezone for expiry inputs
2026-08-31 11:18:55 +08:00
Wesley Liddick 36ee193e3c Merge pull request #6367 from qiujun8023/fix/usage-stats-cards-mobile-horizontal-scroll
fix(frontend): prevent hidden cache tooltip from causing horizontal scroll on mobile
2026-08-31 11:18:03 +08:00
Wesley Liddick 1dd0f2e5dc Merge pull request #6384 from alfadb/fix/unify-usage-window-style
fix(frontend): unify usage window rendering and tighten alert thresholds
2026-08-31 11:17:09 +08:00
Wesley Liddick f34735f462 Merge pull request #6388 from alfadb/feature/ollama-usage-cn-platforms
feat(ollama): 用量窗口支持挂在国产三家平台下的 Ollama Cloud 账号
2026-08-31 11:16:25 +08:00
Wesley Liddick a2d7d41185 Merge pull request #6325 from 251392463/fix/grok-responses-vision-tool-output
fix(grok): preserve Responses vision tool output images
2026-08-31 11:15:33 +08:00
Wesley Liddick 03ab687686 Merge pull request #5866 from wucm667/fix/issue-5863-scope-monitor-groups
fix: scope channel monitor groups by user access
2026-08-31 11:15:13 +08:00
Wesley Liddick 2e756b71f2 Merge pull request #6198 from xuhaihan/fix/openai-reauth-refresh-token
fix(accounts): reauthorize OpenAI with refresh token
2026-08-31 11:14:12 +08:00
Wesley Liddick fbc69322af Merge pull request #6254 from wucm667/fix/issue-6249-configurable-image-cooldown
feat(openai): configure image tool cooldown
2026-08-31 11:13:52 +08:00
Wesley Liddick 414489d152 Merge pull request #5469 from wucm667/fix/issue-5467-passthrough-http-bridge
fix(openai-ws): bridge oversized passthrough requests
2026-08-31 11:13:35 +08:00
Wesley Liddick 50a9dd7a64 Merge pull request #6386 from wucm667/fix/issue-6383-isolate-passthrough-ws
fix(openai): isolate passthrough websocket sessions
2026-08-31 11:13:16 +08:00
Wesley Liddick a4156eea10 Merge pull request #6385 from wucm667/fix/issue-6381-preserve-quota-limits
fix(admin): preserve quota limits on partial group updates
2026-08-31 11:12:56 +08:00
Wesley Liddick e0ecb55d4b Merge pull request #4872 from wucm667/fix/issue-4845-reset-scheduler-rate-limit
fix(account): clear rate limit on quota reset
2026-08-31 11:12:35 +08:00
wucm667 897faea333 fix(account): reset quota cooldown atomically 2026-08-31 09:12:09 +08:00
lylelllll 94edcd5d81 chore: remove second misplaced component copy 2026-08-30 23:31:59 +08:00
lylelllll 3673702af5 chore: remove misplaced uploaded component copy 2026-08-30 23:27:08 +08:00
lylelllll 5778739cdb fix(frontend): use strict local expiry parsing for redeem codes 2026-08-30 23:20:59 +08:00
lylelllll 81e461f651 fix(frontend): parse local expiry datetimes strictly 2026-08-30 23:19:45 +08:00
lylelllll b7aca87fd0 test(frontend): cover local datetime parsing across timezones 2026-08-30 23:17:11 +08:00
lylelllll ae1bcdc25d fix(frontend): clarify expiry timezone in Chinese labels 2026-08-30 23:15:54 +08:00
lylelllll 263605779c fix(frontend): clarify expiry timezone in English labels 2026-08-30 23:13:52 +08:00
lylelllll d66bc88e68 fix(frontend): apply expiry input changes in source paths 2026-08-30 22:11:22 +08:00
lylelllll 8177f27aa3 fix(frontend): clarify local timezone for expiry inputs 2026-08-30 22:10:15 +08:00
wucm667 d5a0124637 fix(openai): isolate passthrough websocket sessions 2026-08-30 16:49:01 +08:00
alfadb 30b29e51e8 feat(ollama): 用量窗口支持挂在国产三家平台下的 Ollama Cloud 账号
Ollama Cloud 用量窗口(浏览器会话、5h/7d 快照、自动刷新、同 key 组共享)此前
只认 platform ∈ {openai, anthropic} 的 apikey 账号。但网关对 kimi/zhipu/deepseek
的 apikey 账号同样按 credentials.base_url 转发,用户把 Ollama Cloud 的 key 挂在
这三个平台下跑 Ollama Cloud 托管的 glm/kimi/deepseek 模型时,请求转发正常,用量
窗口却完全不可用。

- 平台白名单放开到 {openai, anthropic, kimi, zhipu, deepseek};type='apikey'
  与官方 ollama.com base_url 的限制不变
- Go 侧收敛为 isOllamaCloudUsagePlatform,SQL 侧收敛为
  ollamaCloudUsagePlatformsSQL,6 处白名单只引用单一来源,并补两侧互为镜像的
  漂移守卫测试
- CN 周期余额/额度探测跳过 ollama 用量账号:其探测端点由 base_url 衍生,对
  ollama.com 会被出站 URL 白名单拒绝,每轮都白跑并产生噪声
- 前端账号列表 CN 分支在 eligible 时改渲染 Ollama 用量窗口,并跳过 CN 额度/
  余额子单元格(同上,渲染出来只会是一行探测报错)
2026-08-30 16:38:11 +08:00
wucm667 9f1effd71c fix(admin): preserve quota limits on partial group updates 2026-08-30 16:36:13 +08:00
alfadb 6532d5b61e fix(frontend): unify usage window rendering and tighten alert thresholds
CN provider accounts (Kimi/Zhipu/DeepSeek) and the monitor quota view each
hand-rolled their own usage bar, so the same account column rendered three
different label styles, bar widths and threshold palettes. Both now reuse
UsageProgressBar, the component the other five platforms already share.

Also move the default utilization thresholds from >=100 red / >=80 amber to
>=90 / >=75. Turning red only once the quota is exhausted is an after-the-fact
notice rather than a warning. The remainingCapacity mode keeps its 20/50 pair,
which is already a warning semantic.

- UsageProgressBar: add labelWidth 'fixed' | 'auto' so the monitor view can
  render composite labels ("Pro/7d") without breaking column alignment; the
  fixed branch is byte-identical, so the existing platforms are untouched
- drop the superseded utilizationColor/utilizationTextColor/formatReset copies
- shorten CN window labels to 5h / 7d and remove the now-dead resetSoon keys
2026-08-30 15:34:21 +08:00
Ritel-T 82105f260a fix(billing): resolve shadow credential tier contract 2026-08-30 13:40:37 +09:00
Ritel-T 50ad6e2e50 fix(billing): narrow Codex OAuth default exception 2026-08-30 13:40:19 +09:00
Ritel-T f323d84644 test(billing): cover Codex OAuth usage pricing 2026-08-30 13:07:33 +09:00
Ritel-T 3a9070359b fix(billing): scope Codex OAuth tier trust 2026-08-30 13:07:21 +09:00
Ritel-T d39fc491ec test(openai): cover websocket tier separation 2026-08-30 13:07:07 +09:00
Ritel-T 2b8cb628b6 fix(openai): preserve tiers in messages chat fallback 2026-08-30 13:06:45 +09:00
Ritel-T 8b4b3f4a94 fix(openai): preserve tiers in responses chat fallback 2026-08-30 13:06:32 +09:00
Ritel-T 624e4eef64 fix(openai): separate outbound and observed tiers 2026-08-30 13:06:11 +09:00
wucm667 a3bbf33c05 fix(monitor): scope groups by user access 2026-08-30 09:28:40 +08:00
wucm667 7c616db07a fix(openai-ws): bridge oversized passthrough requests 2026-08-30 08:41:08 +08:00
qiujunandClaude Opus 5 0aef702b66 fix(frontend): prevent hidden cache tooltip from causing horizontal scroll
The cache token breakdown tooltip in `UsageStatsCards` was hidden with
`opacity-0`. An element with zero opacity is invisible but still takes part
in layout, so its fixed `w-56` (224px) width kept contributing to the
document's scrollable overflow.

The tooltip is anchored with `left-1/2 -translate-x-1/2`, and on mobile the
stats grid falls back to `grid-cols-2`, which puts the trigger icon close to
the right edge of the viewport. Centering a 224px box on that point pushes it
roughly 26-49px past the right edge, so `/usage` and `/admin/usage` gained a
phantom horizontal scroll with nothing visible to explain it.

Switching the hidden state to `hidden` (`display: none`) takes the tooltip out
of the flow entirely, while `group-hover:block` / `group-focus:block` keep the
existing hover and keyboard-focus behaviour. The 150ms opacity transition is
dropped, since `display` cannot be transitioned.

Verified over Chrome DevTools Protocol at 360/390/414px: `documentElement.scrollWidth`
goes from 424 back to 390 (equal to `clientWidth`) with no other overflowing
elements remaining, and the tooltip still renders at 224x86 on focus. A sweep of
all 39 application routes confirmed these were the only two affected pages.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 17:57:36 +08:00
alfadb b5827cfd54 fix(pricing): align DeepSeek billing with official peak/off-peak rates
同步 DeepSeek 官方峰谷价格与模型定价。
2026-08-29 17:30:23 +08:00
m11y ea4291a92c fix(anthropic): scope Fable scheduling threshold to Fable models
让 Fable 7d_oi 调度阈值仅限制 Fable 模型族,不暂停整个 Anthropic 账号。
2026-08-29 17:30:18 +08:00
101Harry 32ac921f24 fix(anthropic): avoid Fable OAuth system prompt refusal
修复 Fable OAuth 请求因系统提示词形态导致空响应的问题。
2026-08-29 17:29:30 +08:00
chensunlai ed12ea7163 fix(frontend): authenticate Codex API key mode inline
让 Codex API Key 模式在配置文件中正确发送 Bearer 鉴权并保留图像工具授权。
2026-08-29 17:29:26 +08:00
Ryan Chou eb4237a2b0 fix(billing): 渠道定价对带后缀的模型名不再被官方兜底价覆盖
修复带后缀模型无法命中渠道自定义定价的问题。
2026-08-29 17:29:21 +08:00
Brisbanehuang 706b5676a6 fix(groups): show API error messages on create and update
保存分组失败时显示标准化 API 错误信息。
2026-08-29 17:28:20 +08:00
wucm667 c31fe2ed9e fix(admin): preserve saved SMTP TLS mode in test endpoints
保留测试邮件端点已保存的 SMTP TLS 配置,区分省略与显式 false。
2026-08-29 17:28:00 +08:00
Wesley Liddick 987588eaa1 Merge pull request #6358 from Wei-Shaw/fix/openai-spark-model-429-rate-limit
fix(openai): scope Spark quota 429 to model
2026-08-29 15:44:37 +08:00
shaw 804679d996 fix(openai): preserve model scope on stream failover 2026-08-29 15:25:10 +08:00
shaw 571d1e1d9b fix(openai): isolate websocket semantic rate limits 2026-08-29 11:14:54 +08:00
shaw 3c22e78af0 fix(openai): preserve Spark quota reset semantics 2026-08-29 11:08:01 +08:00
shaw 5d9c7abed5 fix(openai): scope Spark quota 429 to model 2026-08-29 09:57:08 +08:00
Wesley Liddick ac18c588c8 Merge PR #6165
修复上游倍率探测导致账号列表整页刷新的问题
2026-08-29 09:12:05 +08:00
Wesley Liddick b83284071a Merge PR #5348
fix(subscription): 周/月窗口展示的重置时间与实际推进锚点对齐
2026-08-29 09:10:07 +08:00
Wesley Liddick fd13c72e95 Merge PR #6330
feat(zhipu): support team GLM Coding Plan usage query
2026-08-29 09:08:42 +08:00
Wesley Liddick 3c2e9a43b9 Merge PR #5860
fix(antigravity): preserve mixed built-in chat tools
2026-08-29 09:07:27 +08:00
Wesley Liddick ca319d09f2 Merge PR #6280
fix(update): strip hyphenated pre-release/custom suffix in version comparison
2026-08-29 09:06:10 +08:00
Wesley Liddick 46598dd491 Merge PR #6306
fix(openai): emit pre-output SSE keepalive on the /v1/responses passthrough path
2026-08-29 09:04:46 +08:00
Wesley Liddick 3045b3ade1 Merge PR #6316
fix(openai): honor Claude Code session id for messages sticky routing
2026-08-29 09:03:14 +08:00
Wesley Liddick 604360d1cc Merge PR #6328
fix(frontend): 批量编辑显式提交 codex_fingerprint_mode=off,修复无法关闭指纹收敛
2026-08-29 09:02:25 +08:00
Wesley Liddick 25dc5742ea Merge PR #6332
fix(monitor): 配额抓取在 singleflight 执行体内重查缓存,消除重复上游查询
2026-08-29 09:01:15 +08:00
Wesley Liddick 1136e290fd Merge PR #6334
fix(payment): show selected currency in recharge rate
2026-08-29 09:01:06 +08:00
Wesley Liddick 6506c0ea68 Merge PR #6343
fix(gateway): preserve Anthropic tool arguments
2026-08-29 09:00:56 +08:00
Wesley Liddick 1323d1645d Merge PR #6346
fix(keys): preserve Claude attribution headers
2026-08-29 09:00:46 +08:00
Wesley Liddick f1d845c630 fix(grok): honor prompt cache key for side calls
fix(grok): honor client prompt_cache_key over X-Grok-Conv-Id so grok-build side-calls share the main-turn cache
2026-08-29 09:00:06 +08:00
RayWangX 5688bcba91 fix(openai): honor Claude Code session for messages sticky routing 2026-08-29 08:31:15 +08:00
Jarl 1a61eb7151 test(usage): update compaction API contracts 2026-08-28 23:32:11 +08:00
Jarl 1cc6999ada feat(usage): track native compaction requests 2026-08-28 23:32:11 +08:00
风起 88cb79d8b3 fix(grok): honor client prompt_cache_key over X-Grok-Conv-Id for cache identity
grok-build CLI recap-style side-calls (turn-summary / title-refresh) send
a fresh per-call X-Grok-Conv-Id label but set body prompt_cache_key to
the parent session id, so a direct xAI connection shares the main turn's
server-side prompt cache prefix.

sub2api's explicitGrokCacheSeed preferred X-Grok-Conv-Id over the body
key, which fragmented the cache identity on every side-call and forced a
full-price replay of the whole conversation (~300K+ tokens each, ~2.3%
cache hit rate observed over 44 side-calls / 4.9M input tokens in 7
days — about a third of all grok-4.6 input).

Priority is now: CC session > generic session headers > body
prompt_cache_key > X-Grok-Conv-Id > explicitKey > previous_response_id.
IDE header-stability tests updated; new regression test locks in the
side-call/parent key-sharing contract.
2026-08-28 21:56:22 +08:00
Ryan ChouandClaude Opus 5 32ad1dcdce fix(subscription): 周/月窗口展示的重置时间与实际推进锚点对齐
订阅首窗初始化在开通日零点时,automaticWindowStartAt 会把实际推进锚点修正为
StartsAt,但展示层(calculateProgress / WeeklyResetTime / MonthlyResetTime)
仍按窗口起点+周期计算,导致仪表盘显示的重置时间早于实际重置时间(差值 =
StartsAt 的时分秒)。用户在显示的重置时间过后请求仍会收到 429
WEEKLY_LIMIT_EXCEEDED。

抽出 windowResetAnchor 供推进与展示共用,保证两者恒一致。非 legacy 锚点
(手动重置或已推进过的窗口)保持权威,不做修正。

日窗口不在本 PR 范围内:#5380 已把日额度改为按日历日对齐(DailyResetTime =
窗口起点所在日的次日 0 点),与周/月的期限对齐滚动窗口语义不同,不共用本修正。
附一个守护用例钉死这条边界。

Fixes #5348

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-28 21:46:44 +08:00
wucm667 c037766040 fix(keys): preserve Claude attribution headers 2026-08-28 20:42:58 +08:00
wucm667 da10822d7d fix(gateway): preserve Anthropic tool arguments 2026-08-28 19:08:26 +08:00
wucm667 5345881b1d Merge remote-tracking branch 'upstream/main' into fix/issue-5843-antigravity-mixed-tools
# Conflicts:
#	backend/internal/repository/group_usage_rollup_trigger_integration_test.go
2026-08-28 17:36:43 +08:00
wucm667 c5ff640dff test(repository): pin rollup trigger session timezone 2026-08-28 17:34:42 +08:00
wucm667 02eee39ddf fix(payment): show selected currency in recharge rate 2026-08-28 16:54:33 +08:00
li e652f6e20a fix(monitor): 配额抓取在 singleflight 执行体内重查缓存,消除重复上游查询
ChannelMonitorQuotaFetcher.Fetch 顶部的 cachedSnapshot 与随后的 flight.DoChan
之间存在一个窗口:goroutine A 判定缓存未命中之后、真正入队之前,goroutine B 的
flight 可能已经跑完、写好缓存,并且它的 singleflight key 也已被摘掉。A 于是不会
并入 B 的那次飞行,而是另起一个新 flight,对同一账号再打一次上游——正是
singleflight 想要消除的重复查询。

多个监控可以关联同一账号,interval 最小 15s,国产配额服务自身无缓存,
这条路径的全部意义就是防止打爆上游配额端点(见文件头注释)。

修复:把 flight 执行体提为具名方法 fetchShared,开头重查一次缓存。
这次重查必然命中,所以合并是确定的而非尽力而为:storeSnapshot 发生在执行体
返回之前,singleflight 删 key 发生在返回之后,因此「能新起一次飞行」必然蕴含
「上一次的快照已经可见」。

该窗口也是主线 CI 上 TestQuotaFetcher_ConcurrentFetchesShareSingleFlight
间歇性失败(expected: 1, actual: 2)的成因,本地 -count=900 复现 3 次(约 0.33%),
带修复后 -count=1700 零失败。

新增两条确定性用例:既有并发用例只能以约 1/300 的概率撞上该窗口,故直接钉住
窗口本身——fetchShared 就是 flight 的执行体,直接调用它等价于「已越过顶层缓存
判定、拿到属于自己的那次飞行」这一状态。另一条守住重查不得退化成无条件短路:
缓存过期后必须照常回源。
2026-08-28 16:49:49 +08:00
wall-wxk c4e46c3be8 feat(zhipu): support team GLM Coding Plan usage query
Team GLM Coding Plan accounts fail the quota probe with
"当前用户不存在coding plan" because the personal-plan query path
does not carry team context.

- quota probe: when credentials contain zhipu_organization, switch the
  Zhipu quota endpoint to the team variant (?type=2 + bigmodel-organization
  / bigmodel-project headers); personal plans keep the existing path
- account create/edit modals: optional organization/project ID inputs for
  zhipu coding-plan accounts, persisted in credentials; edit modal
  backfills them and clears back to personal plan when emptied
- add a click-to-open tooltip tutorial (zh/en) explaining how to grab the
  IDs from the bigmodel.cn web console via the /api/biz/v1/organization
  network request, with a sample URL mapping org-/proj_ segments to fields
2026-08-28 16:36:30 +08:00
li 0756c98102 fix(frontend): 批量编辑显式提交 codex_fingerprint_mode=off,修复无法关闭指纹收敛
批量编辑选「关闭(默认)」时,buildUpdatePayload 先 ensureExtra() 建出 extra
再 delete 掉唯一的键,payload 退化成 {account_ids, extra:{}},被后端
len(req.Extra) > 0 判为空更新,直接 400 "No updates provided";device/session/full
则正常。即便后端放行空 extra 也无济于事:批量更新走 JSONB 顶层合并
(extra = COALESCE(extra,'{}') || payload),删掉 payload 里的键只表示
「本次不更新该键」,清不掉账号上已有的 device/session/full。

根因是把 Create/Edit 的写法直接搬到了 Bulk:那两个表单提交完整 extra 对象、
后端 SetExtra 整体覆盖,删键确实等于清除;批量接口只合并增量键,两种持久化
语义不能共用同一套写法。

改为 off 也显式落键,与本函数里其它「关闭/清除」字段一致——codex_cli_only
直接落 false(EditAccountModal 相邻注释已写明「关闭时显式写 false,避免 extra
为空被后端忽略导致旧值无法清除」)、load_factor 落 0、proxy_id 落 0。

读取侧完全等价,不改变 #5610 定下的语义:codexFingerprintModeFromExtra 对
空值/非法值走 default 回落 off,对 "off" 命中同一分支;
ShouldEnsureCodexFingerprintSeedForExtraUpdates 也只在 device/session/full
时要种子,显式 off 不会触发种子创建。

顺带给「编辑该项」勾选框补 id(同区域 Select 早有 data-testid,兄弟开关
codex_cli_only 也有 id),否则新增用例无法定位该控件。

新增 3 条用例:主复现(默认 off + 勾选编辑 ⇒ 提交 codex_fingerprint_mode=off,
并断言 extra 非空以钉死 400 那个形状)、显式 opt-in 仍原样提交、未勾选时不写入该键。

Fixes #6327
2026-08-28 16:20:14 +08:00
Wesley Liddick 7b693ae429 Merge pull request #6188 from JialinLiu-codedance/feat/usage-requested-reasoning-effort
feat(usage): 使用记录展示映射前的推理强度
2026-08-28 16:18:50 +08:00
Wesley Liddick d4754c211a Merge pull request #6291 from Whisper-stark/fix/grok-codex-responses
fix(grok): sanitize Codex Responses requests
2026-08-28 15:58:39 +08:00
Wesley Liddick 423f895755 Merge pull request #6227 from OG-Wang/codex/promo-code-oauth-signup
fix(auth): pass registration promo codes to OAuth signup
2026-08-28 15:58:09 +08:00
Wesley Liddick c8a6e93f33 Merge pull request #6310 from fengshao1227/fix/nonstreaming-terminal-failure-failover-v2
fix(openai): 非流式路径按流式同一套判定处理 HTTP 200 终止失败事件
2026-08-28 15:57:39 +08:00
Wesley Liddick 446042e518 Merge pull request #6309 from fengshao1227/fix/easypay-resolve-relative-payurl-qrcode
fix(payment): EasyPay 补全 mapi.php 回传的相对 payurl/qrcode
2026-08-28 15:57:10 +08:00
Wesley Liddick 1e6926cb9c Merge pull request #6312 from fengshao1227/fix/openai-ws-client-close-not-account-failure
fix(openai): 入站 WS 的客户端正常关闭与断开不再计为账号故障
2026-08-28 15:56:34 +08:00
251392463 b4b5371640 fix(grok): preserve Responses vision tool output images 2026-08-28 15:38:04 +08:00
Wesley Liddick e866ff6ec4 Merge pull request #6303 from akihitohyh/feat/per-user-public-group-access
feat(admin): 支持为单个用户限制可访问的公开分组
2026-08-28 13:19:11 +08:00
li c83dced4ba fix(openai): 入站 WS 的客户端正常关闭与断开不再计为账号故障
ingress WebSocket 收尾处只把 *service.OpenAIWSClientCloseError 且状态码为 1000
的情况认作正常关闭,其余全部落到 shouldReportOpenAIWSProxyAccountFailure ——
而它只排除 model-switch 与 session-preempted 两种。两种完全正常的结束因此被
误判为上游/账号故障:

- 客户端干净关闭:coder/websocket 直接回裸 coderws.CloseError{Code: 1000},
  ReadOpenAIWSClientMessage 把 conn.Read 的错误原样返回,没有任何一层把它包成
  *OpenAIWSClientCloseError,所以 errors.As 对该类型必然为假。
- 客户端中途断开:ReadOpenAIWSClientMessage 在 controlCtx.Done() 分支用
  StatusGoingAway(1001) 收尾并以 context.Canceled 为 cause,「只认 1000」匹配不到。

两者都会走到 ReportOpenAIAccountScheduleResult(success=false),进而喂给
ObserveOpenAIAPIKeyHealthFailure 与 scheduler.ReportResult,累积到阈值即把上游
账号熔断出调度池——客户端按一次 Ctrl-C 就在扣上游账号的健康分。

同文件的 summarizeWSCloseErrorForLog 一直用 coderws.CloseStatus 读关闭码,就在
该判断之后 12 行被调用,于是同一条 WARN 会打印
close_status=1000(StatusNormalClosure) 的同时把账号记为故障。

失败归因的政策仓库早有明文:同包 failover_loop.go 的 failoverClientGone 写着
「客户端断开后 failover 必须静默终止……并被误报成账号耗尽」。本次把 WS ingress
补齐到同一条规矩。

判定收窄:只豁免 1000 与 context.Canceled。1001 不整类放行——网关也会因自身原因
用 GoingAway 收场,客户端取消那一支已由 context.Canceled 覆盖;
context.DeadlineExceeded 同样不放行,空闲超时那条本就被包成 1000 走前一个判据,
其余 deadline 是真实停滞。

Fixes #6105
2026-08-28 13:17:43 +08:00
Wesley Liddick d674a04f2a Merge branch 'main' into feat/per-user-public-group-access 2026-08-28 13:08:41 +08:00
Wesley Liddick 9b61c1bdd9 Merge pull request #6263 from zenor0/fix/openai-reset-usage-sync
fix(openai): refresh usage after quota reset
2026-08-28 13:08:33 +08:00
Wesley Liddick add86cc3bc Merge branch 'main' into fix/openai-reset-usage-sync 2026-08-28 12:57:48 +08:00
Wesley Liddick dc332d141c Merge pull request #6260 from feeeei/fix/anthropic
fix(gateway): Anthropic/Bedrock 传输层错误转 failover + 持久故障临时摘除账号
2026-08-28 12:57:41 +08:00
Wesley Liddick 59bb131df4 Merge branch 'main' into fix/anthropic 2026-08-28 12:47:15 +08:00
Wesley Liddick 443537daae Merge pull request #6293 from specialpointcentral/fix/ws-v2-passthrough-cyber
fix(openai): detect cyber policy in ws v2 passthrough
2026-08-28 12:47:07 +08:00
Wesley Liddick b588c0bc42 Merge branch 'main' into fix/ws-v2-passthrough-cyber 2026-08-28 12:36:22 +08:00
Wesley Liddick 4e9c1d7c8b Merge pull request #6299 from william-drakemond/fix/openai-raw-stream-truncation
fix(openai): fail raw CC streams truncated before any terminal chunk
2026-08-28 12:36:14 +08:00
Wesley Liddick a0b313018a Merge branch 'main' into fix/openai-raw-stream-truncation 2026-08-28 12:26:01 +08:00
Wesley Liddick fafc4d288d Merge pull request #6278 from kk-418/fix/grok46-xhigh
fix(grok): 为 Grok 4.6 广告 xhigh 推理档位
2026-08-28 12:25:52 +08:00
Wesley Liddick de084cdfc2 Merge branch 'main' into fix/grok46-xhigh 2026-08-28 12:15:53 +08:00
Wesley Liddick 7eed2d3b7d Merge pull request #5623 from fengshao1227/fix/responses-synthesized-created-at
fix(responses): 网关合成的 Responses 对象补齐 created_at,修复严格客户端反序列化失败
2026-08-28 12:15:45 +08:00
Wesley Liddick f7dca22ea5 Merge branch 'main' into fix/responses-synthesized-created-at 2026-08-28 12:05:38 +08:00
Wesley Liddick 5eb8628ff2 Merge pull request #5620 from fengshao1227/fix/anthropic-chat-bridge-reasoning-passback
fix(apicompat): Anthropic→Chat 桥回传工具调用的 thinking,修复 DeepSeek 多轮 400
2026-08-28 12:05:31 +08:00
Wesley Liddick 002aaaa3d0 Merge branch 'main' into fix/anthropic-chat-bridge-reasoning-passback 2026-08-28 11:55:10 +08:00
Wesley Liddick 0f6ad105fe Merge pull request #6246 from wucm667/fix/issue-6240-preserve-multimodal-tool-output
fix(openai): preserve multimodal client tool outputs
2026-08-28 11:55:02 +08:00
Wesley Liddick 28fa458dc7 Merge branch 'main' into fix/issue-6240-preserve-multimodal-tool-output 2026-08-28 11:37:40 +08:00
Wesley Liddick eca8d6b9ab Merge pull request #6200 from xuhaihan/fix/openai-reasoning-replay
fix(openai): normalize cross-provider reasoning replay
2026-08-28 11:37:33 +08:00
li 81ac8ccd64 fix(openai): 非流式路径按流式同一套判定处理 HTTP 200 终止失败事件
stream=false 时上游仍可能回 SSE(其他 sub2api 实例、部分 OpenAI 兼容上游),
容量/限流错误经 HTTP 200 的 response.failed / error 终止事件回传。
handleSSEToJSON 与 handlePassthroughSSEToJSON 把所有终止事件塞进固定 502
(writeOpenAINonStreamingProtocolError),而几百行外的流式读取器对同一帧走
openAIStreamFailedEventShouldFailover / openAIStreamErrorEventShouldFailover
判定并返回 UpstreamFailoverError。

同一个上游、同一个事件,仅因请求上的 stream 标志而结果相反:流式换号,
非流式把上游原文直接抛给客户端,池里还有可调度账号也不换。

新增 nonStreamingTerminalFailureFailover,在两条非流式路径上复用流式的同一套
分类,并按 terminalType 分派——裸 error 帧走更保守的那个(仅正向识别为瞬时才
换号),response.failed 走完整的那个,与流式读取器逐字一致。

安全前提:响应体已被 ReadUpstreamResponseBody 完整缓冲,判定发生在写出任何
语义字节之前。能否真正换号仍由 handler 的 openAIForwardMayFailover 用
keepalive 调整后的写出量仲裁(#3887),service 侧只额外拒绝 IsResponseCommitted
与 account 为 nil 的场景,不重复实现心跳判定,避免与 handler 口径分叉。

行为变更:未被分类为不可重试的泛化 response.failed 在非流式路径上由「回写 502」
变为「换号」——这正是与流式对齐的结果。既有用例
TestHandleSSEToJSON_ResponseFailedReturnsProtocolError 传的是 nil account,
无账号则无可换对象,故其断言原样保留,仅更名为 ...WithoutAccountReturnsProtocolError
并注明原因;带真实账号的同一报文由新增用例钉死。

Fixes #5281
2026-08-28 11:36:08 +08:00
Wesley Liddick d24de611f8 Merge branch 'main' into fix/openai-reasoning-replay 2026-08-28 11:27:39 +08:00
Wesley Liddick f83fe6435b Merge pull request #6277 from TYzzt/fix/apikey-namespace-roundtrip
fix(openai): preserve declared API-key namespace calls
2026-08-28 11:27:31 +08:00
Wesley Liddick ee2c8b97bb Merge branch 'main' into fix/apikey-namespace-roundtrip 2026-08-28 11:16:48 +08:00
Wesley Liddick d36f4dd6c6 Merge pull request #6255 from feeeei/main
fix(openai): 避免 pool 两跳重复计算 system 提示词
2026-08-28 11:16:40 +08:00
li 1e8745c887 fix(payment): EasyPay 补全 mapi.php 回传的相对 payurl/qrcode
createAPIPayment 把上游 mapi.php 返回的 payurl/payurl2/qrcode 原样落库。
EasyPay 兼容上游对该字段并不统一:部分实现回站点根相对路径(如
`/api/pay/toapp/<order>`)而非完整地址。而链路上没有任何一层修复它——
sanitizeCreatePaymentResponseDetails 只剥 NUL 字节就写进 pay_url/qr_code,
前端 PaymentQRCodeView.renderQR 又把 qr_code 直接喂给 QRCode.toCanvas。
结果二维码的载荷是一段裸路径:微信扫出来是文本,pay_url 则落到网关自己的
域名下 404,用户无法进入上游收银台。

同文件的 createRedirectPayment 本就自行拼绝对地址(apiBase + "/submit.php?"),
只有 mapi.php 这一支透传;Alipay provider 也早已写明「Setting it as QRCode
would let the frontend render an unscannable image」并拒绝产出不可扫的值。
本提交让 EasyPay 与这两处对齐:按实例配置的 apiBase 解析上游回传的引用。

仅补全以 "/" 开头的引用。带 scheme 的一律原样返回,覆盖绝对 https:// 收银台
地址与 weixin:// / wxp:// / alipays:// 等 app 深链;无前导斜杠的引用同样不动,
因为二维码载荷常是不透明 token,把 "OrderToken123" 改写成
"<apiBase>/OrderToken123" 会毁掉一个当前可用的载荷,比本 bug 更糟。

payurl2 优先于 payurl 的选择语义保持不变。

Fixes #6292
2026-08-28 11:09:34 +08:00
Wesley Liddick 96e9ab8662 Merge branch 'main' into main 2026-08-28 11:06:29 +08:00
Wesley Liddick 0eddfe1cfa Merge pull request #5395 from ryanchou1994/fix/openai-images-capability-loss-cooldown
fix(openai): 图像能力丢失时冷却 image 调度,不再反复选中坏号
2026-08-28 11:06:22 +08:00
Wesley Liddick acd2f09ddd Merge branch 'main' into fix/openai-images-capability-loss-cooldown 2026-08-28 10:54:47 +08:00
Wesley Liddick 5929cdd38c Merge pull request #6270 from fengshao1227/fix/openai-images-tool-cooldown-not-from-text-heuristic
fix(openai): 图片工具冷却不再由「模型回文字」这一请求级判据触发
2026-08-28 10:54:33 +08:00
HuntercodeT 60756c0ca4 fix(openai): emit pre-output SSE keepalive on the /v1/responses passthrough path
The passthrough streaming loop buffers leading events in `pendingLines` until
the first visible output, so a client receives **zero bytes** — not even the
HTTP response headers, since gin's ResponseWriter only commits them on the
first write — for as long as the upstream takes to produce its first visible
event.

The Forward path already recognises this exact hazard and solves it with a
downstream keepalive:

    // Track downstream writes separately from upstream reads: pre-output
    // failover can buffer response.created / response.in_progress, so
    // keepalive must be based on downstream idle time.

The passthrough path has the same buffering but never starts a keepalive.
Reasoning models routinely think for several minutes before emitting a visible
event, so intermediate proxies close the connection on idle timeout.

Production evidence from one deployment (12h window, gpt-5.6 family):

  * 44 `/v1/responses` requests produced their first visible output only after
    600–900 s, each carrying 30k–50k output tokens — the upstream had finished
    the work.
  * Every one of them was killed by an intermediate nginx `proxy_read_timeout`
    (600 s) and returned 504. The user received nothing.
  * Cutting early does not even save resources: the code deliberately keeps
    draining the upstream for usage after the client disconnects.

Fix: start the existing keepalive helper for the passthrough stream as well.

* `StartOpenAICompactSSEKeepalive` keeps its compact-marker gate; the gate-free
  body is extracted as `startOpenAISSEKeepalive` for callers that already know
  they are in an SSE context.
* The passthrough loop starts it when `gateway.stream_keepalive_interval > 0`
  and stops it immediately before its first real write, so the ResponseWriter
  is never shared.

Pre-output failover is unaffected: keepalive bytes are already excluded by
`OpenAICompactKeepaliveAdjustedWrittenSize`, which `openAIStreamClientOutputStarted`
on this very path already consults (#3887). No pendingLines and no
account-specific headers are ever emitted by a keepalive.

Behaviour is unchanged when `gateway.stream_keepalive_interval` is 0.
2026-08-28 10:19:43 +08:00
akihitohyhandClaude Opus 5 b56c61eccd feat(admin): let admins restrict which public groups a user may access
Public groups have always been bindable by every user: CanBindGroup returned
true for any non-exclusive group, and user_allowed_groups only ever carried the
exclusive groups an admin had granted. Admins had no way to hand a single user
a subset of the public groups short of converting a group to exclusive, which
changes it for everyone already using it.

A user now carries restrict_public_groups. While it is false, which is the
default and what every existing row migrates to, nothing changes: every public
group stays bindable. Once an admin turns it on for a user, that user's public
groups are narrowed to the ones listed in user_allowed_groups, the same table
that already gates exclusive groups.

The flag is an administrative control, so it rides on the admin user DTO only
and leaves the shape of the end-user endpoints alone.

The model plaza filter honours the flag as well, so a restricted user is not
shown groups they would be refused when binding a key. Anonymous visitors have
no user record and keep the previous view.

Enforcement rides on the existing CanBindGroup choke point, so it covers key
creation, key updates, and per-request authorization together. An API key bound
to a group that is later withdrawn stops working at request time rather than
lingering as a key that can be listed but not used.

The admin dialog gains a toggle over the public group list. Turning it off
re-checks every public group, so an admin cannot save a list that reads as
restrictive while the restriction itself is disabled.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 02:40:17 +08:00
Qi HU f4e3eb1c5a fix(openai): detect cyber policy in ws v2 passthrough 2026-08-27 21:50:53 +08:00
Whisper-stark fd872550d5 fix(grok): handle typed invalid tool unions 2026-08-27 21:03:23 +08:00
Whisper-stark f4820c00dc fix(grok): simplify invalid tool union roots 2026-08-27 19:24:50 +08:00
Whisper-stark de6ef7134d fix(grok): sanitize Codex Responses requests 2026-08-27 19:24:50 +08:00
Ryan ChouandClaude Opus 5 e6ea7b9af0 fix(openai): 图像能力丢失时冷却 image 调度,不再反复选中坏号
OAuth 账号被上游收走 image_generation 能力后,/v1/images/* 稳定返回同一句 400
(Tool choice 'image_generation' not found in 'tools' parameter),但网关不做
任何处置,坏号持续被排程选中(报告者实测连续 17 次同 body 400、窗口内 0 成功、
持续 3.5 小时,直到人工停调度)。

RateLimitService 的 400 分支是白名单式,只认 organization has been disabled /
credit balance / identity verification is required 三句,其余 400 明确不处理;
唯一能兜任意 400 的 tryTempUnschedulable 又要求账号开启 temp-unschedulable
开关,OAuth 账号默认没开。两条路都不命中,shouldDisable 恒为 false,也没有任何
model 作用域冷却。

修复收窄到 sub2api 自建 body 的 images 管线:buildOpenAIImagesResponsesRequest
产出的 body 必定同时带 tool_choice 与匹配的 image_generation tool,这条路上出现
该 400 只可能是上游收了账号能力。forwardOpenAIImagesOAuth 在 upstreamCtx 上打
自建标记(覆盖其下游全部三个错误分支),共用错误入口读到标记才把该 400 判为能力
丢失,对 openai:image_generation 作用域冷却 30 分钟(对齐 upstreamModelNotFound
Cooldown),不禁用整颗账号——账号对文本请求依然健康。

未采用 issue 建议①的全局签名匹配:handleOpenAIAccountUpstreamError 同时服务
passthrough 路径,客户端可自带「tool_choice 指 image_generation 但 tools 不带」
的请求触发一字不差的同一句 400,全局匹配等于任何 API 用户一句话即可禁用健康
账号。附 5 个测试,其中防毒杀用例在撤掉该闸门后会实测失败。建议②(连续 N 次
400 熔断)涉及跨请求状态与配置项,另议。

Fixes #4466

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-27 16:36:01 +08:00
WUJI-Labs 9e7aff59df fix(update): strip hyphenated suffix from version string during comparison
When running a custom build or pre-release version (e.g. v0.1.183-custom),
parseVersion failed to parse the patch segment because of the non-numeric suffix,
causing compareVersions to incorrectly report an available update.

This commit strips any hyphenated suffix before splitting SemVer components.

Signed-off-by: WUJI-Labs <zoujincool@gmail.com>
2026-08-27 15:54:22 +08:00
kk 00efee4303 🐛 fix(grok): 为 Grok 4.6 广告 xhigh 推理档位
Grok Build 的 /v1/models 原先给所有可配 reasoning 的 Grok 模型写死三档,
导致 grok-4.6 无法在 /model 里选出官方支持的 xhigh。转发与 Codex catalog
已按模型判断,现与同一条件对齐。
2026-08-27 15:50:20 +08:00
zhaotao1 00b7c855cc fix(openai): preserve declared API-key namespace calls 2026-08-27 15:22:43 +08:00
li 4a02e54171 Merge remote-tracking branch 'origin/main' into fix/anthropic-chat-bridge-reasoning-passback 2026-08-27 13:40:29 +08:00
li 9192426d2e merge: 合入 origin/main,与新增的 service_tier 字段并存
主线在 ResponsesResponse 与两处流式事件上新增了 service_tier;本分支新增的
created_at 与之并列,无语义冲突。同时跟进 ChatCompletionsResponseToResponses
新增的 functionTools 形参。
2026-08-27 13:40:07 +08:00
li d077002eba fix(openai): 图片工具冷却不再由「模型回文字」这一请求级判据触发
v0.1.181 起,/v1/images/generations 只要上游回了文字而没回图,就给账号写 30 分钟
openai:image_generation 模型级冷却。该判据由 openAIImagesTextFallbackErrorForText
从模型的自然语言输出**推断**而来,只说明这个 prompt 这一轮模型选择了说话——
仓库自己的既有用例 fixture 就是一句「Here's a polished image prompt for your request.」。

更糟的是同一个错误被判为可重试(502 → IsOpenAIImagesRetryableUpstreamError)并驱动
newOpenAIAccountFailoverError:一次闲聊回复会被逐个账号重试,沿途把每个账号都冷却掉,
表现为「开启后没多久账号就全部被限制」。

改为只在上游 error 帧点名该状态时才写冷却;网关自行推断的判据打上
SynthesizedFromModelText 标记并跳过。换号行为一字未动。

依据是仓库既有政策(alpha/search 路径原话):工具端点的单次失败
「仍允许本次请求换号,但不修改任何账号状态」。

Fixes #6171
2026-08-27 13:36:50 +08:00
zenor0 5f09442fce fix(openai): refresh usage after quota reset 2026-08-27 10:09:42 +08:00
feeeei 44003d7f6b fix(gateway): Anthropic/Bedrock 传输层错误转 failover + 持久故障临时摘除账号
上游 Do/DoWithTLS 返回非 HTTP 错误(代理/DNS/TCP/TLS)时,Anthropic 五条转发路径
(forward/apikey 透传/Bedrock/ForwardAsResponses/ForwardAsChatCompletions)原先
直接向客户端写 502 且不换号,单账号网络故障期间该账号仍持续被调度,请求全量失败。

对齐 OpenAI 侧 handleOpenAIUpstreamTransportError 的既有语义:
- 传输层错误统一返回 *UpstreamFailoverError(502),由 handler failover 循环换号,
  耗尽后才向客户端渲染错误;service 不再写响应
- 持久性故障(connection refused/no route/DNS not found/代理认证失败)额外临时
  摘除账号 10 分钟(仅写库,Anthropic 调度以持久化状态为准,无内存快路径)
- context.Canceled 保持原样返回:不换号也不摘账号
- 分类器改为平台无关命名(classifyUpstreamTransportError)供两侧共用
- Ops 错误事件保留各路径原有字段(UpstreamURL/DurationMs/Passthrough)
2026-08-27 09:56:41 +08:00
feeeei d881bfc0de fix(openai): 避免 pool 两跳重复计算 system 提示词 2026-08-26 19:55:49 +08:00
wucm667 6ff771d3d3 feat(openai): configure image tool cooldown 2026-08-26 19:07:39 +08:00
Wesley Liddick efb46db0a9 Merge pull request #5926 from baryon/contrib/routed-codex-model-catalog
feat(gateway): generate complete routed Codex model catalogs
2026-08-26 15:49:38 +08:00
shaw 8ba81615e0 merge: resolve conflict with main (upstream endpoint observability)
Merge origin/main into contrib/routed-codex-model-catalog.

Single conflict in openai_gateway_forward.go: main added
ClearActualOpenAIUpstreamEndpoint + SetActualOpenAIUpstreamEndpoint
(commit 4795650d2) at the same insertion point where the PR added
filterOpenAIResponsesNoneReasoningEffortForAccount. Both changes
are independent — endpoint observability first, then body filtering.
2026-08-26 15:29:01 +08:00
wucm667 50ba146298 fix(openai): preserve multimodal client tool outputs 2026-08-26 15:04:17 +08:00
Wesley Liddick b9083fc7ad Merge pull request #6211 from Monika-shipship/codex/ws-v2-tool-id-regression
test(openai): 补充 OAuth Responses WS v2 历史 ID 清洗回归测试
2026-08-26 15:03:24 +08:00
Wesley Liddick 1bf76d26d7 Merge pull request #6229 from Wei-Shaw/fix/openai-upstream-endpoint-observability
fix(openai): 记录真实上游端点避免错误误报
2026-08-26 15:03:11 +08:00
Long Li 2abce65031 fix(codex): harden routed catalog capability sync 2026-08-26 12:46:31 +09:00
shaw 6ca1e15b0a chore: update sponsors 2026-08-26 09:58:16 +08:00
Long LiandCursor 195b219707 fix(codex): isolate API-key catalog cache and DeepSeek Codex defaults
Copy cached API-key manifests before group-specific mutation, use DeepSeek
model IDs for Codex fallbacks, omit unsupported config.toml effort, and
drop wildcard mapping keys from generated catalogs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-26 10:56:17 +09:00
shaw 66d664ff08 chore: update sponsors 2026-08-26 09:53:47 +08:00
shaw 4795650d21 fix(openai): record actual upstream endpoint on errors 2026-08-26 09:42:28 +08:00
Rick d522aed65e fix: preserve promo codes for OAuth signup 2026-08-26 08:58:22 +08:00
github-actions[bot] 7634e3c23b chore: sync VERSION to 0.1.183 [skip ci] 2026-08-25 13:53:14 +00:00
Monika d8694f03bc test(openai): cover WSv2 cleanup of stale native tool IDs 2026-08-25 21:45:24 +08:00
Wesley Liddick e8cb019fab Merge pull request #6078 from YogaSakti/fix/responses-custom-tool-call-id
fix(openai): keep restored tool-call item IDs typed
2026-08-25 21:32:34 +08:00
Wesley Liddick 7a7bd3729c Merge pull request #6166 from jyking/fix/email-bind-alias-dedup
fix(auth): 邮箱换绑增加别名与并发守卫
2026-08-25 21:31:39 +08:00
Wesley Liddick 49ad7021d7 Merge pull request #6164 from jimbirthday/fix/openai-sticky-capacity-spillover
fix(openai): preserve sticky binding on capacity spillover
2026-08-25 21:28:38 +08:00
Wesley Liddick 4952b919a4 Merge pull request #6204 from wucm667/fix/issue-6203-kimi-concurrency-403
fix(kimi): keep concurrency-limit 403 recoverable
2026-08-25 21:28:08 +08:00
Wesley Liddick fa0685a4a3 Merge pull request #6175 from wucm667/feat/issue-6172-antigravity-token-clamp
fix(antigravity): clamp compatible token limits
2026-08-25 21:27:57 +08:00
Wesley Liddick 8040428716 Merge pull request #6201 from xuhaihan/fix/codex-session-id-affinity
fix(openai): honor Codex session-id header
2026-08-25 21:27:24 +08:00
Wesley Liddick 4ff136cfd9 Merge pull request #6193 from antTing/codex/fix-channel-monitor-v2-nullif
fix(channel-monitor-v2): fix composite platform aggregation SQL
2026-08-25 21:26:32 +08:00
Wesley Liddick 832cf4df65 Merge pull request #6208 from Wei-Shaw/fix/openai-oauth-429-quota-scheduling
fix(openai): pause OAuth accounts on exhausted quota 429
2026-08-25 20:41:41 +08:00
Long LiandCursor 77b6c5bb0c merge: sync contrib/routed-codex-model-catalog with upstream v0.1.182
Keep catalog membership on schedulable accounts and intersect advertised
capabilities across all active group members that map an alias. Preserve
upstream plugin, service-tier, and models-list body-limit changes.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:28:57 +09:00
shaw f1aadd48d5 fix(openai): pause OAuth accounts on exhausted quota 429 2026-08-25 20:25:22 +08:00
Long LiandCursor 5934981e22 test(codex): cover unschedulable catalog capability intersection
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:02:47 +09:00
Long LiandCursor db01fb98f8 fix(codex): keep catalog capabilities stable when accounts are unschedulable
Intersect advertised alias capabilities across all active group members
that map the model, not only currently schedulable accounts, so rate-limits
cannot widen image input or context windows.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-25 21:01:58 +09:00
wucm667 3802268e2d fix(kimi): keep concurrency 403 recoverable 2026-08-25 19:40:39 +08:00
hank 8e60d57475 fix(openai): honor Codex session-id header 2026-08-25 17:08:27 +08:00
hank 32064d39e7 fix(openai): normalize cross-provider reasoning replay 2026-08-25 17:04:38 +08:00
hank 92a550973a fix(accounts): reauthorize OpenAI with refresh token 2026-08-25 16:52:29 +08:00
暴走的小笼包 b20f29d115 fix(channel-monitor-v2): fix composite platform aggregation SQL 2026-08-25 16:36:04 +08:00
刘佳林 a8cfe746b3 test(usage): cover user and admin reasoning effort page display
Mount the real usage tables and assert users only see the requested
effort, while admins can reveal mapped effort via column settings.
2026-08-25 15:49:04 +08:00
刘佳林 5705f4a4aa fix(usage): hide mapped reasoning effort from users
Users only see the reasoning effort they requested. Admins still see
the requested value plus the forwarded mapped value, matching the
model column's requested vs upstream split.
2026-08-25 15:36:22 +08:00
刘佳林 11ada80d58 feat(usage): 使用记录展示映射前的推理强度
用量行新增 requested_reasoning_effort,记录分组策略与模型族改写前的客户端请求值;推理强度列与模型列一致,主行显示请求值,有映射时用 ↳ 展示实际转发值。
2026-08-25 15:10:52 +08:00
wucm667 1a9898a6a4 fix(antigravity): clamp compatible token limits 2026-08-25 13:13:08 +08:00
github-actions[bot] aa2c4e8d13 chore: sync VERSION to 0.1.182 [skip ci] 2026-08-25 04:47:52 +00:00
Wesley Liddick 5a7d469622 Merge pull request #6157 from LiPu-jpg/fix/issue-6147-responses-lite-parallel
fix(openai): enforce Responses Lite parallel tool calls across account types
2026-08-25 12:31:32 +08:00
shaw 095b525360 fix(openai): pin Responses Lite parallel tool calls 2026-08-25 12:14:52 +08:00
Wesley Liddick 027d442f9b Merge pull request #6101 from jianjianai/fix/composite-group-channel-monitor-v2
fix(monitor-v2): 修复Composite分组错误事实未解析到真实账号平台导致渠道监控V2无效
2026-08-25 11:27:03 +08:00
Wesley Liddick 810d50a00f Merge pull request #6155 from HypoxanthineOvO/fix/composite-kimi-k3-routing
fix(composite): route Kimi Code K3 model IDs
2026-08-25 11:13:59 +08:00
Wesley Liddick 41d712be09 Merge pull request #5658 from william-drakemond/fix/opencode-go-usage-limit-reset
fix(openai): honor OpenCode Go usage reset durations
2026-08-25 11:05:15 +08:00
Wesley Liddick 3c714d8736 Merge pull request #6152 from ranxi2001/fix/payment-result-balance-refresh
fix(payment): refresh balance after fulfillment
2026-08-25 11:04:10 +08:00
Wesley Liddick 636d7debf9 Merge pull request #6132 from wucm667/fix/issue-6125-anthropic-cache-ttl
fix: prevent duplicate Anthropic cache TTL billing
2026-08-25 11:02:29 +08:00
Wesley Liddick 3dd717ab0d Merge pull request #5920 from wucm667/fix/issue-5884-antigravity-sonnet46
fix(antigravity): define Sonnet 4.5 to 4.6 compatibility routing
2026-08-25 11:01:34 +08:00
Wesley Liddick 1dc1b44268 Merge pull request #6149 from SipengXie2024/fix/oauth-image-verbatim-prompt
fix(openai): preserve OAuth image prompts verbatim
2026-08-25 11:01:20 +08:00
jyking 4ca86c52e3 fix(auth): 邮箱换绑增加别名与并发守卫 2026-08-25 10:40:02 +08:00
yardbirds0 3f1581b2d3 修复上游倍率探测导致账号列表整页刷新的问题 2026-08-25 10:34:46 +08:00
超级英雄吉姆 e55727d4c8 fix(openai): preserve sticky binding on capacity spillover 2026-08-25 10:27:48 +08:00
William DrakeandClaude Opus 5 b7ec3cdadf fix(openai): fail raw CC streams truncated before any terminal chunk
The raw Chat Completions passthrough returned a nil error whenever the upstream
body ended, so an upstream that cut the SSE stream mid-generation surfaced as
HTTP 200 with usage 0/0: the client kept a half-finished answer, the request was
not counted as an SLA failure, and ops saw nothing. Observed against
opencode.ai/zen/go (deepseek-v4-pro): 11 of 1346 streams in 24h recorded a first
token but no final usage, alongside a concurrent `connection reset by peer`
against the upstream edge.

Track whether the upstream ever emitted a terminal signal — [DONE], a usage
chunk, or a finish_reason — and treat a stream that ends without one as an
upstream failure. Matching only [DONE] would misfire on compatible upstreams
that EOF right after their last chunk and would drop billing for tokens that
were actually delivered, so all three count.

  - nothing written to the client yet: UpstreamFailoverError, retried on another
    account with no client-visible half stream
  - bytes already on the wire: typed openAIUpstreamStreamReadError, so the
    handler appends an SSE error frame and marks the request an SLA failure

Client cancel/disconnect keeps the previous semantics — there the upstream read
error is indistinguishable from a truncation, so partial usage is still billed
rather than blamed on the account.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-24 18:29:37 -07:00
shaw 3b7753a8e4 chore: update sponsors 2026-08-25 08:51:58 +08:00
Jiao Ziang d5e43ef7d1 fix(openai): normalize Lite requests in WS HTTP bridge 2026-08-25 03:46:46 +08:00
Jiao Ziang d6012b0b35 fix(openai): preserve numeric precision in Lite payloads 2026-08-25 01:05:28 +08:00
Jiao Ziang 53d76ad800 fix(openai): enforce Responses Lite tool call mode 2026-08-25 00:21:29 +08:00
HypoxanthineOvO 4347e5555b fix(composite): route Kimi Code K3 model IDs 2026-08-24 23:09:30 +08:00
SipengXie2024 329b92ef04 fix(openai): preserve OAuth image prompts verbatim 2026-08-24 14:37:48 +00:00
github-actions[bot] e2d9b823f6 chore: sync VERSION to 0.1.181 [skip ci] 2026-08-24 14:35:57 +00:00
ranxi2001 eb594eefc9 fix(payment): refresh balance after fulfillment 2026-08-24 22:30:24 +08:00
Wesley Liddick 3af5443b22 Merge pull request #6116 from wucm667/fix/issue-6110-gemini-tool-schema
fix(gemini): sanitize unsupported tool schema fields
2026-08-24 22:19:07 +08:00
Wesley Liddick 7ba3e1ac52 Merge pull request #6150 from Wei-Shaw/fix/grok-upstream-user-agent
fix(grok): use official CLI user agent
2026-08-24 22:18:02 +08:00
shaw 9fb260439f fix(grok): use official CLI user agent 2026-08-24 22:14:45 +08:00
Wesley Liddick 07931bbb18 Merge pull request #6143 from akihitohyh/fix/rejected-status-strip-all
fix(openai): clear the rejected input status for the whole item type
2026-08-24 22:01:14 +08:00
Wesley Liddick 2307aa5ca7 Merge pull request #6148 from 759502416/fix/responses-lite-parallel-tool-calls
fix(openai): keep parallel_tool_calls for Responses Lite additional_tools
2026-08-24 21:49:01 +08:00
759502416andClaude Opus 5 1563db3f82 fix(openai): keep parallel_tool_calls for Responses Lite additional_tools
normalizeOpenAIParallelToolCallsWithoutTools only looked at the top-level
"tools" array, but normalizeOpenAIResponsesLiteTools moves namespace tools
into an input item of type "additional_tools" and deletes the top-level key.
A Responses Lite request that carries tools therefore looks like it has none,
and the parallel_tool_calls:false that ensureOpenAIResponsesLiteParallelToolCalls
had just pinned gets deleted on the way out.

OpenAI then applies its default of true and rejects the request:

  400 unsupported_value: "X-OpenAI-Internal-Codex-Responses-Lite requires
  `parallel_tool_calls` to be false."

Note the field cannot simply be pinned to false unconditionally: without tools
OpenAI rejects it with "'parallel_tool_calls' is only allowed when 'tools' are
specified", so the two constraints have to be honoured together.

Reuse the same tool-detection standard the Lite path already uses by adding
openAIRequestBodyHasTools, the []byte counterpart of openAIResponsesLiteHasTools,
so both sides of the repo agree on what "has tools" means.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 21:15:39 +08:00
akihitohyhandClaude Opus 5 e440ac48c7 fix(openai): clear the rejected input status for the whole item type
The upstream names one offending index per response, but a replayed
conversation routinely carries dozens of items of the same type, each with a
status the upstream schema does not accept. Clearing a single index per round
trip needs one retry per item, so a conversation with more than
maxOpenAIResponsesRejectedFieldRetries such items exhausts the bounded budget
and the 400 reaches the client. Reported against tool_search_output items,
where the rejection surfaced as "Unknown parameter: 'input[60].status'".

Clear the status of every input item sharing the rejected item's type in the
same pass. Items of other types keep theirs: the rejection only proves that
the rejected item's type has no status field. When the rejected item carries
no type to match on, fall back to clearing the named index alone.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-24 17:03:44 +08:00
wucm667 71aa6e3574 fix(antigravity): preserve explicit Sonnet 4.5 routing 2026-08-24 16:07:27 +08:00
wucm667 99ec347eaf fix(antigravity): migrate legacy Sonnet tests to 4.6 2026-08-24 15:58:47 +08:00
github-actions[bot] 03e8ab4134 chore: sync VERSION to 0.1.180 [skip ci] 2026-08-24 07:30:34 +00:00
Wesley Liddick c40edb4070 Merge pull request #6139 from xz-dev/fix/configurable-model-list-read-limit
feat(gateway): configure model list read limit
2026-08-24 15:04:50 +08:00
Wesley Liddick 7bb9c0ed7d Merge pull request #6079 from okbexx/fix/codex-analytics-account-affinity
fix(openai): scope Codex identity to OAuth account
2026-08-24 14:54:05 +08:00
Wesley Liddick 5f43696a9a Merge pull request #6121 from creamtea47/codex/feat-openai-auto-reset-credit
feat: OpenAI 重置卡按用量阈值自动使用
2026-08-24 14:39:59 +08:00
Xiangzhe 847c0c4526 feat(gateway): configure model list read limit
Add gateway.models_list_read_max_bytes with the existing 8 MiB behavior as its default, and apply it consistently to generic, Codex, and Antigravity model-list reads.

Read one sentinel byte for Codex manifests so oversized responses return an explicit bounded upstream error instead of malformed JSON.
2026-08-24 14:20:18 +08:00
Wesley Liddick 4a02d80543 Merge pull request #6136 from alfadb/fix/flaky-tool-schema-alloc-guard
fix(openai): harden flaky alloc guard in tool schema sanitize test
2026-08-24 14:17:15 +08:00
Wesley Liddick bd17411d0e Merge pull request #6129 from alfadb/feature/openai-fast-service-tier
feat(openai): Fast 档位请求校验、响应透传与按上游实际档位计费
2026-08-24 14:16:47 +08:00
Wesley Liddick c4ae3550dd Merge pull request #6119 from feeeei/feat/go1.27.0
feat(go1.27.0): 升级 Go 1.27.0,默认启用 jsonv2 ,并同步 CI/Dockerfile
2026-08-24 14:11:03 +08:00
Wesley Liddick 2f43e72bb9 Merge pull request #6109 from feeeei/main
feat(model-plaza): 模型广场增加长上下文阶梯计价显示 & 分时段计价显示
2026-08-24 14:09:56 +08:00
Wesley Liddick b8651947c3 Merge pull request #6137 from yan9651688/codex/fix-cn-anthropic-usage-billing
fix(billing): normalize CN Anthropic usage tokens
2026-08-24 14:09:45 +08:00
NellPoi 96b160d9a0 fix: 修复重置工作流共享告警码检查 2026-08-24 13:46:19 +08:00
NellPoi 6f972145b7 feat: 支持 OpenAI 重置卡按用量阈值自动使用 2026-08-24 13:28:33 +08:00
yan9651688 695ebede70 fix(billing): normalize CN Anthropic usage tokens 2026-08-24 13:05:12 +08:00
alfadb 269a409241 fix(openai): harden flaky alloc guard in tool schema sanitize test 2026-08-24 12:37:53 +08:00
feeeei 73aabc861c build: 取消 gosec G703/G704 全局排除,生产代码逐点 nolint、测试文件按路径豁免;DEV_GUIDE 同步 golangci-lint v2.13 2026-08-24 12:21:06 +08:00
feeeei 3b81776429 fix(test): grok QueryQuota 用例排除后台 /v1/models 同步请求,消除请求计数竞态
QueryQuota 返回前经 scheduleGrokObservedModelsSync 异步拉取 GET /v1/models,
该请求是否早于 upstream.snapshot() 落到 mock 取决于调度时序,三个精确断言
请求数的用例约 0.5% 偶发多出一条。新增 quotaSnapshot 只返回配额探测链路的
请求,三处断言改用它。
2026-08-24 12:02:53 +08:00
feeeei cbe258fd12 build: 升级 Go 1.27.0,同步 CI/Dockerfile 并适配 jsonv2 与 golangci-lint v2.13
- go.mod 1.26.6 → 1.27.0;backend-ci/release/security-scan 的 go version 断言、
  三个 Dockerfile 的 golang 镜像、README 徽章与 DEV_GUIDE 同步
- golangci-lint-action v2.9 → v2.13(v2.9 由 go1.26 构建,拒绝 go.mod 1.27 目标);
  新规则按最小方式处理:排除 G703/G704 污点分析(网关按配置转发/写文件,
  与既有 G304 排除策略一致)、reflect.Ptr → reflect.Pointer、
  ResetQuota 恒返回错误的 SA4023 与 OIDC EC JWK 的 SA1019 加 nolint
- ent 生成代码按 Go 1.27 默认 jsonv2 引擎重新生成:json.RawMessage 字段
  生成为同类型别名 jsontext.Value(group.model_pricing / usage_cleanup_task.filters)
- x/net v0.56 在 go1.27 下包装标准库 HTTP/2:ConfigureTransports 经
  RegisterProtocol("http/2") 打开 Protocols.HTTP2 而不再写 TLSNextProto,
  ReadIdleTimeout/PingTimeout 建连时映射为 HTTP2Config.SendPingTimeout/PingTimeout;
  keepalive 测试改断言 Protocols.HTTP2(),并补真实 HTTP/2 协商用例
2026-08-24 12:02:53 +08:00
alfadb 1591477a3e test(apicompat): adapt ChatCompletionsResponseToResponses call to upstream functionTools signature 2026-08-24 11:58:01 +08:00
alfadb e457f0fa22 fix(openai): adapt service tier observation to upstream constraints
- cc_pipeline: observe Chat Completions chunks/bodies as untyped payloads
  (empty event type) so the upstream-echoed service_tier is trusted, matching
  the upstream constraint that only terminal events and untyped bodies report
  the actual processing tier.
- tests: add model field to terminal SSE frames (observation only triggers on
  model-bearing frames) and assert response.created tier echo is ignored.
2026-08-24 11:52:48 +08:00
alfadb c0c3e1cb47 fix(openai): wire local observer service tier in WS ingress; bound handler tests
- openai_ws_forwarder_ingress: resolve billing tier from the local
  upstreamResponseModelObserver (upstream echo first) instead of the raw
  request payload, matching the HTTP->WS bridge and WS v2 forwarder.
- openai_ws_http_bridge_test: add fast-alias + upstream default case
  proving the local observer's echoed tier wins.
- handler tests: keep only invalid service_tier -> 400 (short-circuits at
  validation); valid/omitted semantics covered by the pure service-level
  validation tests, avoiding real account selection in tests.
2026-08-24 11:52:48 +08:00
alfadb f06bf181d2 feat(openai): support Fast mode service_tier across responses/chat/WS paths
- Accept fast|priority (canonical priority), flex|auto|default|scale on
  /v1/responses and /v1/chat/completions; reject unknown/empty/non-string
  with HTTP 400; omitted and null stay compatible.
- Propagate service_tier through JSON/SSE, Responses<->Chat conversions,
  fallback paths and HTTP->upstream WebSocket bridge.
- Billing prefers the upstream terminal tier; the outbound (policy-
  transformed) tier is used only when upstream omits the field.
  Explicit upstream default bills Standard even when Fast was requested.
- Pricing: Fast premium 2x Standard for gpt-5.6-sol/terra/luna and
  gpt-5.4; 2.5x for gpt-5.5; channel FastMultiplier stays authoritative.
- Live verification (official Codex 0.149.0 + gateway, HTTP & WS):
  upstream ChatGPT backend may return terminal default even when the
  account catalog advertises priority; billing follows the actual tier.
2026-08-24 11:52:48 +08:00
Wesley Liddick 7075ae0d82 Merge pull request #6133 from spongehah/feat-ops-error-detail-back-to-list-pr
feat: 运维监控错误详情支持返回列表并保留筛选状态
2026-08-24 11:40:31 +08:00
Wesley Liddick a177b88e52 Merge pull request #6122 from aeonframework/security/bump-dompurify-xss-fixes
fix(deps): bump dompurify to patch sanitizer-bypass XSS advisories
2026-08-24 11:39:25 +08:00
spongehah cfecc8d113 feat: 运维监控错误详情支持返回列表并保留筛选状态
进入单条错误详情后新增"返回列表"按钮,可回到来源明细列表并保留
筛选/分页状态,避免只能退出到运维监控总览后重新筛选。记录来源列表
类型,返回时跳过列表重开时的筛选重置。
2026-08-24 11:25:43 +08:00
Wesley Liddick c416467882 Merge pull request #6084 from wucm667/fix/issue-6057-responses-lite-parallel-tools
fix(openai): enforce serial tool calls for Responses Lite
2026-08-24 11:24:17 +08:00
feeeei f19095f96d 模型广场:分时时段行明确不含高峰倍率口径并披露叠加
- 实扣倍率为 基础 × 高峰 × 分时;时段行价格与整表一致,按不含高峰的口径展示
- 分组启用高峰时,时段行 tooltip 披露与高峰窗口重叠的部分实付再乘高峰倍率
- PlazaGroupSection 把高峰窗口描述与倍率传入价格表
2026-08-24 11:23:58 +08:00
Wesley Liddick 625f1693cb Merge pull request #6118 from akihitohyh/fix/terminal-output-item-preservation
fix(openai): rebuild streaming terminal output from the reported items
2026-08-24 11:23:51 +08:00
Wesley Liddick f25f399be0 Merge pull request #5905 from wucm667/fix/issue-5883-restore-custom-tool-alias
fix(openai): restore namespaced custom tool aliases
2026-08-24 11:23:37 +08:00
Wesley Liddick 748b84a15a Merge pull request #6081 from wucm667/fix/issue-5942-deferred-tools
fix(responses): remove orphan deferred tool flags
2026-08-24 11:23:25 +08:00
Wesley Liddick fa42c3d706 Merge pull request #6080 from alfadb/fix/cc-stream-empty-tool-call-identity
fix(openai): 剔除流式 tool_call 后续 delta 中的空 id/name
2026-08-24 11:23:12 +08:00
Wesley Liddick fb01f5df2c Merge pull request #6060 from anguobao123/codex/document-openai-force-http-fallback
fix(deploy): forward documented Gateway settings
2026-08-24 11:22:38 +08:00
Wesley Liddick 8238956799 Merge pull request #6095 from xiaxiaxaia/fix/openai-oauth-upstream-model-sync
fix(openai): sync models for OAuth accounts
2026-08-24 11:21:58 +08:00
Wesley Liddick e00a8abdd5 Merge pull request #6124 from anguobao123/codex/diagnose-openai-load-batch-exclusions
fix(scheduler): diagnose load-batch OpenAI exclusions
2026-08-24 11:21:30 +08:00
Wesley Liddick a52665d079 Merge pull request #6061 from shunwang-crypto/fix/ops-mixing-cgroup-host-memory
fix(ops): avoid mixing cgroup and host memory metrics
2026-08-24 11:20:51 +08:00
Wesley Liddick ba5b861ec0 Merge pull request #6073 from lbyxiaolizi/fix/proxy-ipv6-batch-parse
fix(proxy): support bracketed IPv6 hosts in batch proxy URL parsing
2026-08-24 11:20:34 +08:00
Wesley Liddick 817fd1214c Merge pull request #6075 from YogaSakti/fix/user-edit-allow-zero-concurrency
fix(frontend): accept unlimited (0) user concurrency in the edit dialog
2026-08-24 11:20:09 +08:00
Wesley Liddick 41f6e63799 Merge pull request #6117 from wucm667/feat/issue-6114-account-priority-column
fix(admin): show account priority by default
2026-08-24 11:19:53 +08:00
feeeei b07d85c497 模型广场:分时计价同步渠道仅工作日规则
- 阶梯表分时倍率透传渠道 weekdays_only;探针锚点显式固定在工作日
  (原 2026-01-01 恰为周四是巧合,锚点落周末会把仅工作日时段整组剔除)
- 前端时段徽章加「工作日」前缀,tooltip 说明周末全天按标准价计费
2026-08-24 11:16:00 +08:00
wucm667 bc4a9ae432 fix: prevent duplicate Anthropic cache TTL billing 2026-08-24 11:05:57 +08:00
alfadb cc894ef578 fix(openai): strip empty streamed tool-call id/name
DashScope/DeepSeek later tool_call deltas send empty id and
function.name. Clients that merge with !== undefined overwrite
the first delta's identity and dispatch unknown tool "". Drop
those empty fields on the raw Chat Completions SSE path.
2026-08-24 10:59:52 +08:00
feeeei 83d4eb6a43 模型广场:增加渠道分时段计价展示
- 阶梯表查询附带分时倍率时段:时段取自计费解析到的渠道定价,
  每个时段的倍率由计费的 resolvedChannelTimeMultiplier 在时段内取值,
  分组价卡覆盖或配置非法时自然不出现;倍率为 1 的时段不列
- 广场模型条目新增 time_pricing(时区 + 时段 + 倍率)
- 前端把分时时段展开为独立行:模型名旁标注时段,价格按时段倍率折算,
  倍率列显示生效倍率;时区与计算口径放在提示中
2026-08-24 10:50:52 +08:00
feeeei ecce0769c0 模型广场:上下文档位统一标签形态并保证升序
- 阶梯表标签由计费层统一生成:有上限的档为「≤上限」、末档为「>下限」
  (达到阈值即进高档时用 < / ≥),不再沿用渠道区间的自定义 tier_label;
  合并同价段只看单价
- 前端档位按下限升序兜底展示,无标签时按同一形态生成
2026-08-24 10:50:52 +08:00
feeeei 377d1230fc 模型广场:按计费阶梯单价表展示长上下文档位
- 新建 ModelPlazaService(持计费服务与定价解析器)承接广场聚合,
  token 模型的单价与档位全部取自 ResolveContextPricingSchedule,
  渠道选择与计费同源;图片/按次模型沿用原档位合成
- 官方参考价改走计费目录(LiteLLM → 内置兜底 → 模型策略),带官方阶梯
- DTO 增加 long_context_pricing_enabled / long_context_basis /
  official_pricing.intervals
- 前端实付与官方三列按档分行(标签只在首列,其余列按行对齐),
  缓存列按档展示写/读价,边际计价以徽章与 tooltip 标注,
  分组关闭阶梯时在头部说明
2026-08-24 10:50:52 +08:00
feeeei 6466978d2f 计费:统一 token 计费路径选择并提供上下文阶梯单价表查询
- BillingService.CalculateTokenCostForRequest 承接网关的路径选择
  (分组/渠道定价 → 平台旧长上下文规则 → 内置目录),网关改为调用该入口
- Gemini /v1beta 的 200K 边际翻倍常量从 handler 移入
  BillingService.LegacyLongContextRule,入口只声明适用
- 新增 ResolveContextPricingSchedule:沿用 Resolve 解析链收集断点
  (渠道区间边界、目录阶梯阈值、旧规则阈值),每档单价由真实计费函数
  探针差商得出,倍率/策略变更无需同步;附阶梯表 vs 计费函数的对账测试
2026-08-24 10:50:52 +08:00
Wesley Liddick 3b8a148bcf Merge pull request #6111 from feeeei/fix/request_billing
fix(billing): bill fast mode by the tier upstream actually served
2026-08-24 10:43:17 +08:00
Wesley Liddick 3e45d4e030 Merge pull request #6089 from lyen1688/feat/channel-time-pricing-weekdays
新增渠道时间段定价工作日生效规则
2026-08-24 10:21:47 +08:00
Wesley Liddick f82d32207f Merge pull request #6127 from Wei-Shaw/feat/oauth-transport-plugin-system
feat: add OAuth outbound transport plugin system
2026-08-24 10:16:05 +08:00
shaw 40aaf7b3ae fix: handle plugin route health update errors 2026-08-24 10:02:06 +08:00
shaw 391d69e086 fix: preserve initial plugin bridge requests 2026-08-24 09:51:31 +08:00
shaw 684d9efb1f fix: harden plugin runtime and UI bridge 2026-08-24 09:50:46 +08:00
shaw 26ac0498f2 test: update plugin management settings contract 2026-08-24 09:16:16 +08:00
shaw 40ea3aebad feat: add OAuth outbound transport plugin system 2026-08-24 09:03:37 +08:00
anguobao123 3fd66a33be fix(scheduler): diagnose load-batch OpenAI exclusions 2026-08-24 01:25:54 +08:00
aeonframework 4a1da29509 fix(deps): bump dompurify to patch multiple sanitizer-bypass XSS advisories
DOMPurify <=3.3.1 (and the mermaid-transitive 3.3.3) carry ~18 disclosed
sanitizer-bypass/XSS advisories, including GHSA-cj63-jhhr-wcxv
(CVE-2026-65913): with USE_PROFILES enabled, ALLOWED_ATTR is rebuilt as a
plain array and looked up via ALLOWED_ATTR[lcName], so a polluted
Array.prototype property (e.g. onclick) is treated as an allow-listed
attribute and survives sanitization -- this app calls
DOMPurify.sanitize(svg, { USE_PROFILES: { svg: true, svgFilters: true } })
in src/utils/sanitize.ts, whose output is rendered via v-html in
ImageUpload.vue's SVG upload preview.

Bumped to 3.4.14 (latest, OSV-clean) and pinned via pnpm.overrides so the
mermaid-transitive copy dedupes to the same patched version instead of
staying pinned at 3.3.3. Lockfile-only regen via pnpm 9, no other package
changes.
2026-08-23 15:43:27 +00:00
akihitohyhandClaude Opus 5 243921dc0a fix(openai): rebuild streaming terminal output from the reported items
A terminal event that arrives with an empty output was rebuilt from delta
accumulation. BufferedResponseAccumulator models only one reasoning item, one
message, and N function calls, and records no item id, status, or phase, so a
turn carrying several items collapsed into a single fabricated message: the
reasoning item disappeared, the real message id was replaced, and phase was
lost.

reconstructResponseOutputFromSSE already prefers the raw output_item.done
items over accumulation for buffered responses. The streaming path had no
equivalent because it never sees the whole body at once. Collect the raw item
of each output_item.done keyed by output_index and rebuild from those, falling
back to accumulation only when the stream reported no done item at all.

Items are stored as raw JSON, so vendor extensions and item types this gateway
does not model survive the rebuild verbatim.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 20:49:36 +08:00
wucm667 616df479e8 fix(admin): show account priority by default 2026-08-23 19:03:59 +08:00
wucm667 19da0f240e fix(gemini): sanitize unsupported tool schema fields 2026-08-23 18:38:11 +08:00
feeeei 75faedda95 计费:fast/priority 按上游响应实际档位只降不升计费
此前 OpenAI service_tier 与 Anthropic speed 的计费档位只看请求侧,上游
因容量把 priority/fast 静默降为 default/standard 时仍按 2x 收费。现在:

- upstreamResponseModelObserver 同时观察响应侧档位:OpenAI 只信终止事件
  与无类型 body 里的 service_tier(response.created 为请求回显,忽略),
  Anthropic 读 usage.speed;查找仅在带 model 的帧上触发
- ResolveBillingServiceTier 只降不升:响应档位比请求便宜才采纳,更贵、
  未知或缺失一律沿用请求侧,与图片尺寸归并、response_model 计费的
  "不得更贵"不变式一致
- usage_logs.service_tier 记录实际计费档位,降级写
  billing.service_tier_downgraded 审计日志;HTTP、WS 及 ws_v2 relay 全覆盖
2026-08-23 16:53:28 +08:00
Long Li fc589bce17 fix(codex): address routed catalog review feedback 2026-08-23 15:43:57 +09:00
wucm667 4eadee1074 [verified] test(openai): update responses bridge signature 2026-08-23 08:45:04 +08:00
wucm667 31d5b67baa fix(openai): restore namespaced custom tool aliases 2026-08-23 08:11:20 +08:00
jjaw 49752060fc fix(monitor-v2): resolve composite group error facts to concrete account platform
Composite groups are a routing layer. The usage-side aggregation already
maps their traffic to the concrete account platform via
usageLogEffectivePlatformExpr, but the error aggregation read
ops_error_logs.platform verbatim. Pre-routing failures on composite groups
fall back to the group platform 'composite', which is never an enabled
monitor v2 config platform, so those error facts were filtered out of
every channel monitor v2 query and composite groups looked permanently
healthy.

Join groups/accounts in the error dedup CTE and resolve the effective
platform the same way as the usage side: account platform first, then a
real recorded platform, else 'unknown'. The window delete+rewrite
recompute rewrites historical facts with the corrected key.
2026-08-23 02:33:09 +08:00
xiaxiaxaia 913ec5d74b fix(openai): sync models for OAuth accounts 2026-08-23 02:07:59 +08:00
William Drake a6b11ccced fix(openai): honor OpenCode Go usage reset durations
Recognize GoUsageLimitError responses and parse both single and composite reset durations from their human-readable messages. Keep API-key accounts rate-limited until the parsed reset instead of falling back to the short default cooldown.
2026-08-22 10:26:56 -07:00
lyen1688 77e0409f7c 新增渠道时间段定价工作日规则 2026-08-23 00:30:27 +08:00
anguobao123 6a1efda0cc fix(deploy): preserve gateway defaults in compose 2026-08-22 21:02:48 +08:00
wucm667 7498d8fdc8 fix(openai): enforce serial tool calls for Responses Lite 2026-08-22 19:40:19 +08:00
shunwang-cryptoandClaude Opus 4.8 cd05772e91 fix(ops): avoid mixing cgroup and host memory metrics
In Docker + cgroup v2 with no memory limit set, /sys/fs/cgroup/memory.current
returns a small container number while /sys/fs/cgroup/memory.max is "max".
readCgroupMemoryBytes then returned (used=<container>, total=0, ok=true).

collectSystemStats used that container "used" but, being unable to derive a
cgroup total, filled the total from the host via gopsutil. The dashboard then
computed container_used / host_total, e.g. ~60MB / 23GB ≈ 0.3% — wildly
understating real usage.

Fix: introduce resolveMemoryStats, which picks a single self-consistent
(used, total, percent) trio from ONE source. cgroup metrics are used only when
the cgroup exposes both a current usage AND a concrete limit (memory.max != max,
so total > 0); otherwise used/total/percent all fall back to the host reading.
The two sources are never mixed.

- memory.current valid + memory.max = "max"  -> all host metrics
- memory.current = 512MiB + memory.max = 2GiB -> ~25% from cgroup
- no cgroup (bare metal)                       -> all host metrics

CPU metric behavior is unchanged (cgroup attempt then host fallback).

Adds ops_metrics_collector_memory_test.go covering all branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 19:35:31 +08:00
wucm667 7a09a2eaf7 fix(responses): remove orphan deferred tool flags 2026-08-22 16:42:56 +08:00
Jarl d493ce0bb2 fix(openai): scope codex identity to oauth account 2026-08-22 16:06:04 +08:00
Yoga Sakti e0e5e45cd2 fix(openai): keep restored tool-call item IDs typed
The Responses client-tool restore raised an upstream function_call back to
custom_tool_call and tool_search_call without touching its item ID, so the
client received a custom_tool_call carrying the upstream's fc_ ID. That
combination is invalid: once the item lands in the client's history, any
later replay to an upstream that validates item IDs fails the whole request
with "Invalid 'input[N].id': 'fc_...'. Expected an ID that begins with
'ctc'", and every following message re-submits the same bad item.

Re-prefix the ID to match the type it is raised to, keeping the suffix so
the ID stays stable and unique per upstream item. IDs without a known
tool-call prefix are left alone rather than guessed at.

The lowering direction has to reverse this, because the WebSocket HTTP
bridge replays restored items back to the upstream and would otherwise lose
the upstream's own ID. normalizeLoweredFunctionItemID now maps a ctc_/tsc_
call ID back to fc_ instead of dropping it, which makes the round trip
lossless; output item IDs (ctco_/tso_) still have no function-protocol
counterpart and are still dropped. The helper was renamed from
dropInvalidLoweredFunctionItemID to match what it now does.

The stream restorer keeps the upstream ID for its own bookkeeping so later
upstream events still match the call, and emits the client-facing ID on
output_item.added/done and the custom_tool_call_input events.

Closes #6071
2026-08-22 14:03:25 +07:00
Long Li e39fce270d fix(codex): sync routed capabilities from upstream
Account model mappings decide availability, but generated Codex catalogs previously inferred capabilities from local model-name tables. Compatible upstreams can expose unknown models such as OpenCode x-preview-f-free, so reasoning levels, image input, and context limits were missing or wrong.

Sync capability metadata from the account model endpoint, enrich incomplete lists from the Models.dev provider matching the account base URL, and persist only complete snapshots. Mixed groups consume the safe intersection across schedulable accounts. When IDs sync without complete metadata, return an explicit warning and preserve the previous snapshot.

Third-party Responses providers often omit /models. If that endpoint returns 404 or 405 and the account already has concrete model mappings, use those configured upstream IDs for capability enrichment. Authentication, rate-limit, server, and network failures remain hard errors, and metadata is never guessed across providers by model name alone.

Advertise a single none choice for non-reasoning models so current Codex can select them, then omit that catalog placeholder when forwarding to compatible Responses upstreams while preserving official OpenAI request semantics.
2026-08-22 15:33:12 +09:00
Yoga Sakti 5dfad32b87 fix(frontend): accept unlimited (0) user concurrency in the edit dialog
The admin user edit modal rejected concurrency < 1, so a user whose
concurrency is already 0 could not be saved at all — the guard runs
before the request, blocking notes, password, role and RPM edits on that
user too.

Everywhere else already treats 0 as unlimited: the gateway skips slot
limiting when maxConcurrency <= 0 (ConcurrencyService.AcquireUserSlot),
the batch limits endpoint binds concurrency with min=0, and the bulk edit
modal only rejects negative values.

Reject negative and non-integer values instead, mirror the RPM field with
min/step and a "0 = unlimited" placeholder and hint, and rename the error
key to match its new meaning. Account concurrency is unchanged.
2026-08-22 13:22:21 +07:00
lbyxiaolizi ee62dfbaf1 fix(proxy): support bracketed IPv6 hosts in batch proxy URL parsing
The quick-add parser rejected every IPv6 proxy: the host group [^:]+
cannot match IPv6 literals (colons) and the pattern had no bracketed
form, so lines like socks5://[2001:db8::1]:1080 were reported invalid.

Add a bracketed-IPv6 host alternative and strip the brackets before
storing; the backend re-brackets via net.JoinHostPort when building the
proxy URL. Bare (unbracketed) IPv6 stays rejected because it is
ambiguous with host:port. Also add a regression test.
2026-08-22 14:10:01 +08:00
Long Li 5a2f542ab0 fix(codex): prefer configured models over discovery
OpenAI groups can mix OAuth accounts with Responses-compatible API key accounts whose upstreams do not implement /models. The manifest handler previously selected one account and fetched its catalog before merging model_mapping aliases, so a permanent 404 returned 502 and hid models the administrator had already configured.\n\nTreat explicit model mappings from schedulable accounts as the authoritative Codex catalog. Build that catalog locally before account selection, preserve group filtering and final-body ETags, and retain the existing upstream discovery and failover path only when no mappings exist. Composite groups keep their existing account-first catalog behavior.
2026-08-22 14:43:40 +09:00
Long Li b16ed03cad fix(codex): align routed catalogs with actual routes
Anthropic fallbacks previously reused Antigravity defaults, leaking Gemini models into Claude-only groups. Composite aliases were also emitted with generic metadata and could be scheduled to accounts that did not own the exact mapping, while generated configs could default to a model absent from the downloaded catalog.

Keep provider defaults separated, derive alias capabilities from unique upstream targets with conservative fallback, require exact mapping ownership during scheduling, filter media targets, and choose generated config defaults from the fetched catalog.
2026-08-22 14:43:40 +09:00
Long Li 3e98a5a1ad fix(composite): route exact account model aliases
Composite model catalogs expose exact account model_mapping aliases, but request routing only considered explicit routes or model-name detection. Unprefixed aliases were selectable but unroutable.

Resolve unique exact account ownership before the detector, keep explicit routes authoritative, and fail closed when an alias is claimed across platforms.
2026-08-22 14:43:40 +09:00
Long Li e471be7302 feat(codex): complete routed model catalogs
Codex treats the downloaded model catalog as a capability contract. Slug-only entries hide supported reasoning levels, can advertise invalid image inputs, and expose automatic or media-only models in the model picker. Generate complete route-aware metadata, preserve official manifests, filter non-agent models, and compute validators from the final catalog.
2026-08-22 14:43:40 +09:00
Long Li 22e1b8144a feat(gateway): expose routed Codex model catalogs
Codex model discovery expects a top-level models manifest, but Composite and other non-OpenAI groups either reached the OpenAI live-manifest handler or had no Codex-specific response at all. API key users also had no supported way to fetch that manifest and reference it from config.toml.

Generate minimal manifests from each group's effective model list while preserving the official OpenAI live path and the ordinary /models response. Add the Use Key flow for authenticated catalog download and model_catalog_json configuration without writing the API key into the downloaded file.
2026-08-22 14:43:40 +09:00
Wesley Liddick d45135d87d Merge pull request #6068 from okbexx/fix/codex-guardian-parent-affinity
fix(openai): keep auto-review on parent account
2026-08-22 13:41:42 +08:00
Wesley Liddick d29d7f8cbc Merge pull request #6065 from chinnsenn/fix/image-generation-flows
fix(openai): stabilize oauth image generation
2026-08-22 13:35:26 +08:00
Wesley Liddick fd6cd474d6 Merge pull request #5846 from lbyxiaolizi/fix/responses-chat-malformed-tool-arguments
fix(apicompat): reject malformed tool-call arguments
2026-08-22 13:35:02 +08:00
Wesley Liddick 73f6a590bf Merge pull request #5912 from xuhaihan/fix/deepseek-responses-client-tools
fix(deepseek): adapt Codex client tools for Responses
2026-08-22 13:34:49 +08:00
Wesley Liddick ffc01f9c66 Merge pull request #5864 from wucm667/fix/issue-5850-http-bridge-replay
fix(openai): avoid duplicate HTTP bridge replay
2026-08-22 13:34:32 +08:00
Wesley Liddick 6244090c1c Merge pull request #5487 from an-epiphany/fix/file-part-min
fix(apicompat): chat/completions 的 file part 转换为 Responses input_file,不再静默丢弃
2026-08-22 13:34:18 +08:00
Wesley Liddick 844b118785 Merge pull request #5938 from Hakunm/fix/google-one-model-catalog
fix(gemini): 限制 Google One OAuth 模型目录 / constrain Google One model catalog
2026-08-22 13:34:05 +08:00
Wesley Liddick 7c64a48dc2 Merge pull request #6067 from alfadb/fix/ollama-cloud-cc-reasoning-content
fix(ollama): 补齐 Ollama Cloud Chat Completions 兼容(思维字段对齐 + max_tokens 上限 clamp)
2026-08-22 13:33:53 +08:00
alfadb 86470628df feat(ollama): 对 Ollama Cloud 账号 clamp max_tokens 上限 2026-08-22 12:38:05 +08:00
Jarl fa4587041c fix(openai): keep auto-review on parent account 2026-08-22 12:14:19 +08:00
chinnsenn cb8dabc12f fix(openai): stabilize oauth image generation 2026-08-22 11:21:39 +09:00
alfadb b30651a0ad fix(ollama): 对齐 Cloud Chat Completions 思维字段为 reasoning_content
Ollama Cloud 的 raw /v1/chat/completions 把思维放在 reasoning/thinking,
DeepSeek 客户端只认 reasoning_content。仅对 openai+apikey+force_chat_completions
且具备 Ollama Cloud 信号的账号,在 raw CC 直转路径做 wire JSON 双向补齐。
2026-08-22 09:26:23 +08:00
anguobao123 e2263d2565 fix(deploy): forward documented gateway settings 2026-08-22 01:07:29 +08:00
xuhaihan cef18b4ad3 fix(deepseek): route client tools through native responses 2026-08-22 01:04:11 +08:00
xuhaihan 30ae15268f Merge remote-tracking branch 'origin2/main' into fix/deepseek-responses-client-tools 2026-08-22 00:52:33 +08:00
anguobao123 10081a8127 fix(deploy): pass force HTTP setting to containers 2026-08-22 00:39:12 +08:00
anguobao123 9f2f2738fd docs(openai): document force HTTP fallback 2026-08-21 23:43:09 +08:00
Wesley Liddick 67380eafd5 Merge pull request #5549 from zcxads666/fix/openai-capabilities-empty-set
fix(openai): 允许空 openai_capabilities 的 OAuth 账号加入文本调度
2026-08-21 21:53:27 +08:00
Wesley Liddick a53150a7cf Merge pull request #5708 from yan9651688/fix/issue-5524-model-plaza-home
fix(frontend): show model plaza entry on /home
2026-08-21 21:53:17 +08:00
Wesley Liddick 2ddda67354 Merge pull request #6049 from MokoYee/fix/openai-sticky-prefix-system
修复 OpenAI Chat 动态系统消息导致账号亲和漂移
2026-08-21 21:34:57 +08:00
zcxads666 40c26f343a fix(openai): 空 openai_capabilities 不再排除 OAuth 账号的文本调度(#5530) 2026-08-21 21:30:40 +08:00
yan9651688 d9d2854d27 Make enabled model plaza discoverable from /home
The model plaza route already supports public access, but both built-in /home headers omit its entry. Add the link to compact and default headers while keeping the existing feature and authentication settings authoritative, then cover the visibility matrix with focused component tests.

Constraint: Keep the change frontend-only and preserve router-owned access control

Rejected: Add the link to AppHeader only | /home renders its own headers and never mounts AppHeader

Confidence: high

Scope-risk: narrow

Reversibility: clean

Directive: Keep the model plaza entry gated by the existing opt-in flag and require-auth setting

Tested: HomeView focused Vitest, full frontend Vitest (223 files / 1554 tests), ESLint, vue-tsc, production build

Not-tested: Manual browser click-through against a running backend

Related: #5524
2026-08-21 21:29:39 +08:00
Wesley Liddick 219368ec6d Merge pull request #5654 from zninggo/fix/composite-video-generation
fix(gateway): Composite 分组放行视频生成端点
2026-08-21 21:27:17 +08:00
Wesley Liddick fb56bcbd07 Merge pull request #5913 from xuhaihan/fix/deepseek-responses-account-test
fix(deepseek): route Responses account tests through OpenAI probe
2026-08-21 21:27:01 +08:00
Wesley Liddick beaeaaed06 Merge pull request #6009 from HypoxanthineOvO/fix/cn-quota-refresh-affordance
fix(frontend): make CN provider quota/balance refresh affordance explicit
2026-08-21 21:25:05 +08:00
Wesley Liddick ccfced36ae Merge pull request #6011 from HypoxanthineOvO/fix/cn-anthropic-protocol-account-test
fix(accounts): route CN provider anthropic-protocol tests to the native endpoint
2026-08-21 21:24:38 +08:00
Wesley Liddick 77d8516e87 Merge pull request #5911 from xuhaihan/fix/deepseek-relay-balance-validation
fix(deepseek): reject invalid relay balance payloads
2026-08-21 21:24:21 +08:00
Wesley Liddick afa21336a5 Merge pull request #6048 from wucm667/fix/issue-5886-composite-messages-dispatch
fix: allow messages dispatch for composite groups
2026-08-21 21:24:06 +08:00
Wesley Liddick 5fc9778468 Merge pull request #6053 from wucm667/fix/issue-5899-token-refresh-lock-cpu
fix(frontend): prevent token refresh lock loop
2026-08-21 21:23:45 +08:00
wucm667 3445485ebc fix(frontend): prevent token refresh lock loop 2026-08-21 19:14:04 +08:00
MokoYee e45490a360 fix(openai): stabilize chat sticky hash across dynamic system messages 2026-08-21 18:11:18 +08:00
wucm667 68653fb2cc fix: allow messages dispatch for composite groups 2026-08-21 18:01:16 +08:00
Wesley Liddick f646a1f974 Merge pull request #5632 from 3219378872/fix/apicompat-streaming-tool-name-empty
fix(apicompat): omit empty tool name on streamed arguments deltas
2026-08-21 17:49:55 +08:00
Wesley Liddick fd24923f61 Merge pull request #5685 from Monster-DP/main
fix(gateway): 修复池模式同账号错误重试
2026-08-21 17:49:07 +08:00
Wesley Liddick b2d5ce0395 Merge pull request #5662 from yzxcj797/fix/self-referential-paths
docs: fix self-referential URLs after file moved into docs/
2026-08-21 17:47:51 +08:00
Wesley Liddick f96035462f Merge pull request #5612 from wucm667/fix/issue-5611-antigravity-paid-tier-endpoint
fix(antigravity): route paid accounts to daily endpoint
2026-08-21 17:40:39 +08:00
Wesley Liddick 4eb7630ab5 Merge pull request #5625 from sweetcornna/fix/antigravity-official-daily-endpoint
fix(antigravity): use official daily endpoint
2026-08-21 17:40:29 +08:00
Wesley Liddick 6816c63883 Merge pull request #5906 from feeeei/main
fix(test): CN 供应商额度探测 fake 加锁消除并发 append 竞态
2026-08-21 17:39:03 +08:00
Wesley Liddick 9f74eb57f4 Merge pull request #5919 from clearmann/fix/cn-native-anthropic-reasoning-effort
fix(gateway): 记录国产厂商原生 Anthropic 直通路径的 reasoning_effort
2026-08-21 17:38:52 +08:00
Wesley Liddick 98d60931c1 Merge pull request #5954 from StarryKira/codex/fix-5945-upstream
fix: preserve native max reasoning effort
2026-08-21 17:38:39 +08:00
Wesley Liddick c2572535d8 Merge pull request #6016 from YogaSakti/fix/prompt-guard-config-loaded-log-spam
fix(securityaudit): log prompt_guard.config_loaded only on change
2026-08-21 17:38:27 +08:00
Wesley Liddick 9d5171c5d1 Merge pull request #5888 from IanShaw027/fix/openai-responses-compatibility
修复 OpenAI Responses/Codex 与 Grok 多路径兼容问题
2026-08-21 17:06:00 +08:00
Yoga Sakti f6aa9dc3c8 fix(securityaudit): log prompt_guard.config_loaded only on change
ConfigManager.refreshLoop reloads the Prompt Guard config every 5s and
Reload logged config_loaded on every successful load, so an unchanged
config produced up to ~17k identical lines per instance per day and
buried real configuration changes.

Log the event only when the reload carries news: the first snapshot, a
new config version (every admin save bumps it under the advisory lock in
UpdateConfig), a flip of the global risk control gate (a separate setting
that leaves the version untouched), or a recovery from a failed reload so
the degraded-to-healthy transition stays visible.

This mirrors logInvalidTokenEndpoints, which already warns once per
change rather than on every refresh.
2026-08-21 15:51:11 +07:00
HypoxanthineOvO a749673de3 fix(accounts): route CN provider anthropic-protocol tests to the native endpoint
CN-provider accounts explicitly configured with api_protocol=anthropic
(the frontend offers per-provider presets such as GLM Anthropic) fell
through the account-test router to the generic Claude tester, which:

- appended ?beta=true, which the native passthrough deliberately avoids
  for third-party Anthropic endpoints, and
- defaulted a missing base_url to https://api.anthropic.com — sending
  the provider's API key to Anthropic instead of the provider's own
  Anthropic-compatible endpoint (guaranteed 401 plus cross-provider
  credential exposure).

Route them to a dedicated probe that mirrors the adaptive Anthropic
check: resolve the endpoint via GetAnthropicProtocolBaseURL (same
resolution as real /v1/messages forwarding, including per-platform
defaults), use the shared API-key auth header, and mark 401/403 as the
account error state.

Also fail fast with an actionable hint when the anthropic-protocol
base_url still points at an OpenAI-compatible endpoint (paas path,
version segment, or chat/completions suffix): the naive {base}/v1/messages
join would 404 (e.g. /api/paas/v4/v1/messages) without any pointer to
the actual misconfiguration.
2026-08-21 16:37:25 +08:00
heyx 2e279c81d2 fix(frontend): make CN provider quota/balance refresh affordance explicit
The quota cell used the noun label "5-hour window/weekly window" and the
balance cell the balance value itself as the click target for a manual
refresh. Both read as passive captions, so users could not discover the
manual probe and reported the feature as missing ("only OpenAI has a
refresh button").

- Split data display from the refresh action: bars/balance render as
  static rows (snapshot already paints on mount), with a dedicated
  action button below, aligned with the OpenAI "Query" and Grok
  "Probe" buttons (same icon, blue action styling).
- Label the control with a verb (cnProviders.probe: 查询 / Query) and
  give the balance cell a tooltip (it previously had none).
- Keep the tier/label layout classes and data-test hooks intact.
- Extend the quota cell spec and add a balance cell spec covering
  snapshot rendering, explicit action labeling and failure passthrough.
2026-08-21 16:23:11 +08:00
haruka d5824f6a5b fix: preserve native max reasoning effort 2026-08-21 14:24:32 +08:00
IanShaw 1429e8f714 修复 PR 5888 剩余审计问题 2026-08-20 22:01:20 -07:00
IanShaw b2b2adcf8d 修复 PR 5888 审查发现的兼容性与竞态问题 2026-08-20 21:06:48 -07:00
shaw 3548256745 chore: update gitignore 2026-08-21 10:01:29 +08:00
IanShaw 16b15e870d 修复 5888 与 5925 的同号重试语义冲突 2026-08-20 18:16:38 -07:00
IanShaw027 7e9af4c100 将 compact fallback 的流式重试改为循环,避免递归重置单次重试标志 2026-08-21 08:55:04 +08:00
IanShaw027 7c53a842e4 合并上游 main,保留 5925 的 Grok 重试上限与 5888 的协议兼容修复
同账号重试采用次数上限加 deadline;畸形 tools 在出站前删除;compaction 422 与结构化错误扫描一并保留。
2026-08-21 08:49:44 +08:00
Wesley Liddick 4033387fd4 Merge pull request #5925 from IanShaw027/fix/grok-compatibility
fix(grok): 对齐官方目录与计费,并修复容量重试、Realtime 握手与媒体兼容
2026-08-21 08:34:38 +08:00
Hakunm f98a056f75 fix(gemini): constrain Google One model catalog 2026-08-21 02:33:00 +08:00
IanShaw 1bff06ea50 修复 Grok Realtime 关闭检查与 rollup 时区断言
golangci 要求检查 Realtime 上游 Close 返回值,并删除已无调用的 heavy-model 冷却函数。分组日汇总集成测试在 UTC 晚上跨上海零点时会把水位写成 UTC 日期,将会话时区钉到 Asia/Shanghai。
2026-08-20 09:50:23 -07:00
IanShaw 2e68b10aad 完善 Grok 内容拒绝计费与媒体兼容 2026-08-20 09:12:52 -07:00
IanShaw 39aaf2fea3 收紧 Grok 容量重试与兼容性分类 2026-08-20 08:07:04 -07:00
IanShaw d78e366db5 补齐 Grok Realtime 握手失败账号冷却 2026-08-20 07:31:19 -07:00
IanShaw cca235365b 修正 Grok 默认模型测试断言 2026-08-20 07:19:51 -07:00
IanShaw c628b3eea7 让 Grok stream idle 重试上限作用于主路径 2026-08-20 07:07:55 -07:00
IanShaw f7bc1970ef 同步 Grok 默认模型回归断言 2026-08-20 07:00:42 -07:00
IanShaw 0b1f79c83c 同步 Grok 429 bridge 回归断言 2026-08-20 06:43:54 -07:00
IanShaw 2ab24a1e77 修正 Grok 429 边界与 stream idle 重试上限 2026-08-20 06:43:31 -07:00
IanShaw ab9cb69e7e Revert "修复 Grok WebSearch SSE action 兼容"
This reverts commit 726de30101.
2026-08-20 06:36:42 -07:00
IanShaw 5ae254f771 补齐 Grok CC bridge 同号重试 2026-08-20 06:25:57 -07:00
IanShaw 17c0ee3859 支持 Grok compaction 422 重试 2026-08-20 06:08:34 -07:00
IanShaw ad87ddee12 补齐 Grok CC 重试与 compaction 恢复 2026-08-20 06:08:09 -07:00
IanShaw 8db8791a73 为 Grok 普通 429 增加有限同号重试 2026-08-20 05:53:55 -07:00
IanShaw e62ec2c42f Revert "feat(429): add configurable cooldown and retry strategies"
This reverts commit 6c3edc0956.
2026-08-20 05:44:21 -07:00
IanShaw 6c3edc0956 feat(429): add configurable cooldown and retry strategies 2026-08-20 05:43:43 -07:00
IanShaw 726de30101 修复 Grok WebSearch SSE action 兼容 2026-08-20 05:41:26 -07:00
clearmann.liu 2074fe3ba8 fix(gateway): 记录国产厂商原生 Anthropic 直通路径的 reasoning_effort
kimi/zhipu/deepseek 平台分组的 /v1/messages 请求在账号 api_protocol=anthropic
时走零转换直通路径(forwardAnthropicViaNativeAnthropicEndpoint)。该路径的
两个结果构造函数(非流式/流式)从不设置 ReasoningEffort,导致客户端显式
携带 output_config.effort 或启用 thinking 时,usage_log.reasoning_effort
仍恒为 NULL,用量页推理强度列长期显示 '-'。

修复:转发前从入站 body 提取推理强度——优先 Claude 协议 output_config.effort
(NormalizeClaudeOutputEffort),缺失且 thinking 已启用时经
ApplyThinkingEnabledFallback 按 passback-required 白名单兜底为 high,与
Anthropic 网关(gateway_handler)的记录语义对齐;两个结果构造函数补上
ReasoningEffort 字段透传。

测试:新增 4 个单元测试覆盖显式 effort(非流式/流式)、thinking 兜底、
无字段保持 nil 的守卫场景。
2026-08-20 08:16:17 -04:00
wucm667 66808413df fix(openai): drop orphan replay tool calls 2026-08-20 20:10:57 +08:00
wucm667 25da02dddd fix(openai): avoid duplicate HTTP bridge replay 2026-08-20 20:10:57 +08:00
IanShaw 3243983b72 完善 Grok Realtime 与默认映射测试 2026-08-20 04:26:24 -07:00
IanShaw f7145c7508 迁移 Grok 默认模型到 4.6 2026-08-20 04:26:15 -07:00
IanShaw 0e05c61d36 修复 Grok 容量冷却与用量计费 2026-08-20 04:26:02 -07:00
xuhaihan 01a0083948 fix(deepseek): route responses account tests to OpenAI probe 2026-08-20 18:40:05 +08:00
xuhaihan b27cd76a85 fix(deepseek): adapt Codex custom tools for Responses 2026-08-20 18:18:45 +08:00
xuhaihan b0b2734b0b fix(deepseek): ignore invalid relay balance payloads 2026-08-20 18:18:45 +08:00
xuhaihan f75c4161f2 fix(deepseek): make account test links platform-aware 2026-08-20 18:18:44 +08:00
IanShaw e85348be80 调整 Grok 媒体超时与重试语义 2026-08-20 02:39:13 -07:00
IanShaw 61c2f5ad28 复用 Grok Realtime 预握手连接 2026-08-20 02:38:51 -07:00
IanShaw 611a7c8ed3 修复 Grok Realtime 预接入切号 2026-08-20 02:32:51 -07:00
IanShaw ad26172b83 完善 Grok 限流冷却与用量兼容 2026-08-20 02:32:43 -07:00
IanShaw 39485f2e28 更新 Grok 默认模型与官方计费目录 2026-08-20 02:32:22 -07:00
IanShaw027 787f875dd6 修复 Grok 孤儿控件、thinking 断言与流式 failover 的 CI 回归
畸形 tools 继续剥离孤儿 tool_choice;bare error 在临时上游失败时保持可换号;已转发的官方失败帧不再重复写入。
2026-08-20 16:21:29 +08:00
IanShaw ed4207a16f 校正 Grok 模型目录计费与工具出站 2026-08-20 01:03:29 -07:00
IanShaw 5ade094318 优化 Grok 传输超时与 Realtime 握手 2026-08-20 01:02:49 -07:00
IanShaw 953028718d 修复 Grok 错误分类与容量重试 2026-08-20 01:02:40 -07:00
IanShaw027 2ab41b92b6 修复 Grok 兼容测试与 errcheck 导致的 CI 失败
对齐 Grok ModelInput 对 function_call.status 的剥离,恢复畸形 tools 不改写 tool_choice 的行为,并检查测试中的类型断言。
2026-08-20 15:46:53 +08:00
feeeei 0117452551 fix(test): CN 供应商额度探测 fake 加锁消除并发 append 竞态 2026-08-20 15:31:35 +08:00
IanShaw 48615d5d1f Merge remote-tracking branch 'upstream/main' into fix/openai-responses-compatibility
# Conflicts:
#	backend/internal/handler/ops_error_logger.go
2026-08-20 00:31:12 -07:00
IanShaw ccb20ace88 修复 OpenAI 兼容性 PR 的 CI 回归 2026-08-20 00:19:59 -07:00
github-actions[bot] 2bc139ab52 chore: sync VERSION to 0.1.179 [skip ci] 2026-08-20 07:06:50 +00:00
shaw c0e073a798 chore: update sponsors 2026-08-20 14:56:47 +08:00
Wesley Liddick 75f88be5f7 Merge pull request #5876 from wucm667/fix/issue-5872-exclude-model-not-found-sla
fix(ops): exclude model configuration errors from SLA
2026-08-20 14:45:08 +08:00
Wesley Liddick 740f580801 Merge pull request #5842 from SavitarC/feat/adaptive-api-protocol
feat(accounts): 添加账号支持自适应API 协议
2026-08-20 14:43:41 +08:00
IanShaw d1c6456d08 合并上游最新主分支兼容修复 2026-08-19 23:15:50 -07:00
IanShaw e4f869e0c7 完善运维错误详情兼容展示 2026-08-19 23:13:47 -07:00
IanShaw c374ff2951 完善 OpenAI 网关切换与运维错误语义 2026-08-19 23:13:40 -07:00
IanShaw acce29af27 补齐 OpenAI 与 Grok 协议兼容处理 2026-08-19 23:13:29 -07:00
Wesley Liddick 752b3d8574 Merge pull request #5881 from X-T-E-R/codex/promote-grok-tool-search-discoveries
fix(grok): promote Codex tool-search discoveries into callable tools
2026-08-20 14:00:17 +08:00
SavitarC b3092145db fix(accounts): harden adaptive protocol compatibility 2026-08-20 13:57:25 +08:00
Wesley Liddick fb94fd352b Merge pull request #5834 from xuhaihan/feat/configurable-proxy-probe-targets
feat(proxy): allow configurable probe targets
2026-08-20 13:40:35 +08:00
Wesley Liddick cb7fef14c0 Merge pull request #5838 from xuhaihan/fix/admin-user-role-select-styling
fix(frontend): align admin role selector styling
2026-08-20 13:40:21 +08:00
Nai Long 7th 06fc0055cd Merge remote-tracking branch 'origin/main' into codex/promote-grok-tool-search-discoveries 2026-08-20 12:37:45 +08:00
Nai Long 7th 9ede0f7165 fix(grok): promote tool-search discoveries into callable tools 2026-08-20 12:37:39 +08:00
SavitarC 85051616f9 feat(accounts): add adaptive API protocol routing 2026-08-20 11:52:05 +08:00
wucm667 6b0ec50f24 fix(ops): exclude model configuration errors from SLA
404 model_not_found remains unchanged while ops attribution becomes routing/business-limited.
2026-08-20 10:52:29 +08:00
Wesley Liddick 1b5dc676a9 Merge pull request #5851 from IanShaw027/feat/channel-pricing-tier-multipliers
fix(billing): restore Fast tier pricing under channel overrides, add configurable multipliers
2026-08-20 10:13:17 +08:00
Wesley Liddick 394b12afdc Merge pull request #5875 from hansnow/fix/admin-platform-filter-options
fix(admin): 补全平台筛选选项
2026-08-20 10:07:46 +08:00
Wesley Liddick 2d03e40fd6 Merge pull request #5868 from X-T-E-R/codex/fix-grok-tool-search-output
fix(grok): lower Codex tool-search discovery outputs
2026-08-20 10:07:11 +08:00
hansnow 1f2a87adb0 fix(admin): 补全平台筛选选项 2026-08-20 10:00:09 +08:00
Nai Long 7th 5b2089c5a3 fix(grok): lower Codex tool-search discovery outputs 2026-08-20 06:55:47 +08:00
wucm667 b1737cc841 fix(antigravity): preserve mixed built-in chat tools 2026-08-20 00:29:33 +08:00
IanShaw cf3577a3c5 fix(openai): harden Responses compatibility 2026-08-19 09:03:37 -07:00
IanShaw d4d2c746c5 前端:修正账号长上下文开关门控 2026-08-19 06:35:11 -07:00
IanShaw d536795e93 测试:同步长上下文计费断言 2026-08-19 06:35:11 -07:00
IanShaw 26be82cc85 前端:配置渠道倍率并精简长上下文开关 2026-08-19 06:35:11 -07:00
IanShaw 7dae055f2a 计费:识别并记录 Anthropic Fast 请求 2026-08-19 06:35:11 -07:00
IanShaw 5b2a386ed7 计费:应用渠道倍率与上下文区间价格 2026-08-19 06:35:11 -07:00
IanShaw fce90ecf89 渠道定价:持久化服务层级与区间倍率 2026-08-19 06:35:11 -07:00
xuhaihan 1ab3256783 fix(proxy): format validated probe targets 2026-08-19 21:31:11 +08:00
xuhaihan ec5a345935 fix(proxy): validate configurable probe targets 2026-08-19 21:10:08 +08:00
Wesley Liddick 32a0d9ba2d Merge pull request #5837 from xuhaihan/fix/cn-provider-quota-cell-layout
fix(frontend): prevent CN quota labels overlapping bars
2026-08-19 21:05:26 +08:00
lbyxiaolizi fbc9ee626d fix(apicompat): narrow malformed tool-call handling 2026-08-19 20:49:31 +08:00
Wesley Liddick 32ecd5cc62 Merge pull request #5847 from wucm667/feat/issue-5826-cn-header-overrides
feat(accounts): support header overrides for CN providers
2026-08-19 20:28:36 +08:00
Wesley Liddick 38a5f266e9 Merge pull request #5839 from xuhaihan/test/fix-grok-api-key-placeholder
test(frontend): align Grok API key placeholder assertion
2026-08-19 20:28:19 +08:00
Wesley Liddick b7fe8ebaad Merge pull request #5762 from jaxxjj/codex/perf-usage-stats-grouping-sets
perf(usage): aggregate admin stats in one scan
2026-08-19 20:27:42 +08:00
xuhaihan d5484866fa fix(config): register proxy probe URLs default 2026-08-19 19:52:10 +08:00
Wesley Liddick 23dc9377bc Merge pull request #5844 from lyen1688/fix/grok-inline-image-view-image
fix(grok): 避免内联图片与 view_image 工具冲突
2026-08-19 19:22:08 +08:00
Wesley Liddick 68666e1f86 Merge pull request #5845 from vincenthcui/fix/openai-ws-later-turn-429-failover
fix(openai): resume later websocket turns after 429
2026-08-19 19:21:49 +08:00
lyen1688 b0cdea303a 补全 Grok 多入口内联图片工具适配
raw Chat 绕过 Responses 公共补丁,需按 Chat schema 单独移除当前轮内联图片对应的冗余 view_image;同时补充 Chat、raw Chat 与 Messages 真实出站回归测试。
2026-08-19 19:04:53 +08:00
wucm667 1b30a2d747 feat(accounts): support header overrides for CN providers 2026-08-19 19:03:39 +08:00
Vincent CuiandKinso 82cbe6aff7 fix(openai): resume later websocket turns after 429
Port the safe current-turn replay from #4622 onto current main.

Co-authored-by: Kinso <kinsolee@users.noreply.github.com>
2026-08-19 18:51:21 +08:00
lbyxiaolizi e2d9ce0cad fix(apicompat): reject malformed tool-call arguments 2026-08-19 18:48:35 +08:00
lyen1688 99a8b84701 修复 Grok 内联图片与 view_image 冲突 2026-08-19 18:36:53 +08:00
hank 63839f193a fix(frontend): align admin role selector styling 2026-08-19 16:47:51 +08:00
hank 994fbfeddb fix(frontend): prevent CN quota labels overlapping bars 2026-08-19 16:47:51 +08:00
hank b0464a9863 feat(proxy): allow configurable probe targets 2026-08-19 16:47:50 +08:00
hank f917d19d3e test(frontend): align Grok API key placeholder assertion 2026-08-19 16:47:11 +08:00
Wesley Liddick ae62854abc Merge pull request #5815 from 771373073/fix/grok46-xhigh
fix(grok): preserve xhigh effort for grok-4.6
2026-08-19 16:42:52 +08:00
Wesley Liddick 89d28037f2 Merge pull request #5822 from hansnow/fix/ws-http-bridge-followup-client-tools
fix(openai): 修复 WS HTTP bridge 多轮客户端工具回放
2026-08-19 16:42:28 +08:00
Wesley Liddick e8b53c9195 Merge pull request #5801 from MokoYee/fix/openai-chat-buffered-stream-failover
fix(openai): 修复 Chat 非流式缓冲读取错误未触发故障转移
2026-08-19 16:41:33 +08:00
Wesley Liddick 6a945b3cad Merge pull request #5817 from wucm667/fix/issue-5796-composite-new-platforms
fix(composite): support Kimi, GLM, and DeepSeek
2026-08-19 15:36:38 +08:00
hansnow fefd0d5145 test(apicompat): avoid unchecked tool history assertions 2026-08-19 15:31:18 +08:00
hansnow e4896c41d2 test(apicompat): satisfy client tool type assertions 2026-08-19 15:26:19 +08:00
shaw 499a8ee423 fix(composite): exempt resolved grok/CN targets from messages dispatch gate
sanitizeGroupMessagesDispatchFields forces AllowMessagesDispatch=false for
every non-openai platform including composite, and the gate checked only the
group platform — so composite requests resolved to grok/CN targets were
rejected 403 on /v1/messages and /v1/messages/count_tokens before reaching
the composite target whitelist, leaving the CN rollout unusable for
Claude-protocol clients.

- allowOpenAICompatibleMessagesDispatch: composite groups resolved to a
  grok/CN target get the same exemption as the standalone platforms;
  openai-resolved and unresolved targets keep requiring the switch
- resolveOpenAIMessagesDispatchMappedModel: skip the group-level dispatch
  model mapping (openai-specific gpt defaults) for composite grok/CN
  targets — model rewriting stays with account-level model_mapping
2026-08-19 15:24:26 +08:00
hansnow b94e484e23 fix(openai): preserve client tools across WS bridge turns 2026-08-19 15:19:33 +08:00
shaw aa673062e2 fix(composite): keep CN rollout on fully supported paths
- Responses WebSocket composite whitelist stays openai+grok: CN accounts
  cannot pass the WSv2 ingress transport filter and the WS HTTP bridge
  has no Responses conversion for them, so admitting CN targets only
  turns a clear policy rejection into a misleading no-available-account
- widen the responses/input_tokens and messages/count_tokens composite
  gates to the shared openai-compatible text whitelist so CN targets get
  the same local-estimate token counting as generation
- restore the Claude default-model fallback for standalone CN groups
  (admin candidates and custom models list) while keeping composite
  listings limited to CN account mapping keys
- refresh the scheduler bulk-rebuild comment and bucket capacity hints
  for the 8-platform canonical set
2026-08-19 14:58:32 +08:00
shaw bd1ccd9736 Merge remote-tracking branch 'origin/main' into fix/issue-5796-composite-new-platforms 2026-08-19 14:47:34 +08:00
Wesley Liddick 7d9c958482 Merge pull request #5810 from Pluviobyte/codex/fix-responses-input-tokens
fix(codex): handle Responses input token preflight
2026-08-19 14:43:33 +08:00
Wesley Liddick 2a5ae2b2dc Merge pull request #5816 from kingsleydon/fix/composite-codex-support
feat(composite): support Codex endpoints
2026-08-19 14:19:26 +08:00
wucm667 4d3b300a25 test(scheduler): update CN platform expectations 2026-08-19 14:05:52 +08:00
Wesley Liddick e943f817b1 Merge pull request #5729 from lbyxiaolizi/fix/responses-chat-reasoning-content-passback
fix(openai-compat): Responses→Chat 桥接按 reasoning item id 缓存回注 reasoning_content (#5520)
2026-08-19 14:03:03 +08:00
wucm667 b171bb0e4a fix(composite): support CN providers
Extend composite routing, pricing, migrations, and admin options for Kimi, GLM, and DeepSeek.
2026-08-19 13:01:25 +08:00
Kingsley 58e147fba6 feat(composite): support Codex endpoints 2026-08-19 04:40:32 +00:00
boooot 892787723f fix(grok): preserve xhigh effort for grok-4.6 2026-08-19 03:38:11 +00:00
Rain bfac49fef9 fix(codex): handle responses input token preflight 2026-08-19 11:34:18 +08:00
Wesley Liddick 82f7dd14f7 Merge pull request #5794 from Dessalines39394/fix/star-history-chart
docs: fix broken star history chart in README
2026-08-19 11:23:33 +08:00
Wesley Liddick e61595fb35 Merge pull request #5780 from Randark-JMT/fix/channel-monitor-p2
fix(monitor): P2 audit follow-ups — credential/balance semantics, capability checks, single account load, quota placeholder UI
2026-08-19 11:21:35 +08:00
Wesley Liddick c6f4fbde49 Merge pull request #5676 from Perfecto23/agent/openai-capacity-failover
fix(openai): recover message-only capacity failures before output
2026-08-19 11:11:23 +08:00
MokoYee b228b93e9c fix(openai): 修复 Chat 非流式缓冲读取错误未触发故障转移 2026-08-19 10:41:47 +08:00
wucm667 b410c39133 fix(security): add nanoid audit exception for GHSA-2v37-7h3g-55p8 2026-08-19 10:17:52 +08:00
Wesley Liddick 359fd12b2e Merge pull request #5749 from Randark-JMT/chore/remove-sora-leftovers
chore: remove leftover Sora references after platform removal
2026-08-19 09:41:25 +08:00
Wesley Liddick 214210e1b2 Merge pull request #5782 from UnlastingR/fix/cn-provider-account-test-routing
fix(accounts): route CN provider chat tests correctly
2026-08-19 09:37:45 +08:00
Dessalines39394 85cb732cdb docs: fix broken star history chart in README
The star history chart was broken due to GitHub stargazer API restrictions. Update the chart URLs in README.md, README_CN.md and README_JA.md so the chart renders again.
2026-08-18 18:47:23 +00:00
UnlastingR ac6208de16 fix(accounts): route CN provider chat tests correctly 2026-08-18 08:46:28 -04:00
Randark 2c250bfd73 fix(monitor-ui): localize the "quota" placeholder model across monitor views
纯配额模式的主模型存储值是占位符 "quota"(数据源是关联账号而非模型),
此前在管理列表主模型列、用户端监控卡片、用户端详情弹窗、运行结果弹窗
四处被当作真实模型名直接渲染,读起来像假模型。

新增 useChannelMonitorFormat.formatMonitorModel:占位符替换为本地化
「配额」标签(复用既有 monitorCommon.checkMode.quota,零新增 i18n 键),
quota_probe/probe 的真实模型名不受影响;纯匹配逻辑(如 15d/30d 可用率
按模型名回查 detail)仍用原始值。

问题定位备注:另一反馈「设置页看不到 channel_monitor_show_quota 开关」
经查为部署镜像过旧(开关随 #5761 于 08-18 合入,反馈环境容器构建于
08-15),上游代码中开关完整存在,更新镜像即可,无需改码。
2026-08-18 10:35:57 +00:00
Randark c9effc4560 fix(frontend): monitor form check-mode restore, account unbinding and mode badge
审计 P2-7 三处前端收尾:

- 切离 antigravity 时对称还原 check_mode:antigravity 强制 quota,但反向
  切走没有还原——编辑存量 antigravity 监控换平台后停留在 quota,目标平台
  未必支持。现切走时还原为 probe 并清掉占位模型 'quota'(回填值),否则
  probe 模式拿 'quota' 当探活模型(与离开 grok 清 DEFAULT_GROK_MODEL
  同一先例)。
- update 切回 probe 显式发 account_id=0:后端语义 0=解绑、null=不动,
  此前 probe 模式 payload 带 null,存量关联永远清不掉。仅 update——
  create 发 0 会落 &0 触发外键违约(API 类型注释同步写明)。
- 管理列表 provider 徽章旁并列 check_mode 徽章(checkModeLabel 此前
  定义了却无处使用):quota 系=蓝、probe=中性灰,三种模式一眼可分;
  i18n 复用既有 monitorCommon.checkMode.*,零新增键。
2026-08-18 10:28:28 +00:00
Randark e2dfb3b8cb refactor(monitor): pass loaded account through quota sources (single load per fetch)
配额 fetcher 缓存未命中时账号被加载两次:fetchUncached GetByID 一次,
下游数据源(GetUsage / CN QueryUsage / CN QueryBalance)按 ID 再各自
GetByID 一次——每次 GetByID 含 proxies/groups 联查,纯属重复劳动。

统一改为「路由前加载一次、指针直传」:
- fetcher 三个数据源接口签名改收 *Account:
  GetUsageForAccount / QueryUsageForAccount / QueryBalanceForAccount
- AccountUsageService 暴露 GetUsageForAccount 直通(getUsageForAccount
  既有逻辑零改动)
- CN 两侧服务抽 validateCodingPlanAccount / validatePayGAccount
  (加载后校验原样),新 ForAccount 入口 = NOT_CONFIGURED 守卫 → 校验 →
  singleflight → 探测;ID 入口 = load + 委托,语义不变
- singleflight key 保持 "cn_quota:<id>" / "cn_balance:<id>":ForAccount
  与 admin handler 的 ID 入口并发探测仍按账号合并
- cn_provider_balance_check_service.checkOne 刻意留在 ID 入口:调度器
  路径按 ID 取的是最新凭据,不复用可能已过期的已加载账号

校验移到 flight 之外且 ForAccount 复用同一套校验,直传无法绕过
平台/模式检查;专项测试固化(无效账号零出站请求、单次加载指针直传
require.Same)。
2026-08-18 10:28:28 +00:00
Randark c41ae19e52 fix(monitor): reject unusable quota data sources and invalid mode combos at write time
P2-2/P2-4/P2-5 from review:

- validateLinkedAccount/revalidateLinkedAccount now check
  monitorAccountQuotaCapability after the platform match, blocking combos
  that would permanently error at runtime: deepseek coding and
  custom-domain kimi coding (no quota endpoint), zhipu payg (no balance
  endpoint), anthropic/openai API-Key accounts (usage query requires
  oauth; anthropic setup-token still allowed via local estimation).
  gemini/grok/antigravity stay permissive. Quota mode surfaces
  CHANNEL_MONITOR_ACCOUNT_NOT_SUPPORTABLE on edit; probe mode silently
  unbinds (same as platform mismatch).
- applyMonitorUpdate re-runs validateCheckMode on the effective
  provider+check_mode whenever either field is patched, so a
  provider-only update can no longer persist antigravity+probe (the
  check is conditioned on provider/check_mode presence so legacy illegal
  rows can still be renamed/disabled).
- normalizeMonitorPrimaryModel only substitutes the "quota" placeholder
  for pure quota mode; quota_probe with an empty model now fails with
  MissingPrimaryModel instead of probing model="quota". The quota branch
  also moves ahead of the grok default, so grok+quota now gets "quota"
  rather than "grok-4.5" (review L-item).

Tests: capability matrix (15 cases incl. anti-over-blocking rows),
linked-account wiring, revalidate quota/probe split, provider-only
update bypass + legacy-row rename, quota_probe missing-model create,
normalize matrix.
2026-08-18 10:28:28 +00:00
Randark 1128df2592 fix(monitor): align quota-fetcher credential/balance semantics with scheduler
P2-1/P2-3 from review:

- fetchCNQuota: credential-invalid now judged by StatusCode 401/403
  (aligned with fetchCNBalance) instead of `!Success && !CredentialValid` —
  CN quota service only sets CredentialValid=true on the success path, so
  500/429/zhipu business errors were all misclassified as failed instead
  of error.
- fetchCNBalance: snapshot carries new BalanceLow flag computed with the
  scheduler's exact criterion (`!Available || allCNBalancesBelowThreshold`)
  against Gateway.CNProviders.BalanceThreshold (ctor now takes cfg; wire
  regenerated). quotaDegradedHint reports "balance low" instead of the old
  `<=0` check, so an account already paused by the scheduler (balance 5 /
  threshold 10) no longer shows green in the monitor.
- threshold helper falls back to viper default 0.5 for nil/<=0 config to
  avoid a zero-threshold regression where balance=0 stops alerting.

Tests: CN quota status-code matrix (rewrites the test that cemented the
old behavior), balance-low matrix (below-threshold / unavailable /
multi-currency healthy), threshold-from-config; PayG stubs now set
Available explicitly (zero-value trap).
2026-08-18 10:28:28 +00:00
lbyxiaolizi 3d21d61609 chore: retrigger CI(上游 flaky 测试 TestApplyCodexFingerprintClientMetadataRaw_MatchesMapVariant 毫秒边界误报,与本 PR 无关) 2026-08-18 18:20:09 +08:00
github-actions[bot] 49504adc98 chore: sync VERSION to 0.1.178 [skip ci] 2026-08-18 10:03:19 +00:00
Wesley Liddick e0c48a19ed Merge pull request #5761 from Randark-JMT/feat/channel-monitor-quota-mode
feat(monitor): 渠道监控配额模式——关联账号展示用量/余额(重启 #5387)
2026-08-18 17:36:27 +08:00
Wesley Liddick dd04503e1f Merge pull request #5773 from Wei-Shaw/fix/channel-pricing-cn-platforms
fix(channels): 渠道定价支持 Kimi/智谱/DeepSeek 平台
2026-08-18 17:27:43 +08:00
Randark 5cbd0c96a2 fix(monitor-ui): 关联账号选择器改服务端搜索+回填,OpenAI 配额模式加消耗提示
- 选择器改为 /admin/accounts?platform=&search= 服务端搜索(50 条/页,
  300ms 防抖由 Select 承担),AbortController + 序列守卫防乱序覆盖
- 已绑定账号不在结果页时用 getById 回填为固定选项,删除「不在列表即
  静默清空绑定」;绑定仅在显式切换 provider 或账号确实失效时清空(带提示)
- OpenAI 平台配额模式显示 Codex 探测额度消耗提示;checkModeQuotaHint
  修正为「不发送探活请求」不再宣称「不产生 LLM 请求」
- grok spec 补齐缺失的 accounts mock;新增账号选择器 7 用例
  (加载/防抖搜索/回填保留/失效清空/提示门控/固定选项/切平台清空)
2026-08-18 09:14:44 +00:00
Randark 03c3f3b6fc feat(ui): Select 组件支持可选远程搜索(remote/loading props + search 事件)
- remote 模式始终显示搜索框且不做本地过滤,输入 300ms 防抖后 emit('search')
- 关闭/卸载时取消排队中的防抖定时器,避免尾随空 query
- loading 时空态显示加载文案;未设 remote 时行为不变(回归用例保障)
2026-08-18 09:14:31 +00:00
Randark 22df600d09 fix(channel-monitor): 配额快照识别值通道失败并加 60s 负缓存与 singleflight
- fetchUsage 显式识别 UsageInfo.Error/ErrorCode/NeedsReauth/IsBanned/IsForbidden
  (antigravity/grok 等平台失败不走 Go error 通道,此前会被误判为 operational)
- 凭据失效语义(401/403)标记 CredentialInvalid → failed 状态;
  grok quota_unknown 已知未知态豁免不判失败
- 失败快照进 60s 负缓存,避免故障/凭据失效期间以最小 15s 间隔打上游
- 同账号并发抓取由 singleflight 合并(脱离调用方 ctx,45s 总超时兜底)
2026-08-18 09:14:20 +00:00
shaw 8f6f459835 fix(channels): support kimi/zhipu/deepseek platforms in channel pricing
- ChannelsView platformOrder now includes the three CN provider
  platforms so channel pricing can be configured for them; composite
  group expansion/attachment stays limited to the five main platforms
  (matches backend isConcreteRequestPlatform and composite-routes
  target_platform validation)
- SyncPricingModels maps kimi->moonshot, zhipu->zhipu,
  deepseek->deepseek; also fixes gemini mapping to "google" which
  matched zero catalog entries (provider key is "gemini")
- Add CN platform colors to channel pricing model tag classes
2026-08-18 17:12:01 +08:00
Wesley Liddick 58ccea4eaa Merge pull request #5767 from hansnow/fix/ws-http-bridge-custom-tools
fix(openai): 补齐客户端工具终止事件恢复
2026-08-18 16:21:50 +08:00
hansnow c253bd2c72 fix(openai): restore client tools in terminal events 2026-08-18 15:48:02 +08:00
Wesley Liddick 26cb59df05 Merge pull request #5764 from hansnow/fix/ws-http-bridge-custom-tools
fix(openai): 补齐 WS HTTP bridge 的客户端工具适配
2026-08-18 15:47:59 +08:00
Wesley Liddick 58ea46e894 Merge pull request #5661 from wucm667/fix/issue-5659-openai-custom-tools
fix(openai): restore API-key custom tool calls
2026-08-18 15:47:50 +08:00
Wesley Liddick 685455985b Merge pull request #5765 from IanShaw027/feat/grok-from-personal-main
feat(grok): 用量条补齐本站 24h/7d/30d 聚合,并隐藏空预付与 0 限额已用
2026-08-18 15:35:42 +08:00
Wesley Liddick 1ed3b6aef6 Merge pull request #5760 from spongehah/feature/unify-codex-outbound-identity
fix(Fingerprint): 将 Codex 非推理出站身份统一到推理解析链
2026-08-18 15:35:34 +08:00
Wesley Liddick f211a630c8 Merge pull request #5720 from tamseno/fix/invitation-code-toctou-race
fix(auth): make invitation code consumption atomic with user creation
2026-08-18 15:35:16 +08:00
Wesley Liddick 37732dcd34 Merge pull request #5725 from tamseno/fix/gemini-include-server-side-tool-invocations
fix(gemini): support includeServerSideToolInvocations in GeminiToolConfig
2026-08-18 15:35:01 +08:00
yaxin a341239596 fix(fingerprint): align credential-face identity with the real client and de-drift models version
- Replace ApplyCodexCanonicalIdentity with CodexCanonicalAuthIdentity /
  ApplyCodexCanonicalAuthIdentity: the credential face (auth.openai.com
  token exchange / refresh / PAT whoami) now sends the originator +
  canonical User-Agent pair and no version header, matching codex-rs
  default_headers(); the version gate (#3901) only exists on the
  /backend-api/codex inference face. whoami keeps its original header
  shape (originator + UA) with the canonical UA source.
- Token exchange and refresh send the full pair instead of a bare UA,
  eliminating the half-identity (UA without originator) combination no
  real client ever emits.
- Codex models manifest: the Version header now follows the client's
  own client_version when it is valid and >= the upstream floor (same
  source as the query param, restoring the pre-refactor consistency),
  falling back to the canonical version otherwise; the query param
  keeps its verbatim passthrough contract.
- Drop the now-unreferenced openAICodexProbeVersion constant and its
  vacuous consistency assertions; probes resolve their version through
  resolveCodexOutboundIdentity at runtime.
2026-08-18 15:20:08 +08:00
yaxin 1ba92449c7 fix(gemini): wire includeServerSideToolInvocations into the typed transform path
The struct field alone never reached the wire: the raw passthrough
pipeline is covered by enableMixedGeminiToolInvocations (#5711), but
TransformClaudeToGeminiWithOptions builds GeminiToolConfig from scratch
and never set the flag, so gemini-* models entering through the Claude
format gateway could still hit the upstream 400 from issue #5709.

- Set IncludeServerSideToolInvocations=true when the built tool
  declarations mix functionDeclarations with googleSearch, matching the
  raw-path injection semantics.
- Replace the marshal-roundtrip-only test with behavior tests that
  drive TransformClaudeToGeminiWithOptions: mixed tools set the flag,
  function-only and web-search-only requests leave it unset.
2026-08-18 15:05:54 +08:00
yaxin 9617775f9a fix(repo): tolerate ErrTxStarted for tx-bound clients and harden test stubs
- user_repo.create(): keep the TxFromContext fast path, but restore
  tolerance for dbent.ErrTxStarted in the self-owned-transaction branch.
  ent's Client.Tx only inspects the driver type, so a repository built
  from a tx-bound client (client-injected transactions, e.g. the
  integration fixture testEntTx + tx.Client()) hits ErrTxStarted; reuse
  that client instead of failing. Fixes the two red integration tests in
  allowed_groups_contract_integration_test.go.
- createUserAndClaimInvitation: roll back via defer (matching the OAuth
  registration precedent) so a panic inside the transaction cannot leak
  the connection.
- settingRepoStub: guard call counters and state with a mutex; the new
  concurrency regression test exercises it from multiple goroutines and
  the unsynchronized counters were flagged by -race.
2026-08-18 15:02:25 +08:00
IanShaw027 fd42d3722d fix: hide Grok prepaid and used/limit when they are empty
Skip the prepaid chip without a positive balance, and skip used/limit when monthly limit is 0.
2026-08-18 14:55:08 +08:00
Wesley Liddick 1870b58c1d Merge pull request #5721 from lyy0709/codex/bulk-openai-settings
fix(openai): complete bulk account settings
2026-08-18 14:53:55 +08:00
IanShaw027 269fbcac0e feat: Grok 用量条补齐本站 24h/7d/30d 聚合
把已有的本地窗口统计接到 Free 24h 与付费 7d/30d 进度条,避免只显示官方百分比。
2026-08-18 14:50:57 +08:00
hansnow 7e579cb28d fix(openai): adapt client tools in WS HTTP bridge 2026-08-18 14:42:30 +08:00
Randark ebcae03afd fix(lint): 补齐 setting_public.go 与配额 fetcher 测试的 gofmt 对齐 2026-08-18 06:29:43 +00:00
jaxxjj a9514a68d2 perf(usage): aggregate stats in one scan
Reduce filtered admin usage statistics from four scans to one GROUPING SETS query so every breakdown shares the exact same filters. Add concurrent expression indexes for requested and upstream model filters on large usage_logs tables.
2026-08-18 14:26:19 +08:00
Wesley Liddick 938f1868ae Merge pull request #5714 from wucm667/fix/issue-2733-current-main
fix(ops): avoid single-insert fallback after batch failure
2026-08-18 14:11:42 +08:00
Wesley Liddick 4d19836189 Merge pull request #5716 from wucm667/fix/issue-2695-current-main
fix: skip expiry reminders without SMTP config
2026-08-18 14:06:32 +08:00
Wesley Liddick c792b44bb2 Merge pull request #5712 from xiaobo121388/main
feat: add Ollama Cloud usage query action
2026-08-18 14:05:27 +08:00
Randark 7376f4a48c fix(lint): gofmt 对齐 + 移除未使用的 isSupportedProvider
- 三个文件补 gofmt 规范对齐(结构体尾部注释、const 块字段对齐)
- isSupportedProvider 在 validateProvider 改用能力集合后无引用,删除
2026-08-18 05:57:41 +00:00
Wesley Liddick 1ea4150bf0 Merge pull request #5581 from wucm667/fix/issue-5574-passthrough-model-discovery
fix(gateway): align passthrough model discovery
2026-08-18 13:56:02 +08:00
Wesley Liddick ed2da82396 Merge pull request #5711 from wucm667/fix/issue-5709-antigravity-tool-config
fix(antigravity): preserve mixed Gemini tool config
2026-08-18 13:55:24 +08:00
Wesley Liddick 6259940ef2 Merge pull request #5669 from feeeei/main
feat(openai): OpenAI Team 联动熔断
2026-08-18 13:54:32 +08:00
Wesley Liddick baaf59d417 Merge pull request #5755 from feeeei/feat/gemini
fix(gemini): Skipped 错误策略对齐 OpenAI,上游 4xx 不再硬改 500
2026-08-18 13:54:06 +08:00
Wesley Liddick c0325d24f9 Merge pull request #5759 from o2e/codex/fix-codex-usage-probe-model-clean
[codex] 修复部分账号 Codex 额度查询 400
2026-08-18 13:53:49 +08:00
Wesley Liddick c76ff55997 Merge pull request #2148 from feitianbubu/pr/ops-error-detail-support-custom-time
feat: ops 错误详情弹窗支持自定义时间区间
2026-08-18 13:52:34 +08:00
Wesley Liddick 5c12960285 Merge pull request #4005 from feitianbubu/fix/header-role-i18n
fix(ui): localize user role label in app header
2026-08-18 13:52:15 +08:00
Wesley Liddick f6d6613ec6 Merge pull request #4006 from feitianbubu/fix/native-controls-dark-mode
fix(ui): adapt native form controls to dark mode via color-scheme
2026-08-18 13:51:57 +08:00
Wesley Liddick 5dfac8bffa Merge pull request #4049 from feitianbubu/fix/dashboard-token-cache-breakdown
fix(dashboard): include cache tokens in token card breakdown
2026-08-18 13:51:42 +08:00
Wesley Liddick 632b4f4faa Merge pull request #4053 from feitianbubu/fix/announcements-empty-state-copy
fix(announcements): use proper empty-state copy instead of error message
2026-08-18 13:51:21 +08:00
Wesley Liddick 3d9a0a71bd Merge pull request #4057 from feitianbubu/fix/ops-sla-zero-window
fix(ops): show neutral SLA card when window has no requests
2026-08-18 13:51:09 +08:00
Wesley Liddick 1a3ecd2b94 Merge pull request #5004 from wucm667/fix/issue-4990-deferred-tool-cache-control
fix(claude): strip cache control from deferred tools
2026-08-18 13:50:57 +08:00
Wesley Liddick 8532344745 Merge pull request #5715 from wucm667/fix/issue-3917-current-main
fix(frontend): isolate AccountsView helper data loading
2026-08-18 13:50:39 +08:00
Wesley Liddick cddb03c0f1 Merge pull request #5609 from wucm667/fix/issue-5607-auth-pricing-snapshot
fix: preserve group pricing in auth snapshots
2026-08-18 13:50:13 +08:00
Wesley Liddick a7a321232f Merge pull request #5567 from wucm667/fix/issue-5563-anthropic-sse-overload
fix(gateway): handle Anthropic SSE overload errors
2026-08-18 13:49:59 +08:00
Randark 302a10b886 test(monitor-ui): 配额视图渲染与开关门控用例
- 新增 MonitorQuotaView.spec:tier window/label 的 i18n 映射与未知
  token 透出、阈值配色(≥90 红/≥75 黄/其余绿)、进度条宽度钳制、
  多币种余额与单余额回退、plan_level 徽章、错误截断与失败态不渲染 tiers
- 新增 MonitorCard.quota.spec:latest_quota 受系统开关门控,
  无快照或开关关闭时不渲染
- 既有工厂/断言补 check_mode、account_id;provider 网格断言 4→8
2026-08-18 05:10:10 +00:00
Randark a20e1c00cc feat(monitor-ui): 配额模式表单、用量快照视图与 8 平台支持
管理端:
- MonitorFormDialog:新增检查方式三态分段控件(探活/配额/探活+配额),
  provider 网格扩展至 8 平台(+antigravity/kimi/zhipu/deepseek),
  国产平台默认 endpoint 预填、antigravity 强制 quota-only;配额模式
  显示关联账号选择器(复用 adminAPI.accounts.list 按平台过滤),
  隐藏 endpoint/api_key/模型等探活专属字段
- 新建共用 MonitorQuotaView:用量窗口进度条(阈值/样式对齐账号页
  CNProviderQuotaCell)、多币种余额、plan_level 徽章与错误态;
  window/label 机器 token 走 i18n,未知 token 原样透出(前向兼容)
- 列表主模型单元格内联 latest_quota;运行结果弹窗逐行展示配额;
  筛选栏/模板管理/格式化 composable 同步 8 平台(配色对齐
  utils/platformColors.ts);ProviderIcon 搬运 4 家官方 SVG

用户端:
- MonitorCard 支持 latest_quota,渲染受 channel_monitor_show_quota
  系统开关(isChannelMonitorQuotaVisible)控制,服务端剥离为第一道防线

设置与 i18n:
- 管理设置页新增「向用户展示渠道用量/余额」开关(默认关闭);
  zh/en 全量补充 providers/checkMode/quota 文案
2026-08-18 05:09:58 +00:00
o2e 16e4f7ecc3 修复 Codex 额度探针模型兼容性 2026-08-18 13:08:28 +08:00
spongehah bb6c3b4f6a fix: unify Codex OAuth outbound identity onto the inference resolver
Token exchange, PAT whoami, models, probes, and pre-writes now follow
the same UA/version chain as Codex inference instead of hardcoded
codex-cli/0.91.0 or compile-time constants.
2026-08-18 13:03:22 +08:00
Randark c51fd7d0b3 test(channel-monitor): adapt checker body test to 3-arg normalizeMonitorPrimaryModel 2026-08-18 04:51:06 +00:00
Randark 7ab6d3db66 test(channel-monitor): quota mode unit/integration/migration coverage
- fetcher:海外/CN coding/CN payg 分派、账号缺失、凭据失效标记、
  TTL 命中与失败不缓存、UsageInfo→tiers 全窗口归一、状态推导矩阵
- quota mode:RunCheck 三分派(quota 单行 / quota_probe 仅挂主行 /
  probe 不变)、配额失败不翻探活状态、校验矩阵、关联账号复核
  (quota 报错 probe 自动解绑)、quota→probe 切换强制重填 key、
  Duplicate 空明文重加密
- settings:channel_monitor_show_quota 缺省关闭 + 仅 "true" 开启
- 迁移内容断言(仿 176 grok 迁移测试)
- repo 集成测试:check_mode/account_id 往返、quota JSONB 回读、
  探活旧行 NULL 兼容(testcontainers PG 实跑迁移 226)
- 修复 Fetch 在 nil receiver 上的 panic(缓存查询先于原 nil 守卫)
2026-08-18 04:51:02 +00:00
Randark 41344c20ff feat(monitor): wire quota fetcher & expose check_mode in handlers
- handler DTO: create/update 接收 check_mode/account_id,provider oneof 扩至 8 家,
  endpoint/api_key 改为 omitempty(条件必填下沉 service 校验);
  monitor/checkResult/historyItem 响应透传 check_mode/account_id/quota
- 用户端 latest_quota 由 channel_monitor_show_quota 控制,关闭时服务端剥离
- wire: NewChannelMonitorQuotaFetcher 以具体服务类型收参(窄接口包内保留供
  stub),ProvideChannelMonitorRunner 注入后 SetQuotaFetcher
2026-08-18 04:14:15 +00:00
Randark 6a6fd304f6 feat(settings): channel_monitor_show_quota public setting (default off)
- 新增公开设置 key(迁移 226 已插入默认 false),控制用户端监控页
  是否展示配额/余额;管理端不受影响
- 解析 fail-closed:仅字面 "true" 视为开启(对齐 available_channels_enabled
  语义,而非 hide_throughput 的 fail-open)
- 全链路贯通:domain key 常量 → setting_public(公开读取 + ChannelMonitorRuntime
  .ShowQuota + 注入 payload)→ settings_view/parse/update → handler DTO
  (admin/user 响应 + admin 更新请求)→ api_contract_test 两个 wantJSON 块
2026-08-18 04:06:18 +00:00
Randark c44711ac98 feat(channel-monitor): quota mode service layer (fetcher + dispatch + repo)
- 常量:MonitorProvider{Antigravity,Kimi,Zhipu,Deepseek}、MonitorCheckMode 三态、
  配额缓存/告警阈值参数;新增 check_mode 相关业务错误
- 校验:monitorProviders(8)/probeCapableProviders(7) 集合替代 adapter 表判定,
  validateCheckMode 矩阵(antigravity 仅 quota);quota 模式 primary_model
  默认占位 "quota",endpoint/api_key 条件必填
- checker:kimi/deepseek 复用 /v1/chat/completions、zhipu 走
  /api/paas/v4/chat/completions;merge 黑名单与 replace 校验同步扩容
- ChannelMonitorQuotaFetcher:窄接口聚合账号侧三个用量服务 + 账号加载,
  归一为 domain.MonitorQuotaSnapshot;成功快照 5min TTL 缓存防配额端点风暴;
  Fetch 永不返回 error(失败降级 Success=false 快照);状态推导
  operational/degraded(≥90% 或余额耗尽/账号未关联)/failed(401/403)/error
- RunCheck 按 check_mode 分派:quota 单行结果、quota_probe 探活+配额挂主行、
  probe 不变;Update 组合校验 + 关联账号复核(probe 自动解绑失效账号);
  Duplicate 拷贝新字段且 quota 模式空明文重加密(防 runner 误停摆)
- repo:Create/Update/entToServiceMonitor 透传 check_mode/account_id;
  InsertHistoryBatch 写 quota;ListLatestForMonitorIDs 裸 SQL 加 quota 列 +
  scanMonitorQuota JSONB 解包(NULL 安全);ListHistory 透传 quota
2026-08-18 04:03:13 +00:00
Randark 615e6901ea feat(channel-monitor): add quota mode schema (migration 226 + ent)
- 迁移 226:provider CHECK 扩到 8 平台(monitors + request_templates)、
  channel_monitors 加 check_mode/account_id(FK ON DELETE SET NULL)、
  channel_monitor_histories 加 quota JSONB、插入公开设置
  channel_monitor_show_quota(默认 false)
- ent schema 同步:provider 枚举 +4、endpoint 去 NotEmpty(quota 模式存空串)、
  新增 check_mode/account_id 字段、history 加 quota JSON 快照
- domain 新增归一化配额快照类型 MonitorQuotaSnapshot/Tier/Balance
  (放 domain 是因为 ent schema 需要引用,service 会造成 import cycle)
2026-08-18 03:50:01 +00:00
feeeei ab0fcd1a0e fix(gemini): Skipped 错误策略对齐 OpenAI,上游 4xx 不再硬改 500
ErrorPolicySkipped(池模式、或自定义错误码未命中)原来在响应写出上
自成一派:v1beta 原生把上游 4xx 硬改 500 后原文透传,/v1/messages
硬传 500 进映射(客户端拿到 502)。下游网关据此把请求级错误当可重
试的服务端故障反复换号,耗尽后改写成 All available accounts
exhausted(2026-08-17 gemini 生产事故链)。现对齐 OpenAI 路径语义:

- Skipped 只豁免账号状态标记,不豁免换号:可 failover 状态码一律
  返回 UpstreamFailoverError(poolModeSkippedFailoverError 泛化为
  skippedErrorPolicyFailoverError,同账号重试标记仍仅池模式携带)
- 池模式的不可 failover 4xx 保真:v1beta 原码+原文透传(新
  writeGeminiNativeUpstreamError 与 ErrorPolicyNone 共用同一写出,
  并补记 ops 事件),/v1/messages 与 chat completions 按真实状态
  码映射
- 自定义错误码未命中且不可 failover:三路径统一 500 + "Upstream
  gateway error" 固定文案,上游细节仅记 ops 错误日志
- 400 属确定性请求错误:mapped 写出回传脱敏后的上游 message,客户
  端可据此定位非法字段
2026-08-18 11:24:39 +08:00
Wesley Liddick 8869775ed3 Merge pull request #5636 from feitianbubu/fix/proxy-status-expired-i18n-key
fix(i18n): add missing expired key to account status block used by proxy list
2026-08-18 10:06:56 +08:00
Wesley Liddick f66a42c40e Merge pull request #5697 from feitianbubu/fix/ops-error-distribution-legend-labels
fix(admin): show category labels in ops error distribution legend
2026-08-18 10:05:39 +08:00
Wesley Liddick 7d633f5fc3 Merge pull request #5742 from heathermhuang/codex/fix-grok-response-model-audit
fix: normalize Grok response model audit aliases
2026-08-18 10:03:26 +08:00
Wesley Liddick b2d1c3859a Merge pull request #5738 from okbexx/fix/codex-identity-snapshot
fix(openai): make Codex convergence identity consistent
2026-08-18 09:57:05 +08:00
Randark 7e45634df9 chore: remove leftover Sora references after platform removal
PR #1463 removed the Sora platform, but some references survived:

- README/README_CN/README_JA kept the 'Sora status (temporarily
  unavailable)' sections and gateway.sora_* docs that the removal PR
  never touched.
- deploy/config.example.yaml still documented ~130 lines of sora_*
  gateway keys, the top-level sora: direct-client/storage block, and
  token_refresh.sync_linked_sora_accounts - none of which map to any
  field in the config structs anymore.
- The OIDC login PR (02a66a01c, branched off pre-removal main and
  merged 4 days after #1463) re-added the dead
  PublicSettings.SoraClientEnabled field, which no code ever sets.
- A release sync (748a84d87) re-introduced sora i18n keys that the
  later i18n split (d9e514f98) faithfully carried into
  locales/{zh,en}/admin/{overview,settings}.ts. No component references
  any of these keys.

This drops all of the above. Pure deletions, no behavior change.
2026-08-18 00:51:30 +00:00
Wesley Liddick 5253bb72b0 Merge pull request #5737 from lyen1688/feat/channel-time-pricing
功能:支持渠道模型分时倍率定价(支持峰谷定价)
2026-08-17 22:38:41 +08:00
shaw 6bf335965a merge main 并修复与 #5730 的语义冲突
main 侧 #5730 新增的 openai_gateway_cn_fixes_test.go 按旧 11 参签名调用
calculateOpenAIRecordUsageCost;本分支为该函数新增了第 12 个参数
pricingAt。文本无冲突但 test build 会失败,此处按本分支对同类测试
调用点的既有处理方式补传 time.Time{}。
2026-08-17 22:27:22 +08:00
Heatherm Huang c46d07ca07 fix: normalize Grok response model audit aliases 2026-08-17 21:15:12 +08:00
Wesley Liddick ab28a2d10b Merge pull request #5730 from Wei-Shaw/fix/cn-provider-group-entry-and-correctness
fix: 打通 CN 平台分组入口并修复五项功能缺陷(调度闸门 403/计费误计/断开漏记/count_tokens/403 处置)
2026-08-17 20:29:54 +08:00
Jarl 6793d5ac85 fix(openai): make Codex convergence identity consistent 2026-08-17 20:25:28 +08:00
lyen1688 9f24a55305 功能:支持渠道模型分时倍率定价 2026-08-17 19:45:07 +08:00
lbyxiaolizi 401dd43b4b fix(apicompat): 链式工具调用回放本轮 reasoning_content
codex 0.147.0 真实 resume 历史复现:DeepSeek 每轮只在开头流式产生一次
reasoning,reasoning → call A → output A → call B 的链式调用中 call B 前
没有 reasoning item,其 assistant 消息缺 reasoning_content,DeepSeek
thinking mode 400 整个历史。

buildChatMessagesFromItems 新增 lastTurnReasoning:记录本轮最近一次
reasoning,跨 tool output 存活,仅被 user 侧 item 清除;assistant 消息
(文本或 tool_calls)在 pendingReasoning 为空时回放本轮 reasoning。
2026-08-17 18:51:17 +08:00
shaw c38c5beef8 fix(i18n): CN 余额单元格误引 grokBalance 键致渲染原始 key
CNProviderBalanceCell 无快照占位标签引用 admin.accounts.grokBalance,
但该键实际嵌套在 admin.accounts.usageWindow 下,未命中时 DeepSeek/Kimi
payg 账号的用量窗口列直接显示原始键名。

- cnProviders 块新增自有占位键 balance(zh: '余额 --' / en: 'Balance --'),
  单元格改引该键
- 用 esbuild 实际加载模块对 zh/en 全部 admin 语言包做键路径深度比对:
  除本处外 CN 相关键(静态 14 个 + 动态拼接 accountMode/apiProtocol 系列)
  双语全部对齐;唯一存量漂移是 claudeMaxSimulation 块(zh 嵌套于
  modelRouting 下/en 为 groups 直属),全源码零消费方属死键,不在本次
  范围内处理
2026-08-17 17:44:33 +08:00
shaw 10c8b70203 fix(cn-providers): 修复 CN 分组五项功能缺陷(调度闸门/计费/断开漏记/count_tokens/403)
对已合并 PR #5666 + 分组入口放行后的全量功能审计发现的 P0/P1 修复,
全部先经代码与厂商文档实证再实施:

1. /v1/messages 调度闸门(P0):sanitizeGroupMessagesDispatchFields 对非
   openai 平台恒置 AllowMessagesDispatch=false,而闸门豁免名单只有 grok,
   CN 分组经正常途径创建后恒 403——原生 Anthropic 直通(Claude Code 主用例)
   完全不可达。修复:闸门对 CN 与 grok 同语义豁免;count_tokens 处的内联
   裸检查统一走同一 helper;ResolveMessagesDispatchModel 对 CN 早退,避免
   openai 专属的 gpt-5.x 默认映射发给 CN 上游。

2. 计费候选链(P0):候选链兜底含客户端原始模型名,配合 getFallbackPricing
   的 claude→Sonnet 统一兜底,映射的 CN 模型无价时 CN 流量会按 Claude 原价
   (数倍~数十倍)静默误计,且 usage 日志显示 claude-* 名无从察觉。修复:
   CN 账号的 claude-* 候选仅在显式分组/渠道定价时放行;候选全滤空时按
   ErrModelPricingUnavailable 走零成本+告警落账(顺带修复原空候选错误会
   丢弃整条 usage 记录的次生问题)。

3. 断开/中断漏记(P0,#5148 对齐,惠及 openai 平台):messages/responses/
   chat_completions 三个 handler 的错误路径此前在 err!=nil 时丢弃携带的
   部分 result——客户端断开排水后的完整 usage 被丢,payg 上游照常计费而
   平台漏记(anthropic 网关早有同修复,openai 网关缺失)。修复:错误路径
   result 非空时照常提交 usage;failover 错误恒 result=nil 无重复计费。
   Responses×anthropic 流式转换器同时改为断开后继续排水至流自然结束
   (末尾 message_delta 的 output_tokens 不再丢),finalize 帧补工具名反转
   与客户端工具还原、仅在客户端仍连接时写出。

4. count_tokens(P1,证据修正):经实证三家 Anthropic 兼容层均无
   /v1/messages/count_tokens(DeepSeek 官方文档无此端点且注明
   anthropic-version 被忽略;OpenModel 标注该端点 Anthropic only),
   anthropic 协议转发上游=常态 404,且错误处置缺模型上下文会把不计费的
   探测放大成整账号停调。修复:CN 全协议一律本地 tiktoken 估算(与 Grok
   同方案),删除上游转发死代码。

5. 403 处置(P1):CN 此前落入通用 handleAuthError,单次 HTML 403(CDN/
   代理拦截页)即永久禁用,且 403 在 failover 集里会逐账号重放连环禁用
   整组。修复:CN 与 openai 同口径——HTML 豁免 + 3 次累计 + 临时冷却。

新增回归测试 8 项:闸门豁免(含 openai 仍受控断言)、CN 调度映射空返回、
候选过滤三态、空候选零成本落账、断开排水 usage 完整性、HTML-403 零处罚、
结构化 403 首次临时停调。handler/service 全包测试通过。
2026-08-17 17:28:53 +08:00
shaw 7cdca9e495 feat(groups): 放行 kimi/zhipu/deepseek 平台分组创建入口
PR #5666 引入 CN 平台后,路由/调度/前端类型均已支持 CN 平台分组,但分组
创建入口两头缺失:后端 Create/UpdateGroupRequest 的 platform oneof 白名单
与前端 GroupsView 平台选项都没有三平台,导致 CN 账号「无可用分组」、整条
流量链路不通(composite 不能作为替代:CN 不可为 composite 路由目标)。

- group_handler.go: 两处 oneof 加 kimi/zhipu/deepseek;composite 路由目标
  白名单有意不动(DetectModelPlatform/isConcreteRequestPlatform 均无 CN 分支)
- GroupsView: platformOptions/platformFilterOptions 补三项;两处徽章配色链
  按 platformColors.ts 色系补 CN 分支
- i18n: admin.groups.platforms 补 kimi/zhipu/deepseek 键(zh/en),缺键时
  分组徽章/GroupRPM/RateMultipliers 弹窗/ChannelsView 会渲染原始 key
- GroupBadge: badgeClass/labelClass 补 CN 配色
- 新增表驱动测试:9 平台 Create/Update 全放行、非法值(别名/大小写/空格)
  全拒绝、composite target 对 CN 保持拒绝的守卫
2026-08-17 17:28:51 +08:00
wucm667 971544570d test(antigravity): check tool config assertions 2026-08-17 17:11:33 +08:00
lbyxiaolizi 612436a5a7 fix(openai-compat): Responses→Chat 桥接按 reasoning item id 缓存回注 reasoning_content
修复 #5520:Codex 经 force_chat_completions 桥接到 DeepSeek thinking 上游时,
历史中的 encrypted-only reasoning item(summary 为空 + 不透明 encrypted_content,
远程 compaction / 跨会话恢复后常见)取不出明文,后续 assistant 消息缺
reasoning_content,DeepSeek 400 "The reasoning_content in the thinking mode
must be passed back to the API",客户端仅看到通用 502。

reasoning item 的 id 一定会被客户端回传,以其为 key 做服务端缓存:

- GatewayCache 新增 Set/GetReasoningContent(Redis,默认 TTL 7 天)
- 响应侧:流式扫 response.output_item.done、非流式扫 output,把 reasoning
  全文按 item id 写缓存;客户端断连后 drain 期间用 detached ctx 仍会写完
- 请求侧:apicompat 新增 ResponsesToChatCompletionsRequestWithOptions 与
  ReasoningContentByID 钩子,encrypted-only item 查缓存补回 pendingReasoning;
  缓存 miss/出错一律 fail-open 维持原行为
- 自愈:历史里带明文 summary 的 reasoning item 顺手刷新缓存,覆盖 Redis
  flush / 跨实例漂移

测试:apicompat(命中恢复/miss 保持原样/明文优先)、service 端到端(流式
写缓存、请求侧回注+自愈)、repository miniredis 存取。
2026-08-17 16:48:36 +08:00
Tamseno 3c3bb2fa19 fix(gemini): support includeServerSideToolInvocations in GeminiToolConfig
- Add IncludeServerSideToolInvocations field to GeminiToolConfig to prevent dropping client tool settings.
- Fix HTTP 400 error when mixing built-in tools (e.g. Google Search) with function calling on Gemini 3.6/3.7 models.
- Add serialization/deserialization unit test TestGeminiToolConfig_IncludeServerSideToolInvocations.

Fixes #5709
2026-08-17 15:23:49 +08:00
Wesley Liddick e330c243a8 Merge pull request #5666 from Randark-JMT/feat/cn-providers-kimi-zhipu-deepseek
feat: 国产供应商多协议支持(Kimi/Zhipu/DeepSeek 原生 Anthropic 直通 + DeepSeek Responses)与配额/余额监控
2026-08-17 14:55:20 +08:00
lyy0709 8d82bb069c fix(openai): expose bulk account settings 2026-08-17 13:56:16 +08:00
Tamseno b8642ef674 fix(auth): make invitation code consumption atomic with user creation
RegisterWithVerification checked CanUse() and then marked the code used in
two separate, non-transactional steps; the second step's failure was
swallowed ("invitation code mark failure does not affect registration").
Concurrent registrations with the same invitation code could all pass the
check and each create an account, turning a one-time invitation code into
an unlimited account factory (TOCTOU race).

Fix:
- AuthService: create user and claim the invitation code inside one DB
  transaction (createUserAndClaimInvitation). The claim reuses
  redeemRepo.Use's conditional UPDATE (WHERE status='unused'); losers are
  rejected with INVITATION_CODE_INVALID and their transaction (including
  the user insert) is rolled back. No-code registration path unchanged.
- userRepository.create: explicitly join an outer ent transaction via
  TxFromContext instead of relying on Client.Tx returning ErrTxStarted
  (ent's Tx never inspects the context, so the old reuse branch was dead
  code and user inserts always committed in their own transaction,
  leaving orphan users behind when the outer transaction rolled back).

Regression tests:
- unit: concurrent register with one invitation code must succeed exactly
  once (8 goroutines -> 1 success, 7 x INVITATION_CODE_INVALID)
- integration: outer-tx rollback removes user and releases the claim;
  commit persists both atomically
2026-08-17 13:49:46 +08:00
lyy0709 76b70b1685 fix(openai): validate bulk account settings 2026-08-17 13:34:41 +08:00
xiaobo 8a0a76aeb5 Merge pull request #1 from xiaobo121388/codex/add-ollama-usage-query
feat: add Ollama Cloud usage query action
2026-08-16 21:40:44 -07:00
wucm667 cb5e03a720 fix(antigravity): preserve mixed Gemini tool config 2026-08-17 12:36:58 +08:00
xiaobo 9aac3b73f7 add Ollama usage query action 2026-08-16 21:33:53 -07:00
Randark e728545382 style: gofmt 迁移测试注释(CI golangci-lint gofmt) 2026-08-17 04:26:22 +00:00
Randark 4b667ccd45 fix(review): 处理 PR #5666 四个阻断项(B1-B4)
- B1: 移除根目录 docker-compose.yml(个人镜像测试产物混入)
- B2: 新增迁移 224 放宽 user_platform_quotas CHECK 至 8 平台,补
  BulkInsertInitial 国产平台集成测试与迁移内容断言测试
- B3: CC/Responses×anthropic 四个上游读循环接入间隔超时泵
  (gateway.stream_data_interval_timeout,默认 180s):上游挂住 SSE
  不发数据也不断连时结束排水/组装、关闭 resp.Body 归还连接池位,
  排水超时仍按已累计 usage 返回(与 messages 主路径同语义)
- B4: 配额/余额探测发起前过 cnValidateProbeURL(与网关转发同一套
  security.url_allowlist 策略),被拒时零出站、API key 不离开本机
2026-08-17 04:07:06 +00:00
wucm667 1977810cf2 fix(frontend): isolate account helper data loading 2026-08-17 11:04:50 +08:00
wucm667 79c2eb5020 fix: skip expiry reminders without SMTP config 2026-08-17 10:59:43 +08:00
wucm667 5f19433103 fix(ops): avoid single-insert fallback after batch failure 2026-08-17 10:52:57 +08:00
Wesley Liddick 396a9d1130 Merge pull request #5703 from InCerryGit/fix/clarify-openai-fast-policy-ui
fix(frontend): clarify OpenAI Fast/Flex policy rules
2026-08-17 09:40:53 +08:00
Wesley Liddick 51016bb03a Merge pull request #5690 from luckydududu/fix/dockerfile-golang-image-follows-gomod
fix(docker): Go 构建镜像跟上 go.mod 的 1.26.6(否则任一 Dockerfile 构建都直接失败)
2026-08-17 09:30:48 +08:00
InCerryGit 22fc0cdbf5 fix(frontend): clarify OpenAI Fast/Flex policy rules 2026-08-16 23:49:21 +08:00
feitianbubu cb7841d85c fix(i18n): add missing expired key to account status block used by proxy list 2026-08-16 18:48:49 +08:00
feitianbubu e8ff2017c0 fix(admin): show category labels in ops error distribution legend 2026-08-16 18:04:52 +08:00
Lucky 11e1e22882 fix(docker): bump Go builder image to 1.26.6 to match go.mod
89d826be2 raised backend/go.mod to `go 1.26.6` and updated the three CI
workflows' version assertions, but left the Go builder image in all three
Dockerfiles pinned at 1.26.5. Since the official golang images set
GOTOOLCHAIN=local, the toolchain is not auto-downloaded and any image build
fails hard at `go mod download`.

CI does not catch this: the workflows build with actions/setup-go, not with
these Dockerfiles.

Also extend the Go-upgrade checklist in DEV_GUIDE.md, which listed only the
CI files -- that omission is why the Dockerfiles were missed.
2026-08-16 06:17:43 +00:00
myy_321 b1e60ba453 fix(gateway): 修复池模式同账号错误重试
- 透传上游错误处理结果,保留池模式重试判定\n- 对可重试状态码设置同账号重试标记\n- 补充 Chat Completions 与 Responses 的 429 回归测试
2026-08-16 08:29:12 +08:00
Perfecto a600dd1c0a Merge remote-tracking branch 'origin/main' into agent/openai-capacity-failover 2026-08-15 22:54:27 +08:00
Perfecto 5390647989 fix(openai): complete request-scoped capacity recovery 2026-08-15 22:54:21 +08:00
github-actions[bot] baeac1f3de chore: sync VERSION to 0.1.177 [skip ci] 2026-08-15 13:40:21 +00:00
Perfecto c3063e01a4 fix(openai): recover message-only capacity failures 2026-08-15 21:30:56 +08:00
Randark 901a0439f1 feat: 国产供应商一等支持(Kimi/Zhipu/DeepSeek 多协议 + 配额/余额监控)
后端:
- 协议凭证维度 credentials[api_protocol] ∈ chat_completions(默认)/anthropic/responses(deepseek)
- /v1/messages 零转换直通原生 Anthropic 端点(kimi/zhipu/deepseek),CC/Responses
  入站交叉组合走 apicompat 双向转换链(responses/chat_completions anthropic-native 转发器)
- count_tokens:anthropic 协议透传原生端点;其余 CN 协议本地 tiktoken 估算
- Coding Plan 额度探测(5h/weekly 滚动窗口)+ payg 余额探测(kimi/deepseek),
  deepseek 双币种 CNY+USD 明细,任一币种达标不停调
- 周期任务 [CNBalance] 并发探测 + 预算随工作量放大;响应式 429 冷却到最早窗口
  重置点;余额不足可恢复临时停调;智谱 CREDIT_LIMIT 不污染窗口解析
- CC→anthropic 流式客户端断开后继续排水上游保住 usage 计量

前端:
- 创建/编辑弹窗 account_mode + api_protocol + base_url 联动预设(含 watcher 竞态防护)
- 用量单元格:kimi/zhipu coding 显示 5h/weekly 窗口,kimi/deepseek payg 显示余额,
  多币种并列展示;探测失败保留快照;挂载自动探测 5min 去抖
- 调度阈值设置面板补 kimi/zhipu 平台(对齐后端 AllowedSchedulingThresholdPlatforms)
2026-08-15 10:37:51 +00:00
Wesley Liddick 073e92d171 Merge pull request #5668 from Wei-Shaw/fix/codex-turn-state-and-fingerprint-optin
fix(openai): Codex 回合状态回传、v2 压缩探测与指纹收敛改为 opt-in
2026-08-15 17:10:18 +08:00
feeeei d677d67dda feat: OpenAI Team 联动熔断
OpenAI OAuth 账户收到 402 deactivated_workspace(ChatGPT Team 工作区被停用)时,
将同一 Team(credentials.chatgpt_account_id 相同)的其余 active 账户一并置为
error 并进程内立即熔断,让调度瞬间切走,避免流量继续打到已死的兄弟账户。

- 触发条件仅限 402 + detail.code == deactivated_workspace;通用 402 与 401 行为不变
- 调用点两处:fastpath 各类早退之前 + HandleUpstreamError 顶部,进程内 60s 按 Team 去重
- 逐账户 SetError(错误信息标注触发账户),单账户失败不中断其余
- 默认生效,无配置开关
2026-08-15 16:48:40 +08:00
shaw 4d9fedee20 fix(test): check type assertions in turn-state provenance tests
errcheck runs with check-type-assertions enabled, so the single-value
assertions on the provenance map values fail lint.
2026-08-15 16:44:30 +08:00
shaw fce41e318f fix(openai): make Codex fingerprint convergence opt-in and cover passthrough
Default codex_fingerprint_mode to off. v0.1.175 treated a missing key as
"session", so upgrading silently rewrote installation/session/thread/turn/
window identifiers for every existing OAuth account that had never configured
this field. The quota regressions in #5555, #5556 and #5582 line up with that
version boundary, with A/B reports that rolling back to v0.1.173 restores
quota. Convergence is now explicit opt-in (#5610).

Only accounts that never set the field change behaviour; explicit off /
device / session / full keep working exactly as configured. That required
flipping the persistence condition in all three account modals from
"!== 'session'" to "!== 'off'": the old rule deleted the key when it equalled
the default, which after the flip would have silently discarded an
administrator's explicit opt-in to session.

Also extend convergence to the passthrough path, which previously left client
identifiers untouched:

- resolve the ids once in forwardOpenAIPassthrough and rewrite
  client_metadata on the raw bytes (gjson extract + sjson splice) because
  passthrough is a hot path that must not fully unmarshal multi-MB bodies;
  a shared core keeps the raw and map variants from drifting
- both request builders apply the staged ids at the same relative position
  (after session isolation, before identity enforcement) so headers and body
  share one id set and turn_id stays consistent
- stage the ids unconditionally, including nil: a failover from a converged
  account to an off account must not leave the previous account's ids behind
2026-08-15 16:35:26 +08:00
shaw 8ae6d8f67e fix(openai): send session-level beta features and probe native compaction v2
OpenAI sunset the legacy unary /responses/compact endpoint (404, #5598,
#5624), so the account "compact probe" in the admin UI kept failing even for
healthy accounts, and the beta-feature negotiation header was only attached
to compaction turns.

Beta features (codex-rs session/mod.rs build_model_client_beta_features_header
+ client.rs build_responses_headers): the header is a session-level constant
attached to every /responses request, the WS handshake and /responses/compact.
Enumerating FEATURES shows no Experimental feature is enabled by default, so a
default install sends exactly "remote_compaction_v2". Mirror that:

- OAuth requests without a client-declared header get the default shape, so we
  no longer produce a "header only on compaction turns" pattern real Codex
  never emits (#5586 chains that strip the header)
- a client-declared header is preserved as-is: non-empty without v2 means the
  user disabled the feature and the gateway must not rewrite that
- native v2 turns (compaction_trigger in body) always ensure v2 is present
- non-OAuth upstreams keep the compaction-turn-only behaviour
- the WS injection sits outside the client-header copy block so prewarm and
  turn handshakes cannot land in different pool compatibility buckets

Compact probe now exercises native v2 (streaming /responses +
compaction_trigger) instead of the dead endpoint. Success requires an actual
compaction output item — scanning output_item.done/added, the terminal
response.output[] and the whole-JSON fallback — so a 2xx that silently drops
the trigger is reported as unsupported (the "got 0 items" class, #5478,
#5648). Probe identity is now UUID-shaped and applies the account's
convergence, matching real traffic on the same endpoint.
2026-08-15 16:35:08 +08:00
shaw 8219dcfc87 fix(openai): relay x-codex-turn-state and guard cross-account echo
Codex captures x-codex-turn-state from /responses SSE, /responses/compact
JSON and the WS handshake (codex-api sse/responses.rs, endpoint/compact.rs),
then echoes it back on later requests of the same turn. The HTTP path dropped
it because the header is not in the generic response allowlist, while the WS
path already relayed it — an inconsistency that broke the protocol chain.

Relay it explicitly at every commit point instead of widening the global
allowlist (which would leak it into Anthropic/Gemini responses):

- streaming, non-streaming and SSE-to-JSON handlers relay it, clearing any
  value left over by a previous failover attempt when upstream sends none
- under the first-output guard the header is only staged; provenance is
  recorded when applyAttemptResponseHeaders actually writes it, because a
  first-output timeout discards the staged headers and the client never
  receives that blob
- record (api key + client session) -> minting account, TTL-bounded with an
  opportunistic sweep, and strip echoes known to come from another account
  before they go upstream. Stripping only: injection is the Claude bridge's
  job. Same-account or unknown provenance passes through unchanged.
2026-08-15 16:34:47 +08:00
Wesley Liddick 1d3b9665c8 Merge pull request #5641 from InCerryGit/fix/issue-5624-remote-compaction-v2
fix(openai): preserve remote compaction v2 responses endpoint
2026-08-15 13:46:31 +08:00
yzxcj797 98c7b0e88e docs: fix self-referential URLs after file moved into docs/ 2026-08-15 11:42:47 +08:00
wucm667 44ef88f659 fix(openai): restore API-key custom tools 2026-08-15 11:02:59 +08:00
Wesley Liddick c204d33b09 Merge pull request #5649 from lyen1688/feat/group-usage-daily-rollups
feat: 优化分组用量统计
2026-08-15 09:00:16 +08:00
zninggo 1e1798d90d fix(gateway): Composite 分组放行视频生成端点
视频状态(/videos/:id)与内容下载(/videos/:id/content)都已放行
PlatformComposite, 唯独创建端点仅放行 PlatformGrok. Composite 分组
用户配好模型路由后可查询可下载, 唯独创建 404.

补齐放行判断, 与另外两个视频端点保持一致; 其他平台行为不变.
2026-08-15 01:20:26 +08:00
lyen1688 45dcce0e49 修复:隔离分组用量仓储测试时区
分组用量仓储测试原先固定恢复上海时区,覆盖集成测试包初始化的 UTC,并导致后续今日统计多算。统一保存并恢复测试进入前的时区,避免 unit 和 integration 用例相互污染。
2026-08-14 23:48:03 +08:00
lyen1688 89d826be29 修复:解决分组用量 PR 的 CI 失败
恢复分组时区测试进入前的全局时区,避免污染高峰计费和支付租约测试。同步升级 Go 1.26.6 与 nanoid 3.3.18,清除后端和前端安全门禁。
2026-08-14 23:25:31 +08:00
lyen1688 cb7b03795d feat: 优化分组用量统计 2026-08-14 22:34:43 +08:00
InCerryGit a8b9ea22b7 fix(openai): separate native and legacy compaction routing 2026-08-14 18:18:53 +08:00
InCerryGit 9662cff2e7 fix(openai): preserve remote compaction v2 responses endpoint 2026-08-14 16:23:52 +08:00
风起 bafd2e293f fix(apicompat): omit empty tool name on streamed arguments deltas
The responses->chat streaming converter re-emits the tool name as an empty
string on every function_call_arguments.delta because ChatFunctionCall.Name
has no omitempty. OpenAI-compatible clients accumulate the name from the
first tool-call delta, so the trailing "name":"" overwrites it and the
call fails dispatch (unknown tool ""). Empty names also break the next
round-trip upstream (missing field 'name').

Fixes #5631
2026-08-14 06:32:38 +00:00
Cornna 21c07e8351 fix(antigravity): use official daily endpoint
Align the daily Cloud Code host with the current Antigravity client so requests do not use the legacy sandbox hostname.
2026-08-13 21:31:42 -07:00
li 3c5553e253 fix(responses): 网关合成的 Responses 对象补齐 created_at,修复严格客户端反序列化失败
严格的 Responses 客户端(Rust serde 系,如 Codex / Grok CLI)把 created_at 声明为
必填字段,缺失即 `missing field 'created_at'` 直接读不出响应。

apicompat.ResponsesResponse 此前根本没有这个字段,六个构造点全部不写:
Chat→Responses 与 Anthropic→Responses 两座桥的非流式结果、response.created、
终止事件,以及两处手写的 response.failed 帧。

两个流式 state 早就在 New*State() 里采集了 Created 时间戳,只是没有出口——
反方向(Responses→Chat)每个 chunk 都老老实实发 created,正方向直接丢在地上。

原生 Responses 透传走 gjson/sjson 字节级改写,保留上游原样,不受影响;
本次只补合成路径。

Fixes #5601
2026-08-14 11:05:29 +08:00
li 901a77cfb1 fix(apicompat): Anthropic→Chat 桥回传工具调用的 thinking,修复 DeepSeek 多轮 400
/v1/messages 打到只支持 Chat Completions 的 OpenAI 兼容上游时,
anthropicAssistantToChatMessages 把历史 assistant 消息里的 thinking 块整块丢弃。
DeepSeek 的 thinking mode 要求产生工具调用的 reasoning_content 随该 assistant
消息回传,缺失即 400 —— 表现为「单轮正常,一进多轮工具对话必现失败」。

这些 thinking 块本来就是同一个文件的出站方向
(chatMessageToAnthropicBlocks) 用上游 reasoning_content 生成的,客户端只是
原样回传:桥自己造出来的东西,回来时被自己丢掉。

兄弟路径 Responses→Chat (buildChatMessagesFromItems 的 pendingReasoning) 早已
处理同一约束。本次把同一策略补到漏掉的这条桥上,作用域严格对齐:reasoning 只挂
到带 tool_calls 的 assistant 消息,纯文本轮次维持现状。

Fixes #5528
2026-08-14 10:33:47 +08:00
wucm667 e7a3c1202e fix(antigravity): route paid accounts to daily endpoint 2026-08-14 02:40:18 +08:00
wucm667 674570ca17 fix: preserve group pricing in auth snapshots 2026-08-14 00:25:55 +08:00
wucm667 a288bab73a fix(gateway): align passthrough model discovery 2026-08-13 12:40:06 +08:00
Wesley Liddick fbfdcef818 Merge pull request #5573 from IanShaw027/fix/grok-long-context-and-media-fallback
fix(grok): 长上下文阶梯不再被 OpenAI 账号开关否决,媒体 ID 不继承文本价
2026-08-13 10:32:07 +08:00
github-actions[bot] 0e82efe489 chore: sync VERSION to 0.1.176 [skip ci] 2026-08-13 01:46:47 +00:00
Wesley Liddick e803e3851c Merge pull request #5559 from seng1e/fix/scheduled-backup-leader-lock
fix(backup): 定时备份加 leader 锁,避免多实例重复备份
2026-08-13 09:24:11 +08:00
Wesley Liddick 5912ae960c Merge pull request #5543 from luckydududu/fix/invalidate-channel-cache-on-group-platform-change
fix(group): 改分组 platform 后失效渠道缓存(否则最长 10 分钟按旧平台计价)
2026-08-13 09:24:00 +08:00
Wesley Liddick e77b4b260e Merge pull request #5504 from fengshao1227/fix/responses-probe-inconclusive-keeps-unknown
fix(openai): 探测响应未跑完时不再落标为「上游不支持 Responses」
2026-08-13 09:23:50 +08:00
IanShaw027 e215c98c2c fix: 账号页自动刷新偏好改为模块初始化时恢复
onMounted 再读一次会覆盖已在 setup 阶段恢复的偏好。
2026-08-13 09:22:41 +08:00
IanShaw027 e29b93a1fb fix: 未知 Grok 文本兜底排除 image/video/audio 等媒体族
带版本号的媒体 ID(grok-2-image-1212、grok-5-video)会被
grok-N* 白名单当成文本,错吃 4.5 token 价。vision 多模态
仍按 token 计;HasIdentifiedTokenPricing 也不放行这些 ID。
2026-08-13 09:22:38 +08:00
IanShaw027 fd82dfd52d fix: Grok 长上下文只跟分组开关,不受 OpenAI 账号开关否决
openai_long_context_billing_enabled 是 OpenAI 账号设置,Grok
账号无法打开。按账号硬传 false 会让分组开关失效,官方 200k
阶梯和渠道多档区间全部塌到第一档。非 OpenAI 不再传账号门闩。
2026-08-13 09:22:34 +08:00
Wesley Liddick 4853662f99 Merge pull request #5540 from luckydududu/fix/channel-pricing-model-name-normalization
fix(channel): 定价冲突检测与定价缓存键的归一化对齐(#4754 修复残留)
2026-08-13 09:12:41 +08:00
Wesley Liddick 0fa577a19c Merge pull request #5571 from IanShaw027/feat/grok-jwt-tier-and-4.6
feat(grok): 以 JWT tier 识别订阅档位并接入 grok-4.6,修正徽章滞后、未知模型零计费与搜索/Voice 边界
2026-08-13 09:08:35 +08:00
IanShaw027 b61e4bcc45 fix: 对齐 JWT 常量 gofmt,并补全 available groups 契约字段
golangci-lint 要求 const 块按类型对齐;分组 DTO 新增
long_context_pricing_enabled 后,契约夹具未同步导致比较失败。
2026-08-13 08:55:56 +08:00
IanShaw027 0ae151a238 fix: 徽章改读 grok_usage_snapshot,增量刷新比较 Grok 快照
后端写入 grok_usage_snapshot,列表却读无 writer 的 grok_quota_snapshot;
增量刷新不比 Grok 快照,档位更新后行不替换。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  main   openAIHTTPPassthroughSSERateLimitUpstream
         TestOpenAIResponses_APIKeyPassthroughSSERateLimitUsesConfiguredPoolRetry
  branch openAIHTTPPassthroughAuthFailoverUpstream
         TestOpenAIResponses_APIKeyPassthroughPoolAuthFailureRetriesThenSwitchesToHealthyAccount

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

前端
- 选择该模式时提示"计费基准以上游自报模型为准,只降不升,仅对可信上游启用"。
2026-08-10 18:45:15 +08:00
pigzwy 9096492b55 feat(billing): support safe upstream response model billing 2026-08-10 18:45:14 +08:00
wucm667 3d3aee2e72 fix(openai): skip stale and reset Codex snapshots in scheduling threshold evaluator 2026-08-10 14:27:04 +08:00
Pengap 4d4a0be1ad fix(apicompat): chat/completions file part 不再被静默丢弃,转换为 Responses input_file
/v1/chat/completions → Responses 转换层此前只处理 text 和 image_url 两种
content part,type:"file"(PDF 附件)被静默丢弃:请求返回 200、模型正常
回答,但 prompt 里没有文件(prompt_tokens 只剩纯文字)。

现将 file part 映射为 Responses API 的 input_file(filename/file_data/
file_id 透传),与同网关 /v1/responses + input_file 实测可用的格式一致。
无 file_data 且无 file_id 的空 file part 跳过,与空 image URL 行为一致。

参考上游 PR #2497(因混入无关改动未合并)。
2026-08-10 13:59:20 +08:00
li 591d47fb9b fix(openai): 原生 Responses 路径不再把上游确定性 400 归一成可重试 502
上游返回 400(invalid_function_parameters、missing_required_parameter 等)时,
`/v1/responses` 原生路径把它包成 `502 upstream_error / "Upstream request failed"`,
真实的 message/code/param 全部丢弃。

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Add registration_email_domain_quota_enabled (default false) to gate it:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Fixes #5289
2026-08-05 16:28:16 +08:00
Rain 5c52fa93d5 fix grok missing usage billing bypass 2026-08-05 09:49:25 +08:00
wucm667 d3b5703b55 fix(model-plaza): show composite group models 2026-08-04 19:14:28 +08:00
wucm667 c418fd522f [verified] fix(security-audit): deduplicate websocket turn audits 2026-08-03 20:35:56 +08:00
li 66ad405dd6 fix(upstream): set explicit TCP dial timeout on upstream transports
http.Transport 未设置 DialContext 时使用零值 net.Dialer(Timeout=0),
DNS 解析与 TCP 握手没有任何上限,只能等内核 TCP 重传耗尽(Linux 约 130 秒)。
ResponseHeaderTimeout 只约束连接建立之后等待响应头的阶段,覆盖不到建连。

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

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

Fixes #5151
2026-08-03 19:59:58 +08:00
wucm667 7045f89de4 fix(openai): retry pool auth failures before failover 2026-07-29 18:47:07 +08:00
feitianbubu 0d5e3ca9b0 fix(ops): show neutral SLA card when window has no requests 2026-07-11 19:48:08 +08:00
feitianbubu 35e8ba2a3e fix(announcements): use proper empty-state copy instead of error message 2026-07-11 18:28:21 +08:00
feitianbubu a6d868f27b fix(dashboard): include cache tokens in token card breakdown 2026-07-11 17:18:25 +08:00
feitianbubu 7d796f1119 fix(ui): adapt native form controls to dark mode via color-scheme 2026-07-11 00:08:26 +08:00
feitianbubu 3bff4b64b1 fix(ui): localize user role label in app header 2026-07-10 23:43:16 +08:00
feitianbubu 5e72deb7d4 feat: ops 错误详情弹窗支持自定义时间区间 2026-05-01 14:16:49 +08:00
1208 changed files with 143529 additions and 9483 deletions
+3
View File
@@ -49,6 +49,9 @@ coverage/
.env.*
!.env.example
# 本地闭源插件目录可能包含发布签名私钥,绝不能进入 Docker 构建上下文。
/plugins/
# Local config
config.yaml
config.local.yaml
+7
View File
@@ -35,3 +35,10 @@ exceptions:
mitigation: "Proxy configuration not user-controlled; upgrade when axios releases fix"
expires_on: "2026-07-10"
owner: "security@your-domain"
- package: nanoid
advisory: "GHSA-2v37-7h3g-55p8"
severity: high
reason: "Custom generator with size=0 not used; all nanoid calls use default size or positive values"
mitigation: "No zero-size custom generators in codebase; upgrade when nanoid releases fix"
expires_on: "2026-10-13"
owner: "security@your-domain"
+4 -3
View File
@@ -17,6 +17,7 @@ jobs:
/bin/bash -n deploy/apple-container.sh
/bin/bash deploy/tests/apple-container-test.sh
/bin/sh deploy/tests/docker-compose-security-test.sh
/bin/sh deploy/tests/docker-compose-gateway-env-test.sh
/bin/sh deploy/tests/docker-runtime-resources-test.sh
/bin/sh deploy/test-caddyfile-cache.sh
@@ -32,7 +33,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
go version | grep -q 'go1.26.5'
go version | grep -q 'go1.27.0'
- name: Unit tests
working-directory: backend
run: make test-unit
@@ -72,10 +73,10 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
go version | grep -q 'go1.26.5'
go version | grep -q 'go1.27.0'
- name: golangci-lint
uses: golangci/golangci-lint-action@v9
with:
version: v2.9
version: v2.13
args: --timeout=30m
working-directory: backend
+1 -1
View File
@@ -115,7 +115,7 @@ jobs:
- name: Verify Go version
run: |
go version | grep -q 'go1.26.5'
go version | grep -q 'go1.27.0'
# Docker setup for GoReleaser
- name: Set up QEMU
+1 -1
View File
@@ -23,7 +23,7 @@ jobs:
cache-dependency-path: backend/go.sum
- name: Verify Go version
run: |
go version | grep -q 'go1.26.5'
go version | grep -q 'go1.27.0'
- name: Run govulncheck
working-directory: backend
run: |
+15 -1
View File
@@ -1,4 +1,5 @@
docs/claude-relay-service/
# 本地杂项文档、测试数据和外部项目副本
/docs-local/
.codex
# ===================
@@ -136,10 +137,23 @@ docs/*
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
!docs/ASYNC_IMAGE_TASKS.md
!docs/BATCH_IMAGE_MVP.md
!docs/COMPOSITE_GROUPS.md
!docs/PLUGIN_DEVELOPMENT.md
!docs/channel-monitor-v2-safe-defaults.md
!docs/legal/
!docs/legal/*.md
!docs/screenshots/
docs/screenshots/*
!docs/screenshots/mobile-account-actions-menu.png
.serena/
.codex/
frontend/coverage/
aicodex
output/
.codegraph/
# Vitest / Vite cache at repo root
.vite/
# 本地闭源插件开发目录及构建产物不进入 Sub2API 仓库
/plugins/
+5 -5
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.13,本地建议装同一版以免版本差异带来的噪音)
go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@v2.13
# 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.13 |
| **security-scan.yml** | push, pull_request, 每周一 | govulncheck + gosec + pnpm audit |
| **release.yml** | tag `v*` | 构建发布(PR 不触发) |
### CI 要求
- Go 版本必须是 **1.25.7**
- Go 版本必须是 **1.27.0**:三个 workflow 都用 `go-version-file: backend/go.mod` 取版本,随后硬断言 `go version | grep -q 'go1.27.0'`。升级 Go 时要同时改 `backend/go.mod`、`backend-ci.yml`(两处)、`release.yml`、`security-scan.yml` 里的这句断言,**以及三个 Dockerfile 里的 Go 构建镜像**(`Dockerfile` / `deploy/Dockerfile` 的 `ARG GOLANG_IMAGE`、`backend/Dockerfile` 的 `FROM golang:`)。前者漏了 CI 会在版本校验步骤直接失败;**后者漏了 CI 不会报,而是等到有人用这些 Dockerfile 构建时才失败**(`go.mod requires go >= X (running Y; GOTOOLCHAIN=local)`)。
- 前端使用 `pnpm install --frozen-lockfile`,必须提交 `pnpm-lock.yaml`
### 本地测试命令
@@ -203,7 +203,7 @@ go test -tags=integration ./...
**解决**:
```bash
cd backend
go generate ./ent # 重新生成 ent 代码
go generate ./ent # 重新生成 ent 代码(json.RawMessage 字段会生成为同类型的 jsontext.Value,属预期)
git add ent/ # 生成的文件也要提交
```
+1 -1
View File
@@ -8,7 +8,7 @@
# =============================================================================
ARG NODE_IMAGE=node:24-alpine
ARG GOLANG_IMAGE=golang:1.26.5-alpine
ARG GOLANG_IMAGE=golang:1.27.0-alpine
ARG ALPINE_IMAGE=alpine:3.21
ARG POSTGRES_IMAGE=postgres:18-alpine
ARG GOPROXY=https://goproxy.cn,direct
+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
+53 -25
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.27.0-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
@@ -58,6 +58,11 @@ Please read the following carefully before using this project:
<td>Thanks to AIGoCode for sponsoring this project! AIGoCode is an all-in-one platform that integrates Claude Code, Codex, and the latest Gemini models, providing you with stable, efficient, and highly cost-effective AI coding services. The platform offers flexible subscription plans, zero risk of account suspension, direct access with no VPN required, and lightning-fast responses. AIGoCode has prepared a special benefit for sub2api users: if you register via <a href="https://aigocode.com/invite/SUB2API">this link</a>, you'll receive an extra 10% bonus credit on your first top-up!</td>
</tr>
<tr>
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>Huge thanks to BmoPlus for sponsoring this project! BmoPlus is a highly reliable AI account provider built strictly for heavy AI users and developers. They offer rock-solid, ready-to-use accounts and official top-up services for ChatGPT Plus / ChatGPT Pro (Full Warranty) / Claude Pro / Super Grok / Gemini Pro. By registering and ordering through <a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus - Premium AI Accounts & Top-ups</a>, users can unlock the mind-blowing rate of 10% of the official GPT subscription price (90% OFF)</td>
@@ -117,18 +122,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>Thanks to code0.ai for sponsoring this project! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> is an AI coding workbench for developers and engineering teams, aggregating mainstream agent coding capabilities such as Claude Code and Codex, and covering common development scenarios including code generation, project understanding, debugging and fixing, code review, and documentation generation. It suits independent developers, Agent engineers, open-source maintainers, and enterprise R&D teams, with invoicing and team onboarding supported. Register via the <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits and experience a more efficient AI coding workflow.
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
@@ -164,6 +157,21 @@ Please read the following carefully before using this project:
<td><a href="https://hao.ai">hao.ai</a> is a high-speed, stable unified large-model API gateway for developers and teams. With a single API Key and a unified interface, you can access mainstream models such as GPT, Claude, and xAI Grok, with compatibility for common protocols and SDKs including OpenAI and Anthropic. The platform provides model routing, failover, team management, and complete request logs, with model prices as low as 15% of official reference pricing, helping users build AI applications more simply, more reliably, and at lower cost.</td>
</tr>
<tr>
<td width="180"><a href="https://www.swiftproxy.net/?ref=sub2api"><img src="assets/partners/logos/swiftprox.png" alt="Swiftproxy" width="150"></a></td>
<td>Swiftproxy is a high-performance proxy solution built for developers, providing stable and reliable residential and static residential proxy services. With 90M+ clean residential IPs, global coverage, flexible rotation, and precise geo-targeting, it helps projects such as web scraping, AI automation, browser automation, SEO monitoring, and multi-account management overcome access restrictions and improve workflow efficiency. It supports HTTP(S) and SOCKS5 protocols, integrates with popular automation tools like Playwright, Selenium, and Puppeteer, with dynamic proxy traffic that never expires until used and free testing available — <a href="https://www.swiftproxy.net/?ref=sub2api">start your free test now</a>!</td>
</tr>
<tr>
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 90M+ global residential network resources across 195+ countries and regions, with rotation and sticky sessions for public data collection, RAG updates, model evaluation, and multi-region data workloads. 🟢Residential Proxy - 20% Off; 🟢Static Residential Proxy - Starting at ¥50.00/IP; 🟢Unlimited Residential Proxy - Starting at ¥19.8/Hour. ✅Get 500M Free Trial.</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
</tr>
</table>
## Overview
@@ -196,7 +204,7 @@ Community projects that extend or integrate with Sub2API:
| Component | Technology |
|-----------|------------|
| Backend | Go 1.25.7, Gin, Ent |
| Backend | Go 1.27.0, Gin, Ent |
| Frontend | Vue 3.4+, Vite 5+, TailwindCSS |
| Database | PostgreSQL 15+ |
| Cache/Queue | Redis 7+ |
@@ -540,12 +548,6 @@ default:
rate_multiplier: 1.0
```
### Sora Status (Temporarily Unavailable)
> ⚠️ Sora-related features are temporarily unavailable due to technical issues in upstream integration and media delivery.
> Please do not rely on Sora in production at this time.
> Existing `gateway.sora_*` configuration keys are reserved and may not take effect until these issues are resolved.
Additional security-related options are available in `config.yaml`:
- `cors.allowed_origins` for CORS allowlist
@@ -648,6 +650,32 @@ Or set `GATEWAY_OPENAI_WS_MODE_ROUTER_V2_ENABLED=true` in the environment.
Use `http_bridge` for client-WebSocket/upstream-HTTP operation when rolling out
or mitigating upstream WebSocket issues.
#### Force OpenAI upstream HTTP/SSE
When an egress proxy or network repeatedly reconnects OpenAI Responses
WebSockets, set the global fallback in the persisted deployment configuration:
```yaml
gateway:
openai_ws:
force_http: true
```
For Compose and Apple container deployments, the equivalent `.env` setting is:
```bash
GATEWAY_OPENAI_WS_FORCE_HTTP=true
```
This selects HTTP/SSE for OpenAI upstream Responses traffic that would
otherwise use WebSocket. It does not change the client-facing protocol or force
HTTP/1.1; configure `gateway.openai_http2.enabled` (or
`GATEWAY_OPENAI_HTTP2_ENABLED=false`) separately when a proxy is incompatible
with HTTP/2. Unlike the account-level `http_bridge` mode, this global fallback
takes effect without enabling `mode_router_v2_enabled`. Keep the setting in the
deployment's persisted `.env` or `config.yaml`, rather than inside a running
container, so it is read again after an image update or container recreation.
#### ⚠️ Important: Creating the Admin Account
The initial admin account is **only created via the setup wizard** (served at `http://<host>:8080` on first run). The `default.admin_email` / `default.admin_password` fields in `config.yaml` are **not used** to create it — they exist in the template for historical reasons.
@@ -726,7 +754,7 @@ Sub2API supports both Grok subscription accounts through xAI OAuth and standard
- Codex CLI style Responses WebSocket ingress is accepted on the Responses targets and bridged to xAI HTTP/SSE Responses upstream
- Text models: `grok-4.5`, `grok-4.3`, `grok-build-0.1`, `grok-composer-2.5-fast`, `grok-4.20-0309-reasoning`, `grok-4.20-0309-non-reasoning`, and `grok-4.20-multi-agent-0309`
- Media targets for Grok groups: `/v1/images/generations`, `/images/generations`, `/v1/images/edits`, `/images/edits`, `/v1/videos/generations`, `/videos/generations`, `/v1/videos/edits`, `/videos/edits`, `/v1/videos/extensions`, `/videos/extensions`, `/v1/videos/{request_id}`, and `/videos/{request_id}`. Generation, editing, and extension requests require the group image-generation permission.
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
- Media models: `grok-imagine`, `grok-imagine-image-quality`, `grok-imagine-image`, `grok-imagine-image-2.0`, `grok-imagine-edit`, `grok-imagine-video`, and `grok-imagine-video-1.5`
- JSON image-edit and video-generation requests accept image references in `image`, `images`, `reference_images`, and `mask` objects. Use `url` for xAI-compatible payloads; the legacy `image_url` field remains accepted and is normalized to `url` before forwarding.
- Out of scope for this provider: TTS, transcription, browser automation, cookies, and Grok web scraping
@@ -856,11 +884,11 @@ sub2api/
## Star History
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
<a href="https://star-history.dera.page/#Wei-Shaw/sub2api&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
</picture>
</a>
+26 -45
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.27.0-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/)
@@ -59,6 +59,11 @@
<td>感谢 AIGoCode 赞助了本项目!AIGoCode 是一站式集成 Claude Code、Codex 以及最新 Gemini 模型的综合平台,为您提供稳定、高效、高性价比的 AI 编程服务。平台提供灵活的订阅方案,零封号风险,免 VPN 直连,响应极速。AIGoCode 为 sub2api 用户准备了专属福利:通过<a href="https://aigocode.com/invite/SUB2API">此链接</a>注册,首次充值可额外获得 10% 赠送额度!</td>
</tr>
<tr>
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
<td>Real GPT-5.6 series at 3% of OpenAI pricing — <a href="https://codex-everywhere.com">CodexEverywhere</a> is democratizing access to frontier models for developers worldwide. We believe in transparency and honesty, with model quality verified by active community oversight for months. USD and crypto friendly. Start with a free $20 trial at <a href="https://codex-everywhere.com">codex-everywhere.com</a>.</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>感谢 BmoPlus 赞助了本项目!BmoPlus 是一家专为AI订阅重度用户打造的可靠 AI 账号代充服务商,提供稳定的 ChatGPT Plus / ChatGPT Pro(全程质保) / Claude Pro / Super Grok / Gemini Pro 的官方代充&成品账号。 通过<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AI成品号专卖/代充</a>注册下单的用户,可享GPT 官网订阅一折 的震撼价格!</td>
@@ -120,18 +125,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>感谢 code0.ai 对本项目的赞助! <a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> 是面向开发者与技术团队的 AI 编程工作台,聚合 Claude Code、Codex 等主流 Agent 编程能力,支持代码生成、项目理解、调试修复、代码审查与文档生成等常见研发场景。适合独立开发者、Agent 工程师、开源项目维护者和企业研发团队使用,支持开票和团队对接。通过<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,体验更高效的 AI 编程工作流。
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
@@ -167,6 +160,21 @@
<td><a href="https://hao.ai">hao.ai</a> 是面向开发者与团队的高速、稳定大模型统一 API 网关。通过一个 API Key 和统一接口,即可接入 GPT、Claude、xAI Grok 等主流模型,兼容 OpenAI、Anthropic 等常用协议与 SDK。平台提供模型路由、故障回退、团队管理及完整调用日志,模型价格低至官方参考价的 1.5 折,帮助用户更简单、更稳定、更低成本地构建 AI 应用。</td>
</tr>
<tr>
<td width="180"><a href="https://www.swiftproxy.net/?ref=sub2api"><img src="assets/partners/logos/swiftprox.png" alt="Swiftproxy" width="150"></a></td>
<td>Swiftproxy 是面向开发者的高性能代理解决方案,提供稳定可靠的住宅代理和静态住宅代理服务。拥有 9000 万+ 纯净住宅 IP,覆盖全球,支持灵活轮换和精准地理定位,帮助网页抓取、AI 自动化、浏览器自动化、SEO 监控和多账号管理等项目突破访问限制,提升工作流效率。支持 HTTP(S) 和 SOCKS5 协议,兼容 Playwright、Selenium、Puppeteer 等主流自动化工具,动态代理流量用完为止永不过期,支持免费测试 — <a href="https://www.swiftproxy.net/?ref=sub2api">立即开始免费测试</a>!</td>
</tr>
<tr>
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 9000 万+ 全球住宅网络资源,覆盖 195+ 国家和地区,支持轮换和粘性会话,适用于公共数据采集、RAG 更新、模型评估和多区域数据工作负载。🟢住宅代理 - 8 折优惠;🟢静态住宅代理 - ¥50.00/IP 起;🟢无限住宅代理 - ¥19.8/小时 起。✅免费领取 500M 试用流量。</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
</tr>
</table>
## 项目概述
@@ -198,7 +206,7 @@ Sub2API 是一个 AI API 网关平台,用于分发和管理 AI 产品订阅的
| 组件 | 技术 |
|------|------|
| 后端 | Go 1.25.7, Gin, Ent |
| 后端 | Go 1.27.0, Gin, Ent |
| 前端 | Vue 3.4+, Vite 5+, TailwindCSS |
| 数据库 | PostgreSQL 15+ |
| 缓存/队列 | Redis 7+ |
@@ -553,33 +561,6 @@ default:
rate_multiplier: 1.0
```
### Sora 功能状态(暂不可用)
> ⚠️ 当前 Sora 相关功能因上游接入与媒体链路存在技术问题,暂时不可用。
> 现阶段请勿在生产环境依赖 Sora 能力。
> 文档中的 `gateway.sora_*` 配置仅作预留,待技术问题修复后再恢复可用。
### Sora 媒体签名 URL(功能恢复后可选)
当配置 `gateway.sora_media_signing_key` 且 `gateway.sora_media_signed_url_ttl_seconds > 0` 时,网关会将 Sora 输出的媒体地址改写为临时签名 URL(`/sora/media-signed/...`)。这样无需 API Key 即可在浏览器中直接访问,且具备过期控制与防篡改能力(签名包含 path + query)。
```yaml
gateway:
# /sora/media 是否强制要求 API Key(默认 false)
sora_media_require_api_key: false
# 媒体临时签名密钥(为空则禁用签名)
sora_media_signing_key: "your-signing-key"
# 临时签名 URL 有效期(秒)
sora_media_signed_url_ttl_seconds: 900
```
> 若未配置签名密钥,`/sora/media-signed` 将返回 503。
> 如需更严格的访问控制,可将 `sora_media_require_api_key` 设为 true,仅允许携带 API Key 的 `/sora/media` 访问。
访问策略说明:
- `/sora/media`:内部调用或客户端携带 API Key 才能下载
- `/sora/media-signed`:外部可访问,但有签名 + 过期控制
`config.yaml` 还支持以下安全相关配置:
- `cors.allowed_origins` 配置 CORS 白名单
@@ -787,11 +768,11 @@ sub2api/
## Star History
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
<a href="https://star-history.dera.page/#Wei-Shaw/sub2api&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
</picture>
</a>
+26 -24
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.27.0-00ADD8.svg)](https://golang.org/)
[![Vue](https://img.shields.io/badge/Vue-3.4+-4FC08D.svg)](https://vuejs.org/)
[![PostgreSQL](https://img.shields.io/badge/PostgreSQL-15+-336791.svg)](https://www.postgresql.org/)
[![Redis](https://img.shields.io/badge/Redis-7+-DC382D.svg)](https://redis.io/)
@@ -58,6 +58,11 @@
<td>AIGoCode のご支援に感謝します!AIGoCode は Claude Code、Codex、最新の Gemini モデルを統合したオールインワンプラットフォームで、安定的かつ効率的でコストパフォーマンスに優れた AI コーディングサービスを提供します。柔軟なサブスクリプションプラン、アカウント停止リスクゼロ、VPN 不要の直接アクセス、超高速レスポンスが特長です。AIGoCode は sub2api ユーザー向けに特別特典を用意しています:<a href="https://aigocode.com/invite/SUB2API">こちらのリンク</a>から登録すると、初回チャージ時に 10% のボーナスクレジットを追加プレゼント!</td>
</tr>
<tr>
<td width="180"><a href="https://codex-everywhere.com"><img src="assets/partners/logos/codex-everywhere.jpg" alt="CodexEverywhere" width="150"></a></td>
<td>OpenAI 公式価格のわずか 3% で本物の GPT-5.6 シリーズを提供 — <a href="https://codex-everywhere.com">CodexEverywhere</a> は世界中の開発者にフロンティアモデルへのアクセスを民主化しています。私たちは透明性と誠実さを信条とし、モデル品質は数か月にわたるアクティブなコミュニティの監視によって検証されています。USD および暗号通貨に対応。<a href="https://codex-everywhere.com">codex-everywhere.com</a> で $20 の無料トライアルから始めましょう。</td>
</tr>
<tr>
<td width="180"><a href="https://shop.bmoplus.com/?utm_source=github"><img src="assets/partners/logos/bmoplus.jpg" alt="bmoplus" width="150"></a></td>
<td>本プロジェクトにご支援いただいた BmoPlus に感謝いたします!BmoPlusは、AIサブスクリプションのヘビーユーザー向けに特化した信頼性の高いAIアカウントサービスプロバイダーであり、安定した ChatGPT Plus / ChatGPT Pro (完全保証) / Claude Pro / Super Grok / Gemini Pro の公式代行チャージおよび即納アカウントを提供しています。こちらの<a href="https://shop.bmoplus.com/?utm_source=github">BmoPlus AIアカウント専門店/代行チャージ</a>経由でご登録・ご注文いただいたユーザー様は、GPTを 公式サイト価格の約1割(90% OFF) という驚異的な価格でご利用いただけます!</td>
@@ -119,18 +124,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
</td>
</tr>
<tr>
<td width="180"><a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/code0.jpg" alt="code0" width="150"></a></td>
<td>code0.ai のご支援に感謝します!<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">code0.ai</a> は開発者と技術チーム向けの AI プログラミングワークベンチで、Claude Code や Codex などの主要な Agent コーディング能力を集約し、コード生成、プロジェクト理解、デバッグと修正、コードレビュー、ドキュメント生成といった一般的な開発シーンをサポートします。個人開発者、Agent エンジニア、OSS メンテナー、企業の開発チームに最適で、請求書発行やチーム導入にも対応。<a href="https://code0.ai/agent/register/LgpIgl9JHtVG53V1?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取り、より効率的な AI プログラミングワークフローを体験できます。
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
@@ -166,6 +159,21 @@
<td><a href="https://hao.ai">hao.ai</a> は、開発者とチーム向けの高速で安定した大規模モデル統合 API ゲートウェイです。1 つの API Key と統一されたインターフェースで、GPT、Claude、xAI Grok などの主要モデルに接続でき、OpenAI や Anthropic などの一般的なプロトコルと SDK に対応しています。プラットフォームはモデルルーティング、フェイルオーバー、チーム管理、完全な呼び出しログを提供し、モデル価格は公式参考価格の 1.5 割から。よりシンプルに、より安定して、より低コストに AI アプリケーションを構築できます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.swiftproxy.net/?ref=sub2api"><img src="assets/partners/logos/swiftprox.png" alt="Swiftproxy" width="150"></a></td>
<td>Swiftproxy は開発者向けの高性能プロキシソリューションで、安定して信頼できるレジデンシャルおよび静的レジデンシャルプロキシサービスを提供します。9,000 万以上のクリーンな住宅 IP を保有し、グローバルカバレッジ、柔軟なローテーション、精密なジオターゲティングにより、Web スクレイピング、AI オートメーション、ブラウザ自動化、SEO モニタリング、マルチアカウント管理などのプロジェクトがアクセス制限を克服し、ワークフロー効率を向上させます。HTTP(S) および SOCKS5 プロトコルに対応し、Playwright、Selenium、Puppeteer などの主要な自動化ツールと統合可能。動的プロキシトラフィックは使い切るまで期限切れなし、無料テストも利用可能 — <a href="https://www.swiftproxy.net/?ref=sub2api">今すぐ無料テストを開始</a>!</td>
</tr>
<tr>
<td width="180"><a href="https://www.duckip.cn/?keyword=cu7oog6y"><img src="assets/partners/logos/duckip.png" alt="DuckIP" width="150"></a></td>
<td><a href="https://www.duckip.cn/?keyword=cu7oog6y">DuckIP</a> - 195 以上の国と地域にわたる 9,000 万以上のグローバルレジデンシャルネットワークリソース。ローテーションとスティッキーセッションに対応し、パブリックデータ収集、RAG 更新、モデル評価、マルチリージョンデータワークロードに最適。🟢レジデンシャルプロキシ - 20% オフ;🟢スタティックレジデンシャルプロキシ - ¥50.00/IP から;🟢無制限レジデンシャルプロキシ - ¥19.8/時間 から。✅500M 無料トライアルを取得。</td>
</tr>
<tr>
<td width="180"><a href="https://go.apimart.ai/gh-sub2api"><img src="assets/partners/logos/apimart.jpg" alt="APIMart" width="150"></a></td>
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
</tr>
</table>
## 概要
@@ -197,7 +205,7 @@ Sub2API を拡張・統合するコミュニティプロジェクト:
| コンポーネント | 技術 |
|-----------|------------|
| バックエンド | Go 1.25.7, Gin, Ent |
| バックエンド | Go 1.27.0, Gin, Ent |
| フロントエンド | Vue 3.4+, Vite 5+, TailwindCSS |
| データベース | PostgreSQL 15+ |
| キャッシュ/キュー | Redis 7+ |
@@ -540,12 +548,6 @@ default:
rate_multiplier: 1.0
```
### Sora ステータス(一時的に利用不可)
> ⚠️ Sora 関連の機能は、上流統合およびメディア配信の技術的問題により一時的に利用できません。
> 現時点では本番環境で Sora に依存しないでください。
> 既存の `gateway.sora_*` 設定キーは予約されていますが、これらの問題が解決されるまで有効にならない場合があります。
`config.yaml` では追加のセキュリティ関連オプションも利用できます:
- `cors.allowed_origins` - CORS 許可リスト
@@ -722,11 +724,11 @@ sub2api/
## スター履歴
<a href="https://star-history.com/#Wei-Shaw/sub2api&Date">
<a href="https://star-history.dera.page/#Wei-Shaw/sub2api&Date">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=Wei-Shaw/sub2api&type=Date" />
<source media="(prefers-color-scheme: dark)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date&theme=dark" />
<source media="(prefers-color-scheme: light)" srcset="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
<img alt="Star History Chart" src="https://star-history.dera.page/svg?repos=Wei-Shaw/sub2api&type=Date" />
</picture>
</a>
Binary file not shown.

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

-32
View File
@@ -1,32 +0,0 @@
<svg
width="340"
height="112"
viewBox="0 0 340 112"
fill="none"
xmlns="http://www.w3.org/2000/svg"
role="img"
aria-label="Hao.ai"
>
<!-- Fill the compact landscape canvas while preserving the homepage lockup proportions. -->
<g transform="translate(170 56) scale(1.45) translate(-170 -56)">
<!-- Homepage HaoAI mark, rendered in the brand green. -->
<g transform="translate(66.55 12) scale(0.55)">
<path
d="M49 30C42.3726 30 37 35.3726 37 42V118C37 124.627 42.3726 130 49 130C55.6274 130 61 124.627 61 118V42C61 35.3726 55.6274 30 49 30ZM111 30C104.373 30 99 35.3726 99 42V118C99 124.627 104.373 130 111 130C117.627 130 123 124.627 123 118V42C123 35.3726 117.627 30 111 30ZM80 92.5C86.9036 92.5 92.5 86.9036 92.5 80C92.5 73.0964 86.9036 67.5 80 67.5C73.0964 67.5 67.5 73.0964 67.5 80C67.5 86.9036 73.0964 92.5 80 92.5Z"
fill="#1DB954"
fill-rule="evenodd"
clip-rule="evenodd"
/>
</g>
<!-- Homepage wordmark converted from Inter SemiBold to vector outlines. -->
<g transform="translate(150.35 69) scale(0.0166015625 -0.0166015625)" fill="#111111">
<path d="M150 0L150 1490L417 1490L417 874L1110 874L1110 1490L1377 1490L1377 0L1110 0L1110 649L417 649L417 0Z" />
<path transform="translate(1527 0)" d="M450 -23Q344 -23 259 15.5Q174 54 125 129Q76 204 76 314Q76 409 111.5 471Q147 533 207.5 570Q268 607 344 626Q420 645 502 653Q599 664 660 672Q721 680 749.5 698Q778 716 778 754L778 759Q778 814 756 852.5Q734 891 690.5 911.5Q647 932 583 932Q518 932 470.5 912Q423 892 393 860Q363 828 349 791L108 840Q141 939 210 1003.5Q279 1068 374.5 1100Q470 1132 582 1132Q661 1132 741.5 1113.5Q822 1095 889 1051Q956 1007 997 933Q1038 859 1038 747L1038 0L789 0L789 154L779 154Q754 107 710.5 66.5Q667 26 602.5 1.5Q538 -23 450 -23ZM517 170Q598 170 656.5 202Q715 234 747.5 286.5Q780 339 780 401L780 533Q767 523 736.5 514Q706 505 668.5 498Q631 491 594.5 486Q558 481 533 478Q475 470 428 452Q381 434 354.5 401.5Q328 369 328 317Q328 269 352.5 236.5Q377 204 419.5 187Q462 170 517 170Z" />
<path transform="translate(2703 0)" d="M623 -23Q460 -23 340 49.5Q220 122 154.5 251.5Q89 381 89 553Q89 727 154.5 857.5Q220 988 340 1060Q460 1132 623 1132Q787 1132 907 1060Q1027 988 1092.5 857.5Q1158 727 1158 553Q1158 381 1092.5 251.5Q1027 122 907 49.5Q787 -23 623 -23ZM623 188Q714 188 774.5 237Q835 286 864.5 369Q894 452 894 554Q894 657 864.5 740Q835 823 774.5 872Q714 921 623 921Q532 921 472.5 872Q413 823 383.5 740.5Q354 658 354 554Q354 452 383.5 369Q413 286 472.5 237Q532 188 623 188Z" />
<path transform="translate(3950 0)" fill="#1DB954" d="M326 -17Q259 -17 212.5 29Q166 75 166 143Q166 210 212.5 256Q259 302 326 302Q394 302 440.5 256Q487 210 487 143Q487 75 440.5 29Q394 -17 326 -17Z" />
<path transform="translate(4603 0)" d="M450 -23Q344 -23 259 15.5Q174 54 125 129Q76 204 76 314Q76 409 111.5 471Q147 533 207.5 570Q268 607 344 626Q420 645 502 653Q599 664 660 672Q721 680 749.5 698Q778 716 778 754L778 759Q778 814 756 852.5Q734 891 690.5 911.5Q647 932 583 932Q518 932 470.5 912Q423 892 393 860Q363 828 349 791L108 840Q141 939 210 1003.5Q279 1068 374.5 1100Q470 1132 582 1132Q661 1132 741.5 1113.5Q822 1095 889 1051Q956 1007 997 933Q1038 859 1038 747L1038 0L789 0L789 154L779 154Q754 107 710.5 66.5Q667 26 602.5 1.5Q538 -23 450 -23ZM517 170Q598 170 656.5 202Q715 234 747.5 286.5Q780 339 780 401L780 533Q767 523 736.5 514Q706 505 668.5 498Q631 491 594.5 486Q558 481 533 478Q475 470 428 452Q381 434 354.5 401.5Q328 369 328 317Q328 269 352.5 236.5Q377 204 419.5 187Q462 170 517 170Z" />
<path transform="translate(5779 0)" d="M138 0L138 1118L398 1118L398 0ZM268 1276Q206 1276 161.5 1318Q117 1360 117 1418Q117 1478 161.5 1519.5Q206 1561 268 1561Q331 1561 375.5 1519.5Q420 1478 420 1419Q420 1360 375.5 1318Q331 1276 268 1276Z" />
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+10
View File
@@ -121,6 +121,16 @@ linters:
# Default: true — must be true, ent generates 130K+ lines of code
generated-is-used: true
exclusions:
rules:
# G703/G704 污点分析在测试文件中只会命中 httptest mock 与测试者自设的
# 环境变量路径,不构成攻击面;且该分析跨环境结果不稳定(本地/CI 报告的
# 位置集合不同),逐点 nolint 无法收敛,故按路径豁免。生产代码不豁免,
# 必须逐点 //nolint:gosec 并写明信任边界。
- path: '_test\.go$'
linters: [ gosec ]
text: 'G70[34]'
formatters:
enable:
- gofmt
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.26.5-alpine
FROM golang:1.27.0-alpine
WORKDIR /app
+1 -1
View File
@@ -1 +1 @@
0.1.171
0.1.183
+5
View File
@@ -153,6 +153,11 @@ func runMainServer() {
log.Fatalf("Failed to initialize application: %v", err)
}
defer app.Cleanup()
if app.PluginManager != nil {
if err := app.PluginManager.Start(context.Background()); err != nil {
log.Printf("Plugin manager started in degraded state: %v", err)
}
}
if app.PromptAudit != nil {
if err := app.PromptAudit.Start(context.Background()); err != nil {
// Startup continues so unrelated APIs stay up. Fail-closed (unavailable)
+41 -4
View File
@@ -25,9 +25,10 @@ import (
)
type Application struct {
Server *http.Server
PromptAudit *securityaudit.PromptService
Cleanup func()
Server *http.Server
PromptAudit *securityaudit.PromptService
PluginManager *service.PluginManager
Cleanup func()
}
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
@@ -51,12 +52,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// BuildInfo provider
provideServiceBuildInfo,
providePluginHostInfo,
// Cleanup function provider
provideCleanup,
// Application struct
wire.Struct(new(Application), "Server", "PromptAudit", "Cleanup"),
wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "Cleanup"),
)
return nil, nil
}
@@ -72,6 +74,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
}
}
func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo {
return service.PluginHostInfo{
Version: buildInfo.Version,
BuildType: buildInfo.BuildType,
}
}
func provideCleanup(
entClient *ent.Client,
rdb *redis.Client,
@@ -88,6 +97,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
@@ -110,11 +120,14 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
auditLog *service.AuditLogService,
openAIAutoReset *service.OpenAIQuotaAutoResetService,
promptAudit *securityaudit.PromptService,
pluginManager *service.PluginManager,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -127,6 +140,18 @@ func provideCleanup(
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
parallelSteps := []cleanupStep{
{"PluginManager", func() error {
if pluginManager != nil {
pluginManager.Stop()
}
return nil
}},
{"OpenAIQuotaAutoResetService", func() error {
if openAIAutoReset != nil {
openAIAutoReset.Stop()
}
return nil
}},
{"OpsIngressRejectAggregator", func() error {
if opsIngressReject != nil {
opsIngressReject.Stop()
@@ -237,6 +262,12 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"CNProviderBalanceCheckService", func() error {
if cnProviderBalanceCheck != nil {
cnProviderBalanceCheck.Stop()
}
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
@@ -319,6 +350,12 @@ func provideCleanup(
}
return nil
}},
{"ChannelMonitorV2Aggregator", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorRunner != nil {
channelMonitorRunner.Stop()
+71 -19
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, apiKeyService)
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
@@ -186,18 +189,21 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
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)
pluginRepository := repository.NewPluginRepository(db)
pluginHostInfo := providePluginHostInfo(buildInfo)
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
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)
@@ -205,7 +211,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
dbDumper := repository.NewPgDumper(configConfig)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
imageStorageFactory := repository.ProvideImageStorageFactory()
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService)
@@ -215,6 +221,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService, tokenRefreshService)
cnProviderQuotaService := service.ProvideCNProviderQuotaService(accountRepository, proxyRepository, httpUpstream, configConfig)
cnProviderBalanceService := service.ProvideCNProviderBalanceService(accountRepository, proxyRepository, httpUpstream, configConfig)
cnProviderHandler := admin.NewCNProviderHandler(cnProviderQuotaService, cnProviderBalanceService)
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
@@ -245,6 +254,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache)
errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService)
tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService)
pluginHandler := admin.NewPluginHandler(pluginManager)
adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService)
scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db)
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
@@ -274,14 +284,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
passkeyRepository := repository.NewPasskeyRepository(db)
@@ -294,7 +304,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
modelPlazaService := service.NewModelPlazaService(channelRepository, groupRepository, pricingService, billingService, modelPricingResolver)
modelPlazaHandler := handler.NewModelPlazaHandler(modelPlazaService, apiKeyService, settingService)
imageTaskStore := repository.NewImageTaskStore(redisClient)
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
@@ -308,7 +319,8 @@ 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)
openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache)
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, openAIQuotaAutoResetService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
@@ -324,19 +336,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig)
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
channelMonitorQuotaFetcher := service.NewChannelMonitorQuotaFetcher(accountUsageService, cnProviderQuotaService, cnProviderBalanceService, accountRepository, configConfig)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, 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, cnProviderBalanceCheckService, 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, openAIQuotaAutoResetService, promptService, pluginManager)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
Cleanup: v,
Server: httpServer,
PromptAudit: promptService,
PluginManager: pluginManager,
Cleanup: v,
}
return application, nil
}
@@ -344,9 +360,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// wire.go:
type Application struct {
Server *http.Server
PromptAudit *securityaudit.PromptService
Cleanup func()
Server *http.Server
PromptAudit *securityaudit.PromptService
PluginManager *service.PluginManager
Cleanup func()
}
func providePrivacyClientFactory() service.PrivacyClientFactory {
@@ -360,6 +377,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
}
}
func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo {
return service.PluginHostInfo{
Version: buildInfo.Version,
BuildType: buildInfo.BuildType,
}
}
func provideCleanup(
entClient *ent.Client,
rdb *redis.Client,
@@ -376,6 +400,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
@@ -398,11 +423,14 @@ func provideCleanup(
backupSvc *service.BackupService,
paymentOrderExpiry *service.PaymentOrderExpiryService,
channelMonitorRunner *service.ChannelMonitorRunner,
channelMonitorV2Aggregator *service.ChannelMonitorV2Aggregator,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
auditLog *service.AuditLogService,
openAIAutoReset *service.OpenAIQuotaAutoResetService,
promptAudit *securityaudit.PromptService,
pluginManager *service.PluginManager,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -414,6 +442,18 @@ func provideCleanup(
}
parallelSteps := []cleanupStep{
{"PluginManager", func() error {
if pluginManager != nil {
pluginManager.Stop()
}
return nil
}},
{"OpenAIQuotaAutoResetService", func() error {
if openAIAutoReset != nil {
openAIAutoReset.Stop()
}
return nil
}},
{"OpsIngressRejectAggregator", func() error {
if opsIngressReject != nil {
opsIngressReject.Stop()
@@ -524,6 +564,12 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"CNProviderBalanceCheckService", func() error {
if cnProviderBalanceCheck != nil {
cnProviderBalanceCheck.Stop()
}
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
@@ -606,6 +652,12 @@ func provideCleanup(
}
return nil
}},
{"ChannelMonitorV2Aggregator", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorRunner != nil {
channelMonitorRunner.Stop()
+4
View File
@@ -66,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
schedulerSnapshotSvc,
tokenRefreshSvc,
accountExpirySvc,
nil, // cnProviderBalanceCheck
codexVersionSyncSvc,
proxyExpirySvc,
subscriptionExpirySvc,
@@ -88,11 +89,14 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // backupSvc
nil, // paymentOrderExpiry
nil, // channelMonitorRunner
nil, // channelMonitorV2Aggregator
nil, // quotaFlusher
nil, // upstreamBillingProbe
nil, // ollamaCloudUsage
nil, // auditLog
nil, // openAIAutoReset
nil, // promptAudit
nil, // pluginManager
)
require.NotPanics(t, func() {
+28 -3
View File
@@ -27,9 +27,13 @@ type ChannelMonitor struct {
Name string `json:"name,omitempty"`
// Provider holds the value of the "provider" field.
Provider channelmonitor.Provider `json:"provider,omitempty"`
// probe = LLM probe (default); quota = account usage only; quota_probe = both
CheckMode string `json:"check_mode,omitempty"`
// AccountID holds the value of the "account_id" field.
AccountID *int64 `json:"account_id,omitempty"`
// OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions
APIMode string `json:"api_mode,omitempty"`
// Provider base origin, e.g. https://api.openai.com
// Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors
Endpoint string `json:"endpoint,omitempty"`
// AES-256-GCM encrypted API key
APIKeyEncrypted string `json:"-"`
@@ -114,9 +118,9 @@ func (*ChannelMonitor) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte)
case channelmonitor.FieldEnabled:
values[i] = new(sql.NullBool)
case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
case channelmonitor.FieldID, channelmonitor.FieldAccountID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
values[i] = new(sql.NullInt64)
case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode:
case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldCheckMode, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode:
values[i] = new(sql.NullString)
case channelmonitor.FieldCreatedAt, channelmonitor.FieldUpdatedAt, channelmonitor.FieldLastCheckedAt:
values[i] = new(sql.NullTime)
@@ -165,6 +169,19 @@ func (_m *ChannelMonitor) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.Provider = channelmonitor.Provider(value.String)
}
case channelmonitor.FieldCheckMode:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field check_mode", values[i])
} else if value.Valid {
_m.CheckMode = value.String
}
case channelmonitor.FieldAccountID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field account_id", values[i])
} else if value.Valid {
_m.AccountID = new(int64)
*_m.AccountID = value.Int64
}
case channelmonitor.FieldAPIMode:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field api_mode", values[i])
@@ -326,6 +343,14 @@ func (_m *ChannelMonitor) String() string {
builder.WriteString("provider=")
builder.WriteString(fmt.Sprintf("%v", _m.Provider))
builder.WriteString(", ")
builder.WriteString("check_mode=")
builder.WriteString(_m.CheckMode)
builder.WriteString(", ")
if v := _m.AccountID; v != nil {
builder.WriteString("account_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("api_mode=")
builder.WriteString(_m.APIMode)
builder.WriteString(", ")
+29 -5
View File
@@ -23,6 +23,10 @@ const (
FieldName = "name"
// FieldProvider holds the string denoting the provider field in the database.
FieldProvider = "provider"
// FieldCheckMode holds the string denoting the check_mode field in the database.
FieldCheckMode = "check_mode"
// FieldAccountID holds the string denoting the account_id field in the database.
FieldAccountID = "account_id"
// FieldAPIMode holds the string denoting the api_mode field in the database.
FieldAPIMode = "api_mode"
// FieldEndpoint holds the string denoting the endpoint field in the database.
@@ -91,6 +95,8 @@ var Columns = []string{
FieldUpdatedAt,
FieldName,
FieldProvider,
FieldCheckMode,
FieldAccountID,
FieldAPIMode,
FieldEndpoint,
FieldAPIKeyEncrypted,
@@ -127,6 +133,10 @@ var (
UpdateDefaultUpdatedAt func() time.Time
// NameValidator is a validator for the "name" field. It is called by the builders before save.
NameValidator func(string) error
// DefaultCheckMode holds the default value on creation for the "check_mode" field.
DefaultCheckMode string
// CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save.
CheckModeValidator func(string) error
// DefaultAPIMode holds the default value on creation for the "api_mode" field.
DefaultAPIMode string
// APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save.
@@ -164,10 +174,14 @@ type Provider string
// Provider values.
const (
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderAntigravity Provider = "antigravity"
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
)
func (pr Provider) String() string {
@@ -177,7 +191,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
return nil
default:
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
@@ -212,6 +226,16 @@ func ByProvider(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldProvider, opts...).ToFunc()
}
// ByCheckMode orders the results by the check_mode field.
func ByCheckMode(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCheckMode, opts...).ToFunc()
}
// ByAccountID orders the results by the account_id field.
func ByAccountID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAccountID, opts...).ToFunc()
}
// ByAPIMode orders the results by the api_mode field.
func ByAPIMode(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAPIMode, opts...).ToFunc()
+125
View File
@@ -70,6 +70,16 @@ func Name(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldName, v))
}
// CheckMode applies equality check predicate on the "check_mode" field. It's identical to CheckModeEQ.
func CheckMode(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v))
}
// AccountID applies equality check predicate on the "account_id" field. It's identical to AccountIDEQ.
func AccountID(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v))
}
// APIMode applies equality check predicate on the "api_mode" field. It's identical to APIModeEQ.
func APIMode(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v))
@@ -295,6 +305,121 @@ func ProviderNotIn(vs ...Provider) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldProvider, vs...))
}
// CheckModeEQ applies the EQ predicate on the "check_mode" field.
func CheckModeEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v))
}
// CheckModeNEQ applies the NEQ predicate on the "check_mode" field.
func CheckModeNEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNEQ(FieldCheckMode, v))
}
// CheckModeIn applies the In predicate on the "check_mode" field.
func CheckModeIn(vs ...string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIn(FieldCheckMode, vs...))
}
// CheckModeNotIn applies the NotIn predicate on the "check_mode" field.
func CheckModeNotIn(vs ...string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldCheckMode, vs...))
}
// CheckModeGT applies the GT predicate on the "check_mode" field.
func CheckModeGT(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGT(FieldCheckMode, v))
}
// CheckModeGTE applies the GTE predicate on the "check_mode" field.
func CheckModeGTE(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGTE(FieldCheckMode, v))
}
// CheckModeLT applies the LT predicate on the "check_mode" field.
func CheckModeLT(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLT(FieldCheckMode, v))
}
// CheckModeLTE applies the LTE predicate on the "check_mode" field.
func CheckModeLTE(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldCheckMode, v))
}
// CheckModeContains applies the Contains predicate on the "check_mode" field.
func CheckModeContains(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldContains(FieldCheckMode, v))
}
// CheckModeHasPrefix applies the HasPrefix predicate on the "check_mode" field.
func CheckModeHasPrefix(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldHasPrefix(FieldCheckMode, v))
}
// CheckModeHasSuffix applies the HasSuffix predicate on the "check_mode" field.
func CheckModeHasSuffix(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldHasSuffix(FieldCheckMode, v))
}
// CheckModeEqualFold applies the EqualFold predicate on the "check_mode" field.
func CheckModeEqualFold(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEqualFold(FieldCheckMode, v))
}
// CheckModeContainsFold applies the ContainsFold predicate on the "check_mode" field.
func CheckModeContainsFold(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldContainsFold(FieldCheckMode, v))
}
// AccountIDEQ applies the EQ predicate on the "account_id" field.
func AccountIDEQ(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v))
}
// AccountIDNEQ applies the NEQ predicate on the "account_id" field.
func AccountIDNEQ(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNEQ(FieldAccountID, v))
}
// AccountIDIn applies the In predicate on the "account_id" field.
func AccountIDIn(vs ...int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIn(FieldAccountID, vs...))
}
// AccountIDNotIn applies the NotIn predicate on the "account_id" field.
func AccountIDNotIn(vs ...int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldAccountID, vs...))
}
// AccountIDGT applies the GT predicate on the "account_id" field.
func AccountIDGT(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGT(FieldAccountID, v))
}
// AccountIDGTE applies the GTE predicate on the "account_id" field.
func AccountIDGTE(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGTE(FieldAccountID, v))
}
// AccountIDLT applies the LT predicate on the "account_id" field.
func AccountIDLT(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLT(FieldAccountID, v))
}
// AccountIDLTE applies the LTE predicate on the "account_id" field.
func AccountIDLTE(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldAccountID, v))
}
// AccountIDIsNil applies the IsNil predicate on the "account_id" field.
func AccountIDIsNil() predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIsNull(FieldAccountID))
}
// AccountIDNotNil applies the NotNil predicate on the "account_id" field.
func AccountIDNotNil() predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotNull(FieldAccountID))
}
// APIModeEQ applies the EQ predicate on the "api_mode" field.
func APIModeEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v))
+168
View File
@@ -65,6 +65,34 @@ func (_c *ChannelMonitorCreate) SetProvider(v channelmonitor.Provider) *ChannelM
return _c
}
// SetCheckMode sets the "check_mode" field.
func (_c *ChannelMonitorCreate) SetCheckMode(v string) *ChannelMonitorCreate {
_c.mutation.SetCheckMode(v)
return _c
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_c *ChannelMonitorCreate) SetNillableCheckMode(v *string) *ChannelMonitorCreate {
if v != nil {
_c.SetCheckMode(*v)
}
return _c
}
// SetAccountID sets the "account_id" field.
func (_c *ChannelMonitorCreate) SetAccountID(v int64) *ChannelMonitorCreate {
_c.mutation.SetAccountID(v)
return _c
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_c *ChannelMonitorCreate) SetNillableAccountID(v *int64) *ChannelMonitorCreate {
if v != nil {
_c.SetAccountID(*v)
}
return _c
}
// SetAPIMode sets the "api_mode" field.
func (_c *ChannelMonitorCreate) SetAPIMode(v string) *ChannelMonitorCreate {
_c.mutation.SetAPIMode(v)
@@ -303,6 +331,10 @@ func (_c *ChannelMonitorCreate) defaults() {
v := channelmonitor.DefaultUpdatedAt()
_c.mutation.SetUpdatedAt(v)
}
if _, ok := _c.mutation.CheckMode(); !ok {
v := channelmonitor.DefaultCheckMode
_c.mutation.SetCheckMode(v)
}
if _, ok := _c.mutation.APIMode(); !ok {
v := channelmonitor.DefaultAPIMode
_c.mutation.SetAPIMode(v)
@@ -357,6 +389,14 @@ func (_c *ChannelMonitorCreate) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if _, ok := _c.mutation.CheckMode(); !ok {
return &ValidationError{Name: "check_mode", err: errors.New(`ent: missing required field "ChannelMonitor.check_mode"`)}
}
if v, ok := _c.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if _, ok := _c.mutation.APIMode(); !ok {
return &ValidationError{Name: "api_mode", err: errors.New(`ent: missing required field "ChannelMonitor.api_mode"`)}
}
@@ -473,6 +513,14 @@ func (_c *ChannelMonitorCreate) createSpec() (*ChannelMonitor, *sqlgraph.CreateS
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
_node.Provider = value
}
if value, ok := _c.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
_node.CheckMode = value
}
if value, ok := _c.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
_node.AccountID = &value
}
if value, ok := _c.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
_node.APIMode = value
@@ -666,6 +714,42 @@ func (u *ChannelMonitorUpsert) UpdateProvider() *ChannelMonitorUpsert {
return u
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsert) SetCheckMode(v string) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldCheckMode, v)
return u
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsert) UpdateCheckMode() *ChannelMonitorUpsert {
u.SetExcluded(channelmonitor.FieldCheckMode)
return u
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsert) SetAccountID(v int64) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldAccountID, v)
return u
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsert) UpdateAccountID() *ChannelMonitorUpsert {
u.SetExcluded(channelmonitor.FieldAccountID)
return u
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsert) AddAccountID(v int64) *ChannelMonitorUpsert {
u.Add(channelmonitor.FieldAccountID, v)
return u
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsert) ClearAccountID() *ChannelMonitorUpsert {
u.SetNull(channelmonitor.FieldAccountID)
return u
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsert) SetAPIMode(v string) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldAPIMode, v)
@@ -975,6 +1059,48 @@ func (u *ChannelMonitorUpsertOne) UpdateProvider() *ChannelMonitorUpsertOne {
})
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsertOne) SetCheckMode(v string) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetCheckMode(v)
})
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsertOne) UpdateCheckMode() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateCheckMode()
})
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsertOne) SetAccountID(v int64) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetAccountID(v)
})
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsertOne) AddAccountID(v int64) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddAccountID(v)
})
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsertOne) UpdateAccountID() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateAccountID()
})
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsertOne) ClearAccountID() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.ClearAccountID()
})
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsertOne) SetAPIMode(v string) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
@@ -1487,6 +1613,48 @@ func (u *ChannelMonitorUpsertBulk) UpdateProvider() *ChannelMonitorUpsertBulk {
})
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsertBulk) SetCheckMode(v string) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetCheckMode(v)
})
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsertBulk) UpdateCheckMode() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateCheckMode()
})
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsertBulk) SetAccountID(v int64) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetAccountID(v)
})
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsertBulk) AddAccountID(v int64) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddAccountID(v)
})
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsertBulk) UpdateAccountID() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateAccountID()
})
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsertBulk) ClearAccountID() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.ClearAccountID()
})
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsertBulk) SetAPIMode(v string) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
+116
View File
@@ -66,6 +66,47 @@ func (_u *ChannelMonitorUpdate) SetNillableProvider(v *channelmonitor.Provider)
return _u
}
// SetCheckMode sets the "check_mode" field.
func (_u *ChannelMonitorUpdate) SetCheckMode(v string) *ChannelMonitorUpdate {
_u.mutation.SetCheckMode(v)
return _u
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_u *ChannelMonitorUpdate) SetNillableCheckMode(v *string) *ChannelMonitorUpdate {
if v != nil {
_u.SetCheckMode(*v)
}
return _u
}
// SetAccountID sets the "account_id" field.
func (_u *ChannelMonitorUpdate) SetAccountID(v int64) *ChannelMonitorUpdate {
_u.mutation.ResetAccountID()
_u.mutation.SetAccountID(v)
return _u
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_u *ChannelMonitorUpdate) SetNillableAccountID(v *int64) *ChannelMonitorUpdate {
if v != nil {
_u.SetAccountID(*v)
}
return _u
}
// AddAccountID adds value to the "account_id" field.
func (_u *ChannelMonitorUpdate) AddAccountID(v int64) *ChannelMonitorUpdate {
_u.mutation.AddAccountID(v)
return _u
}
// ClearAccountID clears the value of the "account_id" field.
func (_u *ChannelMonitorUpdate) ClearAccountID() *ChannelMonitorUpdate {
_u.mutation.ClearAccountID()
return _u
}
// SetAPIMode sets the "api_mode" field.
func (_u *ChannelMonitorUpdate) SetAPIMode(v string) *ChannelMonitorUpdate {
_u.mutation.SetAPIMode(v)
@@ -453,6 +494,11 @@ func (_u *ChannelMonitorUpdate) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if v, ok := _u.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if v, ok := _u.mutation.APIMode(); ok {
if err := channelmonitor.APIModeValidator(v); err != nil {
return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)}
@@ -517,6 +563,18 @@ func (_u *ChannelMonitorUpdate) sqlSave(ctx context.Context) (_node int, err err
if value, ok := _u.mutation.Provider(); ok {
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
}
if value, ok := _u.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
}
if value, ok := _u.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if value, ok := _u.mutation.AddedAccountID(); ok {
_spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if _u.mutation.AccountIDCleared() {
_spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64)
}
if value, ok := _u.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
}
@@ -755,6 +813,47 @@ func (_u *ChannelMonitorUpdateOne) SetNillableProvider(v *channelmonitor.Provide
return _u
}
// SetCheckMode sets the "check_mode" field.
func (_u *ChannelMonitorUpdateOne) SetCheckMode(v string) *ChannelMonitorUpdateOne {
_u.mutation.SetCheckMode(v)
return _u
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_u *ChannelMonitorUpdateOne) SetNillableCheckMode(v *string) *ChannelMonitorUpdateOne {
if v != nil {
_u.SetCheckMode(*v)
}
return _u
}
// SetAccountID sets the "account_id" field.
func (_u *ChannelMonitorUpdateOne) SetAccountID(v int64) *ChannelMonitorUpdateOne {
_u.mutation.ResetAccountID()
_u.mutation.SetAccountID(v)
return _u
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_u *ChannelMonitorUpdateOne) SetNillableAccountID(v *int64) *ChannelMonitorUpdateOne {
if v != nil {
_u.SetAccountID(*v)
}
return _u
}
// AddAccountID adds value to the "account_id" field.
func (_u *ChannelMonitorUpdateOne) AddAccountID(v int64) *ChannelMonitorUpdateOne {
_u.mutation.AddAccountID(v)
return _u
}
// ClearAccountID clears the value of the "account_id" field.
func (_u *ChannelMonitorUpdateOne) ClearAccountID() *ChannelMonitorUpdateOne {
_u.mutation.ClearAccountID()
return _u
}
// SetAPIMode sets the "api_mode" field.
func (_u *ChannelMonitorUpdateOne) SetAPIMode(v string) *ChannelMonitorUpdateOne {
_u.mutation.SetAPIMode(v)
@@ -1155,6 +1254,11 @@ func (_u *ChannelMonitorUpdateOne) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if v, ok := _u.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if v, ok := _u.mutation.APIMode(); ok {
if err := channelmonitor.APIModeValidator(v); err != nil {
return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)}
@@ -1236,6 +1340,18 @@ func (_u *ChannelMonitorUpdateOne) sqlSave(ctx context.Context) (_node *ChannelM
if value, ok := _u.mutation.Provider(); ok {
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
}
if value, ok := _u.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
}
if value, ok := _u.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if value, ok := _u.mutation.AddedAccountID(); ok {
_spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if _u.mutation.AccountIDCleared() {
_spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64)
}
if value, ok := _u.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
}
+17
View File
@@ -3,6 +3,7 @@
package ent
import (
"encoding/json"
"fmt"
"strings"
"time"
@@ -11,6 +12,7 @@ import (
"entgo.io/ent/dialect/sql"
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistory is the model entity for the ChannelMonitorHistory schema.
@@ -30,6 +32,8 @@ type ChannelMonitorHistory struct {
PingLatencyMs *int `json:"ping_latency_ms,omitempty"`
// Message holds the value of the "message" field.
Message string `json:"message,omitempty"`
// Quota holds the value of the "quota" field.
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
// CheckedAt holds the value of the "checked_at" field.
CheckedAt time.Time `json:"checked_at,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
@@ -63,6 +67,8 @@ func (*ChannelMonitorHistory) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case channelmonitorhistory.FieldQuota:
values[i] = new([]byte)
case channelmonitorhistory.FieldID, channelmonitorhistory.FieldMonitorID, channelmonitorhistory.FieldLatencyMs, channelmonitorhistory.FieldPingLatencyMs:
values[i] = new(sql.NullInt64)
case channelmonitorhistory.FieldModel, channelmonitorhistory.FieldStatus, channelmonitorhistory.FieldMessage:
@@ -128,6 +134,14 @@ func (_m *ChannelMonitorHistory) assignValues(columns []string, values []any) er
} else if value.Valid {
_m.Message = value.String
}
case channelmonitorhistory.FieldQuota:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field quota", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.Quota); err != nil {
return fmt.Errorf("unmarshal field quota: %w", err)
}
}
case channelmonitorhistory.FieldCheckedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field checked_at", values[i])
@@ -197,6 +211,9 @@ func (_m *ChannelMonitorHistory) String() string {
builder.WriteString("message=")
builder.WriteString(_m.Message)
builder.WriteString(", ")
builder.WriteString("quota=")
builder.WriteString(fmt.Sprintf("%v", _m.Quota))
builder.WriteString(", ")
builder.WriteString("checked_at=")
builder.WriteString(_m.CheckedAt.Format(time.ANSIC))
builder.WriteByte(')')
@@ -27,6 +27,8 @@ const (
FieldPingLatencyMs = "ping_latency_ms"
// FieldMessage holds the string denoting the message field in the database.
FieldMessage = "message"
// FieldQuota holds the string denoting the quota field in the database.
FieldQuota = "quota"
// FieldCheckedAt holds the string denoting the checked_at field in the database.
FieldCheckedAt = "checked_at"
// EdgeMonitor holds the string denoting the monitor edge name in mutations.
@@ -51,6 +53,7 @@ var Columns = []string{
FieldLatencyMs,
FieldPingLatencyMs,
FieldMessage,
FieldQuota,
FieldCheckedAt,
}
@@ -365,6 +365,16 @@ func MessageContainsFold(v string) predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldContainsFold(FieldMessage, v))
}
// QuotaIsNil applies the IsNil predicate on the "quota" field.
func QuotaIsNil() predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldIsNull(FieldQuota))
}
// QuotaNotNil applies the NotNil predicate on the "quota" field.
func QuotaNotNil() predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldNotNull(FieldQuota))
}
// CheckedAtEQ applies the EQ predicate on the "checked_at" field.
func CheckedAtEQ(v time.Time) predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldEQ(FieldCheckedAt, v))
@@ -13,6 +13,7 @@ import (
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistoryCreate is the builder for creating a ChannelMonitorHistory entity.
@@ -83,6 +84,12 @@ func (_c *ChannelMonitorHistoryCreate) SetNillableMessage(v *string) *ChannelMon
return _c
}
// SetQuota sets the "quota" field.
func (_c *ChannelMonitorHistoryCreate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryCreate {
_c.mutation.SetQuota(v)
return _c
}
// SetCheckedAt sets the "checked_at" field.
func (_c *ChannelMonitorHistoryCreate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryCreate {
_c.mutation.SetCheckedAt(v)
@@ -226,6 +233,10 @@ func (_c *ChannelMonitorHistoryCreate) createSpec() (*ChannelMonitorHistory, *sq
_spec.SetField(channelmonitorhistory.FieldMessage, field.TypeString, value)
_node.Message = value
}
if value, ok := _c.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
_node.Quota = value
}
if value, ok := _c.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
_node.CheckedAt = value
@@ -401,6 +412,24 @@ func (u *ChannelMonitorHistoryUpsert) ClearMessage() *ChannelMonitorHistoryUpser
return u
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsert) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsert {
u.Set(channelmonitorhistory.FieldQuota, v)
return u
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsert) UpdateQuota() *ChannelMonitorHistoryUpsert {
u.SetExcluded(channelmonitorhistory.FieldQuota)
return u
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsert) ClearQuota() *ChannelMonitorHistoryUpsert {
u.SetNull(channelmonitorhistory.FieldQuota)
return u
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsert) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsert {
u.Set(channelmonitorhistory.FieldCheckedAt, v)
@@ -572,6 +601,27 @@ func (u *ChannelMonitorHistoryUpsertOne) ClearMessage() *ChannelMonitorHistoryUp
})
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsertOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.SetQuota(v)
})
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsertOne) UpdateQuota() *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.UpdateQuota()
})
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsertOne) ClearQuota() *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.ClearQuota()
})
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsertOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
@@ -909,6 +959,27 @@ func (u *ChannelMonitorHistoryUpsertBulk) ClearMessage() *ChannelMonitorHistoryU
})
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsertBulk) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.SetQuota(v)
})
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsertBulk) UpdateQuota() *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.UpdateQuota()
})
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsertBulk) ClearQuota() *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.ClearQuota()
})
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsertBulk) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
@@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistoryUpdate is the builder for updating ChannelMonitorHistory entities.
@@ -145,6 +146,18 @@ func (_u *ChannelMonitorHistoryUpdate) ClearMessage() *ChannelMonitorHistoryUpda
return _u
}
// SetQuota sets the "quota" field.
func (_u *ChannelMonitorHistoryUpdate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdate {
_u.mutation.SetQuota(v)
return _u
}
// ClearQuota clears the value of the "quota" field.
func (_u *ChannelMonitorHistoryUpdate) ClearQuota() *ChannelMonitorHistoryUpdate {
_u.mutation.ClearQuota()
return _u
}
// SetCheckedAt sets the "checked_at" field.
func (_u *ChannelMonitorHistoryUpdate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdate {
_u.mutation.SetCheckedAt(v)
@@ -267,6 +280,12 @@ func (_u *ChannelMonitorHistoryUpdate) sqlSave(ctx context.Context) (_node int,
if _u.mutation.MessageCleared() {
_spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString)
}
if value, ok := _u.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
}
if _u.mutation.QuotaCleared() {
_spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON)
}
if value, ok := _u.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
}
@@ -435,6 +454,18 @@ func (_u *ChannelMonitorHistoryUpdateOne) ClearMessage() *ChannelMonitorHistoryU
return _u
}
// SetQuota sets the "quota" field.
func (_u *ChannelMonitorHistoryUpdateOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdateOne {
_u.mutation.SetQuota(v)
return _u
}
// ClearQuota clears the value of the "quota" field.
func (_u *ChannelMonitorHistoryUpdateOne) ClearQuota() *ChannelMonitorHistoryUpdateOne {
_u.mutation.ClearQuota()
return _u
}
// SetCheckedAt sets the "checked_at" field.
func (_u *ChannelMonitorHistoryUpdateOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdateOne {
_u.mutation.SetCheckedAt(v)
@@ -587,6 +618,12 @@ func (_u *ChannelMonitorHistoryUpdateOne) sqlSave(ctx context.Context) (_node *C
if _u.mutation.MessageCleared() {
_spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString)
}
if value, ok := _u.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
}
if _u.mutation.QuotaCleared() {
_spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON)
}
if value, ok := _u.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
}
@@ -100,10 +100,14 @@ type Provider string
// Provider values.
const (
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderAntigravity Provider = "antigravity"
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
)
func (pr Provider) String() string {
@@ -113,7 +117,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
return nil
default:
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
+97 -3
View File
@@ -4,6 +4,7 @@ package ent
import (
"encoding/json"
"encoding/json/jsontext"
"fmt"
"strings"
"time"
@@ -85,8 +86,22 @@ type Group struct {
VideoPrice720p *float64 `json:"video_price_720p,omitempty"`
// VideoPrice1080p holds the value of the "video_price_1080p" field.
VideoPrice1080p *float64 `json:"video_price_1080p,omitempty"`
// 按模型族和分辨率覆盖视频每秒价格
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
// Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)
WebSearchPricePerCall *float64 `json:"web_search_price_per_call,omitempty"`
// 搜索工具价格 per 1000 calls(web_search 等)
SearchPricePer1k *float64 `json:"search_price_per_1k,omitempty"`
// Voice realtime 每分钟价格(USD)
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min,omitempty"`
// TTS 每百万字符价格(USD)
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"`
// STT 每小时价格(USD)
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"`
// 是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价
LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"`
// 分组逐模型定价;优先级高于渠道和内置定价
ModelPricing jsontext.Value `json:"model_pricing,omitempty"`
// 是否仅允许 Claude Code 客户端
ClaudeCodeOnly bool `json:"claude_code_only,omitempty"`
// 非 Claude Code 请求降级使用的分组 ID
@@ -235,11 +250,11 @@ func (*Group) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
values[i] = new([]byte)
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
values[i] = new(sql.NullFloat64)
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
values[i] = new(sql.NullInt64)
@@ -478,6 +493,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 +508,48 @@ func (_m *Group) assignValues(columns []string, values []any) error {
_m.WebSearchPricePerCall = new(float64)
*_m.WebSearchPricePerCall = value.Float64
}
case group.FieldSearchPricePer1k:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field search_price_per_1k", values[i])
} else if value.Valid {
_m.SearchPricePer1k = new(float64)
*_m.SearchPricePer1k = value.Float64
}
case group.FieldAudioRealtimePricePerMin:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field audio_realtime_price_per_min", values[i])
} else if value.Valid {
_m.AudioRealtimePricePerMin = new(float64)
*_m.AudioRealtimePricePerMin = value.Float64
}
case group.FieldAudioTtsPricePerMillionChars:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field audio_tts_price_per_million_chars", values[i])
} else if value.Valid {
_m.AudioTtsPricePerMillionChars = new(float64)
*_m.AudioTtsPricePerMillionChars = value.Float64
}
case group.FieldAudioSttPricePerHour:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field audio_stt_price_per_hour", values[i])
} else if value.Valid {
_m.AudioSttPricePerHour = new(float64)
*_m.AudioSttPricePerHour = value.Float64
}
case group.FieldLongContextPricingEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field long_context_pricing_enabled", values[i])
} else if value.Valid {
_m.LongContextPricingEnabled = value.Bool
}
case group.FieldModelPricing:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field model_pricing", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.ModelPricing); err != nil {
return fmt.Errorf("unmarshal field model_pricing: %w", err)
}
}
case group.FieldClaudeCodeOnly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field claude_code_only", values[i])
@@ -822,11 +887,40 @@ func (_m *Group) String() string {
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("video_model_prices=")
builder.WriteString(fmt.Sprintf("%v", _m.VideoModelPrices))
builder.WriteString(", ")
if v := _m.WebSearchPricePerCall; v != nil {
builder.WriteString("web_search_price_per_call=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.SearchPricePer1k; v != nil {
builder.WriteString("search_price_per_1k=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.AudioRealtimePricePerMin; v != nil {
builder.WriteString("audio_realtime_price_per_min=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.AudioTtsPricePerMillionChars; v != nil {
builder.WriteString("audio_tts_price_per_million_chars=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.AudioSttPricePerHour; v != nil {
builder.WriteString("audio_stt_price_per_hour=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("long_context_pricing_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.LongContextPricingEnabled))
builder.WriteString(", ")
builder.WriteString("model_pricing=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelPricing))
builder.WriteString(", ")
builder.WriteString("claude_code_only=")
builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly))
builder.WriteString(", ")
+56
View File
@@ -82,8 +82,22 @@ const (
FieldVideoPrice720p = "video_price_720p"
// FieldVideoPrice1080p holds the string denoting the video_price_1080p field in the database.
FieldVideoPrice1080p = "video_price_1080p"
// FieldVideoModelPrices holds the string denoting the video_model_prices field in the database.
FieldVideoModelPrices = "video_model_prices"
// FieldWebSearchPricePerCall holds the string denoting the web_search_price_per_call field in the database.
FieldWebSearchPricePerCall = "web_search_price_per_call"
// FieldSearchPricePer1k holds the string denoting the search_price_per_1k field in the database.
FieldSearchPricePer1k = "search_price_per_1k"
// FieldAudioRealtimePricePerMin holds the string denoting the audio_realtime_price_per_min field in the database.
FieldAudioRealtimePricePerMin = "audio_realtime_price_per_min"
// FieldAudioTtsPricePerMillionChars holds the string denoting the audio_tts_price_per_million_chars field in the database.
FieldAudioTtsPricePerMillionChars = "audio_tts_price_per_million_chars"
// FieldAudioSttPricePerHour holds the string denoting the audio_stt_price_per_hour field in the database.
FieldAudioSttPricePerHour = "audio_stt_price_per_hour"
// FieldLongContextPricingEnabled holds the string denoting the long_context_pricing_enabled field in the database.
FieldLongContextPricingEnabled = "long_context_pricing_enabled"
// FieldModelPricing holds the string denoting the model_pricing field in the database.
FieldModelPricing = "model_pricing"
// FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database.
FieldClaudeCodeOnly = "claude_code_only"
// FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database.
@@ -234,7 +248,14 @@ var Columns = []string{
FieldVideoPrice480p,
FieldVideoPrice720p,
FieldVideoPrice1080p,
FieldVideoModelPrices,
FieldWebSearchPricePerCall,
FieldSearchPricePer1k,
FieldAudioRealtimePricePerMin,
FieldAudioTtsPricePerMillionChars,
FieldAudioSttPricePerHour,
FieldLongContextPricingEnabled,
FieldModelPricing,
FieldClaudeCodeOnly,
FieldFallbackGroupID,
FieldFallbackGroupIDOnInvalidRequest,
@@ -341,6 +362,16 @@ var (
DefaultVideoRateIndependent bool
// DefaultVideoRateMultiplier holds the default value on creation for the "video_rate_multiplier" field.
DefaultVideoRateMultiplier float64
// SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save.
SearchPricePer1kValidator func(float64) error
// AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save.
AudioRealtimePricePerMinValidator func(float64) error
// AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save.
AudioTtsPricePerMillionCharsValidator func(float64) error
// AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
AudioSttPricePerHourValidator func(float64) error
// DefaultLongContextPricingEnabled holds the default value on creation for the "long_context_pricing_enabled" field.
DefaultLongContextPricingEnabled bool
// DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field.
DefaultClaudeCodeOnly bool
// DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field.
@@ -561,6 +592,31 @@ func ByWebSearchPricePerCall(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldWebSearchPricePerCall, opts...).ToFunc()
}
// BySearchPricePer1k orders the results by the search_price_per_1k field.
func BySearchPricePer1k(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldSearchPricePer1k, opts...).ToFunc()
}
// ByAudioRealtimePricePerMin orders the results by the audio_realtime_price_per_min field.
func ByAudioRealtimePricePerMin(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAudioRealtimePricePerMin, opts...).ToFunc()
}
// ByAudioTtsPricePerMillionChars orders the results by the audio_tts_price_per_million_chars field.
func ByAudioTtsPricePerMillionChars(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAudioTtsPricePerMillionChars, opts...).ToFunc()
}
// ByAudioSttPricePerHour orders the results by the audio_stt_price_per_hour field.
func ByAudioSttPricePerHour(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAudioSttPricePerHour, opts...).ToFunc()
}
// ByLongContextPricingEnabled orders the results by the long_context_pricing_enabled field.
func ByLongContextPricingEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLongContextPricingEnabled, opts...).ToFunc()
}
// ByClaudeCodeOnly orders the results by the claude_code_only field.
func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc()
+255
View File
@@ -225,6 +225,31 @@ func WebSearchPricePerCall(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v))
}
// SearchPricePer1k applies equality check predicate on the "search_price_per_1k" field. It's identical to SearchPricePer1kEQ.
func SearchPricePer1k(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v))
}
// AudioRealtimePricePerMin applies equality check predicate on the "audio_realtime_price_per_min" field. It's identical to AudioRealtimePricePerMinEQ.
func AudioRealtimePricePerMin(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v))
}
// AudioTtsPricePerMillionChars applies equality check predicate on the "audio_tts_price_per_million_chars" field. It's identical to AudioTtsPricePerMillionCharsEQ.
func AudioTtsPricePerMillionChars(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioTtsPricePerMillionChars, v))
}
// AudioSttPricePerHour applies equality check predicate on the "audio_stt_price_per_hour" field. It's identical to AudioSttPricePerHourEQ.
func AudioSttPricePerHour(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v))
}
// LongContextPricingEnabled applies equality check predicate on the "long_context_pricing_enabled" field. It's identical to LongContextPricingEnabledEQ.
func LongContextPricingEnabled(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldLongContextPricingEnabled, v))
}
// ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ.
func ClaudeCodeOnly(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
@@ -1765,6 +1790,16 @@ func VideoPrice1080pNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldVideoPrice1080p))
}
// VideoModelPricesIsNil applies the IsNil predicate on the "video_model_prices" field.
func VideoModelPricesIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldVideoModelPrices))
}
// VideoModelPricesNotNil applies the NotNil predicate on the "video_model_prices" field.
func VideoModelPricesNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldVideoModelPrices))
}
// WebSearchPricePerCallEQ applies the EQ predicate on the "web_search_price_per_call" field.
func WebSearchPricePerCallEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldWebSearchPricePerCall, v))
@@ -1815,6 +1850,226 @@ func WebSearchPricePerCallNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldWebSearchPricePerCall))
}
// SearchPricePer1kEQ applies the EQ predicate on the "search_price_per_1k" field.
func SearchPricePer1kEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldSearchPricePer1k, v))
}
// SearchPricePer1kNEQ applies the NEQ predicate on the "search_price_per_1k" field.
func SearchPricePer1kNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldSearchPricePer1k, v))
}
// SearchPricePer1kIn applies the In predicate on the "search_price_per_1k" field.
func SearchPricePer1kIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldSearchPricePer1k, vs...))
}
// SearchPricePer1kNotIn applies the NotIn predicate on the "search_price_per_1k" field.
func SearchPricePer1kNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldSearchPricePer1k, vs...))
}
// SearchPricePer1kGT applies the GT predicate on the "search_price_per_1k" field.
func SearchPricePer1kGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldSearchPricePer1k, v))
}
// SearchPricePer1kGTE applies the GTE predicate on the "search_price_per_1k" field.
func SearchPricePer1kGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldSearchPricePer1k, v))
}
// SearchPricePer1kLT applies the LT predicate on the "search_price_per_1k" field.
func SearchPricePer1kLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldSearchPricePer1k, v))
}
// SearchPricePer1kLTE applies the LTE predicate on the "search_price_per_1k" field.
func SearchPricePer1kLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldSearchPricePer1k, v))
}
// SearchPricePer1kIsNil applies the IsNil predicate on the "search_price_per_1k" field.
func SearchPricePer1kIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldSearchPricePer1k))
}
// SearchPricePer1kNotNil applies the NotNil predicate on the "search_price_per_1k" field.
func SearchPricePer1kNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldSearchPricePer1k))
}
// AudioRealtimePricePerMinEQ applies the EQ predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinNEQ applies the NEQ predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinIn applies the In predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldAudioRealtimePricePerMin, vs...))
}
// AudioRealtimePricePerMinNotIn applies the NotIn predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldAudioRealtimePricePerMin, vs...))
}
// AudioRealtimePricePerMinGT applies the GT predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinGTE applies the GTE predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinLT applies the LT predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinLTE applies the LTE predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldAudioRealtimePricePerMin, v))
}
// AudioRealtimePricePerMinIsNil applies the IsNil predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldAudioRealtimePricePerMin))
}
// AudioRealtimePricePerMinNotNil applies the NotNil predicate on the "audio_realtime_price_per_min" field.
func AudioRealtimePricePerMinNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldAudioRealtimePricePerMin))
}
// AudioTtsPricePerMillionCharsEQ applies the EQ predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsNEQ applies the NEQ predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsIn applies the In predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldAudioTtsPricePerMillionChars, vs...))
}
// AudioTtsPricePerMillionCharsNotIn applies the NotIn predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldAudioTtsPricePerMillionChars, vs...))
}
// AudioTtsPricePerMillionCharsGT applies the GT predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsGTE applies the GTE predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsLT applies the LT predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsLTE applies the LTE predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldAudioTtsPricePerMillionChars, v))
}
// AudioTtsPricePerMillionCharsIsNil applies the IsNil predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldAudioTtsPricePerMillionChars))
}
// AudioTtsPricePerMillionCharsNotNil applies the NotNil predicate on the "audio_tts_price_per_million_chars" field.
func AudioTtsPricePerMillionCharsNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldAudioTtsPricePerMillionChars))
}
// AudioSttPricePerHourEQ applies the EQ predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourNEQ applies the NEQ predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourIn applies the In predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldAudioSttPricePerHour, vs...))
}
// AudioSttPricePerHourNotIn applies the NotIn predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldAudioSttPricePerHour, vs...))
}
// AudioSttPricePerHourGT applies the GT predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourGTE applies the GTE predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourLT applies the LT predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourLTE applies the LTE predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldAudioSttPricePerHour, v))
}
// AudioSttPricePerHourIsNil applies the IsNil predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldAudioSttPricePerHour))
}
// AudioSttPricePerHourNotNil applies the NotNil predicate on the "audio_stt_price_per_hour" field.
func AudioSttPricePerHourNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldAudioSttPricePerHour))
}
// LongContextPricingEnabledEQ applies the EQ predicate on the "long_context_pricing_enabled" field.
func LongContextPricingEnabledEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldLongContextPricingEnabled, v))
}
// LongContextPricingEnabledNEQ applies the NEQ predicate on the "long_context_pricing_enabled" field.
func LongContextPricingEnabledNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldLongContextPricingEnabled, v))
}
// ModelPricingIsNil applies the IsNil predicate on the "model_pricing" field.
func ModelPricingIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldModelPricing))
}
// ModelPricingNotNil applies the NotNil predicate on the "model_pricing" field.
func ModelPricingNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldModelPricing))
}
// ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field.
func ClaudeCodeOnlyEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
+618
View File
@@ -4,6 +4,7 @@ package ent
import (
"context"
"encoding/json/jsontext"
"errors"
"fmt"
"time"
@@ -483,6 +484,12 @@ func (_c *GroupCreate) SetNillableVideoPrice1080p(v *float64) *GroupCreate {
return _c
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (_c *GroupCreate) SetVideoModelPrices(v map[string]map[string]float64) *GroupCreate {
_c.mutation.SetVideoModelPrices(v)
return _c
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (_c *GroupCreate) SetWebSearchPricePerCall(v float64) *GroupCreate {
_c.mutation.SetWebSearchPricePerCall(v)
@@ -497,6 +504,82 @@ func (_c *GroupCreate) SetNillableWebSearchPricePerCall(v *float64) *GroupCreate
return _c
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (_c *GroupCreate) SetSearchPricePer1k(v float64) *GroupCreate {
_c.mutation.SetSearchPricePer1k(v)
return _c
}
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
func (_c *GroupCreate) SetNillableSearchPricePer1k(v *float64) *GroupCreate {
if v != nil {
_c.SetSearchPricePer1k(*v)
}
return _c
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (_c *GroupCreate) SetAudioRealtimePricePerMin(v float64) *GroupCreate {
_c.mutation.SetAudioRealtimePricePerMin(v)
return _c
}
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
func (_c *GroupCreate) SetNillableAudioRealtimePricePerMin(v *float64) *GroupCreate {
if v != nil {
_c.SetAudioRealtimePricePerMin(*v)
}
return _c
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (_c *GroupCreate) SetAudioTtsPricePerMillionChars(v float64) *GroupCreate {
_c.mutation.SetAudioTtsPricePerMillionChars(v)
return _c
}
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
func (_c *GroupCreate) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupCreate {
if v != nil {
_c.SetAudioTtsPricePerMillionChars(*v)
}
return _c
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (_c *GroupCreate) SetAudioSttPricePerHour(v float64) *GroupCreate {
_c.mutation.SetAudioSttPricePerHour(v)
return _c
}
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
func (_c *GroupCreate) SetNillableAudioSttPricePerHour(v *float64) *GroupCreate {
if v != nil {
_c.SetAudioSttPricePerHour(*v)
}
return _c
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_c *GroupCreate) SetLongContextPricingEnabled(v bool) *GroupCreate {
_c.mutation.SetLongContextPricingEnabled(v)
return _c
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_c *GroupCreate) SetNillableLongContextPricingEnabled(v *bool) *GroupCreate {
if v != nil {
_c.SetLongContextPricingEnabled(*v)
}
return _c
}
// SetModelPricing sets the "model_pricing" field.
func (_c *GroupCreate) SetModelPricing(v jsontext.Value) *GroupCreate {
_c.mutation.SetModelPricing(v)
return _c
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate {
_c.mutation.SetClaudeCodeOnly(v)
@@ -980,6 +1063,10 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultVideoRateMultiplier
_c.mutation.SetVideoRateMultiplier(v)
}
if _, ok := _c.mutation.LongContextPricingEnabled(); !ok {
v := group.DefaultLongContextPricingEnabled
_c.mutation.SetLongContextPricingEnabled(v)
}
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
v := group.DefaultClaudeCodeOnly
_c.mutation.SetClaudeCodeOnly(v)
@@ -1155,6 +1242,29 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.VideoRateMultiplier(); !ok {
return &ValidationError{Name: "video_rate_multiplier", err: errors.New(`ent: missing required field "Group.video_rate_multiplier"`)}
}
if v, ok := _c.mutation.SearchPricePer1k(); ok {
if err := group.SearchPricePer1kValidator(v); err != nil {
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
}
}
if v, ok := _c.mutation.AudioRealtimePricePerMin(); ok {
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
}
}
if v, ok := _c.mutation.AudioTtsPricePerMillionChars(); ok {
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
}
}
if v, ok := _c.mutation.AudioSttPricePerHour(); ok {
if err := group.AudioSttPricePerHourValidator(v); err != nil {
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
}
}
if _, ok := _c.mutation.LongContextPricingEnabled(); !ok {
return &ValidationError{Name: "long_context_pricing_enabled", err: errors.New(`ent: missing required field "Group.long_context_pricing_enabled"`)}
}
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)}
}
@@ -1378,10 +1488,38 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldVideoPrice1080p, field.TypeFloat64, value)
_node.VideoPrice1080p = &value
}
if value, ok := _c.mutation.VideoModelPrices(); ok {
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
_node.VideoModelPrices = value
}
if value, ok := _c.mutation.WebSearchPricePerCall(); ok {
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
_node.WebSearchPricePerCall = &value
}
if value, ok := _c.mutation.SearchPricePer1k(); ok {
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
_node.SearchPricePer1k = &value
}
if value, ok := _c.mutation.AudioRealtimePricePerMin(); ok {
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
_node.AudioRealtimePricePerMin = &value
}
if value, ok := _c.mutation.AudioTtsPricePerMillionChars(); ok {
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
_node.AudioTtsPricePerMillionChars = &value
}
if value, ok := _c.mutation.AudioSttPricePerHour(); ok {
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
_node.AudioSttPricePerHour = &value
}
if value, ok := _c.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
_node.LongContextPricingEnabled = value
}
if value, ok := _c.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
_node.ModelPricing = value
}
if value, ok := _c.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
_node.ClaudeCodeOnly = value
@@ -2156,6 +2294,24 @@ func (u *GroupUpsert) ClearVideoPrice1080p() *GroupUpsert {
return u
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (u *GroupUpsert) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsert {
u.Set(group.FieldVideoModelPrices, v)
return u
}
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
func (u *GroupUpsert) UpdateVideoModelPrices() *GroupUpsert {
u.SetExcluded(group.FieldVideoModelPrices)
return u
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (u *GroupUpsert) ClearVideoModelPrices() *GroupUpsert {
u.SetNull(group.FieldVideoModelPrices)
return u
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (u *GroupUpsert) SetWebSearchPricePerCall(v float64) *GroupUpsert {
u.Set(group.FieldWebSearchPricePerCall, v)
@@ -2180,6 +2336,132 @@ func (u *GroupUpsert) ClearWebSearchPricePerCall() *GroupUpsert {
return u
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (u *GroupUpsert) SetSearchPricePer1k(v float64) *GroupUpsert {
u.Set(group.FieldSearchPricePer1k, v)
return u
}
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
func (u *GroupUpsert) UpdateSearchPricePer1k() *GroupUpsert {
u.SetExcluded(group.FieldSearchPricePer1k)
return u
}
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
func (u *GroupUpsert) AddSearchPricePer1k(v float64) *GroupUpsert {
u.Add(group.FieldSearchPricePer1k, v)
return u
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (u *GroupUpsert) ClearSearchPricePer1k() *GroupUpsert {
u.SetNull(group.FieldSearchPricePer1k)
return u
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (u *GroupUpsert) SetAudioRealtimePricePerMin(v float64) *GroupUpsert {
u.Set(group.FieldAudioRealtimePricePerMin, v)
return u
}
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
func (u *GroupUpsert) UpdateAudioRealtimePricePerMin() *GroupUpsert {
u.SetExcluded(group.FieldAudioRealtimePricePerMin)
return u
}
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
func (u *GroupUpsert) AddAudioRealtimePricePerMin(v float64) *GroupUpsert {
u.Add(group.FieldAudioRealtimePricePerMin, v)
return u
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (u *GroupUpsert) ClearAudioRealtimePricePerMin() *GroupUpsert {
u.SetNull(group.FieldAudioRealtimePricePerMin)
return u
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsert) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsert {
u.Set(group.FieldAudioTtsPricePerMillionChars, v)
return u
}
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
func (u *GroupUpsert) UpdateAudioTtsPricePerMillionChars() *GroupUpsert {
u.SetExcluded(group.FieldAudioTtsPricePerMillionChars)
return u
}
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsert) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsert {
u.Add(group.FieldAudioTtsPricePerMillionChars, v)
return u
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsert) ClearAudioTtsPricePerMillionChars() *GroupUpsert {
u.SetNull(group.FieldAudioTtsPricePerMillionChars)
return u
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (u *GroupUpsert) SetAudioSttPricePerHour(v float64) *GroupUpsert {
u.Set(group.FieldAudioSttPricePerHour, v)
return u
}
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
func (u *GroupUpsert) UpdateAudioSttPricePerHour() *GroupUpsert {
u.SetExcluded(group.FieldAudioSttPricePerHour)
return u
}
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
func (u *GroupUpsert) AddAudioSttPricePerHour(v float64) *GroupUpsert {
u.Add(group.FieldAudioSttPricePerHour, v)
return u
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (u *GroupUpsert) ClearAudioSttPricePerHour() *GroupUpsert {
u.SetNull(group.FieldAudioSttPricePerHour)
return u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsert) SetLongContextPricingEnabled(v bool) *GroupUpsert {
u.Set(group.FieldLongContextPricingEnabled, v)
return u
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsert) UpdateLongContextPricingEnabled() *GroupUpsert {
u.SetExcluded(group.FieldLongContextPricingEnabled)
return u
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsert) SetModelPricing(v jsontext.Value) *GroupUpsert {
u.Set(group.FieldModelPricing, v)
return u
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelPricing() *GroupUpsert {
u.SetExcluded(group.FieldModelPricing)
return u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsert) ClearModelPricing() *GroupUpsert {
u.SetNull(group.FieldModelPricing)
return u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert {
u.Set(group.FieldClaudeCodeOnly, v)
@@ -3157,6 +3439,27 @@ func (u *GroupUpsertOne) ClearVideoPrice1080p() *GroupUpsertOne {
})
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (u *GroupUpsertOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetVideoModelPrices(v)
})
}
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateVideoModelPrices() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoModelPrices()
})
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (u *GroupUpsertOne) ClearVideoModelPrices() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoModelPrices()
})
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (u *GroupUpsertOne) SetWebSearchPricePerCall(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -3185,6 +3488,153 @@ func (u *GroupUpsertOne) ClearWebSearchPricePerCall() *GroupUpsertOne {
})
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (u *GroupUpsertOne) SetSearchPricePer1k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetSearchPricePer1k(v)
})
}
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
func (u *GroupUpsertOne) AddSearchPricePer1k(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddSearchPricePer1k(v)
})
}
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateSearchPricePer1k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateSearchPricePer1k()
})
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (u *GroupUpsertOne) ClearSearchPricePer1k() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearSearchPricePer1k()
})
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (u *GroupUpsertOne) SetAudioRealtimePricePerMin(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetAudioRealtimePricePerMin(v)
})
}
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
func (u *GroupUpsertOne) AddAudioRealtimePricePerMin(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddAudioRealtimePricePerMin(v)
})
}
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateAudioRealtimePricePerMin() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioRealtimePricePerMin()
})
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (u *GroupUpsertOne) ClearAudioRealtimePricePerMin() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioRealtimePricePerMin()
})
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertOne) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetAudioTtsPricePerMillionChars(v)
})
}
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertOne) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddAudioTtsPricePerMillionChars(v)
})
}
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateAudioTtsPricePerMillionChars() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioTtsPricePerMillionChars()
})
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertOne) ClearAudioTtsPricePerMillionChars() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioTtsPricePerMillionChars()
})
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (u *GroupUpsertOne) SetAudioSttPricePerHour(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetAudioSttPricePerHour(v)
})
}
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
func (u *GroupUpsertOne) AddAudioSttPricePerHour(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddAudioSttPricePerHour(v)
})
}
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateAudioSttPricePerHour() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioSttPricePerHour()
})
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (u *GroupUpsertOne) ClearAudioSttPricePerHour() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioSttPricePerHour()
})
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsertOne) SetLongContextPricingEnabled(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetLongContextPricingEnabled(v)
})
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateLongContextPricingEnabled() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateLongContextPricingEnabled()
})
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsertOne) SetModelPricing(v jsontext.Value) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetModelPricing(v)
})
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelPricing() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelPricing()
})
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsertOne) ClearModelPricing() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearModelPricing()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -4379,6 +4829,27 @@ func (u *GroupUpsertBulk) ClearVideoPrice1080p() *GroupUpsertBulk {
})
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (u *GroupUpsertBulk) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetVideoModelPrices(v)
})
}
// UpdateVideoModelPrices sets the "video_model_prices" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateVideoModelPrices() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateVideoModelPrices()
})
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (u *GroupUpsertBulk) ClearVideoModelPrices() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearVideoModelPrices()
})
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (u *GroupUpsertBulk) SetWebSearchPricePerCall(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
@@ -4407,6 +4878,153 @@ func (u *GroupUpsertBulk) ClearWebSearchPricePerCall() *GroupUpsertBulk {
})
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (u *GroupUpsertBulk) SetSearchPricePer1k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetSearchPricePer1k(v)
})
}
// AddSearchPricePer1k adds v to the "search_price_per_1k" field.
func (u *GroupUpsertBulk) AddSearchPricePer1k(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddSearchPricePer1k(v)
})
}
// UpdateSearchPricePer1k sets the "search_price_per_1k" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateSearchPricePer1k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateSearchPricePer1k()
})
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (u *GroupUpsertBulk) ClearSearchPricePer1k() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearSearchPricePer1k()
})
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (u *GroupUpsertBulk) SetAudioRealtimePricePerMin(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetAudioRealtimePricePerMin(v)
})
}
// AddAudioRealtimePricePerMin adds v to the "audio_realtime_price_per_min" field.
func (u *GroupUpsertBulk) AddAudioRealtimePricePerMin(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddAudioRealtimePricePerMin(v)
})
}
// UpdateAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateAudioRealtimePricePerMin() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioRealtimePricePerMin()
})
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (u *GroupUpsertBulk) ClearAudioRealtimePricePerMin() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioRealtimePricePerMin()
})
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertBulk) SetAudioTtsPricePerMillionChars(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetAudioTtsPricePerMillionChars(v)
})
}
// AddAudioTtsPricePerMillionChars adds v to the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertBulk) AddAudioTtsPricePerMillionChars(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddAudioTtsPricePerMillionChars(v)
})
}
// UpdateAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateAudioTtsPricePerMillionChars() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioTtsPricePerMillionChars()
})
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (u *GroupUpsertBulk) ClearAudioTtsPricePerMillionChars() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioTtsPricePerMillionChars()
})
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (u *GroupUpsertBulk) SetAudioSttPricePerHour(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetAudioSttPricePerHour(v)
})
}
// AddAudioSttPricePerHour adds v to the "audio_stt_price_per_hour" field.
func (u *GroupUpsertBulk) AddAudioSttPricePerHour(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddAudioSttPricePerHour(v)
})
}
// UpdateAudioSttPricePerHour sets the "audio_stt_price_per_hour" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateAudioSttPricePerHour() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateAudioSttPricePerHour()
})
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (u *GroupUpsertBulk) ClearAudioSttPricePerHour() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearAudioSttPricePerHour()
})
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsertBulk) SetLongContextPricingEnabled(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetLongContextPricingEnabled(v)
})
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateLongContextPricingEnabled() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateLongContextPricingEnabled()
})
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsertBulk) SetModelPricing(v jsontext.Value) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetModelPricing(v)
})
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelPricing() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelPricing()
})
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsertBulk) ClearModelPricing() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearModelPricing()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+457
View File
@@ -4,6 +4,7 @@ package ent
import (
"context"
"encoding/json/jsontext"
"errors"
"fmt"
"time"
@@ -640,6 +641,18 @@ func (_u *GroupUpdate) ClearVideoPrice1080p() *GroupUpdate {
return _u
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (_u *GroupUpdate) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdate {
_u.mutation.SetVideoModelPrices(v)
return _u
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (_u *GroupUpdate) ClearVideoModelPrices() *GroupUpdate {
_u.mutation.ClearVideoModelPrices()
return _u
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (_u *GroupUpdate) SetWebSearchPricePerCall(v float64) *GroupUpdate {
_u.mutation.ResetWebSearchPricePerCall()
@@ -667,6 +680,146 @@ func (_u *GroupUpdate) ClearWebSearchPricePerCall() *GroupUpdate {
return _u
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (_u *GroupUpdate) SetSearchPricePer1k(v float64) *GroupUpdate {
_u.mutation.ResetSearchPricePer1k()
_u.mutation.SetSearchPricePer1k(v)
return _u
}
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableSearchPricePer1k(v *float64) *GroupUpdate {
if v != nil {
_u.SetSearchPricePer1k(*v)
}
return _u
}
// AddSearchPricePer1k adds value to the "search_price_per_1k" field.
func (_u *GroupUpdate) AddSearchPricePer1k(v float64) *GroupUpdate {
_u.mutation.AddSearchPricePer1k(v)
return _u
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (_u *GroupUpdate) ClearSearchPricePer1k() *GroupUpdate {
_u.mutation.ClearSearchPricePer1k()
return _u
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (_u *GroupUpdate) SetAudioRealtimePricePerMin(v float64) *GroupUpdate {
_u.mutation.ResetAudioRealtimePricePerMin()
_u.mutation.SetAudioRealtimePricePerMin(v)
return _u
}
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableAudioRealtimePricePerMin(v *float64) *GroupUpdate {
if v != nil {
_u.SetAudioRealtimePricePerMin(*v)
}
return _u
}
// AddAudioRealtimePricePerMin adds value to the "audio_realtime_price_per_min" field.
func (_u *GroupUpdate) AddAudioRealtimePricePerMin(v float64) *GroupUpdate {
_u.mutation.AddAudioRealtimePricePerMin(v)
return _u
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (_u *GroupUpdate) ClearAudioRealtimePricePerMin() *GroupUpdate {
_u.mutation.ClearAudioRealtimePricePerMin()
return _u
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdate) SetAudioTtsPricePerMillionChars(v float64) *GroupUpdate {
_u.mutation.ResetAudioTtsPricePerMillionChars()
_u.mutation.SetAudioTtsPricePerMillionChars(v)
return _u
}
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupUpdate {
if v != nil {
_u.SetAudioTtsPricePerMillionChars(*v)
}
return _u
}
// AddAudioTtsPricePerMillionChars adds value to the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdate) AddAudioTtsPricePerMillionChars(v float64) *GroupUpdate {
_u.mutation.AddAudioTtsPricePerMillionChars(v)
return _u
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdate) ClearAudioTtsPricePerMillionChars() *GroupUpdate {
_u.mutation.ClearAudioTtsPricePerMillionChars()
return _u
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (_u *GroupUpdate) SetAudioSttPricePerHour(v float64) *GroupUpdate {
_u.mutation.ResetAudioSttPricePerHour()
_u.mutation.SetAudioSttPricePerHour(v)
return _u
}
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableAudioSttPricePerHour(v *float64) *GroupUpdate {
if v != nil {
_u.SetAudioSttPricePerHour(*v)
}
return _u
}
// AddAudioSttPricePerHour adds value to the "audio_stt_price_per_hour" field.
func (_u *GroupUpdate) AddAudioSttPricePerHour(v float64) *GroupUpdate {
_u.mutation.AddAudioSttPricePerHour(v)
return _u
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (_u *GroupUpdate) ClearAudioSttPricePerHour() *GroupUpdate {
_u.mutation.ClearAudioSttPricePerHour()
return _u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_u *GroupUpdate) SetLongContextPricingEnabled(v bool) *GroupUpdate {
_u.mutation.SetLongContextPricingEnabled(v)
return _u
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdate {
if v != nil {
_u.SetLongContextPricingEnabled(*v)
}
return _u
}
// SetModelPricing sets the "model_pricing" field.
func (_u *GroupUpdate) SetModelPricing(v jsontext.Value) *GroupUpdate {
_u.mutation.SetModelPricing(v)
return _u
}
// AppendModelPricing appends value to the "model_pricing" field.
func (_u *GroupUpdate) AppendModelPricing(v jsontext.Value) *GroupUpdate {
_u.mutation.AppendModelPricing(v)
return _u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (_u *GroupUpdate) ClearModelPricing() *GroupUpdate {
_u.mutation.ClearModelPricing()
return _u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate {
_u.mutation.SetClaudeCodeOnly(v)
@@ -1304,6 +1457,26 @@ func (_u *GroupUpdate) check() error {
return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)}
}
}
if v, ok := _u.mutation.SearchPricePer1k(); ok {
if err := group.SearchPricePer1kValidator(v); err != nil {
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
}
}
if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
}
}
if v, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
}
}
if v, ok := _u.mutation.AudioSttPricePerHour(); ok {
if err := group.AudioSttPricePerHourValidator(v); err != nil {
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
}
}
if v, ok := _u.mutation.DefaultMappedModel(); ok {
if err := group.DefaultMappedModelValidator(v); err != nil {
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
@@ -1506,6 +1679,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.VideoPrice1080pCleared() {
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
}
if value, ok := _u.mutation.VideoModelPrices(); ok {
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
}
if _u.mutation.VideoModelPricesCleared() {
_spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON)
}
if value, ok := _u.mutation.WebSearchPricePerCall(); ok {
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
}
@@ -1515,6 +1694,56 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.WebSearchPricePerCallCleared() {
_spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64)
}
if value, ok := _u.mutation.SearchPricePer1k(); ok {
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedSearchPricePer1k(); ok {
_spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
}
if _u.mutation.SearchPricePer1kCleared() {
_spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioRealtimePricePerMin(); ok {
_spec.AddField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
}
if _u.mutation.AudioRealtimePricePerMinCleared() {
_spec.ClearField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioTtsPricePerMillionChars(); ok {
_spec.AddField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
}
if _u.mutation.AudioTtsPricePerMillionCharsCleared() {
_spec.ClearField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioSttPricePerHour(); ok {
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioSttPricePerHour(); ok {
_spec.AddField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
}
if _u.mutation.AudioSttPricePerHourCleared() {
_spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64)
}
if value, ok := _u.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedModelPricing(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, group.FieldModelPricing, value)
})
}
if _u.mutation.ModelPricingCleared() {
_spec.ClearField(group.FieldModelPricing, field.TypeJSON)
}
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
@@ -2533,6 +2762,18 @@ func (_u *GroupUpdateOne) ClearVideoPrice1080p() *GroupUpdateOne {
return _u
}
// SetVideoModelPrices sets the "video_model_prices" field.
func (_u *GroupUpdateOne) SetVideoModelPrices(v map[string]map[string]float64) *GroupUpdateOne {
_u.mutation.SetVideoModelPrices(v)
return _u
}
// ClearVideoModelPrices clears the value of the "video_model_prices" field.
func (_u *GroupUpdateOne) ClearVideoModelPrices() *GroupUpdateOne {
_u.mutation.ClearVideoModelPrices()
return _u
}
// SetWebSearchPricePerCall sets the "web_search_price_per_call" field.
func (_u *GroupUpdateOne) SetWebSearchPricePerCall(v float64) *GroupUpdateOne {
_u.mutation.ResetWebSearchPricePerCall()
@@ -2560,6 +2801,146 @@ func (_u *GroupUpdateOne) ClearWebSearchPricePerCall() *GroupUpdateOne {
return _u
}
// SetSearchPricePer1k sets the "search_price_per_1k" field.
func (_u *GroupUpdateOne) SetSearchPricePer1k(v float64) *GroupUpdateOne {
_u.mutation.ResetSearchPricePer1k()
_u.mutation.SetSearchPricePer1k(v)
return _u
}
// SetNillableSearchPricePer1k sets the "search_price_per_1k" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableSearchPricePer1k(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetSearchPricePer1k(*v)
}
return _u
}
// AddSearchPricePer1k adds value to the "search_price_per_1k" field.
func (_u *GroupUpdateOne) AddSearchPricePer1k(v float64) *GroupUpdateOne {
_u.mutation.AddSearchPricePer1k(v)
return _u
}
// ClearSearchPricePer1k clears the value of the "search_price_per_1k" field.
func (_u *GroupUpdateOne) ClearSearchPricePer1k() *GroupUpdateOne {
_u.mutation.ClearSearchPricePer1k()
return _u
}
// SetAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field.
func (_u *GroupUpdateOne) SetAudioRealtimePricePerMin(v float64) *GroupUpdateOne {
_u.mutation.ResetAudioRealtimePricePerMin()
_u.mutation.SetAudioRealtimePricePerMin(v)
return _u
}
// SetNillableAudioRealtimePricePerMin sets the "audio_realtime_price_per_min" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableAudioRealtimePricePerMin(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetAudioRealtimePricePerMin(*v)
}
return _u
}
// AddAudioRealtimePricePerMin adds value to the "audio_realtime_price_per_min" field.
func (_u *GroupUpdateOne) AddAudioRealtimePricePerMin(v float64) *GroupUpdateOne {
_u.mutation.AddAudioRealtimePricePerMin(v)
return _u
}
// ClearAudioRealtimePricePerMin clears the value of the "audio_realtime_price_per_min" field.
func (_u *GroupUpdateOne) ClearAudioRealtimePricePerMin() *GroupUpdateOne {
_u.mutation.ClearAudioRealtimePricePerMin()
return _u
}
// SetAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdateOne) SetAudioTtsPricePerMillionChars(v float64) *GroupUpdateOne {
_u.mutation.ResetAudioTtsPricePerMillionChars()
_u.mutation.SetAudioTtsPricePerMillionChars(v)
return _u
}
// SetNillableAudioTtsPricePerMillionChars sets the "audio_tts_price_per_million_chars" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableAudioTtsPricePerMillionChars(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetAudioTtsPricePerMillionChars(*v)
}
return _u
}
// AddAudioTtsPricePerMillionChars adds value to the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdateOne) AddAudioTtsPricePerMillionChars(v float64) *GroupUpdateOne {
_u.mutation.AddAudioTtsPricePerMillionChars(v)
return _u
}
// ClearAudioTtsPricePerMillionChars clears the value of the "audio_tts_price_per_million_chars" field.
func (_u *GroupUpdateOne) ClearAudioTtsPricePerMillionChars() *GroupUpdateOne {
_u.mutation.ClearAudioTtsPricePerMillionChars()
return _u
}
// SetAudioSttPricePerHour sets the "audio_stt_price_per_hour" field.
func (_u *GroupUpdateOne) SetAudioSttPricePerHour(v float64) *GroupUpdateOne {
_u.mutation.ResetAudioSttPricePerHour()
_u.mutation.SetAudioSttPricePerHour(v)
return _u
}
// SetNillableAudioSttPricePerHour sets the "audio_stt_price_per_hour" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableAudioSttPricePerHour(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetAudioSttPricePerHour(*v)
}
return _u
}
// AddAudioSttPricePerHour adds value to the "audio_stt_price_per_hour" field.
func (_u *GroupUpdateOne) AddAudioSttPricePerHour(v float64) *GroupUpdateOne {
_u.mutation.AddAudioSttPricePerHour(v)
return _u
}
// ClearAudioSttPricePerHour clears the value of the "audio_stt_price_per_hour" field.
func (_u *GroupUpdateOne) ClearAudioSttPricePerHour() *GroupUpdateOne {
_u.mutation.ClearAudioSttPricePerHour()
return _u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_u *GroupUpdateOne) SetLongContextPricingEnabled(v bool) *GroupUpdateOne {
_u.mutation.SetLongContextPricingEnabled(v)
return _u
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetLongContextPricingEnabled(*v)
}
return _u
}
// SetModelPricing sets the "model_pricing" field.
func (_u *GroupUpdateOne) SetModelPricing(v jsontext.Value) *GroupUpdateOne {
_u.mutation.SetModelPricing(v)
return _u
}
// AppendModelPricing appends value to the "model_pricing" field.
func (_u *GroupUpdateOne) AppendModelPricing(v jsontext.Value) *GroupUpdateOne {
_u.mutation.AppendModelPricing(v)
return _u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (_u *GroupUpdateOne) ClearModelPricing() *GroupUpdateOne {
_u.mutation.ClearModelPricing()
return _u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne {
_u.mutation.SetClaudeCodeOnly(v)
@@ -3210,6 +3591,26 @@ func (_u *GroupUpdateOne) check() error {
return &ValidationError{Name: "subscription_type", err: fmt.Errorf(`ent: validator failed for field "Group.subscription_type": %w`, err)}
}
}
if v, ok := _u.mutation.SearchPricePer1k(); ok {
if err := group.SearchPricePer1kValidator(v); err != nil {
return &ValidationError{Name: "search_price_per_1k", err: fmt.Errorf(`ent: validator failed for field "Group.search_price_per_1k": %w`, err)}
}
}
if v, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
if err := group.AudioRealtimePricePerMinValidator(v); err != nil {
return &ValidationError{Name: "audio_realtime_price_per_min", err: fmt.Errorf(`ent: validator failed for field "Group.audio_realtime_price_per_min": %w`, err)}
}
}
if v, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
if err := group.AudioTtsPricePerMillionCharsValidator(v); err != nil {
return &ValidationError{Name: "audio_tts_price_per_million_chars", err: fmt.Errorf(`ent: validator failed for field "Group.audio_tts_price_per_million_chars": %w`, err)}
}
}
if v, ok := _u.mutation.AudioSttPricePerHour(); ok {
if err := group.AudioSttPricePerHourValidator(v); err != nil {
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
}
}
if v, ok := _u.mutation.DefaultMappedModel(); ok {
if err := group.DefaultMappedModelValidator(v); err != nil {
return &ValidationError{Name: "default_mapped_model", err: fmt.Errorf(`ent: validator failed for field "Group.default_mapped_model": %w`, err)}
@@ -3429,6 +3830,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if _u.mutation.VideoPrice1080pCleared() {
_spec.ClearField(group.FieldVideoPrice1080p, field.TypeFloat64)
}
if value, ok := _u.mutation.VideoModelPrices(); ok {
_spec.SetField(group.FieldVideoModelPrices, field.TypeJSON, value)
}
if _u.mutation.VideoModelPricesCleared() {
_spec.ClearField(group.FieldVideoModelPrices, field.TypeJSON)
}
if value, ok := _u.mutation.WebSearchPricePerCall(); ok {
_spec.SetField(group.FieldWebSearchPricePerCall, field.TypeFloat64, value)
}
@@ -3438,6 +3845,56 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if _u.mutation.WebSearchPricePerCallCleared() {
_spec.ClearField(group.FieldWebSearchPricePerCall, field.TypeFloat64)
}
if value, ok := _u.mutation.SearchPricePer1k(); ok {
_spec.SetField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedSearchPricePer1k(); ok {
_spec.AddField(group.FieldSearchPricePer1k, field.TypeFloat64, value)
}
if _u.mutation.SearchPricePer1kCleared() {
_spec.ClearField(group.FieldSearchPricePer1k, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioRealtimePricePerMin(); ok {
_spec.SetField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioRealtimePricePerMin(); ok {
_spec.AddField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64, value)
}
if _u.mutation.AudioRealtimePricePerMinCleared() {
_spec.ClearField(group.FieldAudioRealtimePricePerMin, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioTtsPricePerMillionChars(); ok {
_spec.SetField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioTtsPricePerMillionChars(); ok {
_spec.AddField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64, value)
}
if _u.mutation.AudioTtsPricePerMillionCharsCleared() {
_spec.ClearField(group.FieldAudioTtsPricePerMillionChars, field.TypeFloat64)
}
if value, ok := _u.mutation.AudioSttPricePerHour(); ok {
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedAudioSttPricePerHour(); ok {
_spec.AddField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
}
if _u.mutation.AudioSttPricePerHourCleared() {
_spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64)
}
if value, ok := _u.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedModelPricing(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, group.FieldModelPricing, value)
})
}
if _u.mutation.ModelPricingCleared() {
_spec.ClearField(group.FieldModelPricing, field.TypeJSON)
}
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
+43 -25
View File
@@ -623,7 +623,9 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "endpoint", Type: field.TypeString, Size: 500},
{Name: "api_key_encrypted", Type: field.TypeString},
@@ -648,7 +650,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "channel_monitors_channel_monitor_request_templates_request_template",
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
Columns: []*schema.Column{ChannelMonitorsColumns[21]},
RefColumns: []*schema.Column{ChannelMonitorRequestTemplatesColumns[0]},
OnDelete: schema.SetNull,
},
@@ -657,7 +659,7 @@ var (
{
Name: "channelmonitor_enabled_last_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[14]},
Columns: []*schema.Column{ChannelMonitorsColumns[13], ChannelMonitorsColumns[16]},
},
{
Name: "channelmonitor_provider",
@@ -667,17 +669,22 @@ var (
{
Name: "channelmonitor_provider_api_mode",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[5]},
Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[7]},
},
{
Name: "channelmonitor_group_name",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[10]},
Columns: []*schema.Column{ChannelMonitorsColumns[12]},
},
{
Name: "channelmonitor_template_id",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
Columns: []*schema.Column{ChannelMonitorsColumns[21]},
},
{
Name: "channelmonitor_account_id",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[6]},
},
},
}
@@ -733,6 +740,7 @@ var (
{Name: "latency_ms", Type: field.TypeInt, Nullable: true},
{Name: "ping_latency_ms", Type: field.TypeInt, Nullable: true},
{Name: "message", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "quota", Type: field.TypeJSON, Nullable: true},
{Name: "checked_at", Type: field.TypeTime},
{Name: "monitor_id", Type: field.TypeInt64},
}
@@ -744,7 +752,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "channel_monitor_histories_channel_monitors_history",
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8]},
RefColumns: []*schema.Column{ChannelMonitorsColumns[0]},
OnDelete: schema.Cascade,
},
@@ -753,12 +761,12 @@ var (
{
Name: "channelmonitorhistory_monitor_id_model_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[6]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[7]},
},
{
Name: "channelmonitorhistory_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[6]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]},
},
},
}
@@ -768,7 +776,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "extra_headers", Type: field.TypeJSON},
@@ -928,7 +936,14 @@ var (
{Name: "video_price_480p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "video_price_720p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "video_price_1080p", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "video_model_prices", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "web_search_price_per_call", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "search_price_per_1k", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "audio_realtime_price_per_min", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "audio_tts_price_per_million_chars", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "audio_stt_price_per_hour", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "long_context_pricing_enabled", Type: field.TypeBool, Default: true},
{Name: "model_pricing", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "claude_code_only", Type: field.TypeBool, Default: false},
{Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true},
{Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true},
@@ -985,7 +1000,7 @@ var (
{
Name: "group_sort_order",
Unique: false,
Columns: []*schema.Column{GroupsColumns[42]},
Columns: []*schema.Column{GroupsColumns[49]},
},
{
Name: "idx_groups_duplicate_operation_id_active",
@@ -1621,6 +1636,8 @@ var (
{Name: "model", Type: field.TypeString, Size: 100},
{Name: "requested_model", Type: field.TypeString, Nullable: true, Size: 100},
{Name: "upstream_model", Type: field.TypeString, Nullable: true, Size: 100},
{Name: "upstream_response_model", Type: field.TypeString, Nullable: true, Size: 200},
{Name: "upstream_model_mismatch", Type: field.TypeBool, Nullable: true},
{Name: "channel_id", Type: field.TypeInt64, Nullable: true},
{Name: "model_mapping_chain", Type: field.TypeString, Nullable: true, Size: 500},
{Name: "billing_tier", Type: field.TypeString, Nullable: true, Size: 50},
@@ -1671,31 +1688,31 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "usage_logs_api_keys_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[41]},
Columns: []*schema.Column{UsageLogsColumns[43]},
RefColumns: []*schema.Column{APIKeysColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_accounts_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[42]},
Columns: []*schema.Column{UsageLogsColumns[44]},
RefColumns: []*schema.Column{AccountsColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_groups_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[43]},
Columns: []*schema.Column{UsageLogsColumns[45]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.SetNull,
},
{
Symbol: "usage_logs_users_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[44]},
Columns: []*schema.Column{UsageLogsColumns[46]},
RefColumns: []*schema.Column{UsersColumns[0]},
OnDelete: schema.NoAction,
},
{
Symbol: "usage_logs_user_subscriptions_usage_logs",
Columns: []*schema.Column{UsageLogsColumns[45]},
Columns: []*schema.Column{UsageLogsColumns[47]},
RefColumns: []*schema.Column{UserSubscriptionsColumns[0]},
OnDelete: schema.SetNull,
},
@@ -1704,32 +1721,32 @@ var (
{
Name: "usagelog_user_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[44]},
Columns: []*schema.Column{UsageLogsColumns[46]},
},
{
Name: "usagelog_api_key_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[41]},
Columns: []*schema.Column{UsageLogsColumns[43]},
},
{
Name: "usagelog_account_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[42]},
Columns: []*schema.Column{UsageLogsColumns[44]},
},
{
Name: "usagelog_group_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[43]},
Columns: []*schema.Column{UsageLogsColumns[45]},
},
{
Name: "usagelog_subscription_id",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[45]},
Columns: []*schema.Column{UsageLogsColumns[47]},
},
{
Name: "usagelog_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[42]},
},
{
Name: "usagelog_model",
@@ -1749,17 +1766,17 @@ var (
{
Name: "usagelog_user_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[44], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[46], UsageLogsColumns[42]},
},
{
Name: "usagelog_api_key_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[41], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[42]},
},
{
Name: "usagelog_group_id_created_at",
Unique: false,
Columns: []*schema.Column{UsageLogsColumns[43], UsageLogsColumns[40]},
Columns: []*schema.Column{UsageLogsColumns[45], UsageLogsColumns[42]},
},
},
}
@@ -1784,6 +1801,7 @@ var (
{Name: "signup_source", Type: field.TypeString, Default: "email"},
{Name: "last_login_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "last_active_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "restrict_public_groups", Type: field.TypeBool, Default: false},
{Name: "balance_notify_enabled", Type: field.TypeBool, Default: true},
{Name: "balance_notify_threshold_type", Type: field.TypeString, Default: "fixed"},
{Name: "balance_notify_threshold", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
+1096 -17
View File
File diff suppressed because it is too large Load Diff
+100 -80
View File
@@ -635,36 +635,28 @@ func init() {
return nil
}
}()
// channelmonitorDescCheckMode is the schema descriptor for check_mode field.
channelmonitorDescCheckMode := channelmonitorFields[2].Descriptor()
// channelmonitor.DefaultCheckMode holds the default value on creation for the check_mode field.
channelmonitor.DefaultCheckMode = channelmonitorDescCheckMode.Default.(string)
// channelmonitor.CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save.
channelmonitor.CheckModeValidator = channelmonitorDescCheckMode.Validators[0].(func(string) error)
// channelmonitorDescAPIMode is the schema descriptor for api_mode field.
channelmonitorDescAPIMode := channelmonitorFields[2].Descriptor()
channelmonitorDescAPIMode := channelmonitorFields[4].Descriptor()
// channelmonitor.DefaultAPIMode holds the default value on creation for the api_mode field.
channelmonitor.DefaultAPIMode = channelmonitorDescAPIMode.Default.(string)
// channelmonitor.APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save.
channelmonitor.APIModeValidator = channelmonitorDescAPIMode.Validators[0].(func(string) error)
// channelmonitorDescEndpoint is the schema descriptor for endpoint field.
channelmonitorDescEndpoint := channelmonitorFields[3].Descriptor()
channelmonitorDescEndpoint := channelmonitorFields[5].Descriptor()
// channelmonitor.EndpointValidator is a validator for the "endpoint" field. It is called by the builders before save.
channelmonitor.EndpointValidator = func() func(string) error {
validators := channelmonitorDescEndpoint.Validators
fns := [...]func(string) error{
validators[0].(func(string) error),
validators[1].(func(string) error),
}
return func(endpoint string) error {
for _, fn := range fns {
if err := fn(endpoint); err != nil {
return err
}
}
return nil
}
}()
channelmonitor.EndpointValidator = channelmonitorDescEndpoint.Validators[0].(func(string) error)
// channelmonitorDescAPIKeyEncrypted is the schema descriptor for api_key_encrypted field.
channelmonitorDescAPIKeyEncrypted := channelmonitorFields[4].Descriptor()
channelmonitorDescAPIKeyEncrypted := channelmonitorFields[6].Descriptor()
// channelmonitor.APIKeyEncryptedValidator is a validator for the "api_key_encrypted" field. It is called by the builders before save.
channelmonitor.APIKeyEncryptedValidator = channelmonitorDescAPIKeyEncrypted.Validators[0].(func(string) error)
// channelmonitorDescPrimaryModel is the schema descriptor for primary_model field.
channelmonitorDescPrimaryModel := channelmonitorFields[5].Descriptor()
channelmonitorDescPrimaryModel := channelmonitorFields[7].Descriptor()
// channelmonitor.PrimaryModelValidator is a validator for the "primary_model" field. It is called by the builders before save.
channelmonitor.PrimaryModelValidator = func() func(string) error {
validators := channelmonitorDescPrimaryModel.Validators
@@ -682,35 +674,35 @@ func init() {
}
}()
// channelmonitorDescExtraModels is the schema descriptor for extra_models field.
channelmonitorDescExtraModels := channelmonitorFields[6].Descriptor()
channelmonitorDescExtraModels := channelmonitorFields[8].Descriptor()
// channelmonitor.DefaultExtraModels holds the default value on creation for the extra_models field.
channelmonitor.DefaultExtraModels = channelmonitorDescExtraModels.Default.([]string)
// channelmonitorDescGroupName is the schema descriptor for group_name field.
channelmonitorDescGroupName := channelmonitorFields[7].Descriptor()
channelmonitorDescGroupName := channelmonitorFields[9].Descriptor()
// channelmonitor.DefaultGroupName holds the default value on creation for the group_name field.
channelmonitor.DefaultGroupName = channelmonitorDescGroupName.Default.(string)
// channelmonitor.GroupNameValidator is a validator for the "group_name" field. It is called by the builders before save.
channelmonitor.GroupNameValidator = channelmonitorDescGroupName.Validators[0].(func(string) error)
// channelmonitorDescEnabled is the schema descriptor for enabled field.
channelmonitorDescEnabled := channelmonitorFields[8].Descriptor()
channelmonitorDescEnabled := channelmonitorFields[10].Descriptor()
// channelmonitor.DefaultEnabled holds the default value on creation for the enabled field.
channelmonitor.DefaultEnabled = channelmonitorDescEnabled.Default.(bool)
// channelmonitorDescIntervalSeconds is the schema descriptor for interval_seconds field.
channelmonitorDescIntervalSeconds := channelmonitorFields[9].Descriptor()
channelmonitorDescIntervalSeconds := channelmonitorFields[11].Descriptor()
// channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save.
channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error)
// channelmonitorDescJitterSeconds is the schema descriptor for jitter_seconds field.
channelmonitorDescJitterSeconds := channelmonitorFields[10].Descriptor()
channelmonitorDescJitterSeconds := channelmonitorFields[12].Descriptor()
// channelmonitor.DefaultJitterSeconds holds the default value on creation for the jitter_seconds field.
channelmonitor.DefaultJitterSeconds = channelmonitorDescJitterSeconds.Default.(int)
// channelmonitor.JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save.
channelmonitor.JitterSecondsValidator = channelmonitorDescJitterSeconds.Validators[0].(func(int) error)
// channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field.
channelmonitorDescExtraHeaders := channelmonitorFields[14].Descriptor()
channelmonitorDescExtraHeaders := channelmonitorFields[16].Descriptor()
// channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field.
channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string)
// channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field.
channelmonitorDescBodyOverrideMode := channelmonitorFields[15].Descriptor()
channelmonitorDescBodyOverrideMode := channelmonitorFields[17].Descriptor()
// channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field.
channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string)
// channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save.
@@ -808,7 +800,7 @@ func init() {
// channelmonitorhistory.MessageValidator is a validator for the "message" field. It is called by the builders before save.
channelmonitorhistory.MessageValidator = channelmonitorhistoryDescMessage.Validators[0].(func(string) error)
// channelmonitorhistoryDescCheckedAt is the schema descriptor for checked_at field.
channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[6].Descriptor()
channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[7].Descriptor()
// channelmonitorhistory.DefaultCheckedAt holds the default value on creation for the checked_at field.
channelmonitorhistory.DefaultCheckedAt = channelmonitorhistoryDescCheckedAt.Default.(func() time.Time)
channelmonitorrequesttemplateMixin := schema.ChannelMonitorRequestTemplate{}.Mixin()
@@ -1117,80 +1109,100 @@ func init() {
groupDescVideoRateMultiplier := groupFields[26].Descriptor()
// group.DefaultVideoRateMultiplier holds the default value on creation for the video_rate_multiplier field.
group.DefaultVideoRateMultiplier = groupDescVideoRateMultiplier.Default.(float64)
// groupDescSearchPricePer1k is the schema descriptor for search_price_per_1k field.
groupDescSearchPricePer1k := groupFields[32].Descriptor()
// group.SearchPricePer1kValidator is a validator for the "search_price_per_1k" field. It is called by the builders before save.
group.SearchPricePer1kValidator = groupDescSearchPricePer1k.Validators[0].(func(float64) error)
// groupDescAudioRealtimePricePerMin is the schema descriptor for audio_realtime_price_per_min field.
groupDescAudioRealtimePricePerMin := groupFields[33].Descriptor()
// group.AudioRealtimePricePerMinValidator is a validator for the "audio_realtime_price_per_min" field. It is called by the builders before save.
group.AudioRealtimePricePerMinValidator = groupDescAudioRealtimePricePerMin.Validators[0].(func(float64) error)
// groupDescAudioTtsPricePerMillionChars is the schema descriptor for audio_tts_price_per_million_chars field.
groupDescAudioTtsPricePerMillionChars := groupFields[34].Descriptor()
// group.AudioTtsPricePerMillionCharsValidator is a validator for the "audio_tts_price_per_million_chars" field. It is called by the builders before save.
group.AudioTtsPricePerMillionCharsValidator = groupDescAudioTtsPricePerMillionChars.Validators[0].(func(float64) error)
// groupDescAudioSttPricePerHour is the schema descriptor for audio_stt_price_per_hour field.
groupDescAudioSttPricePerHour := groupFields[35].Descriptor()
// group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error)
// groupDescLongContextPricingEnabled is the schema descriptor for long_context_pricing_enabled field.
groupDescLongContextPricingEnabled := groupFields[36].Descriptor()
// group.DefaultLongContextPricingEnabled holds the default value on creation for the long_context_pricing_enabled field.
group.DefaultLongContextPricingEnabled = groupDescLongContextPricingEnabled.Default.(bool)
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
groupDescClaudeCodeOnly := groupFields[31].Descriptor()
groupDescClaudeCodeOnly := groupFields[38].Descriptor()
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
groupDescModelRoutingEnabled := groupFields[35].Descriptor()
groupDescModelRoutingEnabled := groupFields[42].Descriptor()
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
groupDescMcpXMLInject := groupFields[36].Descriptor()
groupDescMcpXMLInject := groupFields[43].Descriptor()
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
groupDescSupportedModelScopes := groupFields[37].Descriptor()
groupDescSupportedModelScopes := groupFields[44].Descriptor()
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
// groupDescSortOrder is the schema descriptor for sort_order field.
groupDescSortOrder := groupFields[38].Descriptor()
groupDescSortOrder := groupFields[45].Descriptor()
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
groupDescAllowMessagesDispatch := groupFields[39].Descriptor()
groupDescAllowMessagesDispatch := groupFields[46].Descriptor()
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
// groupDescAllowLive is the schema descriptor for allow_live field.
groupDescAllowLive := groupFields[40].Descriptor()
groupDescAllowLive := groupFields[47].Descriptor()
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[41].Descriptor()
groupDescRequireOauthOnly := groupFields[48].Descriptor()
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
groupDescRequirePrivacySet := groupFields[42].Descriptor()
groupDescRequirePrivacySet := groupFields[49].Descriptor()
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
groupDescDefaultMappedModel := groupFields[43].Descriptor()
groupDescDefaultMappedModel := groupFields[50].Descriptor()
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
groupDescMessagesDispatchModelConfig := groupFields[44].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[51].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
groupDescModelsListConfig := groupFields[45].Descriptor()
groupDescModelsListConfig := groupFields[52].Descriptor()
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
groupDescRpmLimit := groupFields[46].Descriptor()
groupDescRpmLimit := groupFields[53].Descriptor()
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
groupDescMaxReasoningEffort := groupFields[47].Descriptor()
groupDescMaxReasoningEffort := groupFields[54].Descriptor()
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
groupDescReasoningEffortMappings := groupFields[48].Descriptor()
groupDescReasoningEffortMappings := groupFields[55].Descriptor()
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
groupDescProfitControlEnabled := groupFields[49].Descriptor()
groupDescProfitControlEnabled := groupFields[56].Descriptor()
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
groupDescProfitMinMargin := groupFields[50].Descriptor()
groupDescProfitMinMargin := groupFields[57].Descriptor()
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
groupDescProfitSafetyBuffer := groupFields[51].Descriptor()
groupDescProfitSafetyBuffer := groupFields[58].Descriptor()
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
@@ -1982,124 +1994,128 @@ func init() {
usagelogDescUpstreamModel := usagelogFields[6].Descriptor()
// usagelog.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
usagelog.UpstreamModelValidator = usagelogDescUpstreamModel.Validators[0].(func(string) error)
// usagelogDescUpstreamResponseModel is the schema descriptor for upstream_response_model field.
usagelogDescUpstreamResponseModel := usagelogFields[7].Descriptor()
// usagelog.UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
usagelog.UpstreamResponseModelValidator = usagelogDescUpstreamResponseModel.Validators[0].(func(string) error)
// usagelogDescModelMappingChain is the schema descriptor for model_mapping_chain field.
usagelogDescModelMappingChain := usagelogFields[8].Descriptor()
usagelogDescModelMappingChain := usagelogFields[10].Descriptor()
// usagelog.ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
usagelog.ModelMappingChainValidator = usagelogDescModelMappingChain.Validators[0].(func(string) error)
// usagelogDescBillingTier is the schema descriptor for billing_tier field.
usagelogDescBillingTier := usagelogFields[9].Descriptor()
usagelogDescBillingTier := usagelogFields[11].Descriptor()
// usagelog.BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
usagelog.BillingTierValidator = usagelogDescBillingTier.Validators[0].(func(string) error)
// usagelogDescBillingMode is the schema descriptor for billing_mode field.
usagelogDescBillingMode := usagelogFields[10].Descriptor()
usagelogDescBillingMode := usagelogFields[12].Descriptor()
// usagelog.BillingModeValidator is a validator for the "billing_mode" field. It is called by the builders before save.
usagelog.BillingModeValidator = usagelogDescBillingMode.Validators[0].(func(string) error)
// usagelogDescInputTokens is the schema descriptor for input_tokens field.
usagelogDescInputTokens := usagelogFields[13].Descriptor()
usagelogDescInputTokens := usagelogFields[15].Descriptor()
// usagelog.DefaultInputTokens holds the default value on creation for the input_tokens field.
usagelog.DefaultInputTokens = usagelogDescInputTokens.Default.(int)
// usagelogDescOutputTokens is the schema descriptor for output_tokens field.
usagelogDescOutputTokens := usagelogFields[14].Descriptor()
usagelogDescOutputTokens := usagelogFields[16].Descriptor()
// usagelog.DefaultOutputTokens holds the default value on creation for the output_tokens field.
usagelog.DefaultOutputTokens = usagelogDescOutputTokens.Default.(int)
// usagelogDescCacheCreationTokens is the schema descriptor for cache_creation_tokens field.
usagelogDescCacheCreationTokens := usagelogFields[15].Descriptor()
usagelogDescCacheCreationTokens := usagelogFields[17].Descriptor()
// usagelog.DefaultCacheCreationTokens holds the default value on creation for the cache_creation_tokens field.
usagelog.DefaultCacheCreationTokens = usagelogDescCacheCreationTokens.Default.(int)
// usagelogDescCacheReadTokens is the schema descriptor for cache_read_tokens field.
usagelogDescCacheReadTokens := usagelogFields[16].Descriptor()
usagelogDescCacheReadTokens := usagelogFields[18].Descriptor()
// usagelog.DefaultCacheReadTokens holds the default value on creation for the cache_read_tokens field.
usagelog.DefaultCacheReadTokens = usagelogDescCacheReadTokens.Default.(int)
// usagelogDescCacheCreation5mTokens is the schema descriptor for cache_creation_5m_tokens field.
usagelogDescCacheCreation5mTokens := usagelogFields[17].Descriptor()
usagelogDescCacheCreation5mTokens := usagelogFields[19].Descriptor()
// usagelog.DefaultCacheCreation5mTokens holds the default value on creation for the cache_creation_5m_tokens field.
usagelog.DefaultCacheCreation5mTokens = usagelogDescCacheCreation5mTokens.Default.(int)
// usagelogDescCacheCreation1hTokens is the schema descriptor for cache_creation_1h_tokens field.
usagelogDescCacheCreation1hTokens := usagelogFields[18].Descriptor()
usagelogDescCacheCreation1hTokens := usagelogFields[20].Descriptor()
// usagelog.DefaultCacheCreation1hTokens holds the default value on creation for the cache_creation_1h_tokens field.
usagelog.DefaultCacheCreation1hTokens = usagelogDescCacheCreation1hTokens.Default.(int)
// usagelogDescInputCost is the schema descriptor for input_cost field.
usagelogDescInputCost := usagelogFields[19].Descriptor()
usagelogDescInputCost := usagelogFields[21].Descriptor()
// usagelog.DefaultInputCost holds the default value on creation for the input_cost field.
usagelog.DefaultInputCost = usagelogDescInputCost.Default.(float64)
// usagelogDescOutputCost is the schema descriptor for output_cost field.
usagelogDescOutputCost := usagelogFields[20].Descriptor()
usagelogDescOutputCost := usagelogFields[22].Descriptor()
// usagelog.DefaultOutputCost holds the default value on creation for the output_cost field.
usagelog.DefaultOutputCost = usagelogDescOutputCost.Default.(float64)
// usagelogDescCacheCreationCost is the schema descriptor for cache_creation_cost field.
usagelogDescCacheCreationCost := usagelogFields[21].Descriptor()
usagelogDescCacheCreationCost := usagelogFields[23].Descriptor()
// usagelog.DefaultCacheCreationCost holds the default value on creation for the cache_creation_cost field.
usagelog.DefaultCacheCreationCost = usagelogDescCacheCreationCost.Default.(float64)
// usagelogDescCacheReadCost is the schema descriptor for cache_read_cost field.
usagelogDescCacheReadCost := usagelogFields[22].Descriptor()
usagelogDescCacheReadCost := usagelogFields[24].Descriptor()
// usagelog.DefaultCacheReadCost holds the default value on creation for the cache_read_cost field.
usagelog.DefaultCacheReadCost = usagelogDescCacheReadCost.Default.(float64)
// usagelogDescTotalCost is the schema descriptor for total_cost field.
usagelogDescTotalCost := usagelogFields[23].Descriptor()
usagelogDescTotalCost := usagelogFields[25].Descriptor()
// usagelog.DefaultTotalCost holds the default value on creation for the total_cost field.
usagelog.DefaultTotalCost = usagelogDescTotalCost.Default.(float64)
// usagelogDescActualCost is the schema descriptor for actual_cost field.
usagelogDescActualCost := usagelogFields[24].Descriptor()
usagelogDescActualCost := usagelogFields[26].Descriptor()
// usagelog.DefaultActualCost holds the default value on creation for the actual_cost field.
usagelog.DefaultActualCost = usagelogDescActualCost.Default.(float64)
// usagelogDescRateMultiplier is the schema descriptor for rate_multiplier field.
usagelogDescRateMultiplier := usagelogFields[25].Descriptor()
usagelogDescRateMultiplier := usagelogFields[27].Descriptor()
// usagelog.DefaultRateMultiplier holds the default value on creation for the rate_multiplier field.
usagelog.DefaultRateMultiplier = usagelogDescRateMultiplier.Default.(float64)
// usagelogDescLongContextBillingApplied is the schema descriptor for long_context_billing_applied field.
usagelogDescLongContextBillingApplied := usagelogFields[26].Descriptor()
usagelogDescLongContextBillingApplied := usagelogFields[28].Descriptor()
// usagelog.DefaultLongContextBillingApplied holds the default value on creation for the long_context_billing_applied field.
usagelog.DefaultLongContextBillingApplied = usagelogDescLongContextBillingApplied.Default.(bool)
// usagelogDescBillingType is the schema descriptor for billing_type field.
usagelogDescBillingType := usagelogFields[28].Descriptor()
usagelogDescBillingType := usagelogFields[30].Descriptor()
// usagelog.DefaultBillingType holds the default value on creation for the billing_type field.
usagelog.DefaultBillingType = usagelogDescBillingType.Default.(int8)
// usagelogDescStream is the schema descriptor for stream field.
usagelogDescStream := usagelogFields[29].Descriptor()
usagelogDescStream := usagelogFields[31].Descriptor()
// usagelog.DefaultStream holds the default value on creation for the stream field.
usagelog.DefaultStream = usagelogDescStream.Default.(bool)
// usagelogDescUserAgent is the schema descriptor for user_agent field.
usagelogDescUserAgent := usagelogFields[32].Descriptor()
usagelogDescUserAgent := usagelogFields[34].Descriptor()
// usagelog.UserAgentValidator is a validator for the "user_agent" field. It is called by the builders before save.
usagelog.UserAgentValidator = usagelogDescUserAgent.Validators[0].(func(string) error)
// usagelogDescIPAddress is the schema descriptor for ip_address field.
usagelogDescIPAddress := usagelogFields[33].Descriptor()
usagelogDescIPAddress := usagelogFields[35].Descriptor()
// usagelog.IPAddressValidator is a validator for the "ip_address" field. It is called by the builders before save.
usagelog.IPAddressValidator = usagelogDescIPAddress.Validators[0].(func(string) error)
// usagelogDescImageCount is the schema descriptor for image_count field.
usagelogDescImageCount := usagelogFields[34].Descriptor()
usagelogDescImageCount := usagelogFields[36].Descriptor()
// usagelog.DefaultImageCount holds the default value on creation for the image_count field.
usagelog.DefaultImageCount = usagelogDescImageCount.Default.(int)
// usagelogDescImageSize is the schema descriptor for image_size field.
usagelogDescImageSize := usagelogFields[35].Descriptor()
usagelogDescImageSize := usagelogFields[37].Descriptor()
// usagelog.ImageSizeValidator is a validator for the "image_size" field. It is called by the builders before save.
usagelog.ImageSizeValidator = usagelogDescImageSize.Validators[0].(func(string) error)
// usagelogDescImageInputSize is the schema descriptor for image_input_size field.
usagelogDescImageInputSize := usagelogFields[36].Descriptor()
usagelogDescImageInputSize := usagelogFields[38].Descriptor()
// usagelog.ImageInputSizeValidator is a validator for the "image_input_size" field. It is called by the builders before save.
usagelog.ImageInputSizeValidator = usagelogDescImageInputSize.Validators[0].(func(string) error)
// usagelogDescImageOutputSize is the schema descriptor for image_output_size field.
usagelogDescImageOutputSize := usagelogFields[37].Descriptor()
usagelogDescImageOutputSize := usagelogFields[39].Descriptor()
// usagelog.ImageOutputSizeValidator is a validator for the "image_output_size" field. It is called by the builders before save.
usagelog.ImageOutputSizeValidator = usagelogDescImageOutputSize.Validators[0].(func(string) error)
// usagelogDescImageSizeSource is the schema descriptor for image_size_source field.
usagelogDescImageSizeSource := usagelogFields[38].Descriptor()
usagelogDescImageSizeSource := usagelogFields[40].Descriptor()
// usagelog.ImageSizeSourceValidator is a validator for the "image_size_source" field. It is called by the builders before save.
usagelog.ImageSizeSourceValidator = usagelogDescImageSizeSource.Validators[0].(func(string) error)
// usagelogDescVideoCount is the schema descriptor for video_count field.
usagelogDescVideoCount := usagelogFields[40].Descriptor()
usagelogDescVideoCount := usagelogFields[42].Descriptor()
// usagelog.DefaultVideoCount holds the default value on creation for the video_count field.
usagelog.DefaultVideoCount = usagelogDescVideoCount.Default.(int)
// usagelogDescVideoResolution is the schema descriptor for video_resolution field.
usagelogDescVideoResolution := usagelogFields[41].Descriptor()
usagelogDescVideoResolution := usagelogFields[43].Descriptor()
// usagelog.VideoResolutionValidator is a validator for the "video_resolution" field. It is called by the builders before save.
usagelog.VideoResolutionValidator = usagelogDescVideoResolution.Validators[0].(func(string) error)
// usagelogDescCacheTTLOverridden is the schema descriptor for cache_ttl_overridden field.
usagelogDescCacheTTLOverridden := usagelogFields[43].Descriptor()
usagelogDescCacheTTLOverridden := usagelogFields[45].Descriptor()
// usagelog.DefaultCacheTTLOverridden holds the default value on creation for the cache_ttl_overridden field.
usagelog.DefaultCacheTTLOverridden = usagelogDescCacheTTLOverridden.Default.(bool)
// usagelogDescCreatedAt is the schema descriptor for created_at field.
usagelogDescCreatedAt := usagelogFields[44].Descriptor()
usagelogDescCreatedAt := usagelogFields[46].Descriptor()
// usagelog.DefaultCreatedAt holds the default value on creation for the created_at field.
usagelog.DefaultCreatedAt = usagelogDescCreatedAt.Default.(func() time.Time)
userMixin := schema.User{}.Mixin()
@@ -2201,24 +2217,28 @@ func init() {
user.DefaultSignupSource = userDescSignupSource.Default.(string)
// user.SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
user.SignupSourceValidator = userDescSignupSource.Validators[0].(func(string) error)
// userDescRestrictPublicGroups is the schema descriptor for restrict_public_groups field.
userDescRestrictPublicGroups := userFields[15].Descriptor()
// user.DefaultRestrictPublicGroups holds the default value on creation for the restrict_public_groups field.
user.DefaultRestrictPublicGroups = userDescRestrictPublicGroups.Default.(bool)
// userDescBalanceNotifyEnabled is the schema descriptor for balance_notify_enabled field.
userDescBalanceNotifyEnabled := userFields[15].Descriptor()
userDescBalanceNotifyEnabled := userFields[16].Descriptor()
// user.DefaultBalanceNotifyEnabled holds the default value on creation for the balance_notify_enabled field.
user.DefaultBalanceNotifyEnabled = userDescBalanceNotifyEnabled.Default.(bool)
// userDescBalanceNotifyThresholdType is the schema descriptor for balance_notify_threshold_type field.
userDescBalanceNotifyThresholdType := userFields[16].Descriptor()
userDescBalanceNotifyThresholdType := userFields[17].Descriptor()
// user.DefaultBalanceNotifyThresholdType holds the default value on creation for the balance_notify_threshold_type field.
user.DefaultBalanceNotifyThresholdType = userDescBalanceNotifyThresholdType.Default.(string)
// userDescBalanceNotifyExtraEmails is the schema descriptor for balance_notify_extra_emails field.
userDescBalanceNotifyExtraEmails := userFields[18].Descriptor()
userDescBalanceNotifyExtraEmails := userFields[19].Descriptor()
// user.DefaultBalanceNotifyExtraEmails holds the default value on creation for the balance_notify_extra_emails field.
user.DefaultBalanceNotifyExtraEmails = userDescBalanceNotifyExtraEmails.Default.(string)
// userDescTotalRecharged is the schema descriptor for total_recharged field.
userDescTotalRecharged := userFields[19].Descriptor()
userDescTotalRecharged := userFields[20].Descriptor()
// user.DefaultTotalRecharged holds the default value on creation for the total_recharged field.
user.DefaultTotalRecharged = userDescTotalRecharged.Default.(float64)
// userDescRpmLimit is the schema descriptor for rpm_limit field.
userDescRpmLimit := userFields[20].Descriptor()
userDescRpmLimit := userFields[21].Descriptor()
// user.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
user.DefaultRpmLimit = userDescRpmLimit.Default.(int)
userallowedgroupFields := schema.UserAllowedGroup{}.Fields()
+21 -3
View File
@@ -35,15 +35,32 @@ func (ChannelMonitor) Fields() []ent.Field {
NotEmpty().
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok"),
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
// check_mode: 'probe' | 'quota' | 'quota_probe'
// probe - LLM 探活(默认,原有行为)
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
// quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行)
// antigravity 无探活 adapter,仅允许 quota。
field.String("check_mode").
Default("probe").
MaxLen(32).
Comment("probe = LLM probe (default); quota = account usage only; quota_probe = both"),
// account_id: 配额模式的数据源账号(复用账号侧用量服务,不直接对接上游)。
// 普通字段而非 edge(FK 由 SQL 迁移管理);账号删除时数据库置空,
// 监控保留并报「账号未关联」。
field.Int64("account_id").
Optional().
Nillable(),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
Comment("OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions"),
// endpoint: 探活模式必填(service 层校验);quota 模式存空串
// (列保持 NOT NULL,去掉 NotEmpty 校验器即可)。
field.String("endpoint").
NotEmpty().
MaxLen(500).
Comment("Provider base origin, e.g. https://api.openai.com"),
Comment("Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors"),
field.String("api_key_encrypted").
NotEmpty().
Sensitive().
@@ -115,5 +132,6 @@ func (ChannelMonitor) Indexes() []ent.Index {
index.Fields("provider", "api_mode"),
index.Fields("group_name"),
index.Fields("template_id"),
index.Fields("account_id"),
}
}
@@ -3,6 +3,8 @@ package schema
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
@@ -43,6 +45,10 @@ func (ChannelMonitorHistory) Fields() []ent.Field {
Optional().
Default("").
MaxLen(500),
// quota: 配额模式(check_mode = quota / quota_probe)检测时附带的
// 归一化配额快照(domain.MonitorQuotaSnapshot,JSONB);探活模式为 NULL。
field.JSON("quota", &domain.MonitorQuotaSnapshot{}).
Optional(),
field.Time("checked_at").
Default(time.Now),
}
@@ -39,7 +39,8 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
NotEmpty().
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok"),
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+41
View File
@@ -1,6 +1,8 @@
package schema
import (
"encoding/json"
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/internal/domain"
@@ -148,12 +150,51 @@ func (Group) Fields() []ent.Field {
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}),
field.JSON("video_model_prices", map[string]map[string]float64{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("按模型族和分辨率覆盖视频每秒价格"),
field.Float("web_search_price_per_call").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("Codex alpha/search 网页搜索单次价格(USD/次);nil 表示使用默认价 0.01(官方 $10/1000 次)"),
// 搜索/工具调用显式定价(per 1k calls),用于 Grok web_search 等。
field.Float("search_price_per_1k").
Optional().
Nillable().
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("搜索工具价格 per 1000 calls(web_search 等)"),
// Grok Voice 显式定价(realtime / TTS / STT),不按文本 RateMultiplier。
field.Float("audio_realtime_price_per_min").
Optional().
Nillable().
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("Voice realtime 每分钟价格(USD)"),
field.Float("audio_tts_price_per_million_chars").
Optional().
Nillable().
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("TTS 每百万字符价格(USD)"),
field.Float("audio_stt_price_per_hour").
Optional().
Nillable().
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("STT 每小时价格(USD)"),
field.Bool("long_context_pricing_enabled").
Default(true).
Comment("是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价"),
field.JSON("model_pricing", json.RawMessage{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("分组逐模型定价;优先级高于渠道和内置定价"),
// Claude Code 客户端限制 (added by migration 029)
field.Bool("claude_code_only").
Default(false).
+11
View File
@@ -53,6 +53,17 @@ func (UsageLog) Fields() []ent.Field {
MaxLen(100).
Optional().
Nillable(),
// UpstreamResponseModel stores the model name declared by the upstream
// response before any protocol conversion or client-facing rewrite.
field.String("upstream_response_model").
MaxLen(200).
Optional().
Nillable(),
// UpstreamModelMismatch is tri-state: NULL means the upstream response did
// not declare a model (or predates this field); false/true means observed.
field.Bool("upstream_model_mismatch").
Optional().
Nillable(),
field.Int64("channel_id").Optional().Nillable().Comment("渠道 ID"),
field.String("model_mapping_chain").MaxLen(500).Optional().Nillable().Comment("模型映射链"),
field.String("billing_tier").MaxLen(50).Optional().Nillable().Comment("计费层级标签"),
+5
View File
@@ -96,6 +96,11 @@ func (User) Fields() []ent.Field {
Nillable().
SchemaType(map[string]string{dialect.Postgres: "timestamptz"}),
// 公开分组访问限制:为 false 时用户可绑定任意非专属分组(默认行为),
// 为 true 时仅可绑定 user_allowed_groups 中列出的公开分组。
field.Bool("restrict_public_groups").
Default(false),
// 余额不足通知
field.Bool("balance_notify_enabled").
Default(true),
+2 -1
View File
@@ -41,7 +41,8 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity", "grok":
case "anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+2 -1
View File
@@ -4,6 +4,7 @@ package ent
import (
"encoding/json"
"encoding/json/jsontext"
"fmt"
"strings"
"time"
@@ -25,7 +26,7 @@ type UsageCleanupTask struct {
// Status holds the value of the "status" field.
Status string `json:"status,omitempty"`
// Filters holds the value of the "filters" field.
Filters json.RawMessage `json:"filters,omitempty"`
Filters jsontext.Value `json:"filters,omitempty"`
// CreatedBy holds the value of the "created_by" field.
CreatedBy int64 `json:"created_by,omitempty"`
// DeletedRows holds the value of the "deleted_rows" field.
+5 -5
View File
@@ -4,7 +4,7 @@ package ent
import (
"context"
"encoding/json"
"encoding/json/jsontext"
"errors"
"fmt"
"time"
@@ -58,7 +58,7 @@ func (_c *UsageCleanupTaskCreate) SetStatus(v string) *UsageCleanupTaskCreate {
}
// SetFilters sets the "filters" field.
func (_c *UsageCleanupTaskCreate) SetFilters(v json.RawMessage) *UsageCleanupTaskCreate {
func (_c *UsageCleanupTaskCreate) SetFilters(v jsontext.Value) *UsageCleanupTaskCreate {
_c.mutation.SetFilters(v)
return _c
}
@@ -375,7 +375,7 @@ func (u *UsageCleanupTaskUpsert) UpdateStatus() *UsageCleanupTaskUpsert {
}
// SetFilters sets the "filters" field.
func (u *UsageCleanupTaskUpsert) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsert {
func (u *UsageCleanupTaskUpsert) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsert {
u.Set(usagecleanuptask.FieldFilters, v)
return u
}
@@ -592,7 +592,7 @@ func (u *UsageCleanupTaskUpsertOne) UpdateStatus() *UsageCleanupTaskUpsertOne {
}
// SetFilters sets the "filters" field.
func (u *UsageCleanupTaskUpsertOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertOne {
func (u *UsageCleanupTaskUpsertOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertOne {
return u.Update(func(s *UsageCleanupTaskUpsert) {
s.SetFilters(v)
})
@@ -999,7 +999,7 @@ func (u *UsageCleanupTaskUpsertBulk) UpdateStatus() *UsageCleanupTaskUpsertBulk
}
// SetFilters sets the "filters" field.
func (u *UsageCleanupTaskUpsertBulk) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertBulk {
func (u *UsageCleanupTaskUpsertBulk) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertBulk {
return u.Update(func(s *UsageCleanupTaskUpsert) {
s.SetFilters(v)
})
+5 -5
View File
@@ -4,7 +4,7 @@ package ent
import (
"context"
"encoding/json"
"encoding/json/jsontext"
"errors"
"fmt"
"time"
@@ -51,13 +51,13 @@ func (_u *UsageCleanupTaskUpdate) SetNillableStatus(v *string) *UsageCleanupTask
}
// SetFilters sets the "filters" field.
func (_u *UsageCleanupTaskUpdate) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdate {
func (_u *UsageCleanupTaskUpdate) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdate {
_u.mutation.SetFilters(v)
return _u
}
// AppendFilters appends value to the "filters" field.
func (_u *UsageCleanupTaskUpdate) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdate {
func (_u *UsageCleanupTaskUpdate) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdate {
_u.mutation.AppendFilters(v)
return _u
}
@@ -374,13 +374,13 @@ func (_u *UsageCleanupTaskUpdateOne) SetNillableStatus(v *string) *UsageCleanupT
}
// SetFilters sets the "filters" field.
func (_u *UsageCleanupTaskUpdateOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne {
func (_u *UsageCleanupTaskUpdateOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne {
_u.mutation.SetFilters(v)
return _u
}
// AppendFilters appends value to the "filters" field.
func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne {
func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne {
_u.mutation.AppendFilters(v)
return _u
}
+30 -2
View File
@@ -37,6 +37,10 @@ type UsageLog struct {
RequestedModel *string `json:"requested_model,omitempty"`
// UpstreamModel holds the value of the "upstream_model" field.
UpstreamModel *string `json:"upstream_model,omitempty"`
// UpstreamResponseModel holds the value of the "upstream_response_model" field.
UpstreamResponseModel *string `json:"upstream_response_model,omitempty"`
// UpstreamModelMismatch holds the value of the "upstream_model_mismatch" field.
UpstreamModelMismatch *bool `json:"upstream_model_mismatch,omitempty"`
// 渠道 ID
ChannelID *int64 `json:"channel_id,omitempty"`
// 模型映射链
@@ -198,13 +202,13 @@ func (*UsageLog) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case usagelog.FieldImageSizeBreakdown:
values[i] = new([]byte)
case usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
case usagelog.FieldUpstreamModelMismatch, usagelog.FieldLongContextBillingApplied, usagelog.FieldStream, usagelog.FieldCacheTTLOverridden:
values[i] = new(sql.NullBool)
case usagelog.FieldInputCost, usagelog.FieldOutputCost, usagelog.FieldCacheCreationCost, usagelog.FieldCacheReadCost, usagelog.FieldTotalCost, usagelog.FieldActualCost, usagelog.FieldRateMultiplier, usagelog.FieldAccountRateMultiplier:
values[i] = new(sql.NullFloat64)
case usagelog.FieldID, usagelog.FieldUserID, usagelog.FieldAPIKeyID, usagelog.FieldAccountID, usagelog.FieldChannelID, usagelog.FieldGroupID, usagelog.FieldSubscriptionID, usagelog.FieldInputTokens, usagelog.FieldOutputTokens, usagelog.FieldCacheCreationTokens, usagelog.FieldCacheReadTokens, usagelog.FieldCacheCreation5mTokens, usagelog.FieldCacheCreation1hTokens, usagelog.FieldBillingType, usagelog.FieldDurationMs, usagelog.FieldFirstTokenMs, usagelog.FieldImageCount, usagelog.FieldVideoCount, usagelog.FieldVideoDurationSeconds:
values[i] = new(sql.NullInt64)
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
case usagelog.FieldRequestID, usagelog.FieldModel, usagelog.FieldRequestedModel, usagelog.FieldUpstreamModel, usagelog.FieldUpstreamResponseModel, usagelog.FieldModelMappingChain, usagelog.FieldBillingTier, usagelog.FieldBillingMode, usagelog.FieldUserAgent, usagelog.FieldIPAddress, usagelog.FieldImageSize, usagelog.FieldImageInputSize, usagelog.FieldImageOutputSize, usagelog.FieldImageSizeSource, usagelog.FieldVideoResolution:
values[i] = new(sql.NullString)
case usagelog.FieldCreatedAt:
values[i] = new(sql.NullTime)
@@ -273,6 +277,20 @@ func (_m *UsageLog) assignValues(columns []string, values []any) error {
_m.UpstreamModel = new(string)
*_m.UpstreamModel = value.String
}
case usagelog.FieldUpstreamResponseModel:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field upstream_response_model", values[i])
} else if value.Valid {
_m.UpstreamResponseModel = new(string)
*_m.UpstreamResponseModel = value.String
}
case usagelog.FieldUpstreamModelMismatch:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field upstream_model_mismatch", values[i])
} else if value.Valid {
_m.UpstreamModelMismatch = new(bool)
*_m.UpstreamModelMismatch = value.Bool
}
case usagelog.FieldChannelID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field channel_id", values[i])
@@ -606,6 +624,16 @@ func (_m *UsageLog) String() string {
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.UpstreamResponseModel; v != nil {
builder.WriteString("upstream_response_model=")
builder.WriteString(*v)
}
builder.WriteString(", ")
if v := _m.UpstreamModelMismatch; v != nil {
builder.WriteString("upstream_model_mismatch=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
if v := _m.ChannelID; v != nil {
builder.WriteString("channel_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
+18
View File
@@ -28,6 +28,10 @@ const (
FieldRequestedModel = "requested_model"
// FieldUpstreamModel holds the string denoting the upstream_model field in the database.
FieldUpstreamModel = "upstream_model"
// FieldUpstreamResponseModel holds the string denoting the upstream_response_model field in the database.
FieldUpstreamResponseModel = "upstream_response_model"
// FieldUpstreamModelMismatch holds the string denoting the upstream_model_mismatch field in the database.
FieldUpstreamModelMismatch = "upstream_model_mismatch"
// FieldChannelID holds the string denoting the channel_id field in the database.
FieldChannelID = "channel_id"
// FieldModelMappingChain holds the string denoting the model_mapping_chain field in the database.
@@ -163,6 +167,8 @@ var Columns = []string{
FieldModel,
FieldRequestedModel,
FieldUpstreamModel,
FieldUpstreamResponseModel,
FieldUpstreamModelMismatch,
FieldChannelID,
FieldModelMappingChain,
FieldBillingTier,
@@ -222,6 +228,8 @@ var (
RequestedModelValidator func(string) error
// UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
UpstreamModelValidator func(string) error
// UpstreamResponseModelValidator is a validator for the "upstream_response_model" field. It is called by the builders before save.
UpstreamResponseModelValidator func(string) error
// ModelMappingChainValidator is a validator for the "model_mapping_chain" field. It is called by the builders before save.
ModelMappingChainValidator func(string) error
// BillingTierValidator is a validator for the "billing_tier" field. It is called by the builders before save.
@@ -327,6 +335,16 @@ func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc()
}
// ByUpstreamResponseModel orders the results by the upstream_response_model field.
func ByUpstreamResponseModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamResponseModel, opts...).ToFunc()
}
// ByUpstreamModelMismatch orders the results by the upstream_model_mismatch field.
func ByUpstreamModelMismatch(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamModelMismatch, opts...).ToFunc()
}
// ByChannelID orders the results by the channel_id field.
func ByChannelID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldChannelID, opts...).ToFunc()
+105
View File
@@ -90,6 +90,16 @@ func UpstreamModel(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModel, v))
}
// UpstreamResponseModel applies equality check predicate on the "upstream_response_model" field. It's identical to UpstreamResponseModelEQ.
func UpstreamResponseModel(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
}
// UpstreamModelMismatch applies equality check predicate on the "upstream_model_mismatch" field. It's identical to UpstreamModelMismatchEQ.
func UpstreamModelMismatch(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
}
// ChannelID applies equality check predicate on the "channel_id" field. It's identical to ChannelIDEQ.
func ChannelID(v int64) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
@@ -615,6 +625,101 @@ func UpstreamModelContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamModel, v))
}
// UpstreamResponseModelEQ applies the EQ predicate on the "upstream_response_model" field.
func UpstreamResponseModelEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelNEQ applies the NEQ predicate on the "upstream_response_model" field.
func UpstreamResponseModelNEQ(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelIn applies the In predicate on the "upstream_response_model" field.
func UpstreamResponseModelIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldIn(FieldUpstreamResponseModel, vs...))
}
// UpstreamResponseModelNotIn applies the NotIn predicate on the "upstream_response_model" field.
func UpstreamResponseModelNotIn(vs ...string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotIn(FieldUpstreamResponseModel, vs...))
}
// UpstreamResponseModelGT applies the GT predicate on the "upstream_response_model" field.
func UpstreamResponseModelGT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGT(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelGTE applies the GTE predicate on the "upstream_response_model" field.
func UpstreamResponseModelGTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldGTE(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelLT applies the LT predicate on the "upstream_response_model" field.
func UpstreamResponseModelLT(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLT(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelLTE applies the LTE predicate on the "upstream_response_model" field.
func UpstreamResponseModelLTE(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldLTE(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelContains applies the Contains predicate on the "upstream_response_model" field.
func UpstreamResponseModelContains(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContains(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelHasPrefix applies the HasPrefix predicate on the "upstream_response_model" field.
func UpstreamResponseModelHasPrefix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasPrefix(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelHasSuffix applies the HasSuffix predicate on the "upstream_response_model" field.
func UpstreamResponseModelHasSuffix(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldHasSuffix(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelIsNil applies the IsNil predicate on the "upstream_response_model" field.
func UpstreamResponseModelIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamResponseModel))
}
// UpstreamResponseModelNotNil applies the NotNil predicate on the "upstream_response_model" field.
func UpstreamResponseModelNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamResponseModel))
}
// UpstreamResponseModelEqualFold applies the EqualFold predicate on the "upstream_response_model" field.
func UpstreamResponseModelEqualFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEqualFold(FieldUpstreamResponseModel, v))
}
// UpstreamResponseModelContainsFold applies the ContainsFold predicate on the "upstream_response_model" field.
func UpstreamResponseModelContainsFold(v string) predicate.UsageLog {
return predicate.UsageLog(sql.FieldContainsFold(FieldUpstreamResponseModel, v))
}
// UpstreamModelMismatchEQ applies the EQ predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchEQ(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldUpstreamModelMismatch, v))
}
// UpstreamModelMismatchNEQ applies the NEQ predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchNEQ(v bool) predicate.UsageLog {
return predicate.UsageLog(sql.FieldNEQ(FieldUpstreamModelMismatch, v))
}
// UpstreamModelMismatchIsNil applies the IsNil predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchIsNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldIsNull(FieldUpstreamModelMismatch))
}
// UpstreamModelMismatchNotNil applies the NotNil predicate on the "upstream_model_mismatch" field.
func UpstreamModelMismatchNotNil() predicate.UsageLog {
return predicate.UsageLog(sql.FieldNotNull(FieldUpstreamModelMismatch))
}
// ChannelIDEQ applies the EQ predicate on the "channel_id" field.
func ChannelIDEQ(v int64) predicate.UsageLog {
return predicate.UsageLog(sql.FieldEQ(FieldChannelID, v))
+161
View File
@@ -85,6 +85,34 @@ func (_c *UsageLogCreate) SetNillableUpstreamModel(v *string) *UsageLogCreate {
return _c
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_c *UsageLogCreate) SetUpstreamResponseModel(v string) *UsageLogCreate {
_c.mutation.SetUpstreamResponseModel(v)
return _c
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableUpstreamResponseModel(v *string) *UsageLogCreate {
if v != nil {
_c.SetUpstreamResponseModel(*v)
}
return _c
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_c *UsageLogCreate) SetUpstreamModelMismatch(v bool) *UsageLogCreate {
_c.mutation.SetUpstreamModelMismatch(v)
return _c
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_c *UsageLogCreate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogCreate {
if v != nil {
_c.SetUpstreamModelMismatch(*v)
}
return _c
}
// SetChannelID sets the "channel_id" field.
func (_c *UsageLogCreate) SetChannelID(v int64) *UsageLogCreate {
_c.mutation.SetChannelID(v)
@@ -788,6 +816,11 @@ func (_c *UsageLogCreate) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _c.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _c.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -950,6 +983,14 @@ func (_c *UsageLogCreate) createSpec() (*UsageLog, *sqlgraph.CreateSpec) {
_spec.SetField(usagelog.FieldUpstreamModel, field.TypeString, value)
_node.UpstreamModel = &value
}
if value, ok := _c.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
_node.UpstreamResponseModel = &value
}
if value, ok := _c.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
_node.UpstreamModelMismatch = &value
}
if value, ok := _c.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
_node.ChannelID = &value
@@ -1327,6 +1368,42 @@ func (u *UsageLogUpsert) ClearUpstreamModel() *UsageLogUpsert {
return u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsert) SetUpstreamResponseModel(v string) *UsageLogUpsert {
u.Set(usagelog.FieldUpstreamResponseModel, v)
return u
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateUpstreamResponseModel() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldUpstreamResponseModel)
return u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsert) ClearUpstreamResponseModel() *UsageLogUpsert {
u.SetNull(usagelog.FieldUpstreamResponseModel)
return u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsert) SetUpstreamModelMismatch(v bool) *UsageLogUpsert {
u.Set(usagelog.FieldUpstreamModelMismatch, v)
return u
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsert) UpdateUpstreamModelMismatch() *UsageLogUpsert {
u.SetExcluded(usagelog.FieldUpstreamModelMismatch)
return u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsert) ClearUpstreamModelMismatch() *UsageLogUpsert {
u.SetNull(usagelog.FieldUpstreamModelMismatch)
return u
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsert) SetChannelID(v int64) *UsageLogUpsert {
u.Set(usagelog.FieldChannelID, v)
@@ -2162,6 +2239,48 @@ func (u *UsageLogUpsertOne) ClearUpstreamModel() *UsageLogUpsertOne {
})
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsertOne) SetUpstreamResponseModel(v string) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamResponseModel(v)
})
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateUpstreamResponseModel() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamResponseModel()
})
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsertOne) ClearUpstreamResponseModel() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamResponseModel()
})
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsertOne) SetUpstreamModelMismatch(v bool) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamModelMismatch(v)
})
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsertOne) UpdateUpstreamModelMismatch() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamModelMismatch()
})
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsertOne) ClearUpstreamModelMismatch() *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamModelMismatch()
})
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsertOne) SetChannelID(v int64) *UsageLogUpsertOne {
return u.Update(func(s *UsageLogUpsert) {
@@ -3276,6 +3395,48 @@ func (u *UsageLogUpsertBulk) ClearUpstreamModel() *UsageLogUpsertBulk {
})
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (u *UsageLogUpsertBulk) SetUpstreamResponseModel(v string) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamResponseModel(v)
})
}
// UpdateUpstreamResponseModel sets the "upstream_response_model" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateUpstreamResponseModel() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamResponseModel()
})
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (u *UsageLogUpsertBulk) ClearUpstreamResponseModel() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamResponseModel()
})
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (u *UsageLogUpsertBulk) SetUpstreamModelMismatch(v bool) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.SetUpstreamModelMismatch(v)
})
}
// UpdateUpstreamModelMismatch sets the "upstream_model_mismatch" field to the value that was provided on create.
func (u *UsageLogUpsertBulk) UpdateUpstreamModelMismatch() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.UpdateUpstreamModelMismatch()
})
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (u *UsageLogUpsertBulk) ClearUpstreamModelMismatch() *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
s.ClearUpstreamModelMismatch()
})
}
// SetChannelID sets the "channel_id" field.
func (u *UsageLogUpsertBulk) SetChannelID(v int64) *UsageLogUpsertBulk {
return u.Update(func(s *UsageLogUpsert) {
+114
View File
@@ -142,6 +142,46 @@ func (_u *UsageLogUpdate) ClearUpstreamModel() *UsageLogUpdate {
return _u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_u *UsageLogUpdate) SetUpstreamResponseModel(v string) *UsageLogUpdate {
_u.mutation.SetUpstreamResponseModel(v)
return _u
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdate {
if v != nil {
_u.SetUpstreamResponseModel(*v)
}
return _u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (_u *UsageLogUpdate) ClearUpstreamResponseModel() *UsageLogUpdate {
_u.mutation.ClearUpstreamResponseModel()
return _u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_u *UsageLogUpdate) SetUpstreamModelMismatch(v bool) *UsageLogUpdate {
_u.mutation.SetUpstreamModelMismatch(v)
return _u
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_u *UsageLogUpdate) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdate {
if v != nil {
_u.SetUpstreamModelMismatch(*v)
}
return _u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (_u *UsageLogUpdate) ClearUpstreamModelMismatch() *UsageLogUpdate {
_u.mutation.ClearUpstreamModelMismatch()
return _u
}
// SetChannelID sets the "channel_id" field.
func (_u *UsageLogUpdate) SetChannelID(v int64) *UsageLogUpdate {
_u.mutation.ResetChannelID()
@@ -1016,6 +1056,11 @@ func (_u *UsageLogUpdate) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _u.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -1108,6 +1153,18 @@ func (_u *UsageLogUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.UpstreamModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
}
if _u.mutation.UpstreamResponseModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
}
if _u.mutation.UpstreamModelMismatchCleared() {
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
}
if value, ok := _u.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
}
@@ -1599,6 +1656,46 @@ func (_u *UsageLogUpdateOne) ClearUpstreamModel() *UsageLogUpdateOne {
return _u
}
// SetUpstreamResponseModel sets the "upstream_response_model" field.
func (_u *UsageLogUpdateOne) SetUpstreamResponseModel(v string) *UsageLogUpdateOne {
_u.mutation.SetUpstreamResponseModel(v)
return _u
}
// SetNillableUpstreamResponseModel sets the "upstream_response_model" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableUpstreamResponseModel(v *string) *UsageLogUpdateOne {
if v != nil {
_u.SetUpstreamResponseModel(*v)
}
return _u
}
// ClearUpstreamResponseModel clears the value of the "upstream_response_model" field.
func (_u *UsageLogUpdateOne) ClearUpstreamResponseModel() *UsageLogUpdateOne {
_u.mutation.ClearUpstreamResponseModel()
return _u
}
// SetUpstreamModelMismatch sets the "upstream_model_mismatch" field.
func (_u *UsageLogUpdateOne) SetUpstreamModelMismatch(v bool) *UsageLogUpdateOne {
_u.mutation.SetUpstreamModelMismatch(v)
return _u
}
// SetNillableUpstreamModelMismatch sets the "upstream_model_mismatch" field if the given value is not nil.
func (_u *UsageLogUpdateOne) SetNillableUpstreamModelMismatch(v *bool) *UsageLogUpdateOne {
if v != nil {
_u.SetUpstreamModelMismatch(*v)
}
return _u
}
// ClearUpstreamModelMismatch clears the value of the "upstream_model_mismatch" field.
func (_u *UsageLogUpdateOne) ClearUpstreamModelMismatch() *UsageLogUpdateOne {
_u.mutation.ClearUpstreamModelMismatch()
return _u
}
// SetChannelID sets the "channel_id" field.
func (_u *UsageLogUpdateOne) SetChannelID(v int64) *UsageLogUpdateOne {
_u.mutation.ResetChannelID()
@@ -2486,6 +2583,11 @@ func (_u *UsageLogUpdateOne) check() error {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_model": %w`, err)}
}
}
if v, ok := _u.mutation.UpstreamResponseModel(); ok {
if err := usagelog.UpstreamResponseModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_response_model", err: fmt.Errorf(`ent: validator failed for field "UsageLog.upstream_response_model": %w`, err)}
}
}
if v, ok := _u.mutation.ModelMappingChain(); ok {
if err := usagelog.ModelMappingChainValidator(v); err != nil {
return &ValidationError{Name: "model_mapping_chain", err: fmt.Errorf(`ent: validator failed for field "UsageLog.model_mapping_chain": %w`, err)}
@@ -2595,6 +2697,18 @@ func (_u *UsageLogUpdateOne) sqlSave(ctx context.Context) (_node *UsageLog, err
if _u.mutation.UpstreamModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamResponseModel(); ok {
_spec.SetField(usagelog.FieldUpstreamResponseModel, field.TypeString, value)
}
if _u.mutation.UpstreamResponseModelCleared() {
_spec.ClearField(usagelog.FieldUpstreamResponseModel, field.TypeString)
}
if value, ok := _u.mutation.UpstreamModelMismatch(); ok {
_spec.SetField(usagelog.FieldUpstreamModelMismatch, field.TypeBool, value)
}
if _u.mutation.UpstreamModelMismatchCleared() {
_spec.ClearField(usagelog.FieldUpstreamModelMismatch, field.TypeBool)
}
if value, ok := _u.mutation.ChannelID(); ok {
_spec.SetField(usagelog.FieldChannelID, field.TypeInt64, value)
}
+12 -1
View File
@@ -53,6 +53,8 @@ type User struct {
LastLoginAt *time.Time `json:"last_login_at,omitempty"`
// LastActiveAt holds the value of the "last_active_at" field.
LastActiveAt *time.Time `json:"last_active_at,omitempty"`
// RestrictPublicGroups holds the value of the "restrict_public_groups" field.
RestrictPublicGroups bool `json:"restrict_public_groups,omitempty"`
// BalanceNotifyEnabled holds the value of the "balance_notify_enabled" field.
BalanceNotifyEnabled bool `json:"balance_notify_enabled,omitempty"`
// BalanceNotifyThresholdType holds the value of the "balance_notify_threshold_type" field.
@@ -237,7 +239,7 @@ func (*User) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case user.FieldTotpEnabled, user.FieldBalanceNotifyEnabled:
case user.FieldTotpEnabled, user.FieldRestrictPublicGroups, user.FieldBalanceNotifyEnabled:
values[i] = new(sql.NullBool)
case user.FieldBalance, user.FieldFrozenBalance, user.FieldBalanceNotifyThreshold, user.FieldTotalRecharged:
values[i] = new(sql.NullFloat64)
@@ -381,6 +383,12 @@ func (_m *User) assignValues(columns []string, values []any) error {
_m.LastActiveAt = new(time.Time)
*_m.LastActiveAt = value.Time
}
case user.FieldRestrictPublicGroups:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field restrict_public_groups", values[i])
} else if value.Valid {
_m.RestrictPublicGroups = value.Bool
}
case user.FieldBalanceNotifyEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field balance_notify_enabled", values[i])
@@ -588,6 +596,9 @@ func (_m *User) String() string {
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("restrict_public_groups=")
builder.WriteString(fmt.Sprintf("%v", _m.RestrictPublicGroups))
builder.WriteString(", ")
builder.WriteString("balance_notify_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.BalanceNotifyEnabled))
builder.WriteString(", ")
+10
View File
@@ -51,6 +51,8 @@ const (
FieldLastLoginAt = "last_login_at"
// FieldLastActiveAt holds the string denoting the last_active_at field in the database.
FieldLastActiveAt = "last_active_at"
// FieldRestrictPublicGroups holds the string denoting the restrict_public_groups field in the database.
FieldRestrictPublicGroups = "restrict_public_groups"
// FieldBalanceNotifyEnabled holds the string denoting the balance_notify_enabled field in the database.
FieldBalanceNotifyEnabled = "balance_notify_enabled"
// FieldBalanceNotifyThresholdType holds the string denoting the balance_notify_threshold_type field in the database.
@@ -212,6 +214,7 @@ var Columns = []string{
FieldSignupSource,
FieldLastLoginAt,
FieldLastActiveAt,
FieldRestrictPublicGroups,
FieldBalanceNotifyEnabled,
FieldBalanceNotifyThresholdType,
FieldBalanceNotifyThreshold,
@@ -280,6 +283,8 @@ var (
DefaultSignupSource string
// SignupSourceValidator is a validator for the "signup_source" field. It is called by the builders before save.
SignupSourceValidator func(string) error
// DefaultRestrictPublicGroups holds the default value on creation for the "restrict_public_groups" field.
DefaultRestrictPublicGroups bool
// DefaultBalanceNotifyEnabled holds the default value on creation for the "balance_notify_enabled" field.
DefaultBalanceNotifyEnabled bool
// DefaultBalanceNotifyThresholdType holds the default value on creation for the "balance_notify_threshold_type" field.
@@ -390,6 +395,11 @@ func ByLastActiveAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLastActiveAt, opts...).ToFunc()
}
// ByRestrictPublicGroups orders the results by the restrict_public_groups field.
func ByRestrictPublicGroups(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRestrictPublicGroups, opts...).ToFunc()
}
// ByBalanceNotifyEnabled orders the results by the balance_notify_enabled field.
func ByBalanceNotifyEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldBalanceNotifyEnabled, opts...).ToFunc()
+15
View File
@@ -145,6 +145,11 @@ func LastActiveAt(v time.Time) predicate.User {
return predicate.User(sql.FieldEQ(FieldLastActiveAt, v))
}
// RestrictPublicGroups applies equality check predicate on the "restrict_public_groups" field. It's identical to RestrictPublicGroupsEQ.
func RestrictPublicGroups(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
}
// BalanceNotifyEnabled applies equality check predicate on the "balance_notify_enabled" field. It's identical to BalanceNotifyEnabledEQ.
func BalanceNotifyEnabled(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
@@ -1115,6 +1120,16 @@ func LastActiveAtNotNil() predicate.User {
return predicate.User(sql.FieldNotNull(FieldLastActiveAt))
}
// RestrictPublicGroupsEQ applies the EQ predicate on the "restrict_public_groups" field.
func RestrictPublicGroupsEQ(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldRestrictPublicGroups, v))
}
// RestrictPublicGroupsNEQ applies the NEQ predicate on the "restrict_public_groups" field.
func RestrictPublicGroupsNEQ(v bool) predicate.User {
return predicate.User(sql.FieldNEQ(FieldRestrictPublicGroups, v))
}
// BalanceNotifyEnabledEQ applies the EQ predicate on the "balance_notify_enabled" field.
func BalanceNotifyEnabledEQ(v bool) predicate.User {
return predicate.User(sql.FieldEQ(FieldBalanceNotifyEnabled, v))
+65
View File
@@ -270,6 +270,20 @@ func (_c *UserCreate) SetNillableLastActiveAt(v *time.Time) *UserCreate {
return _c
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_c *UserCreate) SetRestrictPublicGroups(v bool) *UserCreate {
_c.mutation.SetRestrictPublicGroups(v)
return _c
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_c *UserCreate) SetNillableRestrictPublicGroups(v *bool) *UserCreate {
if v != nil {
_c.SetRestrictPublicGroups(*v)
}
return _c
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_c *UserCreate) SetBalanceNotifyEnabled(v bool) *UserCreate {
_c.mutation.SetBalanceNotifyEnabled(v)
@@ -636,6 +650,10 @@ func (_c *UserCreate) defaults() error {
v := user.DefaultSignupSource
_c.mutation.SetSignupSource(v)
}
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
v := user.DefaultRestrictPublicGroups
_c.mutation.SetRestrictPublicGroups(v)
}
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
v := user.DefaultBalanceNotifyEnabled
_c.mutation.SetBalanceNotifyEnabled(v)
@@ -730,6 +748,9 @@ func (_c *UserCreate) check() error {
return &ValidationError{Name: "signup_source", err: fmt.Errorf(`ent: validator failed for field "User.signup_source": %w`, err)}
}
}
if _, ok := _c.mutation.RestrictPublicGroups(); !ok {
return &ValidationError{Name: "restrict_public_groups", err: errors.New(`ent: missing required field "User.restrict_public_groups"`)}
}
if _, ok := _c.mutation.BalanceNotifyEnabled(); !ok {
return &ValidationError{Name: "balance_notify_enabled", err: errors.New(`ent: missing required field "User.balance_notify_enabled"`)}
}
@@ -844,6 +865,10 @@ func (_c *UserCreate) createSpec() (*User, *sqlgraph.CreateSpec) {
_spec.SetField(user.FieldLastActiveAt, field.TypeTime, value)
_node.LastActiveAt = &value
}
if value, ok := _c.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
_node.RestrictPublicGroups = value
}
if value, ok := _c.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
_node.BalanceNotifyEnabled = value
@@ -1384,6 +1409,18 @@ func (u *UserUpsert) ClearLastActiveAt() *UserUpsert {
return u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsert) SetRestrictPublicGroups(v bool) *UserUpsert {
u.Set(user.FieldRestrictPublicGroups, v)
return u
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsert) UpdateRestrictPublicGroups() *UserUpsert {
u.SetExcluded(user.FieldRestrictPublicGroups)
return u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsert) SetBalanceNotifyEnabled(v bool) *UserUpsert {
u.Set(user.FieldBalanceNotifyEnabled, v)
@@ -1819,6 +1856,20 @@ func (u *UserUpsertOne) ClearLastActiveAt() *UserUpsertOne {
})
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsertOne) SetRestrictPublicGroups(v bool) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.SetRestrictPublicGroups(v)
})
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsertOne) UpdateRestrictPublicGroups() *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
s.UpdateRestrictPublicGroups()
})
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsertOne) SetBalanceNotifyEnabled(v bool) *UserUpsertOne {
return u.Update(func(s *UserUpsert) {
@@ -2436,6 +2487,20 @@ func (u *UserUpsertBulk) ClearLastActiveAt() *UserUpsertBulk {
})
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (u *UserUpsertBulk) SetRestrictPublicGroups(v bool) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.SetRestrictPublicGroups(v)
})
}
// UpdateRestrictPublicGroups sets the "restrict_public_groups" field to the value that was provided on create.
func (u *UserUpsertBulk) UpdateRestrictPublicGroups() *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
s.UpdateRestrictPublicGroups()
})
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (u *UserUpsertBulk) SetBalanceNotifyEnabled(v bool) *UserUpsertBulk {
return u.Update(func(s *UserUpsert) {
+34
View File
@@ -321,6 +321,20 @@ func (_u *UserUpdate) ClearLastActiveAt() *UserUpdate {
return _u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_u *UserUpdate) SetRestrictPublicGroups(v bool) *UserUpdate {
_u.mutation.SetRestrictPublicGroups(v)
return _u
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_u *UserUpdate) SetNillableRestrictPublicGroups(v *bool) *UserUpdate {
if v != nil {
_u.SetRestrictPublicGroups(*v)
}
return _u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_u *UserUpdate) SetBalanceNotifyEnabled(v bool) *UserUpdate {
_u.mutation.SetBalanceNotifyEnabled(v)
@@ -1069,6 +1083,9 @@ func (_u *UserUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.LastActiveAtCleared() {
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
}
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
}
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
}
@@ -1997,6 +2014,20 @@ func (_u *UserUpdateOne) ClearLastActiveAt() *UserUpdateOne {
return _u
}
// SetRestrictPublicGroups sets the "restrict_public_groups" field.
func (_u *UserUpdateOne) SetRestrictPublicGroups(v bool) *UserUpdateOne {
_u.mutation.SetRestrictPublicGroups(v)
return _u
}
// SetNillableRestrictPublicGroups sets the "restrict_public_groups" field if the given value is not nil.
func (_u *UserUpdateOne) SetNillableRestrictPublicGroups(v *bool) *UserUpdateOne {
if v != nil {
_u.SetRestrictPublicGroups(*v)
}
return _u
}
// SetBalanceNotifyEnabled sets the "balance_notify_enabled" field.
func (_u *UserUpdateOne) SetBalanceNotifyEnabled(v bool) *UserUpdateOne {
_u.mutation.SetBalanceNotifyEnabled(v)
@@ -2775,6 +2806,9 @@ func (_u *UserUpdateOne) sqlSave(ctx context.Context) (_node *User, err error) {
if _u.mutation.LastActiveAtCleared() {
_spec.ClearField(user.FieldLastActiveAt, field.TypeTime)
}
if value, ok := _u.mutation.RestrictPublicGroups(); ok {
_spec.SetField(user.FieldRestrictPublicGroups, field.TypeBool, value)
}
if value, ok := _u.mutation.BalanceNotifyEnabled(); ok {
_spec.SetField(user.FieldBalanceNotifyEnabled, field.TypeBool, value)
}
+12 -7
View File
@@ -1,6 +1,6 @@
module github.com/Wei-Shaw/sub2api
go 1.26.5
go 1.27.0
require (
entgo.io/ent v0.14.5
@@ -24,6 +24,8 @@ require (
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.8.0
github.com/imroc/req/v3 v3.59.0
github.com/klauspost/compress v1.18.2
github.com/lib/pq v1.10.9
@@ -54,6 +56,8 @@ require (
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
@@ -121,12 +125,14 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
@@ -149,6 +155,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@@ -187,10 +194,9 @@ require (
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
@@ -200,8 +206,7 @@ require (
golang.org/x/text v0.39.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
+45 -16
View File
@@ -120,6 +120,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@@ -176,6 +178,7 @@ github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -232,6 +235,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -260,6 +265,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
@@ -267,6 +276,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo=
@@ -279,6 +290,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -305,8 +318,12 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
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=
@@ -346,6 +363,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/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
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=
@@ -429,6 +448,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -497,18 +517,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
@@ -598,6 +620,8 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -605,6 +629,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -668,29 +695,31 @@ golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+179 -1
View File
@@ -32,7 +32,7 @@ const (
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// UMQ(用户消息队列)模式常量
const (
@@ -61,6 +61,10 @@ const (
// 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024
// DefaultModelsListReadMaxBytes 上游模型列表响应体的默认读取上限。
// 可通过 gateway.models_list_read_max_bytes 配置项覆盖。
const DefaultModelsListReadMaxBytes int64 = 8 * 1024 * 1024
type Config struct {
Server ServerConfig `mapstructure:"server"`
Log LogConfig `mapstructure:"log"`
@@ -99,6 +103,18 @@ type Config struct {
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
BatchImage BatchImageConfig `mapstructure:"batch_image"`
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
Plugins PluginConfig `mapstructure:"plugins"`
}
// PluginConfig 控制管理员手动上传的本地进程插件。
// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。
type PluginConfig struct {
DataDir string `mapstructure:"data_dir"`
AllowUnsigned bool `mapstructure:"allow_unsigned"`
TrustedPublishers map[string]string `mapstructure:"trusted_publishers"`
MaxUploadBytes int64 `mapstructure:"max_upload_bytes"`
MaxUncompressedBytes int64 `mapstructure:"max_uncompressed_bytes"`
StartTimeoutSeconds int `mapstructure:"start_timeout_seconds"`
}
type LogConfig struct {
@@ -830,6 +846,53 @@ type ProxyFallbackConfig struct {
type ProxyProbeConfig struct {
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证
// URLs 按优先级排列的自定义探测 URL 列表。
// 留空时使用内置默认列表(ip-api → ipify)。
// 某些 AI API 专用代理只允许访问特定域名,配置多个备选可提高探测成功率。
URLs []ProbeURLConfig `mapstructure:"urls"`
}
// ProbeURLConfig 描述一个探测端点及其响应解析方式。
type ProbeURLConfig struct {
URL string `mapstructure:"url"`
Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace"
}
func normalizeProxyProbeURLs(targets []ProbeURLConfig) ([]ProbeURLConfig, error) {
if len(targets) == 0 {
return nil, nil
}
normalized := make([]ProbeURLConfig, 0, len(targets))
for i, target := range targets {
rawURL := strings.TrimSpace(target.URL)
parser := strings.ToLower(strings.TrimSpace(target.Parser))
if rawURL == "" {
return nil, fmt.Errorf("entry %d: url is required", i)
}
if parser == "" {
return nil, fmt.Errorf("entry %d: parser is required", i)
}
switch parser {
case "ip-api", "ipify", "chatgpt-trace":
default:
return nil, fmt.Errorf("entry %d: unsupported parser %q", i, target.Parser)
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("entry %d: invalid url %q", i, target.URL)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("entry %d: url scheme must be http or https", i)
}
normalized = append(normalized, ProbeURLConfig{
URL: rawURL,
Parser: parser,
})
}
return normalized, nil
}
type BillingConfig struct {
@@ -887,6 +950,9 @@ type GatewayConfig struct {
// OpenAIResponseHeaderTimeout: OpenAI/Codex 上游等待响应头的超时时间(秒),0表示无超时
// OpenAI/Codex 请求可能在上游排队较久;默认不使用通用响应头超时截断。
OpenAIResponseHeaderTimeout int `mapstructure:"openai_response_header_timeout"`
// GrokResponseHeaderTimeout bounds the pre-first-byte wait for xAI/Grok.
// A zero value uses the provider-safe default instead of the generic gateway timeout.
GrokResponseHeaderTimeout int `mapstructure:"grok_response_header_timeout"`
// OpenAIFirstOutputTimeoutSeconds: native HTTP Responses 首个语义输出超时(秒),0表示禁用。
OpenAIFirstOutputTimeoutSeconds int `mapstructure:"openai_first_output_timeout_seconds"`
// OpenAIHighEffortFirstOutputTimeoutSeconds: high/xhigh/max 推理的首个语义输出超时(秒)。
@@ -898,6 +964,8 @@ type GatewayConfig struct {
TextMaxBodySize int64 `mapstructure:"text_max_body_size"`
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"`
// 上游模型列表响应体读取上限(字节)
ModelsListReadMaxBytes int64 `mapstructure:"models_list_read_max_bytes"`
// 代理探测响应体读取上限(字节)
ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"`
// Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销)
@@ -1022,6 +1090,55 @@ 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"`
// CNProviders: 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。
// 仅作用于 payg(按量付费)账号:周期探测余额,低于阈值则临时停调。
CNProviders GatewayCNProvidersConfig `mapstructure:"cn_providers"`
}
// 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"`
}
// GatewayCNProvidersConfig 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。
//
// 仅作用于 payg(按量付费)账号(kimi/deepseek 有公开余额端点;zhipu 无,仅靠响应式 429/402)。
// - balance_check_enabled: 是否启用周期余额检测(默认 true)
// - balance_threshold: 余额低于此值(账户货币单位,默认 0.5)触发临时停调
// - balance_check_interval_minutes: 余额检测周期(分钟,默认 10)
type GatewayCNProvidersConfig struct {
BalanceCheckEnabled bool `mapstructure:"balance_check_enabled"`
BalanceThreshold float64 `mapstructure:"balance_threshold"`
BalanceCheckIntervalMinutes int `mapstructure:"balance_check_interval_minutes"`
}
type GatewayLiveConfig struct {
@@ -1670,6 +1787,10 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
// 环境变量支持
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
if tz, ok := os.LookupEnv("TZ"); ok && strings.TrimSpace(tz) != "" {
// AutomaticEnv 会先把 timezone 映射到 TIMEZONE;显式 Set 保证标准 TZ 变量优先。
viper.Set("timezone", strings.TrimSpace(tz))
}
if err := viper.BindEnv("server.enable_server_timing", "ENABLE_SERVER_TIMING"); err != nil {
return nil, fmt.Errorf("bind ENABLE_SERVER_TIMING: %w", err)
}
@@ -2166,6 +2287,14 @@ func setDefaults() {
viper.SetDefault("pricing.update_interval_hours", 24)
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
// 本地进程插件。插件必须由管理员手动上传,项目默认不携带任何插件能力。
viper.SetDefault("plugins.data_dir", "")
viper.SetDefault("plugins.allow_unsigned", false)
viper.SetDefault("plugins.trusted_publishers", map[string]string{})
viper.SetDefault("plugins.max_upload_bytes", int64(128*1024*1024))
viper.SetDefault("plugins.max_uncompressed_bytes", int64(256*1024*1024))
viper.SetDefault("plugins.start_timeout_seconds", 15)
// Timezone (default to Asia/Shanghai for Chinese users)
viper.SetDefault("timezone", "Asia/Shanghai")
@@ -2227,6 +2356,7 @@ func setDefaults() {
// Gateway
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
viper.SetDefault("gateway.openai_response_header_timeout", 0)
viper.SetDefault("gateway.grok_response_header_timeout", 120)
viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0)
viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0)
viper.SetDefault("gateway.log_upstream_error_body", true)
@@ -2309,6 +2439,19 @@ 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)
// 国产供应商余额检测(kimi/deepseek payg;zhipu 无余额端点,仅靠响应式 429/402)。
viper.SetDefault("gateway.cn_providers.balance_check_enabled", true)
viper.SetDefault("gateway.cn_providers.balance_threshold", 0.5)
viper.SetDefault("gateway.cn_providers.balance_check_interval_minutes", 10)
viper.SetDefault("gateway.image_concurrency.enabled", false)
viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
@@ -2319,6 +2462,7 @@ func setDefaults() {
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024))
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes)
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
viper.SetDefault("gateway.gemini_debug_response_headers", false)
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
@@ -2503,6 +2647,20 @@ func (c *Config) Validate() error {
}
c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders
c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders)
proxyProbeURLs, err := normalizeProxyProbeURLs(c.Security.ProxyProbe.URLs)
if err != nil {
return fmt.Errorf("security.proxy_probe.urls: %w", err)
}
c.Security.ProxyProbe.URLs = proxyProbeURLs
if c.Plugins.MaxUploadBytes <= 0 || c.Plugins.MaxUploadBytes > 1024*1024*1024 {
return fmt.Errorf("plugins.max_upload_bytes must be between 1 and 1073741824")
}
if c.Plugins.MaxUncompressedBytes < c.Plugins.MaxUploadBytes || c.Plugins.MaxUncompressedBytes > 2*1024*1024*1024 {
return fmt.Errorf("plugins.max_uncompressed_bytes must be between max_upload_bytes and 2147483648")
}
if c.Plugins.StartTimeoutSeconds < 1 || c.Plugins.StartTimeoutSeconds > 120 {
return fmt.Errorf("plugins.start_timeout_seconds must be between 1 and 120")
}
if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 {
return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds")
}
@@ -3114,6 +3272,9 @@ func (c *Config) Validate() error {
if c.Gateway.UpstreamResponseReadMaxBytes <= 0 {
return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive")
}
if c.Gateway.ModelsListReadMaxBytes <= 0 {
return fmt.Errorf("gateway.models_list_read_max_bytes must be positive")
}
if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 {
return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive")
}
@@ -3123,6 +3284,9 @@ func (c *Config) Validate() error {
if c.Gateway.OpenAIResponseHeaderTimeout < 0 {
return fmt.Errorf("gateway.openai_response_header_timeout must be non-negative")
}
if c.Gateway.GrokResponseHeaderTimeout < 0 || c.Gateway.GrokResponseHeaderTimeout > 1800 {
return fmt.Errorf("gateway.grok_response_header_timeout must be between 0-1800 seconds")
}
if c.Gateway.OpenAIFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIFirstOutputTimeoutSeconds > 600 ||
(c.Gateway.OpenAIFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIFirstOutputTimeoutSeconds < 30) {
return fmt.Errorf("gateway.openai_first_output_timeout_seconds must be 0 or between 30-600 seconds")
@@ -3518,6 +3682,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)
}
+66
View File
@@ -23,6 +23,45 @@ func resetViperWithJWTSecret(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
}
func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes)
}
func TestLoadTimezonePrecedence(t *testing.T) {
tests := []struct {
name string
fileTimezone string
timezoneEnv string
tzEnv string
want string
}{
{name: "default", want: "Asia/Shanghai"},
{name: "config_file", fileTimezone: "Europe/London", want: "Europe/London"},
{name: "timezone_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", want: "UTC"},
{name: "tz_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", tzEnv: "America/New_York", want: "America/New_York"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("TIMEZONE", tt.timezoneEnv)
t.Setenv("TZ", tt.tzEnv)
if tt.fileTimezone != "" {
configFile := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("timezone: "+tt.fileTimezone+"\n"), 0o600))
t.Setenv("CONFIG_FILE", configFile)
}
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, tt.want, cfg.Timezone)
})
}
}
func TestLoadServerTimingConfig(t *testing.T) {
t.Run("disabled by default", func(t *testing.T) {
resetViperWithJWTSecret(t)
@@ -521,6 +560,15 @@ func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) {
require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds)
}
func TestLoadOpenAIWSForceHTTPFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("GATEWAY_OPENAI_WS_FORCE_HTTP", "true")
cfg, err := Load()
require.NoError(t, err)
require.True(t, cfg.Gateway.OpenAIWS.ForceHTTP)
}
func TestLoadDefaultOpenAICompactModel(t *testing.T) {
resetViperWithJWTSecret(t)
@@ -538,6 +586,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)
@@ -1749,6 +1810,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 },
wantErr: "gateway.text_max_body_size",
},
{
name: "gateway models list read limit",
mutate: func(c *Config) { c.Gateway.ModelsListReadMaxBytes = 0 },
wantErr: "gateway.models_list_read_max_bytes",
},
{
name: "gateway response header timeout",
mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 },
@@ -45,6 +45,20 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin
// is out of scope here — such settings need a config file either way.
continue
}
if ft.Kind() == reflect.Slice {
elem := ft.Elem()
for elem.Kind() == reflect.Ptr {
elem = elem.Elem()
}
if elem.Kind() == reflect.Struct {
// AutomaticEnv exposes one string value. Viper's string-to-slice
// hook can populate scalar slices, but it cannot decode a string
// into []struct. Registering a default would turn silent ignore
// into a startup unmarshal error, so structured slices remain
// config-file-only just like maps.
continue
}
}
out[strings.ToLower(key)] = ft.String()
}
}
@@ -62,7 +76,7 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin
// were lost, silently disabling async image tasks for env-driven deployments.
//
// When this fails, register a zero-valued default in setEnvReachableDefaults
// for each reported key.
// for each reported scalar key. Maps and slices of structs are config-file-only.
func TestConfigKeysAreEnvReachable(t *testing.T) {
bound := map[string]string{}
collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound)
@@ -0,0 +1,47 @@
//go:build unit
package config
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeProxyProbeURLs(t *testing.T) {
t.Parallel()
got, err := normalizeProxyProbeURLs([]ProbeURLConfig{
{URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "},
{URL: "https://api64.ipify.org?format=json", Parser: "ipify"},
})
require.NoError(t, err)
require.Equal(t, []ProbeURLConfig{
{URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"},
{URL: "https://api64.ipify.org?format=json", Parser: "ipify"},
}, got)
}
func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target ProbeURLConfig
wantErr string
}{
{name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"},
{name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"},
{name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"},
{name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"},
{name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target})
require.ErrorContains(t, err, tt.wantErr)
})
}
}
@@ -0,0 +1,68 @@
package domain
import "time"
// 渠道监控「配额模式」的归一化配额快照类型。
//
// 配额模式监控不直接对接上游,而是关联一个已有账号,复用账号侧的用量服务
// (AccountUsageService / CNProviderQuotaService / CNProviderBalanceService),
// 把各平台形态各异的用量数据归一成 MonitorQuotaSnapshot,随检测历史持久化
// 到 channel_monitor_histories.quota(JSONB),供管理端与用户端渲染。
//
// 类型放在 domain 包是因为 ent schema(internal/domain 的下游)需要引用它做
// field.JSON 序列化;service 不能被 ent import(会造成循环依赖)。
// MonitorQuotaTier 单个用量窗口的快照。
//
// Window 取值约定(与前端 monitorCommon.quota.windows.* 标签一一对应):
// - "5h" 5 小时滚动窗口(Claude/Codex/Kimi/Zhipu coding plan)
// - "7d" 7 天窗口(Claude/Codex)
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
// - "7d-fable" Claude 7 天 Fable 独立额度
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
// - "30d" 30 天窗口(Grok 月度)
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
//
// 同一 Window 可能出现多条(Gemini 多档日配额、Antigravity per-model、
// Grok requests/tokens),用 Label 区分:Label 是机器 token(requests/tokens/
// shared/pro/flash 或模型名),前端已知 token 走 i18n,未知原样展示。
type MonitorQuotaTier struct {
Window string `json:"window"`
Label string `json:"label,omitempty"`
UsedPercent float64 `json:"used_percent"` // 0-100+;仅有绝对值时按 used/limit 计算
Used float64 `json:"used,omitempty"`
Limit float64 `json:"limit,omitempty"`
ResetAt string `json:"reset_at,omitempty"` // RFC3339;未知时留空
}
// MonitorQuotaSnapshot 一次配额查询的完整快照。
//
// Source 取值:
// - "usage" 海外平台(AccountUsageService.GetUsage)
// - "cn_quota" 国产 Coding Plan(CNProviderQuotaService.QueryUsage)
// - "cn_balance" 国产按量付费余额(CNProviderBalanceService.QueryBalance)
type MonitorQuotaSnapshot struct {
Source string `json:"source"`
Success bool `json:"success"`
Tiers []MonitorQuotaTier `json:"tiers,omitempty"`
Balance *float64 `json:"balance,omitempty"` // cn_balance 主余额
Balances []MonitorBalance `json:"balances,omitempty"` // 多币种余额(如 DeepSeek CNY+USD)
Currency string `json:"currency,omitempty"` // 主余额币种
PlanLevel string `json:"plan_level,omitempty"` // 套餐等级(如智谱 level)
// BalanceLow 余额低于阈值或账号被上游标记不可用(仅 cn_balance 来源)。
// 抓取器按 Gateway.CNProviders.BalanceThreshold 判定,口径与账号停调
// (CNProviderBalanceCheckService.checkOne)一致:任一币种达标即健康。
BalanceLow bool `json:"balance_low,omitempty"`
// CredentialInvalid 上游 401/403 鉴权失败(区别于网络/解析错误),
// 检测状态据此推导 failed 而非 error。
CredentialInvalid bool `json:"credential_invalid,omitempty"`
Error string `json:"error,omitempty"` // Success=false 时的错误摘要
FetchedAt time.Time `json:"fetched_at"`
}
// MonitorBalance 单币种余额条目。
type MonitorBalance struct {
Currency string `json:"currency"`
Balance float64 `json:"balance"`
}
+31 -4
View File
@@ -23,7 +23,28 @@ const (
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
PlatformComposite = "composite"
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformComposite = "composite"
)
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
// 存储于 credentials["account_mode"],决定 base_url 预设与额度监控方式。
const (
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
)
// API protocol constants 国产供应商的上游 API 协议维度。存储于
// credentials["api_protocol"],与 account_mode 正交:协议决定转发端点与格式,
// 模式决定额度监控方式。同协议请求零转换直通;跨协议组合才走转换链。
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
// Account type constants
@@ -84,11 +105,11 @@ var DefaultAntigravityModelMapping = map[string]string{
"claude-opus-4-6": "claude-opus-4-6-thinking", // 简称映射
"claude-opus-4-5-thinking": "claude-opus-4-6-thinking", // 迁移旧模型
"claude-sonnet-4-6": "claude-sonnet-4-6",
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-5-thinking",
"claude-sonnet-4-5": "claude-sonnet-4-5", // 显式 canonical 选择透传
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6", // 迁移旧兼容别名
// Claude 详细版本 ID 映射
"claude-opus-4-5-20251101": "claude-opus-4-6-thinking", // 迁移旧模型
"claude-sonnet-4-5-20250929": "claude-sonnet-4-5",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6", // 迁移旧模型
// Claude Haiku → Sonnet(无 Haiku 支持)
"claude-haiku-4-5": "claude-sonnet-4-6",
"claude-haiku-4-5-20251001": "claude-sonnet-4-6",
@@ -117,6 +138,12 @@ var DefaultAntigravityModelMapping = map[string]string{
"gemini-3.1-flash-image": "gemini-3.1-flash-image",
// Gemini 3.1 image preview 映射
"gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
// Gemini 3.6 Flash tiered models
"gemini-3.6-flash": "gemini-3.6-flash",
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
"gemini-3-pro-image": "gemini-3.1-flash-image",
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
+23
View File
@@ -43,6 +43,21 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_PreservesExplicitSonnet45AndMigratesLegacyAliases(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-sonnet-4-5": "claude-sonnet-4-5",
"claude-sonnet-4-5-thinking": "claude-sonnet-4-6",
"claude-sonnet-4-5-20250929": "claude-sonnet-4-6",
}
for model, want := range cases {
if got := DefaultAntigravityModelMapping[model]; got != want {
t.Fatalf("expected model %q to map to %q, got %q", model, want, got)
}
}
}
func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
t.Parallel()
@@ -65,6 +80,14 @@ func TestDefaultAntigravityModelMapping_Gemini31ProAliases(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.6-flash", "gemini-3.6-flash-high", "gemini-3.6-flash-low", "gemini-3.6-flash-medium", "gemini-3.6-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
@@ -1025,7 +1025,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
// 当前请求。探测错误仅记录日志,不向上下文传播:探测失败时标记保持缺失,
// 网关会按"现状即证据"默认走 Responses。
func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) {
if account == nil || account.Platform != service.PlatformOpenAI || account.Type != service.AccountTypeAPIKey {
if account == nil || account.Type != service.AccountTypeAPIKey ||
(account.Platform != service.PlatformOpenAI && !service.IsCNProvider(account.Platform)) {
return
}
if h.accountTestService == nil {
@@ -1065,6 +1066,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 +1099,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 +1425,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 +1455,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 +2449,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 +2500,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"`
@@ -2552,8 +2614,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
// Handle Gemini accounts
if account.IsGemini() {
// For OAuth accounts: return default Gemini models
// Consumer Google One OAuth still uses the legacy Gemini CLI / Code
// Assist channel. Do not advertise newer 3.x or image models that the
// channel cannot serve.
if account.IsOAuth() {
if account.IsGeminiGoogleOne() {
response.Success(c, geminicli.GoogleOneModels)
return
}
response.Success(c, geminicli.DefaultModels)
return
}
@@ -2706,13 +2774,15 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
return
}
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), account)
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), account)
if err != nil {
var syncErr *service.UpstreamModelSyncError
if errors.As(err, &syncErr) {
switch syncErr.Kind {
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
response.BadRequest(c, syncErr.SafeMessage())
case service.UpstreamModelSyncErrorInternal:
response.InternalError(c, syncErr.SafeMessage())
default:
slog.Warn("sync_upstream_models_failed", "account_id", accountID, "kind", syncErr.Kind)
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
@@ -2725,29 +2795,35 @@ func (h *AccountHandler) SyncUpstreamModels(c *gin.Context) {
return
}
response.Success(c, gin.H{"models": models})
response.Success(c, catalog)
}
// SyncUpstreamModelsPreview handles syncing live supported models using provided credentials (no account ID needed).
// POST /api/v1/admin/accounts/models/sync-upstream-preview
func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
var req struct {
Platform string `json:"platform" binding:"required"`
Type string `json:"type" binding:"required"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key" binding:"required"`
Platform string `json:"platform" binding:"required"`
Type string `json:"type" binding:"required"`
BaseURL string `json:"base_url"`
APIKey string `json:"api_key" binding:"required"`
ModelMapping map[string]string `json:"model_mapping"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
modelMapping := make(map[string]any, len(req.ModelMapping))
for sourceModel, upstreamModel := range req.ModelMapping {
modelMapping[sourceModel] = upstreamModel
}
tempAccount := &service.Account{
Platform: req.Platform,
Type: req.Type,
Credentials: map[string]any{
"api_key": req.APIKey,
"base_url": req.BaseURL,
"api_key": req.APIKey,
"base_url": req.BaseURL,
"model_mapping": modelMapping,
},
}
@@ -2756,13 +2832,15 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
return
}
models, err := h.accountTestService.FetchUpstreamSupportedModels(c.Request.Context(), tempAccount)
catalog, err := h.accountTestService.SyncUpstreamModelCatalog(c.Request.Context(), tempAccount)
if err != nil {
var syncErr *service.UpstreamModelSyncError
if errors.As(err, &syncErr) {
switch syncErr.Kind {
case service.UpstreamModelSyncErrorConfiguration, service.UpstreamModelSyncErrorUnsupported:
response.BadRequest(c, syncErr.SafeMessage())
case service.UpstreamModelSyncErrorInternal:
response.InternalError(c, syncErr.SafeMessage())
default:
slog.Warn("sync_upstream_models_preview_failed", "platform", req.Platform, "kind", syncErr.Kind)
response.Error(c, http.StatusBadGateway, syncErr.SafeMessage())
@@ -2775,7 +2853,7 @@ func (h *AccountHandler) SyncUpstreamModelsPreview(c *gin.Context) {
return
}
response.Success(c, gin.H{"models": models})
response.Success(c, catalog)
}
// SetPrivacy handles setting privacy for a single OpenAI/Antigravity OAuth account
@@ -38,14 +38,20 @@ func setupAvailableModelsRouter(adminSvc service.AdminService) *gin.Engine {
}
type syncUpstreamHTTPUpstream struct {
resp *http.Response
err error
resp *http.Response
responses []*http.Response
err error
}
func (u *syncUpstreamHTTPUpstream) Do(req *http.Request, proxyURL string, accountID int64, accountConcurrency int) (*http.Response, error) {
if u.err != nil {
return nil, u.err
}
if len(u.responses) > 0 {
resp := u.responses[0]
u.responses = u.responses[1:]
return resp, nil
}
return u.resp, nil
}
@@ -68,6 +74,7 @@ func setupSyncUpstreamModelsRouter(adminSvc service.AdminService, upstream servi
)
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, accountTestSvc, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/accounts/:id/models/sync-upstream", handler.SyncUpstreamModels)
router.POST("/api/v1/admin/accounts/models/sync-upstream-preview", handler.SyncUpstreamModelsPreview)
return router
}
@@ -287,6 +294,42 @@ func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(
}, ids, "影子可用模型由 model_mapping 派生(非写死)")
}
func TestAccountHandlerGetAvailableModels_GeminiGoogleOneUsesConservativeCatalog(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 45,
Name: "google-one",
Platform: service.PlatformGemini,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Credentials: map[string]any{
"oauth_type": "google_one",
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
ids := make([]string, 0, len(resp.Data))
for _, model := range resp.Data {
ids = append(ids, model.ID)
}
require.ElementsMatch(t, []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"}, ids)
require.NotContains(t, ids, "gemini-3.5-flash")
require.NotContains(t, ids, "gemini-2.5-flash-image")
}
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -311,6 +354,99 @@ func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testin
require.Contains(t, rec.Body.String(), "No OpenAI API key is available")
}
func TestAccountHandlerSyncUpstreamModelsReturnsCapabilityMetadata(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 48, Name: "custom-openai", Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey, Status: service.StatusActive,
Credentials: map[string]any{"api_key": "key", "base_url": "https://provider.example/v1"},
},
}
upstream := &syncUpstreamHTTPUpstream{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"models":[{
"id":"custom-thinking-model",
"reasoning":true,
"default_reasoning_level":"high",
"supported_reasoning_levels":["low","high"],
"input_modalities":["text","image"],
"context_window":256000
}]}`)),
}}
router := setupSyncUpstreamModelsRouter(svc, upstream)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/48/models/sync-upstream", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data service.UpstreamModelCatalog `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, []string{"custom-thinking-model"}, resp.Data.Models)
metadata := resp.Data.Metadata["custom-thinking-model"]
require.NotNil(t, metadata.Reasoning)
require.True(t, *metadata.Reasoning)
require.Equal(t, []string{"low", "high"}, metadata.SupportedReasoningLevels)
require.Equal(t, []string{"text", "image"}, metadata.InputModalities)
}
// Scenario: 创建账号 preview 将具体 mapping 传给 404/405 配置回退。
func TestAccountHandlerSyncUpstreamModelsPreviewUsesProvidedModelMapping(t *testing.T) {
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
{
StatusCode: http.StatusNotFound,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":"not found"}`)),
},
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{
"configured-provider": {
"api": "https://provider.example/v1",
"models": {
"glm-5.3": {
"id": "glm-5.3",
"reasoning": true,
"reasoning_options": [{"type":"effort","values":["low","high"]}],
"modalities": {"input":["text"],"output":["text"]},
"limit": {"context":1000000,"output":131072}
}
}
}
}`)),
},
}}
router := setupSyncUpstreamModelsRouter(newStubAdminService(), upstream)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/admin/accounts/models/sync-upstream-preview",
strings.NewReader(`{
"platform":"openai",
"type":"apikey",
"base_url":"https://provider.example/v1",
"api_key":"key",
"model_mapping":{"public-glm":"glm-5.3"}
}`),
)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data service.UpstreamModelCatalog `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, []string{"glm-5.3"}, resp.Data.Models)
require.Equal(t, []string{"low", "high"}, resp.Data.Metadata["glm-5.3"].SupportedReasoningLevels)
}
func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -341,3 +477,52 @@ func TestAccountHandlerSyncUpstreamModels_UpstreamErrorDoesNotExposeBody(t *test
require.Contains(t, rec.Body.String(), "Upstream model list request failed with HTTP 502")
require.NotContains(t, rec.Body.String(), "SECRET_TOKEN")
}
// Scenario: 能力补全失败显示部分成功。
func TestAccountHandlerSyncUpstreamModels_MetadataEnrichmentFailureReturnsWarning(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 46,
Name: "opencode-id-only-model-list",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Credentials: map[string]any{
"api_key": "opencode-key",
"base_url": "https://opencode.ai/zen/v1",
},
},
}
upstream := &syncUpstreamHTTPUpstream{responses: []*http.Response{
{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"data":[{"id":"x-preview-f-free"}]}`)),
},
{
StatusCode: http.StatusBadGateway,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"error":"registry unavailable"}`)),
},
}}
router := setupSyncUpstreamModelsRouter(svc, upstream)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/accounts/46/models/sync-upstream", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data struct {
Models []string `json:"models"`
Warnings []struct {
Code string `json:"code"`
} `json:"warnings"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, []string{"x-preview-f-free"}, resp.Data.Models)
require.Len(t, resp.Data.Warnings, 1)
require.Equal(t, "upstream_model_metadata_incomplete", resp.Data.Warnings[0].Code)
}
@@ -0,0 +1,99 @@
package admin
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// upstreamBillingRatesResponse is deliberately smaller than the regular
// account-list response. The frontend uses the returned ID order to detect a
// page boundary/order change and only applies snapshots when the page is
// otherwise stable.
type upstreamBillingRatesResponse struct {
Items []service.UpstreamBillingRateSnapshotItem `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
}
// GetUpstreamBillingRates returns persisted upstream billing snapshots for the
// current account-list page. It never probes an upstream and never loads
// today-stats, usage, concurrency, or credentials for the response.
//
// GET /api/v1/admin/accounts/upstream-billing-rates
func (h *AccountHandler) GetUpstreamBillingRates(c *gin.Context) {
if h.adminService == nil {
response.Error(c, http.StatusServiceUnavailable, "account service unavailable")
return
}
page, pageSize := response.ParsePagination(c)
platform := c.Query("platform")
accountType := c.Query("type")
status := c.Query("status")
search := strings.TrimSpace(c.Query("search"))
if len(search) > 100 {
search = search[:100]
}
privacyMode := strings.TrimSpace(c.Query("privacy_mode"))
sortBy := c.DefaultQuery("sort_by", "name")
sortOrder := c.DefaultQuery("sort_order", "asc")
var groupID int64
if groupQuery := c.Query("group"); groupQuery != "" {
if groupQuery == accountListGroupUngroupedQueryValue {
groupID = service.AccountListGroupUngrouped
} else {
parsed, err := strconv.ParseInt(groupQuery, 10, 64)
if err != nil || parsed < 0 {
response.BadRequest(c, "invalid group filter")
return
}
groupID = parsed
}
}
accounts, total, err := h.adminService.ListAccounts(
c.Request.Context(), page, pageSize, platform, accountType, status,
search, groupID, privacyMode, sortBy, sortOrder,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
payload := upstreamBillingRatesResponse{
Items: service.BuildUpstreamBillingRateSnapshotItems(accounts),
Total: total,
Page: page,
PageSize: pageSize,
}
c.Header("Cache-Control", "private, no-cache")
etag := buildUpstreamBillingRatesETag(payload)
if etag != "" {
c.Header("ETag", etag)
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
}
response.Success(c, payload)
}
func buildUpstreamBillingRatesETag(payload upstreamBillingRatesResponse) string {
raw, err := json.Marshal(payload)
if err != nil {
return ""
}
sum := sha256.Sum256(raw)
return "\"" + hex.EncodeToString(sum[:]) + "\""
}
@@ -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)
}
// ─── 恢复操作(需要重新输入管理员密码) ───
+173 -68
View File
@@ -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"`
@@ -57,29 +57,48 @@ type updateChannelRequest struct {
}
type channelModelPricingRequest struct {
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
TimePricing *channelTimePricingRequest `json:"time_pricing"`
}
type channelTimePricingRequest struct {
Timezone string `json:"timezone"`
WeekdaysOnly bool `json:"weekdays_only"`
Periods []channelTimePricingPeriodRequest `json:"periods"`
}
type channelTimePricingPeriodRequest struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
type pricingIntervalRequest struct {
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
PerRequestPrice *float64 `json:"per_request_price"`
SortOrder int `json:"sort_order"`
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"`
OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"`
CacheWriteMultiplier *float64 `json:"cache_write_multiplier" binding:"omitempty,gt=0"`
CacheReadMultiplier *float64 `json:"cache_read_multiplier" binding:"omitempty,gt=0"`
PerRequestPrice *float64 `json:"per_request_price"`
SortOrder int `json:"sort_order"`
}
type accountStatsPricingRuleRequest struct {
@@ -108,31 +127,50 @@ type channelResponse struct {
}
type channelModelPricingResponse struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
TimePricing *channelTimePricingResponse `json:"time_pricing"`
}
type channelTimePricingResponse struct {
Timezone string `json:"timezone"`
WeekdaysOnly bool `json:"weekdays_only"`
Periods []channelTimePricingPeriodResponse `json:"periods"`
}
type channelTimePricingPeriodResponse struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
type pricingIntervalResponse struct {
ID int64 `json:"id"`
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
PerRequestPrice *float64 `json:"per_request_price"`
SortOrder int `json:"sort_order"`
ID int64 `json:"id"`
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier"`
OutputMultiplier *float64 `json:"output_multiplier"`
CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
PerRequestPrice *float64 `json:"per_request_price"`
SortOrder int `json:"sort_order"`
}
type accountStatsPricingRuleResponse struct {
@@ -224,29 +262,55 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheReadPrice: p.CacheReadPrice,
FastMultiplier: p.FastMultiplier,
FlexMultiplier: p.FlexMultiplier,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingToResponse(p.TimePricing),
}
}
func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricingResponse {
if value == nil {
return nil
}
periods := make([]channelTimePricingPeriodResponse, 0, len(value.Periods))
for _, period := range value.Periods {
periods = append(periods, channelTimePricingPeriodResponse{
StartTime: period.StartTime,
EndTime: period.EndTime,
Multiplier: period.Multiplier,
})
}
return &channelTimePricingResponse{
Timezone: value.Timezone,
WeekdaysOnly: value.WeekdaysOnly,
Periods: periods,
}
}
func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
return pricingIntervalResponse{
ID: iv.ID,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
PerRequestPrice: iv.PerRequestPrice,
SortOrder: iv.SortOrder,
ID: iv.ID,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: iv.InputMultiplier,
OutputMultiplier: iv.OutputMultiplier,
CacheWriteMultiplier: iv.CacheWriteMultiplier,
CacheReadMultiplier: iv.CacheReadMultiplier,
PerRequestPrice: iv.PerRequestPrice,
SortOrder: iv.SortOrder,
}
}
func pricingRequestToService(reqs []channelModelPricingRequest) []service.ChannelModelPricing {
func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMultipliers bool) []service.ChannelModelPricing {
result := make([]service.ChannelModelPricing, 0, len(reqs))
for _, r := range reqs {
billingMode := service.BillingMode(r.BillingMode)
@@ -256,18 +320,34 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
platform := r.Platform
intervals := make([]service.PricingInterval, 0, len(r.Intervals))
for _, iv := range r.Intervals {
var inputMultiplier, outputMultiplier, cacheWriteMultiplier, cacheReadMultiplier *float64
if allowChannelMultipliers {
inputMultiplier = iv.InputMultiplier
outputMultiplier = iv.OutputMultiplier
cacheWriteMultiplier = iv.CacheWriteMultiplier
cacheReadMultiplier = iv.CacheReadMultiplier
}
intervals = append(intervals, service.PricingInterval{
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
PerRequestPrice: iv.PerRequestPrice,
SortOrder: iv.SortOrder,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: inputMultiplier,
OutputMultiplier: outputMultiplier,
CacheWriteMultiplier: cacheWriteMultiplier,
CacheReadMultiplier: cacheReadMultiplier,
PerRequestPrice: iv.PerRequestPrice,
SortOrder: iv.SortOrder,
})
}
var fastMultiplier, flexMultiplier *float64
if allowChannelMultipliers {
fastMultiplier = r.FastMultiplier
flexMultiplier = r.FlexMultiplier
}
result = append(result, service.ChannelModelPricing{
Platform: platform,
Models: r.Models,
@@ -276,21 +356,43 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
OutputPrice: r.OutputPrice,
CacheWritePrice: r.CacheWritePrice,
CacheReadPrice: r.CacheReadPrice,
FastMultiplier: fastMultiplier,
FlexMultiplier: flexMultiplier,
ImageInputPrice: r.ImageInputPrice,
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingRequestToService(r.TimePricing),
})
}
return result
}
func timePricingRequestToService(value *channelTimePricingRequest) *service.ChannelTimePricing {
if value == nil {
return nil
}
periods := make([]service.ChannelTimePricingPeriod, 0, len(value.Periods))
for _, period := range value.Periods {
periods = append(periods, service.ChannelTimePricingPeriod{
StartTime: period.StartTime,
EndTime: period.EndTime,
Multiplier: period.Multiplier,
})
}
return &service.ChannelTimePricing{
Timezone: value.Timezone,
WeekdaysOnly: value.WeekdaysOnly,
Periods: periods,
}
}
func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule {
return service.AccountStatsPricingRule{
Name: r.Name,
GroupIDs: r.GroupIDs,
AccountIDs: r.AccountIDs,
Pricing: pricingRequestToService(r.Pricing),
Pricing: pricingRequestToService(r.Pricing, false),
}
}
@@ -351,7 +453,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
return
}
pricing := pricingRequestToService(req.ModelPricing)
pricing := pricingRequestToService(req.ModelPricing, true)
// Main model_pricing requires a platform; default to anthropic for backward compatibility.
for i := range pricing {
if pricing[i].Platform == "" {
@@ -425,7 +527,7 @@ func (h *ChannelHandler) Update(c *gin.Context) {
ApplyPricingToAccountStats: req.ApplyPricingToAccountStats,
}
if req.ModelPricing != nil {
pricing := pricingRequestToService(*req.ModelPricing)
pricing := pricingRequestToService(*req.ModelPricing, true)
for i := range pricing {
if pricing[i].Platform == "" {
pricing[i].Platform = service.PlatformAnthropic
@@ -512,9 +614,12 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
var platformToLiteLLMProvider = map[string]string{
service.PlatformAnthropic: "anthropic",
service.PlatformOpenAI: "openai",
service.PlatformGemini: "google",
service.PlatformGemini: "gemini",
service.PlatformAntigravity: "anthropic",
service.PlatformGrok: "xai",
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -305,7 +305,7 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := pricingRequestToService([]channelModelPricingRequest{tt.req})
result := pricingRequestToService([]channelModelPricingRequest{tt.req}, true)
require.Len(t, result, 1)
switch tt.wantField {
case "BillingMode":
@@ -332,7 +332,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
},
}
result := pricingRequestToService(reqs)
result := pricingRequestToService(reqs, true)
require.Len(t, result, 1)
r := result[0]
require.Equal(t, "openai", r.Platform)
@@ -373,7 +373,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
},
}
result := pricingRequestToService(reqs)
result := pricingRequestToService(reqs, true)
require.Len(t, result, 1)
require.Len(t, result[0].Intervals, 2)
@@ -396,7 +396,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
}
func TestPricingRequestToService_EmptySlice(t *testing.T) {
result := pricingRequestToService([]channelModelPricingRequest{})
result := pricingRequestToService([]channelModelPricingRequest{}, true)
require.NotNil(t, result)
require.Empty(t, result)
}
@@ -410,7 +410,7 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) {
},
}
result := pricingRequestToService(reqs)
result := pricingRequestToService(reqs, true)
require.Len(t, result, 1)
r := result[0]
require.Nil(t, r.InputPrice)
@@ -421,6 +421,89 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) {
require.Nil(t, r.PerRequestPrice)
}
func TestPricingRequestToService_TimePricing(t *testing.T) {
req := channelModelPricingRequest{
Models: []string{"gpt-5"},
BillingMode: "token",
TimePricing: &channelTimePricingRequest{
Timezone: "Asia/Shanghai",
WeekdaysOnly: true,
Periods: []channelTimePricingPeriodRequest{{
StartTime: "09:00", EndTime: "12:00", Multiplier: 2,
}},
},
}
got := pricingRequestToService([]channelModelPricingRequest{req}, true)
require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone)
require.True(t, got[0].TimePricing.WeekdaysOnly)
require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier)
}
func TestPricingRequestToService_TimePricingNil(t *testing.T) {
got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}}, true)
require.Nil(t, got[0].TimePricing)
}
// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃,
// 避免渠道倍率意外污染账号成本口径。
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
req := channelModelPricingRequest{
Models: []string{"gpt-5"},
BillingMode: "token",
FastMultiplier: float64Ptr(2.5),
FlexMultiplier: float64Ptr(0.5),
Intervals: []pricingIntervalRequest{{
MinTokens: 272000,
InputMultiplier: float64Ptr(2),
OutputMultiplier: float64Ptr(1.5),
CacheWriteMultiplier: float64Ptr(2),
CacheReadMultiplier: float64Ptr(2),
}},
}
allowed := pricingRequestToService([]channelModelPricingRequest{req}, true)
require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier)
require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier)
require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheReadMultiplier)
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
require.Nil(t, dropped[0].FastMultiplier)
require.Nil(t, dropped[0].FlexMultiplier)
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
require.Nil(t, dropped[0].Intervals[0].CacheReadMultiplier)
// 非倍率字段不受开关影响
require.Equal(t, 272000, dropped[0].Intervals[0].MinTokens)
}
func TestPricingToResponse_TimePricing(t *testing.T) {
got := pricingToResponse(&service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
TimePricing: &service.ChannelTimePricing{
Timezone: "Asia/Shanghai",
WeekdaysOnly: true,
Periods: []service.ChannelTimePricingPeriod{{
StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25,
}},
},
})
require.NotNil(t, got.TimePricing)
require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone)
require.True(t, got.TimePricing.WeekdaysOnly)
require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier)
}
func TestPricingToResponse_TimePricingNil(t *testing.T) {
got := pricingToResponse(&service.ChannelModelPricing{})
require.Nil(t, got.TimePricing)
}
// ---------------------------------------------------------------------------
// 3. SyncPricingModels handler
// ---------------------------------------------------------------------------
@@ -459,7 +542,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
svc := service.NewPricingService(nil, nil)
router := setupSyncPricingModelsRouter(svc)
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity"} {
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -39,10 +40,10 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"required,max=500"`
APIKey string `json:"api_key" binding:"required,max=2000"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
PrimaryModel string `json:"primary_model" binding:"max=200"`
ExtraModels []string `json:"extra_models"`
GroupName string `json:"group_name" binding:"max=100"`
@@ -53,11 +54,17 @@ type channelMonitorCreateRequest struct {
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
BodyOverride map[string]any `json:"body_override"`
// CheckMode: probe(默认)/ quota / quota_probe。quota 模式 endpoint/api_key
// 可空(条件必填校验在 service 层按模式分支)。
CheckMode string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
// AccountID: 配额模式关联的账号 ID。
AccountID *int64 `json:"account_id"`
}
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -72,6 +79,10 @@ type channelMonitorUpdateRequest struct {
ExtraHeaders *map[string]string `json:"extra_headers"`
BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
BodyOverride *map[string]any `json:"body_override"`
// CheckMode/AccountID:nil = 不更新;AccountID 指向 0 = 清空关联。
CheckMode *string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
AccountID *int64 `json:"account_id"`
}
type channelMonitorResponse struct {
@@ -101,25 +112,33 @@ type channelMonitorResponse struct {
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode"`
BodyOverride map[string]any `json:"body_override"`
// 配额模式:check_mode + 关联账号 + 主模型最近配额快照
// (LatestQuota 由 List handler 批量聚合后填充;管理端不受 channel_monitor_show_quota 影响)。
CheckMode string `json:"check_mode"`
AccountID *int64 `json:"account_id"`
LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
}
type channelMonitorCheckResultResponse struct {
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
}
type channelMonitorHistoryItemResponse struct {
ID int64 `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
ID int64 `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
}
// maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。
@@ -163,7 +182,10 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
ExtraHeaders: headers,
BodyOverrideMode: m.BodyOverrideMode,
BodyOverride: m.BodyOverride,
// PrimaryStatus / PrimaryLatencyMs / Availability7d 由 List handler 在批量聚合后填充。
CheckMode: m.CheckMode,
AccountID: m.AccountID,
// PrimaryStatus / PrimaryLatencyMs / Availability7d / LatestQuota
// 由 List handler 在批量聚合后填充。
}
if m.LastCheckedAt != nil {
s := m.LastCheckedAt.UTC().Format(time.RFC3339)
@@ -180,6 +202,7 @@ func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResp
PingLatencyMs: r.PingLatencyMs,
Message: r.Message,
CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339),
Quota: r.Quota,
}
}
@@ -192,6 +215,7 @@ func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonito
PingLatencyMs: e.PingLatencyMs,
Message: e.Message,
CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339),
Quota: e.Quota,
}
}
@@ -270,6 +294,7 @@ func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorSta
resp.PrimaryStatus = summary.PrimaryStatus
resp.PrimaryLatencyMs = summary.PrimaryLatencyMs
resp.Availability7d = summary.Availability7d
resp.LatestQuota = summary.LatestQuota
resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels))
for _, e := range summary.ExtraModels {
resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{
@@ -327,6 +352,8 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
ExtraHeaders: req.ExtraHeaders,
BodyOverrideMode: req.BodyOverrideMode,
BodyOverride: req.BodyOverride,
CheckMode: req.CheckMode,
AccountID: req.AccountID,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -421,6 +448,8 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
ExtraHeaders: req.ExtraHeaders,
BodyOverrideMode: req.BodyOverrideMode,
BodyOverride: req.BodyOverride,
CheckMode: req.CheckMode,
AccountID: req.AccountID,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -0,0 +1,69 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// CNProviderHandler 暴露国产供应商(kimi/zhipu/deepseek)的额度与余额查询端点。
//
// - GET /admin/cn-providers/accounts/:id/quota Coding Plan 滚动窗口用量(kimi/zhipu)
// - GET /admin/cn-providers/accounts/:id/balance payg 账号余额(kimi/deepseek)
//
// 智谱(zhipu)无余额端点,故同一账号仅 quota 或 balance 其一可用:服务端按账号
// platform + account_mode 校验并返回明确错误(见 CNProvider*Service 的 load*Account)。
type CNProviderHandler struct {
quotaService *service.CNProviderQuotaService
balanceService *service.CNProviderBalanceService
}
func NewCNProviderHandler(
quotaService *service.CNProviderQuotaService,
balanceService *service.CNProviderBalanceService,
) *CNProviderHandler {
return &CNProviderHandler{
quotaService: quotaService,
balanceService: balanceService,
}
}
// QueryQuota 查询 Coding Plan 滚动窗口用量(5h + weekly)。
func (h *CNProviderHandler) QueryQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h == nil || h.quotaService == nil {
response.BadRequest(c, "cn provider quota service is not enabled")
return
}
result, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// QueryBalance 查询 payg 账号余额。
func (h *CNProviderHandler) QueryBalance(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h == nil || h.balanceService == nil {
response.BadRequest(c, "cn provider balance service is not enabled")
return
}
result, err := h.balanceService.QueryBalance(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
@@ -64,6 +64,18 @@ func parseTimeRange(c *gin.Context) (time.Time, time.Time) {
return startTime, endTime
}
func parseOptionalBoolDashboardFilter(c *gin.Context, name string) (*bool, error) {
raw := strings.TrimSpace(c.Query(name))
if raw == "" {
return nil, nil
}
value, err := strconv.ParseBool(raw)
if err != nil {
return nil, err
}
return &value, nil
}
// GetStats handles getting dashboard statistics
// GET /api/v1/admin/dashboard/stats
func (h *DashboardHandler) GetStats(c *gin.Context) {
@@ -200,6 +212,7 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -249,8 +262,18 @@ func (h *DashboardHandler) GetUsageTrend(c *gin.Context) {
return
}
}
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
if err != nil {
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
return
}
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
trend, hit, err := h.getUsageTrendCached(c.Request.Context(), startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get usage trend")
return
@@ -277,6 +300,7 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -330,8 +354,18 @@ func (h *DashboardHandler) GetModelStats(c *gin.Context) {
return
}
}
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
if err != nil {
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
return
}
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, billingType)
stats, hit, err := h.getModelStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, modelSource, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get model statistics")
return
@@ -355,6 +389,7 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
var requestType *int16
var stream *bool
var billingType *int8
var upstreamModelMismatch *bool
if userIDStr := c.Query("user_id"); userIDStr != "" {
if id, err := strconv.ParseInt(userIDStr, 10, 64); err == nil {
@@ -401,8 +436,18 @@ func (h *DashboardHandler) GetGroupStats(c *gin.Context) {
return
}
}
nativeCompactionV2, err := parseOptionalBoolDashboardFilter(c, "native_compaction_v2")
if err != nil {
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
return
}
upstreamModelMismatch, err = parseOptionalBoolDashboardFilter(c, "upstream_model_mismatch")
if err != nil {
response.BadRequest(c, "Invalid upstream_model_mismatch value, use true or false")
return
}
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
stats, hit, err := h.getGroupStatsCached(c.Request.Context(), startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, nativeCompactionV2, billingType, upstreamModelMismatch)
if err != nil {
response.Error(c, 500, "Failed to get group statistics")
return
@@ -671,6 +716,14 @@ func (h *DashboardHandler) GetUserBreakdown(c *gin.Context) {
dim.Stream = &s
}
}
if v := strings.TrimSpace(c.Query("native_compaction_v2")); v != "" {
value, err := strconv.ParseBool(v)
if err != nil {
response.BadRequest(c, "Invalid native_compaction_v2 value, use true or false")
return
}
dim.NativeCompactionV2 = &value
}
if v := c.Query("billing_type"); v != "" {
if bt, err := strconv.ParseInt(v, 10, 8); err == nil {
btVal := int8(bt)
@@ -15,13 +15,32 @@ import (
type dashboardUsageRepoCapture struct {
service.UsageLogRepository
trendRequestType *int16
trendStream *bool
modelRequestType *int16
modelStream *bool
rankingLimit int
ranking []usagestats.UserSpendingRankingItem
rankingTotal float64
trendRequestType *int16
trendStream *bool
trendNativeCompaction *bool
modelRequestType *int16
modelStream *bool
modelNativeCompaction *bool
groupNativeCompaction *bool
trendMismatch *bool
modelMismatch *bool
groupMismatch *bool
rankingLimit int
ranking []usagestats.UserSpendingRankingItem
rankingTotal float64
}
func (s *dashboardUsageRepoCapture) GetUsageTrendWithUsageFilters(
ctx context.Context,
startTime, endTime time.Time,
granularity string,
filters usagestats.UsageLogFilters,
) ([]usagestats.TrendDataPoint, error) {
s.trendRequestType = filters.RequestType
s.trendStream = filters.Stream
s.trendNativeCompaction = filters.NativeCompactionV2
s.trendMismatch = filters.UpstreamModelMismatch
return []usagestats.TrendDataPoint{}, nil
}
func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
@@ -39,6 +58,29 @@ func (s *dashboardUsageRepoCapture) GetUsageTrendWithFilters(
return []usagestats.TrendDataPoint{}, nil
}
func (s *dashboardUsageRepoCapture) GetModelStatsWithUsageFiltersBySource(
ctx context.Context,
startTime, endTime time.Time,
filters usagestats.UsageLogFilters,
source string,
) ([]usagestats.ModelStat, error) {
s.modelRequestType = filters.RequestType
s.modelStream = filters.Stream
s.modelNativeCompaction = filters.NativeCompactionV2
s.modelMismatch = filters.UpstreamModelMismatch
return []usagestats.ModelStat{}, nil
}
func (s *dashboardUsageRepoCapture) GetGroupStatsWithUsageFilters(
ctx context.Context,
startTime, endTime time.Time,
filters usagestats.UsageLogFilters,
) ([]usagestats.GroupStat, error) {
s.groupNativeCompaction = filters.NativeCompactionV2
s.groupMismatch = filters.UpstreamModelMismatch
return []usagestats.GroupStat{}, nil
}
func (s *dashboardUsageRepoCapture) GetModelStatsWithFilters(
ctx context.Context,
startTime, endTime time.Time,
@@ -73,6 +115,7 @@ func newDashboardRequestTypeTestRouter(repo *dashboardUsageRepoCapture) *gin.Eng
router := gin.New()
router.GET("/admin/dashboard/trend", handler.GetUsageTrend)
router.GET("/admin/dashboard/models", handler.GetModelStats)
router.GET("/admin/dashboard/groups", handler.GetGroupStats)
router.GET("/admin/dashboard/users-ranking", handler.GetUserSpendingRanking)
return router
}
@@ -171,6 +214,88 @@ func TestDashboardModelStatsValidModelSource(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
}
func TestDashboardNativeCompactionFilterPropagatesAlongsideTransport(t *testing.T) {
resetDashboardReadCachesForTest()
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?request_type=stream&native_compaction_v2=true",
"/admin/dashboard/models?request_type=stream&native_compaction_v2=true",
"/admin/dashboard/groups?request_type=stream&native_compaction_v2=true",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, path)
}
require.NotNil(t, repo.trendNativeCompaction)
require.True(t, *repo.trendNativeCompaction)
require.NotNil(t, repo.modelNativeCompaction)
require.True(t, *repo.modelNativeCompaction)
require.NotNil(t, repo.groupNativeCompaction)
require.True(t, *repo.groupNativeCompaction)
require.NotNil(t, repo.trendRequestType)
require.Equal(t, int16(service.RequestTypeStream), *repo.trendRequestType)
}
func TestDashboardNativeCompactionFilterRejectsInvalidBoolean(t *testing.T) {
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?native_compaction_v2=invalid",
"/admin/dashboard/models?native_compaction_v2=invalid",
"/admin/dashboard/groups?native_compaction_v2=invalid",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, path)
}
}
func TestDashboardModelAuditFilterPropagatesToTrendModelAndGroupQueries(t *testing.T) {
resetDashboardReadCachesForTest()
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?upstream_model_mismatch=true",
"/admin/dashboard/models?upstream_model_mismatch=true",
"/admin/dashboard/groups?upstream_model_mismatch=true",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code, path)
}
require.NotNil(t, repo.trendMismatch)
require.True(t, *repo.trendMismatch)
require.NotNil(t, repo.modelMismatch)
require.True(t, *repo.modelMismatch)
require.NotNil(t, repo.groupMismatch)
require.True(t, *repo.groupMismatch)
}
func TestDashboardModelAuditFilterRejectsInvalidBoolean(t *testing.T) {
repo := &dashboardUsageRepoCapture{}
router := newDashboardRequestTypeTestRouter(repo)
for _, path := range []string{
"/admin/dashboard/trend?upstream_model_mismatch=invalid",
"/admin/dashboard/models?upstream_model_mismatch=invalid",
"/admin/dashboard/groups?upstream_model_mismatch=invalid",
} {
req := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code, path)
}
}
func TestDashboardUsersRankingLimitAndCache(t *testing.T) {
dashboardUsersRankingCache = newSnapshotCache(5 * time.Minute)
repo := &dashboardUsageRepoCapture{
@@ -18,30 +18,34 @@ var (
)
type dashboardTrendCacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
NativeCompactionV2 *bool `json:"native_compaction_v2"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
type dashboardModelGroupCacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
ModelSource string `json:"model_source,omitempty"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
ModelSource string `json:"model_source,omitempty"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
NativeCompactionV2 *bool `json:"native_compaction_v2"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
}
type dashboardEntityTrendCacheKey struct {
@@ -83,23 +87,31 @@ func (h *DashboardHandler) getUsageTrendCached(
model string,
requestType *int16,
stream *bool,
nativeCompactionV2 *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.TrendDataPoint, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardTrendCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
Model: model,
RequestType: requestType,
Stream: stream,
NativeCompactionV2: nativeCompactionV2,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardTrendCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetUsageTrendWithFilters(ctx, startTime, endTime, granularity, userID, apiKeyID, accountID, groupID, model, requestType, stream, billingType)
return h.dashboardService.GetUsageTrendWithUsageFilters(ctx, startTime, endTime, granularity, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
Model: model, RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
})
if err != nil {
return nil, hit, err
@@ -115,22 +127,30 @@ func (h *DashboardHandler) getModelStatsCached(
modelSource string,
requestType *int16,
stream *bool,
nativeCompactionV2 *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.ModelStat, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
ModelSource: usagestats.NormalizeModelSource(modelSource),
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
ModelSource: usagestats.NormalizeModelSource(modelSource),
RequestType: requestType,
Stream: stream,
NativeCompactionV2: nativeCompactionV2,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardModelStatsCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetModelStatsWithFiltersBySource(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType, modelSource)
return h.dashboardService.GetModelStatsWithUsageFiltersBySource(ctx, startTime, endTime, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
}, modelSource)
})
if err != nil {
return nil, hit, err
@@ -145,21 +165,29 @@ func (h *DashboardHandler) getGroupStatsCached(
userID, apiKeyID, accountID, groupID int64,
requestType *int16,
stream *bool,
nativeCompactionV2 *bool,
billingType *int8,
upstreamModelMismatch *bool,
) ([]usagestats.GroupStat, bool, error) {
key := mustMarshalDashboardCacheKey(dashboardModelGroupCacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestType: requestType,
Stream: stream,
BillingType: billingType,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
UserID: userID,
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestType: requestType,
Stream: stream,
NativeCompactionV2: nativeCompactionV2,
BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
entry, hit, err := dashboardGroupStatsCache.GetOrLoad(key, func() (any, error) {
return h.dashboardService.GetGroupStatsWithFilters(ctx, startTime, endTime, userID, apiKeyID, accountID, groupID, requestType, stream, billingType)
return h.dashboardService.GetGroupStatsWithUsageFilters(ctx, startTime, endTime, usagestats.UsageLogFilters{
UserID: userID, APIKeyID: apiKeyID, AccountID: accountID, GroupID: groupID,
RequestType: requestType, Stream: stream, NativeCompactionV2: nativeCompactionV2, BillingType: billingType,
UpstreamModelMismatch: upstreamModelMismatch,
})
})
if err != nil {
return nil, hit, err
@@ -37,34 +37,38 @@ type dashboardSnapshotV2Response struct {
}
type dashboardSnapshotV2Filters struct {
UserID int64
APIKeyID int64
AccountID int64
GroupID int64
Model string
RequestType *int16
Stream *bool
BillingType *int8
UserID int64
APIKeyID int64
AccountID int64
GroupID int64
Model string
RequestType *int16
Stream *bool
NativeCompactionV2 *bool
BillingType *int8
UpstreamModelMismatch *bool
}
type dashboardSnapshotV2CacheKey struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
BillingType *int8 `json:"billing_type"`
IncludeStats bool `json:"include_stats"`
IncludeTrend bool `json:"include_trend"`
IncludeModels bool `json:"include_models"`
IncludeGroups bool `json:"include_groups"`
IncludeUsersTrend bool `json:"include_users_trend"`
UsersTrendLimit int `json:"users_trend_limit"`
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Granularity string `json:"granularity"`
UserID int64 `json:"user_id"`
APIKeyID int64 `json:"api_key_id"`
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Model string `json:"model"`
RequestType *int16 `json:"request_type"`
Stream *bool `json:"stream"`
NativeCompactionV2 *bool `json:"native_compaction_v2"`
BillingType *int8 `json:"billing_type"`
UpstreamModelMismatch *bool `json:"upstream_model_mismatch"`
IncludeStats bool `json:"include_stats"`
IncludeTrend bool `json:"include_trend"`
IncludeModels bool `json:"include_models"`
IncludeGroups bool `json:"include_groups"`
IncludeUsersTrend bool `json:"include_users_trend"`
UsersTrendLimit int `json:"users_trend_limit"`
}
func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
@@ -93,23 +97,25 @@ func (h *DashboardHandler) GetSnapshotV2(c *gin.Context) {
}
keyRaw, _ := json.Marshal(dashboardSnapshotV2CacheKey{
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
RequestType: filters.RequestType,
Stream: filters.Stream,
BillingType: filters.BillingType,
IncludeStats: includeStats,
IncludeTrend: includeTrend,
IncludeModels: includeModels,
IncludeGroups: includeGroups,
IncludeUsersTrend: includeUsersTrend,
UsersTrendLimit: usersTrendLimit,
StartTime: startTime.UTC().Format(time.RFC3339),
EndTime: endTime.UTC().Format(time.RFC3339),
Granularity: granularity,
UserID: filters.UserID,
APIKeyID: filters.APIKeyID,
AccountID: filters.AccountID,
GroupID: filters.GroupID,
Model: filters.Model,
RequestType: filters.RequestType,
Stream: filters.Stream,
NativeCompactionV2: filters.NativeCompactionV2,
BillingType: filters.BillingType,
UpstreamModelMismatch: filters.UpstreamModelMismatch,
IncludeStats: includeStats,
IncludeTrend: includeTrend,
IncludeModels: includeModels,
IncludeGroups: includeGroups,
IncludeUsersTrend: includeUsersTrend,
UsersTrendLimit: usersTrendLimit,
})
cacheKey := string(keyRaw)
@@ -183,7 +189,9 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.Model,
filters.RequestType,
filters.Stream,
filters.NativeCompactionV2,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get usage trend")
@@ -203,7 +211,9 @@ func (h *DashboardHandler) buildSnapshotV2Response(
usagestats.ModelSourceRequested,
filters.RequestType,
filters.Stream,
filters.NativeCompactionV2,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get model statistics")
@@ -222,7 +232,9 @@ func (h *DashboardHandler) buildSnapshotV2Response(
filters.GroupID,
filters.RequestType,
filters.Stream,
filters.NativeCompactionV2,
filters.BillingType,
filters.UpstreamModelMismatch,
)
if err != nil {
return nil, errors.New("failed to get group statistics")
@@ -290,6 +302,14 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
filters.Stream = &streamVal
}
if nativeCompactionV2Str := strings.TrimSpace(c.Query("native_compaction_v2")); nativeCompactionV2Str != "" {
value, err := strconv.ParseBool(nativeCompactionV2Str)
if err != nil {
return nil, err
}
filters.NativeCompactionV2 = &value
}
if billingTypeStr := strings.TrimSpace(c.Query("billing_type")); billingTypeStr != "" {
v, err := strconv.ParseInt(billingTypeStr, 10, 8)
if err != nil {
@@ -299,5 +319,13 @@ func parseDashboardSnapshotV2Filters(c *gin.Context) (*dashboardSnapshotV2Filter
filters.BillingType = &bt
}
if mismatchStr := strings.TrimSpace(c.Query("upstream_model_mismatch")); mismatchStr != "" {
value, err := strconv.ParseBool(mismatchStr)
if err != nil {
return nil, err
}
filters.UpstreamModelMismatch = &value
}
return filters, nil
}
@@ -13,6 +13,7 @@ import (
const (
grokImportProbeConcurrency = 3
grokImportProbeTimeout = 25 * time.Second
grokImportProbeQueueLimit = 64
)
type grokImportProber interface {
@@ -27,6 +28,8 @@ type grokImportProbeTask struct {
type grokImportProbeScheduler struct {
mu sync.Mutex
queue []grokImportProbeTask
pending map[int64]struct{}
inFlight map[int64]struct{}
concurrency int
workers int
maxWorkers int
@@ -48,6 +51,8 @@ func newGrokImportProbeScheduler(concurrency int, timeout time.Duration) *grokIm
return &grokImportProbeScheduler{
concurrency: concurrency,
timeout: timeout,
pending: make(map[int64]struct{}),
inFlight: make(map[int64]struct{}),
}
}
@@ -60,7 +65,21 @@ func (s *grokImportProbeScheduler) schedule(prober grokImportProber, account *se
}
s.mu.Lock()
if _, exists := s.pending[account.ID]; exists {
s.mu.Unlock()
return
}
if _, exists := s.inFlight[account.ID]; exists {
s.mu.Unlock()
return
}
if len(s.queue) >= grokImportProbeQueueLimit {
s.mu.Unlock()
slog.Debug("grok_import_active_probe_dropped", "account_id", account.ID, "reason", "queue_full")
return
}
s.queue = append(s.queue, grokImportProbeTask{prober: prober, accountID: account.ID})
s.pending[account.ID] = struct{}{}
if s.workers < s.concurrency {
s.workers++
if s.workers > s.maxWorkers {
@@ -78,6 +97,7 @@ func (s *grokImportProbeScheduler) worker() {
return
}
s.run(task.prober, task.accountID)
s.finish(task.accountID)
}
}
@@ -94,9 +114,17 @@ func (s *grokImportProbeScheduler) nextTask() (grokImportProbeTask, bool) {
if len(s.queue) == 0 {
s.queue = nil
}
delete(s.pending, task.accountID)
s.inFlight[task.accountID] = struct{}{}
return task, true
}
func (s *grokImportProbeScheduler) finish(accountID int64) {
s.mu.Lock()
delete(s.inFlight, accountID)
s.mu.Unlock()
}
func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64) {
defer func() {
if recovered := recover(); recovered != nil {
@@ -108,8 +136,6 @@ func (s *grokImportProbeScheduler) run(prober grokImportProber, accountID int64)
}
}()
// Queue time is intentionally excluded: every imported account is probed,
// while this timeout only bounds the actual upstream probe execution.
ctx, cancel := context.WithTimeout(context.Background(), s.timeout)
defer cancel()
result, err := prober.QueryQuota(ctx, accountID)
@@ -4,6 +4,7 @@ package admin
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
@@ -62,6 +63,10 @@ func (grokImportOAuthClientStub) RefreshToken(context.Context, string, string, s
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
}
func (grokImportOAuthClientStub) LoginWithPassword(context.Context, string, string, string) (*service.GrokPasswordLoginResult, error) {
return nil, errors.New("unexpected password login")
}
func (grokImportOAuthClientStub) ConvertSSOToBuild(context.Context, string, string) (*xai.TokenResponse, error) {
return &xai.TokenResponse{AccessToken: "access-token", RefreshToken: "refresh-token", ExpiresIn: 3600}, nil
}
@@ -142,7 +142,7 @@ func TestGrokImportProbeSchedulerProbesSingleAccountOnce(t *testing.T) {
}
func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.T) {
const taskCount = 100
const taskCount = 50
release := make(chan struct{})
scheduler := newGrokImportProbeScheduler(3, time.Second)
prober := newGrokImportProbeStub(taskCount)
@@ -156,7 +156,7 @@ func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.
awaitGrokProbeSignal(t, prober.started)
}
snapshot := snapshotGrokImportProbeScheduler(scheduler)
require.Equal(t, 97, snapshot.queued)
require.Equal(t, taskCount-3, snapshot.queued)
require.Equal(t, 3, snapshot.workers)
require.Equal(t, 3, snapshot.maxWorkers)
select {
@@ -182,6 +182,55 @@ func TestGrokImportProbeSchedulerQueuesBatchWithoutPerTaskGoroutines(t *testing.
require.Equal(t, 3, snapshot.maxWorkers)
}
func TestGrokImportProbeSchedulerDeduplicatesPendingAndInFlightAccounts(t *testing.T) {
scheduler := newGrokImportProbeScheduler(1, time.Second)
prober := newGrokImportProbeStub(2)
release := make(chan struct{})
prober.block = release
account := newGrokOAuthImportAccount(501)
queued := newGrokOAuthImportAccount(502)
scheduler.schedule(prober, account)
require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.started))
scheduler.schedule(prober, account)
scheduler.schedule(prober, queued)
scheduler.schedule(prober, queued)
scheduler.mu.Lock()
require.Len(t, scheduler.queue, 1)
require.Contains(t, scheduler.inFlight, int64(501))
require.Contains(t, scheduler.pending, int64(502))
scheduler.mu.Unlock()
close(release)
require.Equal(t, int64(501), awaitGrokProbeSignal(t, prober.done))
require.Equal(t, int64(502), awaitGrokProbeSignal(t, prober.done))
calls, _, _ := prober.snapshot()
require.Equal(t, 1, calls[501])
require.Equal(t, 1, calls[502])
}
func TestGrokImportProbeSchedulerBoundsPendingQueue(t *testing.T) {
scheduler := newGrokImportProbeScheduler(1, time.Second)
prober := newGrokImportProbeStub(grokImportProbeQueueLimit + 1)
release := make(chan struct{})
prober.block = release
scheduler.schedule(prober, newGrokOAuthImportAccount(600))
require.Equal(t, int64(600), awaitGrokProbeSignal(t, prober.started))
for id := int64(601); id < 601+grokImportProbeQueueLimit+10; id++ {
scheduler.schedule(prober, newGrokOAuthImportAccount(id))
}
scheduler.mu.Lock()
require.Len(t, scheduler.queue, grokImportProbeQueueLimit)
scheduler.mu.Unlock()
close(release)
for i := 0; i < grokImportProbeQueueLimit+1; i++ {
awaitGrokProbeSignal(t, prober.done)
}
}
func TestGrokImportProbeSchedulerTimeoutCancelsProbe(t *testing.T) {
neverRelease := make(chan struct{})
scheduler := newGrokImportProbeScheduler(1, 20*time.Millisecond)
@@ -47,6 +47,10 @@ type GrokGenerateAuthURLRequest struct {
RedirectURI string `json:"redirect_uri"`
}
func (h *GrokOAuthHandler) GetCapabilities(c *gin.Context) {
response.Success(c, h.grokOAuthService.GetCapabilities())
}
func (h *GrokOAuthHandler) GenerateAuthURL(c *gin.Context) {
var req GrokGenerateAuthURLRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -95,6 +99,17 @@ type GrokRefreshTokenRequest struct {
ProxyID *int64 `json:"proxy_id"`
}
type GrokSSOTokenRequest struct {
SSOToken string `json:"sso_token"`
ProxyID *int64 `json:"proxy_id"`
}
type GrokPasswordAuthorizeRequest struct {
Email string `json:"email"`
Password string `json:"password"`
ProxyID *int64 `json:"proxy_id"`
}
func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
var req GrokRefreshTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -113,9 +128,15 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
var proxyURL string
if req.ProxyID != nil {
proxy, err := h.adminService.GetProxy(c.Request.Context(), *req.ProxyID)
if err == nil && proxy != nil {
proxyURL = proxy.URL()
if err != nil {
response.ErrorFrom(c, err)
return
}
if proxy == nil {
response.BadRequest(c, "GROK_OAUTH_PROXY_NOT_FOUND: proxy not found")
return
}
proxyURL = proxy.URL()
}
tokenInfo, err := h.grokOAuthService.RefreshToken(c.Request.Context(), refreshToken, proxyURL, req.ClientID)
if err != nil {
@@ -125,6 +146,38 @@ func (h *GrokOAuthHandler) RefreshToken(c *gin.Context) {
response.Success(c, tokenInfo)
}
// ValidateSSOToken converts a Web SSO cookie into Build OAuth tokens.
// Response contains OAuth token info only — never echoes sso_token.
func (h *GrokOAuthHandler) ValidateSSOToken(c *gin.Context) {
var req GrokSSOTokenRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.grokOAuthService.ValidateSSOToken(c.Request.Context(), req.SSOToken, req.ProxyID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}
// AuthorizePassword exchanges email/password for Build OAuth tokens via SSO conversion.
// Response never includes password or raw sso_token.
func (h *GrokOAuthHandler) AuthorizePassword(c *gin.Context) {
var req GrokPasswordAuthorizeRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
tokenInfo, err := h.grokOAuthService.AuthorizePassword(c.Request.Context(), req.Email, req.Password, req.ProxyID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, tokenInfo)
}
func (h *GrokOAuthHandler) RefreshAccountToken(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
@@ -412,11 +465,38 @@ func (h *GrokOAuthHandler) createAccountFromSSOToken(ctx context.Context, req Gr
// 配置且 Build 恒写官方地址,会吞掉导入时指定的自定义转发地址——与
// RefreshAccountToken 的保留逻辑对齐,请求显式提供时以请求为准。
func grokSSOImportCredentials(built map[string]any, reqCredentials map[string]any) map[string]any {
credentials := service.MergeCredentials(cloneGrokSSOMap(reqCredentials), built)
// Only merge operator config from the request — never free-form secrets
// (password / sso_token / cookie / etc.) into stored credentials.
allowedReqKeys := map[string]struct{}{
"base_url": {}, "model_mapping": {},
"header_override": {}, "header_overrides": {}, "header_override_enabled": {},
"custom_headers": {},
}
ops := map[string]any{}
for k, v := range reqCredentials {
if _, ok := allowedReqKeys[k]; !ok {
continue
}
if service.IsSensitiveCredentialKey(k) {
continue
}
ops[k] = v
}
credentials := service.MergeCredentials(ops, built)
// Strip any sensitive keys that might have slipped in via older callers.
for k := range credentials {
if service.IsSensitiveCredentialKey(k) {
// Keep only keys produced by BuildAccountCredentials (tokens).
if k == "access_token" || k == "refresh_token" || k == "id_token" {
continue
}
delete(credentials, k)
}
}
if reqBaseURL, ok := reqCredentials["base_url"].(string); ok && strings.TrimSpace(reqBaseURL) != "" {
credentials["base_url"] = strings.TrimSpace(reqBaseURL)
}
return credentials
return service.SanitizeStoredCredentials(service.PlatformGrok, credentials)
}
func grokSSOImportExpiry(requestExpiresAt *int64, requestAutoPause *bool, tokenInfo *service.GrokTokenInfo) (*int64, *bool) {
@@ -535,8 +615,10 @@ func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) {
response.BadRequest(c, "grok quota service is not enabled")
return
}
// ResetQuota 恒返回 GROK_QUOTA_RESET_UNSUPPORTED(xAI 无 OAuth 配额重置接口),err != nil 恒真为预期。
//nolint:staticcheck // SA4023
result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID)
if err != nil {
if err != nil { //nolint:staticcheck // SA4023
response.ErrorFrom(c, err)
return
}
@@ -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) {
+104 -78
View File
@@ -8,11 +8,11 @@ import (
"log/slog"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/platform/liveattestation"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -81,8 +81,8 @@ func (f optionalLimitField) ToServiceInput() *float64 {
if f.value != nil {
return f.value
}
zero := 0.0
return &zero
unlimited := -1.0
return &unlimited
}
// NewGroupHandler creates a new admin group handler
@@ -96,41 +96,48 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
// CreateGroupRequest represents create group request
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
ModelPricing []service.ChannelModelPricing `json:"model_pricing"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
AllowImageGeneration bool `json:"allow_image_generation"`
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
AllowImageGeneration bool `json:"allow_image_generation"`
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
SearchPricePer1k *float64 `json:"search_price_per_1k"`
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
ClaudeCodeOnly bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled bool `json:"model_routing_enabled"`
@@ -157,42 +164,49 @@ type CreateGroupRequest struct {
// UpdateGroupRequest represents update group request
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
LongContextPricingEnabled *bool `json:"long_context_pricing_enabled"`
ModelPricing *[]service.ChannelModelPricing `json:"model_pricing"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
AllowImageGeneration *bool `json:"allow_image_generation"`
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
ImageRateIndependent *bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent *bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled *bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled *bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
ClaudeCodeOnly *bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
AllowImageGeneration *bool `json:"allow_image_generation"`
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
ImageRateIndependent *bool `json:"image_rate_independent"`
ImageRateMultiplier *float64 `json:"image_rate_multiplier"`
BatchImageDiscountMultiplier *float64 `json:"batch_image_discount_multiplier"`
BatchImageHoldMultiplier *float64 `json:"batch_image_hold_multiplier"`
VideoRateIndependent *bool `json:"video_rate_independent"`
VideoRateMultiplier *float64 `json:"video_rate_multiplier"`
PeakRateEnabled *bool `json:"peak_rate_enabled"`
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled *bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
VideoPrice480P *float64 `json:"video_price_480p"`
VideoPrice720P *float64 `json:"video_price_720p"`
VideoPrice1080P *float64 `json:"video_price_1080p"`
VideoModelPrices map[string]map[string]float64 `json:"video_model_prices,omitempty"`
WebSearchPricePerCall *float64 `json:"web_search_price_per_call"`
SearchPricePer1k *float64 `json:"search_price_per_1k"`
AudioRealtimePricePerMin *float64 `json:"audio_realtime_price_per_min"`
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars"`
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour"`
ClaudeCodeOnly *bool `json:"claude_code_only"`
FallbackGroupID *int64 `json:"fallback_group_id"`
FallbackGroupIDOnInvalidRequest *int64 `json:"fallback_group_id_on_invalid_request"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled *bool `json:"model_routing_enabled"`
@@ -220,7 +234,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -498,6 +512,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
LongContextPricingEnabled: req.LongContextPricingEnabled,
ModelPricing: req.ModelPricing,
AllowImageGeneration: req.AllowImageGeneration,
AllowBatchImageGeneration: req.AllowBatchImageGeneration,
ImageRateIndependent: req.ImageRateIndependent,
@@ -519,7 +535,12 @@ func (h *GroupHandler) Create(c *gin.Context) {
VideoPrice480P: req.VideoPrice480P,
VideoPrice720P: req.VideoPrice720P,
VideoPrice1080P: req.VideoPrice1080P,
VideoModelPrices: req.VideoModelPrices,
WebSearchPricePerCall: req.WebSearchPricePerCall,
SearchPricePer1k: req.SearchPricePer1k,
AudioRealtimePricePerMin: req.AudioRealtimePricePerMin,
AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars,
AudioSTTPricePerHour: req.AudioSttPricePerHour,
ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
@@ -620,6 +641,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
LongContextPricingEnabled: req.LongContextPricingEnabled,
ModelPricing: req.ModelPricing,
AllowImageGeneration: req.AllowImageGeneration,
AllowBatchImageGeneration: req.AllowBatchImageGeneration,
ImageRateIndependent: req.ImageRateIndependent,
@@ -641,7 +664,12 @@ func (h *GroupHandler) Update(c *gin.Context) {
VideoPrice480P: req.VideoPrice480P,
VideoPrice720P: req.VideoPrice720P,
VideoPrice1080P: req.VideoPrice1080P,
VideoModelPrices: req.VideoModelPrices,
WebSearchPricePerCall: req.WebSearchPricePerCall,
SearchPricePer1k: req.SearchPricePer1k,
AudioRealtimePricePerMin: req.AudioRealtimePricePerMin,
AudioTTSPricePerMillionChars: req.AudioTtsPricePerMillionChars,
AudioSTTPricePerHour: req.AudioSttPricePerHour,
ClaudeCodeOnly: req.ClaudeCodeOnly,
FallbackGroupID: req.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: req.FallbackGroupIDOnInvalidRequest,
@@ -706,12 +734,10 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
_ = groupID // TODO: implement actual stats
}
// GetUsageSummary returns today's and cumulative cost for all groups.
// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai
// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
// GET /api/v1/admin/groups/usage-summary
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
userTZ := c.Query("timezone")
now := timezone.NowInUserLocation(userTZ)
todayStart := timezone.StartOfDayInUserLocation(now, userTZ)
todayStart := service.GroupUsageTodayStart(time.Now())
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
if err != nil {
@@ -0,0 +1,33 @@
package admin
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestUpdateGroupRequestLimitFieldsTriState(t *testing.T) {
t.Run("omitted means unchanged", func(t *testing.T) {
var req UpdateGroupRequest
require.NoError(t, json.Unmarshal([]byte(`{}`), &req))
require.Nil(t, req.DailyLimitUSD.ToServiceInput())
require.Nil(t, req.WeeklyLimitUSD.ToServiceInput())
require.Nil(t, req.MonthlyLimitUSD.ToServiceInput())
})
t.Run("null means unlimited", func(t *testing.T) {
var req UpdateGroupRequest
require.NoError(t, json.Unmarshal([]byte(`{"daily_limit_usd":null}`), &req))
limit := req.DailyLimitUSD.ToServiceInput()
require.NotNil(t, limit)
require.Negative(t, *limit)
})
t.Run("number is preserved", func(t *testing.T) {
var req UpdateGroupRequest
require.NoError(t, json.Unmarshal([]byte(`{"weekly_limit_usd":0,"monthly_limit_usd":42.5}`), &req))
require.Equal(t, 0.0, *req.WeeklyLimitUSD.ToServiceInput())
require.Equal(t, 42.5, *req.MonthlyLimitUSD.ToServiceInput())
})
}
@@ -0,0 +1,81 @@
//go:build unit
package admin
import (
"bytes"
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
return c.ShouldBindJSON(target)
}
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
var req CreateGroupRequest
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应通过 CreateGroupRequest 校验", platform)
require.Equal(t, platform, req.Platform)
})
t.Run("update_"+platform, func(t *testing.T) {
var req UpdateGroupRequest
body := fmt.Sprintf(`{"platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应通过 UpdateGroupRequest 校验", platform)
require.Equal(t, platform, req.Platform)
})
}
}
func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
invalid := []string{
"moonshot", // 厂商别名,不是平台标识
"Kimi", // 大小写敏感
"openai ", // 尾随空格
"glm",
"bogus",
}
for _, platform := range invalid {
t.Run("create_"+platform, func(t *testing.T) {
var req CreateGroupRequest
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应被 CreateGroupRequest 拒绝", platform)
})
t.Run("update_"+platform, func(t *testing.T) {
var req UpdateGroupRequest
body := fmt.Sprintf(`{"platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应被 UpdateGroupRequest 拒绝", platform)
})
}
}
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
require.Equal(t, platform, req.TargetPlatform)
}
}
@@ -27,6 +27,7 @@ type OpenAIOAuthHandler struct {
type openAIQuotaService interface {
QueryUsage(ctx context.Context, accountID int64) (*service.OpenAIQuotaUsage, error)
CacheResetCreditsSnapshot(ctx context.Context, accountID int64, credits *service.OpenAIRateLimitResetCredits) error
CachePostResetSnapshot(ctx context.Context, accountID int64, usage *service.OpenAIQuotaUsage) error
ResetCredit(ctx context.Context, accountID int64) (*service.OpenAIQuotaResetResult, error)
}
@@ -34,12 +35,6 @@ type openAIAccountStateRecoverer interface {
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
}
const (
openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed"
openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed"
openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed"
)
// openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the
// (non-refundable) reset credit has already been consumed upstream. The whole
// request must stay comfortably inside the panel HTTP client timeout, otherwise
@@ -493,6 +488,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
service.NotifyOpenAIAutoResetCredit(accountID)
response.Success(c, usage)
}
@@ -523,6 +519,7 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) {
response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result")
return
}
service.NotifyOpenAIAutoResetCredit(accountID)
refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage}
// A failed snapshot write leaves the previous cache intact — report it as a
@@ -600,54 +597,19 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context())
defer cancelPost()
// Step 1 — unblocking the account is the whole point of consuming a credit
// (#3672 / #3740), so it runs FIRST and is never gated on the display cache.
// Recovery is DB-only and leaves the manual `schedulable` switch untouched.
if h.rateLimitService == nil {
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
response.Success(c, resetResponse)
return
postResult := service.RunOpenAIQuotaResetPostProcess(
postCtx,
accountID,
h.quotaService,
h.rateLimitService,
h.adminService.GetAccount,
)
resetResponse.Quota = postResult.Quota
resetResponse.CacheRefreshed = postResult.CacheRefreshed
resetResponse.AccountStateRecovered = postResult.AccountStateRecovered
resetResponse.WarningCode = postResult.WarningCode
if postResult.Account != nil {
resetResponse.Account = dto.AccountFromService(postResult.Account)
}
if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{
InvalidateToken: true,
}); err != nil {
// Recovery failures are almost always storage-level; the remaining steps
// share that dependency, so stop here instead of compounding the failure.
slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err)
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
response.Success(c, resetResponse)
return
}
resetResponse.AccountStateRecovered = true
// Step 2 — refresh the reset-credit display cache. A failure here is reported
// but must not hide the recovered account row produced by step 3.
usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID)
switch {
case usageErr != nil || usage == nil:
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr)
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
default:
if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil {
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
} else {
resetResponse.Quota = usage
resetResponse.CacheRefreshed = true
}
}
// Step 3 — hand back the post-recovery account row so the list drops the
// stale rate-limit badge without waiting for the next poll.
account, err := h.adminService.GetAccount(postCtx, accountID)
if err != nil {
slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err)
if resetResponse.WarningCode == "" {
resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed
}
response.Success(c, resetResponse)
return
}
resetResponse.Account = dto.AccountFromService(account)
response.Success(c, resetResponse)
}
@@ -48,6 +48,12 @@ func (s *openAIQuotaWorkflowStub) CacheResetCreditsSnapshot(ctx context.Context,
return s.cacheErr
}
func (s *openAIQuotaWorkflowStub) CachePostResetSnapshot(ctx context.Context, _ int64, _ *service.OpenAIQuotaUsage) error {
s.cacheCalls++
s.cacheCtxErr = ctx.Err()
return s.cacheErr
}
type openAIAccountStateRecovererStub struct {
err error
calls int
@@ -215,7 +221,7 @@ func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) {
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.False(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
@@ -237,7 +243,7 @@ func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) {
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.False(t, envelope.Data.AccountStateRecovered)
require.Zero(t, quota.queryCalls)
require.Zero(t, adminService.calls)
@@ -260,7 +266,7 @@ func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
@@ -285,7 +291,7 @@ func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
@@ -307,7 +313,7 @@ func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.CacheRefreshed)
require.True(t, envelope.Data.AccountStateRecovered)
require.NotNil(t, envelope.Data.Quota)
@@ -331,7 +337,7 @@ func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T)
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.Nil(t, envelope.Data.Account)
}
@@ -435,7 +441,7 @@ func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) {
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.NotNil(t, envelope.Data.Account)
require.Zero(t, quota.cacheCalls)
@@ -0,0 +1,257 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"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"
)
const pluginUISessionTTL = 30 * time.Minute
// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。
type PluginHandler struct {
manager *service.PluginManager
}
func NewPluginHandler(manager *service.PluginManager) *PluginHandler {
return &PluginHandler{manager: manager}
}
func (h *PluginHandler) List(c *gin.Context) {
plugins, err := h.manager.List(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, plugins)
}
func (h *PluginHandler) Get(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
plugin, err := h.manager.Get(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, plugin)
}
func (h *PluginHandler) Upload(c *gin.Context) {
maxBytes := h.manager.MaxUploadBytes()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
file, header, err := c.Request.FormFile("plugin")
if err != nil {
response.BadRequest(c, "请选择有效的 .s2plugin 文件")
return
}
defer func() { _ = file.Close() }()
if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") {
response.BadRequest(c, "插件包扩展名必须是 .s2plugin")
return
}
var installedBy *int64
if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
userID := subject.UserID
installedBy = &userID
}
plugin, err := h.manager.Install(c.Request.Context(), file, installedBy)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, plugin)
}
type pluginEnableRequest struct {
AcceptUntested bool `json:"accept_untested"`
RolloutPercent int `json:"rollout_percent"`
}
func (h *PluginHandler) Enable(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
request := pluginEnableRequest{RolloutPercent: 100}
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "启用参数无效")
return
}
plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, plugin)
}
func (h *PluginHandler) Disable(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
plugin, err := h.manager.Disable(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, plugin)
}
func (h *PluginHandler) Delete(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
if err := h.manager.Delete(c.Request.Context(), id); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "插件已卸载"})
}
func (h *PluginHandler) GetConfig(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
configJSON, err := h.manager.GetConfig(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON)
}
func (h *PluginHandler) SaveConfig(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
response.BadRequest(c, "插件配置必须是有效 JSON")
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
response.BadRequest(c, "插件配置只能包含一个 JSON 值")
return
}
raw, err := json.Marshal(value)
if err != nil {
response.BadRequest(c, "插件配置无法序列化")
return
}
saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw)
if err != nil {
response.BadRequest(c, err.Error())
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", saved)
}
func (h *PluginHandler) Test(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
result, err := h.manager.Test(c.Request.Context(), id)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, result)
}
func (h *PluginHandler) CreateUISession(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL)
if err != nil {
response.ErrorFrom(c, err)
return
}
bridgeToken, err := randomPluginToken()
if err != nil {
response.InternalError(c, "创建插件 UI Bridge 失败")
return
}
response.Success(c, gin.H{
"url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken),
"bridge_token": bridgeToken,
"ui_bridge_version": 1,
"expires_at": expires,
})
}
// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。
func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
token := strings.TrimSpace(c.Param("token"))
pluginID, err := h.manager.ResolveUIAssetToken(token)
if err != nil {
c.Status(http.StatusGone)
return
}
relative := strings.TrimPrefix(c.Param("path"), "/")
data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.Status(http.StatusNotFound)
return
}
c.Status(http.StatusNotFound)
return
}
contentType := mime.TypeByExtension(filepath.Ext(logicalPath))
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Cache-Control", "private, no-store")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "SAMEORIGIN")
// sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
// 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'")
c.Data(http.StatusOK, contentType, data)
}
func pluginIDParam(c *gin.Context) (int64, bool) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "插件 ID 无效")
return 0, false
}
return id, true
}
func randomPluginToken() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
@@ -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,
@@ -287,6 +288,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
MaxClaudeCodeVersion: settings.MaxClaudeCodeVersion,
AllowUngroupedKeyScheduling: settings.AllowUngroupedKeyScheduling,
BackendModeEnabled: settings.BackendModeEnabled,
OpenAITTFTMode: settings.OpenAITTFTMode,
EnableFingerprintUnification: settings.EnableFingerprintUnification,
EnableMetadataPassthrough: settings.EnableMetadataPassthrough,
EnableCCHSigning: settings.EnableCCHSigning,
@@ -370,17 +372,26 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentAlipayMobilePrecreateDeepLink: paymentCfg.AlipayMobilePrecreateDeepLink,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
GrokDefaultTextModel: settings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
ModelPlazaDescription: settings.ModelPlazaDescription,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
PluginManagementEnabled: settings.PluginManagementEnabled,
ModelPlazaDescription: settings.ModelPlazaDescription,
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")
}
@@ -449,6 +452,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.EnableFingerprintUnification != after.EnableFingerprintUnification {
changed = append(changed, "enable_fingerprint_unification")
}
if before.OpenAITTFTMode != after.OpenAITTFTMode {
changed = append(changed, "openai_ttft_mode")
}
if before.EnableMetadataPassthrough != after.EnableMetadataPassthrough {
changed = append(changed, "enable_metadata_passthrough")
}
@@ -598,6 +604,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 +820,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]
@@ -17,7 +17,14 @@ type TestSMTPRequest struct {
SMTPPort int `json:"smtp_port"`
SMTPUsername string `json:"smtp_username"`
SMTPPassword string `json:"smtp_password"`
SMTPUseTLS bool `json:"smtp_use_tls"`
SMTPUseTLS *bool `json:"smtp_use_tls"`
}
func resolveSMTPUseTLS(requested *bool, savedConfig *service.SMTPConfig) bool {
if requested != nil {
return *requested
}
return savedConfig != nil && savedConfig.UseTLS
}
// TestSMTPConnection 测试SMTP连接
@@ -64,7 +71,7 @@ func (h *SettingHandler) TestSMTPConnection(c *gin.Context) {
Port: req.SMTPPort,
Username: req.SMTPUsername,
Password: password,
UseTLS: req.SMTPUseTLS,
UseTLS: resolveSMTPUseTLS(req.SMTPUseTLS, savedConfig),
}
err := h.emailService.TestSMTPConnectionWithConfig(config)
@@ -85,7 +92,7 @@ type SendTestEmailRequest struct {
SMTPPassword string `json:"smtp_password"`
SMTPFrom string `json:"smtp_from_email"`
SMTPFromName string `json:"smtp_from_name"`
SMTPUseTLS bool `json:"smtp_use_tls"`
SMTPUseTLS *bool `json:"smtp_use_tls"`
}
// SendTestEmail 发送测试邮件
@@ -142,7 +149,7 @@ func (h *SettingHandler) SendTestEmail(c *gin.Context) {
Password: password,
From: req.SMTPFrom,
FromName: req.SMTPFromName,
UseTLS: req.SMTPUseTLS,
UseTLS: resolveSMTPUseTLS(req.SMTPUseTLS, savedConfig),
}
siteName := h.settingService.GetSiteName(c.Request.Context())

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