Compare commits

...
152 Commits
Author SHA1 Message Date
Wesley Liddick 99c8e4bf75 Merge pull request #4973 from yiancode/fix/openai-live-store-resilience
fix(openai-live): Live 会话 finalize 与 observer 对 store 故障的容错,防止用量记录静默丢失
2026-07-29 09:42:56 +08:00
Wesley Liddick f2d824836f Merge pull request #5008 from hansnow/fix/claude-sonnet-5-status-alias
fix(frontend): 补充 Claude Sonnet 5 模型状态别名
2026-07-29 09:41:54 +08:00
Wesley Liddick 6e1cbed423 Merge pull request #5024 from Wei-Shaw/fix/passkey-disabled-toast
fix(profile): 修复未配置 Passkey 时 /profile 每次访问都弹「加载 Passkey 失败」
2026-07-29 09:41:06 +08:00
shaw acad7f1a09 fix(profile): stop passkey load error toast when feature is disabled
The PASSKEY_DISABLED silence guard compared the string error code
against error.code, but the api client puts the numeric envelope code
there and the string code in error.reason, so the guard never matched
and every /profile visit on deployments without WebAuthn configured
showed a spurious "failed to load passkeys" toast.

Read error.reason instead, and skip the credentials request entirely
when the feature is disabled so the card no longer issues a request
that is guaranteed to fail with 403.
2026-07-28 22:51:58 +08:00
Wesley Liddick 8fd01c2814 Merge pull request #5003 from feeeei/main
feat(模型广场): add model plaza with group-scoped pricing showcase
2026-07-28 20:02:52 +08:00
Wesley Liddick 39903f006e Merge pull request #5005 from Wei-Shaw/refactor/scoped-column-updates
refactor(repository): scope user/api-key updates to explicitly declared columns
2026-07-28 19:43:50 +08:00
hansnow 32618e71e4 fix(frontend): 补充 Claude Sonnet 5 状态别名 2026-07-28 18:17:38 +08:00
shaw 86fb4781f4 refactor(repository): scope user/api-key updates to declared columns
UserRepository.Update and APIKeyRepository.Update rewrote the whole row on
every call, regardless of which fields the caller meant to change. Several
columns on those tables are maintained by dedicated atomic paths (balance
deduction, quota and rate-limit counters, limit adjustments, activity
timestamps), so a caller holding a slightly older snapshot could silently
roll them back - a lost update.

Both methods now take an explicit column mask and persist only the columns
the caller declares; everything else keeps its current database value.

- All user and API-key call sites declare exactly what they mutate, which
  turns admin edits and profile saves into genuine partial updates.
- Email uniqueness locking/lookup and allowed_groups sync only run when
  those fields are part of the update.
- UserUpdateFields deliberately has no balance/total_recharged members, so
  Update cannot touch them. New AdjustBalance/SetBalance apply the change in
  a single statement and return before/after values; admin balance
  adjustment uses them instead of read-modify-write.
- promo_codes.used_count is no longer written by Update; it is only ever
  incremented by the redemption path.
- The billing hot path that marks an API key quota-exhausted writes only
  status.
- Dropped a no-op row write in RevokeAllUserTokens: users has no
  token_version column, so it persisted nothing while still overwriting
  concurrently-updated columns.

Adds integration coverage that a stale snapshot cannot revert concurrent
atomic writes, and unit coverage pinning the column set each entry point
declares.
2026-07-28 17:21:32 +08:00
feeeei 720c405e35 feat: add model plaza with group-scoped pricing showcase
- public /model-plaza page (standalone + admin-embedded) listing groups
  with discounted effective prices alongside LiteLLM official reference
- faceted platform/group/rate filters: cross-dimension options gray out
  instead of disappearing, platform-tinted chips via accent color-mix
- paid-price columns highlighted with per-platform tint band
- OptionalJWT middleware so anonymous and signed-in users share one route
- admin settings: enable switch, require-auth switch, markdown description
2026-07-28 16:19:41 +08:00
Wesley Liddick 2e432173f7 Merge pull request #4920 from alexj11324/feat/passkey-auth
feat: add passkey authentication
2026-07-28 14:58:37 +08:00
shaw 38ef8dc069 feat: require account password for passkey enrollment and revocation
A hijacked session must not be able to silently add a passkey as a
persistent backdoor or remove the victim's credentials. Registration
(begin) and deletion now verify the account password server-side,
reusing the existing PASSWORD_REQUIRED / PASSWORD_INCORRECT errors.

The password is used instead of TOTP step-up so the guard also protects
deployments that never configured a TOTP encryption key. The password
key in both request bodies is covered by the audit middleware's
key-substring redaction, so no credential material reaches audit_logs.

Frontend: the add-passkey form gains a current-password field, and the
delete confirmation is now a dialog with a password input (replacing
window.confirm), mirroring the TOTP disable dialog. Backend error
messages (e.g. wrong password) are surfaced instead of the generic
failure toast. Rename remains password-free as it is cosmetic.
2026-07-28 14:12:46 +08:00
shaw 97f44b21bb fix: keep passkey switch coupled to WebAuthn config and fix CI issues
- parseSettings now reports passkey_enabled=false whenever the WebAuthn
  deployment config is absent: a stale "true" row left behind after the
  config is removed previously made the admin update gate reject every
  settings save while the UI toggle was disabled, leaving no recovery
  path from the admin panel. Added a regression test.
- update the admin settings API contract goldens with the new
  passkey_enabled/passkey_configured/passkey_rp_id/passkey_rp_origins
  fields.
- errcheck: check rows.Close in passkey repository (repo convention).
- staticcheck QF1001: apply De Morgan's law in WebAuthn origin scheme
  validation.
2026-07-28 11:45:18 +08:00
Wesley Liddick 0ef2228ce9 Merge pull request #4906 from alfadb/feature/kimi-k3-support
feat: 支持 Kimi K3 与 Kimi Code 模型 ID
2026-07-28 11:23:53 +08:00
Wesley Liddick 1cf6972e73 Merge pull request #4950 from bestony/worktree/rapid-cloud-510e
feat(setup): add explicit setup bypass
2026-07-28 11:08:34 +08:00
Wesley Liddick 20893dc4fd Merge pull request #4945 from Jopqior/fix/gpt56-anthropic-max-effort
fix(openai): preserve GPT-5.6 max effort in messages bridges
2026-07-28 11:08:20 +08:00
Wesley Liddick a12d88ed4b Merge pull request #4957 from wucm667/fix/issue-4948-codex-web-search-manifest
fix(openai): preserve web search for API-key Codex clients
2026-07-28 11:07:16 +08:00
Wesley Liddick 6c04e41952 Merge pull request #4968 from jeshica/fix/claude-oauth-system-cache-breakpoint
fix: 保留 Claude OAuth system 迁移后的缓存断点
2026-07-28 11:07:03 +08:00
Wesley Liddick f71332ff85 Merge pull request #4980 from yan9651688/feat/model-id-copy
feat(accounts): add one-click model ID copy
2026-07-28 11:06:48 +08:00
Wesley Liddick c342a885b5 Merge pull request #4942 from HuntercodeT/fix/openai-passthrough-model-mapping-4936
fix(openai): honor passthrough over non-empty model_mapping in account selection (#4936)
2026-07-28 11:06:33 +08:00
alfadb 1b966dbea1 fix: treat Kimi 1M suffix as client syntax 2026-07-28 10:08:39 +08:00
alfadb 7e65eafbe4 feat: add Kimi K3 support 2026-07-28 10:08:39 +08:00
Wesley Liddick 1aeacf4d41 Merge pull request #4983 from Wei-Shaw/fix/issue-4887-prompt-audit-recovery
fix(security-audit): 解密失败不再吞掉整份配置,修复升级后配置消失且无法保存的死锁
2026-07-28 09:49:40 +08:00
Wesley Liddick d95f6b98ce Merge pull request #4975 from Vibeone/fix/msg-id-format-authentic
fix(gateway): 修复模拟响应 message ID 格式,改为正宗 Anthropic msg_01 格式
2026-07-28 09:49:31 +08:00
shaw bfbe113f5e fix(security-audit): 解密失败不再吞掉整份配置,修复升级后配置消失且无法保存的死锁 (#4887)
根因:prompt audit 是共享 TOTP_ENCRYPTION_KEY 加密器的功能中唯一不校验
EncryptionKeyConfigured 的落点。未配置固定密钥的部署每次重启自动生成新
密钥,v162 保存的节点 Token 密文在升级重启后永久无法解密,Reload 中
ActiveFromStorage 整体失败导致快照永远装不上:管理端 GET 回退默认 v1
(v166 起为 503),而保存路径直读数据库做 CAS 版本对比,必然冲突——
配置既看不见也改不掉。PR #4893 仅改变了报错形态,未修复根因。

修复:
- ActiveFromStorage 对单节点解密失败降级容忍:该节点运行时禁用并标记
  TokenInvalid,配置整体照常激活;管理端恢复显示真实版本号,重新输入
  Token 即可自愈(密文保留,密钥恢复后自动复原)
- blocking 意图下零可用节点时 evaluator 仍返回 unavailable,请求照旧
  被拒,fail-closed 语义不回归;async 意图下 enqueue 直接 drop 并告警
- Save 在未配置固定加密密钥时拒绝保存新 Token(与 TOTP/Ollama/备份
  一致的门控),错误码 prompt_audit_encryption_key_required
- token_status 新增 invalid 状态,前端凭据列与编辑框提示重新输入
- 新增 config_token_invalid 告警日志(集合变化时记录一次,不随 5s
  刷新刷屏)
2026-07-28 09:31:36 +08:00
yan9651688 d8ae153ae9 feat(accounts): make model IDs easy to copy
Administrators often need exact model identifiers while editing account whitelists. Add a dedicated copy action without changing model selection or upstream sync behavior.

Constraint: Keep the contribution frontend-only and avoid model routing or persistence changes
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep copy and selection as separate actions
Tested: focused Vitest, account component regression tests, frontend typecheck, lint, and production build
Not-tested: Authenticated browser screenshot
Related: Wei-Shaw/sub2api#2151
2026-07-28 09:23:52 +08:00
eyre 248236ce6d fix(gateway): 修复模拟响应使用 Bedrock msg_bdrk_ 格式,改为正宗 Anthropic msg_01 格式
问题:
探针拦截(suggestion mode / warmup / max_tokens=1 haiku)的模拟响应以及
Gemini/Antigravity 兼容层生成的 message ID 不符合 Anthropic 官方 API 格式,
容易被客户端识别为非正宗响应。

修复:
1. generateRealisticMsgID():msg_bdrk_ + 24字符 → msg_01 + 22位 Base62
   (与官方 API 返回的 msg_011CdS6b8gAhoKWdW9jE87Zs 格式一致)
2. 去掉固定的 msg_mock_suggestion / msg_mock_warmup,统一使用随机 ID
3. 流式响应格式对齐官方:
   - message_start 增加 stop_details/cache token 字段
   - content_block_start 字段顺序修正
   - message_delta.usage 只含 output_tokens
4. 非流式响应:增加 stop_details:null,移除非标准 total_tokens
5. Gemini Messages/ChatCompletions 兼容层:msg_ + hex → msg_01 + Base62
6. Antigravity response/stream transformer:msg_ + 12位 → msg_01 + 22位 Base62

验证方式:对照 Anthropic 官方 API 实际响应格式确认。
2026-07-27 15:20:03 +00:00
yian 1c26dc7ad8 fix(openai-live): Live 会话 finalize 与 observer 对 store 故障的容错
- finalizeLiveCall 写 usage log 改走 writeUsageLogBestEffort(日志 + 同步
  兜底重试)。MarkLiveCallClosed 已在 Redis 标记 first,这是该会话唯一一次
  落库机会,失败不能再被 `_, _ =` 静默丢弃
- observeLiveCall / waitForLiveObserverRetry 把 store 报错与「控制权被他人
  接管」拆开:store 抖动时有限次重试(liveObserverStoreRetryLimit),仍失败
  则按 record.ExpiresAt 兜底 finalize,保证 usage log 与租约释放不因 Redis
  故障而丢失;记录确实不存在(ErrLiveCallNotFound)才停止重试
- Live 会话 TotalCost/ActualCost 恒 0 的零计费行为保持不变,加 TODO 注明
  需产品决策(免费 or 按时长接入计费管道)
2026-07-27 22:22:46 +08:00
jy.liu 1631b19f84 fix: preserve system cache breakpoint in OAuth mimic 2026-07-27 19:28:45 +08:00
wucm667 3c62b5ca85 fix(openai): preserve web search for API-key Codex clients 2026-07-27 18:42:34 +08:00
Bestony@Homelab a6fc2d10b3 feat(setup): add explicit setup bypass 2026-07-27 17:08:41 +08:00
github-actions[bot] 59ce11c780 chore: sync VERSION to 0.1.166 [skip ci] 2026-07-27 08:57:42 +00:00
shaw dc893dd0b8 chore: update sponsors 2026-07-27 15:49:14 +08:00
Wesley Liddick f18f3143e2 Merge pull request #4946 from Wei-Shaw/feat/panel-api-rate-limit
feat(security): 面板 API 限流保护——防止高频刷接口打爆数据库
2026-07-27 15:44:06 +08:00
shaw fead4c7ec3 feat(security): add panel API rate limiting to protect DB from high-frequency requests
用户可高频刷面板接口(usage/dashboard 等重聚合查询)直接打爆数据库:
现有限流器只覆盖登录/注册等公开认证入口,登录后的全部面板端点无任何限流。

三层防护(阈值均可在后台可视化配置,panel_rate_limit_settings):

1. 认证面板接口按「用户 ID」限流,与来源 IP 无关——反向代理/NAT 共享出口
   (所有请求源地址坍缩为 127.0.0.1 等)不会互相误伤:
   - Global 档(默认 240 rpm/账号):user/auth/payment/admin 全部登录后路由
   - Heavy 档(默认 60 rpm/账号):/usage、/usage/dashboard/*、
     /user/api-keys/:id/usage/daily 等重 SQL 聚合端点叠加计数
   - 管理员默认豁免(可关闭)

2. 无认证公开接口(/api/v1/settings/*,每次请求都查 DB)按安全客户端 IP
   限流(默认 300 rpm/IP);回环/私网/链路本地地址(反代内部转发地址)
   一律跳过计数,杜绝把整条反代链路合并进同一个桶造成大面积误拦截。

3. 修复既有隐患:auth 入口限流的 IP 取值从 c.ClientIP() 切换到与审计日志/
   会话绑定/API Key ACL 同源的安全客户端 IP 解析(尊重后台「信任反代转发
   IP」开关快照)。原实现下默认反代部署(未配置 server.trusted_proxies)
   所有用户共享同一个登录限流桶,既会全员误拦也可被单人恶意占满形成登录
   DoS;开关关闭时行为与原来完全一致。

工程约束:
- 配置热路径走进程内缓存(atomic.Value + singleflight,60s TTL),
  限流中间件零 DB 访问;保存后当前节点立即生效
- 面板限流 Redis 故障 fail-open(auth 入口保持原有 fail-close)
- 429 响应携带 Retry-After;错误码 RATE_LIMITED
- 支付 webhook / 公开支付回调有意不挂限流
- 新增 GET/PUT /api/v1/admin/settings/panel-rate-limit;设置页安全 tab
  新增「面板接口限流」卡片(zh/en i18n 全量)

测试:rate_limiter/panel_rate_limit/setting_panel_rate_limit 单测全绿;
routes、handler/admin、-tags unit 契约测试通过;前端 vue-tsc/ESLint/
SettingsView spec(26/26,含新增交互用例)/i18n 守卫全部通过。
2026-07-27 15:12:51 +08:00
JopqiorandClaude 46dba19397 fix(openai): preserve GPT-5.6 max effort in messages bridges
Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-27 14:54:01 +08:00
HuntercodeT 83b368553d fix(openai): honor passthrough over model_mapping in IsModelSupported (#4936)
An OpenAI account with auto-passthrough enabled (extra.openai_passthrough=true,
"replace auth only, allow all models") was still filtered out during account
selection when it had a non-empty credentials.model_mapping that did not list the
requested model. isOpenAICompatibleAccountEligibleForRequest calls
Account.IsModelSupported directly, and IsModelSupported only bypassed the mapping
whitelist for passthrough accounts when the mapping was empty. A leftover mapping
(common after switching an account from whitelist mode to passthrough) therefore
excluded the account -> zero candidates -> ErrNoAvailableAccounts, and the client
saw 404 "Model ... is not supported by any configured account in this group" even
though a direct account test with the same model succeeded (that path already
honored passthrough via isModelSupportedByAccount).

Fix: short-circuit passthrough at the top of Account.IsModelSupported, before the
model_mapping check, so it is consistent with isModelSupportedByAccount. Add a
regression test covering passthrough + non-empty leftover mapping.
2026-07-27 14:20:12 +08:00
Wesley Liddick d96b6a31ff Merge pull request #4908 from chinnsenn/fix/antigravity-openai-compat
fix(antigravity): route OpenAI-compatible requests through native gateway
2026-07-27 13:59:47 +08:00
Wesley Liddick ab73bc0c77 Merge pull request #4924 from Cynicismcart/fix/group-description-wrapping
修复分组描述换行与下拉框溢出
2026-07-27 13:52:44 +08:00
Senn Chinn 3ce8efc125 Merge branch 'Wei-Shaw:main' into fix/antigravity-openai-compat 2026-07-27 13:40:05 +09:00
chinnsenn cc84cd8b4c test(gemini): check function declaration assertions 2026-07-27 13:19:00 +09:00
Wesley Liddick 95590b5530 Merge pull request #4932 from Ricardo-binZzz/codex-responses-compat
Fix Codex (Responses API) <-> Anthropic tool compatibility
2026-07-27 11:47:13 +08:00
Wesley Liddick b765a7f9f6 Merge pull request #4890 from SemonCat/fix/openai-cross-mode-reasoning-failover
fix(openai): strip foreign reasoning on account failover
2026-07-27 11:46:49 +08:00
Wesley Liddick b72d487b85 Merge pull request #4878 from StarryKira/codex/fix-payment-dashboard-currencies
fix(payment): group dashboard stats by currency
2026-07-27 11:46:23 +08:00
Wesley Liddick ad34f89152 Merge pull request #4933 from visa2/fix/usage-model-mapping-statistics
fix(usage): report channel-mapped requests under their real upstream model
2026-07-27 11:45:58 +08:00
Wesley Liddick de6b189a6b Merge pull request #4879 from wey-gu/fix/security-deps-20260726
fix(deps): update image and telemetry packages
2026-07-27 11:45:06 +08:00
Wesley Liddick a74e11c26a Merge pull request #4868 from visa2/fix/settings-partial-update-clobber
fix(settings): keep fields a settings PUT never sent at their stored value
2026-07-27 11:44:40 +08:00
Wesley Liddick 131d42d25d Merge pull request #4839 from visa2/fix/composite-route-prefix-passthrough
fix(composite): pass the requested model through when a prefix route leaves upstream_model empty
2026-07-27 11:44:15 +08:00
Wesley Liddick 031c83b7e0 Merge pull request #4875 from StarryKira/codex/fix-4859-gemini-36-flash-billing
fix(billing): price Antigravity Gemini 3.6 Flash
2026-07-27 11:43:49 +08:00
Wesley Liddick 7a3fda57c8 Merge pull request #4820 from feeeei/main
fix(gemini): 完善gemini号池模式时retryable失效问题
2026-07-27 11:43:13 +08:00
Wesley Liddick 16365199aa Merge pull request #4884 from Brisbanehuang/fix/probe-scheduling-nanosecond-timestamps
fix(repository): 修复上游计费倍率探测因纳秒时间戳解析失败导致的调度饿死
2026-07-27 11:42:59 +08:00
Wesley Liddick 91a2281c7a Merge pull request #4861 from coo1white/fix-flaky-concurrency-tests
test: stop four concurrency tests from failing on a busy machine
2026-07-27 11:42:34 +08:00
Wesley Liddick ece9517091 Merge pull request #4930 from wucm667/fix/issue-4928-config-file-path
fix(config): honor explicit CONFIG_FILE path
2026-07-27 11:42:08 +08:00
Wesley Liddick 4cc88e27b6 Merge pull request #4873 from wey-gu/fix/admin-usage-request-id-filter
fix(admin): filter usage logs by request id
2026-07-27 11:41:09 +08:00
Wesley Liddick b468e428e9 Merge pull request #4926 from Vibeone/fix/oauth-mimicry-cache-prefix-break
fix(gateway): 识别被代理的 Claude Code 流量,避免 mimicry 重写破坏 prompt cache
2026-07-27 11:40:43 +08:00
Wesley Liddick a40d6de12e Merge pull request #4907 from feitianbubu/fix/bump-claude-cli-version-2.1.220
fix(claude): 伪装的 Claude Code CLI 版本号升级到 2.1.220
2026-07-27 11:40:18 +08:00
Wesley Liddick bc9173be15 Merge pull request #4934 from OG-Wang/fix/monitor-timeline-overflow
fix(frontend): 修复渠道监控时间线在窄卡片下溢出
2026-07-27 11:39:34 +08:00
Wesley Liddick eb6e3d1f1d Merge pull request #4787 from KtzeAbyss/fix/4760-ws-turn-model-billing
fix(openai): track WebSocket models per turn
2026-07-27 10:25:57 +08:00
Wesley Liddick a93bfb6623 Merge pull request #4757 from lucas-ward/codex/fix-4691-caddy-sse-buffering
fix(deploy): prevent Caddy compression from buffering SSE
2026-07-27 10:22:58 +08:00
Wesley Liddick 8f47bd5fa0 Merge pull request #4893 from wucm667/fix/issue-4887-prompt-audit-config-load
fix(security-audit): reject unavailable prompt config
2026-07-27 10:20:14 +08:00
Wesley Liddick beeb4b84ed Merge pull request #4900 from wucm667/fix/issue-4889-mobile-available-channels
fix(frontend): adapt available channels for mobile
2026-07-27 10:19:44 +08:00
Wesley Liddick aac44473aa Merge pull request #4876 from wucm667/fix/issue-4846-show-usage-user
fix: show routed user in usage filters
2026-07-27 10:19:31 +08:00
Wesley Liddick 465362e1af Merge pull request #4877 from wucm667/fix/issue-4863-turnstile-invite-overlap
fix: show optional affiliate code on registration
2026-07-27 10:19:16 +08:00
Wesley Liddick 6ee2304dcd Merge pull request #4912 from yan9651688/fix/issue-4794-grok-test-402
fix(grok): pause accounts after manual test payment failure
2026-07-27 10:19:01 +08:00
Rick e94383a4c4 fix(frontend): 修复渠道监控时间线在窄卡片下溢出
MonitorTimeline 每根柱子设置了 min-w-[3px],60 根柱子加 2px 间距的
最小总宽度为 298px。当卡片内容区宽度低于该值时(如 100% 缩放下的
部分布局),时间线整体溢出卡片边缘。改为 min-w-0 让柱子随容器等分
压缩,任意宽度下均不再溢出。
2026-07-27 09:08:56 +08:00
shaw 7d3a896fcd chore: update sponsors 2026-07-27 08:59:12 +08:00
Ricardo-binZzz 7dde9370e4 Codex++ Responses<->Anthropic compatibility fixes
Namespace tool flatten/restore, array function_call_output, omit empty input_schema for native tools, lift additional_tools; scoped to ForwardAsResponses.
2026-07-27 08:19:19 +08:00
wucm667 5c471485ab fix(config): honor explicit CONFIG_FILE path
Make CONFIG_FILE select an explicit config for both full loading and lightweight address lookup, with regression tests.
2026-07-27 06:20:11 +08:00
eyre 7b3ed2a961 fix(gateway): detect proxied Claude Code traffic by body to preserve prompt cache
When an upstream API gateway (e.g. new-api) relays real Claude Code
requests, the User-Agent becomes Go-http-client while the body retains
the full Claude Code fingerprint (billing attribution block +
metadata.user_id + cache_control breakpoints).

Previously, the OAuth mimicry path relied solely on UA matching to
detect Claude Code clients. Without a matching UA, the gateway would
rewrite the system prompt — replacing the client's carefully structured
system blocks and cache_control breakpoints with its own injection.
This breaks Anthropic's prefix-based prompt cache: since the cache key
evaluates tools → system → messages in order, a changed system
invalidates all downstream message caching.

Symptoms observed:
- cache_read permanently locked at ~25K (only system prompt cached)
- cache_creation growing monotonically every turn (full messages rewrite)
- Single-request costs $17-27 instead of normal $1-2

Fix: when UA does not match but the body contains a valid billing
attribution block (x-anthropic-billing-header with cc_entrypoint=),
treat the request as proxied Claude Code traffic and skip mimicry.
This preserves the client's original system structure and cache_control
breakpoints, allowing Anthropic's prompt cache to function correctly.
2026-07-26 17:54:56 +00:00
visa2 be65c713ff fix(usage): preserve final upstream model 2026-07-27 00:43:53 +08:00
Cynicismcart 78f78947f1 fix(frontend): 完善下拉框视口边界处理 2026-07-27 00:41:40 +08:00
Cynicismcart 005a5d2a37 fix(frontend): 修复分组描述换行和下拉框溢出 2026-07-27 00:35:15 +08:00
visa2 1f45c99de7 fix(usage): correct mapped model statistics 2026-07-26 23:56:34 +08:00
Zhixuan Jiang 357c5b917b feat: add passkey sign-in settings control 2026-07-26 11:07:12 -04:00
Zhixuan Jiang 4158e73b3f fix: harden passkey deployment readiness 2026-07-26 10:14:55 -04:00
Zhixuan Jiang cc62979aa7 feat: add passkey authentication 2026-07-26 09:50:28 -04:00
chinnsenn 3e08106116 fix(gemini): preserve Hermes web search functions 2026-07-26 22:09:26 +09:00
yan9651688 2db0cbd292 fix(grok): pause accounts after manual test payment failure
Manual Grok connection tests previously surfaced upstream HTTP 402 errors without changing account availability. Persist the same 30-minute payment-required cooldown used by the live forwarding path so refreshed account lists no longer present the account as schedulable.

Constraint: Keep manual-test HTTP 402 handling aligned with existing Grok forwarding semantics.
Rejected: Mark the account permanently error | payment state can recover and the forwarding path intentionally uses a bounded cooldown.
Confidence: high
Scope-risk: narrow
Directive: Keep the manual-test cooldown reason and duration aligned with handleGrokAccountUpstreamError.
Tested: go test -tags=unit ./internal/service -count=1; go vet -tags=unit ./internal/service; production package compile check
Not-tested: Live xAI account with an exhausted subscription
Related: #4794
2026-07-26 21:04:18 +08:00
chinnsenn 71d7f86883 fix(antigravity):
1. harden OpenAI compatibility forwarding
 2. reject usage-only non-stream responses
2026-07-26 20:23:50 +09:00
feitianbubu 7af28ca843 fix(claude): 伪装的 Claude Code CLI 版本号升级到 2.1.220 2026-07-26 19:20:11 +08:00
wucm667 16dd3d8ee6 fix(frontend): adapt available channels for mobile 2026-07-26 16:26:25 +08:00
wucm667 56b5f0df68 fix(security-audit): reject unavailable prompt config 2026-07-26 14:33:59 +08:00
KtzeAbyss 7ce6e8d652 fix(openai): track websocket models per turn 2026-07-26 13:43:06 +08:00
Edison42andClaude Opus 4.8 a36222748d fix(openai): strip foreign reasoning on account failover
When a /responses forwarding loop has attempted an OpenAI passthrough
account, sanitize every subsequent non-passthrough attempt by deriving its
body from the immutable canonical request and dropping provider-specific
encrypted reasoning input items in full.

This prevents Bedrock-compatible accounts from rejecting Kiro reasoning
IDs and encrypted_content, including on same-account retries and later
non-passthrough failovers. Preserve JSON numbers exactly while sanitizing.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-26 13:33:56 +08:00
Brisbanehuang 2447c44f85 fix(repository): parse nanosecond next_probe_at in due probe scheduling
Go persists upstream_billing_probe.next_probe_at via RFC3339Nano, but
jsonpath datetime() parses at most 6 fractional digits, so every stored
timestamp failed to parse and was treated as malformed: fail-open due,
ordered into the invalid bucket by id ASC. With more enabled accounts
than the per-cycle limit, the same lowest IDs monopolized every cycle
and higher IDs were never probed again. Trim the fraction to
microseconds before datetime(), mirroring ListDueOllamaCloudUsageAccounts,
and pin the behavior with integration regressions: nanosecond parsing,
due-time ordering beyond the limit, preserved fail-open for truly
invalid dates.
2026-07-25 23:04:21 -04:00
feeeei fd7e2039d3 fix(gemini): 完善gemini号池模式时retryable失效问题
此前 Gemini 三条转发路径对池模式账号命中 ErrorPolicySkipped 时直接把
上游错误体透传给客户端(强制标 500),既不同账号重试也不换号,
pool_mode_retry_count 配置完全不生效;而 Anthropic/OpenAI 等路径
会构造 UpstreamFailoverError 交给 handler 层按池模式配置重试后换号。

- 新增 poolModeSkippedFailoverError:池模式 + 可 failover 状态码时
  返回 UpstreamFailoverError,RetryableOnSameAccount 按
  pool_mode_retry_status_codes(默认 401/403/429)判定
- 原生 v1beta(ForwardNative)与 Claude messages 兼容路径的
  Skipped 分支接入;非池模式的自定义错误码透传行为不变
- chat completions 兼容路径的 failover 错误补上
  RetryableOnSameAccount 池模式判定
2026-07-26 09:52:47 +08:00
Wey Gu ba5fa6a38f fix(deps): update image and telemetry packages 2026-07-26 04:23:02 +08:00
haruka 6d99e668d2 fix(payment): group dashboard stats by currency 2026-07-26 03:18:30 +08:00
wucm667 0875143d98 fix: show optional affiliate code on registration 2026-07-26 02:47:35 +08:00
wucm667 d11b838702 fix: show routed user in usage filters 2026-07-26 02:25:54 +08:00
haruka 6c7625800a fix(billing): price Antigravity Gemini 3.6 Flash 2026-07-26 01:56:10 +08:00
Wey Gu 1850e00955 fix(admin): filter usage logs by request id 2026-07-26 00:53:13 +08:00
Nick 291a737422 test: stop four concurrency tests from failing on a busy machine
All four pass on a quiet box and fail on a loaded one, each for its own
reason. None of them is testing the clock, so none of them should be
failing on it.

- ollama_cloud_usage_test.go: the second caller was released with
  `close(release)` right after its goroutine was started, not after it
  had reached the singleflight group. When the first refresh won that
  race, the second became a new singleflight execution, re-read the
  account, saw the LastAttemptAt the first one had just written, and
  came back with the 30-second manual-refresh 429 at line 685. It now
  counts account loads and waits for the second caller's own load,
  which happens right before it joins the group. Adds a counting
  GetByID to the test repo.

- gateway_hotpath_optimization_test.go: a 20ms sleep was meant to let
  all 12 callers reach the cache before the loader was released. A
  caller that arrived after the load had finished got a hit, not a
  miss, so the miss count came out 11 of 12. It now waits on the miss
  counter itself, which is the value the test asserts on.

- token_refresh_pool_health_test.go: the floor was `configuredSpacing`
  minus 10ms, i.e. 40ms out of 50ms. Each start timestamp is taken
  after the rate gate releases the goroutine, so scheduler delay can
  compress one observed gap with the gate behaving correctly — seen at
  37ms and again at 13ms. The floor is now a tenth of the configured
  spacing. Measured with providerQPS=20, 8 attempts, concurrency 2:
  gate at 50ms gives a minimum gap of 49.97ms, gate at 0 gives 22µs.
  So an unpaced gate sits three orders of magnitude under the 5ms floor
  and is still caught, while jitter has room to move. A comment warns
  against replacing this with an assertion on the total span of the
  starts: the span is set by how long each attempt takes under the
  concurrency limit, not by the gate — 471ms paced against 241ms
  unpaced — so a span check passes with the gate disabled.

- prompt_guard_test.go: the bound only has to show the failover shared
  the first endpoint's 70ms deadline and did not take the second
  endpoint's own 500ms one. An unshared deadline lands near 535ms, so
  350ms still fails loudly (seen: 224ms against a 180ms bound).

Tests only; no product code is touched. Each fix was checked in both
directions: it passes with the behaviour intact, and it still fails when
the behaviour is broken on purpose (for the QPS one, by swapping the
shared rate gate for a zero-interval one).
2026-07-25 21:59:20 +07:00
visa2 1614ae9c99 Merge remote-tracking branch 'origin/main' into fix/composite-route-prefix-passthrough 2026-07-25 22:20:27 +08:00
github-actions[bot] 2730c1c43b chore: sync VERSION to 0.1.165 [skip ci] 2026-07-25 13:59:09 +00:00
visa2andClaude Opus 5 0b5903d458 fix(settings): keep fields a settings PUT never sent at their stored value
PUT /api/v1/admin/settings is a whole-document write. The admin UI always sends
the complete document, so saving from the settings page is unaffected both
before and after this change. The bug is only reachable when an API client calls
the endpoint directly and sends just the fields it wants to change, which is the
natural assumption for a PUT on a settings resource.

Value-typed fields of UpdateSettingsRequest bind to their zero value when the
payload omits them, and buildSystemSettingsUpdates writes every key
unconditionally, so such a caller has no way to say "leave this one alone".
Omitting a field and explicitly clearing it are indistinguishable on the wire.
A caller that sends only the field it wants to change, e.g.

    {"risk_control_enabled": true}

sets that flag and clears every other unguarded field in the same request.
Measured against a fully configured store, one such call empties site_name,
site_subtitle, api_base_url, contact_info and doc_url, and turns
registration_enabled, email_verify_enabled, invitation_code_enabled and
turnstile_enabled off. turnstile_enabled alone gates the captcha on login,
register, forgot-password and both verify-code endpoints, and
email_verify_enabled is a precondition of IsPasswordResetEnabled.

The damage is easy to miss. site_name has a built-in fallback, so
getStringOrDefault renders the cleared value as the default product name and the
login page visibly changes, while the toggles just go quiet. Reopening the
settings page reads the already-cleared state back into the form, so correcting
the one visible field and saving persists the rest of the damage.

Fields that grew their own guard already survive this: the SMTP block falls back
to the previous values when smtp_host arrives empty, secret fields are written
only when non-empty, and 132 request fields are pointers whose handler merges an
omitted field with the stored value. This generalizes that pattern rather than
adding a fourth ad-hoc guard.

The handler now decodes the payload a second time as a raw field map, resolves
the setting key each absent field would have written, and hands that set to the
service, which drops those keys before SetMultiple, so the stored value is never
touched. Fields the payload does carry are written as before, giving the caller
the partial-update semantics it was already assuming. The mapping is reflected off
the request's json tags so new fields are covered without maintaining a list;
smtp_from_email is the only field whose json name differs from its setting key
and is aliased explicitly.

Only value-typed fields are filtered. Pointer fields keep whole-document
behaviour on purpose: forwarded_client_ip_headers and
api_key_acl_trust_forwarded_ip depend on being rewritten on every save to
re-normalize fail-closed state, which the malformed forwarded-client-IP header
test pins down.

An explicitly sent empty value is still a deliberate clear; only absent fields
are preserved. A partial write refreshes the in-process caches from storage
instead of from the request struct, which holds zero values for whatever the
caller omitted.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-25 21:03:00 +08:00
Wesley Liddick e9a58c1cb8 Merge pull request #4814 from yiancode/fix/email-alias-registration-dedup
fix(auth): 注册查重归一化邮箱别名,防止单收件箱批量注册
2026-07-25 20:54:37 +08:00
shaw ef0ca5bdf5 style: 修正 dotStrippedEmailExpr 注释以满足 gofmt 文档注释规则 2026-07-25 19:51:19 +08:00
shaw bc3acd6e28 fix(auth): 收紧注册别名查重(根点绕过 / 误拒 / 无界扫描 / 并发竞态)
对 #4814 的审计跟进修复:

- 域名尾随点绕过:user@gmail.com. 的域名不在 gmail 家族名单内,点号折叠与
  googlemail 归一被整体跳过,别名刷号原样可复现。归一化入口统一去掉 FQDN 根点。
- 误拒合法用户:剥 "+后缀" 缺空串守卫,+alice@ 与 +bob@ 都折叠成 @domain,
  该域后续 "+x@" 注册会永久 EMAIL_EXISTS 且无自助恢复。改为仅当 "+" 不在首位时剥离。
- 无界不可索引全表扫描:原实现按 LOWER(email) LIKE '%@domain' 把整域邮箱读进内存,
  且挂在公开未鉴权的 send-verify-code 上。改为按去点邮箱
  REPLACE(LOWER(TRIM(email)), '.', '') 做等值 + "local+%@domain" 前缀探针并带 LIMIT,
  新增同表达式的部分索引(migrations/190)。TRIM 口径与既有精确匹配一致,
  历史带首尾空白的行同样命中;LIKE 元字符转义,% 与 _ 不会扩大匹配面。
- 并发竞态:注册改走 CreateWithEmailAliasGuard,在邮箱唯一性锁上追加收件箱身份锁并在
  锁内复查,避免同一收件箱的多个别名变体同时通过服务层前置查重。管理员建号仍走
  Create,不受别名限制。
- 能力断言静默 fail-open:别名查重方法上提到 UserRepository 端口(编译期强制),
  移除可选接口类型断言与静默降级分支。
- OAuth 邮箱注册的两条建号路径(同样发放注册赠额)纳入同一查重口径;邮箱换绑/绑定
  不纳入,否则用户把邮箱改成自己收件箱的别名会被误拒。
2026-07-25 19:40:53 +08:00
yianandClaude Fable 5 b6f9277515 fix(auth): normalize email aliases in registration dedup
Registration duplicate checks compared the email verbatim (lowercase +
trim only), so a single inbox could spawn unlimited accounts via provider
alias features: plus addressing (user+tag@gmail.com) and the Gmail dot
trick (u.s.e.r@gmail.com) both deliver to the same mailbox but were seen
as distinct, verifiable addresses. This lets abusers bulk-register to farm
signup grants while the domain whitelist and email verification pass.

Add NormalizeEmailForAliasDedup to collapse these variants to a single
"inbox identity" and use it (via existsByEmailOrAlias) on the three local
email-registration paths: register, send-verify-code, and its async
variant. The exact ExistsByEmail check runs first; only on a miss do we
scan the candidate domains (gmail.com/googlemail.com are mutual aliases)
and compare normalized forms. Lookup errors fail closed, matching the
existing check, so the path cannot be bypassed by inducing errors.

Scoped to registration only — email storage, display, login, and delivery
are unchanged. OAuth-bound emails (already provider-verified) are out of
scope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-25 19:19:15 +08:00
Wesley Liddick 2e2638c01d Merge pull request #4864 from Wei-Shaw/fix/ollama-usage-pg-compat-and-fetch-floor
fix(ollama): 修复 PG<=16 上 due 判定失效并恢复抓取下限
2026-07-25 18:58:06 +08:00
shaw 1763db3a2d fix(ollama): 修复 PG<=16 上 due 判定失效并恢复抓取下限
#4850 的用量刷新调度有三个问题,本次一并修复。

jsonpath .datetime() 直到 PostgreSQL 17 才接受 ISO-8601 的 Z 标识符,
而服务写入的快照时间戳全部是 UTC(即 Z 形式)。在 PG 14/15/16 上
ollamaCloudUsageParseRFC3339SQL 因此把 fetched_at / last_attempt_at /
next_refresh_at 全部解析成 NULL,due 判定整条退化为 fail-open 分支:
每轮 20 个刷新额度被 id 最小的非 due 组占满,id 更大的组永远不会刷新——
正是该 PR 声称修复的饥饿场景。项目文档声明支持 PG 14/15/16,而集成测试
harness 固定使用 postgres:18.1,因此现有测试无法发现。

修法是在送入 jsonpath 前把结尾的 Z 改写为 +00:00。仍保留 jsonpath 而不
直接 ::timestamptz,因为通过形状正则但日历非法的值(如 2026-02-30)需要
fail-open 成 NULL 而非中断整条查询。已在真实 PG 14/15/16/17/18 上验证
五个版本行为一致,且非法日历与垃圾输入仍正确 fail-open。

成功路径不再查阅 next_refresh_at,而 nextOllamaCloudUsageDelay 的
15 分钟下限正作用于该字段,导致同组对 ollama.com 的抓取下限从 15 分钟
降到一个 runner 周期。请求间隔略大于 debounce 的交互式流量(典型 Claude
Code 用法)会把单组 24 小时抓取次数从 24-96 抬高到数百次。改为对成功路径
显式施加 fetched_at + OllamaCloudUsageMinFetchInterval 的下限,Go 与 SQL
两侧同步;空闲账号不再轮询这一主要收益不受影响。

debounce_minutes 与 interval_minutes 此前各自独立校验,因此
debounce >= interval 是合法组合;此时 min(lastUsed+debounce,
fetchedAt+maxWait) 中的 debounce 项恒为死项,管理员配置被静默忽略。
改为在写入时拒绝该组合。

另修复 refreshAccount 中 ListOllamaCloudUsageGroupAccounts 的错误被
静默吞掉(违反 CLAUDE.md 禁止忽略错误):失败时回退到更窄的活动信号会
改变 due 语义,现在记录日志。

测试:
- 恢复被删除的 7/8/9 位小数秒解析覆盖,并改写为可判别形式(断言"不应
  返回",解析失败会落入 fail-open 而被捕获)。已验证该测试在 PG 15 上
  无此修复时失败、有修复时通过。
- 新增 min fetch interval 下限与 debounce/interval 交叉校验的单元测试。
- integration harness 新增 SUB2API_TEST_POSTGRES_IMAGE 覆盖,使套件可
  针对最低受支持版本运行。
2026-07-25 18:34:37 +08:00
Wesley Liddick bb0c38306f Merge pull request #4850 from alfadb/feat/ollama-usage-request-debounce
feat(ollama): 按模型请求刷新云端用量
2026-07-25 18:09:21 +08:00
alfadb b403f88f51 fix(ollama): 避免刷新候选饥饿
ListDue 在 LIMIT 前用与 service 纯函数一致的 debounce/max-wait/backoff
规则筛真正 due 组,防止有活动但未到期的组占满每轮 20 名额。
2026-07-25 15:37:06 +08:00
alfadb f1eaed31ed fix(ollama): 写入设置时缺省 debounce 补 1
兼容旧客户端省略 debounce_minutes 的 PUT 请求。
2026-07-25 15:37:06 +08:00
alfadb 0f72b7dca7 feat(ollama): 按模型请求刷新云端用量
将 Ollama Cloud settings HTML 自动抓取从固定间隔轮询改为请求驱动的
trailing debounce + max-wait:无新请求不再抓取,连续请求最晚在
max-wait 强制刷新;失败退避仍优先于活动 due。新增 debounce_minutes
(默认 1),interval_minutes 保留为最长等待兼容字段。
2026-07-25 15:37:05 +08:00
Wesley Liddick 43d4bae246 Merge pull request #4843 from slovx2/feature/openai-live-gateway
feat(openai): 支持 ChatGPT Live / Frameless 网关
2026-07-25 15:27:10 +08:00
shaw 5374ce2a0f Merge remote-tracking branch 'origin/main' into feature/openai-live-gateway 2026-07-25 15:16:05 +08:00
shaw 7acc13a29b fix(live): 租约丢失终止会话并补齐过期时的 usage log
三处功能修复(审计发现):

1. 租约续租失败按会话终结处理。RefreshLiveLease 的 Lua 在 leaseID 被 GC 后不会
   重新 ZADD,重连拿不回并发槽;原先把 ErrLiveUnavailable 当临时错误交给 observer
   重连,会让会话以约 1 秒一轮的节奏空转到 ExpiresAt(最长 60 分钟),期间持着
   上游 WS 连接却不计入账号/用户/API Key 的任何并发限制。

2. observer 因过期放弃时补写 usage log。waitForLiveObserverRetry 原先把过期判定
   混在重试条件里并返回 false,直接 return 绕过了 observeLiveCall 循环顶部的过期
   分支,导致该路径既不写 usage log 也不释放租约(静默结束)。改为只判控制权归属,
   过期交回循环顶部 finalize。

3. 把两处重复的三项终止判据抽成 liveSessionEnded,消除 ProxyLiveSideband 与
   observeLiveCall 之间的判据漂移风险。

迁移改名 186/187 -> 188/189:原编号会成为第三个 186,且 187 与已合入 main 的
#4801 的 187_add_usage_log_session_id.sql 撞号。文件名即迁移主键,功能无害但易误读。

新增两个测试均经变异验证(去掉修复后会失败)。
2026-07-25 15:15:59 +08:00
Wesley Liddick b99ea955d9 Merge pull request #4818 from weiness/fix/announcement-display-and-preview
fix(frontend): 修复home页公告弹窗样式错乱问题,增加后台公告预览功能
2026-07-25 14:44:04 +08:00
Wesley Liddick e6a3030beb Merge pull request #4803 from wucm667/fix/issue-4798-gemini-image-chat-content
fix(gemini): preserve image output in chat completions
2026-07-25 14:43:30 +08:00
Wesley Liddick 3176cdee17 Merge pull request #4823 from wucm667/fix/issue-4819-drop-orphan-tool-choice
Supersedes the earlier attempt #4439 by @KnWsdHunter, which its own author closed on 2026-07-20 and which was never merged. This PR discloses that prior work in its description and contributes a broader 6-case test table. Courtesy credit to @KnWsdHunter for the original diagnosis.
2026-07-25 14:43:01 +08:00
shaw 6b267e4f40 Merge branch 'main' into fix/announcement-display-and-preview
解决 frontend/pnpm-lock.yaml 冲突:采用 main 的 frontend/package.json 与
frontend/pnpm-lock.yaml。本 PR 附带的 postcss ^8.4.32 -> ^8.5.22 并非安全降级
(高于 main 的 pnpm.overrides 下限 >=8.5.18),但与本 PR 的公告弹窗主题无关,
回退后 PR 范围回归其本职改动。
本 PR 的功能改动(AnnouncementBell/Popup、announcement-markdown.css、
AnnouncementsView、i18n)未受影响。
2026-07-25 14:33:58 +08:00
shaw f06c8c5698 Merge branch 'main' into fix/issue-4798-gemini-image-chat-content
解决 frontend/pnpm-lock.yaml 冲突:采用 main 的 frontend/package.json 与
frontend/pnpm-lock.yaml,回退本 PR 附带的 postcss ^8.4.32 -> ^8.5.12 改动。
main 已通过 pnpm.overrides ("postcss@<8.5.18": ">=8.5.18") 强制安全下限,
本 PR 的直接依赖下限低于该值,保留会与 overrides 冲突且无安全收益。
本 PR 的功能改动(gemini_* compat service 及其测试)未受影响。
2026-07-25 14:33:06 +08:00
shaw 8bed40c67b Merge branch 'main' into fix/issue-4819-drop-orphan-tool-choice
解决 frontend/pnpm-lock.yaml 冲突:采用 main 的 frontend/package.json 与
frontend/pnpm-lock.yaml,回退本 PR 附带的 postcss ^8.4.32 -> ^8.5.12 改动。
main 已通过 pnpm.overrides ("postcss@<8.5.18": ">=8.5.18") 强制安全下限,
本 PR 的直接依赖下限低于该值,保留会与 overrides 冲突且无安全收益。
本 PR 的功能改动(openai_gateway_grok.go 及其测试)未受影响。
2026-07-25 14:32:18 +08:00
Wesley Liddick a29a62112b Merge pull request #4842 from Cynicismcart/fix/openai-pool-retry-transient-cooldown
fix(openai): preserve pool-mode same-account retries
2026-07-25 14:30:04 +08:00
Wesley Liddick eb03be0dbf Merge pull request #4788 from wucm667/fix/issue-4782-pool-5xx-cooldown
fix(grok): avoid pool cooldown on 5xx
2026-07-25 14:29:36 +08:00
Wesley Liddick 6d956bdc20 Merge pull request #4801 from SemonCat/feat/persist-session-id
feat(usage): persist client session identifiers
2026-07-25 14:03:32 +08:00
Wesley Liddick 333acde7d1 Merge pull request #4832 from ListenCodes/fix/openai-apikey-responses-item-id-v2
fix(openai): sanitize API-key responses item IDs
2026-07-25 13:42:35 +08:00
Wesley Liddick 9994eaa70f Merge pull request #4804 from scp-planet/fix/openai-strip-input-namespace
fix(openai): strip input item namespaces before HTTP forwarding / HTTP 转发前移除 input 项 namespace
2026-07-25 13:42:17 +08:00
Wesley Liddick a9866f03db Merge pull request #4841 from wucm667/fix/issue-4838-affiliate-mobile-copy
fix(frontend): adapt affiliate copy controls for mobile
2026-07-25 13:42:03 +08:00
Wesley Liddick 0e39e21fa2 Merge pull request #4796 from 404QAQ/codex/fix-image-request-diagnostics
fix(images): log requested quality and size
2026-07-25 13:41:52 +08:00
Wesley Liddick 95cc6ee2c0 Merge pull request #4793 from 404QAQ/codex/fix-pricing-empty-remote-url
fix(pricing): skip remote scheduler without URL
2026-07-25 13:41:40 +08:00
song db6fbdbf29 fix(openai): satisfy Live CI checks 2026-07-25 12:51:20 +08:00
song 988d4b577e feat(openai): add macOS Live attestation 2026-07-25 12:50:46 +08:00
song ec23716ee4 fix(openai): align Live request metadata 2026-07-25 12:50:46 +08:00
song e6eb23eaac feat(openai): add Live gateway support 2026-07-25 12:50:46 +08:00
Wesley Liddick 37ed639d1e Merge pull request #4852 from Wei-Shaw/fix/bump-postcss-audit
fix(security): 升 postcss 到 >=8.5.18 修复 frontend-security 红灯
2026-07-25 12:01:42 +08:00
shaw a5aae5db9a fix(security): 升 postcss 到 >=8.5.18 修复 frontend-security 红灯
新披露两条 high 级公告命中锁文件里的 postcss@8.5.6,frontend-security 的
audit exception 检查失败:

- GHSA-6g55-p6wh-862q(2026-07-23 披露,修复版 8.5.12)
  CSS 注释中攻击者可控的 sourceMappingURL 导致任意文件读取与信息泄露
- GHSA-r28c-9q8g-f849(2026-07-24 披露,修复版 8.5.18)
  Previous Source Map 自动加载存在路径穿越,导致任意 .map 文件泄露

postcss 不只是 devDependency —— 它经 vue → @vue/compiler-sfc 进入生产依赖树,
因此 `pnpm audit --prod` 会命中。用 pnpm.overrides 而非只升直接依赖,可保证
所有引入路径的实例都被抬到修复版(沿用本仓 form-data@<4.0.6 的既有写法)。

锁文件用 pnpm 10 重新解析以匹配现有锁文件的生成工具,避免 pnpm 9 误删
11 处 libc: [glibc|musl] 平台门控字段;lockfileVersion 保持 9.0。
实际解析到 postcss 8.5.23,nanoid 3.3.11→3.3.16 是 postcss 自身依赖的
补丁级跟随,diff 无其他无关变动。

验证:复现 CI 失败步骤(pnpm audit --prod --audit-level=high +
tools/check_pnpm_audit_exceptions.py)已通过;CI 所用 pnpm 9 的
--frozen-lockfile 接受该锁文件;vue-tsc --noEmit、pnpm build、vitest 均通过。
2026-07-25 11:45:42 +08:00
shaw 6c9b84cc7a feat: 适配 Anthropic 新模型 claude-opus-5
模型登记:/v1/models 清单、Bedrock 默认映射(us.anthropic.claude-opus-5-v1)、
定价条目($5/$25 per MTok、1M 上下文、128K 输出)、前端模型清单与
Anthropic/Bedrock 预设映射、限流 scope 简称。

同时修复两个会静默出错的问题:

- 定价家族兜底 3 倍超收:定价数据缺 claude-opus-5 时,matchByModelFamily
  的 Phase 2 关键字兜底会落到 opus-4 系列、getFallbackPricing 会落到
  claude-3-opus,两条路都按 $15/$75 计费(官方 $5/$25),输入输出双双
  3 倍超收且无任何报错。两处补 opus-5 家族并回退到同价的 4.8;判断用
  opus-5/opus5 子串而非裸 "5",避免误伤 claude-opus-4-5。顺带补齐兜底表
  缺失的 claude-opus-4.8(此前同样会掉到 claude-3-opus)。

- Bedrock 版本闸门降级:claudeVersionRe 强制要求 major-minor 两段版本号,
  只有主版本号的 claude-opus-5 / claude-sonnet-5 完全不匹配,被当成旧模型:
  isBedrockOpus47OrNewer 假导致 thinking.enabled 不转 adaptive(Opus 5 上游
  已移除 budget_tokens,透传直接 400)、isBedrockClaude45OrNewer 假导致
  cache_control.ttl 被剥离、bedrockModelSupportsToolSearch 假导致 tool search
  被过滤。改为 minor 可选(缺省 minor=0),claude-sonnet-5 的同一问题一并修复。

Vertex 无需改动:normalizeVertexAnthropicModelID 只处理 -YYYYMMDD→@YYYYMMDD,
无日期后缀的裸 ID 原样透传即正确。context-1m-2025-08-07 白名单不动:Opus 系
上游不接受该 beta,且 Opus 5 的 1M 上下文是默认能力。

Antigravity 暂不接入:无上游支持证据,mapAntigravityModel 对未映射模型返回
空字符串即"该账号不支持",fails closed 安全。

回归测试 internal/service/claude_opus5_test.go 覆盖定价两层兜底、Bedrock
三个闸门、thinking 转换与模型清单;逐个回退上述修复已确认测试会红。
2026-07-25 11:22:42 +08:00
visa2 386b57bb77 fix(composite): pass the requested model through when a prefix route leaves upstream_model empty
Composite model routes support a match_type: prefix mode with an optional
upstream_model. Leaving upstream_model empty is meant to pass the concrete
requested model through to the upstream, but every request matching the prefix
collapsed to the route's public_model instead. A route with
public_model=deepseek-v4, match_type=prefix and an empty upstream_model
forwarded both deepseek-v4-flash and deepseek-v4-pro upstream as deepseek-v4.

Root cause: normalizeCompositeRouteInput backfilled an empty upstream_model with
public_model at save time, so CompositeRouteResolver.Resolve never reached its
"upstreamModel == '' -> concrete requested model" fallback.

- Skip the backfill for prefix routes only; an empty upstream_model stays empty
  and Resolve passes the concrete requested model through.
- exact routes keep the backfill (model == public_model, so persisted/display
  contract is unchanged).
- Explicit upstream_model still forwards that fixed model in every mode.
- Frontend: add an upstream_model hint (zh/en) documenting empty = passthrough.
2026-07-25 10:20:50 +08:00
Cynicismcart 521db6869e fix(openai): preserve pool-mode same-account retries
Avoid recording the generic account-model transient cooldown for pool-mode statuses explicitly configured for same-account retry. This lets the bounded request-local retry budget complete while preserving cooldowns for other statuses and non-pool accounts.
2026-07-25 05:31:31 +08:00
wucm667 30d146e8b5 fix(frontend): adapt affiliate copy controls for mobile 2026-07-25 04:27:19 +08:00
LIULIXING 1891faa68d fix(openai): keep item ID sanitization linear 2026-07-25 01:40:26 +08:00
wucm667 41456b69dd fix(frontend): upgrade postcss for security audit 2026-07-24 22:37:42 +08:00
ListenCodes c5d9d57940 fix(openai): sanitize API-key responses item IDs 2026-07-24 20:15:58 +08:00
wucm667 41f12e874d fix(grok): drop orphaned Responses tool choice 2026-07-24 16:25:53 +08:00
weiness 67bb446b5c fix(deps): upgrade postcss for GHSA-6g55-p6wh-862q 2026-07-24 16:12:34 +08:00
weiness 972a4a471e feat(admin): add announcement preview action 2026-07-24 15:44:34 +08:00
weiness 7b387e479d fix(frontend): share announcement rich text styles 2026-07-24 15:44:27 +08:00
wucm667 b5979050f3 fix(frontend): upgrade postcss for security audit 2026-07-24 08:33:58 +08:00
wucm667 8276fdf605 fix: resolve Gemini compat test lint 2026-07-24 03:00:26 +08:00
scp-planet d2d4fb9221 fix(openai): satisfy buffer write lint checks 2026-07-24 02:59:32 +08:00
scp-planet 1bdf99109b fix(openai): strip input namespaces for api key accounts 2026-07-24 02:59:32 +08:00
scp-planet e70fbf3204 fix(openai): strip residual input namespaces before HTTP forwarding 2026-07-24 02:59:32 +08:00
wucm667 4dc0aedbfe fix(gemini): preserve image output in chat completions 2026-07-24 02:42:23 +08:00
Edison42 1c0cb24c7e feat(usage): persist client session identifiers 2026-07-24 01:22:34 +08:00
404QAQ 4fd9182aff fix(images): log requested quality and size 2026-07-23 23:04:00 +08:00
404QAQ 38cc916f7d fix(pricing): skip remote scheduler without URL 2026-07-23 22:43:36 +08:00
wucm667 51f354f5c2 fix(grok): avoid pool cooldown on 5xx 2026-07-23 12:24:30 +00:00
github-actions[bot] cb24522dd5 chore: sync VERSION to 0.1.164 [skip ci] 2026-07-23 09:54:18 +00:00
BayinForge e46d55bc57 fix(ci): make Caddy check portable across awk implementations 2026-07-23 14:46:09 +08:00
BayinForge c81191b46a fix(deploy): prevent Caddy compression from buffering SSE
Replace the broad text response matcher with an explicit non-SSE MIME allowlist. Document proxy behavior and enforce the canonical Caddy compression policy in CI.
2026-07-23 07:40:54 +08:00
396 changed files with 21969 additions and 1253 deletions
+1
View File
@@ -16,6 +16,7 @@ jobs:
run: |
/bin/bash -n deploy/apple-container.sh
/bin/bash deploy/tests/apple-container-test.sh
/bin/sh deploy/test-caddyfile-cache.sh
test:
runs-on: ubuntu-latest
-23
View File
@@ -102,18 +102,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Thanks to Apikl for sponsoring this project! Built on Sub2API, the platform provides developers with relay services for Codex / Claude series models, focusing on long-term stability, high-speed direct connections, and excellent cost-effectiveness. It offers pay-as-you-go balance billing, enterprise-grade official invoices, and one-on-one dedicated support. <a href="https://apikl.ai">Register now</a> for a 1:1 top-up bonus — double your balance!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>Thanks to TokenEum for sponsoring this project! <a href="https://tokeneum.ai">TokenEum</a> is a comprehensive AI model aggregation platform and intelligent agent development company. It brings together top-tier international models — including Claude, Gemini, and OpenAI — alongside leading open-source models such as GLM, Qwen, and Kimi, offering a wide range of options across different quality and price tiers to suit every need. TokenEum also provides access to cutting-edge video generation models like Seedance2.0 and Happy Horse. Committed to transparency and honest business practices, TokenEum ensures all model information is accurate and reliable. Visit <a href="https://tokeneum.ai">tokeneum.ai</a> to get started.
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>Thanks to 666api for sponsoring this project! <a href="https://666api.work/sub2api">666api</a> is an all-in-one platform offering:<br>
@@ -124,23 +112,12 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Thanks to Dola seed for sponsoring this project! Dola Seed 2.0 is a full‑modal general large model independently developed by ByteDance for the global market. Built on a unified multimodal architecture, it supports joint understanding and generation of text, images, audio, and video. It natively enables agent collaboration, with strong reasoning, long‑task execution, tool integration, and coding capabilities. It is widely applicable to smart cockpits, personal assistants, education, customer support, marketing, retail, and other scenarios. It excels in multimodal perception, end‑to‑end complex task delivery, stable interaction, and data security, and is readily accessible and deployable via the ModelArk platform.Register via <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">this link</a> to get 500,000 tokens of free inference quota per model.<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陆地区的开发者请点击这里</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>Thanks to MiyaIP for sponsoring this project! <a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> is a platform dedicated to global residential proxy network services, committed to providing high-quality, pure overseas residential IP resources for enterprise developers, cross-border business teams, and AI application users. It delivers stable, independent overseas network environments for AI platforms, overseas SaaS, and other online services, supporting multi-region access testing and project environment isolation. Ideal for development and testing scenarios that require access to overseas AI services, such as: AI model platform access, AI development testing, AI SaaS service usage, AI API debugging, and multi-region network environment validation.
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td>Thanks to <a href="https://anpin.ai">anpin.ai</a> for sponsoring this project! anpin.ai is a premium AI relay service platform dedicated to advancing AI accessibility. With an advanced technical architecture and globally distributed deployment, it provides users with a direct high-speed channel to the world's top-tier large language models.<br>
-23
View File
@@ -105,18 +105,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>感谢 Apikl 赞助本项目!平台基于 Sub2API 搭建,为开发者提供 Codex / Claude 系列模型的中转服务,专注于长期稳定、高速直连与高性价比。支持按量计费的余额结算、企业级正规发票及一对一专属对接。<a href="https://apikl.ai">立即注册</a>即享充值 1:1 赠送 — 余额翻倍!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>感谢 TokenEum 赞助本项目!<a href="https://tokeneum.ai">TokenEum</a> 是一家综合性 AI 模型聚合平台与智能体开发公司,汇聚 Claude、Gemini、OpenAI 等国际顶级模型,以及 GLM、Qwen、Kimi 等主流开源模型,提供不同质量与价格梯度的丰富选择,满足多样化需求。平台还接入了 Seedance2.0、Happy Horse 等前沿视频生成模型。秉持透明诚信的经营理念,TokenEum 确保所有模型信息真实可靠。访问 <a href="https://tokeneum.ai">tokeneum.ai</a> 开始使用。
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>感谢 666api 赞助本项目!<a href="https://666api.work/sub2api">666api</a> 是一站式综合服务平台,提供:<br>
@@ -127,23 +115,12 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/huoshan.png" alt="HuoShan" width="150"></a></td>
<td>感谢火山方舟 Agent Plan 模型赞助了本项目!方舟 Agent Plan 模型订阅套餐集成了包含 Doubao-Seed、Doubao-Seedance、Doubao-Seedream 等在内的字节跳动自研 SOTA 级模型,覆盖文本、代码、图像、视频等多模态任务。最新支持 MiniMax-M3、DeepSeek-V4 系列、GLM-5.1、Doubao-Seed-2.0 系列、Kimi-K2.6 等模型,工具不限。超全模态模型与 Harness 升级一步到位,深度支持 Agent 框架与 AI 编程工具。一次订阅,可以为不同任务切换合适的 AI 引擎。方舟 Coding Plan 为 Sub2Api 的用户提供了专属福利:通过<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">此链接</a>订阅方舟 Coding Plan,新客户首两个月享 2.5 折优惠 <a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">>>For developers outside Mainland China, please click here</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>感谢 MiyaIP 赞助本项目!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> 是一家专注于全球住宅代理网络服务的平台,致力于为企业开发者、跨境业务团队及AI 应用用户提供高质量、纯净的海外住宅 IP 资源。为 AI 平台、海外 SaaS 及其他在线服务提供稳定、独立的海外网络环境,支持多地区访问测试和项目环境隔离。适用于需要访问海外 AI 服务的开发和测试场景,例如:AI 模型平台访问、AI 开发测试、AI SaaS 服务使用、AI API 调试、多地区网络环境验证
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td>感谢 <a href="https://anpin.ai">anpin.ai</a> 赞助本项目!anpin.ai 是一家致力于推动 AI 普惠的高端 AI 中转服务平台。我们以先进的技术架构和全球分布式部署,为用户提供直达国际顶尖大模型的高速通道。<br>
-23
View File
@@ -104,41 +104,18 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://apikl.ai"><img src="assets/partners/logos/apikl.png" alt="apikl" width="150"></a></td>
<td>Apikl のご支援に感謝します!Sub2API をベースに構築された本プラットフォームは、開発者向けに Codex / Claude シリーズモデルの中継サービスを提供しています。長期安定性、高速直結、高いコストパフォーマンスを重視し、従量課金の残高ベース課金、エンタープライズグレードの正規請求書、1対1の専属サポートを提供します。<a href="https://apikl.ai">今すぐ登録</a>でチャージ 1:1 ボーナス — 残高が倍に!
</td>
</tr>
<tr>
<td width="180"><a href="https://tokeneum.ai"><img src="assets/partners/logos/tokeneum.png" alt="tokeneum" width="150"></a></td>
<td>TokenEum のご支援に感謝します!<a href="https://tokeneum.ai">TokenEum</a> は総合的な AI モデル集約プラットフォームおよびインテリジェントエージェント開発企業です。Claude、Gemini、OpenAI などの世界トップクラスのモデルに加え、GLM、Qwen、Kimi などの主要なオープンソースモデルも集約しており、品質と価格の異なる豊富な選択肢を提供してあらゆるニーズに対応します。また、Seedance2.0 や Happy Horse などの最先端の動画生成モデルも利用可能です。TokenEum は透明性と誠実なビジネスを重視し、すべてのモデル情報の正確性と信頼性を保証します。<a href="https://tokeneum.ai">tokeneum.ai</a> でぜひお試しください。
</td>
</tr>
<tr>
<td width="180"><a href="https://666api.work/sub2api"><img src="assets/partners/logos/666api.jpg" alt="666api" width="150"></a></td>
<td>666api のご支援に感謝します!<a href="https://666api.work/sub2api">666api</a> はオールインワンプラットフォームで、以下を提供しています:⚡ API 中継 — グローバルモデルへの従量課金アクセス、100% 公式ソースから直接供給、公式価格の最大 75% オフ。独占特典:Zhipu GLM 50% オフ・DeepSeek V4-pro 50% オフ・Seedance2.0 8% オフ(ホワイトリスト)・HappyHorse Overseas 30% オフ(ホワイトリスト)🔑 GPT サブスクリプションアカウント — 同源 IP 込み・グローバル住宅 IP 💰 請求書発行対応
</td>
</tr>
<tr>
<td width="180"><a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"><img src="assets/partners/logos/byteplus.png" alt="BytePlus" width="150"></a></td>
<td>Dola seed のご支援に感謝します!Dola Seed 2.0 は、ByteDance がグローバル市場向けに独自開発した全モーダル汎用大規模モデルです。統一されたマルチモーダルアーキテクチャに基づき、テキスト・画像・音声・動画の共同理解と生成をサポートします。エージェント協調をネイティブに有効化し、強力な推論・長期タスク実行・ツール統合・コーディング能力を備えています。スマートコックピット、パーソナルアシスタント、教育、カスタマーサポート、マーケティング、リテールなど多様なシナリオに広く適用可能です。マルチモーダル知覚、エンドツーエンドの複雑タスク配信、安定したインタラクション、データセキュリティに優れ、ModelArk プラットフォームを通じて容易にアクセス・デプロイできます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html">こちらのリンク</a>から登録すると、各モデルにつき 50 万トークンの無料推論クォータを獲得できます。<a href="https://dis.chatdesks.cn/chatdesk/hsyqsub2api.html"> >>中国大陸地域の開発者はこちらをクリック</a></td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.miyaip.com/?invitecode=sub2api"><img src="assets/partners/logos/miyaip.png" alt="miyaip" width="150"></a></td>
<td>MiyaIP のご支援に感謝します!<a href="https://www.miyaip.com/?invitecode=sub2api">MiyaIP</a> はグローバル住宅プロキシネットワークサービスに特化したプラットフォームで、企業開発者、越境ビジネスチーム、AI アプリケーションユーザーに高品質で純粋な海外住宅 IP リソースを提供することに注力しています。AI プラットフォーム、海外 SaaS、その他のオンラインサービスに安定した独立した海外ネットワーク環境を提供し、複数地域でのアクセステストやプロジェクト環境の分離をサポートします。海外 AI サービスへのアクセスが必要な開発・テストシナリオに最適です。例:AI モデルプラットフォームアクセス、AI 開発テスト、AI SaaS サービス利用、AI API デバッグ、複数地域のネットワーク環境検証など。
</td>
</tr>
<tr>
<td width="180"><a href="https://anpin.ai"><img src="assets/partners/logos/anpin.jpg" alt="anpin" width="150"></a></td>
<td><a href="https://anpin.ai">anpin.ai</a> のご支援に感謝します!anpin.ai は AI の普及推進に取り組むハイエンド AI 中継サービスプラットフォームです。先進的な技術アーキテクチャとグローバル分散デプロイメントにより、国際トップクラスの大規模モデルへの高速直通ルートを提供します。<br>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.163
0.1.166
+12 -3
View File
@@ -46,8 +46,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
return nil, err
}
userRepository := repository.NewUserRepository(client, db)
passkeyRepository := repository.NewPasskeyRepository(db)
redeemCodeRepository := repository.NewRedeemCodeRepository(client)
redisClient := repository.ProvideRedis(configConfig)
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
refreshTokenCache := repository.NewRefreshTokenCache(redisClient)
settingRepository := repository.NewSettingRepository(client)
groupRepository := repository.NewGroupRepository(client, db)
@@ -79,6 +81,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
affiliateRepository := repository.NewAffiliateRepository(client, db)
affiliateService := service.NewAffiliateService(affiliateRepository, settingService, apiKeyAuthCacheInvalidator, billingCacheService)
authService := service.NewAuthService(client, userRepository, redeemCodeRepository, refreshTokenCache, configConfig, settingService, emailService, turnstileService, emailQueueService, promoService, subscriptionService, affiliateService, serviceUserPlatformQuotaRepository)
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
if err != nil {
return nil, err
}
userService := service.NewUserService(userRepository, settingRepository, apiKeyAuthCacheInvalidator, billingCache)
redeemCache := repository.NewRedeemCache(redisClient)
redeemService := service.NewRedeemService(redeemCodeRepository, userRepository, subscriptionService, redeemCache, billingCacheService, client, apiKeyAuthCacheInvalidator, affiliateService)
@@ -92,6 +98,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
userAttributeValueRepository := repository.NewUserAttributeValueRepository(client)
userAttributeService := service.NewUserAttributeService(userAttributeDefinitionRepository, userAttributeValueRepository)
authHandler := handler.NewAuthHandler(configConfig, authService, userService, settingService, promoService, redeemService, totpService, userAttributeService)
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
userHandler := handler.NewUserHandler(userService, authService, emailService, emailCache, affiliateService, serviceUserPlatformQuotaRepository)
apiKeyHandler := handler.NewAPIKeyHandler(apiKeyService)
usageLogRepository := repository.NewUsageLogRepository(client, db)
@@ -255,7 +262,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
contentModerationHandler := admin.NewContentModerationHandler(contentModerationService)
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor)
configManager := securityaudit.NewConfigManager(db, settingRepository, redisClient, secretEncryptor, configConfig)
postgreSQLRepository := securityaudit.NewPostgreSQLRepository(db)
redisPayloadStore := securityaudit.NewRedisPayloadStore(redisClient)
openAICompatibleScanner := securityaudit.NewOpenAICompatibleScanner()
@@ -283,6 +290,7 @@ 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)
imageTaskStore := repository.NewImageTaskStore(redisClient)
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
@@ -296,13 +304,14 @@ 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, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
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)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
apiKeyAuthMiddleware := middleware.NewAPIKeyAuthMiddleware(apiKeyService, subscriptionService, configConfig)
auditLogMiddleware := middleware.NewAuditLogMiddleware(auditLogService)
stepUpAuthMiddleware := middleware.NewStepUpAuthMiddleware(totpService, userService, settingService)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
engine := server.ProvideRouter(configConfig, handlers, jwtAuthMiddleware, optionalJWTAuthMiddleware, adminAuthMiddleware, apiKeyAuthMiddleware, auditLogMiddleware, stepUpAuthMiddleware, apiKeyService, subscriptionService, opsService, settingService, compositeRouteResolver, redisClient)
httpServer := server.ProvideHTTPServer(configConfig, engine)
opsMetricsCollector := service.ProvideOpsMetricsCollector(opsRepository, settingRepository, accountRepository, concurrencyService, db, redisClient, configConfig)
opsAggregationService := service.ProvideOpsAggregationService(opsRepository, settingRepository, db, redisClient, configConfig)
+12 -1
View File
@@ -105,6 +105,8 @@ type Group struct {
SortOrder int `json:"sort_order,omitempty"`
// 是否允许 /v1/messages 调度到此 OpenAI 分组
AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"`
// 是否允许此 OpenAI 分组访问 Live 接口
AllowLive bool `json:"allow_live,omitempty"`
// 仅允许非 apikey 类型账号关联到此分组
RequireOauthOnly bool `json:"require_oauth_only,omitempty"`
// 调度时仅允许 privacy 已成功设置的账号
@@ -229,7 +231,7 @@ func (*Group) scanValues(columns []string) ([]any, error) {
switch columns[i] {
case 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.FieldRequireOauthOnly, group.FieldRequirePrivacySet:
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:
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:
values[i] = new(sql.NullFloat64)
@@ -537,6 +539,12 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.AllowMessagesDispatch = value.Bool
}
case group.FieldAllowLive:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field allow_live", values[i])
} else if value.Valid {
_m.AllowLive = value.Bool
}
case group.FieldRequireOauthOnly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i])
@@ -826,6 +834,9 @@ func (_m *Group) String() string {
builder.WriteString("allow_messages_dispatch=")
builder.WriteString(fmt.Sprintf("%v", _m.AllowMessagesDispatch))
builder.WriteString(", ")
builder.WriteString("allow_live=")
builder.WriteString(fmt.Sprintf("%v", _m.AllowLive))
builder.WriteString(", ")
builder.WriteString("require_oauth_only=")
builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly))
builder.WriteString(", ")
+10
View File
@@ -102,6 +102,8 @@ const (
FieldSortOrder = "sort_order"
// FieldAllowMessagesDispatch holds the string denoting the allow_messages_dispatch field in the database.
FieldAllowMessagesDispatch = "allow_messages_dispatch"
// FieldAllowLive holds the string denoting the allow_live field in the database.
FieldAllowLive = "allow_live"
// FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database.
FieldRequireOauthOnly = "require_oauth_only"
// FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database.
@@ -236,6 +238,7 @@ var Columns = []string{
FieldSupportedModelScopes,
FieldSortOrder,
FieldAllowMessagesDispatch,
FieldAllowLive,
FieldRequireOauthOnly,
FieldRequirePrivacySet,
FieldDefaultMappedModel,
@@ -341,6 +344,8 @@ var (
DefaultSortOrder int
// DefaultAllowMessagesDispatch holds the default value on creation for the "allow_messages_dispatch" field.
DefaultAllowMessagesDispatch bool
// DefaultAllowLive holds the default value on creation for the "allow_live" field.
DefaultAllowLive bool
// DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field.
DefaultRequireOauthOnly bool
// DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field.
@@ -576,6 +581,11 @@ func ByAllowMessagesDispatch(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAllowMessagesDispatch, opts...).ToFunc()
}
// ByAllowLive orders the results by the allow_live field.
func ByAllowLive(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAllowLive, opts...).ToFunc()
}
// ByRequireOauthOnly orders the results by the require_oauth_only field.
func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc()
+15
View File
@@ -260,6 +260,11 @@ func AllowMessagesDispatch(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAllowMessagesDispatch, v))
}
// AllowLive applies equality check predicate on the "allow_live" field. It's identical to AllowLiveEQ.
func AllowLive(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAllowLive, v))
}
// RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ.
func RequireOauthOnly(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
@@ -1985,6 +1990,16 @@ func AllowMessagesDispatchNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAllowMessagesDispatch, v))
}
// AllowLiveEQ applies the EQ predicate on the "allow_live" field.
func AllowLiveEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAllowLive, v))
}
// AllowLiveNEQ applies the NEQ predicate on the "allow_live" field.
func AllowLiveNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAllowLive, v))
}
// RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field.
func RequireOauthOnlyEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
+65
View File
@@ -607,6 +607,20 @@ func (_c *GroupCreate) SetNillableAllowMessagesDispatch(v *bool) *GroupCreate {
return _c
}
// SetAllowLive sets the "allow_live" field.
func (_c *GroupCreate) SetAllowLive(v bool) *GroupCreate {
_c.mutation.SetAllowLive(v)
return _c
}
// SetNillableAllowLive sets the "allow_live" field if the given value is not nil.
func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate {
if v != nil {
_c.SetAllowLive(*v)
}
return _c
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate {
_c.mutation.SetRequireOauthOnly(v)
@@ -948,6 +962,10 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultAllowMessagesDispatch
_c.mutation.SetAllowMessagesDispatch(v)
}
if _, ok := _c.mutation.AllowLive(); !ok {
v := group.DefaultAllowLive
_c.mutation.SetAllowLive(v)
}
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
v := group.DefaultRequireOauthOnly
_c.mutation.SetRequireOauthOnly(v)
@@ -1101,6 +1119,9 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.AllowMessagesDispatch(); !ok {
return &ValidationError{Name: "allow_messages_dispatch", err: errors.New(`ent: missing required field "Group.allow_messages_dispatch"`)}
}
if _, ok := _c.mutation.AllowLive(); !ok {
return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)}
}
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)}
}
@@ -1334,6 +1355,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value)
_node.AllowMessagesDispatch = value
}
if value, ok := _c.mutation.AllowLive(); ok {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
_node.AllowLive = value
}
if value, ok := _c.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
_node.RequireOauthOnly = value
@@ -2224,6 +2249,18 @@ func (u *GroupUpsert) UpdateAllowMessagesDispatch() *GroupUpsert {
return u
}
// SetAllowLive sets the "allow_live" field.
func (u *GroupUpsert) SetAllowLive(v bool) *GroupUpsert {
u.Set(group.FieldAllowLive, v)
return u
}
// UpdateAllowLive sets the "allow_live" field to the value that was provided on create.
func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert {
u.SetExcluded(group.FieldAllowLive)
return u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert {
u.Set(group.FieldRequireOauthOnly, v)
@@ -3193,6 +3230,20 @@ func (u *GroupUpsertOne) UpdateAllowMessagesDispatch() *GroupUpsertOne {
})
}
// SetAllowLive sets the "allow_live" field.
func (u *GroupUpsertOne) SetAllowLive(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetAllowLive(v)
})
}
// UpdateAllowLive sets the "allow_live" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateAllowLive()
})
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -4345,6 +4396,20 @@ func (u *GroupUpsertBulk) UpdateAllowMessagesDispatch() *GroupUpsertBulk {
})
}
// SetAllowLive sets the "allow_live" field.
func (u *GroupUpsertBulk) SetAllowLive(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetAllowLive(v)
})
}
// UpdateAllowLive sets the "allow_live" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateAllowLive()
})
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+34
View File
@@ -822,6 +822,20 @@ func (_u *GroupUpdate) SetNillableAllowMessagesDispatch(v *bool) *GroupUpdate {
return _u
}
// SetAllowLive sets the "allow_live" field.
func (_u *GroupUpdate) SetAllowLive(v bool) *GroupUpdate {
_u.mutation.SetAllowLive(v)
return _u
}
// SetNillableAllowLive sets the "allow_live" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate {
if v != nil {
_u.SetAllowLive(*v)
}
return _u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate {
_u.mutation.SetRequireOauthOnly(v)
@@ -1495,6 +1509,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AllowMessagesDispatch(); ok {
_spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value)
}
if value, ok := _u.mutation.AllowLive(); ok {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
}
if value, ok := _u.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
}
@@ -2627,6 +2644,20 @@ func (_u *GroupUpdateOne) SetNillableAllowMessagesDispatch(v *bool) *GroupUpdate
return _u
}
// SetAllowLive sets the "allow_live" field.
func (_u *GroupUpdateOne) SetAllowLive(v bool) *GroupUpdateOne {
_u.mutation.SetAllowLive(v)
return _u
}
// SetNillableAllowLive sets the "allow_live" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetAllowLive(*v)
}
return _u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne {
_u.mutation.SetRequireOauthOnly(v)
@@ -3330,6 +3361,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.AllowMessagesDispatch(); ok {
_spec.SetField(group.FieldAllowMessagesDispatch, field.TypeBool, value)
}
if value, ok := _u.mutation.AllowLive(); ok {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
}
if value, ok := _u.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
}
+1
View File
@@ -938,6 +938,7 @@ var (
{Name: "supported_model_scopes", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "sort_order", Type: field.TypeInt, Default: 0},
{Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false},
{Name: "allow_live", Type: field.TypeBool, Default: false},
{Name: "require_oauth_only", Type: field.TypeBool, Default: false},
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
+55 -1
View File
@@ -21911,6 +21911,7 @@ type GroupMutation struct {
sort_order *int
addsort_order *int
allow_messages_dispatch *bool
allow_live *bool
require_oauth_only *bool
require_privacy_set *bool
default_mapped_model *string
@@ -24226,6 +24227,42 @@ func (m *GroupMutation) ResetAllowMessagesDispatch() {
m.allow_messages_dispatch = nil
}
// SetAllowLive sets the "allow_live" field.
func (m *GroupMutation) SetAllowLive(b bool) {
m.allow_live = &b
}
// AllowLive returns the value of the "allow_live" field in the mutation.
func (m *GroupMutation) AllowLive() (r bool, exists bool) {
v := m.allow_live
if v == nil {
return
}
return *v, true
}
// OldAllowLive returns the old "allow_live" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldAllowLive(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAllowLive is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAllowLive requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAllowLive: %w", err)
}
return oldValue.AllowLive, nil
}
// ResetAllowLive resets all changes to the "allow_live" field.
func (m *GroupMutation) ResetAllowLive() {
m.allow_live = nil
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (m *GroupMutation) SetRequireOauthOnly(b bool) {
m.require_oauth_only = &b
@@ -24907,7 +24944,7 @@ func (m *GroupMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *GroupMutation) Fields() []string {
fields := make([]string, 0, 51)
fields := make([]string, 0, 52)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -25037,6 +25074,9 @@ func (m *GroupMutation) Fields() []string {
if m.allow_messages_dispatch != nil {
fields = append(fields, group.FieldAllowMessagesDispatch)
}
if m.allow_live != nil {
fields = append(fields, group.FieldAllowLive)
}
if m.require_oauth_only != nil {
fields = append(fields, group.FieldRequireOauthOnly)
}
@@ -25155,6 +25195,8 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.SortOrder()
case group.FieldAllowMessagesDispatch:
return m.AllowMessagesDispatch()
case group.FieldAllowLive:
return m.AllowLive()
case group.FieldRequireOauthOnly:
return m.RequireOauthOnly()
case group.FieldRequirePrivacySet:
@@ -25266,6 +25308,8 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldSortOrder(ctx)
case group.FieldAllowMessagesDispatch:
return m.OldAllowMessagesDispatch(ctx)
case group.FieldAllowLive:
return m.OldAllowLive(ctx)
case group.FieldRequireOauthOnly:
return m.OldRequireOauthOnly(ctx)
case group.FieldRequirePrivacySet:
@@ -25592,6 +25636,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetAllowMessagesDispatch(v)
return nil
case group.FieldAllowLive:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAllowLive(v)
return nil
case group.FieldRequireOauthOnly:
v, ok := value.(bool)
if !ok {
@@ -26180,6 +26231,9 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldAllowMessagesDispatch:
m.ResetAllowMessagesDispatch()
return nil
case group.FieldAllowLive:
m.ResetAllowLive()
return nil
case group.FieldRequireOauthOnly:
m.ResetRequireOauthOnly()
return nil
+12 -8
View File
@@ -1141,40 +1141,44 @@ func init() {
groupDescAllowMessagesDispatch := groupFields[39].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()
// 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[40].Descriptor()
groupDescRequireOauthOnly := groupFields[41].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[41].Descriptor()
groupDescRequirePrivacySet := groupFields[42].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[42].Descriptor()
groupDescDefaultMappedModel := groupFields[43].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[43].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[44].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[44].Descriptor()
groupDescModelsListConfig := groupFields[45].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[45].Descriptor()
groupDescRpmLimit := groupFields[46].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[46].Descriptor()
groupDescMaxReasoningEffort := groupFields[47].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[47].Descriptor()
groupDescReasoningEffortMappings := groupFields[48].Descriptor()
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
+3
View File
@@ -198,6 +198,9 @@ func (Group) Fields() []ent.Field {
field.Bool("allow_messages_dispatch").
Default(false).
Comment("是否允许 /v1/messages 调度到此 OpenAI 分组"),
field.Bool("allow_live").
Default(false).
Comment("是否允许此 OpenAI 分组访问 Live 接口"),
field.Bool("require_oauth_only").
Default(false).
Comment("仅允许非 apikey 类型账号关联到此分组"),
+18 -9
View File
@@ -16,11 +16,12 @@ require (
github.com/coder/websocket v1.8.14
github.com/dgraph-io/ristretto v0.2.0
github.com/gin-gonic/gin v1.9.1
github.com/golang-jwt/jwt/v5 v5.2.2
github.com/go-webauthn/webauthn v0.17.4
github.com/golang-jwt/jwt/v5 v5.3.1
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/imroc/req/v3 v3.57.0
github.com/imroc/req/v3 v3.59.0
github.com/klauspost/compress v1.18.2
github.com/lib/pq v1.10.9
github.com/patrickmn/go-cache v2.1.0+incompatible
@@ -43,7 +44,7 @@ require (
github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.53.0
golang.org/x/image v0.39.0
golang.org/x/image v0.41.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0
@@ -97,6 +98,7 @@ require (
github.com/fatih/color v1.18.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/fxamacker/cbor/v2 v2.9.2 // indirect
github.com/gabriel-vasile/mimetype v1.4.2 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-logr/logr v1.4.3 // indirect
@@ -106,9 +108,12 @@ require (
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect
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/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
@@ -137,11 +142,12 @@ require (
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
github.com/philhofer/fwd v1.2.0 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.57.1 // indirect
github.com/quic-go/quic-go v0.60.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/sagikazarmark/locafero v0.4.0 // indirect
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
@@ -158,20 +164,22 @@ require (
github.com/testcontainers/testcontainers-go v0.40.0 // indirect
github.com/tidwall/match v1.1.1 // indirect
github.com/tidwall/pretty v1.2.0 // indirect
github.com/tinylib/msgp v1.6.4 // indirect
github.com/tklauser/go-sysconf v0.3.12 // indirect
github.com/tklauser/numcpus v0.6.1 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect
github.com/x448/float16 v0.8.4 // indirect
github.com/yuin/gopher-lua v1.1.1 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/auto/sdk 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.37.0 // indirect
go.opentelemetry.io/otel/metric v1.37.0 // indirect
go.opentelemetry.io/otel/sdk v1.37.0 // indirect
go.opentelemetry.io/otel/trace v1.37.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.uber.org/atomic v1.10.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
@@ -179,6 +187,7 @@ require (
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.46.0 // indirect
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
+38 -44
View File
@@ -128,6 +128,8 @@ github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHk
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
github.com/fxamacker/cbor/v2 v2.9.2 h1:X4Ksno9+x3cz0TZv69ec1hxP/+tymuR8PXQJyDwfh78=
github.com/fxamacker/cbor/v2 v2.9.2/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
@@ -153,21 +155,29 @@ github.com/go-playground/validator/v10 v10.14.0 h1:vgvQWe3XCz3gIeFDm/HnTIbj6UGmg
github.com/go-playground/validator/v10 v10.14.0/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-test/deep v1.0.3 h1:ZrJSEWsXzPOxaZnFteGEfooLba+ju3FYIbOrS+rQd68=
github.com/go-test/deep v1.0.3/go.mod h1:wGDj63lr65AM2AQyKZd/NYHGb0R+1RLqB8NKt3aSFNA=
github.com/go-viper/mapstructure/v2 v2.5.0 h1:vM5IJoUAy3d7zRSVtIwQgBj7BiWtMPfmPEgAXnvj1Ro=
github.com/go-viper/mapstructure/v2 v2.5.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/go-webauthn/webauthn v0.17.4 h1:KFTSz3R2RYDiUn/0cDi3XTJgFenSG74eKTTHlqWhlxk=
github.com/go-webauthn/webauthn v0.17.4/go.mod h1:pZk63EE/BdztlmyS4Yc+9H5g4a8blNlbtGmdHQHbZX8=
github.com/go-webauthn/x v0.2.6 h1:TEyDuQAIiEgYpx60nKiBJIX/5nSUC8LxNbH+uf5U9uk=
github.com/go-webauthn/x v0.2.6/go.mod h1:45bA7YEqyQhRcQJ/TiBb46Ww8yqHBGvgEhQ3WWF0aDo=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/google/go-tpm v0.9.8 h1:slArAR9Ft+1ybZu0lBwpSmpwhRXaa85hWtMinMyRAWo=
github.com/google/go-tpm v0.9.8/go.mod h1:h9jEsEECg7gtLis0upRBQU+GhYVH6jMjrFxI8u6bVUY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -185,8 +195,8 @@ github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZY
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
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.57.0 h1:LMTUjNRUybUkTPn8oJDq8Kg3JRBOBTcnDhKu7mzupKI=
github.com/imroc/req/v3 v3.57.0/go.mod h1:JL62ey1nvSLq81HORNcosvlf7SxZStONNqOprg0Pz00=
github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo=
github.com/imroc/req/v3 v3.59.0/go.mod h1:cwLwwaE90Pq6ZMmKuHA9FivJbg3avnCH8/RA5lUUs74=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
@@ -222,8 +232,6 @@ github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovk
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -257,8 +265,6 @@ github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -267,6 +273,8 @@ github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaR
github.com/patrickmn/go-cache v2.1.0+incompatible/go.mod h1:3Qf8kWWT7OJRJbdiICTKqZju1ZixQ/KpMGzzAfe6+WQ=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
@@ -278,22 +286,22 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
github.com/prashantv/gostub v1.1.0 h1:BTyx3RfQjRHnUWaGF9oQos79AlQ5k8WNktv7VGvVH4g=
github.com/prashantv/gostub v1.1.0/go.mod h1:A5zLQHz7ieHGG7is6LLXLz7I8+3LZzsrV0P1IAHhP5U=
github.com/quic-go/go-ossfuzz-seeds v0.1.0 h1:APacT+iIaNF6fd8AGEiN3bT/Jtkd2jz4v4TzM7MFjy0=
github.com/quic-go/go-ossfuzz-seeds v0.1.0/go.mod h1:3IOHRbJIc+L6YKMwfDtJAM9Vj9k0YY4muhuyUYk5tbk=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.57.1 h1:25KAAR9QR8KZrCZRThWMKVAwGoiHIrNbT72ULHTuI10=
github.com/quic-go/quic-go v0.57.1/go.mod h1:ly4QBAjHA2VhdnxhojRsCUOeJwKYg+taDlos92xb1+s=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.13.1 h1:KvO1DLK/DRN07sQ1LQKScxyZJuNnedQ5/wKSR38lUII=
github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWNMNyH2VO9fmH0o=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -322,8 +330,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@@ -364,6 +370,8 @@ github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
github.com/tiktoken-go/tokenizer v0.8.0 h1:drHWno2Zx3eAm/hk/LmvBKXPpSImB7BRyh/ru4+3Q7Y=
github.com/tiktoken-go/tokenizer v0.8.0/go.mod h1:pTmPz4r14MV3JkUGAmAcdLdYhSxN68MCjrP+EoxBdx0=
github.com/tinylib/msgp v1.6.4 h1:mOwYbyYDLPj35mkA2BjjYejgJk9BuHxDdvRnb6v2ZcQ=
github.com/tinylib/msgp v1.6.4/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA=
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
@@ -374,6 +382,8 @@ github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4d
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs=
github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q=
github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/gopher-lua v1.1.1 h1:kYKnWBjvbNP4XLT3+bPEwAXJx262OhaHDWDVOPjL46M=
@@ -386,22 +396,22 @@ github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWB
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k=
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
go.opentelemetry.io/auto/sdk v1.1.0 h1:cH53jehLUN6UFLY71z+NDOiNJqDdPRaXzTel0sJySYA=
go.opentelemetry.io/auto/sdk v1.1.0/go.mod h1:3wSPjt5PWp2RhlCcmmOial7AvC4DQqZb7a7wCow3W8A=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
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.37.0 h1:9zhNfelUvx0KBfu/gb+ZgeAfAgtWrfHJZcAqFC228wQ=
go.opentelemetry.io/otel v1.37.0/go.mod h1:ehE/umFRLnuLa/vSccNq9oS1ErUlkkK71gMcN34UG8I=
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/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.37.0 h1:mvwbQS5m0tbmqML4NqK+e3aDiO02vsf/WgbsdpcPoZE=
go.opentelemetry.io/otel/metric v1.37.0/go.mod h1:04wGrZurHYKOc+RKeye86GwKiTb9FKm1WHtO+4EVr2E=
go.opentelemetry.io/otel/sdk v1.37.0 h1:ItB0QUqnjesGRvNcmAcU0LyvkVyGJ2xftD29bWdDvKI=
go.opentelemetry.io/otel/sdk v1.37.0/go.mod h1:VredYzxUvuo2q3WRcDnKDjbdvmO0sCzOvVAiY+yUkAg=
go.opentelemetry.io/otel/trace v1.37.0 h1:HLdcFNbRQBE2imdSEgm/kwqmQj1Or1l/7bW6mxVK7z4=
go.opentelemetry.io/otel/trace v1.37.0/go.mod h1:TlgrlQ+PtQO5XFerSPUYG0JSgGyryXewPGyayAWSBS0=
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/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=
@@ -419,24 +429,16 @@ go.uber.org/zap v1.24.0/go.mod h1:2kMP+WWQ8aoFoedH3T2sq6iJ2yDWpHbP0f6MQbS9Gkg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.3.0 h1:02VY4/ZcO/gBOH6PUaoiptASxtXU10jazRCP865E97k=
golang.org/x/arch v0.3.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI=
golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww=
golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA=
golang.org/x/mod v0.35.0 h1:Ww1D637e6Pg+Zb2KrWfHQUnH2dQRLBQyAtpr/haaJeM=
golang.org/x/mod v0.35.0/go.mod h1:+GwiRhIInF8wPm+4AoT6L0FA1QWAad3OMdTRx4tFYlU=
golang.org/x/image v0.41.0 h1:8wS72eGJMJaBxK6okTzd4WaXumUlTVlb753MlsSvTCo=
golang.org/x/image v0.41.0/go.mod h1:uIc348UZMSvS5Z65CVZ7iDPaNobNFEPeJ4kbqTOszmA=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8=
golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -448,22 +450,14 @@ golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY=
golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4=
golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc=
golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.44.0 h1:UP4ajHPIcuMjT1GqzDWRlalUEoY+uzoZKnhOjbIPD2c=
golang.org/x/tools v0.44.0/go.mod h1:KA0AfVErSdxRZIsOVipbv3rQhVXTnlU6UhKxHd1seDI=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
+87 -18
View File
@@ -73,6 +73,7 @@ type Config struct {
Ops OpsConfig `mapstructure:"ops"`
JWT JWTConfig `mapstructure:"jwt"`
Totp TotpConfig `mapstructure:"totp"`
WebAuthn WebAuthnConfig `mapstructure:"webauthn"`
LinuxDo LinuxDoConnectConfig `mapstructure:"linuxdo_connect"`
WeChat WeChatConnectConfig `mapstructure:"wechat_connect"`
OIDC OIDCConnectConfig `mapstructure:"oidc_connect"`
@@ -686,6 +687,16 @@ type CORSConfig struct {
AllowCredentials bool `mapstructure:"allow_credentials"`
}
// WebAuthnConfig configures this deployment as a WebAuthn relying party.
// RPID and RPOrigins are security boundaries and must never be inferred from
// untrusted request Host or Origin headers.
type WebAuthnConfig struct {
Enabled bool `mapstructure:"enabled"`
RPDisplayName string `mapstructure:"rp_display_name"`
RPID string `mapstructure:"rp_id"`
RPOrigins []string `mapstructure:"rp_origins"`
}
const MaxForwardedClientIPHeaders = 16
type ForwardedClientIPSettings struct {
@@ -913,6 +924,8 @@ type GatewayConfig struct {
OpenAICompactModel string `mapstructure:"openai_compact_model"`
// OpenAIWS: OpenAI Responses WebSocket 配置(默认开启,可按需回滚到 HTTP)
OpenAIWS GatewayOpenAIWSConfig `mapstructure:"openai_ws"`
// Live: ChatGPT Frameless Live 会话配置。
Live GatewayLiveConfig `mapstructure:"live"`
// OpenAIScheduler: OpenAI 高级调度器粘性逃逸配置
OpenAIScheduler GatewayOpenAISchedulerConfig `mapstructure:"openai_scheduler"`
// OpenAIHTTP2: OpenAI HTTP 上游协议策略(默认启用 HTTP/2,可按代理能力回退 HTTP/1.1)
@@ -999,6 +1012,11 @@ type GatewayConfig struct {
UserMessageQueue UserMessageQueueConfig `mapstructure:"user_message_queue"`
}
type GatewayLiveConfig struct {
// MaxSessionDurationSeconds 是 Live 会话的硬上限。
MaxSessionDurationSeconds int `mapstructure:"max_session_duration_seconds"`
}
// GatewayOpenAIHTTP2Config OpenAI HTTP 上游协议配置。
// 默认启用 HTTP/2;在部分代理不兼容时按策略回退 HTTP/1.1。
type GatewayOpenAIHTTP2Config struct {
@@ -1633,20 +1651,7 @@ func LoadForBootstrap() (*Config, error) {
func load(allowMissingJWTSecret bool) (*Config, error) {
viper.SetConfigName("config")
viper.SetConfigType("yaml")
// Add config paths in priority order
// 1. DATA_DIR environment variable (highest priority)
if dataDir := os.Getenv("DATA_DIR"); dataDir != "" {
viper.AddConfigPath(dataDir)
}
// 2. Docker data directory
viper.AddConfigPath("/app/data")
// 3. Current directory
viper.AddConfigPath(".")
// 4. Config subdirectory
viper.AddConfigPath("./config")
// 5. System config directory
viper.AddConfigPath("/etc/sub2api")
configureConfigSource(viper.SetConfigFile, viper.AddConfigPath)
// 环境变量支持
viper.AutomaticEnv()
@@ -1821,6 +1826,22 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
return &cfg, nil
}
func configureConfigSource(setConfigFile, addConfigPath func(string)) {
if configFile := strings.TrimSpace(os.Getenv("CONFIG_FILE")); configFile != "" {
setConfigFile(configFile)
return
}
// Add config paths in priority order.
if dataDir := strings.TrimSpace(os.Getenv("DATA_DIR")); dataDir != "" {
addConfigPath(dataDir)
}
addConfigPath("/app/data")
addConfigPath(".")
addConfigPath("./config")
addConfigPath("/etc/sub2api")
}
func setDefaults() {
viper.SetDefault("run_mode", RunModeStandard)
@@ -1865,12 +1886,21 @@ func setDefaults() {
viper.SetDefault("cors.allowed_origins", []string{})
viper.SetDefault("cors.allow_credentials", true)
// WebAuthn / Passkeys are opt-in because every deployment must explicitly
// declare its relying-party domain and trusted browser origins.
viper.SetDefault("webauthn.enabled", false)
viper.SetDefault("webauthn.rp_display_name", "Sub2API")
viper.SetDefault("webauthn.rp_id", "")
viper.SetDefault("webauthn.rp_origins", []string{})
// Security
viper.SetDefault("security.url_allowlist.enabled", false)
viper.SetDefault("security.url_allowlist.upstream_hosts", []string{
"api.openai.com",
"api.anthropic.com",
"api.kimi.com",
"api.moonshot.ai",
"api.moonshot.cn",
"open.bigmodel.cn",
"api.minimaxi.com",
"generativelanguage.googleapis.com",
@@ -2188,6 +2218,7 @@ func setDefaults() {
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
viper.SetDefault("gateway.live.max_session_duration_seconds", 3600)
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
viper.SetDefault("gateway.openai_ws.enabled", true)
viper.SetDefault("gateway.openai_ws.mode_router_v2_enabled", false)
@@ -2580,6 +2611,43 @@ func (c *Config) Validate() error {
}
warnIfInsecureURL("server.frontend_url", c.Server.FrontendURL)
}
if c.WebAuthn.Enabled {
c.WebAuthn.RPDisplayName = strings.TrimSpace(c.WebAuthn.RPDisplayName)
c.WebAuthn.RPID = strings.ToLower(strings.TrimSpace(c.WebAuthn.RPID))
c.WebAuthn.RPOrigins = normalizeStringSlice(c.WebAuthn.RPOrigins)
if c.WebAuthn.RPDisplayName == "" {
return fmt.Errorf("webauthn.rp_display_name is required when passkeys are enabled")
}
if c.WebAuthn.RPID == "" {
return fmt.Errorf("webauthn.rp_id is required when passkeys are enabled")
}
if strings.Contains(c.WebAuthn.RPID, "://") || strings.ContainsAny(c.WebAuthn.RPID, "/:") {
return fmt.Errorf("webauthn.rp_id must be a domain without scheme, port, or path")
}
if len(c.WebAuthn.RPOrigins) == 0 {
return fmt.Errorf("webauthn.rp_origins must contain at least one origin when passkeys are enabled")
}
for i, origin := range c.WebAuthn.RPOrigins {
u, err := url.Parse(origin)
if err != nil || u.Scheme == "" || u.Host == "" {
return fmt.Errorf("webauthn.rp_origins contains invalid origin %q", origin)
}
if u.User != nil || u.RawQuery != "" || u.Fragment != "" || u.Path != "" {
return fmt.Errorf("webauthn.rp_origins entry %q must not include userinfo, path, query, or fragment", origin)
}
u.Scheme = strings.ToLower(u.Scheme)
u.Host = strings.ToLower(u.Host)
host := strings.ToLower(u.Hostname())
localDevelopment := host == "localhost" || host == "127.0.0.1" || host == "::1"
if u.Scheme != "https" && (u.Scheme != "http" || !localDevelopment) {
return fmt.Errorf("webauthn.rp_origins entry %q must use HTTPS (HTTP is allowed only for localhost)", origin)
}
if host != c.WebAuthn.RPID && !strings.HasSuffix(host, "."+c.WebAuthn.RPID) {
return fmt.Errorf("webauthn.rp_origins entry %q is not within relying party ID %q", origin, c.WebAuthn.RPID)
}
c.WebAuthn.RPOrigins[i] = u.Scheme + "://" + u.Host
}
}
if c.JWT.ExpireHour <= 0 {
return fmt.Errorf("jwt.expire_hour must be positive")
}
@@ -3039,6 +3107,9 @@ func (c *Config) Validate() error {
(c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIHighEffortFirstOutputTimeoutSeconds < 30) {
return fmt.Errorf("gateway.openai_high_effort_first_output_timeout_seconds must be 0 or between 30-1800 seconds")
}
if c.Gateway.Live.MaxSessionDurationSeconds <= 0 {
c.Gateway.Live.MaxSessionDurationSeconds = 3600
}
if strings.TrimSpace(c.Gateway.ConnectionPoolIsolation) != "" {
switch c.Gateway.ConnectionPoolIsolation {
case ConnectionPoolIsolationProxy, ConnectionPoolIsolationAccount, ConnectionPoolIsolationAccountProxy:
@@ -3477,14 +3548,12 @@ func generateJWTSecret(byteLength int) (string, error) {
// GetServerAddress returns the server address (host:port) from config file or environment variable.
// This is a lightweight function that can be used before full config validation,
// such as during setup wizard startup.
// Priority: config.yaml > environment variables > defaults
// Priority: environment variables > config file > defaults.
func GetServerAddress() string {
v := viper.New()
v.SetConfigName("config")
v.SetConfigType("yaml")
v.AddConfigPath(".")
v.AddConfigPath("./config")
v.AddConfigPath("/etc/sub2api")
configureConfigSource(v.SetConfigFile, v.AddConfigPath)
// Support SERVER_HOST and SERVER_PORT environment variables
v.AutomaticEnv()
+76
View File
@@ -17,6 +17,9 @@ import (
func resetViperWithJWTSecret(t *testing.T) {
t.Helper()
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("CONFIG_FILE", "")
t.Setenv("DATA_DIR", "")
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
}
@@ -253,8 +256,53 @@ func TestLoadTrustedProxiesPresenceFromYAML(t *testing.T) {
}
}
func TestLoadTrustedProxiesFromConfigFile(t *testing.T) {
resetViperWithJWTSecret(t)
configFile := filepath.Join(t.TempDir(), "reporter-config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte(`server:
host: 192.0.2.10
trusted_proxies:
- 127.0.0.1/32
- 10.0.0.0/8
- 172.16.0.0/12
- 192.168.0.0/16
`), 0o600))
t.Setenv("CONFIG_FILE", configFile)
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, "192.0.2.10", cfg.Server.Host)
require.Equal(t, []string{"127.0.0.1/32", "10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16"}, cfg.Server.TrustedProxies)
require.True(t, cfg.Server.TrustedProxiesConfigured)
}
func TestConfigFileTakesPrecedenceOverDataDir(t *testing.T) {
resetViperWithJWTSecret(t)
dataDir := t.TempDir()
require.NoError(t, os.WriteFile(filepath.Join(dataDir, "config.yaml"), []byte("server:\n host: 192.0.2.20\n"), 0o600))
configFile := filepath.Join(t.TempDir(), "explicit.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("server:\n host: 192.0.2.30\n"), 0o600))
t.Setenv("DATA_DIR", dataDir)
t.Setenv("CONFIG_FILE", configFile)
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, "192.0.2.30", cfg.Server.Host)
}
func TestLoadReturnsErrorForMissingConfigFile(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("CONFIG_FILE", filepath.Join(t.TempDir(), "missing.yaml"))
_, err := Load()
require.ErrorContains(t, err, "read config error")
}
func TestLoadForBootstrapAllowsMissingJWTSecret(t *testing.T) {
viper.Reset()
t.Cleanup(viper.Reset)
t.Setenv("CONFIG_FILE", "")
t.Setenv("DATA_DIR", "")
t.Setenv("JWT_SECRET", "")
cfg, err := LoadForBootstrap()
@@ -707,6 +755,21 @@ func TestLoadDefaultSecurityToggles(t *testing.T) {
if !cfg.Security.ResponseHeaders.Enabled {
t.Fatalf("ResponseHeaders.Enabled = false, want true")
}
wantHosts := []string{
"api.kimi.com",
"api.moonshot.ai",
"api.moonshot.cn",
}
hostSet := make(map[string]struct{}, len(cfg.Security.URLAllowlist.UpstreamHosts))
for _, h := range cfg.Security.URLAllowlist.UpstreamHosts {
hostSet[h] = struct{}{}
}
for _, want := range wantHosts {
if _, ok := hostSet[want]; !ok {
t.Fatalf("URLAllowlist.UpstreamHosts missing %q; got %v", want, cfg.Security.URLAllowlist.UpstreamHosts)
}
}
}
func TestLoadDefaultServerMode(t *testing.T) {
@@ -1155,6 +1218,8 @@ func TestNormalizeStringSlice(t *testing.T) {
}
func TestGetServerAddressFromEnv(t *testing.T) {
t.Setenv("CONFIG_FILE", "")
t.Setenv("DATA_DIR", "")
t.Setenv("SERVER_HOST", "127.0.0.1")
t.Setenv("SERVER_PORT", "9090")
@@ -1164,6 +1229,17 @@ func TestGetServerAddressFromEnv(t *testing.T) {
}
}
func TestGetServerAddressFromConfigFile(t *testing.T) {
configFile := filepath.Join(t.TempDir(), "setup.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("server:\n host: 192.0.2.40\n port: 9191\n"), 0o600))
t.Setenv("CONFIG_FILE", configFile)
t.Setenv("DATA_DIR", "")
t.Setenv("SERVER_HOST", "")
t.Setenv("SERVER_PORT", "")
require.Equal(t, "192.0.2.40:9191", GetServerAddress())
}
func TestValidateAbsoluteHTTPURL(t *testing.T) {
if err := ValidateAbsoluteHTTPURL("https://example.com/path"); err != nil {
t.Fatalf("ValidateAbsoluteHTTPURL valid url error: %v", err)
+104
View File
@@ -0,0 +1,104 @@
package config
import (
"strings"
"testing"
"github.com/spf13/viper"
"github.com/stretchr/testify/require"
)
func TestValidateWebAuthnConfig(t *testing.T) {
tests := []struct {
name string
configure func(*Config)
wantError string
}{
{
name: "valid production origin",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "sub2api.example.com",
RPOrigins: []string{"https://sub2api.example.com"},
}
},
},
{
name: "valid localhost development origin",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API Dev",
RPID: "localhost",
RPOrigins: []string{"http://localhost:5173"},
}
},
},
{
name: "missing relying party id",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPOrigins: []string{"https://sub2api.example.com"},
}
},
wantError: "webauthn.rp_id",
},
{
name: "relying party id contains scheme",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "https://sub2api.example.com",
RPOrigins: []string{"https://sub2api.example.com"},
}
},
wantError: "domain without scheme",
},
{
name: "non-local insecure origin",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "sub2api.example.com",
RPOrigins: []string{"http://sub2api.example.com"},
}
},
wantError: "must use HTTPS",
},
{
name: "origin outside relying party id",
configure: func(cfg *Config) {
cfg.WebAuthn = WebAuthnConfig{
Enabled: true,
RPDisplayName: "Sub2API",
RPID: "example.com",
RPOrigins: []string{"https://example.net"},
}
},
wantError: "not within relying party ID",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
viper.Reset()
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
cfg, err := Load()
require.NoError(t, err)
tt.configure(cfg)
err = cfg.Validate()
if tt.wantError == "" {
require.NoError(t, err)
} else {
require.ErrorContains(t, err, tt.wantError)
}
})
}
}
+1
View File
@@ -133,6 +133,7 @@ var DefaultBedrockModelMapping = map[string]string{
// Claude Fable
"claude-fable-5": "anthropic.claude-fable-5",
// Claude Opus
"claude-opus-5": "us.anthropic.claude-opus-5-v1",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-opus-4-7": "us.anthropic.claude-opus-4-7-v1",
"claude-opus-4-6-thinking": "us.anthropic.claude-opus-4-6-v1",
@@ -61,7 +61,7 @@ func (r *ollamaCloudUsageHandlerTestRepo) UpdateOllamaCloudUsageSnapshot(context
func (r *ollamaCloudUsageHandlerTestRepo) DisableOllamaCloudUsageAutoRefresh(context.Context, *service.Account) error {
return nil
}
func (r *ollamaCloudUsageHandlerTestRepo) ListDueOllamaCloudUsageAccounts(context.Context, time.Time, int) ([]service.Account, error) {
func (r *ollamaCloudUsageHandlerTestRepo) ListDueOllamaCloudUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]service.Account, error) {
return nil, nil
}
@@ -273,4 +273,5 @@ func TestGetOllamaCloudUsageSettingsHandlerSuccess(t *testing.T) {
require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"enabled":false`)
require.Contains(t, recorder.Body.String(), `"interval_minutes":60`)
require.Contains(t, recorder.Body.String(), `"debounce_minutes":1`)
}
@@ -13,6 +13,7 @@ import (
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"
"github.com/gin-gonic/gin"
@@ -25,6 +26,16 @@ type GroupHandler struct {
groupCapacityService *service.GroupCapacityService
}
// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。
func (h *GroupHandler) GetLiveCapability(c *gin.Context) {
err := liveattestation.NewProvider().Check(c.Request.Context())
result := gin.H{"supported": err == nil}
if err != nil {
result["reason"] = err.Error()
}
response.Success(c, result)
}
type optionalLimitField struct {
set bool
value *float64
@@ -125,6 +136,7 @@ type CreateGroupRequest struct {
SupportedModelScopes []string `json:"supported_model_scopes"`
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
AllowLive bool `json:"allow_live"`
RequireOAuthOnly bool `json:"require_oauth_only"`
RequirePrivacySet bool `json:"require_privacy_set"`
DefaultMappedModel string `json:"default_mapped_model"`
@@ -183,6 +195,7 @@ type UpdateGroupRequest struct {
SupportedModelScopes *[]string `json:"supported_model_scopes"`
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
AllowLive *bool `json:"allow_live"`
RequireOAuthOnly *bool `json:"require_oauth_only"`
RequirePrivacySet *bool `json:"require_privacy_set"`
DefaultMappedModel *string `json:"default_mapped_model"`
@@ -499,6 +512,7 @@ func (h *GroupHandler) Create(c *gin.Context) {
MCPXMLInject: req.MCPXMLInject,
SupportedModelScopes: req.SupportedModelScopes,
AllowMessagesDispatch: req.AllowMessagesDispatch,
AllowLive: req.AllowLive,
RequireOAuthOnly: req.RequireOAuthOnly,
RequirePrivacySet: req.RequirePrivacySet,
DefaultMappedModel: req.DefaultMappedModel,
@@ -617,6 +631,7 @@ func (h *GroupHandler) Update(c *gin.Context) {
MCPXMLInject: req.MCPXMLInject,
SupportedModelScopes: req.SupportedModelScopes,
AllowMessagesDispatch: req.AllowMessagesDispatch,
AllowLive: req.AllowLive,
RequireOAuthOnly: req.RequireOAuthOnly,
RequirePrivacySet: req.RequirePrivacySet,
DefaultMappedModel: req.DefaultMappedModel,
@@ -123,6 +123,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
if paymentCfg == nil {
paymentCfg = &service.PaymentConfig{}
}
passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration()
payload := dto.SystemSettings{
RegistrationEnabled: settings.RegistrationEnabled,
@@ -134,6 +135,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
PasskeyEnabled: settings.PasskeyEnabled,
PasskeyConfigured: passkeyConfigured,
PasskeyRPID: passkeyRPID,
PasskeyRPOrigins: passkeyRPOrigins,
SessionBindingEnabled: settings.SessionBindingEnabled,
StepUpEnabled: settings.StepUpEnabled,
AuditLogRetentionDays: settings.AuditLogRetentionDays,
@@ -346,6 +351,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
ModelPlazaDescription: settings.ModelPlazaDescription,
AffiliateEnabled: settings.AffiliateEnabled,
AllowUserViewErrorRequests: settings.AllowUserViewErrorRequests,
@@ -56,6 +56,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.TotpEnabled != after.TotpEnabled {
changed = append(changed, "totp_enabled")
}
if before.PasskeyEnabled != after.PasskeyEnabled {
changed = append(changed, "passkey_enabled")
}
if before.SessionBindingEnabled != after.SessionBindingEnabled {
changed = append(changed, "session_binding_enabled")
}
@@ -525,6 +528,15 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
changed = append(changed, "model_plaza_enabled")
}
if before.ModelPlazaRequireAuth != after.ModelPlazaRequireAuth {
changed = append(changed, "model_plaza_require_auth")
}
if before.ModelPlazaDescription != after.ModelPlazaDescription {
changed = append(changed, "model_plaza_description")
}
if before.AffiliateEnabled != after.AffiliateEnabled {
changed = append(changed, "affiliate_enabled")
}
@@ -0,0 +1,67 @@
//go:build unit
package admin
import (
"net/http"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// Saving settings is a whole-document PUT. A client that sends only the field it
// cares about must not reset everything else: a payload as small as
// `{"risk_control_enabled":true}` used to clear site_name, after which
// getStringOrDefault rendered the empty value as the built-in default and the
// login page silently changed name.
func TestUpdateSettingsPartialPayloadKeepsUnsentKeys(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeySiteName: "Example Gateway",
service.SettingKeySiteSubtitle: "Example Gateway Platform",
service.SettingKeySMTPHost: "smtp.example.com",
service.SettingKeySMTPFrom: "noreply@example.com",
service.SettingKeyTurnstileEnabled: "true",
})
rec := doUpdateSettings(t, h, map[string]any{"risk_control_enabled": true}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "true", repo.values[service.SettingKeyRiskControlEnabled],
"the field the caller actually sent must be written")
require.Equal(t, "Example Gateway", repo.values[service.SettingKeySiteName])
require.Equal(t, "Example Gateway Platform", repo.values[service.SettingKeySiteSubtitle])
require.Equal(t, "smtp.example.com", repo.values[service.SettingKeySMTPHost])
require.Equal(t, "noreply@example.com", repo.values[service.SettingKeySMTPFrom])
require.Equal(t, "true", repo.values[service.SettingKeyTurnstileEnabled])
}
// A full payload keeps whole-document semantics: fields explicitly set to their
// zero value are still cleared.
func TestUpdateSettingsFullPayloadStillClearsSentEmptyFields(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeySiteName: "Example Gateway",
})
rec := doUpdateSettings(t, h, map[string]any{"site_name": ""}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "", repo.values[service.SettingKeySiteName],
"an explicitly sent empty value is a deliberate clear, not an omission")
}
// smtp_from_email is the one request field whose JSON name differs from its
// setting key; the alias keeps it from being treated as always-omitted.
func TestUpdateSettingsSMTPFromAliasIsWritable(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeySMTPFrom: "old@example.com",
})
rec := doUpdateSettings(t, h, map[string]any{"smtp_from_email": "new@example.com"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "new@example.com", repo.values[service.SettingKeySMTPFrom])
}
@@ -154,6 +154,70 @@ func (h *SettingHandler) UpdateRateLimit429CooldownSettings(c *gin.Context) {
})
}
// GetPanelRateLimitSettings 获取面板 API 限流配置
// GET /api/v1/admin/settings/panel-rate-limit
func (h *SettingHandler) GetPanelRateLimitSettings(c *gin.Context) {
settings, err := h.settingService.GetPanelRateLimitSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.PanelRateLimitSettings{
Enabled: settings.Enabled,
UserRPM: settings.UserRPM,
HeavyRPM: settings.HeavyRPM,
ExemptAdmin: settings.ExemptAdmin,
PublicIPRPM: settings.PublicIPRPM,
})
}
// UpdatePanelRateLimitSettingsRequest 更新面板 API 限流配置请求
type UpdatePanelRateLimitSettingsRequest struct {
Enabled bool `json:"enabled"`
UserRPM int `json:"user_rpm"`
HeavyRPM int `json:"heavy_rpm"`
ExemptAdmin bool `json:"exempt_admin"`
PublicIPRPM int `json:"public_ip_rpm"`
}
// UpdatePanelRateLimitSettings 更新面板 API 限流配置
// PUT /api/v1/admin/settings/panel-rate-limit
func (h *SettingHandler) UpdatePanelRateLimitSettings(c *gin.Context) {
var req UpdatePanelRateLimitSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
settings := &service.PanelRateLimitSettings{
Enabled: req.Enabled,
UserRPM: req.UserRPM,
HeavyRPM: req.HeavyRPM,
ExemptAdmin: req.ExemptAdmin,
PublicIPRPM: req.PublicIPRPM,
}
if err := h.settingService.SetPanelRateLimitSettings(c.Request.Context(), settings); err != nil {
response.BadRequest(c, err.Error())
return
}
updatedSettings, err := h.settingService.GetPanelRateLimitSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, dto.PanelRateLimitSettings{
Enabled: updatedSettings.Enabled,
UserRPM: updatedSettings.UserRPM,
HeavyRPM: updatedSettings.HeavyRPM,
ExemptAdmin: updatedSettings.ExemptAdmin,
PublicIPRPM: updatedSettings.PublicIPRPM,
})
}
// GetStreamTimeoutSettings 获取流超时处理配置
// GET /api/v1/admin/settings/stream-timeout
func (h *SettingHandler) GetStreamTimeoutSettings(c *gin.Context) {
@@ -6,6 +6,7 @@ import (
"errors"
"log/slog"
"net/http"
"reflect"
"strings"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -15,6 +16,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/gin-gonic/gin/binding"
)
// UpdateSettingsRequest 更新设置请求
@@ -28,6 +30,7 @@ type UpdateSettingsRequest struct {
FrontendURL string `json:"frontend_url"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
PasskeyEnabled *bool `json:"passkey_enabled"` // Passkey 登录(省略=保持现值)
SessionBindingEnabled *bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定(省略=保持现值)
StepUpEnabled *bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA(省略=保持现值)
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
@@ -310,6 +313,11 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
// Model Plaza feature switches + description
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
ModelPlazaDescription *string `json:"model_plaza_description"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled *bool `json:"affiliate_enabled"`
@@ -375,12 +383,70 @@ func (h *SettingHandler) ensureActorTotpForStepUp(c *gin.Context) bool {
return true
}
// settingKeyJSONAliases covers the request fields whose JSON name differs from
// the setting key they persist to. Every other field of UpdateSettingsRequest
// is named after its setting key.
var settingKeyJSONAliases = map[string]string{
"smtp_from_email": service.SettingKeySMTPFrom,
}
// settingKeyByJSONName maps the value-typed top-level JSON fields of
// UpdateSettingsRequest to the setting key each one writes. Resolved once from
// the struct tags so new fields are covered without touching this file.
//
// Pointer-typed fields are deliberately excluded: they already carry their own
// "omitted = keep the stored value" merge in UpdateSettings, and some of them
// rely on being rewritten on every save to re-normalize fail-closed security
// state (see TestUpdateSettingsMalformedForwardedClientIPHeadersRemainFailClosedWhenOmitted).
// Only the value-typed fields are indistinguishable from a deliberate clear.
var settingKeyByJSONName = buildSettingKeyByJSONName()
func buildSettingKeyByJSONName() map[string]string {
t := reflect.TypeOf(UpdateSettingsRequest{})
out := make(map[string]string, t.NumField())
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Type.Kind() == reflect.Ptr {
continue
}
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
if name == "" || name == "-" {
continue
}
if alias, ok := settingKeyJSONAliases[name]; ok {
out[name] = alias
continue
}
out[name] = name
}
return out
}
// omittedSettingKeys reports the setting keys this payload never mentioned.
// Saving settings is a whole-document PUT, so without this a client that sends
// only the one field it cares about resets every other field to a zero value.
func omittedSettingKeys(sentFields map[string]json.RawMessage) service.OmittedSettingKeys {
omitted := make(service.OmittedSettingKeys, len(settingKeyByJSONName))
for jsonName, settingKey := range settingKeyByJSONName {
if _, sent := sentFields[jsonName]; !sent {
omitted[settingKey] = struct{}{}
}
}
return omitted
}
func (h *SettingHandler) UpdateSettings(c *gin.Context) {
var req UpdateSettingsRequest
if err := c.ShouldBindJSON(&req); err != nil {
var sentFields map[string]json.RawMessage
if err := c.ShouldBindBodyWith(&sentFields, binding.JSON); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
var req UpdateSettingsRequest
if err := c.ShouldBindBodyWith(&req, binding.JSON); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
omitted := omittedSettingKeys(sentFields)
previousSettings, err := h.settingService.GetAllSettings(c.Request.Context())
if err != nil {
@@ -403,6 +469,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if req.StepUpEnabled != nil {
stepUpEnabled = *req.StepUpEnabled
}
passkeyEnabled := previousSettings.PasskeyEnabled
if req.PasskeyEnabled != nil {
passkeyEnabled = *req.PasskeyEnabled
}
if passkeyEnabled {
configured, _, _ := h.settingService.PasskeyConfiguration()
if !configured {
response.BadRequest(c, "Passkey sign-in requires a valid WebAuthn RP ID and allowed HTTPS origins in the deployment configuration")
return
}
}
forwardedClientIPHeaders := append([]string(nil), previousSettings.ForwardedClientIPHeaders...)
if req.ForwardedClientIPHeaders != nil {
forwardedClientIPHeaders = append([]string(nil), (*req.ForwardedClientIPHeaders)...)
@@ -1253,6 +1330,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
FrontendURL: req.FrontendURL,
InvitationCodeEnabled: req.InvitationCodeEnabled,
TotpEnabled: req.TotpEnabled,
PasskeyEnabled: passkeyEnabled,
SessionBindingEnabled: sessionBindingEnabled,
StepUpEnabled: stepUpEnabled,
AuditLogRetentionDays: req.AuditLogRetentionDays,
@@ -1605,6 +1683,24 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AvailableChannelsEnabled
}(),
ModelPlazaEnabled: func() bool {
if req.ModelPlazaEnabled != nil {
return *req.ModelPlazaEnabled
}
return previousSettings.ModelPlazaEnabled
}(),
ModelPlazaRequireAuth: func() bool {
if req.ModelPlazaRequireAuth != nil {
return *req.ModelPlazaRequireAuth
}
return previousSettings.ModelPlazaRequireAuth
}(),
ModelPlazaDescription: func() string {
if req.ModelPlazaDescription != nil {
return *req.ModelPlazaDescription
}
return previousSettings.ModelPlazaDescription
}(),
AffiliateEnabled: func() bool {
if req.AffiliateEnabled != nil {
return *req.AffiliateEnabled
@@ -1692,7 +1788,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
},
ForceEmailOnThirdPartySignup: boolValueOrDefault(req.ForceEmailOnThirdPartySignup, previousAuthSourceDefaults.ForceEmailOnThirdPartySignup),
}
if err := h.settingService.UpdateSettingsWithAuthSourceDefaults(c.Request.Context(), settings, authSourceDefaults); err != nil {
if err := h.settingService.UpdateSettingsWithAuthSourceDefaultsOmitting(c.Request.Context(), settings, authSourceDefaults, omitted); err != nil {
response.ErrorFrom(c, err)
return
}
@@ -1776,6 +1872,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
if updatedPaymentCfg == nil {
updatedPaymentCfg = &service.PaymentConfig{}
}
passkeyConfigured, passkeyRPID, passkeyRPOrigins := h.settingService.PasskeyConfiguration()
payload := dto.SystemSettings{
RegistrationEnabled: updatedSettings.RegistrationEnabled,
@@ -1787,6 +1884,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
InvitationCodeEnabled: updatedSettings.InvitationCodeEnabled,
TotpEnabled: updatedSettings.TotpEnabled,
TotpEncryptionKeyConfigured: h.settingService.IsTotpEncryptionKeyConfigured(),
PasskeyEnabled: updatedSettings.PasskeyEnabled,
PasskeyConfigured: passkeyConfigured,
PasskeyRPID: passkeyRPID,
PasskeyRPOrigins: passkeyRPOrigins,
SessionBindingEnabled: updatedSettings.SessionBindingEnabled,
StepUpEnabled: updatedSettings.StepUpEnabled,
AuditLogRetentionDays: updatedSettings.AuditLogRetentionDays,
@@ -1995,6 +2096,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
@@ -110,6 +110,7 @@ func (h *UsageHandler) List(c *gin.Context) {
}
model := c.Query("model")
requestID := strings.TrimSpace(c.Query("request_id"))
billingMode := strings.TrimSpace(c.Query("billing_mode"))
var requestType *int16
@@ -176,6 +177,7 @@ func (h *UsageHandler) List(c *gin.Context) {
APIKeyID: apiKeyID,
AccountID: accountID,
GroupID: groupID,
RequestID: requestID,
Model: model,
ModelFilterSource: usagestats.ModelSourceRequested,
RequestType: requestType,
@@ -107,6 +107,18 @@ func TestAdminUsageListExactTotalTrue(t *testing.T) {
require.True(t, repo.listFilters.ExactTotal)
}
func TestAdminUsageListRequestIDFilter(t *testing.T) {
repo := &adminUsageRepoCapture{}
router := newAdminUsageRequestTypeTestRouter(repo)
req := httptest.NewRequest(http.MethodGet, "/admin/usage?request_id=req-0123", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "req-0123", repo.listFilters.RequestID)
}
func TestAdminUsageListInvalidExactTotal(t *testing.T) {
repo := &adminUsageRepoCapture{}
router := newAdminUsageRequestTypeTestRouter(repo)
+6 -2
View File
@@ -98,16 +98,20 @@ func ensureLoginUserActive(user *service.User) error {
// respondWithTokenPair 生成 Token 对并返回认证响应
// 如果 Token 对生成失败,回退到只返回 Access Token(向后兼容)
func (h *AuthHandler) respondWithTokenPair(c *gin.Context, user *service.User) {
respondWithTokenPair(c, h.authService, user)
}
func respondWithTokenPair(c *gin.Context, authService *service.AuthService, user *service.User) {
if err := ensureLoginUserActive(user); err != nil {
response.ErrorFrom(c, err)
return
}
tokenPair, err := h.authService.GenerateTokenPair(c.Request.Context(), user, "")
tokenPair, err := authService.GenerateTokenPair(c.Request.Context(), user, "")
if err != nil {
slog.Error("failed to generate token pair", "error", err, "user_id", user.ID)
// 回退到只返回Access Token
token, tokenErr := h.authService.GenerateToken(c.Request.Context(), user)
token, tokenErr := authService.GenerateToken(c.Request.Context(), user)
if tokenErr != nil {
response.InternalError(c, "Failed to generate token")
return
@@ -2978,6 +2978,17 @@ func (r *oauthPendingFlowUserRepo) Create(ctx context.Context, user *service.Use
return nil
}
func (r *oauthPendingFlowUserRepo) CreateWithEmailAliasGuard(ctx context.Context, user *service.User) error {
aliasExists, err := r.ExistsByEmailAlias(ctx, user.Email)
if err != nil {
return err
}
if aliasExists {
return service.ErrEmailExists
}
return r.Create(ctx, user)
}
func (r *oauthPendingFlowUserRepo) GetByID(ctx context.Context, id int64) (*service.User, error) {
entity, err := r.client.User.Get(ctx, id)
if err != nil {
@@ -3004,7 +3015,7 @@ func (r *oauthPendingFlowUserRepo) GetFirstAdmin(context.Context) (*service.User
panic("unexpected GetFirstAdmin call")
}
func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.User) error {
func (r *oauthPendingFlowUserRepo) Update(ctx context.Context, user *service.User, fields service.UserUpdateFields) error {
entity, err := r.client.User.UpdateOneID(user.ID).
SetEmail(user.Email).
SetUsername(user.Username).
@@ -3157,6 +3168,14 @@ func (r *oauthPendingFlowUserRepo) DeductBalance(context.Context, int64, float64
panic("unexpected DeductBalance call")
}
func (r *oauthPendingFlowUserRepo) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
panic("unexpected AdjustBalance call")
}
func (r *oauthPendingFlowUserRepo) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
panic("unexpected SetBalance call")
}
func (r *oauthPendingFlowUserRepo) UpdateConcurrency(context.Context, int64, int) error {
panic("unexpected UpdateConcurrency call")
}
@@ -3185,6 +3204,20 @@ func (r *oauthPendingFlowUserRepo) ExistsByEmail(ctx context.Context, email stri
return count > 0, err
}
func (r *oauthPendingFlowUserRepo) ExistsByEmailAlias(ctx context.Context, email string) (bool, error) {
identity := service.NormalizeEmailForAliasDedup(email)
emails, err := r.client.User.Query().Select(dbuser.FieldEmail).Strings(ctx)
if err != nil {
return false, err
}
for _, stored := range emails {
if service.NormalizeEmailForAliasDedup(stored) == identity {
return true, nil
}
}
return false, nil
}
func (r *oauthPendingFlowUserRepo) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
panic("unexpected RemoveGroupFromAllowedGroups call")
}
@@ -47,7 +47,11 @@ func TestAuthHandlerRevokeAllSessionsInvalidatesAccessTokens(t *testing.T) {
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []int64{29}, refreshTokenCache.revokedUserIDs)
require.Equal(t, int64(8), repo.user.TokenVersion)
// users 表没有 token_version 列(见 resolvedTokenVersion:JWT 里的值由
// email+password_hash 指纹推导),所以自增 TokenVersion 只停留在内存里。
// 此前紧跟其后的整行 Update 不写任何有效数据,却会用旧快照覆盖并发写入的列,
// 已移除。会话撤销由上面的 refresh session 清理承担。
require.Equal(t, int64(7), repo.user.TokenVersion)
var resp struct {
Code int `json:"code"`
@@ -42,6 +42,9 @@ func (h *BatchImageHandler) Submit(c *gin.Context) {
if !h.checkSecurityAuditBeforeSubmit(c, &req) {
return
}
if sessionID := service.ExtractClientSessionID(c); sessionID != "" {
req.SessionID = &sessionID
}
got, err := h.service.Submit(c.Request.Context(), owner, req, c.GetHeader("Idempotency-Key"))
if err != nil {
batchImageError(c, err)
+2
View File
@@ -204,6 +204,7 @@ func groupFromServiceBase(g *service.Group) Group {
FallbackGroupID: g.FallbackGroupID,
FallbackGroupIDOnInvalidRequest: g.FallbackGroupIDOnInvalidRequest,
AllowMessagesDispatch: g.AllowMessagesDispatch,
AllowLive: g.AllowLive,
RequireOAuthOnly: g.RequireOAuthOnly,
RequirePrivacySet: g.RequirePrivacySet,
RPMLimit: g.RPMLimit,
@@ -669,6 +670,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
MediaType: l.MediaType,
UserAgent: l.UserAgent,
IPAddress: l.IPAddress,
SessionID: l.SessionID,
CacheTTLOverridden: l.CacheTTLOverridden,
BillingMode: l.BillingMode,
CreatedAt: l.CreatedAt,
+25 -3
View File
@@ -36,9 +36,13 @@ type SystemSettings struct {
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
TotpEncryptionKeyConfigured bool `json:"totp_encryption_key_configured"` // TOTP 加密密钥是否已配置
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
PasskeyEnabled bool `json:"passkey_enabled"`
PasskeyConfigured bool `json:"passkey_configured"`
PasskeyRPID string `json:"passkey_rp_id"`
PasskeyRPOrigins []string `json:"passkey_rp_origins"`
SessionBindingEnabled bool `json:"session_binding_enabled"` // 会话 IP/UA 绑定
StepUpEnabled bool `json:"step_up_enabled"` // 敏感操作 step-up 2FA
AuditLogRetentionDays int `json:"audit_log_retention_days"` // 审计日志保留天数
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
@@ -286,6 +290,11 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
ModelPlazaDescription string `json:"model_plaza_description"`
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
@@ -320,6 +329,7 @@ type PublicSettings struct {
PasswordResetEnabled bool `json:"password_reset_enabled"`
InvitationCodeEnabled bool `json:"invitation_code_enabled"`
TotpEnabled bool `json:"totp_enabled"` // TOTP 双因素认证
PasskeyEnabled bool `json:"passkey_enabled"`
LoginAgreementEnabled bool `json:"login_agreement_enabled"`
LoginAgreementMode string `json:"login_agreement_mode"`
LoginAgreementUpdatedAt string `json:"login_agreement_updated_at"`
@@ -369,6 +379,9 @@ type PublicSettings struct {
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
AffiliateEnabled bool `json:"affiliate_enabled"`
RiskControlEnabled bool `json:"risk_control_enabled"`
@@ -394,6 +407,15 @@ type RateLimit429CooldownSettings struct {
CooldownSeconds int `json:"cooldown_seconds"`
}
// PanelRateLimitSettings 面板 API 限流配置 DTO
type PanelRateLimitSettings struct {
Enabled bool `json:"enabled"`
UserRPM int `json:"user_rpm"`
HeavyRPM int `json:"heavy_rpm"`
ExemptAdmin bool `json:"exempt_admin"`
PublicIPRPM int `json:"public_ip_rpm"`
}
// StreamTimeoutSettings 流超时处理配置 DTO
type StreamTimeoutSettings struct {
Enabled bool `json:"enabled"`
+5
View File
@@ -132,6 +132,8 @@ type Group struct {
// OpenAI Messages 调度开关(用户侧需要此字段判断是否展示 Claude Code 教程)
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
// OpenAI Live 接口开关
AllowLive bool `json:"allow_live"`
// 账号过滤控制(仅 OpenAI/Antigravity 平台有效)
RequireOAuthOnly bool `json:"require_oauth_only"`
@@ -521,6 +523,9 @@ type UsageLog struct {
UserAgent *string `json:"user_agent"`
// IPAddress is visible to the owner of the usage record.
IPAddress *string `json:"ip_address,omitempty"`
// SessionID is the explicit client-provided request correlation identifier
// (e.g. the session_id / X-Session-Id headers). Omitted when absent.
SessionID *string `json:"session_id,omitempty"`
// Cache TTL Override 标记
CacheTTLOverridden bool `json:"cache_ttl_overridden"`
+23 -1
View File
@@ -31,9 +31,12 @@ const (
EndpointGeminiModels = "/v1beta/models"
)
const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent"
// gin.Context keys used by the middleware and helpers below.
const (
ctxKeyInboundEndpoint = "_gateway_inbound_endpoint"
ctxKeyInboundEndpoint = "_gateway_inbound_endpoint"
ctxKeyActualUpstreamEndpoint = "_gateway_actual_upstream_endpoint"
)
// ──────────────────────────────────────────────────────────
@@ -297,6 +300,13 @@ func GetInboundEndpoint(c *gin.Context) string {
// and the account platform. Handlers call this after scheduling an
// account, passing account.Platform.
func GetUpstreamEndpoint(c *gin.Context, platform string) string {
if c != nil {
if value, ok := c.Get(ctxKeyActualUpstreamEndpoint); ok {
if endpoint, ok := value.(string); ok && endpoint != "" {
return endpoint
}
}
}
inbound := GetInboundEndpoint(c)
rawPath := ""
if c != nil && c.Request != nil && c.Request.URL != nil {
@@ -304,3 +314,15 @@ func GetUpstreamEndpoint(c *gin.Context, platform string) string {
}
return DeriveUpstreamEndpoint(inbound, rawPath, platform)
}
func setActualUpstreamEndpoint(c *gin.Context, endpoint string) {
if c != nil {
c.Set(ctxKeyActualUpstreamEndpoint, strings.TrimSpace(endpoint))
}
}
func shouldUseAntigravityCompat(account *service.Account) bool {
return account != nil &&
account.Platform == service.PlatformAntigravity &&
account.Type == service.AccountTypeOAuth
}
+34
View File
@@ -146,6 +146,40 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
}
}
func TestShouldUseAntigravityCompat(t *testing.T) {
tests := []struct {
name string
account *service.Account
want bool
}{
{"oauth", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeOAuth}, true},
{"setup token", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeSetupToken}, false},
{"upstream", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeUpstream}, false},
{"api key", &service.Account{Platform: service.PlatformAntigravity, Type: service.AccountTypeAPIKey}, false},
{"anthropic oauth", &service.Account{Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth}, false},
{"nil", nil, false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldUseAntigravityCompat(tt.account))
})
}
}
func TestGetUpstreamEndpointPrefersRuntimeOverride(t *testing.T) {
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, EndpointChatCompletions, nil)
c.Set(ctxKeyInboundEndpoint, EndpointChatCompletions)
setActualUpstreamEndpoint(c, EndpointAntigravityGenerateContent)
require.Equal(t, EndpointAntigravityGenerateContent, GetUpstreamEndpoint(c, service.PlatformAntigravity))
setActualUpstreamEndpoint(c, "")
require.Equal(t, EndpointMessages, GetUpstreamEndpoint(c, service.PlatformAntigravity))
}
func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
tests := []struct {
name string
+19 -15
View File
@@ -535,6 +535,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -547,6 +548,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
@@ -970,6 +972,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -982,6 +985,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
@@ -2122,22 +2126,22 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
switch interceptType {
case InterceptTypeSuggestionMode:
msgID = "msg_mock_suggestion"
msgID = generateRealisticMsgID()
outputTokens = 1
textDeltas = []string{""} // 空内容
default: // InterceptTypeWarmup
msgID = "msg_mock_warmup"
msgID = generateRealisticMsgID()
outputTokens = 2
textDeltas = []string{"New", " Conversation"}
}
// Build message_start event with fixed schema.
messageStartJSON := `{"type":"message_start","message":{"id":` + strconv.Quote(msgID) + `,"type":"message","role":"assistant","model":` + strconv.Quote(model) + `,"content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":10,"output_tokens":0}}}`
// Build message_start event — field order matches real Anthropic API response.
messageStartJSON := `{"type":"message_start","message":{"model":` + strconv.Quote(model) + `,"id":` + strconv.Quote(msgID) + `,"type":"message","role":"assistant","content":[],"stop_reason":null,"stop_sequence":null,"stop_details":null,"usage":{"input_tokens":10,"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"output_tokens":0}}}`
// Build events
events := []string{
`event: message_start` + "\n" + `data: ` + string(messageStartJSON),
`event: content_block_start` + "\n" + `data: {"content_block":{"text":"","type":"text"},"index":0,"type":"content_block_start"}`,
`event: content_block_start` + "\n" + `data: {"type":"content_block_start","index":0,"content_block":{"type":"text","text":""}}`,
}
// Add text deltas
@@ -2147,7 +2151,7 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
}
// Add final events
messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":10,"output_tokens":` + strconv.Itoa(outputTokens) + `}}`
messageDeltaJSON := `{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null,"stop_details":null},"usage":{"output_tokens":` + strconv.Itoa(outputTokens) + `}}`
events = append(events,
`event: content_block_stop`+"\n"+`data: {"index":0,"type":"content_block_stop"}`,
@@ -2162,20 +2166,20 @@ func sendMockInterceptStream(c *gin.Context, model string, interceptType Interce
}
}
// generateRealisticMsgID 生成仿真的消息 ID(msg_bdrk_XXXXXXX 格式)
// 格式与 Claude API 真实响应一致,24 位随机字母数字
// generateRealisticMsgID 生成仿真的消息 ID(msg_01XXXXXXX 格式)
// 格式与 Anthropic API 官方响应一致:msg_01 + 22 位 Base62 随机字符
func generateRealisticMsgID() string {
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const idLen = 24
const idLen = 22
randomBytes := make([]byte, idLen)
if _, err := rand.Read(randomBytes); err != nil {
return fmt.Sprintf("msg_bdrk_%d", time.Now().UnixNano())
return fmt.Sprintf("msg_01%d", time.Now().UnixNano())
}
b := make([]byte, idLen)
for i := range b {
b[i] = charset[int(randomBytes[i])%len(charset)]
}
return "msg_bdrk_" + string(b)
return "msg_01" + string(b)
}
// sendMockInterceptResponse 发送非流式 mock 响应(用于请求拦截)
@@ -2185,7 +2189,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
switch interceptType {
case InterceptTypeSuggestionMode:
msgID = "msg_mock_suggestion"
msgID = generateRealisticMsgID()
text = ""
outputTokens = 1
stopReason = "end_turn"
@@ -2195,13 +2199,13 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
outputTokens = 1
stopReason = "max_tokens" // max_tokens=1 探测请求的 stop_reason 应为 max_tokens
default: // InterceptTypeWarmup
msgID = "msg_mock_warmup"
msgID = generateRealisticMsgID()
text = "New Conversation"
outputTokens = 2
stopReason = "end_turn"
}
// 构建完整的响应格式(与 Claude API 响应格式一致)
// 构建完整的响应格式(与 Anthropic API 官方响应格式一致)
response := gin.H{
"model": model,
"id": msgID,
@@ -2210,6 +2214,7 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
"content": []gin.H{{"type": "text", "text": text}},
"stop_reason": stopReason,
"stop_sequence": nil,
"stop_details": nil,
"usage": gin.H{
"input_tokens": 10,
"cache_creation_input_tokens": 0,
@@ -2219,7 +2224,6 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
"ephemeral_1h_input_tokens": 0,
},
"output_tokens": outputTokens,
"total_tokens": 10 + outputTokens,
},
}
@@ -240,6 +240,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
}
var result *service.ForwardResult
setActualUpstreamEndpoint(c, "")
if account.Platform == service.PlatformGemini {
if h.geminiCompatService == nil {
h.chatCompletionsErrorResponse(c, http.StatusBadGateway, "upstream_error", "Gemini compatibility service is not configured")
@@ -249,6 +250,16 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
return
}
result, err = h.geminiCompatService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody)
} else if shouldUseAntigravityCompat(account) {
if h.antigravityGatewayService == nil {
h.chatCompletionsErrorResponse(c, http.StatusBadGateway, "upstream_error", "Antigravity compatibility service is not configured")
if accountReleaseFunc != nil {
accountReleaseFunc()
}
return
}
setActualUpstreamEndpoint(c, EndpointAntigravityGenerateContent)
result, err = h.antigravityGatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, parsedReq)
} else {
result, err = h.gatewayService.ForwardAsChatCompletions(c.Request.Context(), c, account, forwardBody, parsedReq)
}
@@ -298,6 +309,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -312,6 +324,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
reqLog.Error("gateway.cc.record_usage_failed",
@@ -8,6 +8,7 @@ import (
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
@@ -53,6 +54,22 @@ func TestGatewayEnsureForwardErrorResponse_AppendsSSEAfterWritten(t *testing.T)
assert.Contains(t, w.Body.String(), `data: {"type":"error"`)
}
func TestGatewayEnsureForwardErrorResponse_SkipsCommittedSSEError(t *testing.T) {
gin.SetMode(gin.TestMode)
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil)
c.Header("Content-Type", "text/event-stream")
_, _ = c.Writer.WriteString("event: error\ndata: {\"type\":\"error\"}\n\n")
service.MarkResponseCommitted(c)
h := &GatewayHandler{}
wrote := h.ensureForwardErrorResponse(c, true)
require.False(t, wrote)
require.Equal(t, 1, strings.Count(w.Body.String(), "event: error"))
}
// case B 回归:Anthropic-backed /responses,Writer 已被写过时
// ensureForwardErrorResponse 仍要发 response.failed。
func TestGatewayEnsureForwardErrorResponse_ResponsesRouteAfterWrittenEmitsResponseFailed(t *testing.T) {
@@ -49,7 +49,7 @@ func TestSendMockInterceptResponse_MaxTokensOneHaiku(t *testing.T) {
id, ok := response["id"].(string)
require.True(t, ok)
require.True(t, strings.HasPrefix(id, "msg_bdrk_"))
require.True(t, strings.HasPrefix(id, "msg_01"))
content, ok := response["content"].([]any)
require.True(t, ok)
@@ -228,7 +228,21 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
if channelMapping.Mapped {
forwardBody = h.gatewayService.ReplaceModelInBody(body, channelMapping.MappedModel)
}
result, err := h.gatewayService.ForwardAsResponses(requestCtx, c, account, forwardBody, parsedReq)
var result *service.ForwardResult
setActualUpstreamEndpoint(c, "")
if shouldUseAntigravityCompat(account) {
if h.antigravityGatewayService == nil {
h.responsesErrorResponse(c, http.StatusBadGateway, "upstream_error", "Antigravity compatibility service is not configured")
if accountReleaseFunc != nil {
accountReleaseFunc()
}
return
}
setActualUpstreamEndpoint(c, EndpointAntigravityGenerateContent)
result, err = h.antigravityGatewayService.ForwardAsResponses(requestCtx, c, account, forwardBody, parsedReq)
} else {
result, err = h.gatewayService.ForwardAsResponses(requestCtx, c, account, forwardBody, parsedReq)
}
if accountReleaseFunc != nil {
accountReleaseFunc()
@@ -276,6 +290,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -290,6 +305,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
reqLog.Error("gateway.responses.record_usage_failed",
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -289,7 +290,7 @@ func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_MixedScheduli
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "msg_mock_warmup", resp["id"])
require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01"))
require.Equal(t, "claude-sonnet-4-5", resp["model"])
content, ok := resp["content"].([]any)
@@ -378,6 +379,6 @@ func TestGatewayHandlerMessages_InterceptWarmup_AntigravityAccount_ForcePlatform
var resp map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.Equal(t, "msg_mock_warmup", resp["id"])
require.True(t, strings.HasPrefix(resp["id"].(string), "msg_01"))
require.Equal(t, "claude-sonnet-4-5", resp["model"])
}
@@ -532,6 +532,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
Result: result,
@@ -549,6 +550,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
LongContextMultiplier: 2.0, // 超出部分双倍计费
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
+2
View File
@@ -465,6 +465,7 @@ func recordGrokMediaUsage(
) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
sessionID := service.ExtractClientSessionID(c)
payloadForHash := body
if len(payloadForHash) == 0 && strings.TrimSpace(requestID) != "" {
payloadForHash = []byte(requestID)
@@ -493,6 +494,7 @@ func recordGrokMediaUsage(
RequestPayloadHash: service.HashUsageRequestPayload(payloadForHash),
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: channelUsageFields,
}); err != nil {
logger.L().With(
+2
View File
@@ -58,9 +58,11 @@ type Handlers struct {
OpenAIGateway *OpenAIGatewayHandler
Setting *SettingHandler
Totp *TotpHandler
Passkey *PasskeyHandler
Payment *PaymentHandler
PaymentWebhook *PaymentWebhookHandler
AvailableChannel *AvailableChannelHandler
ModelPlaza *ModelPlazaHandler
AsyncImage *AsyncImageHandler
BatchImage *BatchImageHandler
}
@@ -0,0 +1,198 @@
package handler
import (
"log/slog"
"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"
)
// ModelPlazaHandler 处理「模型广场」查询。
//
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
type ModelPlazaHandler struct {
channelService *service.ChannelService
apiKeyService *service.APIKeyService
settingService *service.SettingService
}
// NewModelPlazaHandler 创建模型广场 handler。
func NewModelPlazaHandler(
channelService *service.ChannelService,
apiKeyService *service.APIKeyService,
settingService *service.SettingService,
) *ModelPlazaHandler {
return &ModelPlazaHandler{
channelService: channelService,
apiKeyService: apiKeyService,
settingService: settingService,
}
}
// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。
type modelPlazaOfficialPricing struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
CacheReadPrice *float64 `json:"cache_read_price"`
}
// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
type modelPlazaModel struct {
Name string `json:"name"`
Platform string `json:"platform"`
Pricing *userSupportedModelPricing `json:"pricing"`
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
}
// modelPlazaGroup 广场分组条目(白名单字段)。
type modelPlazaGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
SubscriptionType string `json:"subscription_type"`
RateMultiplier float64 `json:"rate_multiplier"`
UserRateMultiplier *float64 `json:"user_rate_multiplier,omitempty"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
Models []modelPlazaModel `json:"models"`
}
// modelPlazaResponse 广场页响应。
type modelPlazaResponse struct {
Description string `json:"description"`
Groups []modelPlazaGroup `json:"groups"`
}
// Get 返回模型广场数据。
// GET /api/v1/model-plaza
func (h *ModelPlazaHandler) Get(c *gin.Context) {
if h.settingService == nil {
response.NotFound(c, "Model plaza is not enabled")
return
}
rt := h.settingService.GetModelPlazaRuntime(c.Request.Context())
if !rt.Enabled {
response.NotFound(c, "Model plaza is not enabled")
return
}
subject, authed := middleware.GetAuthSubjectFromContext(c)
if rt.RequireAuth && !authed {
response.Unauthorized(c, "Authentication required")
return
}
groups, err := h.channelService.ListPlazaGroups(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
// allowedExclusive == nil 表示匿名;登录用户恒为非 nil(可能为空集合)。
var allowedExclusive map[int64]struct{}
var userRates map[int64]float64
if authed {
allowedExclusive, err = h.apiKeyService.GetUserAllowedGroupIDSet(c.Request.Context(), subject.UserID)
if err != nil {
// 可见性数据拿不到时不能静默降级成匿名视图(会错漏专属分组),直接报错。
response.ErrorFrom(c, err)
return
}
userRates, err = h.apiKeyService.GetUserGroupRates(c.Request.Context(), subject.UserID)
if err != nil {
// 专属倍率仅是展示增强,失败降级为分组默认倍率。
slog.Warn("model_plaza_user_rates_failed", "error", err, "user_id", subject.UserID)
userRates = nil
}
}
visible := filterPlazaVisibleGroups(groups, allowedExclusive)
out := make([]modelPlazaGroup, 0, len(visible))
for i := range visible {
out = append(out, toModelPlazaGroupDTO(&visible[i], userRates))
}
response.Success(c, modelPlazaResponse{
Description: rt.Description,
Groups: out,
})
}
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
// allowedExclusive == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
func filterPlazaVisibleGroups(
groups []service.PlazaGroup,
allowedExclusive map[int64]struct{},
) []service.PlazaGroup {
visible := make([]service.PlazaGroup, 0, len(groups))
for _, g := range groups {
if g.IsExclusive {
if allowedExclusive == nil {
continue
}
if _, ok := allowedExclusive[g.ID]; !ok {
continue
}
}
visible = append(visible, g)
}
return visible
}
// toModelPlazaGroupDTO 将 service 层广场分组映射为白名单 DTO,并合并用户专属倍率。
func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) modelPlazaGroup {
models := make([]modelPlazaModel, 0, len(g.Models))
for i := range g.Models {
m := &g.Models[i]
models = append(models, modelPlazaModel{
Name: m.Name,
Platform: m.Platform,
Pricing: toUserPricing(m.Pricing),
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
})
}
dto := modelPlazaGroup{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
}
return dto
}
// toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。
func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing {
if p == nil {
return nil
}
return &modelPlazaOfficialPricing{
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
}
}
@@ -0,0 +1,126 @@
//go:build unit
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func plazaGroups() []service.PlazaGroup {
return []service.PlazaGroup{
{ID: 1, Name: "public-standard", Platform: "anthropic", SubscriptionType: "standard", RateMultiplier: 1},
{ID: 2, Name: "exclusive-a", Platform: "anthropic", IsExclusive: true, RateMultiplier: 0.5},
{ID: 3, Name: "public-subscription", Platform: "openai", SubscriptionType: "subscription", RateMultiplier: 1},
{ID: 4, Name: "exclusive-b", Platform: "openai", IsExclusive: true, RateMultiplier: 0.8},
}
}
func TestFilterPlazaVisibleGroups_AnonymousSeesOnlyNonExclusive(t *testing.T) {
// 匿名(allowedExclusive == nil):仅非专属分组;订阅型公开分组照常可见(橱窗语义)。
visible := filterPlazaVisibleGroups(plazaGroups(), nil)
require.Len(t, visible, 2)
ids := []int64{visible[0].ID, visible[1].ID}
require.ElementsMatch(t, []int64{1, 3}, ids)
}
func TestFilterPlazaVisibleGroups_AuthedSeesGrantedExclusive(t *testing.T) {
// 登录:非专属 + 授权的专属;未授权的专属仍不可见。
allowed := map[int64]struct{}{2: {}}
visible := filterPlazaVisibleGroups(plazaGroups(), allowed)
require.Len(t, visible, 3)
ids := make([]int64, 0, len(visible))
for _, g := range visible {
ids = append(ids, g.ID)
}
require.ElementsMatch(t, []int64{1, 2, 3}, ids)
}
func TestFilterPlazaVisibleGroups_AuthedEmptySetSeesNoExclusive(t *testing.T) {
// 登录但无任何专属授权(空集合,非 nil):与匿名同样只见非专属,
// 但语义区分要保持——空集合不能被当作 nil 匿名分支。
visible := filterPlazaVisibleGroups(plazaGroups(), map[int64]struct{}{})
require.Len(t, visible, 2)
}
func TestModelPlazaHandler_NilSettingServiceFailsClosed404(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &ModelPlazaHandler{} // settingService == nil → fail-closed
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest(http.MethodGet, "/api/v1/model-plaza", nil)
h.Get(c)
require.Equal(t, http.StatusNotFound, w.Code)
}
func TestToModelPlazaGroupDTO_UserRateAndFieldWhitelist(t *testing.T) {
g := service.PlazaGroup{
ID: 2, Name: "vip", Description: "d", Platform: "anthropic",
SubscriptionType: "standard", RateMultiplier: 1, IsExclusive: true,
Models: []service.PlazaModel{{
Name: "claude-sonnet",
Platform: "anthropic",
Pricing: &service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
InputPrice: testPtr(3e-6),
},
OfficialPricing: &service.PlazaOfficialPricing{
InputPrice: testPtr(3e-6),
CacheReadPrice: testPtr(3e-7),
},
}},
}
// 有专属倍率:user_rate_multiplier 序列化输出
dto := toModelPlazaGroupDTO(&g, map[int64]float64{2: 0.5})
raw, err := json.Marshal(dto)
require.NoError(t, err)
var decoded map[string]any
require.NoError(t, json.Unmarshal(raw, &decoded))
for _, key := range []string{
"id", "name", "description", "platform", "subscription_type",
"rate_multiplier", "user_rate_multiplier", "is_exclusive", "models",
"peak_rate_enabled", "peak_start", "peak_end", "peak_rate_multiplier",
} {
_, exists := decoded[key]
require.Truef(t, exists, "plaza group DTO must expose %q", key)
}
require.InDelta(t, 0.5, decoded["user_rate_multiplier"].(float64), 1e-9)
// 模型条目:pricing + official_pricing 并存;official 缺失字段输出 null 而非省略
models := decoded["models"].([]any)
require.Len(t, models, 1)
model := models[0].(map[string]any)
require.Contains(t, model, "pricing")
require.Contains(t, model, "official_pricing")
official := model["official_pricing"].(map[string]any)
require.Contains(t, official, "input_price")
require.Contains(t, official, "cache_read_price")
_, has1h := official["cache_write_1h_price"]
require.False(t, has1h, "1h 缓存写价为 nil 时应 omitempty")
// 无专属倍率:user_rate_multiplier 整个字段省略
dtoNoRate := toModelPlazaGroupDTO(&g, nil)
rawNoRate, err := json.Marshal(dtoNoRate)
require.NoError(t, err)
var decodedNoRate map[string]any
require.NoError(t, json.Unmarshal(rawNoRate, &decodedNoRate))
_, hasRate := decodedNoRate["user_rate_multiplier"]
require.False(t, hasRate, "无专属倍率时 user_rate_multiplier 应 omitempty")
}
func TestToModelPlazaOfficialPricing_NilPassthrough(t *testing.T) {
require.Nil(t, toModelPlazaOfficialPricing(nil))
}
func testPtr(v float64) *float64 { return &v }
@@ -233,6 +233,7 @@ func (h *OpenAIGatewayHandler) recordAlphaSearchUsage(
) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
sessionID := service.ExtractClientSessionID(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
@@ -252,6 +253,7 @@ func (h *OpenAIGatewayHandler) recordAlphaSearchUsage(
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: channelMapping.ToUsageFields(requestedModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
@@ -340,6 +340,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
@@ -355,6 +356,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -15,9 +15,9 @@ import (
// Codex CLI and the Codex desktop app refresh their model picker from
// GET {base_url}/models?client_version=... (custom provider mode) or
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
// here. The manifest is proxied verbatim from the selected account's ChatGPT
// backend or custom API key upstream. API key manifests use a short-lived,
// asynchronously revalidated cache to tolerate canceled client requests.
// here. ChatGPT manifests are proxied verbatim; custom API key manifests receive
// provider-compatibility normalization and use a short-lived, asynchronously
// revalidated cache to tolerate canceled client requests.
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
if c.Request.Context().Err() != nil {
return
@@ -243,6 +243,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
@@ -257,6 +258,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
@@ -42,6 +42,28 @@ func TestGatewayChatCredentialStopDoesNotSelectAnotherAccountAndReturnsSafe503(t
require.NotContains(t, recorder.Body.String(), "client_secret")
}
func TestGatewayChatAntigravityCredentialFailureReturnsActionableMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
(&GatewayHandler{}).handleCCFailoverExhausted(c, &service.UpstreamFailoverError{
StatusCode: http.StatusUnauthorized,
Stage: service.GatewayFailureStageAccountAuth,
Scope: service.GatewayFailureScopeAccount,
Reason: service.AntigravityCredentialRejectedReason,
NextAccountAction: service.NextAccountRetry,
ClientStatusCode: http.StatusBadGateway,
ClientMessage: service.AntigravityCredentialRejectedClientMessage,
ResponseBody: []byte(`{"error":{"message":"Invalid bearer token","refresh_token":"must-not-leak"}}`),
}, false)
require.Equal(t, http.StatusBadGateway, recorder.Code)
require.Contains(t, recorder.Body.String(), service.AntigravityCredentialRejectedClientMessage)
require.NotContains(t, strings.ToLower(recorder.Body.String()), "bearer")
require.NotContains(t, strings.ToLower(recorder.Body.String()), "refresh_token")
}
func TestGatewayChatInferenceExhaustionRestoresRetryAfter(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
@@ -9,6 +9,7 @@ import (
"runtime/debug"
"strconv"
"strings"
"sync/atomic"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
@@ -43,6 +44,49 @@ type OpenAIGatewayHandler struct {
cfg *config.Config
}
type openAIWSTurnChannelMappingSnapshot struct {
turn int
mapping service.ChannelMappingResult
}
var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch")
func newOpenAIWSUnsupportedModelSwitchError(model string) error {
cause := fmt.Errorf("%w: model %q", errOpenAIWSUnsupportedModelSwitch, strings.TrimSpace(model))
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, "model switch requires reconnect", cause)
}
func shouldReportOpenAIWSProxyAccountFailure(err error) bool {
return err != nil && !errors.Is(err, errOpenAIWSUnsupportedModelSwitch)
}
func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping service.ChannelMappingResult, requestedModel, upstreamModel string) string {
billingModel := ""
if result != nil {
billingModel = strings.TrimSpace(result.BillingModel)
}
if billingModel == "" {
billingModel = strings.TrimSpace(upstreamModel)
}
if billingModel == "" {
billingModel = strings.TrimSpace(requestedModel)
}
requestedModel = strings.TrimSpace(requestedModel)
switch mapping.BillingModelSource {
case service.BillingModelSourceRequested:
if requestedModel != "" {
billingModel = requestedModel
}
case service.BillingModelSourceChannelMapped:
mappedModel := strings.TrimSpace(mapping.MappedModel)
if mappedModel != "" && mappedModel != requestedModel {
billingModel = mappedModel
}
}
return billingModel
}
type grokMediaEligibilityProber interface {
ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error)
}
@@ -386,6 +430,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
sameAccountRetryCount := make(map[int64]int)
var lastFailoverErr *service.UpstreamFailoverError
var oauth429FailoverState service.OpenAIOAuth429FailoverState
var passthroughFailoverState openAIPassthroughFailoverState
// 生图意图的 /v1/responses 请求必须调度到确实支持 Responses API 的账号,否则
// 会在 forward 阶段被静默降级为无法生图的 Chat Completions 直转(#4417)。
@@ -482,13 +527,17 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
// 用扣除 compact 心跳字节的口径快照:心跳注释不构成语义响应,
// 不能因心跳字节变化而放弃 failover 换号(#3887)。
writerSizeBeforeForward := service.OpenAICompactKeepaliveAdjustedWrittenSize(c)
// 跨 passthrough 边界的 failover:从 Kiro 等透传账号切到 Bedrock 等非透传账号前,
// 从不可变的 canonical forwardBody 派生本次尝试 body 并整块剔除上游私有的加密
// reasoning item(含耦合的 id/summary),避免非透传上游 400 拒绝 Kiro reasoning 形态。
attemptBody := h.deriveOpenAIForwardAttemptBody(reqLog, forwardBody, account, &passthroughFailoverState)
result, err := func() (*service.OpenAIForwardResult, error) {
defer func() {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
}()
return h.gatewayService.Forward(c.Request.Context(), c, account, forwardBody)
return h.gatewayService.Forward(c.Request.Context(), c, account, attemptBody)
}()
cyberBlockKeyHTTP := ""
if service.GetOpsCyberPolicy(c) != nil {
@@ -627,6 +676,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
@@ -644,6 +694,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -1138,6 +1189,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
@@ -1154,6 +1206,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -1515,7 +1568,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
previousResponseCanMove := !firstMessageToolCoverage.HasFunctionCallOutput || firstMessageToolCoverage.ContextCoversAllCallIDs
reqLog = reqLog.With(
zap.Bool("ws_ingress", true),
zap.String("model", reqModel),
zap.String("session_initial_model", reqModel),
zap.Bool("has_previous_response_id", previousResponseID != ""),
zap.String("previous_response_id_kind", previousResponseIDKind),
)
@@ -1767,6 +1820,10 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
reasoningEffortMappings = apiKey.Group.ReasoningEffortMappings
}
var requestPayloadHash string
// Passthrough rejects overlapping response.create frames, so one immutable
// turn-tagged slot preserves the exact mapping used for the in-flight request.
var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot]
turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS})
hooks := &service.OpenAIWSIngressHooks{
InitialRequestModel: reqModel,
MaxReasoningEffort: maxReasoningEffort,
@@ -1791,6 +1848,22 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
}
return nil
},
MapRequestModel: func(turn int, originalModel string) (string, error) {
model := strings.TrimSpace(originalModel)
if model == "" {
model = reqModel
}
mapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, model)
mappedModelUnchanged := false
if previous := turnChannelMapping.Load(); previous != nil && previous.turn < turn {
mappedModelUnchanged = strings.TrimSpace(previous.mapping.MappedModel) == strings.TrimSpace(mapping.MappedModel)
}
if turn > 1 && !mappedModelUnchanged && !account.IsModelSupported(model) && !account.IsModelSupported(mapping.MappedModel) {
return "", newOpenAIWSUnsupportedModelSwitchError(mapping.MappedModel)
}
turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: turn, mapping: mapping})
return mapping.MappedModel, nil
},
BeforeTurn: func(turn int) error {
// turn==1 的会话屏蔽已由握手层检查覆盖;连接内 flag 只拦截后续 turn。
if cyberBlockedThisConn {
@@ -1832,7 +1905,27 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// 届时 defer 已清除标记)。
defer clearCyberPolicyTurnState(c)
releaseTurnSlots()
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, reqModel, turnErr != nil, cyberBlockKey, clientRequestedUsageFields(c, channelMappingWS, reqModel, ""), requestPayloadHash)
turnRequestedModel := reqModel
turnUpstreamModel := ""
if result != nil && turn > 1 {
if model := strings.TrimSpace(result.Model); model != "" {
turnRequestedModel = model
}
}
if result != nil {
turnUpstreamModel = strings.TrimSpace(result.UpstreamModel)
}
var turnMapping service.ChannelMappingResult
if snapshot := turnChannelMapping.Load(); snapshot != nil && snapshot.turn == turn {
turnMapping = snapshot.mapping
} else {
turnMapping, _ = h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, turnRequestedModel)
}
if turnUpstreamModel == "" {
turnUpstreamModel = turnRequestedModel
}
turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel)
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockKey, turnUsageFields, requestPayloadHash)
if service.GetOpsCyberPolicy(c) != nil {
cyberBlockedThisConn = true
}
@@ -1854,14 +1947,26 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
if result == nil {
return
}
result.BillingModel = openAIWSTurnBillingModel(result, turnMapping, turnRequestedModel, turnUpstreamModel)
reqLog.Debug("openai.websocket_turn_billing",
zap.Int("turn", turn),
zap.String("turn_requested_model", turnRequestedModel),
zap.String("turn_upstream_model", turnUpstreamModel),
zap.String("billing_model", result.BillingModel),
)
// 排除 spark 影子:其 codex_* 仅由 QueryUsage(/wham/usage bengalfox)更新(外审第7轮 P1)。
if account.Type == service.AccountTypeOAuth && !account.IsShadow() {
h.gatewayService.UpdateCodexUsageSnapshotFromHeaders(ctx, account.ID, result.ResponseHeaders)
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), openAIForwardSucceededForScheduling(result), result.FirstTokenMs)
scheduleModel := turnUpstreamModel
if scheduleModel == "" {
scheduleModel = turnRequestedModel
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, scheduleModel, openAIForwardSucceededForScheduling(result), result.FirstTokenMs)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
@@ -1877,7 +1982,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
ChannelUsageFields: clientRequestedUsageFields(c, channelMappingWS, reqModel, result.UpstreamModel),
SessionID: sessionID,
ChannelUsageFields: turnUsageFields,
CyberBlocked: cyberBlocked,
}); err != nil {
reqLog.Error("openai.websocket_record_usage_failed",
@@ -1890,11 +1996,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
},
}
// 应用渠道模型映射到 WebSocket 首条消息
wsFirstMessage := firstMessage
if channelMappingWS.Mapped {
wsFirstMessage = h.gatewayService.ReplaceModelInBody(firstMessage, channelMappingWS.MappedModel)
}
// 切组/会话失配防护:previous_response_id 未在当前分组命中粘连账号(StickyPreviousHit=false),
// 说明该会话链不属于本次调度到的账号,原样转发会触发上游会话链鉴权失败(“鉴权失败,请检查 API Key”)。
// 故剥离首包里的 previous_response_id,改用首包内 input 重建上下文;带 function_call_output 的
@@ -1938,7 +2040,9 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
if shouldReportOpenAIWSProxyAccountFailure(err) {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, account.GetMappedModel(reqModel), false, nil)
}
closeStatus, closeReason := summarizeWSCloseErrorForLog(err)
proxyFailedFields := []zap.Field{
zap.Int64("account_id", account.ID),
@@ -2221,7 +2325,9 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE
}
func credentialFailoverClientResponse(failoverErr *service.UpstreamFailoverError) (int, string) {
_ = failoverErr
if failoverErr != nil && failoverErr.Reason == service.AntigravityCredentialRejectedReason {
return http.StatusBadGateway, service.AntigravityCredentialRejectedClientMessage
}
return http.StatusServiceUnavailable, service.GrokCredentialUnavailableClientMessage
}
@@ -2884,6 +2990,8 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
userAgent = c.GetHeader("User-Agent")
clientIPStr = strings.TrimSpace(ip.GetClientIP(c))
}
// 提前拍成标量,避免在下方 goroutine 内访问 gin.Context。
sessionID := service.ExtractClientSessionID(c)
apiKeyPrefix := ""
if apiKey != nil {
apiKeyPrefix = keyPrefix(apiKey.Key, 8)
@@ -2940,6 +3048,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIPStr,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
APIKeyService: apiKeySvc,
ChannelUsageFields: channelFields,
@@ -4,6 +4,7 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
@@ -1369,6 +1370,229 @@ func TestOpenAIResponsesWebSocket_PassthroughUsageLogLeavesUserAgentNilWhenMissi
require.Equal(t, "medium", *got.log.ReasoningEffort)
}
func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"sol","stream":false}`,
secondPayload: `{"type":"response.create","model":"terra","stream":false}`,
channelMapping: map[string]string{
"sol": "sol-channel",
"terra": "terra-channel",
},
accountModelMapping: map[string]any{
"sol": "gpt-5.6-sol",
"terra": "gpt-5.6-terra",
"sol-channel": "gpt-5.6-sol",
"terra-channel": "gpt-5.6-terra",
},
})
require.Len(t, got.upstreamPayloads, 2)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String())
require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.upstreamPayloads[1], "model").String())
require.Len(t, got.clientEvents, 2)
require.Equal(t, "sol", gjson.GetBytes(got.clientEvents[0], "response.model").String())
require.Equal(t, "terra", gjson.GetBytes(got.clientEvents[1], "response.model").String())
require.Len(t, got.logs, 2)
require.Equal(t, "sol", got.logs[0].Model)
require.Equal(t, "sol", got.logs[0].RequestedModel)
require.NotNil(t, got.logs[0].UpstreamModel)
require.Equal(t, "gpt-5.6-sol", *got.logs[0].UpstreamModel)
require.NotNil(t, got.logs[0].ModelMappingChain)
require.Equal(t, "sol→sol-channel→gpt-5.6-sol", *got.logs[0].ModelMappingChain)
require.Equal(t, "terra", got.logs[1].Model)
require.Equal(t, "terra", got.logs[1].RequestedModel)
require.NotNil(t, got.logs[1].UpstreamModel)
require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel)
require.NotNil(t, got.logs[1].ModelMappingChain)
require.Equal(t, "terra→terra-channel→gpt-5.6-terra", *got.logs[1].ModelMappingChain)
require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12,
"each turn must be billed with its own channel-mapped model")
}
func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`,
secondPayload: `{"type":"response.create","stream":false}`,
channelMapping: map[string]string{
"public-alias": "gpt-5.6-sol",
},
accountModelMapping: map[string]any{
"public-alias": "gpt-5.6-terra",
},
})
require.Len(t, got.upstreamPayloads, 2)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String())
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[1], "model").String())
require.Len(t, got.clientEvents, 2)
require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[0], "response.model").String())
require.Equal(t, "public-alias", gjson.GetBytes(got.clientEvents[1], "response.model").String())
require.Len(t, got.logs, 2)
for _, usageLog := range got.logs {
require.Equal(t, "public-alias", usageLog.RequestedModel)
require.NotNil(t, usageLog.UpstreamModel)
require.Equal(t, "gpt-5.6-sol", *usageLog.UpstreamModel)
require.NotNil(t, usageLog.ModelMappingChain)
require.Equal(t, "public-alias→gpt-5.6-sol", *usageLog.ModelMappingChain)
}
}
func TestOpenAIResponsesWebSocket_PassthroughKeepsTurnMappingSnapshot(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"sol","stream":false}`,
secondPayload: `{"type":"response.create","model":"sol","stream":false}`,
channelMapping: map[string]string{
"sol": "gpt-5.6-sol",
},
afterFirstUpstreamRequest: func(channelSvc *service.ChannelService) error {
if channelSvc == nil {
return errors.New("channel service is nil")
}
_, err := channelSvc.Update(context.Background(), 7701, &service.UpdateChannelInput{
ModelMapping: map[string]map[string]string{
service.PlatformOpenAI: {"sol": "gpt-5.6-terra"},
},
})
return err
},
})
require.Len(t, got.upstreamPayloads, 2)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String())
require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.upstreamPayloads[1], "model").String())
require.Len(t, got.logs, 2)
require.Equal(t, "sol", got.logs[0].Model)
require.NotNil(t, got.logs[0].UpstreamModel)
require.Equal(t, "gpt-5.6-sol", *got.logs[0].UpstreamModel)
require.NotNil(t, got.logs[0].ModelMappingChain)
require.Equal(t, "sol→gpt-5.6-sol", *got.logs[0].ModelMappingChain)
require.InDelta(t, 40e-6, got.logs[0].TotalCost, 1e-12,
"the in-flight turn must retain the channel-mapped billing model used when it was sent")
require.Equal(t, "sol", got.logs[1].Model)
require.NotNil(t, got.logs[1].UpstreamModel)
require.Equal(t, "gpt-5.6-terra", *got.logs[1].UpstreamModel)
require.NotNil(t, got.logs[1].ModelMappingChain)
require.Equal(t, "sol→gpt-5.6-terra", *got.logs[1].ModelMappingChain)
require.InDelta(t, got.logs[1].TotalCost*2, got.logs[0].TotalCost, 1e-12,
"the next turn must use the updated channel mapping")
}
func TestOpenAIResponsesWebSocket_CtxPoolAppliesPerTurnMappingAndPreservesRequestedModel(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.6-sol","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-5.6-terra","stream":false}`,
ingressMode: service.OpenAIWSIngressModeCtxPool,
billingModelSource: service.BillingModelSourceRequested,
channelMapping: map[string]string{
"gpt-5.6-terra": "gpt-5.6-sol",
},
})
require.Len(t, got.upstreamPayloads, 2)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[0], "model").String())
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.upstreamPayloads[1], "model").String())
require.Len(t, got.clientEvents, 2)
require.Equal(t, "gpt-5.6-sol", gjson.GetBytes(got.clientEvents[0], "response.model").String())
require.Equal(t, "gpt-5.6-terra", gjson.GetBytes(got.clientEvents[1], "response.model").String())
require.Len(t, got.logs, 2)
require.Equal(t, "gpt-5.6-sol", got.logs[0].RequestedModel)
require.Nil(t, got.logs[0].ModelMappingChain)
require.InDelta(t, 40e-6, got.logs[0].TotalCost, 1e-12)
require.Equal(t, "gpt-5.6-terra", got.logs[1].RequestedModel)
require.NotNil(t, got.logs[1].ModelMappingChain)
require.Equal(t, "gpt-5.6-terra→gpt-5.6-sol", *got.logs[1].ModelMappingChain)
require.InDelta(t, 20e-6, got.logs[1].TotalCost, 1e-12,
"BillingModelSourceRequested must use the client model before channel mapping")
}
func TestOpenAIWSTurnBillingModelPreservesImagePricingModel(t *testing.T) {
tests := []struct {
name string
resultModel string
mapping service.ChannelMappingResult
requestedModel string
upstreamModel string
wantBillingModel string
}{
{
name: "upstream billing preserves image model",
resultModel: "gpt-image-2",
mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceUpstream},
requestedModel: "gpt-5.6-sol",
upstreamModel: "gpt-5.6-sol",
wantBillingModel: "gpt-image-2",
},
{
name: "unmapped channel preserves image model",
resultModel: "gpt-image-2",
mapping: service.ChannelMappingResult{MappedModel: "gpt-5.6-sol", BillingModelSource: service.BillingModelSourceChannelMapped},
requestedModel: "gpt-5.6-sol",
upstreamModel: "gpt-5.6-sol",
wantBillingModel: "gpt-image-2",
},
{
name: "requested source overrides image model",
resultModel: "gpt-image-2",
mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceRequested},
requestedModel: "public-image-alias",
upstreamModel: "gpt-5.6-sol",
wantBillingModel: "public-image-alias",
},
{
name: "mapped channel source overrides image model",
resultModel: "gpt-image-2",
mapping: service.ChannelMappingResult{MappedModel: "priced-channel-model", BillingModelSource: service.BillingModelSourceChannelMapped},
requestedModel: "public-image-alias",
upstreamModel: "gpt-5.6-sol",
wantBillingModel: "priced-channel-model",
},
{
name: "text turn falls back to upstream model",
mapping: service.ChannelMappingResult{BillingModelSource: service.BillingModelSourceUpstream},
requestedModel: "public-alias",
upstreamModel: "gpt-5.6-sol",
wantBillingModel: "gpt-5.6-sol",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := &service.OpenAIForwardResult{BillingModel: tt.resultModel}
require.Equal(t, tt.wantBillingModel, openAIWSTurnBillingModel(result, tt.mapping, tt.requestedModel, tt.upstreamModel))
})
}
}
func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) {
t.Run("unsupported client model switch does not penalize account", func(t *testing.T) {
err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported"))
require.False(t, shouldReportOpenAIWSProxyAccountFailure(err))
var closeErr *service.OpenAIWSClientCloseError
require.ErrorAs(t, err, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.StatusCode())
require.Equal(t, "model switch requires reconnect", closeErr.Reason())
})
t.Run("upstream policy violation still penalizes account", func(t *testing.T) {
err := service.NewOpenAIWSClientCloseError(
coderws.StatusPolicyViolation,
"upstream websocket authentication failed",
errors.New("upstream rejected credentials"),
)
require.True(t, shouldReportOpenAIWSProxyAccountFailure(err))
})
t.Run("generic proxy failure still penalizes account", func(t *testing.T) {
require.True(t, shouldReportOpenAIWSProxyAccountFailure(errors.New("upstream websocket read failed")))
})
}
func TestSetOpenAIClientTransportHTTP(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -1516,14 +1740,22 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject
}
type openAIResponsesWSUsageLogCase struct {
firstPayload string
userAgent *string
channelMapping map[string]string
firstPayload string
secondPayload string
userAgent *string
ingressMode string
channelMapping map[string]string
billingModelSource string
accountModelMapping map[string]any
afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error
}
type openAIResponsesWSUsageLogResult struct {
log *service.UsageLog
logs []*service.UsageLog
upstreamFirstPayload []byte
upstreamPayloads [][]byte
clientEvents [][]byte
}
type openAIWSUsageHandlerAccountRepoStub struct {
@@ -1633,12 +1865,50 @@ func (s *openAIWSUsageHandlerUsageLogRepoStub) Create(ctx context.Context, log *
type openAIWSUsageHandlerChannelRepoStub struct {
service.ChannelRepository
mu sync.Mutex
channels []service.Channel
groupPlatforms map[int64]string
}
func (s *openAIWSUsageHandlerChannelRepoStub) ListAll(ctx context.Context) ([]service.Channel, error) {
return s.channels, nil
s.mu.Lock()
defer s.mu.Unlock()
out := make([]service.Channel, 0, len(s.channels))
for i := range s.channels {
out = append(out, *s.channels[i].Clone())
}
return out, nil
}
func (s *openAIWSUsageHandlerChannelRepoStub) GetByID(ctx context.Context, id int64) (*service.Channel, error) {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.channels {
if s.channels[i].ID == id {
return s.channels[i].Clone(), nil
}
}
return nil, service.ErrChannelNotFound
}
func (s *openAIWSUsageHandlerChannelRepoStub) Update(ctx context.Context, channel *service.Channel) error {
s.mu.Lock()
defer s.mu.Unlock()
for i := range s.channels {
if s.channels[i].ID == channel.ID {
s.channels[i] = *channel.Clone()
return nil
}
}
return service.ErrChannelNotFound
}
func (s *openAIWSUsageHandlerChannelRepoStub) GetGroupIDs(ctx context.Context, channelID int64) ([]int64, error) {
channel, err := s.GetByID(ctx, channelID)
if err != nil {
return nil, err
}
return append([]int64(nil), channel.GroupIDs...), nil
}
func (s *openAIWSUsageHandlerChannelRepoStub) GetGroupPlatforms(ctx context.Context, groupIDs []int64) (map[int64]string, error) {
@@ -2147,8 +2417,13 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
t.Helper()
gin.SetMode(gin.TestMode)
upstreamPayloadCh := make(chan []byte, 1)
turnCount := 1
if strings.TrimSpace(tc.secondPayload) != "" {
turnCount = 2
}
upstreamPayloadCh := make(chan []byte, turnCount)
upstreamErrCh := make(chan error, 1)
var channelSvc *service.ChannelService
upstreamServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
conn, err := coderws.Accept(w, r, &coderws.AcceptOptions{
CompressionMode: coderws.CompressionContextTakeover,
@@ -2161,29 +2436,39 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
_ = conn.CloseNow()
}()
readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
msgType, payload, readErr := conn.Read(readCtx)
cancelRead()
if readErr != nil {
upstreamErrCh <- readErr
return
}
if msgType != coderws.MessageText && msgType != coderws.MessageBinary {
upstreamErrCh <- errors.New("unexpected upstream websocket message type")
return
}
upstreamPayloadCh <- payload
for turn := 1; turn <= turnCount; turn++ {
readCtx, cancelRead := context.WithTimeout(r.Context(), 3*time.Second)
msgType, payload, readErr := conn.Read(readCtx)
cancelRead()
if readErr != nil {
upstreamErrCh <- readErr
return
}
if msgType != coderws.MessageText && msgType != coderws.MessageBinary {
upstreamErrCh <- errors.New("unexpected upstream websocket message type")
return
}
upstreamPayloadCh <- payload
if turn == 1 && tc.afterFirstUpstreamRequest != nil {
if callbackErr := tc.afterFirstUpstreamRequest(channelSvc); callbackErr != nil {
upstreamErrCh <- callbackErr
return
}
}
writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
writeErr := conn.Write(writeCtx, coderws.MessageText, []byte(
`{"type":"response.completed","response":{"id":"resp_usage_e2e","model":"gpt-5.4","usage":{"input_tokens":2,"output_tokens":1}}}`,
))
cancelWrite()
if writeErr != nil {
upstreamErrCh <- writeErr
return
response := fmt.Sprintf(
`{"type":"response.completed","response":{"id":"resp_usage_e2e_%d","model":%q,"usage":{"input_tokens":2,"output_tokens":1}}}`,
turn,
gjson.GetBytes(payload, "model").String(),
)
writeCtx, cancelWrite := context.WithTimeout(r.Context(), 3*time.Second)
writeErr := conn.Write(writeCtx, coderws.MessageText, []byte(response))
cancelWrite()
if writeErr != nil {
upstreamErrCh <- writeErr
return
}
}
_ = conn.Close(coderws.StatusNormalClosure, "done")
upstreamErrCh <- nil
}))
defer upstreamServer.Close()
@@ -2198,14 +2483,18 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
Schedulable: true,
Concurrency: 1,
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": upstreamServer.URL,
"api_key": "sk-test",
"base_url": upstreamServer.URL,
"model_mapping": tc.accountModelMapping,
},
Extra: map[string]any{
"openai_apikey_responses_websockets_v2_enabled": true,
"openai_apikey_responses_websockets_v2_mode": service.OpenAIWSIngressModePassthrough,
},
}
if strings.TrimSpace(tc.ingressMode) != "" {
account.Extra["openai_apikey_responses_websockets_v2_mode"] = tc.ingressMode
}
cfg := &config.Config{}
cfg.RunMode = config.RunModeSimple
@@ -2221,17 +2510,17 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
cfg.Gateway.OpenAIWS.WriteTimeoutSeconds = 3
accountRepo := &openAIWSUsageHandlerAccountRepoStub{account: account}
usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, 1)}
usageRepo := &openAIWSUsageHandlerUsageLogRepoStub{created: make(chan *service.UsageLog, turnCount)}
var channelSvc *service.ChannelService
if len(tc.channelMapping) > 0 {
channelSvc = service.NewChannelService(&openAIWSUsageHandlerChannelRepoStub{
channels: []service.Channel{{
ID: 7701,
Name: "openai-ws-e2e-channel",
Status: service.StatusActive,
GroupIDs: []int64{groupID},
ModelMapping: map[string]map[string]string{service.PlatformOpenAI: tc.channelMapping},
ID: 7701,
Name: "openai-ws-e2e-channel",
Status: service.StatusActive,
GroupIDs: []int64{groupID},
ModelMapping: map[string]map[string]string{service.PlatformOpenAI: tc.channelMapping},
BillingModelSource: tc.billingModelSource,
}},
groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI},
}, nil, nil, nil)
@@ -2314,26 +2603,44 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
cancelWrite()
require.NoError(t, err)
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
_, event, err := clientConn.Read(readCtx)
cancelRead()
require.NoError(t, err)
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
clientEvents := make([][]byte, 0, turnCount)
readCompleted := func() {
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
_, event, readErr := clientConn.Read(readCtx)
cancelRead()
require.NoError(t, readErr)
require.Equal(t, "response.completed", gjson.GetBytes(event, "type").String())
clientEvents = append(clientEvents, append([]byte(nil), event...))
}
readCompleted()
if turnCount == 2 {
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.secondPayload))
cancelWrite()
require.NoError(t, err)
readCompleted()
}
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
var usageLog *service.UsageLog
select {
case usageLog = <-usageRepo.created:
require.NotNil(t, usageLog)
case <-time.After(3 * time.Second):
t.Fatal("等待 WebSocket usage log 写入超时")
usageLogs := make([]*service.UsageLog, 0, turnCount)
for len(usageLogs) < turnCount {
select {
case usageLog := <-usageRepo.created:
require.NotNil(t, usageLog)
usageLogs = append(usageLogs, usageLog)
case <-time.After(3 * time.Second):
t.Fatal("等待 WebSocket usage log 写入超时")
}
}
var upstreamFirstPayload []byte
select {
case upstreamFirstPayload = <-upstreamPayloadCh:
case <-time.After(3 * time.Second):
t.Fatal("等待上游 WebSocket 首帧超时")
upstreamPayloads := make([][]byte, 0, turnCount)
for len(upstreamPayloads) < turnCount {
select {
case payload := <-upstreamPayloadCh:
upstreamPayloads = append(upstreamPayloads, payload)
case <-time.After(3 * time.Second):
t.Fatal("等待上游 WebSocket 请求帧超时")
}
}
select {
@@ -2344,8 +2651,11 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
}
return openAIResponsesWSUsageLogResult{
log: usageLog,
upstreamFirstPayload: upstreamFirstPayload,
log: usageLogs[0],
logs: usageLogs,
upstreamFirstPayload: upstreamPayloads[0],
upstreamPayloads: upstreamPayloads,
clientEvents: clientEvents,
}
}
@@ -0,0 +1,62 @@
package handler
import (
"github.com/Wei-Shaw/sub2api/internal/service"
"go.uber.org/zap"
)
// openAIPassthroughFailoverState tracks whether this forwarding loop has attempted
// an OpenAI passthrough account. Once it has, every subsequent non-passthrough
// attempt must use a sanitized body because the immutable canonical body can carry
// provider-specific encrypted reasoning produced by that passthrough upstream.
type openAIPassthroughFailoverState struct {
passthroughSeen bool
}
// deriveOpenAIForwardAttemptBody returns the request body for the upcoming forward
// attempt against account. It always derives from the immutable canonical body and
// only removes encrypted reasoning input item(s) when the loop has already attempted
// a passthrough account and the upcoming account is non-passthrough. This remains
// sticky across retries and additional non-passthrough accounts. Attempts before
// any passthrough account, and all passthrough attempts, forward the canonical body
// unchanged. The canonical slice is never mutated.
//
// This method is invoked exactly once per forward attempt, immediately before the
// Forward call, and advances the failover state as a side effect.
func (h *OpenAIGatewayHandler) deriveOpenAIForwardAttemptBody(
reqLog *zap.Logger,
canonicalBody []byte,
account *service.Account,
state *openAIPassthroughFailoverState,
) []byte {
currentPassthrough := account.IsOpenAIPassthroughEnabled()
if currentPassthrough {
state.passthroughSeen = true
return canonicalBody
}
if !state.passthroughSeen {
return canonicalBody
}
sanitized, changed, err := service.SanitizeOpenAICrossModeFailoverReasoning(canonicalBody)
if err != nil {
if reqLog != nil {
reqLog.Warn("openai.failover_cross_mode_reasoning_sanitize_failed",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
}
return canonicalBody
}
if !changed {
return canonicalBody
}
if reqLog != nil {
reqLog.Info("openai.failover_cross_mode_reasoning_stripped",
zap.Int64("account_id", account.ID),
zap.Bool("account_passthrough", currentPassthrough),
zap.Bool("passthrough_seen", true),
)
}
return sanitized
}
@@ -0,0 +1,146 @@
package handler
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// kiroReasoningCanonicalBody mirrors a native OpenAI Responses request whose input
// carries a provider-specific encrypted reasoning item (Kiro shape: encrypted_content
// coupled with an id and summary), plus surrounding message items.
const kiroReasoningCanonicalBody = `{"model":"gpt-5.1","stream":false,"input":[` +
`{"type":"message","role":"user","content":"hello"},` +
`{"type":"reasoning","id":"rs_kiro_abc123","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]},` +
`{"type":"message","role":"assistant","content":"hi"}` +
`]}`
func newOpenAIPassthroughAccount(id int64, passthrough bool) *service.Account {
extra := map[string]any{"openai_passthrough": passthrough}
return &service.Account{
ID: id,
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Extra: extra,
}
}
func reasoningItemCount(t *testing.T, body []byte) int {
t.Helper()
count := 0
gjson.GetBytes(body, "input").ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "reasoning" {
count++
}
return true
})
return count
}
// TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning drives the real
// account-switch/request-body seam used inside OpenAIGatewayHandler.Responses: the
// same deriveOpenAIForwardAttemptBody call the failover loop makes, fed real
// *service.Account values in sequence. It proves the first passthrough attempt gets
// the untouched reasoning item, and after a failover to a non-passthrough account
// the second attempt body has the entire reasoning item removed — while the
// immutable canonical body is never mutated.
func TestDeriveOpenAIForwardAttemptBody_CrossModeStripsKiroReasoning(t *testing.T) {
h := &OpenAIGatewayHandler{}
canonical := []byte(kiroReasoningCanonicalBody)
kiro := newOpenAIPassthroughAccount(1, true) // Kiro: openai_passthrough=true
bedrock := newOpenAIPassthroughAccount(2, false) // Bedrock Mantle: passthrough disabled
state := &openAIPassthroughFailoverState{}
// Attempt 1 — passthrough (Kiro): original reasoning item must survive intact.
firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt must keep the reasoning item")
require.Equal(t, "ENC_BLOB", gjson.GetBytes(firstBody, "input.1.encrypted_content").String())
require.Equal(t, "rs_kiro_abc123", gjson.GetBytes(firstBody, "input.1.id").String())
require.JSONEq(t, kiroReasoningCanonicalBody, string(firstBody), "first attempt body must equal the canonical body")
// Attempt 2 — failover switches to non-passthrough (Bedrock): the whole
// provider-specific encrypted reasoning item (id/summary/encrypted_content) is dropped.
secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state)
require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt must drop the reasoning item entirely")
require.False(t, gjson.GetBytes(secondBody, "input.#(encrypted_content)").Exists(), "no encrypted_content may remain")
require.NotContains(t, string(secondBody), "rs_kiro_abc123", "coupled reasoning id must be gone")
require.NotContains(t, string(secondBody), "ENC_BLOB")
// Surviving message items are preserved.
require.Equal(t, 2, int(gjson.GetBytes(secondBody, "input.#").Int()))
require.Equal(t, "hello", gjson.GetBytes(secondBody, "input.0.content").String())
require.Equal(t, "hi", gjson.GetBytes(secondBody, "input.1.content").String())
// The canonical body is immutable across attempts.
require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated")
}
// TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning proves that
// non-passthrough attempts before any passthrough attempt, passthrough-family
// failovers, and switching into passthrough forward the canonical reasoning item
// unchanged.
func TestDeriveOpenAIForwardAttemptBody_SameModePreservesReasoning(t *testing.T) {
h := &OpenAIGatewayHandler{}
canonical := []byte(kiroReasoningCanonicalBody)
t.Run("non_passthrough_to_non_passthrough", func(t *testing.T) {
state := &openAIPassthroughFailoverState{}
first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(10, false), state)
second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(11, false), state)
require.Equal(t, 1, reasoningItemCount(t, first))
require.Equal(t, 1, reasoningItemCount(t, second), "Bedrock-family failover must preserve reasoning")
require.JSONEq(t, kiroReasoningCanonicalBody, string(second))
})
t.Run("passthrough_to_passthrough", func(t *testing.T) {
state := &openAIPassthroughFailoverState{}
first := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(20, true), state)
second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(21, true), state)
require.Equal(t, 1, reasoningItemCount(t, first))
require.Equal(t, 1, reasoningItemCount(t, second), "Kiro-family failover must preserve reasoning")
})
t.Run("non_passthrough_to_passthrough", func(t *testing.T) {
state := &openAIPassthroughFailoverState{}
_ = h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(30, false), state)
second := h.deriveOpenAIForwardAttemptBody(nil, canonical, newOpenAIPassthroughAccount(31, true), state)
require.Equal(t, 1, reasoningItemCount(t, second), "switching into passthrough must preserve reasoning")
})
t.Run("same_account_pool_retry", func(t *testing.T) {
state := &openAIPassthroughFailoverState{}
kiro := newOpenAIPassthroughAccount(40, true)
_ = h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
require.Equal(t, 1, reasoningItemCount(t, retry), "same-account retry must preserve reasoning")
})
}
// TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries proves
// that once a passthrough account has been attempted, all later non-passthrough
// retries and failovers remain sanitized instead of restoring the canonical Kiro
// reasoning item after the first Bedrock failure.
func TestDeriveOpenAIForwardAttemptBody_SanitizationSticksAcrossBedrockRetries(t *testing.T) {
h := &OpenAIGatewayHandler{}
canonical := []byte(kiroReasoningCanonicalBody)
state := &openAIPassthroughFailoverState{}
kiro := newOpenAIPassthroughAccount(50, true)
bedrockA := newOpenAIPassthroughAccount(51, false)
bedrockB := newOpenAIPassthroughAccount(52, false)
first := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
second := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state)
retry := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockA, state)
nextAccount := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrockB, state)
require.Equal(t, 1, reasoningItemCount(t, first))
require.Equal(t, 0, reasoningItemCount(t, second))
require.Equal(t, 0, reasoningItemCount(t, retry), "same Bedrock account retry must remain sanitized")
require.Equal(t, 0, reasoningItemCount(t, nextAccount), "later Bedrock account must remain sanitized")
require.JSONEq(t, kiroReasoningCanonicalBody, string(canonical), "canonical forwardBody must never be mutated")
}
@@ -91,6 +91,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
zap.Bool("stream", parsed.Stream),
zap.Bool("multipart", parsed.Multipart),
zap.String("capability", string(parsed.RequiredCapability)),
zap.String("img_quality", parsed.Quality),
zap.String("img_size", parsed.Size),
)
if !service.GroupAllowsImageGeneration(apiKey.Group) {
@@ -374,6 +376,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
if result != nil {
upstreamModel = result.UpstreamModel
}
sessionID := service.ExtractClientSessionID(c)
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
@@ -388,6 +391,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, requestModel, upstreamModel),
}); err != nil {
logger.L().With(
@@ -12,11 +12,14 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type openAIImagesFailoverAccountRepo struct {
@@ -154,8 +157,10 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
)
handler.maxAccountSwitches = 10
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
core, observedLogs := observer.New(zap.DebugLevel)
requestCtx := logger.IntoContext(context.Background(), zap.New(core))
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)).WithContext(requestCtx)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -173,6 +178,16 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
handler.Images(c)
accountSelectingLogs := observedLogs.FilterMessage("openai.images.account_selecting").All()
require.NotEmpty(t, accountSelectingLogs)
loggedFields := make(map[string]string)
for _, field := range accountSelectingLogs[0].Context {
loggedFields[field.Key] = field.String
}
require.Equal(t, "high", loggedFields["img_quality"])
require.Equal(t, "1536x1024", loggedFields["img_size"])
require.NotContains(t, loggedFields, "prompt")
require.Equal(t, []int64{1, 2}, upstream.calls())
require.Equal(t, http.StatusBadGateway, rec.Code)
require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+236
View File
@@ -0,0 +1,236 @@
package handler
import (
"encoding/json"
"errors"
"io"
"net/http"
"net/url"
"strconv"
"strings"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
coderws "github.com/coder/websocket"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"go.uber.org/zap"
)
func (h *OpenAIGatewayHandler) Live(c *gin.Context) {
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok {
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
return
}
if apiKey.Group == nil || apiKey.Group.Platform != service.PlatformOpenAI {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live is not supported for this platform")
return
}
if !liveEnabledForAPIKey(apiKey) {
h.errorResponse(c, http.StatusForbidden, "permission_error", "Live is not enabled for this group")
return
}
request, err := parseLiveCallRequest(c)
if err != nil {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
model := strings.TrimSpace(gjson.GetBytes(request.Session, "model").String())
reqLog := requestLogger(
c,
"handler.openai_gateway.live",
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", apiKey.ID),
zap.Any("group_id", apiKey.GroupID),
)
if decision := h.checkSecurityAudit(
c,
reqLog,
apiKey,
subject,
service.ContentModerationProtocolOpenAIResponses,
model,
request.Session,
); decision != nil && !decision.AllowNextStage {
h.openAISecurityAuditError(c, decision)
return
}
subscription, _ := middleware2.GetSubscriptionFromContext(c)
if h.billingCacheService == nil {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Billing service unavailable")
return
}
if err := h.billingCacheService.CheckBillingEligibility(
c.Request.Context(),
apiKey.User,
apiKey,
apiKey.Group,
subscription,
service.QuotaPlatform(c.Request.Context(), apiKey),
); err != nil {
status, code, message, retryAfter := billingErrorDetails(err)
if retryAfter > 0 {
c.Header("Retry-After", strconv.Itoa(retryAfter))
}
h.errorResponse(c, status, code, message)
return
}
userRelease, acquired, err := h.concurrencyHelper.TryAcquireUserSlot(
c.Request.Context(),
subject.UserID,
subject.Concurrency,
)
if err != nil {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Live concurrency unavailable")
return
}
if !acquired {
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Live concurrency limit reached")
return
}
defer userRelease()
identity := liveCallIdentity(c, apiKey, subject.UserID, subscription)
created, err := h.gatewayService.CreateLiveCall(c.Request.Context(), request, identity, subject.Concurrency)
if err != nil {
h.writeLiveCreateError(c, err)
return
}
c.Header("Location", liveSidebandLocation(c.FullPath(), created.CallID))
c.Data(http.StatusOK, "application/sdp", created.SDP)
}
func parseLiveCallRequest(c *gin.Context) (*service.LiveCallRequest, error) {
contentType := strings.ToLower(c.GetHeader("Content-Type"))
if strings.HasPrefix(contentType, "multipart/form-data") {
sdp := c.PostForm("sdp")
session := json.RawMessage(c.PostForm("session"))
request := &service.LiveCallRequest{SDP: sdp, Session: session}
if err := service.ValidateLiveCallRequest(request); err != nil {
return nil, err
}
return request, nil
}
var request service.LiveCallRequest
decoder := json.NewDecoder(c.Request.Body)
if err := decoder.Decode(&request); err != nil {
return nil, errors.New("request body must be valid JSON")
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return nil, errors.New("request body must contain one JSON object")
}
if err := service.ValidateLiveCallRequest(&request); err != nil {
return nil, err
}
return &request, nil
}
func liveSidebandLocation(fullPath, callID string) string {
prefix := "/v1/live/"
if strings.HasPrefix(fullPath, "/backend-api/codex/") {
prefix = "/backend-api/codex/"
}
return prefix + url.PathEscape(callID)
}
func liveCallIdentity(
c *gin.Context,
apiKey *service.APIKey,
userID int64,
subscription *service.UserSubscription,
) service.LiveCallIdentity {
var subscriptionID *int64
if subscription != nil {
value := subscription.ID
subscriptionID = &value
}
return service.LiveCallIdentity{
APIKeyID: apiKey.ID,
UserID: userID,
GroupID: apiKey.GroupID,
SubscriptionID: subscriptionID,
UserAgent: c.GetHeader("User-Agent"),
IPAddress: ip.GetClientIP(c),
InboundEndpoint: GetInboundEndpoint(c),
}
}
func (h *OpenAIGatewayHandler) writeLiveCreateError(c *gin.Context, err error) {
switch {
case errors.Is(err, service.ErrLiveConcurrencyFull):
h.errorResponse(c, http.StatusTooManyRequests, "rate_limit_error", "Live concurrency limit reached")
case errors.Is(err, service.ErrLiveUnavailable):
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "Live is unavailable")
default:
var attestationErr *service.LiveAttestationUnavailableError
if errors.As(err, &attestationErr) {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", attestationErr.Error())
return
}
var upstreamErr *service.UpstreamFailoverError
if errors.As(err, &upstreamErr) && upstreamErr.StatusCode >= 400 && upstreamErr.StatusCode < 500 {
h.errorResponse(c, upstreamErr.StatusCode, "invalid_request_error", "Live upstream rejected the request")
return
}
h.errorResponse(c, http.StatusBadGateway, "api_error", "Live upstream request failed")
}
}
func (h *OpenAIGatewayHandler) LiveSideband(c *gin.Context) {
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok {
h.errorResponse(c, http.StatusUnauthorized, "authentication_error", "Invalid API key")
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "User context not found")
return
}
if !liveEnabledForAPIKey(apiKey) {
h.errorResponse(c, http.StatusForbidden, "permission_error", "Live is not enabled for this group")
return
}
identity := service.LiveCallIdentity{
APIKeyID: apiKey.ID,
UserID: subject.UserID,
GroupID: apiKey.GroupID,
}
record, err := h.gatewayService.GetLiveCallForIdentity(c.Request.Context(), c.Param("call_id"), identity)
if err != nil {
if errors.Is(err, service.ErrLiveIdentityMismatch) {
h.errorResponse(c, http.StatusForbidden, "permission_error", "Live call belongs to another identity")
return
}
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Live call not found")
return
}
downstream, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{
InsecureSkipVerify: true,
})
if err != nil {
return
}
defer func() { _ = downstream.CloseNow() }()
if err := h.gatewayService.ProxyLiveSideband(c.Request.Context(), record, downstream); err != nil {
_ = downstream.Close(coderws.StatusInternalError, "live sideband closed")
return
}
_ = downstream.Close(coderws.StatusNormalClosure, "")
}
func liveEnabledForAPIKey(apiKey *service.APIKey) bool {
return apiKey != nil &&
apiKey.Group != nil &&
apiKey.Group.Platform == service.PlatformOpenAI &&
apiKey.Group.AllowLive
}
@@ -0,0 +1,118 @@
package handler
import (
"bytes"
"encoding/json"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestParseLiveCallRequestMultipartPreservesSession(t *testing.T) {
gin.SetMode(gin.TestMode)
session := `{"model":"gpt-live-test","delegation":{"type":"client"},"instructions":"你好"}`
var body bytes.Buffer
writer := multipart.NewWriter(&body)
require.NoError(t, writer.WriteField("sdp", "v=0\r\n"))
require.NoError(t, writer.WriteField("session", session))
require.NoError(t, writer.Close())
request := httptest.NewRequest("POST", "/v1/live", &body)
request.Header.Set("Content-Type", writer.FormDataContentType())
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = request
parsed, err := parseLiveCallRequest(context)
require.NoError(t, err)
require.Equal(t, "v=0\r\n", parsed.SDP)
require.JSONEq(t, session, string(parsed.Session))
require.Equal(t, "client", jsonPathString(t, parsed.Session, "delegation", "type"))
}
func TestParseLiveCallRequestJSONPreservesSessionWithoutDelegation(t *testing.T) {
gin.SetMode(gin.TestMode)
body := `{"sdp":"v=0\\r\\n","session":{"model":"gpt-live-test","instructions":"standalone"}}`
request := httptest.NewRequest("POST", "/backend-api/codex/realtime/calls", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = request
parsed, err := parseLiveCallRequest(context)
require.NoError(t, err)
require.NotContains(t, string(parsed.Session), "delegation")
require.Equal(t, "standalone", jsonPathString(t, parsed.Session, "instructions"))
}
func TestParseLiveCallRequestRejectsInvalidJSONShape(t *testing.T) {
gin.SetMode(gin.TestMode)
testCases := []string{
`{"session":{"type":"quicksilver"}}`,
`{"sdp":"v=0\\r\\n","session":[]}`,
`{"sdp":"v=0\\r\\n","session":null}`,
`{"sdp":"v=0\\r\\n","session":{"type":"quicksilver"}} {}`,
}
for _, body := range testCases {
request := httptest.NewRequest("POST", "/backend-api/codex/realtime/calls", bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
context, _ := gin.CreateTestContext(httptest.NewRecorder())
context.Request = request
_, err := parseLiveCallRequest(context)
require.Error(t, err)
}
}
func TestLiveSidebandLocationMatchesCreateRoute(t *testing.T) {
require.Equal(t, "/v1/live/call_123", liveSidebandLocation("/v1/live", "call_123"))
require.Equal(
t,
"/backend-api/codex/call_123",
liveSidebandLocation("/backend-api/codex/realtime/calls", "call_123"),
)
}
func TestLiveEnabledForAPIKey(t *testing.T) {
require.False(t, liveEnabledForAPIKey(nil))
require.False(t, liveEnabledForAPIKey(&service.APIKey{}))
require.False(t, liveEnabledForAPIKey(&service.APIKey{
Group: &service.Group{Platform: service.PlatformOpenAI},
}))
require.False(t, liveEnabledForAPIKey(&service.APIKey{
Group: &service.Group{Platform: service.PlatformAnthropic, AllowLive: true},
}))
require.True(t, liveEnabledForAPIKey(&service.APIKey{
Group: &service.Group{Platform: service.PlatformOpenAI, AllowLive: true},
}))
}
func TestLiveAttestationErrorIsExplicit(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
(&OpenAIGatewayHandler{}).writeLiveCreateError(context, &service.LiveAttestationUnavailableError{
Reason: "Live attestation is only supported when Sub2API runs on macOS",
})
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.Contains(t, recorder.Body.String(), "Sub2API runs on macOS")
}
func jsonPathString(t *testing.T, raw json.RawMessage, keys ...string) string {
t.Helper()
var value any
require.NoError(t, json.Unmarshal(raw, &value))
current := value
for _, key := range keys {
object, ok := current.(map[string]any)
require.True(t, ok)
current = object[key]
}
result, ok := current.(string)
require.True(t, ok)
return result
}
+254
View File
@@ -0,0 +1,254 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"strconv"
"strings"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type PasskeyHandler struct {
passkeys *service.PasskeyService
authService *service.AuthService
settingSvc *service.SettingService
}
func NewPasskeyHandler(
passkeys *service.PasskeyService,
authService *service.AuthService,
settingService *service.SettingService,
) *PasskeyHandler {
return &PasskeyHandler{
passkeys: passkeys,
authService: authService,
settingSvc: settingService,
}
}
type passkeyOptionsResponse struct {
SessionToken string `json:"session_token"`
Options any `json:"options"`
}
type passkeyFinishRequest struct {
SessionToken string `json:"session_token" binding:"required"`
Name string `json:"name,omitempty"`
Credential json.RawMessage `json:"credential" binding:"required"`
}
type passkeyRenameRequest struct {
Name string `json:"name" binding:"required"`
}
// passkeyPasswordRequest carries the account password that gates passkey
// enrollment and revocation. Binding errors are tolerated: a missing or
// malformed body yields an empty password, which the service rejects with a
// precise PASSWORD_REQUIRED error.
type passkeyPasswordRequest struct {
Password string `json:"password"`
}
func bindPasskeyPassword(c *gin.Context) string {
var req passkeyPasswordRequest
_ = c.ShouldBindJSON(&req)
return req.Password
}
const passkeyFinishBodyMaxBytes = 64 * 1024
// BeginLogin starts a usernameless, discoverable-credential login ceremony.
func (h *PasskeyHandler) BeginLogin(c *gin.Context) {
if !h.requirePasskeysEnabled(c) {
return
}
assertion, token, err := h.passkeys.BeginLogin(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: assertion})
}
// FinishLogin validates a passkey assertion and creates a normal Sub2API token
// session. User verification is mandatory, so a successful passkey assertion
// already supplies phishing-resistant multi-factor authentication and does not
// enter the separate TOTP challenge flow.
func (h *PasskeyHandler) FinishLogin(c *gin.Context) {
if !h.requirePasskeysEnabled(c) {
return
}
req, ok := bindPasskeyFinishRequest(c)
if !ok {
return
}
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
user, err := h.passkeys.FinishLogin(c.Request.Context(), req.SessionToken, credentialRequest)
if err != nil {
response.ErrorFrom(c, err)
return
}
if err = h.ensureBackendModeAllowsUser(c.Request.Context(), user); err != nil {
response.ErrorFrom(c, err)
return
}
middleware2.SetAuditActor(c, user.ID, user.Email)
c.Set("auth_method", service.AuditAuthMethodPasskey)
h.authService.RecordSuccessfulLogin(c.Request.Context(), user.ID)
respondWithTokenPair(c, h.authService, user)
}
func (h *PasskeyHandler) BeginRegistration(c *gin.Context) {
if !h.requirePasskeysEnabled(c) {
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID, bindPasskeyPassword(c))
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, passkeyOptionsResponse{SessionToken: token, Options: creation})
}
func (h *PasskeyHandler) FinishRegistration(c *gin.Context) {
if !h.requirePasskeysEnabled(c) {
return
}
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
req, valid := bindPasskeyFinishRequest(c)
if !valid {
return
}
credentialRequest := cloneRequestWithJSON(c.Request, req.Credential)
credential, err := h.passkeys.FinishRegistration(
c.Request.Context(),
subject.UserID,
req.SessionToken,
req.Name,
credentialRequest,
)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, credential)
}
func (h *PasskeyHandler) List(c *gin.Context) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return
}
credentials, err := h.passkeys.List(c.Request.Context(), subject.UserID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, credentials)
}
func (h *PasskeyHandler) Rename(c *gin.Context) {
subject, credentialID, ok := passkeyMutationTarget(c)
if !ok {
return
}
var req passkeyRenameRequest
if err := c.ShouldBindJSON(&req); err != nil || strings.TrimSpace(req.Name) == "" {
response.BadRequest(c, "Passkey name is required")
return
}
if err := h.passkeys.Rename(c.Request.Context(), subject.UserID, credentialID, req.Name); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
func (h *PasskeyHandler) Delete(c *gin.Context) {
subject, credentialID, ok := passkeyMutationTarget(c)
if !ok {
return
}
if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID, bindPasskeyPassword(c)); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"success": true})
}
func (h *PasskeyHandler) requirePasskeysEnabled(c *gin.Context) bool {
if h.settingSvc == nil {
response.ErrorFrom(c, service.ErrPasskeysDisabled)
return false
}
enabled, err := h.settingSvc.PasskeyEnabled(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return false
}
if !enabled {
response.ErrorFrom(c, service.ErrPasskeysDisabled)
}
return enabled
}
func (h *PasskeyHandler) ensureBackendModeAllowsUser(ctx context.Context, user *service.User) error {
if err := ensureLoginUserActive(user); err != nil {
return err
}
if h.settingSvc == nil || !h.settingSvc.IsBackendModeEnabled(ctx) || user.IsAdmin() {
return nil
}
return infraerrors.Forbidden("BACKEND_MODE_ADMIN_ONLY", "Backend mode is active. Only admin login is allowed.")
}
func bindPasskeyFinishRequest(c *gin.Context) (*passkeyFinishRequest, bool) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, passkeyFinishBodyMaxBytes)
var req passkeyFinishRequest
if err := c.ShouldBindJSON(&req); err != nil || len(req.Credential) == 0 {
response.BadRequest(c, "Invalid passkey response")
return nil, false
}
return &req, true
}
func cloneRequestWithJSON(original *http.Request, payload []byte) *http.Request {
request := original.Clone(original.Context())
request.Body = io.NopCloser(bytes.NewReader(payload))
request.ContentLength = int64(len(payload))
request.Header = original.Header.Clone()
request.Header.Set("Content-Type", "application/json")
return request
}
func passkeyMutationTarget(c *gin.Context) (middleware2.AuthSubject, int64, bool) {
subject, ok := middleware2.GetAuthSubjectFromContext(c)
if !ok {
response.Unauthorized(c, "User not authenticated")
return middleware2.AuthSubject{}, 0, false
}
credentialID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || credentialID <= 0 {
response.BadRequest(c, "Invalid passkey ID")
return middleware2.AuthSubject{}, 0, false
}
return subject, credentialID, true
}
@@ -0,0 +1,101 @@
package handler
import (
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type passkeySwitchSettingRepo struct {
value string
err error
}
func (r *passkeySwitchSettingRepo) Get(context.Context, string) (*service.Setting, error) {
return nil, service.ErrSettingNotFound
}
func (r *passkeySwitchSettingRepo) GetValue(context.Context, string) (string, error) {
return r.value, r.err
}
func (r *passkeySwitchSettingRepo) Set(context.Context, string, string) error { return nil }
func (r *passkeySwitchSettingRepo) GetMultiple(context.Context, []string) (map[string]string, error) {
return map[string]string{}, nil
}
func (r *passkeySwitchSettingRepo) SetMultiple(context.Context, map[string]string) error {
return nil
}
func (r *passkeySwitchSettingRepo) GetAll(context.Context) (map[string]string, error) {
return map[string]string{}, nil
}
func (r *passkeySwitchSettingRepo) Delete(context.Context, string) error { return nil }
func TestBindPasskeyFinishRequestRejectsOversizedBody(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
context, _ := gin.CreateTestContext(recorder)
context.Request = httptest.NewRequest(
http.MethodPost,
"/api/v1/auth/passkey/login/finish",
strings.NewReader(`{"credential":"`+strings.Repeat("x", passkeyFinishBodyMaxBytes)+`"}`),
)
context.Request.Header.Set("Content-Type", "application/json")
_, ok := bindPasskeyFinishRequest(context)
require.False(t, ok)
require.Equal(t, http.StatusBadRequest, recorder.Code)
}
func TestPasskeyBeginLoginRejectsDisabledAdminSwitch(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &passkeySwitchSettingRepo{value: "false"}
settings := service.NewSettingService(repo, &config.Config{
WebAuthn: config.WebAuthnConfig{Enabled: true},
})
handler := NewPasskeyHandler(nil, nil, settings)
recorder := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(recorder)
ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
handler.BeginLogin(ginContext)
require.Equal(t, http.StatusForbidden, recorder.Code)
require.Contains(t, recorder.Body.String(), "PASSKEY_DISABLED")
}
func TestPasskeyBeginLoginReportsSettingStoreFailure(t *testing.T) {
gin.SetMode(gin.TestMode)
settings := service.NewSettingService(
&passkeySwitchSettingRepo{err: errors.New("database unavailable")},
&config.Config{WebAuthn: config.WebAuthnConfig{Enabled: true}},
)
handler := NewPasskeyHandler(nil, nil, settings)
recorder := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(recorder)
ginContext.Request = httptest.NewRequest(http.MethodPost, "/api/v1/auth/passkey/login/begin", nil)
handler.BeginLogin(ginContext)
require.Equal(t, http.StatusInternalServerError, recorder.Code)
require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
}
func TestPasskeyCredentialListRemainsAvailableWhenSignInDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
handler := NewPasskeyHandler(nil, nil, nil)
recorder := httptest.NewRecorder()
ginContext, _ := gin.CreateTestContext(recorder)
ginContext.Request = httptest.NewRequest(http.MethodGet, "/api/v1/user/passkeys", nil)
handler.List(ginContext)
require.Equal(t, http.StatusUnauthorized, recorder.Code)
require.NotContains(t, recorder.Body.String(), "PASSKEY_DISABLED")
}
@@ -52,6 +52,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
PasswordResetEnabled: settings.PasswordResetEnabled,
InvitationCodeEnabled: settings.InvitationCodeEnabled,
TotpEnabled: settings.TotpEnabled,
PasskeyEnabled: settings.PasskeyEnabled,
LoginAgreementEnabled: settings.LoginAgreementEnabled,
LoginAgreementMode: settings.LoginAgreementMode,
LoginAgreementUpdatedAt: settings.LoginAgreementUpdatedAt,
@@ -98,6 +99,9 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
AffiliateEnabled: settings.AffiliateEnabled,
RiskControlEnabled: settings.RiskControlEnabled,
+20 -2
View File
@@ -26,6 +26,9 @@ type userHandlerRepoStub struct {
}
func (s *userHandlerRepoStub) Create(context.Context, *service.User) error { return nil }
func (s *userHandlerRepoStub) CreateWithEmailAliasGuard(context.Context, *service.User) error {
return nil
}
func (s *userHandlerRepoStub) GetByID(context.Context, int64) (*service.User, error) {
cloned := *s.user
return &cloned, nil
@@ -38,7 +41,7 @@ func (s *userHandlerRepoStub) GetFirstAdmin(context.Context) (*service.User, err
cloned := *s.user
return &cloned, nil
}
func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User) error {
func (s *userHandlerRepoStub) Update(_ context.Context, user *service.User, _ service.UserUpdateFields) error {
cloned := *user
s.user = &cloned
return nil
@@ -90,6 +93,14 @@ func (s *userHandlerRepoStub) UpdateConcurrency(context.Context, int64, int) err
func (s *userHandlerRepoStub) BatchSetConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
func (s *userHandlerRepoStub) AdjustBalance(ctx context.Context, id int64, delta float64) (service.BalanceChange, error) {
panic("unexpected AdjustBalance call")
}
func (s *userHandlerRepoStub) SetBalance(ctx context.Context, id int64, value float64) (service.BalanceChange, error) {
panic("unexpected SetBalance call")
}
func (s *userHandlerRepoStub) BatchAddConcurrency(context.Context, []int64, int) (int, error) {
return 0, nil
}
@@ -97,6 +108,9 @@ func (s *userHandlerRepoStub) BatchUpdateLimits(context.Context, []int64, *int,
return 0, nil
}
func (s *userHandlerRepoStub) ExistsByEmail(context.Context, string) (bool, error) { return false, nil }
func (s *userHandlerRepoStub) ExistsByEmailAlias(context.Context, string) (bool, error) {
return false, nil
}
func (s *userHandlerRepoStub) RemoveGroupFromAllowedGroups(context.Context, int64) (int64, error) {
return 0, nil
}
@@ -650,7 +664,11 @@ func TestUserHandlerUnbindIdentityRevokesAllUserSessionsWhenAuthServiceConfigure
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, []int64{23}, refreshTokenCache.revokedUserIDs)
require.Equal(t, int64(5), repo.user.TokenVersion)
// 撤销依赖的是 refresh session 清理,而不是 token_version:users 表没有这一列
// (见 resolvedTokenVersion,实际值由 email+password_hash 指纹推导),
// 所以此前"自增 TokenVersion 再整行写回"不持久化任何东西,
// 却会用旧快照覆盖并发写入的列。这里断言用户行未被改写。
require.Equal(t, int64(4), repo.user.TokenVersion)
}
func TestUserHandlerUnbindIdentityDoesNotRevokeSessionsWhenNothingWasUnbound(t *testing.T) {
+6
View File
@@ -179,9 +179,11 @@ func ProvideHandlers(
openaiGatewayHandler *OpenAIGatewayHandler,
settingHandler *SettingHandler,
totpHandler *TotpHandler,
passkeyHandler *PasskeyHandler,
paymentHandler *PaymentHandler,
paymentWebhookHandler *PaymentWebhookHandler,
availableChannelHandler *AvailableChannelHandler,
modelPlazaHandler *ModelPlazaHandler,
asyncImageHandler *AsyncImageHandler,
batchImageHandler *BatchImageHandler,
_ *service.IdempotencyCoordinator,
@@ -201,9 +203,11 @@ func ProvideHandlers(
OpenAIGateway: openaiGatewayHandler,
Setting: settingHandler,
Totp: totpHandler,
Passkey: passkeyHandler,
Payment: paymentHandler,
PaymentWebhook: paymentWebhookHandler,
AvailableChannel: availableChannelHandler,
ModelPlaza: modelPlazaHandler,
AsyncImage: asyncImageHandler,
BatchImage: batchImageHandler,
}
@@ -223,10 +227,12 @@ var ProviderSet = wire.NewSet(
ProvideGatewayHandler,
ProvideOpenAIGatewayHandler,
NewTotpHandler,
NewPasskeyHandler,
ProvideSettingHandler,
NewPaymentHandler,
NewPaymentWebhookHandler,
NewAvailableChannelHandler,
NewModelPlazaHandler,
NewAsyncImageHandler,
ProvideBatchImageHandler,
+61 -17
View File
@@ -8,6 +8,8 @@ import (
"strconv"
"time"
ippkg "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
)
@@ -72,6 +74,52 @@ func NewRateLimiter(redisClient *redis.Client) *RateLimiter {
}
}
// AllowResult 单次固定窗口限流判定结果。
type AllowResult struct {
// Allowed 是否放行
Allowed bool
// Count 当前窗口内累计请求数(含本次)
Count int64
// RetryAfter 超限时距窗口重置的剩余时间(尽力而为;PTTL 不可用时回退为完整窗口)
RetryAfter time.Duration
}
// Allow 对给定 key(不含 "rate_limit:" 前缀)执行一次固定窗口计数判定。
// 供需要自定义限流维度(如按用户 ID)的调用方使用;Redis 错误由调用方决定 fail-open/close。
func (r *RateLimiter) Allow(ctx context.Context, key string, limit int, window time.Duration) (AllowResult, error) {
redisKey := r.prefix + key
windowMillis := windowTTLMillis(window)
count, repaired, err := rateLimitRun(ctx, r.redis, redisKey, windowMillis)
if err != nil {
return AllowResult{}, err
}
if repaired {
log.Printf("[RateLimit] ttl repaired: key=%s window_ms=%d", redisKey, windowMillis)
}
result := AllowResult{Allowed: count <= int64(limit), Count: count}
if !result.Allowed {
result.RetryAfter = window
if ttl, ttlErr := r.redis.PTTL(ctx, redisKey).Result(); ttlErr == nil && ttl > 0 {
result.RetryAfter = ttl
}
}
return result, nil
}
// clientIPForRateLimit 返回 IP 维度限流使用的客户端地址。
// 与审计日志/会话绑定/API Key IP ACL 共用同一套安全客户端 IP 解析
// (SessionBindingContext 快照:兼容开关开启时信任反代转发头,关闭时走
// server.trusted_proxies 可信链)。避免默认反代部署下 Gin ClientIP 恒等于
// 代理地址、所有用户坍缩进同一个限流桶造成整体误拦截。
func clientIPForRateLimit(c *gin.Context) string {
if resolved := ippkg.GetSecurityClientIP(c, false); resolved != "" {
return resolved
}
return c.ClientIP()
}
// Limit 返回速率限制中间件
// key: 限制类型标识
// limit: 时间窗口内最大请求数
@@ -88,32 +136,21 @@ func (r *RateLimiter) LimitWithOptions(key string, limit int, window time.Durati
}
return func(c *gin.Context) {
ip := c.ClientIP()
redisKey := r.prefix + key + ":" + ip
ctx := c.Request.Context()
windowMillis := windowTTLMillis(window)
// 使用 Lua 脚本原子操作增加计数并设置过期
count, repaired, err := rateLimitRun(ctx, r.redis, redisKey, windowMillis)
result, err := r.Allow(c.Request.Context(), key+":"+clientIPForRateLimit(c), limit, window)
if err != nil {
log.Printf("[RateLimit] redis error: key=%s mode=%s err=%v", redisKey, failureModeLabel(failureMode), err)
log.Printf("[RateLimit] redis error: key=%s mode=%s err=%v", r.prefix+key, failureModeLabel(failureMode), err)
if failureMode == RateLimitFailClose {
abortRateLimit(c)
abortRateLimit(c, window)
return
}
// Redis 错误时放行,避免影响正常服务
c.Next()
return
}
if repaired {
log.Printf("[RateLimit] ttl repaired: key=%s window_ms=%d", redisKey, windowMillis)
}
// 超过限制
if count > int64(limit) {
abortRateLimit(c)
if !result.Allowed {
abortRateLimit(c, result.RetryAfter)
return
}
@@ -129,7 +166,14 @@ func windowTTLMillis(window time.Duration) int64 {
return ttl
}
func abortRateLimit(c *gin.Context) {
func abortRateLimit(c *gin.Context, retryAfter time.Duration) {
if retryAfter > 0 {
seconds := int64(retryAfter / time.Second)
if retryAfter%time.Second > 0 {
seconds++
}
c.Header("Retry-After", strconv.FormatInt(seconds, 10))
}
c.AbortWithStatusJSON(http.StatusTooManyRequests, gin.H{
"error": "rate limit exceeded",
"message": "Too many requests, please try again later",
@@ -7,6 +7,8 @@ import (
"testing"
"time"
ippkg "github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/gin-gonic/gin"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/require"
@@ -103,6 +105,87 @@ func TestRateLimiterDifferentIPsIndependent(t *testing.T) {
require.Equal(t, http.StatusTooManyRequests, rec3.Code, "第一个 IP 的第二次请求应被限流")
}
func TestRateLimiterAllow(t *testing.T) {
originalRun := rateLimitRun
var gotKey string
count := int64(0)
rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
gotKey = key
count++
return count, false, nil
}
t.Cleanup(func() {
rateLimitRun = originalRun
})
// PTTL 走真实客户端(不可达地址)→ 失败后 RetryAfter 应回退为完整窗口
limiter := NewRateLimiter(redis.NewClient(&redis.Options{
Addr: "127.0.0.1:1",
DialTimeout: 50 * time.Millisecond,
ReadTimeout: 50 * time.Millisecond,
WriteTimeout: 50 * time.Millisecond,
}))
res, err := limiter.Allow(context.Background(), "panel:global:user:42", 1, time.Minute)
require.NoError(t, err)
require.True(t, res.Allowed)
require.Equal(t, int64(1), res.Count)
require.Zero(t, res.RetryAfter)
require.Equal(t, "rate_limit:panel:global:user:42", gotKey)
res, err = limiter.Allow(context.Background(), "panel:global:user:42", 1, time.Minute)
require.NoError(t, err)
require.False(t, res.Allowed)
require.Equal(t, int64(2), res.Count)
require.Equal(t, time.Minute, res.RetryAfter)
}
func TestRateLimiterHonorsForwardedIPSnapshot(t *testing.T) {
gin.SetMode(gin.TestMode)
callCounts := make(map[string]int64)
originalRun := rateLimitRun
rateLimitRun = func(ctx context.Context, client *redis.Client, key string, windowMillis int64) (int64, bool, error) {
callCounts[key]++
return callCounts[key], false, nil
}
t.Cleanup(func() {
rateLimitRun = originalRun
})
limiter := NewRateLimiter(redis.NewClient(&redis.Options{Addr: "127.0.0.1:1"}))
router := gin.New()
// 模拟 SessionBindingContext:开启转发 IP 兼容模式快照
router.Use(func(c *gin.Context) {
ippkg.SetForwardedIPSettings(c, true, nil)
c.Next()
})
router.Use(limiter.Limit("fwd", 1, time.Second))
router.GET("/test", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"ok": true})
})
send := func(xff string) int {
req := httptest.NewRequest(http.MethodGet, "/test", nil)
// 所有请求都来自同一个反代地址
req.RemoteAddr = "127.0.0.1:5678"
req.Header.Set("X-Forwarded-For", xff)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec.Code
}
// 反代后两个不同的真实客户端应各自独立计数,不因共享代理地址被合并限流
require.Equal(t, http.StatusOK, send("198.51.100.1"))
require.Equal(t, http.StatusOK, send("198.51.100.2"))
// 同一真实客户端第二次请求应被限流
require.Equal(t, http.StatusTooManyRequests, send("198.51.100.1"))
require.Contains(t, callCounts, "rate_limit:fwd:198.51.100.1")
require.Contains(t, callCounts, "rate_limit:fwd:198.51.100.2")
}
func TestRateLimiterSuccessAndLimit(t *testing.T) {
gin.SetMode(gin.TestMode)
@@ -293,7 +293,7 @@ func (p *NonStreamingProcessor) buildResponse(geminiResp *GeminiResponse, respon
respID = geminiResp.ResponseID
}
if respID == "" {
respID = "msg_" + generateRandomID()
respID = generateAnthropicMsgID()
}
return &ClaudeResponse{
@@ -372,3 +372,18 @@ func generateRandomID() string {
}
return string(id)
}
// generateAnthropicMsgID 生成 Anthropic 官方格式的 message ID:msg_01 + 22 位 Base62
func generateAnthropicMsgID() string {
const charset = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
const idLen = 22
randomBytes := make([]byte, idLen)
if _, err := rand.Read(randomBytes); err != nil {
return "msg_01" + generateRandomID() + generateRandomID()[:10]
}
b := make([]byte, idLen)
for i := range b {
b[i] = charset[int(randomBytes[i])%len(charset)]
}
return "msg_01" + string(b)
}
@@ -195,7 +195,7 @@ func (p *StreamingProcessor) emitMessageStart(v1Resp *V1InternalResponse) []byte
responseID = v1Resp.Response.ResponseID
}
if responseID == "" {
responseID = "msg_" + generateRandomID()
responseID = generateAnthropicMsgID()
}
var usageValue any = usage
@@ -586,7 +586,7 @@ func (r *ResponsesClientToolStreamRestorer) restoreNamespaceEvent(event Response
event.Item.Name, event.Item.Namespace = name.Name, name.Namespace
}
}
if event.Type == "response.function_call_arguments.done" {
if event.Type == "response.function_call_arguments.delta" || event.Type == "response.function_call_arguments.done" {
if name, ok := r.adapter.NamespaceTools[event.Name]; ok {
event.Name = name.Name
}
@@ -146,7 +146,13 @@ func TestResponsesClientToolStreamRestorer_RestoresNamespaceLifecycle(t *testing
require.Equal(t, "open", gjson.GetBytes(added[0], "item.name").String())
require.Equal(t, "browser", gjson.GetBytes(added[0], "item.namespace").String())
done, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.done","sequence_number":5,"output_index":0,"item_id":"i1","name":"browser__open","arguments":"{}"}`))
delta, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.delta","sequence_number":5,"output_index":0,"item_id":"i1","name":"browser__open","delta":"{\"url\":"}`))
require.NoError(t, err)
require.True(t, changed)
require.Len(t, delta, 1)
require.Equal(t, "open", gjson.GetBytes(delta[0], "name").String())
done, changed, err := restorer.RestoreEvent([]byte(`{"type":"response.function_call_arguments.done","sequence_number":6,"output_index":0,"item_id":"i1","name":"browser__open","arguments":"{}"}`))
require.NoError(t, err)
require.True(t, changed)
require.Len(t, done, 1)
@@ -152,11 +152,7 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess
case item.Type == "function_call_output":
// function_call_output → user message with tool_result block
outputContent := item.Output
if outputContent == "" {
outputContent = "(empty)"
}
contentJSON, _ := json.Marshal(outputContent)
contentJSON := responsesFunctionOutputToAnthropicContent(item)
block := AnthropicContentBlock{
Type: "tool_result",
ToolUseID: fromResponsesCallIDToAnthropic(item.CallID),
@@ -216,6 +212,45 @@ func convertResponsesInputToAnthropic(instructions string, inputRaw json.RawMess
return system, messages, nil
}
func responsesFunctionOutputToAnthropicContent(item ResponsesInputItem) json.RawMessage {
if len(item.outputRaw) == 0 {
output := item.Output
if output == "" {
output = "(empty)"
}
content, _ := json.Marshal(output)
return content
}
var parts []ResponsesContentPart
if err := json.Unmarshal(item.outputRaw, &parts); err == nil {
blocks := make([]AnthropicContentBlock, 0, len(parts))
for _, part := range parts {
switch part.Type {
case "input_text", "output_text", "text":
if part.Text != "" {
blocks = append(blocks, AnthropicContentBlock{Type: "text", Text: part.Text})
}
case "input_image":
if source := dataURIToAnthropicImageSource(part.ImageURL); source != nil {
blocks = append(blocks, AnthropicContentBlock{Type: "image", Source: source})
}
}
}
if len(blocks) > 0 {
content, _ := json.Marshal(blocks)
return content
}
if len(parts) == 0 {
content, _ := json.Marshal("(empty)")
return content
}
}
content, _ := json.Marshal(item.Output)
return content
}
// normalizeAnthropicToolPairing rebuilds the message sequence so it satisfies
// Anthropic's tool_use/tool_result invariants, which the naive item-by-item
// conversion violates whenever the Responses history interleaves anything
@@ -163,3 +163,28 @@ func TestAnthropicPairing_SingleCall(t *testing.T) {
require.True(t, hasToolUse(parseContentBlocks(msgs[1].Content), "call_A"))
require.True(t, hasToolResult(parseContentBlocks(msgs[2].Content), "call_A"))
}
func TestResponsesToAnthropic_FunctionOutputContentArray(t *testing.T) {
msgs := convertAnthropic(t, `[
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_A","output":[
{"type":"input_text","text":"image loaded"},
{"type":"input_image","image_url":"data:image/png;base64,YQ=="}
]}
]`)
require.Len(t, msgs, 2)
resultBlocks := parseContentBlocks(msgs[1].Content)
require.Len(t, resultBlocks, 1)
require.Equal(t, "tool_result", resultBlocks[0].Type)
var content []AnthropicContentBlock
require.NoError(t, json.Unmarshal(resultBlocks[0].Content, &content))
require.Len(t, content, 2)
require.Equal(t, "text", content[0].Type)
require.Equal(t, "image loaded", content[0].Text)
require.Equal(t, "image", content[1].Type)
require.NotNil(t, content[1].Source)
require.Equal(t, "image/png", content[1].Source.MediaType)
require.Equal(t, "YQ==", content[1].Source.Data)
}
@@ -122,6 +122,9 @@ func TestResponsesToAnthropic_MixedToolsProduceValidAnthropicTools(t *testing.T)
assert.Equal(t, "web_search_20250305", tools[2].Type)
assert.Equal(t, "web_search", tools[2].Name)
assert.Empty(t, tools[2].InputSchema)
serverToolWire, err := json.Marshal(tools[2])
require.NoError(t, err)
assert.NotContains(t, string(serverToolWire), `"input_schema"`)
}
func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) {
+33 -3
View File
@@ -4,7 +4,10 @@
// formats can be served through a unified gateway.
package apicompat
import "encoding/json"
import (
"bytes"
"encoding/json"
)
// ---------------------------------------------------------------------------
// Anthropic Messages API types
@@ -112,7 +115,7 @@ type AnthropicTool struct {
Type string `json:"type,omitempty"` // e.g. "web_search_20250305" for server tools
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema json.RawMessage `json:"input_schema"` // JSON Schema object
InputSchema json.RawMessage `json:"input_schema,omitempty"` // JSON Schema object
CacheControl *AnthropicCacheControl `json:"cache_control,omitempty"`
}
@@ -260,7 +263,34 @@ type ResponsesInputItem struct {
ID string `json:"id,omitempty"`
// type=function_call_output
Output string `json:"output,omitempty"`
Output string `json:"output,omitempty"`
outputRaw json.RawMessage
}
func (i *ResponsesInputItem) UnmarshalJSON(data []byte) error {
type alias ResponsesInputItem
var wire struct {
*alias
Output json.RawMessage `json:"output"`
}
*i = ResponsesInputItem{}
wire.alias = (*alias)(i)
if err := json.Unmarshal(data, &wire); err != nil {
return err
}
output := bytes.TrimSpace(wire.Output)
if len(output) == 0 || bytes.Equal(output, []byte("null")) {
return nil
}
if err := json.Unmarshal(output, &i.Output); err == nil {
return nil
}
i.outputRaw = append(i.outputRaw[:0], output...)
i.Output = string(output)
return nil
}
// ResponsesContentPart is a typed content part in a Responses message.
+7 -1
View File
@@ -66,7 +66,7 @@ const DefaultCacheControlTTL = "5m"
// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。
// 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。
// 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。
const CLICurrentVersion = "2.1.161"
const CLICurrentVersion = "2.1.220"
// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表,
// 用于 OAuth 账号伪装成 Claude Code 时使用。
@@ -147,6 +147,12 @@ var DefaultModels = []Model{
DisplayName: "Claude Opus 4.8",
CreatedAt: "2026-05-29T00:00:00Z",
},
{
ID: "claude-opus-5",
Type: "model",
DisplayName: "Claude Opus 5",
CreatedAt: "2026-07-25T00:00:00Z",
},
{
ID: "claude-sonnet-5",
Type: "model",
@@ -270,6 +270,7 @@ type UsageLogFilters struct {
APIKeyID int64
AccountID int64
GroupID int64
RequestID string
Model string
// ModelFilterSource controls how Model is matched. Empty preserves raw usage_logs.model semantics.
ModelFilterSource string
@@ -0,0 +1,17 @@
package liveattestation
import (
"context"
"errors"
)
var (
ErrUnsupportedPlatform = errors.New("live attestation is only supported when Sub2API runs on macOS; Windows support is not implemented yet")
ErrChatGPTAppMissing = errors.New("live attestation requires the official ChatGPT app on the Sub2API server")
)
// Provider 在发起 Live 请求前生成 ChatGPT DeviceCheck attestation。
type Provider interface {
Check(ctx context.Context) error
Generate(ctx context.Context) (string, error)
}
@@ -0,0 +1,294 @@
//go:build darwin
package liveattestation
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"time"
"github.com/google/uuid"
)
const (
chatGPTApplicationPath = "/Applications/ChatGPT.app"
attestationTimeout = 5 * time.Second
)
type darwinProvider struct {
appSessionID string
appPaths []string
}
type deviceSignals struct {
SchemaVersion int `json:"schemaVersion"`
PreferredLanguages []string `json:"preferredLanguages"`
Locale string `json:"locale"`
Timezone string `json:"timezone"`
ScreenSizeSum int `json:"screenSizeSum"`
ScreenScale float64 `json:"screenScale"`
AppSessionID string `json:"appSessionId"`
}
type macOSSignals struct {
Locale string `json:"locale"`
Languages []string `json:"languages"`
Timezone string `json:"timezone"`
Width float64 `json:"width"`
Height float64 `json:"height"`
Scale float64 `json:"scale"`
}
func NewProvider() Provider {
paths := []string{chatGPTApplicationPath}
if home, err := os.UserHomeDir(); err == nil && strings.TrimSpace(home) != "" {
paths = append(paths, filepath.Join(home, "Applications", "ChatGPT.app"))
}
return &darwinProvider{
appSessionID: uuid.NewString(),
appPaths: paths,
}
}
func (p *darwinProvider) Check(ctx context.Context) error {
checkCtx, cancel := context.WithTimeout(ctx, attestationTimeout)
defer cancel()
_, _, _, err := p.resolveRuntime(checkCtx)
return err
}
func (p *darwinProvider) resolveRuntime(ctx context.Context) (string, string, string, error) {
if runtime.GOARCH != "arm64" {
return "", "", "", errors.New("live attestation currently requires Apple Silicon; Intel macOS is not supported")
}
appPath, err := p.findApplication()
if err != nil {
return "", "", "", err
}
resourcesPath := filepath.Join(appPath, "Contents", "Resources")
nodePath := filepath.Join(resourcesPath, "cua_node", "bin", "node")
modulePath := filepath.Join(resourcesPath, "native", "devicecheck.node")
for filePath, label := range map[string]string{
nodePath: "bundled Node.js runtime",
modulePath: "DeviceCheck native module",
} {
if info, statErr := os.Stat(filePath); statErr != nil || info.IsDir() {
return "", "", "", fmt.Errorf("%w: ChatGPT app is missing its %s", ErrChatGPTAppMissing, label)
}
}
bundleID, err := readBundleIdentifier(ctx, appPath)
if err != nil {
return "", "", "", err
}
return nodePath, modulePath, bundleID, nil
}
func (p *darwinProvider) Generate(ctx context.Context) (string, error) {
runCtx, cancel := context.WithTimeout(ctx, attestationTimeout)
defer cancel()
nodePath, modulePath, bundleID, err := p.resolveRuntime(runCtx)
if err != nil {
return "", err
}
signals, err := p.readSignals(runCtx)
if err != nil {
return "", err
}
signalsJSON, err := json.Marshal(signals)
if err != nil {
return "", fmt.Errorf("encode Live attestation signals: %w", err)
}
command := exec.CommandContext(runCtx, nodePath, "-e", deviceCheckScript)
command.Env = []string{
"PATH=/usr/bin:/bin",
"SUB2API_DEVICECHECK_MODULE=" + modulePath,
"SUB2API_ATTESTATION_BUNDLE_ID=" + bundleID,
"SUB2API_ATTESTATION_SIGNALS=" + string(signalsJSON),
}
var stdout bytes.Buffer
var stderr bytes.Buffer
command.Stdout = &stdout
command.Stderr = &stderr
if err := command.Run(); err != nil {
if errors.Is(runCtx.Err(), context.DeadlineExceeded) {
return "", errors.New("ChatGPT DeviceCheck token generation timed out")
}
reason := strings.TrimSpace(stderr.String())
if len(reason) > 240 {
reason = reason[:240]
}
if reason == "" {
reason = err.Error()
}
return "", fmt.Errorf("ChatGPT DeviceCheck token generation failed: %s", reason)
}
header := strings.TrimSpace(stdout.String())
if len(header) < 20 || len(header) > 16*1024 || !json.Valid([]byte(header)) {
return "", errors.New("ChatGPT DeviceCheck returned a malformed attestation")
}
return header, nil
}
func (p *darwinProvider) findApplication() (string, error) {
for _, appPath := range p.appPaths {
info, err := os.Stat(appPath)
if err == nil && info.IsDir() {
return appPath, nil
}
}
return "", ErrChatGPTAppMissing
}
func readBundleIdentifier(ctx context.Context, appPath string) (string, error) {
infoPlist := filepath.Join(appPath, "Contents", "Info.plist")
output, err := exec.CommandContext(
ctx,
"/usr/bin/plutil",
"-extract",
"CFBundleIdentifier",
"raw",
infoPlist,
).Output()
if err != nil {
return "", fmt.Errorf("%w: cannot read its bundle identifier", ErrChatGPTAppMissing)
}
bundleID := strings.TrimSpace(string(output))
if !strings.HasPrefix(bundleID, "com.openai.") {
return "", errors.New("the installed ChatGPT app has an unexpected bundle identifier")
}
return bundleID, nil
}
func (p *darwinProvider) readSignals(ctx context.Context) (deviceSignals, error) {
const script = `ObjC.import("Foundation"); ObjC.import("AppKit");
const screen = $.NSScreen.mainScreen;
const frame = screen.frame;
JSON.stringify({
locale: ObjC.unwrap($.NSLocale.currentLocale.localeIdentifier),
languages: ObjC.deepUnwrap($.NSLocale.preferredLanguages),
timezone: ObjC.unwrap($.NSTimeZone.localTimeZone.name),
width: Number(frame.size.width),
height: Number(frame.size.height),
scale: Number(screen.backingScaleFactor)
})`
output, err := exec.CommandContext(ctx, "/usr/bin/osascript", "-l", "JavaScript", "-e", script).Output()
if err != nil {
return deviceSignals{}, fmt.Errorf("read macOS signals for Live attestation: %w", err)
}
var values macOSSignals
if err := json.Unmarshal(output, &values); err != nil {
return deviceSignals{}, fmt.Errorf("decode macOS signals for Live attestation: %w", err)
}
locale := truncateSignal(values.Locale, 64, "unknown")
languages := values.Languages
if len(languages) == 0 {
languages = []string{locale}
}
if len(languages) > 16 {
languages = languages[:16]
}
for index := range languages {
languages[index] = truncateSignal(languages[index], 64, locale)
}
scale := values.Scale
if scale <= 0 {
scale = 1
}
return deviceSignals{
SchemaVersion: 1,
PreferredLanguages: languages,
Locale: locale,
Timezone: truncateSignal(values.Timezone, 64, "unknown"),
ScreenSizeSum: max(0, int(values.Width+values.Height+0.5)),
ScreenScale: scale,
AppSessionID: truncateSignal(p.appSessionID, 128, uuid.NewString()),
}, nil
}
func truncateSignal(value string, limit int, fallback string) string {
value = strings.TrimSpace(value)
if value == "" {
value = fallback
}
if len(value) > limit {
return value[:limit]
}
return value
}
const deviceCheckScript = `
const addon = require(process.env.SUB2API_DEVICECHECK_MODULE);
const signals = JSON.parse(process.env.SUB2API_ATTESTATION_SIGNALS);
const bundleID = process.env.SUB2API_ATTESTATION_BUNDLE_ID;
function head(major, value) {
if (value < 24) return Buffer.from([major + value]);
if (value <= 255) return Buffer.from([major + 24, value]);
if (value <= 65535) {
const out = Buffer.allocUnsafe(3);
out[0] = major + 25;
out.writeUInt16BE(value, 1);
return out;
}
const out = Buffer.allocUnsafe(5);
out[0] = major + 26;
out.writeUInt32BE(value, 1);
return out;
}
function uint(value) { return head(0, value); }
function text(value) {
const body = Buffer.from(value, "utf8");
return Buffer.concat([head(96, body.length), body]);
}
function float(value) {
if (Number.isSafeInteger(value) && value >= 0) return uint(value);
const out = Buffer.allocUnsafe(9);
out[0] = 251;
out.writeDoubleBE(value, 1);
return out;
}
function array(values) { return Buffer.concat([head(128, values.length), ...values]); }
function map(entries) {
return Buffer.concat([head(160, entries.length), ...entries.flatMap(([key, value]) => [uint(key), value])]);
}
function field(key, value) { return Buffer.concat([text(key), text(value)]); }
function base64url(value) {
return value.toString("base64").replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/u, "");
}
(async () => {
const result = await addon.generateToken();
if (!result || !result.supported) throw new Error("DeviceCheck is not supported on this Mac");
if (!result.tokenBase64) throw new Error("DeviceCheck returned no token");
const fingerprint = map([
[0, uint(signals.schemaVersion)],
[1, array(signals.preferredLanguages.map(text))],
[2, text(signals.locale)],
[3, text(signals.timezone)],
[4, uint(signals.screenSizeSum)],
[5, float(signals.screenScale)],
[6, text(signals.appSessionId)]
]);
const fields = [
field("token", result.tokenBase64),
field("bundle_id", bundleID),
Buffer.concat([text("f"), head(64, fingerprint.length), fingerprint])
];
if (result.latencyMs != null) {
fields.push(Buffer.concat([text("t"), float(result.latencyMs)]));
}
const token = "v1." + base64url(Buffer.concat([Buffer.from([160 + fields.length]), ...fields]));
process.stdout.write(JSON.stringify({v: 1, s: 0, t: token}));
})().catch((error) => {
process.stderr.write(error instanceof Error ? error.message : String(error));
process.exitCode = 1;
});`
@@ -0,0 +1,19 @@
//go:build !darwin
package liveattestation
import "context"
type unsupportedProvider struct{}
func NewProvider() Provider {
return unsupportedProvider{}
}
func (unsupportedProvider) Check(context.Context) error {
return ErrUnsupportedPlatform
}
func (unsupportedProvider) Generate(context.Context) (string, error) {
return "", ErrUnsupportedPlatform
}
@@ -0,0 +1,20 @@
//go:build !darwin
package liveattestation
import (
"context"
"errors"
"testing"
)
func TestUnsupportedProviderReturnsExplicitPlatformError(t *testing.T) {
provider := NewProvider()
if err := provider.Check(context.Background()); !errors.Is(err, ErrUnsupportedPlatform) {
t.Fatalf("Check() error = %v, want ErrUnsupportedPlatform", err)
}
_, err := provider.Generate(context.Background())
if !errors.Is(err, ErrUnsupportedPlatform) {
t.Fatalf("Generate() error = %v, want ErrUnsupportedPlatform", err)
}
}
+11 -1
View File
@@ -3358,6 +3358,12 @@ func (r *accountRepository) FindByExtraField(ctx context.Context, key string, va
// ListDueUpstreamBillingProbeAccounts bounds result hydration and network work
// to limit. PostgreSQL must still filter and order all enabled candidates;
// MATERIALIZED avoids repeating the defensive timestamp parse expression.
// Go writes next_probe_at via RFC3339Nano (up to 9 fractional digits) while
// jsonpath datetime() parses at most microseconds, so fractions beyond 6
// digits are trimmed first — mirroring ListDueOllamaCloudUsageAccounts.
// Without this, every nanosecond timestamp is treated as malformed and the
// fail-open ordering pins the cycle to the lowest account IDs, starving the
// rest of the pool.
func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Context, now time.Time, limit int) ([]service.Account, error) {
if limit <= 0 {
return []service.Account{}, nil
@@ -3387,7 +3393,11 @@ func (r *accountRepository) ListDueUpstreamBillingProbeAccounts(ctx context.Cont
jsonb_path_query_first_tz(
jsonb_build_object(
'value',
replace(regexp_replace(next_probe_at, 'Z$', '+00:00'), 'T', ' ')
replace(regexp_replace(regexp_replace(
next_probe_at,
'(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$',
'\1\2'
), 'Z$', '+00:00'), 'T', ' ')
),
'$.value.datetime()',
'{}'::jsonb,
@@ -354,74 +354,182 @@ func canonicalJSON(raw string) string {
return string(encoded)
}
// ListDueOllamaCloudUsageAccounts returns at most one due representative per
// exact API key before hydration, preventing one shared group from consuming a
// whole runner cycle.
func (r *accountRepository) ListDueOllamaCloudUsageAccounts(ctx context.Context, now time.Time, limit int) ([]service.Account, error) {
// ollamaCloudUsageParseRFC3339SQL reuses the verified RFC3339(/Nano) parse path
// for a snapshot timestamp expression. Invalid or missing values fail open to NULL.
//
// The value is rewritten twice before it reaches jsonpath:
// 1. Sub-second precision beyond 6 digits is truncated, because .datetime()
// rejects more than microsecond resolution while Go emits 9 digits.
// 2. A trailing "Z" is rewritten to "+00:00". jsonpath .datetime() only learned
// to accept the ISO-8601 "Z" designator in PostgreSQL 17, and every timestamp
// this service writes is UTC (hence "Z"). Without this rewrite the parse
// silently yields NULL on PostgreSQL <= 16, which makes every due column NULL
// and collapses ListDueOllamaCloudUsageAccounts into its fail-open branch.
//
// jsonpath (rather than a direct ::timestamptz cast) is required so that values
// passing the shape regex but naming an impossible date (e.g. 2026-02-30) fail
// open to NULL instead of aborting the whole query.
func ollamaCloudUsageParseRFC3339SQL(expression string) string {
return `CASE
WHEN ` + expression + ` IS NULL THEN NULL
WHEN ` + expression + ` ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$'
THEN jsonb_path_query_first_tz(
to_jsonb(regexp_replace(
regexp_replace(
` + expression + `,
'(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$',
'\1\2'
),
'Z$',
'+00:00'
)),
'$.datetime()', '{}'::jsonb, true
) #>> '{}'
ELSE NULL
END`
}
// ListDueOllamaCloudUsageAccounts returns at most one truly-due activity-driven
// candidate per exact API key. Due timing (debounce, max-wait, failure backoff)
// is evaluated in SQL before LIMIT so non-due active groups cannot starve due ones.
// Account.LastUsedAt is stamped with the group MAX(last_used_at) for a service
// pure-function recheck against races between list and refresh.
//
// Rules mirror service.ollamaCloudUsageAutoRefreshDueAt (keep both in sync):
// - missing/invalid snapshot or times → fail-open first due
// - success: activity after fetched_at;
// due_at = GREATEST(LEAST(last_used+debounce, fetched+maxWait), fetched+minFetchInterval)
// - failed/unauthorized: activity after last_attempt; activity_due = LEAST(...);
// final due_at is not earlier than a valid next_refresh_at (invalid/missing fail-open)
func (r *accountRepository) ListDueOllamaCloudUsageAccounts(
ctx context.Context,
now time.Time,
debounce, maxWait time.Duration,
limit int,
) ([]service.Account, error) {
if limit <= 0 {
return []service.Account{}, nil
}
if r == nil || r.sql == nil {
return nil, errors.New("account repository SQL executor not configured")
}
if debounce <= 0 {
debounce = time.Minute
}
if maxWait <= 0 {
maxWait = time.Hour
}
debounceSeconds := debounce.Seconds()
maxWaitSeconds := maxWait.Seconds()
minFetchIntervalSeconds := service.OllamaCloudUsageMinFetchInterval.Seconds()
rows, err := r.sql.QueryContext(ctx, `
WITH candidates AS (
SELECT id, credentials ->> 'api_key' AS api_key,
extra #>> '{ollama_cloud_usage_snapshot,next_refresh_at}' AS next_refresh_at
WITH eligible AS (
SELECT id,
credentials ->> 'api_key' AS api_key,
last_used_at,
extra -> 'ollama_cloud_usage_snapshot' AS snapshot
FROM accounts
WHERE deleted_at IS NULL
AND status = 'active'
AND `+ollamaCloudUsageEligibleSQL+`
AND jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'
AND extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb
), group_activity AS (
SELECT credentials ->> 'api_key' AS api_key,
MAX(last_used_at) AS group_last_used_at
FROM accounts
WHERE deleted_at IS NULL
AND `+ollamaCloudUsageEligibleSQL+`
AND jsonb_typeof(credentials -> 'api_key') = 'string'
GROUP BY credentials ->> 'api_key'
), joined AS (
SELECT e.id, e.api_key, e.snapshot, g.group_last_used_at,
e.snapshot #>> '{status}' AS status,
e.snapshot #>> '{fetched_at}' AS fetched_at,
e.snapshot #>> '{last_attempt_at}' AS last_attempt_at,
e.snapshot #>> '{next_refresh_at}' AS next_refresh_at
FROM eligible e
JOIN group_activity g ON g.api_key = e.api_key
), parsed AS MATERIALIZED (
SELECT id, api_key, next_refresh_at,
next_refresh_at ~ '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}(\.[0-9]+)?(Z|[+-][0-9]{2}:[0-9]{2})$' AS rfc3339_shape,
jsonb_path_query_first_tz(
to_jsonb(regexp_replace(
next_refresh_at,
'(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$',
'\1\2'
)),
'$.datetime()', '{}'::jsonb, true
) #>> '{}' AS parsed_next_refresh_at
FROM candidates
), due AS (
SELECT id, api_key, snapshot, group_last_used_at, status,
`+ollamaCloudUsageParseRFC3339SQL("fetched_at")+` AS parsed_fetched_at,
`+ollamaCloudUsageParseRFC3339SQL("last_attempt_at")+` AS parsed_last_attempt_at,
`+ollamaCloudUsageParseRFC3339SQL("next_refresh_at")+` AS parsed_next_refresh_at
FROM joined
), timed AS (
SELECT *,
CASE WHEN next_refresh_at IS NULL OR NOT rfc3339_shape OR parsed_next_refresh_at IS NULL THEN 0 ELSE 1 END AS due_class
CASE
WHEN status = 'ok'
AND parsed_fetched_at IS NOT NULL
AND group_last_used_at IS NOT NULL
AND group_last_used_at > parsed_fetched_at::timestamptz
THEN GREATEST(
LEAST(
group_last_used_at + make_interval(secs => $2::double precision),
parsed_fetched_at::timestamptz + make_interval(secs => $3::double precision)
),
parsed_fetched_at::timestamptz + make_interval(secs => $5::double precision)
)
WHEN status IN ('failed', 'unauthorized')
AND parsed_last_attempt_at IS NOT NULL
AND group_last_used_at IS NOT NULL
AND group_last_used_at > parsed_last_attempt_at::timestamptz
THEN GREATEST(
LEAST(
group_last_used_at + make_interval(secs => $2::double precision),
parsed_last_attempt_at::timestamptz + make_interval(secs => $3::double precision)
),
COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz)
)
ELSE NULL
END AS activity_due_at
FROM parsed
WHERE next_refresh_at IS NULL
OR NOT rfc3339_shape
OR parsed_next_refresh_at IS NULL
OR parsed_next_refresh_at::timestamptz <= $1
), candidates AS (
SELECT *,
CASE
WHEN snapshot IS NULL OR snapshot = 'null'::jsonb OR status IS NULL
OR status NOT IN ('ok', 'failed', 'unauthorized') THEN 0
WHEN status = 'ok' AND parsed_fetched_at IS NULL THEN 0
WHEN status IN ('failed', 'unauthorized') AND parsed_last_attempt_at IS NULL THEN 0
WHEN activity_due_at IS NOT NULL AND $1 >= activity_due_at THEN 1
ELSE NULL
END AS due_class,
activity_due_at AS due_at
FROM timed
), ranked AS (
SELECT *, row_number() OVER (
PARTITION BY api_key
ORDER BY due_class,
CASE WHEN rfc3339_shape AND parsed_next_refresh_at IS NOT NULL THEN parsed_next_refresh_at::timestamptz END NULLS FIRST,
id
) AS group_rank
FROM due
SELECT id, api_key, group_last_used_at, due_class, due_at,
row_number() OVER (
PARTITION BY api_key
ORDER BY due_class,
due_at NULLS FIRST,
id
) AS group_rank
FROM candidates
WHERE due_class IS NOT NULL
)
SELECT id
SELECT id, group_last_used_at
FROM ranked
WHERE group_rank = 1
ORDER BY due_class,
CASE WHEN rfc3339_shape AND parsed_next_refresh_at IS NOT NULL THEN parsed_next_refresh_at::timestamptz END NULLS FIRST,
id
LIMIT $2
`, now.UTC(), limit)
ORDER BY due_class, due_at NULLS FIRST, id
LIMIT $4
`, now.UTC(), debounceSeconds, maxWaitSeconds, limit, minFetchIntervalSeconds)
if err != nil {
return nil, err
}
defer func() { _ = rows.Close() }()
type dueRow struct {
id int64
groupLastUsed *time.Time
}
rowsOut := make([]dueRow, 0, limit)
ids := make([]int64, 0, limit)
for rows.Next() {
var id int64
if err := rows.Scan(&id); err != nil {
var row dueRow
if err := rows.Scan(&row.id, &row.groupLastUsed); err != nil {
return nil, err
}
ids = append(ids, id)
rowsOut = append(rowsOut, row)
ids = append(ids, row.id)
}
if err := rows.Err(); err != nil {
return nil, err
@@ -430,11 +538,26 @@ func (r *accountRepository) ListDueOllamaCloudUsageAccounts(ctx context.Context,
if err != nil {
return nil, err
}
result := make([]service.Account, 0, len(accounts))
byID := make(map[int64]*service.Account, len(accounts))
for _, account := range accounts {
if account != nil {
result = append(result, *account)
byID[account.ID] = account
}
}
result := make([]service.Account, 0, len(rowsOut))
for _, row := range rowsOut {
account := byID[row.id]
if account == nil {
continue
}
// Stamp group MAX(last_used_at) for service due evaluation.
if row.groupLastUsed != nil {
ts := row.groupLastUsed.UTC()
account.LastUsedAt = &ts
} else {
account.LastUsedAt = nil
}
result = append(result, *account)
}
return result, nil
}
@@ -23,81 +23,199 @@ func TestListDueOllamaCloudUsageAccountsOrderingLimitAndProxyHydration(t *testin
Username: "user", Password: "pass", Status: service.StatusActive,
})
createAccount := func(name, baseURL string, proxyID *int64, nextRefreshAt *time.Time) *service.Account {
createAccount := func(name, baseURL string, proxyID *int64, snapshot map[string]any, lastUsed *time.Time) *service.Account {
t.Helper()
extra := map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
}
if nextRefreshAt != nil {
extra[service.OllamaCloudUsageSnapshotExtraKey] = map[string]any{
"status": service.OllamaCloudUsageStatusOK, "next_refresh_at": nextRefreshAt.UTC().Format(time.RFC3339Nano),
}
if snapshot != nil {
extra[service.OllamaCloudUsageSnapshotExtraKey] = snapshot
}
return mustCreateAccount(t, tx.Client(), &service.Account{
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": name, "base_url": baseURL},
Extra: extra, ProxyID: proxyID,
Extra: extra, ProxyID: proxyID, LastUsedAt: lastUsed,
})
}
uppercasePath := createAccount("ollama-uppercase-path", "https://ollama.com/V1", nil, nil)
missingSnapshot := createAccount("ollama-due-missing", "HTTPS://WWW.OLLAMA.COM:443/v1", &proxy.ID, nil)
oldest := now.Add(-2 * time.Hour)
due := createAccount("ollama-due-oldest", "https://ollama.com", nil, &oldest)
future := now.Add(time.Minute)
_ = createAccount("ollama-not-due", "https://ollama.com", nil, &future)
_ = createAccount("ollama-ineligible", "https://ollama.com.evil.test", nil, nil)
uppercasePath := createAccount("ollama-uppercase-path", "https://ollama.com/V1", nil, nil, nil)
missingSnapshot := createAccount("ollama-due-missing", "HTTPS://WWW.OLLAMA.COM:443/v1", &proxy.ID, nil, nil)
fetched := now.Add(-2 * time.Hour)
activity := now.Add(-5 * time.Minute)
due := createAccount("ollama-due-activity", "https://ollama.com", nil, map[string]any{
"status": service.OllamaCloudUsageStatusOK,
"fetched_at": fetched.UTC().Format(time.RFC3339Nano),
"last_attempt_at": fetched.UTC().Format(time.RFC3339Nano),
"next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano),
}, &activity)
// Success snapshot without newer activity must not be listed.
_ = createAccount("ollama-not-due-idle", "https://ollama.com", nil, map[string]any{
"status": service.OllamaCloudUsageStatusOK,
"fetched_at": now.Add(-time.Hour).UTC().Format(time.RFC3339Nano),
"last_attempt_at": now.Add(-time.Hour).UTC().Format(time.RFC3339Nano),
"next_refresh_at": now.Add(-time.Minute).UTC().Format(time.RFC3339Nano),
}, nil)
_ = createAccount("ollama-ineligible", "https://ollama.com.evil.test", nil, nil, nil)
accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, 2)
accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, time.Minute, time.Hour, 2)
require.NoError(t, err)
require.Len(t, accounts, 2)
require.Equal(t, missingSnapshot.ID, accounts[0].ID)
require.Equal(t, due.ID, accounts[1].ID)
require.NotNil(t, accounts[1].LastUsedAt)
require.WithinDuration(t, activity.UTC(), accounts[1].LastUsedAt.UTC(), time.Second)
require.NotContains(t, accountIDs(accounts), uppercasePath.ID)
require.NotNil(t, accounts[0].Proxy)
require.Equal(t, proxy.ID, accounts[0].Proxy.ID)
require.Equal(t, proxy.URL(), accounts[0].Proxy.URL())
}
func TestListDueOllamaCloudUsageAccountsParsesRFC3339NanoAndFailsOpen(t *testing.T) {
// TestListDueOllamaCloudUsageAccountsParsesAllRFC3339Precisions pins the SQL
// timestamp parse path across the sub-second precisions and zone spellings that
// actually reach the database.
//
// Each fixture stores a fetched_at only two minutes old with activity 30s later,
// so a correctly parsed row is NOT due (debounce and the min fetch interval both
// place it in the future). A row whose timestamp fails to parse becomes NULL and
// falls into the fail-open branch, which makes it due. Asserting on absence is
// therefore what makes this test able to fail:
//
// - Go writes UTC times, i.e. the "Z" designator. jsonpath .datetime() only
// accepts "Z" from PostgreSQL 17 on, so without the Z -> +00:00 rewrite in
// ollamaCloudUsageParseRFC3339SQL every fixture here goes due on 14-16.
// - 7/8/9 sub-second digits exceed the microsecond resolution .datetime()
// allows and must be truncated first.
//
// Run against the oldest supported server to exercise the version-sensitive path:
//
// SUB2API_TEST_POSTGRES_IMAGE=postgres:15-alpine go test -tags integration ./internal/repository/
func TestListDueOllamaCloudUsageAccountsParsesAllRFC3339Precisions(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Date(2026, time.July, 22, 14, 0, 0, 0, time.UTC)
activity := now.Add(-30 * time.Second)
create := func(name, nextRefreshAt string) *service.Account {
t.Helper()
return mustCreateAccount(t, tx.Client(), &service.Account{
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": name, "base_url": "https://ollama.com"},
// All three spell the same instant, now-2m, with different precision/zone.
notDue := map[string]string{
"nano-z": "2026-07-22T13:58:00.123456789Z",
"eight-positive": "2026-07-22T14:58:00.12345678+01:00",
"seven-negative": "2026-07-22T11:58:00.1234567-02:00",
}
for name, fetchedAt := range notDue {
_ = mustCreateAccount(t, tx.Client(), &service.Account{
Name: "ollama-precision-" + name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "precision-" + name, "base_url": "https://ollama.com"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
"status": service.OllamaCloudUsageStatusOK, "next_refresh_at": nextRefreshAt,
"status": service.OllamaCloudUsageStatusOK,
"fetched_at": fetchedAt,
"last_attempt_at": fetchedAt,
},
},
LastUsedAt: &activity,
})
}
sevenDigitsOffset := create("ollama-nano-seven", "2026-07-22T11:00:00.1234567-02:00")
eightDigitsOffset := create("ollama-nano-eight", "2026-07-22T11:00:00.12345678+01:00")
nineDigitsZ := create("ollama-nano-nine", "2026-07-22T09:00:00.123456789Z")
invalidCalendar := create("ollama-nano-invalid", "2026-02-30T09:00:00.123456789Z")
future := create("ollama-nano-future", "2026-07-22T15:00:00.123456789Z")
// Guards against a vacuous pass: an genuinely due row must still come back.
staleFetched := now.Add(-2 * time.Hour)
due := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "ollama-precision-due", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "precision-due", "base_url": "https://ollama.com"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
"status": service.OllamaCloudUsageStatusOK,
"fetched_at": staleFetched.UTC().Format(time.RFC3339Nano),
"last_attempt_at": staleFetched.UTC().Format(time.RFC3339Nano),
},
},
LastUsedAt: &activity,
})
accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, 10)
accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, time.Minute, time.Hour, 10)
require.NoError(t, err)
ids := accountIDs(accounts)
require.Contains(t, ids, due.ID, "a stale snapshot with fresh activity must be due")
require.Len(t, ids, 1,
"only the stale group may be due; extra rows mean a timestamp failed to parse and fell into the fail-open branch")
}
func TestListDueOllamaCloudUsageAccountsUsesGroupMaxLastUsedAndFailsOpen(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Date(2026, time.July, 22, 14, 0, 0, 0, time.UTC)
fetched := now.Add(-30 * time.Minute)
older := now.Add(-10 * time.Minute)
newer := now.Add(-2 * time.Minute)
leader := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "ollama-group-leader", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "shared-key", "base_url": "https://ollama.com"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
"status": service.OllamaCloudUsageStatusOK,
"fetched_at": fetched.UTC().Format(time.RFC3339Nano),
"last_attempt_at": fetched.UTC().Format(time.RFC3339Nano),
"next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano),
},
},
LastUsedAt: &older,
})
_ = mustCreateAccount(t, tx.Client(), &service.Account{
Name: "ollama-group-sibling", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "shared-key", "base_url": "https://www.ollama.com/v1"},
LastUsedAt: &newer,
})
invalid := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "ollama-invalid-snapshot", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "invalid-key", "base_url": "https://ollama.com"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
"status": service.OllamaCloudUsageStatusOK, "fetched_at": "2026-02-30T09:00:00.123456789Z",
},
},
})
idle := mustCreateAccount(t, tx.Client(), &service.Account{
Name: "ollama-idle-ok", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "idle-key", "base_url": "https://ollama.com"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
"status": service.OllamaCloudUsageStatusOK,
"fetched_at": fetched.UTC().Format(time.RFC3339Nano),
"last_attempt_at": fetched.UTC().Format(time.RFC3339Nano),
"next_refresh_at": fetched.Add(time.Hour).UTC().Format(time.RFC3339Nano),
},
},
})
accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, time.Minute, time.Hour, 10)
require.NoError(t, err, "invalid stored values must not abort the query")
require.Equal(t, []int64{
invalidCalendar.ID,
nineDigitsZ.ID,
eightDigitsOffset.ID,
sevenDigitsOffset.ID,
}, accountIDs(accounts))
require.NotContains(t, accountIDs(accounts), future.ID)
ids := accountIDs(accounts)
require.Contains(t, ids, invalid.ID)
require.Contains(t, ids, leader.ID)
require.NotContains(t, ids, idle.ID)
for _, account := range accounts {
if account.ID == leader.ID {
require.NotNil(t, account.LastUsedAt)
require.WithinDuration(t, newer.UTC(), account.LastUsedAt.UTC(), time.Second,
"group MAX(last_used_at) must come from the sibling")
}
}
}
func TestLockAndMergeAccountProbeExtraCoalescesNullableOllamaGroupIdentity(t *testing.T) {
@@ -413,3 +531,111 @@ func TestUpdateCredentialsUnchangedCredentialsPreserveManagedExtra(t *testing.T)
require.NotContains(t, probeLoaded.Extra, service.UpstreamBillingProbeExtraKey,
"changed credentials must keep clearing the probe snapshot")
}
// TestListDueOllamaCloudUsageAccountsSQLDueRulesMatchService proves the SQL
// candidate layer applies debounce / max-wait / failure-backoff before LIMIT,
// matching service.ollamaCloudUsageIsAutoRefreshDue, and that >20 active-but-
// not-yet-due groups cannot starve a truly due max-wait group.
func TestListDueOllamaCloudUsageAccountsSQLDueRulesMatchService(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Date(2026, time.July, 25, 12, 0, 0, 0, time.UTC)
debounce := time.Minute
maxWait := time.Hour
createOK := func(name string, fetched, lastUsed time.Time) *service.Account {
t.Helper()
return mustCreateAccount(t, tx.Client(), &service.Account{
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": name, "base_url": "https://ollama.com"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: map[string]any{
"status": service.OllamaCloudUsageStatusOK,
"fetched_at": fetched.UTC().Format(time.RFC3339Nano),
"last_attempt_at": fetched.UTC().Format(time.RFC3339Nano),
"next_refresh_at": fetched.Add(maxWait).UTC().Format(time.RFC3339Nano),
},
},
LastUsedAt: &lastUsed,
})
}
createFailed := func(name string, lastAttempt, lastUsed, nextRefresh time.Time, nextRefreshRaw string) *service.Account {
t.Helper()
snapshot := map[string]any{
"status": service.OllamaCloudUsageStatusFailed,
"last_attempt_at": lastAttempt.UTC().Format(time.RFC3339Nano),
"failure_count": 1,
}
if nextRefreshRaw != "" {
snapshot["next_refresh_at"] = nextRefreshRaw
} else {
snapshot["next_refresh_at"] = nextRefresh.UTC().Format(time.RFC3339Nano)
}
return mustCreateAccount(t, tx.Client(), &service.Account{
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": name, "base_url": "https://ollama.com"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "cipher:wos-session=fixture",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: snapshot,
},
LastUsedAt: &lastUsed,
})
}
// 21 groups with activity after fetch but debounce not elapsed — previously
// these alone could fill LIMIT 20 every minute and starve true due groups.
notDueIDs := make(map[int64]struct{}, 21)
for i := 0; i < 21; i++ {
// fetched 10m ago, last used 10s ago → due_at = lastUsed+debounce = now+50s (not due)
acc := createOK(fmt.Sprintf("ollama-not-due-debounce-%02d", i), now.Add(-10*time.Minute), now.Add(-10*time.Second))
notDueIDs[acc.ID] = struct{}{}
}
// Truly due via max-wait: fetched 2h ago, continuous activity 10s ago.
// due_at = min(now-10s+1m, now-2h+1h) = now-1h → due.
maxWaitDue := createOK("ollama-due-maxwait", now.Add(-2*time.Hour), now.Add(-10*time.Second))
// Success debounce elapsed: last used 2m ago with debounce 1m → due.
debounceDue := createOK("ollama-due-debounce", now.Add(-30*time.Minute), now.Add(-2*time.Minute))
// Success still within debounce → not due.
_ = createOK("ollama-not-due-fresh", now.Add(-30*time.Minute), now.Add(-20*time.Second))
// Failure blocked by next_refresh_at backoff even with new activity.
_ = createFailed("ollama-fail-backoff", now.Add(-30*time.Minute), now.Add(-2*time.Minute), now.Add(10*time.Minute), "")
// Failure after backoff with new request → due.
failDue := createFailed("ollama-fail-due", now.Add(-30*time.Minute), now.Add(-2*time.Minute), now.Add(-time.Minute), "")
// Invalid next_refresh_at must fail open (not abort query / not block activity due).
failInvalidNext := createFailed("ollama-fail-invalid-next", now.Add(-30*time.Minute), now.Add(-2*time.Minute), time.Time{}, "not-a-timestamp")
accounts, err := repo.ListDueOllamaCloudUsageAccounts(ctx, now, debounce, maxWait, 20)
require.NoError(t, err)
ids := accountIDs(accounts)
require.Contains(t, ids, maxWaitDue.ID, "max-wait due group must not be starved by not-yet-due activity groups")
require.Contains(t, ids, debounceDue.ID, "success debounce elapsed must be due in SQL")
require.Contains(t, ids, failDue.ID, "failure after backoff with new activity must be due in SQL")
require.Contains(t, ids, failInvalidNext.ID, "invalid next_refresh_at must fail open to activity due")
require.LessOrEqual(t, len(accounts), 20)
// Fixtures below match service.ollamaCloudUsageIsAutoRefreshDue semantics;
// none of the not-yet-due groups may appear even when they outnumber the limit.
for _, id := range ids {
_, isNotDue := notDueIDs[id]
require.False(t, isNotDue, "not-yet-due debounce group %d must not be returned by SQL LIMIT layer", id)
}
require.NotContains(t, ids, int64(0))
// Explicit not-due names must stay out: fresh success and failure still in backoff.
for _, account := range accounts {
require.NotContains(t, account.Name, "not-due")
require.NotEqual(t, "ollama-fail-backoff", account.Name)
require.NotEqual(t, "ollama-not-due-fresh", account.Name)
}
}
@@ -190,13 +190,15 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) {
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
now := time.Date(2026, time.July, 22, 12, 0, 0, 0, time.UTC)
debounce := time.Minute
maxWait := time.Hour
var capturedSQL string
mock.ExpectQuery("WITH candidates AS").
WithArgs(now, 20).
WillReturnRows(sqlmock.NewRows([]string{"id"}))
mock.ExpectQuery("WITH eligible AS").
WithArgs(now.UTC(), debounce.Seconds(), maxWait.Seconds(), 20, service.OllamaCloudUsageMinFetchInterval.Seconds()).
WillReturnRows(sqlmock.NewRows([]string{"id", "group_last_used_at"}))
repo := newAccountRepositoryWithSQL(nil, captureQuerySQL{db: db, captured: &capturedSQL}, nil)
accounts, err := repo.ListDueOllamaCloudUsageAccounts(context.Background(), now, 20)
accounts, err := repo.ListDueOllamaCloudUsageAccounts(context.Background(), now, debounce, maxWait, 20)
require.NoError(t, err)
require.Empty(t, accounts)
@@ -209,10 +211,24 @@ func TestListDueOllamaCloudUsageAccountsFiltersOrdersAndLimits(t *testing.T) {
ollamaCloudBaseURLMatchesSQL("credentials ->> 'base_url'"),
"jsonb_typeof(extra -> 'ollama_cloud_usage_session') = 'string'",
`extra @> '{"ollama_cloud_usage_auto_refresh": true}'::jsonb`,
"parsed_next_refresh_at::timestamptz <= $1",
"MAX(last_used_at) AS group_last_used_at",
"PARTITION BY api_key",
"WHERE group_rank = 1",
"LIMIT $2",
"LIMIT $4",
"make_interval(secs => $2::double precision)",
"make_interval(secs => $3::double precision)",
// Minimum interval floor between successful fetches.
"make_interval(secs => $5::double precision)",
// jsonpath .datetime() only accepts the ISO-8601 "Z" designator from
// PostgreSQL 17 on, and this service writes UTC timestamps. Without this
// rewrite every parsed_* column is NULL on 14-16 and the due filter
// collapses into its fail-open branch.
`regexp_replace( regexp_replace( fetched_at, '(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$', '\1\2' ), 'Z$', '+00:00' )`,
"group_last_used_at > parsed_fetched_at::timestamptz",
"group_last_used_at > parsed_last_attempt_at::timestamptz",
"$1 >= activity_due_at",
"COALESCE(parsed_next_refresh_at::timestamptz, '-infinity'::timestamptz)",
"ORDER BY due_class, due_at NULLS FIRST, id",
} {
require.Contains(t, normalized, clause)
}
@@ -49,3 +49,107 @@ func TestListDueUpstreamBillingProbeAccountsHandlesInvalidCalendarDate(t *testin
require.Equal(t, invalidID, accounts[0].ID)
require.Equal(t, dueID, accounts[1].ID)
}
func insertUpstreamBillingProbeAccount(ctx context.Context, t *testing.T, tx sqlQueryer, name, nextProbeAt string) int64 {
t.Helper()
var id int64
extra := fmt.Sprintf(`{
"upstream_billing_probe_enabled": true,
"upstream_billing_probe": {"status": "ok", "next_probe_at": %q}
}`, nextProbeAt)
err := scanSingleRow(ctx, tx, `
INSERT INTO accounts (name, platform, type, status, extra)
VALUES ($1, 'openai', $2, 'active', $3::jsonb)
RETURNING id
`, []any{name, service.AccountTypeAPIKey, extra}, &id)
require.NoError(t, err)
return id
}
// rfc3339WithFraction renders t in UTC with an explicit fractional-second
// suffix, e.g. "2026-07-25T17:29:00.123456789Z". The probes' Go writer uses
// RFC3339Nano, so stored fractions carry up to 9 digits.
func rfc3339WithFraction(t time.Time, fraction string) string {
return fmt.Sprintf("%s.%sZ", t.UTC().Format("2006-01-02T15:04:05"), fraction)
}
// Regression for the scheduled-probe starvation bug: Go persists next_probe_at
// via RFC3339Nano (7-9 fractional digits), which jsonpath datetime() cannot
// parse. Before the trim fix every such row was treated as malformed and
// fail-open "due", so the cycle always returned the lowest account IDs and
// higher IDs were never probed.
func TestListDueUpstreamBillingProbeAccountsParsesNanosecondTimestamps(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Date(2026, time.July, 25, 17, 30, 0, 0, time.UTC)
_, err := tx.ExecContext(ctx, `
UPDATE accounts
SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe'
`)
require.NoError(t, err)
// 22 low-ID accounts that are NOT due yet, stored with 9 fractional digits
// exactly as the legacy writer produced them (no migration).
notDue := now.Add(time.Hour)
for i := 0; i < 22; i++ {
insertUpstreamBillingProbeAccount(ctx, t, tx,
fmt.Sprintf("probe-nano-not-due-%02d", i),
rfc3339WithFraction(notDue.Add(time.Duration(i)*time.Second), "123456789"))
}
// 3 high-ID accounts that ARE due, with 9/8/7 fractional digits. Their
// parsed order must follow due time, not insertion/ID order.
dueThird := insertUpstreamBillingProbeAccount(ctx, t, tx,
"probe-nano-due-9digits", rfc3339WithFraction(now.Add(-time.Minute), "123456789"))
dueFirst := insertUpstreamBillingProbeAccount(ctx, t, tx,
"probe-nano-due-8digits", rfc3339WithFraction(now.Add(-3*time.Minute), "12345678"))
dueSecond := insertUpstreamBillingProbeAccount(ctx, t, tx,
"probe-nano-due-7digits", rfc3339WithFraction(now.Add(-2*time.Minute), "1234567"))
accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20)
require.NoError(t, err)
require.Len(t, accounts, 3)
require.Equal(t, dueFirst, accounts[0].ID)
require.Equal(t, dueSecond, accounts[1].ID)
require.Equal(t, dueThird, accounts[2].ID)
}
// With more due accounts than the cycle limit, selection must follow the
// parsed due time so accounts beyond the first <limit> IDs still get their
// turn; before the fix the same lowest IDs monopolized every cycle.
func TestListDueUpstreamBillingProbeAccountsSelectsEarliestDueAcrossIDs(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
now := time.Date(2026, time.July, 25, 17, 30, 0, 0, time.UTC)
_, err := tx.ExecContext(ctx, `
UPDATE accounts
SET extra = extra - 'upstream_billing_probe_enabled' - 'upstream_billing_probe'
`)
require.NoError(t, err)
// 25 due accounts; the LOWEST IDs carry the LATEST due times, so a
// correct query must pick the 20 highest-ID rows here.
ids := make([]int64, 0, 25)
for i := 0; i < 25; i++ {
due := now.Add(-time.Duration(i+1) * time.Minute)
ids = append(ids, insertUpstreamBillingProbeAccount(ctx, t, tx,
fmt.Sprintf("probe-nano-order-%02d", i),
rfc3339WithFraction(due, "123456789")))
}
accounts, err := repo.ListDueUpstreamBillingProbeAccounts(ctx, now, 20)
require.NoError(t, err)
require.Len(t, accounts, 20)
got := make([]int64, 0, len(accounts))
for _, account := range accounts {
got = append(got, account.ID)
}
// Earliest due first == highest index first; the five most recently due
// (lowest index / lowest IDs) fall outside the limit this cycle.
want := make([]int64, 0, 20)
for i := 24; i >= 5; i-- {
want = append(want, ids[i])
}
require.Equal(t, want, got)
}
@@ -32,6 +32,7 @@ func TestAccountRepositoryListDueUpstreamBillingProbeAccountsBoundsQuery(t *test
require.Contains(t, normalized, "type = 'apikey'")
require.Contains(t, normalized, `extra @> '{"upstream_billing_probe_enabled": true}'::jsonb`)
require.Contains(t, normalized, "jsonb_path_query_first_tz")
require.Contains(t, normalized, `'(\.[0-9]{6})[0-9]+(Z|[+-][0-9]{2}:[0-9]{2})$'`)
require.Contains(t, normalized, "parsed AS MATERIALIZED")
require.Contains(t, normalized, "parsed_next_probe_at::timestamptz <= $1")
require.Contains(t, normalized, "LIMIT $2")
+72 -45
View File
@@ -199,6 +199,7 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
group.FieldMcpXMLInject,
group.FieldSupportedModelScopes,
group.FieldAllowMessagesDispatch,
group.FieldAllowLive,
group.FieldDefaultMappedModel,
group.FieldMessagesDispatchModelConfig,
group.FieldModelsListConfig,
@@ -221,7 +222,12 @@ func (r *apiKeyRepository) GetByKeyForAuth(ctx context.Context, key string) (*se
return apiKeyEntityToService(m), nil
}
func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) error {
func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey, fields service.APIKeyUpdateFields) error {
// 空掩码代表调用方不改任何列,直接返回,避免产生一次无意义的整行写。
if fields.IsEmpty() {
return nil
}
// 使用原子操作:将软删除检查与更新合并到同一语句,避免竞态条件。
// 之前的实现先检查 Exist 再 UpdateOneID,若在两步之间发生软删除,
// 则会更新已删除的记录。
@@ -231,57 +237,77 @@ func (r *apiKeyRepository) Update(ctx context.Context, key *service.APIKey) erro
now := time.Now()
builder := client.APIKey.Update().
Where(apikey.IDEQ(key.ID), apikey.DeletedAtIsNil()).
SetName(key.Name).
SetStatus(key.Status).
SetQuota(key.Quota).
SetQuotaUsed(key.QuotaUsed).
SetRateLimit5h(key.RateLimit5h).
SetRateLimit1d(key.RateLimit1d).
SetRateLimit7d(key.RateLimit7d).
SetUsage5h(key.Usage5h).
SetUsage1d(key.Usage1d).
SetUsage7d(key.Usage7d).
SetUpdatedAt(now)
if key.GroupID != nil {
builder.SetGroupID(*key.GroupID)
} else {
builder.ClearGroupID()
if fields.Name {
builder.SetName(key.Name)
}
if fields.Status {
builder.SetStatus(key.Status)
}
if fields.Quota {
builder.SetQuota(key.Quota)
}
if fields.QuotaUsed {
builder.SetQuotaUsed(key.QuotaUsed)
}
if fields.RateLimits {
builder.
SetRateLimit5h(key.RateLimit5h).
SetRateLimit1d(key.RateLimit1d).
SetRateLimit7d(key.RateLimit7d)
}
if fields.RateLimitUsage {
builder.
SetUsage5h(key.Usage5h).
SetUsage1d(key.Usage1d).
SetUsage7d(key.Usage7d)
// Rate limit window start times
if key.Window5hStart != nil {
builder.SetWindow5hStart(*key.Window5hStart)
} else {
builder.ClearWindow5hStart()
}
if key.Window1dStart != nil {
builder.SetWindow1dStart(*key.Window1dStart)
} else {
builder.ClearWindow1dStart()
}
if key.Window7dStart != nil {
builder.SetWindow7dStart(*key.Window7dStart)
} else {
builder.ClearWindow7dStart()
}
}
if fields.GroupID {
if key.GroupID != nil {
builder.SetGroupID(*key.GroupID)
} else {
builder.ClearGroupID()
}
}
// Expiration time
if key.ExpiresAt != nil {
builder.SetExpiresAt(*key.ExpiresAt)
} else {
builder.ClearExpiresAt()
}
// Rate limit window start times
if key.Window5hStart != nil {
builder.SetWindow5hStart(*key.Window5hStart)
} else {
builder.ClearWindow5hStart()
}
if key.Window1dStart != nil {
builder.SetWindow1dStart(*key.Window1dStart)
} else {
builder.ClearWindow1dStart()
}
if key.Window7dStart != nil {
builder.SetWindow7dStart(*key.Window7dStart)
} else {
builder.ClearWindow7dStart()
if fields.ExpiresAt {
if key.ExpiresAt != nil {
builder.SetExpiresAt(*key.ExpiresAt)
} else {
builder.ClearExpiresAt()
}
}
// IP 限制字段
if len(key.IPWhitelist) > 0 {
builder.SetIPWhitelist(key.IPWhitelist)
} else {
builder.ClearIPWhitelist()
}
if len(key.IPBlacklist) > 0 {
builder.SetIPBlacklist(key.IPBlacklist)
} else {
builder.ClearIPBlacklist()
if fields.IPRules {
if len(key.IPWhitelist) > 0 {
builder.SetIPWhitelist(key.IPWhitelist)
} else {
builder.ClearIPWhitelist()
}
if len(key.IPBlacklist) > 0 {
builder.SetIPBlacklist(key.IPBlacklist)
} else {
builder.ClearIPBlacklist()
}
}
affected, err := builder.Save(ctx)
@@ -948,6 +974,7 @@ func groupEntityToService(g *dbent.Group) *service.Group {
SupportedModelScopes: g.SupportedModelScopes,
SortOrder: g.SortOrder,
AllowMessagesDispatch: g.AllowMessagesDispatch,
AllowLive: g.AllowLive,
RequireOAuthOnly: g.RequireOauthOnly,
RequirePrivacySet: g.RequirePrivacySet,
DefaultMappedModel: g.DefaultMappedModel,
@@ -139,7 +139,7 @@ func (s *APIKeyRepoSuite) TestUpdate() {
key.Name = "Renamed"
key.Status = service.StatusDisabled
err := s.repo.Update(s.ctx, key)
err := s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Name: true, Status: true})
s.Require().NoError(err, "Update")
got, err := s.repo.GetByID(s.ctx, key.ID)
@@ -163,7 +163,7 @@ func (s *APIKeyRepoSuite) TestUpdate_ClearGroupID() {
s.Require().NoError(s.repo.Create(s.ctx, key))
key.GroupID = nil
err := s.repo.Update(s.ctx, key)
err := s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{GroupID: true})
s.Require().NoError(err, "Update")
got, err := s.repo.GetByID(s.ctx, key.ID)
@@ -368,7 +368,7 @@ func (s *APIKeyRepoSuite) TestCRUD_Search_ClearGroupID() {
key.Name = "Renamed"
key.Status = service.StatusDisabled
key.GroupID = nil
s.Require().NoError(s.repo.Update(s.ctx, key), "Update")
s.Require().NoError(s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Name: true, Status: true, GroupID: true}), "Update")
got2, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
@@ -486,7 +486,7 @@ func (s *APIKeyRepoSuite) TestIncrementQuotaUsedAndGetState() {
key := s.mustCreateApiKey(user.ID, "sk-quota-state", "QuotaState", nil)
key.Quota = 3
key.QuotaUsed = 1
s.Require().NoError(s.repo.Update(s.ctx, key), "Update quota")
s.Require().NoError(s.repo.Update(s.ctx, key, service.APIKeyUpdateFields{Quota: true, QuotaUsed: true}), "Update quota")
state, err := s.repo.IncrementQuotaUsedAndGetState(s.ctx, key.ID, 2.5)
s.Require().NoError(err, "IncrementQuotaUsedAndGetState")
@@ -0,0 +1,111 @@
//go:build integration
package repository
import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
// api_keys 上的用量列由计费热路径原子递增(IncrementQuotaUsed /
// IncrementRateLimitUsage)。编辑 Key 时若整行回写,
// 并发累计的配额与限流计数就会被旧快照覆盖。
func (s *APIKeyRepoSuite) TestUpdate_DoesNotRevertConcurrentQuotaUsage() {
user := s.mustCreateUser("apikey-lost-update-quota@example.com")
key := &service.APIKey{
UserID: user.ID,
Key: "sk-lost-update-quota",
Name: "before",
Status: service.StatusActive,
Quota: 100,
}
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
stale, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
s.Require().Zero(stale.QuotaUsed)
newUsed, err := s.repo.IncrementQuotaUsed(s.ctx, key.ID, 30)
s.Require().NoError(err, "IncrementQuotaUsed")
s.Require().InDelta(30, newUsed, 1e-9)
stale.Name = "after"
s.Require().NoError(
s.repo.Update(s.ctx, stale, service.APIKeyUpdateFields{Name: true}),
"Update",
)
got, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().Equal("after", got.Name, "declared column must still be written")
s.Require().InDelta(30, got.QuotaUsed, 1e-9, "quota_used must not be reverted by a stale key edit")
}
func (s *APIKeyRepoSuite) TestUpdate_DoesNotRevertConcurrentRateLimitUsage() {
user := s.mustCreateUser("apikey-lost-update-ratelimit@example.com")
key := &service.APIKey{
UserID: user.ID,
Key: "sk-lost-update-ratelimit",
Name: "before",
Status: service.StatusActive,
RateLimit5h: 100,
}
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
stale, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
s.Require().Zero(stale.Usage5h)
s.Require().NoError(s.repo.IncrementRateLimitUsage(s.ctx, key.ID, 42), "IncrementRateLimitUsage")
stale.Name = "after"
s.Require().NoError(
s.repo.Update(s.ctx, stale, service.APIKeyUpdateFields{Name: true}),
"Update",
)
got, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID after update")
s.Require().InDelta(42, got.Usage5h, 1e-9, "usage_5h must not be reverted by a stale key edit")
s.Require().InDelta(42, got.Usage1d, 1e-9, "usage_1d must not be reverted by a stale key edit")
s.Require().InDelta(42, got.Usage7d, 1e-9, "usage_7d must not be reverted by a stale key edit")
}
// 显式重置仍然必须生效,避免收窄写入列时把功能改坏。
func (s *APIKeyRepoSuite) TestUpdate_StillResetsUsageWhenDeclared() {
user := s.mustCreateUser("apikey-reset-usage@example.com")
key := &service.APIKey{
UserID: user.ID,
Key: "sk-reset-usage",
Name: "reset",
Status: service.StatusActive,
Quota: 100,
}
s.Require().NoError(s.repo.Create(s.ctx, key), "Create")
_, err := s.repo.IncrementQuotaUsed(s.ctx, key.ID, 30)
s.Require().NoError(err, "IncrementQuotaUsed")
s.Require().NoError(s.repo.IncrementRateLimitUsage(s.ctx, key.ID, 42), "IncrementRateLimitUsage")
current, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID")
current.QuotaUsed = 0
current.Usage5h = 0
current.Usage1d = 0
current.Usage7d = 0
current.Window5hStart = nil
current.Window1dStart = nil
current.Window7dStart = nil
s.Require().NoError(
s.repo.Update(s.ctx, current, service.APIKeyUpdateFields{QuotaUsed: true, RateLimitUsage: true}),
"Update",
)
got, err := s.repo.GetByID(s.ctx, key.ID)
s.Require().NoError(err, "GetByID after reset")
s.Require().Zero(got.QuotaUsed, "explicit quota reset must still apply")
s.Require().Zero(got.Usage5h, "explicit rate limit reset must still apply")
s.Require().Zero(got.Usage1d)
s.Require().Zero(got.Usage7d)
s.Require().Nil(got.Window5hStart)
}
@@ -77,8 +77,8 @@ func TestAuthCacheInvalidationTriggers_CoverSecurityMutationsOnly(t *testing.T)
userRepo := NewUserRepository(integrationEntClient, integrationDB)
loadedUser, err := userRepo.GetByID(ctx, user.ID)
require.NoError(t, err)
loadedUser.Balance += 10
require.NoError(t, userRepo.Update(ctx, loadedUser))
_, err = userRepo.AdjustBalance(ctx, loadedUser.ID, 10)
require.NoError(t, err)
require.Zero(t, count(), "balance update with unchanged allowed groups must not enqueue")
_, err = integrationDB.ExecContext(ctx, "UPDATE users SET status = 'disabled' WHERE id = $1", user.ID)
@@ -750,7 +750,7 @@ INSERT INTO batch_image_jobs (
batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price,
pricing_snapshot_version,
currency, hold_id,
idempotency_key, request_hash, manifest_hash, retry_count, output_expires_at
idempotency_key, request_hash, manifest_hash, retry_count, session_id, output_expires_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14,
@@ -760,7 +760,7 @@ INSERT INTO batch_image_jobs (
$25, $26, $27, $28,
$29,
$30, $31,
$32, $33, $34, $35, $36
$32, $33, $34, $35, $36, $37
)
RETURNING `+batchImageJobColumns,
params.BatchID, params.UserID, params.APIKeyID, params.AccountID, params.Provider, params.Model, params.TaskName, params.ParentBatchID, params.Status,
@@ -771,7 +771,7 @@ RETURNING `+batchImageJobColumns,
params.BatchDiscountMultiplier, params.HoldMultiplier, params.BillableUnitPrice, params.HoldUnitPrice,
params.PricingSnapshotVersion,
params.Currency, params.HoldID,
params.IdempotencyKey, params.RequestHash, params.ManifestHash, params.RetryCount, params.OutputExpiresAt,
params.IdempotencyKey, params.RequestHash, params.ManifestHash, params.RetryCount, params.SessionID, params.OutputExpiresAt,
))
}
@@ -825,7 +825,7 @@ batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price
pricing_snapshot_version,
currency, hold_id,
idempotency_key, request_hash, manifest_hash,
retry_count, version, output_expires_at, input_deleted_at, output_deleted_at, downloaded_at, user_deleted_at,
retry_count, version, session_id, output_expires_at, input_deleted_at, output_deleted_at, downloaded_at, user_deleted_at,
last_error_code, last_error_message,
created_at, updated_at, submitted_at, started_at, finished_at, settled_at`
@@ -838,6 +838,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) {
var parentBatchID sql.NullString
var holdAmount, actualCost sql.NullFloat64
var holdID, idempotencyKey, requestHash, manifestHash sql.NullString
var sessionID sql.NullString
var outputExpiresAt, inputDeletedAt, outputDeletedAt, downloadedAt, userDeletedAt sql.NullTime
var lastErrorCode, lastErrorMessage sql.NullString
var submittedAt, startedAt, finishedAt, settledAt sql.NullTime
@@ -852,7 +853,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) {
&job.PricingSnapshotVersion,
&job.Currency, &holdID,
&idempotencyKey, &requestHash, &manifestHash,
&job.RetryCount, &job.Version, &outputExpiresAt, &inputDeletedAt, &outputDeletedAt, &downloadedAt, &userDeletedAt,
&job.RetryCount, &job.Version, &sessionID, &outputExpiresAt, &inputDeletedAt, &outputDeletedAt, &downloadedAt, &userDeletedAt,
&lastErrorCode, &lastErrorMessage,
&job.CreatedAt, &job.UpdatedAt, &submittedAt, &startedAt, &finishedAt, &settledAt,
)
@@ -874,6 +875,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) {
job.IdempotencyKey = batchImageNullStringPtr(idempotencyKey)
job.RequestHash = batchImageNullStringPtr(requestHash)
job.ManifestHash = batchImageNullStringPtr(manifestHash)
job.SessionID = batchImageNullStringPtr(sessionID)
job.OutputExpiresAt = batchImageNullTimePtr(outputExpiresAt)
job.InputDeletedAt = batchImageNullTimePtr(inputDeletedAt)
job.OutputDeletedAt = batchImageNullTimePtr(outputDeletedAt)

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