Compare commits

...
295 Commits
Author SHA1 Message Date
Wesley Liddick c043c24774 Merge pull request #4925 from Brisbanehuang/feat/group-profit-control
feat(scheduler): 分组级利润控制——按账号倍率过滤 token 调度候选
2026-08-02 18:19:13 +08:00
Wesley Liddick 11c1e944b9 Merge pull request #4911 from Brisbanehuang/feat/upstream-billing-rate-writeback
feat(billing-probe): 探测成功后可选将上游声明倍率自动同步为账号倍率
2026-08-02 18:18:50 +08:00
Wesley Liddick d99ee72911 Merge pull request #4896 from Brisbanehuang/feat/upstream-billing-probe-multi-platform
feat(billing-probe): 上游计费倍率探测放宽到全部 API-key 平台账号
2026-08-02 18:18:37 +08:00
shaw dec47e8fae fix(profit-control): stop leaking profit policy, close veto livelock, restore passthrough turn pricing
审计修复,逐条如下。

H1 利润策略泄露给所有普通用户
  profit_control_enabled / profit_min_margin / profit_safety_buffer 从
  dto.Group 移到 dto.AdminGroup(后者内嵌前者),赋值相应从
  groupFromServiceBase 移到 GroupFromServiceAdmin;前端 TS 同步从 Group 移到
  AdminGroup。dto.Group 是 GET /api/v1/groups/available 的响应体,该响应本就带
  rate_multiplier,相乘即可反推运营方上游采购成本上限。
  api_contract_test.go 的 /groups/available golden JSON 回滚这三个字段,并把
  fixture 改成非零值(require.JSONEq 是精确比对,缺字段即失败)。
  新增 dto 层边界测试:普通用户 DTO 不含三字段、管理员 DTO 仍含。

M1 利润终检 continue 与 failover 503 退避互动产生活锁
  FailoverState 新增 profitVetoedAccountIDs / profitVetoCount 与
  RecordProfitVeto():加入排除集 + 计数,达 maxProfitVetoAttempts(10) 返回
  FailoverExhausted。HandleSelectionExhausted 的 503 清空分支改为清空后把利润
  否决的账号放回排除集;若排除集已全部由利润否决贡献,清空不会带来任何新候选,
  直接判定耗尽(否则 SwitchCount 永不前进、退避条件永远成立,每 2s 空转一轮)。
  五个 handler 否决点(gateway_handler ×2 / responses / chat_completions /
  gemini_v1beta)改为经 RecordProfitVeto 决策,耗尽时按无可用账号终止。
  回归测试钉死:503 之后持续利润否决必须有限步终止且不 spin;未启用利润控制的
  请求退避语义完全不变。

M2 排队等槽后才终检,延迟可放大到 N × WaitPlan.Timeout
  OpenAI 侧选号循环(自有 failedAccountIDs map,非 FailoverState)新增
  recordOpenAIProfitVeto + handleOpenAIProfitVetoExhausted,共用同一上限语义。
  覆盖 responses / messages-dispatch / chat_completions / alpha_search /
  embeddings / images / grok_media 七处,以及 WS 两处否决分支。

M4 ws_v2 透传 ingress 绕过 per-turn 重定价(选方案 B:最小止血)
  透传 relay 只回调 AfterTurn、没有任何 turn 起始回调,hooks.BeforeTurn 永远
  不触发,而 handler 把 turnPricingAt 初始化成建连时刻 ⇒ 透传连接全部 turn 按
  建连时刻的高峰因子结算,客户端峰前建连保活即可全程谷价——正是本 PR 想堵的
  漏洞。改为 openAIWSTurnPricing 零值起步、只由 BeforeTurn 冻结;透传路径保持
  零值,RecordUsage 回退记录时刻,与引入利润控制前的基线一致。
  未选方案 A(给透传补 turn 起始回调):passthrough_relay.go 是 #5167 刚修过的
  取消传播/close frame 时序敏感区;且 BeforeTurn 还承担 turn>1 的并发槽位抢占,
  接进去等于给透传连接引入 per-turn 抢槽,风险远超本次修复范围。透传仍有建连时
  的准入门,只是没有 turn 级复核,已在两处注释写明。
  测试:service 层钉死透传 ingress 不触发 BeforeTurn(含失败时的复核指引),
  handler 层钉死零值语义与逐 turn 覆盖。

M5 装门读分组走了带账号计数聚合的 GetByID
  SchedulerSnapshotService 新增 GetGroupByIDLite,openai/gateway 两处装门改用
  之。门只需要平台/倍率/利润/高峰字段,且该查询发生在「是否启用利润控制」判定
  之前,未启用的分组同样付代价。两个测试 stub 的 GetByID 改成 panic 守卫。

M6 认证快照注释与真实读取路径相反
  门解析优先取 ctxkey.Group,而它就是本快照物化出来的对象,直连流量走的正是这
  条路。改正注释,与 api_key_repo.go 投影处的说明对齐,避免后人照旧注释删列。

M3 rate_multiplier 为 nil 时利润门 fail-closed(不改行为,加护栏)
  保留 fail-closed。补 repository 层测试钉死账号调度快照的 full/metadata 两份
  payload 都必须保留 RateMultiplier(含 0 值),漏列在 CI 就红。

L1 迁移号注释 191 / 191-192 改为实际的 192/193。
L2 admin group Create 的利润配置预校验改用与 CreateGroup 一致的归一化平台
   (新增 service.NormalizeGroupPlatform,两边共用)。保留预校验而非删除:
   service 层返回的是无类型 error,经 ErrorFrom 会变成 500,删掉会把合法的
   400 降级成 500。
L3 前端利润校验的上界改为判定换算后的小数(后端按小数校验 [0,1)),
   99.999% 会四舍五入进位成 1.0 而被后端 400;i18n en/zh 同步改为 0-99.99。
L4 clampProfitControlThreshold / profitControlOverThreshold 抽为共用函数,
   线上装门/否决点与 profit-preview 不再各自实现,附边界语义测试。
L5 profit-preview 补「默认 D 有账号但最低有效 D 归零」的告警(两档都为 0 由
   既有告警覆盖,不重复)。
2026-08-01 22:39:33 +08:00
Brisbanehuang fad2f215e8 fix(profit-control): decouple gate from image intent, propagate gate via selection, restore eager sticky fallback
Review fixes for the profit-control feature commit:

- Image intent no longer disables the profit gate. The shared /v1/responses
  handler previously skipped the pricing context (and therefore the gate)
  whenever the platform-wide image intent predicate matched, which includes
  Codex's passive image_gen namespace declaration: any client could disable
  admission control for anthropic/gemini/antigravity groups by declaring a
  namespace tool in the request body. Both /v1/responses paths now always
  install the token pricing context; image intent only drives capability
  routing and image billing. Mixed token+image requests stay token-gated;
  only dedicated media endpoints remain out of scope.
- Out-of-scope paths are now explicitly suppressed instead of implicitly
  ungated: Grok media (billed by media multipliers; also prevents in-flight
  video lookups from turning into spurious 404s), OpenAI-group count_tokens
  (unbilled), and Live calls (duration billed) carry a suppress marker that
  every install point honors, including the defensive scheduler-entry
  install.
- The gate resolved during selection now travels back to handlers on the
  AccountSelectionResult. The shared gateway installed the gate only on a
  scheduler-local context, so handler-side post-slot terminal rechecks and
  post-admission sticky binding were no-ops for anthropic/gemini/
  antigravity/shared-grok requests (and for composite-routed member groups
  on the OpenAI path). Handlers re-apply the carried gate via
  ContextWithSelectionProfitGate before the terminal recheck and binding;
  the WS acquired-selection branch gained the previously missing recheck.
- Sticky binding semantics restored for ungated traffic:
  BindStickySessionAfterProfitAdmission falls back to the official eager
  bind when no gate is installed (wait paths lost their only binding point
  otherwise), reads the pre-existing binding at bind time only when gated
  (removes the unconditional per-request Redis read the feature added to
  the shared handlers), and the legacy engine's three selection-time
  binding writes are skipped under a gate so a terminally vetoed account
  can no longer become the new sticky target.
- Responses WS connections re-freeze pricingAt and re-resolve the gate at
  every turn (BeforeTurn) and bill each turn with its own instant, closing
  the connect-at-valley/bill-at-valley window; a turn that fails the
  recheck closes the connection so the client reselects on reconnect.
- Terminal recheck no longer swaps a DB-fresh selected account for a
  staler snapshot object (UpdatedAt guard), and observer counters are
  documented as per-evaluation.
- Migrations renumbered 191/192 -> 192/193 after the passkey migration
  landed upstream as 191.

New regressions: selection-carried gate propagation (control group proves
the pre-fix no-op), image intent not disabling the shared gate, eager
binding fallback without a gate (both services), gated
read-failure/sentinel-miss binding semantics, legacy-engine deferred
binding under a gate with eager behavior preserved ungated, turn-level
pricing refresh (config re-resolution, scheduled-group precedence,
suppress, mid-connection disable), and suppress-marker coverage for the
request pricing context.
2026-08-01 22:39:32 +08:00
Brisbanehuang 20ad5ec506 feat(scheduler): per-group profit control for token account admission
Group pricing (rate multiplier, peak windows, per-user overrides) and
account cost (accounts.rate_multiplier) already live side by side, but
nothing stops the scheduler from handing a request to an account whose
cost multiplier exceeds what the group's pricing can profitably serve.
Add an opt-in per-group profit gate that filters scheduling candidates
by a margin rule, while ordering, scoring, stickiness and breakers keep
working unchanged among qualified accounts.

Admission rule: an account qualifies iff U <= D * (1 - min_margin -
safety_buffer) within a small relative epsilon, where U is
accounts.rate_multiplier (0 is legal; missing/negative/NaN/Inf are
conservatively rejected as invalid) and D is the requester's effective
downstream multiplier (user-group override ?? group default, times the
group peak factor) frozen at the request's pricing instant.

- groups gain profit_control_enabled / profit_min_margin /
  profit_safety_buffer (migration 191); the durable auth-cache
  invalidation trigger additionally watches the profit and pricing
  columns (migration 192) so out-of-band group edits cannot leave
  stale auth snapshots; GetByKeyForAuth explicitly projects the new
  columns and the API-key auth snapshot version is bumped to force a
  refresh of pre-existing snapshots
- request-level pricing instant: token entry points install pricingAt
  into ctx; the profit threshold D and the RecordUsage peak factor
  read the same instant, so one request never changes price mid-flight
  across waits/retries/failover (media and unwired paths keep the
  existing record-time semantics)
- the gate covers token requests on openai, anthropic, gemini, grok
  and antigravity groups: OpenAI-family handlers via
  WithOpenAIRequestPricingContext (responses incl. WS bridge, chat
  completions, messages, embeddings, alpha search), the shared gateway
  via WithGatewayTokenRequestPricing (messages, chat completions,
  responses, gemini model actions); composite groups cannot enable it
  directly; image/video/models/usage/count_tokens stay ungated and an
  explicit image-generation intent suppresses the gate end to end
- post-slot recheck: after a slot is acquired the account is re-read
  via SchedulerSnapshotService.GetAccount (scheduler cache, then DB;
  only when both fail the check fails open with WARN + metric); a
  vetoed account releases its slot and joins the request's exclusion
  set for reselection; sticky bindings are written only after the
  final check passes, and an over-threshold sticky account is skipped,
  not deleted, so it comes back once its rate recovers
- sticky-session cache contract: GatewayCache.GetSessionAccountID now
  returns ErrStickySessionNotFound on a miss (mapped from redis.Nil in
  the repository implementation, mirroring ErrRefreshTokenNotFound) so
  the profit sticky path can distinguish "no binding yet" from a real
  read failure without importing the cache driver in service code
- cross-group re-entry (composite parent -> member group) resolves the
  gate against the member group and clears a stale parent gate instead
  of letting a foreign threshold veto accounts
- per-platform/group activity counters (installs, threshold vetoes,
  invalid-rate vetoes, refresh failures) for observability
- admin UI: profit-control section on the five platforms' group forms
  with percent input, validation and platform-switch reset; group
  create/update/duplicate normalize and validate the config at a
  single choke point
- cmd/profit-preview: offline what-if tool that replays the production
  admission semantics over an exported config/account/override/model
  dump, reports per-model admitted-account counts under the default
  and the worst-case (lowest user override) D, and surfaces probe-sync
  staleness as warnings without affecting admission

Tests: service unit coverage for gate resolution/veto/threshold
epsilon/pricing instant/suppress marker/scheduler filtering and
post-slot recheck (incl. -race on the profit surface), unit-tagged
handler slot-recheck and capability-mapping regressions, sqlmock and
real-PostgreSQL integration regressions for the GetByKeyForAuth
projection and the migration-192 trigger watch list, API contract
update, and frontend specs for the five-platform form helpers.
2026-08-01 22:39:31 +08:00
shaw 0b6b4ea956 fix(billing-probe): govern the automatic account rate write-back
- 自动写回值域治理与留痕:上游声明值必须 > 0 且 <= 100 才写回。0 会让
  accountCost 恒为 0(账号总额/日/周配额与成本告警全部静默失效),极大值可
  一次打爆配额并污染成本报表;越界时保持原倍率、记 WARN,探测快照照常记 ok。
  写回成功时记结构化 slog(account_id / 旧值 / 新值 / source),并在快照中
  新增 synced_rate_multiplier 记录本次写回值——后台任务裸 SQL 不产生
  audit_logs,这两处是唯一可追溯来源。管理员手工设 0 不受影响。
- 写回改用 resolved_rate_multiplier(不含高峰的基准倍率):effective 含探测
  那一刻的高峰系数,写回会把一个探测周期的峰值/谷值冻结进静态列,而展示与
  调度用 upstreamBillingRateAt 按当前时间重算高峰,两者会持续不一致。
- 倍率解析失败不再污染公共探测路径:账号级值域/精度只在该账号已开启同步、
  真要写回时才有影响;未开同步的账号照常记 ok 快照,不累计 failure_count、
  不进入指数退避。
- 单账号编辑补 service 层守卫:同步开启时拒绝手工倍率(新增
  UPSTREAM_BILLING_RATE_SYNC_CONFLICT,与批量路径同族),此前只有前端
  disabled 挡人,直接 PUT /admin/accounts/{id} 可写入并活到下次成功探测。
  判断的是本次请求生效后的状态,"关同步 + 改倍率"同请求仍然放行。
- 修正开关反推方向:不再由 rate_sync=true 推出 probe=true,否则一条"同步开、
  探测键缺失"的僵尸记录会在任意一次无关编辑时静默打开周期性外呼;改为探测
  关闭/缺失一律把同步归零。
- 删除死代码 UpdateWithUpstreamBillingProbeEnabled(PR 删接口后生产已无调用
  方),其回滚测试改为直接覆盖生产路径 UpdateWithAccountBillingSettings。
  upstreamBillingRateSyncEnabled 不再是只服务测试的假门控,现为写回前置过滤,
  SQL CAS 仍是权威门控,两侧均加注释说明分工。
- en/zh 文案补充:同步的是不含高峰的基准倍率;开启同步会连带打开自动探测。
2026-08-01 22:11:10 +08:00
Brisbanehuang b0f5007f04 feat(billing-probe): optionally sync account rate from upstream declared rate
Successful upstream billing probes already persist the upstream-declared
rate as a display-only snapshot. Add a per-account opt-in that writes
that declared rate back to the account's rate_multiplier, so the account
cost basis follows upstream repricing automatically instead of drifting
until an operator notices.

- new per-account flag upstream_billing_rate_sync_enabled stored next to
  the probe flag in account extra: enabling sync force-enables the
  probe, disabling the probe cascades sync off, and eligibility follows
  IsUpstreamBillingProbeIdentity (tightened from any non-empty platform
  to an explicit whitelist of the five supported API-key platforms so
  future platforms do not silently inherit probe/sync semantics)
- only a successful probe whose declared rate survives validation
  (finite, within bounds, not rounded to zero at the rate_multiplier
  decimal(10,4) scale) writes back; failed/unsupported/invalid probes
  leave rate_multiplier unchanged
- the writeback rides the existing snapshot CAS transaction:
  UpdateUpstreamBillingProbeSnapshot takes an optional rateMultiplier
  and applies it atomically with the snapshot under the same
  identity/snapshot compare-and-swap, so a probe result observed on a
  stale account cannot clobber a concurrent admin edit
- admin edit goes through UpdateWithAccountBillingSettings, which
  applies the form without overwriting a rate that a probe synchronized
  after the edit form was loaded (nil rateMultiplier = not edited);
  once sync is enabled the edit form shows the rate as managed
- bulk update rejects a manual rate_multiplier change when any target
  account has rate sync enabled (whole batch fails with a dedicated
  error so partial writes cannot bypass the sync ownership)
- frontend: sync toggle with hints in the edit modal (probe/sync
  enable/disable coupling enforced in the form), synced-rate tooltip on
  the rate cell, bulk edit modal warns and blocks rate edits that hit
  sync-enabled accounts; en/zh copy updated
- tests: service unit tests for sync gating/validation/cascade, sqlmock
  repo tests for the extended CAS, real-PostgreSQL integration tests
  (rate written only for successful+enabled accounts, manual rate
  protected after sync disabled, admin edit preserved across concurrent
  probe sync), handler/API contract updates, frontend specs for modal
  coupling, bulk rejection and rate cell
2026-08-01 22:11:09 +08:00
shaw 56f3d3c9b0 fix(billing-probe): 收口探测资格放宽后的抑制清单与调度信任面
#4896 把上游计费探测从 OpenAI API-key 放宽到全部 API-key 账号后,
遗留了四处需要收口的问题:

- M1 官方域抑制清单补 ollama.com。Ollama Cloud 是本仓一等支持配置
  (platform openai/anthropic + type apikey + base_url
  https://ollama.com/v1),放宽后 anthropic 侧这类账号会每个探测周期
  拿 Ollama Key 请求 ollama.com/v1/sub2api/billing 并恒定落空,正是该
  守卫注释声明要防的行为。后缀匹配已覆盖 www.ollama.com 等子域,
  notollama.com 等形似域不受影响。
- M2 legacy 低倍率优先排序补平台门控。newOpenAILegacyUpstreamRateOrder
  遍历全部候选且无平台门控,与 openAIUpstreamCostFactors 的门控不对称;
  放宽后 grok 账号的上游自报倍率开始影响 legacy 调度排序,而实际结算走
  本地倍率,中转方自报低价即可吸流量。现补上同一道门控,使调度侧信任面
  回到 PR 前状态(探测资格的放宽保持不变)。
- L1 修正 IsUpstreamBillingProbeIdentity 注释。原注释称类型限制的依据是
  "OAuth/Bedrock 没有静态 API key",但 AccountTypeUpstream(antigravity
  中转账号)同样是 base_url + 静态 api_key 却也被排除。仅改注释如实说明
  取舍,不改行为。
- L2 批量探测空选文案去掉 OpenAI 限定(en/zh 成对)。
- L3 unsupported 状态改用加长退避(interval 的 8 倍,仍按 24h 封顶)。
  放宽后大量官方域账号会落 unsupported 并按常规 interval 重排,占满每周期
  20 个名额,把真正接入 sub2api 的中转账号挤到后面。封顶保证上游后来接入时
  最迟一天内会被重新发现;Retry-After 更长时原样保留不被缩短;手动探测不受
  退避影响。

测试:ollama.com 官方域行为级与 host 匹配矩阵用例、legacy 排序平台门控
(含混合候选集)用例、unsupported 退避上下界与 runner 跳过/手动探测放行
用例;同步更新既有 unsupported 的 next_probe_at 断言。
2026-08-01 21:54:11 +08:00
Wesley Liddick b74024c786 Merge pull request #5167 from Wei-Shaw/fix/openai-ws-passthrough-close-frame-race
fix(openai-ws): keep downstream writes off the relay cancellation context
2026-08-01 20:47:42 +08:00
shaw 21aacde0b3 fix(openai-ws): keep downstream writes off the relay cancellation context
coder/websocket arms a context.AfterFunc that hard-closes the connection
when a write context is canceled, and AfterFunc stop does not wait for a
callback that already started. An external cancellation (e.g. ingress
lease loss) landing inside the disarm window of an already-successful
downstream write could therefore kill the TCP connection before the
retry close frame (1013) was written, leaving the client with a bare
EOF. Mirror the read side: bound downstream writes with the write
timeout only, and rely on the explicit Close/CloseNow performed by every
relay exit path for teardown.

Fixes the flaky TestPassthroughLifecycle_LeaseLossSendsRetryClose.
2026-08-01 20:32:10 +08:00
Brisbanehuang f3a3d86845 feat(billing-probe): extend upstream billing probe to all API-key platforms
/v1/sub2api/billing is a key-scoped sub2api convention: any API-key
account whose base_url points at a sub2api-compatible upstream answers
it regardless of the account platform. Widen probe eligibility from
platform=openai to every API-key account (OAuth/Bedrock stay excluded:
no static key to present).

- central predicate exported as IsUpstreamBillingProbeIdentity; runner,
  manual probe, SetAccountEnabled, admin create/update/bulk validation
  and CRS reconcile all follow it
- probe target resolution reads credentials.api_key/base_url directly.
  OpenAI keeps its official-default base URL and openai transport
  profile. Other platforms whose base_url is empty or points at an
  official provider API domain (anthropic.com, googleapis.com, x.ai,
  grok.com, openai.com - matched on the normalized hostname, port and
  trailing dot stripped, as the exact host or any subdomain) persist
  "unsupported" without sending a request: the create form fills empty
  base_url with official defaults and offers official regional presets
  (e.g. us-east-1.api.x.ai), and official APIs cannot answer
  /v1/sub2api/billing, so probing would only send the account key to a
  nonexistent official path
- due-scan SQL, BulkUpdate probe WHERE, UpdateCredentials stale-snapshot
  CASE and proxy-change invalidation drop their platform filters
  (type='apikey' retained)
- frontend: rate cell, edit/create/bulk modals gate on type==='apikey';
  the antigravity upstream create flow (its own helper and form section)
  shows the auto-probe toggle and passes upstream_billing_probe_enabled;
  bulk WS-mode section stays OpenAI-only; settings copy de-scoped
- tests: multiplatform service unit tests (relay success, official/empty
  base_url unsupported without request, normalized official-host matrix,
  OpenAI defaults preserved), real-PostgreSQL due-scan coverage for
  openai/anthropic/grok plus oauth/disabled exclusion, updated
  sqlmock/CRS/frontend specs; probe stays opt-in per account
2026-08-01 02:31:04 -04:00
Wesley Liddick b22f73e725 Merge pull request #5154 from Wei-Shaw/fix/issue-5148-stream-partial-usage-billing
fix(gateway): 流中断时保留已观测 usage 入账,修复 newapi 类上游大面积漏记(#5148)
2026-08-01 13:51:06 +08:00
shaw d6d53052f8 chore: update sponsors 2026-08-01 11:32:31 +08:00
shaw bd52e5d770 fix(gateway): record observed usage when anthropic stream is interrupted
Fixes #5148: with aggregator upstreams (e.g. newapi) that end SSE
streams without a proper terminal event, every such request was
silently missing from usage logs and billing.

Root cause (tracked via the nested audit issue): the low-level
Anthropic SSE readers already return the partially collected usage
together with the stream error (missing terminal event, read error,
interval timeout), but Forward converted every such result to
(nil, err) and the handler returned before submitting RecordUsage.

Changes:
- Add partialStreamUsageResult: on stream errors, wrap observed usage
  into a ForwardResult and return it alongside the error, for both the
  regular Anthropic path and the API-key passthrough path. Invariants:
  UpstreamFailoverError always keeps result=nil (failover retries are
  billed as the successful attempt, never twice), and zero observed
  usage returns no partial result (no phantom zero-usage records).
- Messages handler: hoist the usage submission block into a closure
  shared by the success path and the new partial-result error path.
- Usage record worker pool: distinguish pool-stopped drops
  (dropped_stopped) from operator-configured drop/sample overflow
  drops; billing tasks now fall back to inline synchronous execution
  only during the shutdown window, while explicit drop/sample overflow
  semantics are preserved. Image usage keeps its mandatory fallback
  for both drop kinds via the new mode.Dropped() helper.

Tests: Forward-level regressions for missing-terminal / read-error /
no-usage / failover-invariant on both paths, plus handler-level
stopped-pool sync fallback and drop-policy preservation tests.
2026-08-01 11:29:58 +08:00
Wesley Liddick d4cada3b6b Merge pull request #5089 from feeeei/fix/openai_sse_rate_limit
fix(openai): retry SSE rate limits as HTTP 429
2026-08-01 10:50:57 +08:00
Wesley Liddick 8f5caef78e Merge pull request #5153 from Wei-Shaw/fix/issue-5152-classifier-multi-system-entries
fix(anthropic): recognize classifier requests with extra system entries
2026-08-01 10:50:27 +08:00
shaw 2ef1246295 fix(anthropic): recognize classifier requests with extra system entries
Real claude-cli/2.1.220 auto-mode classifier requests carry two system
entries: the security-monitor prompt plus an appended session-context
block. The previous len(systemEntries) != 1 guard rejected them before
any content check ran, so claude_code_only groups kept refusing the
classifier (#5152, follow-up to #5041/#5048).

Scan every entry for the monitor prompt instead of requiring exactly
one. Discrimination is unchanged: the matching entry still needs the
10k-char minimum, the fixed prefix, and all eight markers.
2026-08-01 10:32:43 +08:00
shaw dd9a177a62 chore: update sponsors 2026-08-01 10:01:52 +08:00
feeeei 85a27fae39 fix(openai): retry SSE rate limits as HTTP 429
OpenAI Responses may return code=rate_limit_exceeded in a response.failed SSE event while the HTTP status remains 200. Classify these failures as 429 so configured pool-mode retries and account failover are applied. No account-level rate-limit state is written on this path: the 200-stream response headers carry normal quota snapshots, and retry semantics stay owned by the failover engine.

Ignore compact keepalive bytes when determining whether semantic output has started, preserving safe retries before real output.
2026-08-01 10:01:47 +08:00
Wesley Liddick eb1c5c7ee8 Merge pull request #5146 from tudoujunha/codex/fix-responses-tool-output-media
fix(apicompat): preserve images in Responses tool outputs
2026-08-01 08:54:54 +08:00
Wesley Liddick d9fba8fe78 Merge pull request #5101 from Tongzai123/feat/admin-select-all-filtered-results
feat(admin): 支持按筛选结果全选账号
2026-08-01 08:54:38 +08:00
Wesley Liddick 15b3c0c5aa Merge pull request #5145 from zvensmoluya/codex/update-auto-review-pricing
[codex] update Codex Auto-review pricing
2026-08-01 08:54:26 +08:00
Wesley Liddick 2e338af822 Merge pull request #5085 from feeeei/main
feat(model-plaza): Filter bar line alignment, model sorting, and table spacing
2026-08-01 08:54:11 +08:00
Wesley Liddick 682c4fe0e6 Merge pull request #5147 from Wei-Shaw/feat/moderation-proxy-and-smtp-starttls
feat(moderation): proxy support for content audit; fix(email): SMTP STARTTLS test/send parity
2026-07-31 23:24:44 +08:00
shaw 948b63c9ca feat(moderation): route content moderation through configurable proxy server
Implements #2646: the risk-control content audit can now send OpenAI
Moderations requests through a proxy from IP Management - Proxy Servers.

Backend:
- ContentModerationConfig gains proxy_id (nil = direct, unchanged default)
- update semantics: null keeps, 0 clears, >0 selects (validated to exist)
- moderation calls build the client via the shared httpclient pool; proxy
  resolution failure surfaces as a moderation error and never silently
  falls back to direct connection
- proxy_id -> URL resolution cached 60s (single-entry, invalidated on
  config save) so the pre-block hot path does not hit the DB per request
- test-key endpoint accepts proxy_id too (null = saved config's proxy,
  0 = force direct), so input-key/saved-key tests exercise the same path
- proxy usage/inactivity logged (content_moderation.proxy_enabled /
  proxy_not_active) without leaking credentials

Frontend:
- ProxySelector in the risk-control basic settings tab, proxy list loaded
  non-blockingly; save and test payloads carry proxy_id; zh/en i18n
2026-07-31 23:12:22 +08:00
shaw 4c80d160dd fix(email): unify SMTP connection path between send and test-connection
- UseTLS now tries implicit TLS first (port 465 semantics) and, when the
  server answers in plaintext (tls.RecordHeaderError, e.g. port 587
  submission), automatically retries with mandatory STARTTLS; encryption
  is never silently downgraded (fixes #1470, supersedes #1488)
- TestSMTPConnectionWithConfig now shares connectSMTP with the send path,
  adding the opportunistic STARTTLS upgrade the send path gained in
  b402c367d; this removes the 'test connection fails but test email
  sends' mismatch reported in #1488
- test-connection now also honors dial/IO timeouts and ignores
  non-standard QUIT responses, matching the send path
2026-07-31 23:12:02 +08:00
Wesley Liddick 570ea74d12 Merge pull request #5117 from gaoren002/feat/prompt-audit-blocking-latest-input
feat(security-audit): add optional narrow blocking audit scope
2026-07-31 22:32:00 +08:00
Wesley Liddick 2980ff3850 Merge pull request #5094 from wucm667/feat/issue-5065-compact-homepage
feat(home): add compact home page preset to avoid abuse classification
2026-07-31 21:51:33 +08:00
Wesley Liddick 04c96a2015 Merge pull request #4981 from INKCR0W/fix/openai-preserve-codex-namespaces
fix(openai): OAuth 原生 Responses 默认保留 Codex namespace,修复 code_mode_only 模型无法派发子代理
2026-07-31 21:49:44 +08:00
Wesley Liddick 07f980b99f Merge pull request #5084 from apple-ouyang/codex/fix-openai-compaction-encrypted-retry
fix(openai): recover stale encrypted compaction
2026-07-31 21:48:10 +08:00
Ouyang Xingyuan fe21725865 fix(openai): recover stale encrypted compaction
Reason:
- Responses retries can carry account-bound encrypted compaction items that OpenAI rejects with invalid_encrypted_content.

Changes:
- Drop encrypted compaction and compaction_summary items only during the existing recovery retry.
- Preserve unencrypted compaction items and cover HTTP and WebSocket recovery paths.
2026-07-31 21:21:13 +08:00
Zven 698547418f fix(pricing): keep Auto-review rates evidence-based 2026-07-31 21:20:32 +08:00
Zven f54e9827a0 fix(pricing): update Codex Auto-review rates 2026-07-31 21:16:06 +08:00
Wesley Liddick d29acc29a5 Merge pull request #5066 from wucm667/fix/issue-5051-subscription-quota-window
fix(subscription): align quota windows with subscription term
2026-07-31 20:40:18 +08:00
Wesley Liddick 66998918b6 Merge pull request #5143 from wucm667/fix/issue-5138-codex-instructions
fix(openai): default missing passthrough instructions
2026-07-31 20:39:57 +08:00
Wesley Liddick da6194c1c3 Merge pull request #5112 from chenty2333/fix/openai-stream-capacity-pool-retry
fix(openai): retry streamed capacity errors in pool mode
2026-07-31 20:38:15 +08:00
Wesley Liddick 132d446ca9 Merge pull request #5133 from dawnx/fix/payment-visible-method-wipe
fix(payment): 保存系统设置时不再清空可见支付方式配置
2026-07-31 20:37:58 +08:00
Wesley Liddick 0eac363e67 Merge pull request #5120 from Vibeone/fix/grok-pool-mode-cooldown-bypass
fix(grok): 公共池模式跳过所有默认冷却路径
2026-07-31 20:27:46 +08:00
Wesley Liddick 796313e993 Merge pull request #5131 from wucm667/fix/issue-5125-image-data-url-offload
fix(images): decode data URLs during task offload
2026-07-31 20:27:35 +08:00
Wesley Liddick c772d18666 Merge pull request #5130 from moonfunjohn/codex/fix-epay-method-selector-overflow
fix(payment): prevent EasyPay method selector overflow
2026-07-31 20:27:25 +08:00
tudoujun 2bf9c6d56b 修复工具输出图片桥接 2026-07-31 19:36:47 +08:00
Wesley Liddick 94df1fffc2 Merge pull request #5124 from wucm667/fix/issue-5105-filter-grok-billing-ping
fix(grok): filter billing ping response events
2026-07-31 19:20:16 +08:00
shaw 30967d5d9a fix(grok): ping 帧统一改写为 SSE 注释并限制过滤缓冲
Responses 事件类型对严格客户端是闭合枚举,任何 event: ping 帧都会令
grok CLI / Codex CLI 整轮失败。原实现只精确匹配 inference-cost 标记帧
和 cost=="0" 帧,上游尾帧携带非零 cost 或格式微调即复发 #5105;且对
其它 ping 变体的保守放行同样会炸掉严格解析器。现改为:event: ping 帧
(data 声明的 type 与事件名不冲突时)一律改写为 SSE 注释 ": ping",
所有解析器安全忽略且保留保活效果。

同时把整帧缓冲改为增量状态机:非 ping 帧首行即判定、逐行零拷贝直通,
不再累积;仅 ping 候选帧缓冲,并设 16 行 / 16KB 上限,超限回放原文
转直通,杜绝上游用永不结束的帧撑爆网关内存。
2026-07-31 18:57:43 +08:00
wucm667 dfdbc27709 fix(openai): default missing passthrough instructions 2026-07-31 18:55:15 +08:00
wucm667 beeb2f989b test(settings): include compact home in API contracts 2026-07-31 18:34:13 +08:00
wucm667 77d4df9544 test(grok): check filter body close error 2026-07-31 18:34:11 +08:00
github-actions[bot] 7ceabb3fd5 chore: sync VERSION to 0.1.169 [skip ci] 2026-07-31 09:19:08 +00:00
Wesley Liddick 26d894ef4f Merge pull request #5137 from Wei-Shaw/fix/upstream-url-path-segment-validation
fix(gateway): 收紧上游 URL 路径片段校验
2026-07-31 16:46:00 +08:00
shaw 017f6bbd5e fix(gateway): 收紧上游 URL 路径片段校验
网关有若干位置会把客户端可控的字符串拼进上游请求的 URL path(Responses
子路径、Gemini 模型名)。此前这些字符串未经校验直接参与拼接,可能改变上游
请求的路径结构,使实际发出的请求与客户端意图不一致。

- 新增 internal/service/upstream_path_guard.go:路径片段闭集允许清单
  (\w + `-` + `.`),拒绝空片段、纯点片段、超长片段与过深后缀
- /responses/*subpath 三条路由入口新增守卫,不可转发的子路径直接 404;
  service 层同时保证不产出不合规后缀,拼接函数再兜底一层
- Gemini AI Studio 原先 5 处重复的 URL 拼接收敛为唯一构造点
  buildGeminiAIStudioModelActionURL(校验模型片段 + action 白名单)
- Gemini native / GetModel handler 增加入口校验;ForwardAIStudioGET 逐片段校验
- Grok video 端点的 request_id 增加片段合规校验

合法子路径(/compact、/compact/detail、/{id}/cancel 形态)与既有模型名行为
不变,通配路由保留。
2026-07-31 16:11:45 +08:00
wucm667 d6467f6eb0 fix(images): decode data URLs during task offload 2026-07-31 15:04:56 +08:00
moonfunjohn 8ed9f754cf fix(payment): prevent method selector overflow 2026-07-31 14:54:01 +08:00
dawn 3deb2f17d8 fix(payment): 保存系统设置时不再清空可见支付方式配置
UpdatePaymentConfig 无条件写入全部设置键,未传的指针字段经
derefStr(nil) / formatBoolOrEmpty(nil) 转成空串后仍会落库。

setting_handler_update.go 构造 UpdatePaymentConfigRequest 时从不填充
VisibleMethod* 四个字段,而 UpdateSettingsWithAuthSourceDefaultsOmitting
先写入正确值、UpdatePaymentConfig 紧接着以空串覆盖,导致管理员每次保存
系统设置都会静默重置支付宝/微信的可见支付方式路由。

改为仅写入调用方显式提供的字段,符合 PATCH 语义。EnabledTypes 传
空切片仍可显式清空。
2026-07-31 14:21:36 +08:00
Wesley Liddick f9d2791693 Merge pull request #5032 from Ricardo-binZzz/fix/release-pricing-fallback-resource
fix(release): include pricing fallback resources
2026-07-31 14:00:07 +08:00
Ricardo-binZzz 105e5c5da3 fix(release): include pricing fallback resources 2026-07-31 13:51:22 +08:00
wucm667 baaae8e121 fix(grok): filter billing ping response events 2026-07-31 12:31:45 +08:00
Wesley Liddick bf0fc03ab7 Merge pull request #5018 from hongheshan-svg/fix/glm-5.2-fallback-pricing
fix(billing): 补上 glm-5.2 兜底价,避免被 glm-5 子串匹配抢走
2026-07-31 12:00:45 +08:00
Wesley Liddick 2be08f3f39 Merge pull request #4913 from jeshica/fix/anthropic-count-tokens-max-tokens
fix: strip max_tokens from Anthropic count tokens
2026-07-31 11:58:28 +08:00
hongheshan-svgandClaude Opus 5 493955f7bd fix(billing): add glm-5.2 fallback pricing to stop glm-5 substring match
glm-5.2 has no entry in the fallback table, so getFallbackPricing falls
through to `strings.Contains(modelLower, "glm-5")` and prices it at
GLM-5 rates ($1.00 in / $3.20 out per MTok) instead of the official
z.ai rates ($1.40 / $4.40) — roughly 27% under.

LiteLLM carries no bare `glm-5.2` key either (only provider-prefixed
`cloudflare/@cf/zai-org/glm-5.2` and `fireworks_ai/.../glm-5p2`, which
the lookup candidates never match), so the request always lands on the
fallback path and the discrepancy shows up directly in usage logs.

Add the glm-5.2 entry (same price as glm-5.1 per docs.z.ai) and match it
before the bare `glm-5` branch, with a note that dotted variants must
precede it. The existing regression test asserting the old glm-5 price
is updated accordingly.

Source: https://docs.z.ai/guides/overview/pricing

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-31 11:49:30 +08:00
zhiyu 53aa5cd247 test: align ModelMappingPreservesOtherFields with max_tokens strip
The count_tokens path now strips max_tokens; this sibling test also
exercises ForwardCountTokens but still asserted max_tokens preservation.
Flip its assertion to expect the field is filtered, matching
CountTokensFiltersGenerationFields.
2026-07-31 11:47:21 +08:00
Wesley Liddick 0a45be17d8 Merge pull request #5033 from Ricardo-binZzz/fix/sub2api-no-new-privileges
fix(deploy): prevent application privilege gains
2026-07-31 11:44:28 +08:00
Wesley Liddick 60f6dc91cf Merge pull request #5115 from zvensmoluya/codex/update-gpt56-luna-terra-pricing
[codex] update GPT-5.6 Luna and Terra pricing
2026-07-31 11:44:21 +08:00
Wesley Liddick 1702ee1362 Merge pull request #5078 from wucm667/fix/issue-5072-subscription-expiry-label
fix(frontend): correct subscription expiry labels
2026-07-31 11:44:07 +08:00
Wesley Liddick c4b461c68c Merge pull request #5063 from lucas-ward/codex/issue-4211
fix(payment): keep subscription plan titles readable
2026-07-31 11:44:00 +08:00
Wesley Liddick 3c387a164d Merge pull request #5060 from alexj11324/codex/passkey-deployment-guidance
fix: clarify passkey deployment guidance
2026-07-31 11:43:53 +08:00
Wesley Liddick e854132a57 Merge pull request #4953 from spongehah/brn-qwen3guard-auxiliary-fields
feat(security-audit): allow Qwen3Guard auxiliary fields
2026-07-31 11:43:47 +08:00
Wesley Liddick a8cd33eead Merge pull request #5048 from wucm667/fix/issue-5041-claude-auto-classifier
fix(anthropic): recognize auto mode classifier
2026-07-31 11:43:39 +08:00
Wesley Liddick 276d9cbd9b Merge pull request #4993 from 17Yuns/fix/smtp-message-format
fix(email): generate standards-compliant SMTP messages
2026-07-31 11:43:32 +08:00
Wesley Liddick 1f8b3a9c60 Merge pull request #5037 from heathermhuang/codex/fix-composite-available-models
fix(channels): show Composite models by platform
2026-07-31 11:43:25 +08:00
Wesley Liddick c9156c1e7f Merge pull request #5053 from wucm667/fix/issue-5015-skip-unschedulable-refresh
fix(account): skip unschedulable token refresh candidates
2026-07-31 11:43:18 +08:00
Wesley Liddick 91850d36e6 Merge pull request #5030 from Ricardo-binZzz/fix/ops-cleanup-success-log-level
fix(logging): record cleanup success at info level
2026-07-31 11:43:11 +08:00
eyre 5c9629ddb7 fix(grok): unify pool mode bypass for all default cooldown paths
- Move pool mode check before the status switch so 401/402/403/5xx
  all skip tempUnschedule consistently
- Skip rateLimitGrok in updateGrokUsageSnapshot for pool mode
- Expand test coverage to all affected status codes
2026-07-31 03:02:01 +00:00
eyre 4d13925c9e fix(grok): skip entitlement 403 cooldown for pool mode accounts 2026-07-31 03:02:01 +00:00
Wesley Liddick 6fa784fdd0 Merge pull request #5118 from Wei-Shaw/fix/openai-proxy-stream-circuit-fail-open
fix(openai): 代理断流熔断改为 fail-open 偏好,修复共用代理部署下的调度不可用
2026-07-31 10:42:24 +08:00
shaw da49ce3f29 fix(openai): fail open proxy stream circuit and collapse burst disconnects
The proxy stream circuit introduced in v0.1.164 (#4749) removes every
account behind a quarantined proxy from scheduling. When all schedulable
accounts share one proxy (a common deployment), two mid-stream
disconnects within a minute zeroed out capacity for 10 minutes and every
request failed with 502. One HTTP/2 connection loss also killed all
multiplexed streams at once, tripping the threshold from a single event.

- Quarantine now degrades to a preference: when the only reason no
  account is available is proxy quarantine, selection retries once with
  the quarantine bypassed, so capacity can never reach zero.
- Disconnects within 3s per proxy collapse into one failure event.
- Add gateway.openai_proxy_stream_circuit.disabled escape hatch.
- A completed stream still clears the quarantine immediately; TTL,
  thresholds and recording guards are unchanged.
2026-07-31 10:30:30 +08:00
Zven 313121f3f7 test(pricing): update requested Terra cost 2026-07-31 10:02:04 +08:00
Zven 488d3b09ec test(pricing): update Terra billing ratios 2026-07-31 09:55:16 +08:00
gaoren002 d74e669a23 feat(security-audit): add narrow blocking audit scope 2026-07-31 01:47:53 +00:00
Zven b2d895fb8d fix(pricing): update GPT-5.6 Luna and Terra rates 2026-07-31 09:42:46 +08:00
diannaaav 7d3bf86e55 fix(openai): retry streamed capacity errors in pool mode 2026-07-31 02:31:35 +08:00
Litong 2a871ec852 feat(admin): 支持按筛选结果全选账号 2026-07-30 18:07:27 +08:00
Litong a35ff9613e feat(admin): 为账号批量删除增加并发限制 2026-07-30 18:03:54 +08:00
wucm667 739c0ff9c5 feat(home): add compact home page preset to avoid abuse classification
- Add compact_home_enabled setting to provide a minimal landing page
- Preserves custom home_content priority over compact mode
- Renders only site identity, navigation, and login/dashboard link
- Avoids marketing copy that triggers anti-fraud systems
- Includes focused backend/frontend tests and i18n support

Fixes #5065
2026-07-30 16:28:19 +08:00
feeeei 3d99acb0ad 优化模型广场UI:筛选栏换行对齐、模型排序与表格留白
- 筛选栏各级标签移出 flex-wrap 容器,chip 单独成组,换行后左边缘与「全部」对齐
- 模型排序增加二级规则:同官方输出价时按名称降序(gpt-5.6 先于 gpt-5.5)
- 按图片/按次计费的模型沉到分组末尾,不再与 token 模型按官方价混排
- 表格顶到卡片边缘,左右留白改由首列/末列 padding 提供,hover 与分区底色横贯整行
2026-07-30 15:32:59 +08:00
wucm667 fb40211305 fix(frontend): correct subscription expiry labels 2026-07-30 12:47:05 +08:00
wucm667 7b6111f2f7 fix(subscription): align quota windows with subscription term 2026-07-30 02:37:14 +08:00
wucm667 352b21f4e2 fix(claude): relax marker to tolerate category element in classifier output
Real claude-cli/2.1.220 samples insert <category>...</category> between
</block> and <reason>, breaking the previous literal marker.
Also fix an unchecked type assertion in the test suite.
2026-07-29 23:25:02 +08:00
BayinForge 0ee9ea5765 fix(payment): keep subscription plan titles readable 2026-07-29 21:23:16 +08:00
Zhixuan Jiang 711056f5b7 fix: clarify passkey deployment guidance 2026-07-29 08:58:33 -04:00
wucm667 ac90355a87 fix(account): skip unschedulable token refresh candidates 2026-07-29 18:35:56 +08:00
wucm667 54b1f8f6b3 fix(anthropic): recognize auto mode classifier 2026-07-29 16:55:42 +08:00
Ricardo-binZzz 0010894f99 fix(deploy): prevent application privilege gains 2026-07-29 13:25:05 +08:00
Ricardo-binZzz 0353cdadfe fix(logging): record cleanup success at info level 2026-07-29 13:25:03 +08:00
Heatherm Huang 92dc61d401 fix(channels): show composite models by platform
Expand visible Composite groups into each configured concrete model platform while preserving ordinary group isolation and empty-state behavior.\n\nFixes #4985
2026-07-29 12:32:44 +08:00
github-actions[bot] 5a6143097d chore: sync VERSION to 0.1.168 [skip ci] 2026-07-29 03:51:01 +00:00
github-actions[bot] b9c7cb8e24 chore: sync VERSION to 0.1.167 [skip ci] 2026-07-29 03:36:59 +00:00
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
17Yuns edac19e87a fix(email): generate standards-compliant SMTP messages 2026-07-28 12:28: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
InkCrow 272735b0a7 fix(openai): preserve Codex namespace tools on OAuth Responses forwarding
Flattening Codex `type:"namespace"` tool declarations into `namespace__name`
renames the tool but cannot rewrite the contract the client already handed the
model: Codex tells it to call `to=functions.collaboration.spawn_agent`. Under
`tool_mode: code_mode_only` (gpt-5.6-*) collaboration tools are the model's only
direct channel, so the rename left it unable to address them — it fell back to
`functions.exec` and looped on meaningless shell commands until the session
collapsed, with no upstream error anywhere. Fixes #4978.

Preserving is what the upstream actually wants. OAuth egress is unconditionally
chatgpt.com/backend-api/codex/responses (base_url is read only for API-key
accounts), i.e. the party that defines the extension; codex-rs builds one
request for both WS and HTTP and falls back WS->HTTP mid-session, so these
declarations already reach that host over HTTP — as they do today via the
responses-lite carrier and the WS/HTTP bridge, neither of which ever flattened.

Namespace names cannot be allowlisted: `features.multi_agent_v2.tool_namespace`
is user-configurable (openai/codex#31864 recommends renaming it to `agents`) and
MCP/connector namespaces are generated at runtime (mcp__codex_apps__gmail).
So the default is inverted rather than extended with more names.

- Preserve namespace declarations by default for OpenAI OAuth; the new
  per-account `extra.openai_responses_flatten_namespaces` restores the previous
  behavior for deployments routing OAuth traffic to a relay that rejects them.
- Keep `input[].namespace` on tool-call items for OAuth non-compact requests.
  The upstream requires the round-trip ("Missing namespace for function_call
  '...'. Round-trip the model's function_call item with its namespace field
  included."), so preserving declarations while stripping the calls would 400 on
  the second turn. The item-type allowlist mirrors the existing reactive strip.
- Keep compact on its current behavior: that endpoint rejects the field outright
  ("Unknown parameter: 'input[894].namespace'", #4761) and there is no evidence
  either way about namespace declarations there, so this change does not widen
  its surface. API-key accounts are untouched — their upstream is a standard
  Responses API and the reactive retry only clears one index per round trip.
- Clear the flatten mapping at the start of each Forward attempt so an account
  reached through failover cannot restore responses with the previous account's
  mapping.

Admin UI gets an OAuth-only toggle (create/edit/bulk) plus zh/en copy noting the
compact exception.
2026-07-28 09:19:13 +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
spongehah fc495e087d feat(security-audit): allow Qwen3Guard auxiliary fields 2026-07-27 17:40:11 +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
jy.liu efa5a2240d fix: strip max_tokens from Anthropic count tokens 2026-07-26 21:20:37 +08: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
Wesley Liddick cd8bb98c44 Merge pull request #4774 from superman2003/fix/issues-4763-4765-4769-20260723
fix: optimize Codex identity imports and OpenAI account tests
2026-07-23 17:38:05 +08:00
shaw 1be6f30188 fix(ollama): 测试仓储到期查询深拷贝消除数据竞争
ollamaUsageTestRepo.ListDueOllamaCloudUsageAccounts 返回浅拷贝共享
Extra/Credentials map,RunDue 过滤循环的无锁读与组写协程在 r.mu 下的
map 删改构成数据竞争(-race 可复现;无 -race 时也可能触发 runtime
concurrent map read/write fatal 导致 CI 偶发崩溃)。两条返回路径改为
r.mu 下 mergeMap 深拷贝,并补全 ListOllamaCloudUsageGroupAccounts
只拷 Extra 不拷 Credentials 的半克隆,与基类 GetByID 惯例对齐。
生产仓储每次查询返回全新行,不受影响。
2026-07-23 17:14:10 +08:00
shaw 2faa0891e4 fix(ollama): 审计日志不落会话明文并收紧凭证清理守卫
- PUT /admin/accounts/:id/ollama-cloud-usage/session 加入审计整体不入库
  路由,并把裸键 session 纳入键级脱敏兜底,防止浏览器会话 Cookie 明文
  留存 audit_logs.request_body
- UpdateCredentials 的 Ollama 清理分支加顶层 credentials DISTINCT 守卫,
  凭证未变化的持久化不再误清 openai 探测快照或重写 NULL extra
2026-07-23 16:43:18 +08:00
Wesley Liddick 3f03c93bc6 Merge pull request #4776 from alfadb/feature/ollama-cloud-usage
feat(ollama): 支持 Cloud 官方用量自动刷新
2026-07-23 16:36:59 +08:00
alfadb 5ac4a9fac2 feat(ollama): 支持 Cloud 官方用量自动刷新 2026-07-23 15:50:44 +08:00
BayinForge e46d55bc57 fix(ci): make Caddy check portable across awk implementations 2026-07-23 14:46:09 +08:00
Wesley Liddick 2c76506e07 Merge pull request #4734 from wjx2951874/feat/alipay-mobile-precreate-deep-link
feat(payment): add mobile Alipay precreate deep link
2026-07-23 14:06:18 +08:00
superman2003 dd5956be5e fix(openai): prefer concrete GPT-5.6 test model 2026-07-23 13:44:15 +08:00
superman2003 5dfe838c21 fix(admin): optimize Codex identity import index 2026-07-23 13:43:57 +08:00
Wesley Liddick 6aeea70ee0 Merge pull request #4749 from heathermhuang/codex/fix-openai-proxy-stream-quarantine
fix(openai): quarantine proxies after incomplete SSE streams
2026-07-23 11:20:01 +08:00
Wesley Liddick 6f7bad3f2f Merge pull request #4751 from heathermhuang/codex/fix-grok-402-account-cooldown
fix(grok): cool down accounts after upstream 402
2026-07-23 11:19:30 +08:00
Wesley Liddick fbc88edf90 Merge pull request #4750 from heathermhuang/codex/fix-simple-default-grok-image
fix(simple-mode): enable images for auto-created Grok default
2026-07-23 11:19:20 +08:00
Wesley Liddick da5c80e113 Merge pull request #4724 from fengshao1227/fix/passthrough-input-normalize
fix(openai): OAuth 透传路径补齐 input 规范化,修复 Input must be a list
2026-07-23 11:19:12 +08:00
Wesley Liddick 09b1309c91 Merge pull request #4755 from wucm667/fix/issue-4754-channel-pricing-model-normalization
fix(billing): normalize channel pricing model names
2026-07-23 11:18:10 +08:00
Wesley Liddick aee9ab36cb Merge pull request #4721 from superman2003/fix/ccswitch-grokbuild-4720
fix(frontend): import Grok keys into Grok Build
2026-07-23 11:18:02 +08:00
Wesley Liddick 31e7ae8195 Merge pull request #4726 from feitianbubu/fix/model-rate-limit-reset-format
fix(admin): 模型限流恢复时间进位到天并在提示中补全日期
2026-07-23 11:17:54 +08:00
shaw ba88cc239c fix(billing): bill composite alias requests by the concrete forwarded model
Composite public aliases (e.g. all/claude) reach the Anthropic/Gemini
billing core via OriginalModel/ChannelMappedModel source overrides.
Unknown aliases resolved to no pricing and silently recorded $0 cost,
while family-word aliases were mispriced by the fallback family match
(Opus traffic billed at the Sonnet fallback rate). The OpenAI path
already guards this via usageBillingModelCandidates; the shared
recordUsageCore had neither the guard nor a fallback.

- composite groups: unless the admin explicitly configured channel
  pricing for the alias (OpenRouter-style custom pricing), bill by the
  concrete forwarded model
- general safety net: when the selected billing model has no resolvable
  pricing at all, fall back to the concrete forwarded model instead of
  silently recording $0
- grok media usage records now attribute OriginalModel to the client
  requested public alias, consistent with every other endpoint
  (billing unaffected: empty BillingModelSource never triggers source
  overrides)

Priced traffic and non-composite groups are unaffected.
2026-07-23 10:26:58 +08:00
shaw 90c4f50a5e fix(admin): restore currency and timestamps in admin plan list response
The composite-groups PR (#3581) replaced the raw ent SubscriptionPlan
response of GET /admin/payment/plans with a projection struct but
dropped the currency field added by #4323. PlanEditDialog then read an
undefined currency, sent an empty string on save, and silently wiped
the stored plan currency. Restore currency plus created_at/updated_at
so the projection preserves the full original response shape.
2026-07-23 10:26:46 +08:00
Wesley Liddick 3e5d4af411 Merge pull request #3581 from heathermhuang/codex/composite-groups
feat: Add composite group route registry
2026-07-23 10:09:39 +08:00
Heatherm Huang ca6b192728 fix: preserve composite video content routing 2026-07-23 09:26:43 +08:00
Heatherm Huang cb81e17fea test: align composite route contracts after rebase 2026-07-23 09:20:52 +08:00
Heatherm Huang 1c7959d0de fix: allow composite grok chat completions 2026-07-23 09:20:52 +08:00
Heatherm Huang 1d2dfab86d fix: allow composite grok messages routing 2026-07-23 09:20:52 +08:00
Heatherm Huang 06e7d12640 Fix composite Grok video status routing 2026-07-23 09:20:52 +08:00
Heatherm Huang ee332cee64 Fix composite model defaults for linked platforms 2026-07-23 09:20:52 +08:00
Heatherm Huang 2e774a48b3 Align Grok composite example with live model 2026-07-23 09:20:52 +08:00
Heatherm Huang ce3272c41b Build composite subscription bucket two 2026-07-23 09:20:52 +08:00
Heatherm Huang 3a683fff55 Fix composite route alias attribution 2026-07-23 09:20:52 +08:00
Heatherm Huang ff666be530 Fix composite route lint issues 2026-07-23 09:20:18 +08:00
Heatherm Huang a008b63c16 Add composite group route registry 2026-07-23 09:20:18 +08:00
Heatherm Huang c8d1e2e16f Harden composite group product surfaces 2026-07-23 09:19:25 +08:00
Heatherm Huang ebc1028771 Add composite group routing 2026-07-23 09:19:24 +08:00
shaw fa2da0409e chore: update sponsors 2026-07-23 09:04:08 +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
wucm667 44093579e9 fix(billing): normalize channel pricing model names 2026-07-22 20:25:27 +00:00
Heatherm Huang 47ad29db3e fix(openai): quarantine proxies after stream disconnects 2026-07-23 00:12:28 +08:00
Heatherm Huang 33d694b89b fix(simple-mode): enable images for auto Grok default 2026-07-23 00:08:23 +08:00
Heatherm Huang ca0d3314cf fix(grok): cool down accounts after 402 2026-07-23 00:06:04 +08:00
shaw 63cef60594 chore: update sponsors 2026-07-22 22:21:43 +08:00
wjx2951874 7914433011 feat(payment): add mobile Alipay precreate deep link 2026-07-22 19:18:04 +08:00
github-actions[bot] 60013c5f10 chore: sync VERSION to 0.1.163 [skip ci] 2026-07-22 09:08:53 +00:00
feitianbubu 48d58d72ff fix(admin): 模型限流恢复时间进位到天并在提示中补全日期 2026-07-22 16:57:51 +08:00
li 3e26dfa5bb style: 统一 input 包装类型为 []any,与正常 OAuth 路径一致 2026-07-22 16:49:31 +08:00
li 851436c552 fix(openai): OAuth 透传路径补齐 input 规范化,修复 Input must be a list
openai_passthrough=true 的 OAuth 账号透传路径
(normalizeOpenAIPassthroughOAuthBody) 未对 input 字段做
string→array 转换,导致上游拒绝 "Input must be a list"。

正常 OAuth 路径 (openai_codex_transform.go:290) 已有此逻辑,
透传路径遗漏。补齐三种非数组 input 的规范化:
- string → [{type:"message",role:"user",content:<text>}]
- 空白 string → []
- 单 object → [<object>]
2026-07-22 16:46:29 +08:00
superman2003 a3a1575e9d fix(frontend): import Grok keys into Grok Build 2026-07-22 16:12:47 +08:00
666 changed files with 56269 additions and 2612 deletions
+3
View File
@@ -16,6 +16,9 @@ jobs:
run: |
/bin/bash -n deploy/apple-container.sh
/bin/bash deploy/tests/apple-container-test.sh
/bin/sh deploy/tests/docker-compose-security-test.sh
/bin/sh deploy/tests/docker-runtime-resources-test.sh
/bin/sh deploy/test-caddyfile-cache.sh
test:
runs-on: ubuntu-latest
+1
View File
@@ -49,6 +49,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.version={{ .Version }}"
+4
View File
@@ -65,6 +65,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.version={{ .Version }}"
@@ -80,6 +81,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.version={{ .Version }}"
@@ -95,6 +97,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/amd64"
- "--label=org.opencontainers.image.version={{ .Version }}"
@@ -110,6 +113,7 @@ dockers:
use: buildx
extra_files:
- deploy/docker-entrypoint.sh
- backend/resources
build_flag_templates:
- "--platform=linux/arm64"
- "--label=org.opencontainers.image.version={{ .Version }}"
+2 -1
View File
@@ -42,8 +42,9 @@ RUN addgroup -g 1000 sub2api && \
WORKDIR /app
# Copy pre-built binary from GoReleaser
# Copy the pre-built binary and runtime fallback resources from GoReleaser
COPY sub2api /app/sub2api
COPY --chown=sub2api:sub2api backend/resources /app/resources
# Create data directory
RUN mkdir -p /app/data && chown -R sub2api:sub2api /app
+30 -43
View File
@@ -59,8 +59,8 @@ Please read the following carefully before using this project:
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>Thanks to AICodeMirror for sponsoring this project! AICodeMirror provides official high-stability relay services for Claude Code / Codex / Gemini CLI, with enterprise-grade concurrency, fast invoicing, and 24/7 dedicated technical support. Claude Code / Codex / Gemini official channels at 38% / 2% / 9% of original price, with extra discounts on top-ups! AICodeMirror offers special benefits for sub2api users: register via <a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">this link</a> to enjoy 20% off your first top-up, and enterprise customers can get up to 25% off!</td>
</tr>
<tr>
@@ -84,12 +84,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>Thanks to Unity2 for sponsoring this project! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> is a high-performance AI model API relay for individuals, teams, and enterprises, handling 30B+ tokens/day with 5000 RPM concurrency. One API Key works across Claude Code, Codex, OpenAI models, IDE plugins, and Agent workflows, with balance billing, bundled subscriptions, enterprise invoicing, and 1-on-1 support. <a href="https://unity2.ai/register?source=sub2api">Register</a> to claim $2 in balance, plus $10 more by joining the official group — up to $12 in free credit.
</td>
</tr>
<tr>
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
<td>Thanks to Veilx for sponsoring this project! <a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN is purpose-built for large-scale AI API traffic, deeply optimized for relay services and call chains across OpenAI, Claude, Gemini, and scenarios like chat, image generation, embeddings, and streaming — delivering lower latency and higher stability under heavy concurrency. It also offers China three-network optimized return lines, making it ideal for global AI relay platforms, overseas AI SaaS, and cross-border high-concurrency deployments.
@@ -102,45 +96,12 @@ 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>
⚡ API Relay — Pay-as-you-go access to global models sourced 100% from official providers, up to 75% off official pricing<br>
&nbsp;&nbsp;&nbsp;&nbsp;Exclusive: Zhipu GLM 50% off · DeepSeek V4-pro 50% off · Seedance 2.0 8% off (whitelisted) · HappyHorse Overseas 30% off (whitelisted)<br>
🔑 GPT Subscription Accounts (same-origin IP included) · Global Residential IP <br>
💰 Invoices supported
</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>
@@ -172,8 +133,8 @@ Model authenticity: no content intervention or secondary filtering — experienc
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Thanks to Claude API for sponsoring this project! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> is an official-channel API provider focused on Claude models. Built on official Anthropic keys and the official AWS Bedrock channel, it delivers a stable integration experience for Claude Code and Agent applications, supports the full Claude model lineup, and retains official capabilities such as Tool Use and long context. The service involves no reverse engineering and no model degradation, making it a great fit for heavy Claude Code users, Agent engineers, and enterprise engineering teams. Register via the <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">exclusive link</a> and contact customer support to receive free trial credits; invoicing and team onboarding are also supported.
</td>
</tr>
@@ -183,6 +144,31 @@ Model authenticity: no content intervention or secondary filtering — experienc
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>Thanks to <a href="https://www.novada.com/?sub2api/">Novada</a> for sponsoring this project! Novada provides residential, ISP, datacenter, and mobile proxies, along with Web Unlocker and Scraper APIs for developers building AI applications and automation workflows. With global IP coverage, flexible rotating and sticky sessions, and precise geo-targeting, Novada helps teams access web data reliably for AI agent workflows, cross-region testing, web research, and browser automation. Explore Novada to build more stable and scalable AI workflows.</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
<td>Thanks to Qiniu AI for sponsoring this project! Qiniu AI is the enterprise-grade large-model MaaS platform under Qiniu Cloud (02567.HK), offering one-stop access to 150+ mainstream models worldwide, compatible with the protocols of major global model providers, and covering full-modality capabilities including text, image, audio, video, and file processing, serving over 1.69 million enterprises and developers. Qiniu AI offers an exclusive benefit for Sub2API users: register via <a href="https://s.qiniu.com/u6rQrq">this link</a> — enterprise users get 12 million tokens free, and developers get 3 million tokens free.</td>
</tr>
<tr>
<td width="180"><a href="https://api.fenno.ai/s/dC4k"><img src="assets/partners/logos/fennoai.jpg" alt="FennoAI" width="150"></a></td>
<td>Thanks to FennoAI for sponsoring this project! FennoAI is a high-stability, high-performance API relay provider for enterprise R&D teams and developers, compatible with the OpenAI and Anthropic protocols and seamlessly integrating with mainstream AI coding tools such as Codex, Claude Code, and OpenCode. The platform delivers enterprise-grade stability, supporting call volumes of 100 billion tokens per day, and supports business-to-business settlement and invoicing for both domestic and overseas entities to meet enterprise R&D and procurement needs. As an exclusive benefit for Sub2API users, purchase a subscription via the <a href="https://api.fenno.ai/s/dC4k">exclusive link</a> to get $50 worth of Coding Plan credit for only $1.99. Referral rewards are also available: invite friends to purchase and earn up to 20% commission — the more you invite, the more you earn.</td>
</tr>
<tr>
<td width="180"><a href="https://lanox.ai/?c=6"><img src="assets/partners/logos/lanox.jpg" alt="LanoX AI" width="150"></a></td>
<td>Thank you to LanoX AI for sponsoring this project! <a href="https://lanox.ai/?c=6">LanoX AI</a> provides stable, cost-effective global model access services for developers, teams, and enterprises. 🎁 New User Benefits — Claim millions of free tokens, plus 500+ free models for easier low-cost testing, validation, and deployment 🧠 Global Leading Models — GPT · Claude · Gemini · Qwen · Grok... 🎬 Multimodal Creation — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ Enterprise-Grade Reliability — High availability 💎 native capability output 💎 no intelligence degradation 💎 no model mixing 💎 transparent usage and billing 💎 💰 Lower API Costs — Top-tier models from as low as 10% of official pricing, with clear documentation, simple integration, invoicing support, and enterprise-scale batch usage 🏢 Enterprise Choice — Ideal for AI products, Agents, content platforms, and R&D teams with high-volume model usage</td>
</tr>
</table>
## Overview
@@ -199,6 +185,7 @@ Sub2API is an AI API gateway platform designed to distribute and manage API quot
- **Rate Limiting** - Configurable request and token rate limits
- **Built-in Payment System** - Supports EasyPay, Alipay, WeChat Pay, and Stripe for user self-service top-up, no separate payment service needed ([Configuration Guide](docs/PAYMENT.md))
- **Admin Dashboard** - Web interface for monitoring and management
- **Composite Groups** - Admin routing layer that resolves requested models to concrete providers for multi-provider groups ([Operator Guide](docs/COMPOSITE_GROUPS.md))
- **External System Integration** - Embed external systems (e.g. ticketing) via iframe to extend the admin dashboard
## Ecosystem
+29 -43
View File
@@ -60,8 +60,8 @@
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>感谢 AICodeMirror 赞助了本项目!AICodeMirror 提供 Claude Code / Codex / Gemini CLI 官方高稳定性中转服务,企业级并发、快速开票、7×24 小时专属技术支持。Claude Code / Codex / Gemini 官方通道低至原价 38% / 2% / 9%,充值更享额外折扣!AICodeMirror 为 sub2api 用户提供专属福利:通过<a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">此链接</a>注册,首次充值立享 8 折优惠,企业客户最高可享 75 折!</td>
</tr>
<tr>
@@ -87,12 +87,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>感谢 Unity2 赞助本项目! <a href="https://unity2.ai/register?source=sub2api">Unity2</a> 是面向个人开发者、团队、企业的高性能 AI 模型 API 中转平台,长期服务国内头部企业,日均承载超 300 亿 token 调用,支持 5000 RPM 级高并发。一个 API Key 即可适配 Claude Code、Codex、OpenAI 模型、IDE 插件和 Agent 工作流等场景。具备企业级稳定供应能力,在高并发、持续调用和团队集中采购场景下依然保持低延迟、高可用。同时支持余额计费、组合订阅、首充优惠、企业开票、专属 1v1 对接,适合个人高频使用和企业长期接入。现在注册 Unity2.ai 可领取 $2 余额,加入官方群再送 $10 余额,合计最高可领 $12 免费额度,适合先体验后长期使用。<a href="https://unity2.ai/register?source=sub2api">注册链接</a>
</td>
</tr>
<tr>
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
<td>感谢 Veilx 赞助本项目! <a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN 专为超大规模 API 请求场景打造,针对 AI 中转站业务与 AI API 调用链路进行了深度优化,轻松应对高并发、高频请求与大流量传输,为开发者与企业提供更快、更稳、更低延迟的加速体验。无论是 OpenAI、Claude、Gemini 等 AI 接口中转,还是聊天、绘图、Embedding、流式输出等复杂场景,Veilx 都能显著提升响应速度与连接稳定性,有效降低网络波动带来的超时与失败问题。同时,Veilx 提供中国三网优化回国极速线路,大幅提升中国大陆地区访问海外 AI 服务的速度与稳定性,特别适合全球 AI 中转平台、海外 AI SaaS、跨境业务与高并发 API 系统部署。专为 AI API 而生,让你的 AI 中转服务更快、更稳、更省心。<a href="https://veilx.io/#/hello/SJRBRVDV">购买地址</a>
@@ -105,45 +99,12 @@
</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>
⚡ API 中转 — 全球模型按量计费接入,100% 官方源直供,最高 75 折优惠<br>
&nbsp;&nbsp;&nbsp;&nbsp;独家特惠:智谱 GLM 5 折 · DeepSeek V4-pro 5 折 · Seedance 2.0 0.8 折(白名单)· HappyHorse 海外版 3 折(白名单)<br>
🔑 GPT 订阅账号(含同源 IP)· 全球住宅 IP<br>
💰 支持开票
</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>
@@ -175,8 +136,8 @@
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>感谢 Claude API 对本项目的赞助! <a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> 是专注 Claude 模型的官方渠道 API 服务商,基于 Anthropic 官方 Key 与 AWS Bedrock 官方渠道,提供稳定的 Claude Code 与 Agent 应用接入体验,支持 Claude 全系列模型,保留 Tool Use、长上下文等官方能力。服务非逆向、非降智,适合 Claude Code 深度用户、Agent 工程师与企业技术团队使用。通过<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">[专属链接]</a>注册后联系客服,可领取免费测试额度,并支持开票和团队对接。
</td>
</tr>
@@ -186,6 +147,31 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>感谢 <a href="https://www.novada.com/?sub2api/">Novada</a> 赞助本项目!Novada 为构建 AI 应用与自动化工作流的开发者提供住宅代理、ISP 代理、数据中心代理与移动代理,以及 Web Unlocker 和 Scraper API。凭借全球 IP 覆盖、灵活的轮换与粘性会话以及精准的地理定位,Novada 帮助团队在 AI Agent 工作流、跨区域测试、网络调研与浏览器自动化等场景中稳定获取网络数据。立即体验 Novada,构建更稳定、更可扩展的 AI 工作流。</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="七牛云AI" width="150"></a></td>
<td>感谢 七牛云AI 赞助本项目!七牛云AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过169万企业及开发者用户。七牛云 AI 为 Sub2API 的用户提供了专属福利:通过<a href="https://s.qiniu.com/u6rQrq">此链接</a>注册,企业用户免费领1200万Token,开发者免费领300万Token。</td>
</tr>
<tr>
<td width="180"><a href="https://api.fenno.ai/s/dC4k"><img src="assets/partners/logos/fennoai.jpg" alt="FennoAI" width="150"></a></td>
<td>感谢 FennoAI 赞助本项目!FennoAI 是一家面向企业研发团队和开发者的高稳定、高性能 API 中转服务商,兼容 OpenAI 与 Anthropic 协议,可无缝接入 Codex、Claude Code、OpenCode 等主流 AI 编程工具。平台具备企业级稳定性,可支撑千亿 Token/日的调用规模,并支持境内外主体公对公结算及开票,满足企业级研发与采购需求。作为 Sub2API 用户专属福利,通过<a href="https://api.fenno.ai/s/dC4k">专属链接</a>购买订阅,仅需 1.99 美元即可获得价值 50 美元的 Coding Plan 额度。同时支持邀请奖励,邀请好友购买最高可获得 20% 返佣,邀请越多,奖励越高。</td>
</tr>
<tr>
<td width="180"><a href="https://lanox.ai/?c=6"><img src="assets/partners/logos/lanox.jpg" alt="LanoX AI" width="150"></a></td>
<td>感谢 LanoX AI 对本项目的赞助!<a href="https://lanox.ai/?c=6">LanoX AI</a> 为开发者、团队与企业提供稳定、高性价比的全球模型接入服务。 🎁 新用户福利 — 免费领取 百万 Token ,更有500+ 免费模型 — 低成本测试、验证、部署更轻松 🧠 全球主流模型 — GPT · Claude · Gemini · Qwen · Grok... 🎬 多模态创作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ 企业级稳定服务 — 高可用💎原生能力输出💎不降智💎不混模💎调用与计费透明💎 💰 更低调用成本 — 顶级模型低至官方价 1 折起,文档清晰、接入简单、支持开票与企业批量调用 🏢 企业优选 — 适用于 AI 产品、Agent、内容平台、研发团队批量调用</td>
</tr>
</table>
## 项目概述
+29 -39
View File
@@ -59,8 +59,8 @@
</tr>
<tr>
<td width="180"><a href="https://www.aicodemirror.com/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.com/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
<td width="180"><a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM"><img src="assets/partners/logos/AICodeMirror.jpg" alt="AICodeMirror" width="150"></a></td>
<td>AICodeMirror のご支援に感謝します!AICodeMirror は Claude Code / Codex / Gemini CLI の公式高安定性リレーサービスを提供しており、エンタープライズグレードの同時実行、迅速な請求書発行、24時間年中無休の専属テクニカルサポートを備えています。Claude Code / Codex / Gemini の公式チャネルを定価の 38% / 2% / 9% で利用可能、チャージ時にはさらに追加割引!AICodeMirror は sub2api ユーザー向けに特別特典を提供中:<a href="https://www.aicodemirror.ai/register?invitecode=KMVZQM">こちらのリンク</a>から登録すると、初回チャージが 20% オフ、法人のお客様は最大 25% オフ!</td>
</tr>
<tr>
@@ -86,12 +86,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://unity2.ai/register?source=sub2api"><img src="assets/partners/logos/unity2.png" alt="unity2" width="150"></a></td>
<td>Unity2 のご支援に感謝します!<a href="https://unity2.ai/register?source=sub2api">Unity2</a> は個人開発者、チーム、企業向けの高性能 AI モデル API 中継プラットフォームです。中国の大手企業に長期にわたりサービスを提供しており、1日あたり 300 億以上のトークン呼び出しを処理し、5000 RPM 級の高並列性をサポートします。1つの API キーで Claude Code、Codex、OpenAI モデル、IDE プラグイン、Agent ワークフローなど様々なシナリオに対応できます。エンタープライズグレードの安定供給能力を備え、高並列・継続的な呼び出し・チームの集中購入シーンでも低レイテンシと高可用性を維持します。残高課金、組み合わせサブスクリプション、初回チャージ特典、企業向け請求書発行、専属 1v1 サポートにも対応しており、個人の頻繁な利用にも企業の長期導入にも適しています。今 Unity2.ai に登録すると $2 の残高、公式グループに参加するとさらに $10 の残高がもらえ、合計最大 $12 の無料クレジットを獲得できます — 試用後に長期利用したい方に最適です。<a href="https://unity2.ai/register?source=sub2api">登録リンク</a>
</td>
</tr>
<tr>
<td width="180"><a href="https://veilx.io/#/hello/SJRBRVDV"><img src="assets/partners/logos/veilx.png" alt="veilx" width="150"></a></td>
<td>Veilx のご支援に感謝します!<a href="https://veilx.io/#/hello/SJRBRVDV">Veilx</a> CDN は超大規模 API リクエストシナリオ向けに設計されており、AI 中継サービスと AI API 呼び出しチェーンに対して深く最適化されています。高並列・高頻度リクエスト・大容量トラフィックに容易に対応し、開発者と企業により高速で安定した、低レイテンシの加速体験を提供します。OpenAI、Claude、Gemini などの AI インターフェース中継はもちろん、チャット、画像生成、Embedding、ストリーミング出力などの複雑なシナリオでも、Veilx は応答速度と接続安定性を大幅に向上させ、ネットワーク変動によるタイムアウトや失敗を効果的に削減します。さらに、Veilx は中国三大ネットワーク最適化の高速回線を提供しており、中国本土から海外 AI サービスへのアクセス速度と安定性を大幅に向上させます。グローバル AI 中継プラットフォーム、海外 AI SaaS、越境ビジネス、高並列 API システム展開に特に適しています。AI API のために生まれ、あなたの AI 中継サービスをより速く、より安定して、より安心に。<a href="https://veilx.io/#/hello/SJRBRVDV">購入リンク</a>
@@ -104,41 +98,12 @@
</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>
@@ -170,8 +135,8 @@
</tr>
<tr>
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z?utm_source=zcf&utm_medium=partner&utm_campaign=zcf_2026&utm_content=default">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
<td width="180"><a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z"><img src="assets/partners/logos/claudeapi.jpg" alt="claudeapi" width="150"></a></td>
<td>Claude API のご支援に感謝します!<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">Claude API</a> は Claude モデルに特化した公式チャネルの API サービスプロバイダーで、Anthropic 公式キーと AWS Bedrock 公式チャネルをベースに、Claude Code や Agent アプリケーションへの安定した接続体験を提供します。Claude 全シリーズのモデルに対応し、Tool Use や長文コンテキストなどの公式機能もそのまま利用可能。リバースエンジニアリングやモデル劣化のないサービスで、Claude Code のヘビーユーザー、Agent エンジニア、企業の技術チームに最適です。<a href="https://console.claudeapi.com/agent/register/drTKjyn6wGLK061Z">専用リンク</a>から登録後カスタマーサポートへご連絡いただくと、無料お試しクレジットを受け取れます。請求書発行やチーム導入にも対応しています。
</td>
</tr>
@@ -181,6 +146,31 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://nagora.ai/"><img src="assets/partners/logos/nagora.png" alt="Nagora" width="150"></a></td>
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>Novada のご支援に感謝します!<a href="https://www.novada.com/?sub2api/">Novada</a> は、AI アプリケーションや自動化ワークフローを構築する開発者向けに、レジデンシャル、ISP、データセンター、モバイルプロキシに加え、Web Unlocker と Scraper API を提供しています。グローバルな IP カバレッジ、柔軟なローテーション/スティッキーセッション、精密なジオターゲティングにより、AI エージェントワークフロー、クロスリージョンテスト、Web リサーチ、ブラウザ自動化などのシーンで、チームが Web データへ確実にアクセスできるよう支援します。Novada で、より安定しスケーラブルな AI ワークフローを構築しましょう。</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
<td>七牛云AI のご支援に感謝します!七牛云AI は、七牛云(02567.HK)傘下のエンタープライズ向け大規模モデル MaaS プラットフォームです。世界の主要モデル 150+ をワンストップで利用でき、グローバル主要モデルプロバイダーのプロトコルに対応し、テキスト、画像、音声、動画、ファイル処理などのフルモーダル処理能力をカバー。169万を超える企業・開発者ユーザーにサービスを提供しています。Sub2API ユーザー向けの限定特典として、<a href="https://s.qiniu.com/u6rQrq">こちらのリンク</a>から登録すると、企業ユーザーは 1200万 Token、開発者は 300万 Token を無料で獲得できます。</td>
</tr>
<tr>
<td width="180"><a href="https://api.fenno.ai/s/dC4k"><img src="assets/partners/logos/fennoai.jpg" alt="FennoAI" width="150"></a></td>
<td>FennoAI のご支援に感謝します!FennoAI は、企業の研究開発チームと開発者向けの高安定・高性能 API 中継サービスプロバイダーです。OpenAI と Anthropic のプロトコルに対応し、Codex、Claude Code、OpenCode などの主要 AI コーディングツールにシームレスに接続できます。エンタープライズ級の安定性を備え、1 日あたり千億規模の Token 呼び出しに対応。国内外法人間の企業間決済と請求書発行もサポートし、企業の研究開発・調達ニーズに応えます。Sub2API ユーザー限定特典として、<a href="https://api.fenno.ai/s/dC4k">専用リンク</a>からサブスクリプションを購入すると、わずか 1.99 ドルで 50 ドル相当の Coding Plan クレジットを獲得できます。さらに招待報酬にも対応しており、友達の購入で最大 20% の還元を獲得可能。招待が多いほど、報酬も増えます。</td>
</tr>
<tr>
<td width="180"><a href="https://lanox.ai/?c=6"><img src="assets/partners/logos/lanox.jpg" alt="LanoX AI" width="150"></a></td>
<td>本プロジェクトをご支援いただいている <a href="https://lanox.ai/?c=6">LanoX AI</a> に感謝します!LanoX AI は、開発者、チーム、企業向けに、安定性とコストパフォーマンスに優れたグローバルモデル接続サービスを提供しています。 🎁 新規ユーザー特典 — 数百万 Token を無料で獲得可能。さらに 500+ の無料モデルで、低コストなテスト、検証、デプロイをより簡単に 🧠 世界の主要モデル — GPT · Claude · Gemini · Qwen · Grok... 🎬 マルチモーダル制作 — Seedance 2.0 · GPT Image · Gemini Nano Banana 🛡️ エンタープライズ級の安定性 — 高可用性 💎 ネイティブ能力の出力 💎 性能劣化なし 💎 モデル混在なし 💎 利用量と課金が透明 💎 💰 より低い利用コスト — トップモデルを公式価格の 1 割から利用可能。明確なドキュメント、簡単な接続、請求書発行、企業向け一括利用に対応 🏢 企業に最適 — AI プロダクト、Agent、コンテンツプラットフォーム、大量利用する開発チームに最適</td>
</tr>
</table>
## 概要
Binary file not shown.

Before

Width:  |  Height:  |  Size: 204 KiB

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.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 105 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 102 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

+237
View File
@@ -0,0 +1,237 @@
// profit-preview 读取生产只读导出的 JSON(分组利润配置、账号倍率与探测状态、
// 用户覆盖倍率、主力模型清单),复用线上 U/D/阈值判定做五平台离线预演。
//
// 用法:
//
// go run ./cmd/profit-preview -input dump.json [-assume-enabled] [-json]
package main
import (
"encoding/json"
"flag"
"fmt"
"os"
"sort"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type inputGroup struct {
ID int64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
RateMultiplier float64 `json:"rate_multiplier"`
SubscriptionType string `json:"subscription_type"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin float64 `json:"profit_min_margin"`
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
PeakRateEnabled bool `json:"peak_rate_enabled"`
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier float64 `json:"peak_rate_multiplier"`
}
type inputAccount struct {
ID int64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
Type string `json:"type"`
RateMultiplier *float64 `json:"rate_multiplier"`
Extra map[string]any `json:"extra"`
ModelMapping map[string]string `json:"model_mapping"`
}
type inputEntry struct {
Group inputGroup `json:"group"`
Accounts []inputAccount `json:"accounts"`
UserOverrides map[string]*float64 `json:"user_overrides"`
Models []string `json:"models"`
}
type inputDoc struct {
Groups []inputEntry `json:"groups"`
}
func main() {
inputPath := flag.String("input", "", "生产只读导出 JSON 路径")
assumeEnabled := flag.Bool("assume-enabled", false, "把当前关闭的支持平台分组按保存配置视为已启用")
jsonOut := flag.Bool("json", false, "以 JSON 输出完整报告(默认输出可读表格)")
flag.Parse()
if *inputPath == "" {
fmt.Fprintln(os.Stderr, "usage: profit-preview -input dump.json [-assume-enabled] [-json]")
os.Exit(2)
}
raw, err := os.ReadFile(*inputPath)
if err != nil {
fmt.Fprintf(os.Stderr, "read input: %v\n", err)
os.Exit(1)
}
inputs, err := parsePreviewInputs(raw, *assumeEnabled)
if err != nil {
fmt.Fprintf(os.Stderr, "parse input: %v\n", err)
os.Exit(1)
}
evalAt := time.Now()
reports := service.PreviewProfitAdmission(inputs, evalAt)
if len(reports) == 0 {
fmt.Fprintln(os.Stderr, "input produced no preview reports")
os.Exit(1)
}
if *jsonOut {
enc := json.NewEncoder(os.Stdout)
enc.SetIndent("", " ")
if err := enc.Encode(map[string]any{"evaluated_at": evalAt, "reports": reports}); err != nil {
fmt.Fprintf(os.Stderr, "write output: %v\n", err)
os.Exit(1)
}
return
}
fmt.Printf("利润门预演 @ %s(U=账号倍率;探测状态仅告警)\n", evalAt.Format(time.RFC3339))
for _, report := range reports {
fmt.Printf("\n== 分组 %d %s [%s] ==\n", report.GroupID, report.GroupName, report.Platform)
fmt.Printf(" 利润门生效=%v 假定启用=%v | 默认 D=%.4f 阈值=%.4f | 最低有效 D=%.4f 阈值=%.4f\n",
report.EffectiveGate, report.AssumedEnabled,
report.DefaultD, report.ThresholdDefault, report.MinEffectiveD, report.ThresholdMinD)
counts := map[string]int{}
for _, v := range report.Verdicts {
counts[v.Class]++
rate := "-"
if v.AccountRate != nil {
rate = fmt.Sprintf("%.4f", *v.AccountRate)
}
flags := make([]string, 0, 2)
if v.RejectedUnderMinD {
flags = append(flags, "最低有效D下拒绝")
}
if len(v.Warnings) > 0 {
flags = append(flags, strings.Join(v.Warnings, ","))
}
suffix := ""
if len(flags) > 0 {
suffix = " [" + strings.Join(flags, "; ") + "]"
}
fmt.Printf(" 账号 %-4d %-24s 平台=%-12s U=%-8s 来源=%-19s %s%s\n",
v.AccountID, v.Name, v.Platform, rate, v.RateSource, v.Class, suffix)
}
fmt.Printf(" 分类合计: 准入=%d 利润不足=%d 倍率非法=%d\n",
counts[service.ProfitPreviewClassAdmitted],
counts[service.ProfitPreviewClassRejectedThreshold],
counts[service.ProfitPreviewClassRejectedInvalidRate])
models := make([]string, 0, len(report.RemainingByModel))
for model := range report.RemainingByModel {
models = append(models, model)
}
sort.Strings(models)
for _, model := range models {
fmt.Printf(" 模型 %-20s 利润门准入账号: 默认D=%d 最低有效D=%d\n",
model, report.RemainingByModel[model], report.RemainingByModelMinD[model])
}
for _, model := range modelsWithZeroRemaining(report) {
fmt.Printf(" 警告: 模型 %s 启用后利润门准入账号为 0\n", model)
}
for _, model := range modelsWithZeroRemainingUnderMinD(report) {
fmt.Printf(" 警告: 模型 %s 在最低有效D(存在低倍率用户覆盖)下利润门准入账号为 0\n", model)
}
}
}
func parsePreviewInputs(raw []byte, assumeEnabled bool) ([]service.ProfitPreviewGroupInput, error) {
var doc inputDoc
if err := json.Unmarshal(raw, &doc); err != nil {
return nil, err
}
if len(doc.Groups) == 0 {
return nil, fmt.Errorf("input contains no groups; check the export query and target configuration")
}
inputs := make([]service.ProfitPreviewGroupInput, 0, len(doc.Groups))
for i, entry := range doc.Groups {
if entry.Group.ID <= 0 || strings.TrimSpace(entry.Group.Platform) == "" {
return nil, fmt.Errorf("invalid group at index %d: id and platform are required", i)
}
group := &service.Group{
ID: entry.Group.ID,
Name: entry.Group.Name,
Platform: entry.Group.Platform,
Status: service.StatusActive,
Hydrated: true,
RateMultiplier: entry.Group.RateMultiplier,
SubscriptionType: entry.Group.SubscriptionType,
ProfitControlEnabled: entry.Group.ProfitControlEnabled,
ProfitMinMargin: entry.Group.ProfitMinMargin,
ProfitSafetyBuffer: entry.Group.ProfitSafetyBuffer,
PeakRateEnabled: entry.Group.PeakRateEnabled,
PeakStart: entry.Group.PeakStart,
PeakEnd: entry.Group.PeakEnd,
PeakRateMultiplier: entry.Group.PeakRateMultiplier,
}
accounts := make([]*service.Account, 0, len(entry.Accounts))
for _, a := range entry.Accounts {
account := &service.Account{
ID: a.ID,
Name: a.Name,
Platform: a.Platform,
Type: a.Type,
RateMultiplier: a.RateMultiplier,
Extra: a.Extra,
}
if len(a.ModelMapping) > 0 {
mapping := make(map[string]any, len(a.ModelMapping))
for k, v := range a.ModelMapping {
mapping[k] = v
}
account.Credentials = map[string]any{"model_mapping": mapping}
}
accounts = append(accounts, account)
}
overrides := make(map[int64]float64, len(entry.UserOverrides))
for userID, rate := range entry.UserOverrides {
if rate == nil {
continue
}
var id int64
if _, err := fmt.Sscan(userID, &id); err == nil && id > 0 {
overrides[id] = *rate
}
}
inputs = append(inputs, service.ProfitPreviewGroupInput{
Group: group,
Accounts: accounts,
UserOverrides: overrides,
Models: entry.Models,
AssumeEnabled: assumeEnabled,
})
}
return inputs, nil
}
func modelsWithZeroRemaining(report service.ProfitPreviewGroupReport) []string {
var out []string
for model, count := range report.RemainingByModel {
if count == 0 {
out = append(out, model)
}
}
sort.Strings(out)
return out
}
// modelsWithZeroRemainingUnderMinD 返回默认 D 下仍有准入账号、但在最低有效 D
// 下会归零的模型。最低有效 D 来自分组内最低的用户级倍率覆盖:这些模型对那部分
// 用户是全黑的,而只看默认 D 的告警完全看不出来。
// 两档都为 0 的模型由 modelsWithZeroRemaining 报告,这里不重复。
func modelsWithZeroRemainingUnderMinD(report service.ProfitPreviewGroupReport) []string {
var out []string
for model, count := range report.RemainingByModelMinD {
if count == 0 && report.RemainingByModel[model] > 0 {
out = append(out, model)
}
}
sort.Strings(out)
return out
}
+92
View File
@@ -0,0 +1,92 @@
package main
import (
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestParsePreviewInputsIgnoresNullUserOverride(t *testing.T) {
raw := []byte(`{
"groups": [{
"group": {
"id": 50,
"name": "preview",
"platform": "openai",
"rate_multiplier": 0.5,
"subscription_type": "standard",
"profit_control_enabled": false,
"profit_min_margin": 0.1,
"profit_safety_buffer": 0
},
"accounts": [{
"id": 1,
"name": "cheap",
"platform": "openai",
"type": "apikey",
"rate_multiplier": 0.2
}],
"user_overrides": {"40": null, "41": 0.4},
"models": ["gpt-test"]
}]
}`)
inputs, err := parsePreviewInputs(raw, true)
require.NoError(t, err)
require.Len(t, inputs, 1)
require.Equal(t, map[int64]float64{41: 0.4}, inputs[0].UserOverrides)
require.True(t, inputs[0].AssumeEnabled)
report := service.PreviewProfitAdmission(inputs, time.Date(2026, 1, 15, 8, 30, 0, 0, time.UTC))[0]
require.InDelta(t, 0.4, report.MinEffectiveD, 1e-12, "null 覆盖不能被解码成 0 倍率")
require.InDelta(t, 0.36, report.ThresholdMinD, 1e-12)
}
func TestParsePreviewInputsRejectsEmptyGroups(t *testing.T) {
for _, raw := range [][]byte{
[]byte(`{"groups":null}`),
[]byte(`{"groups":[]}`),
} {
inputs, err := parsePreviewInputs(raw, false)
require.ErrorContains(t, err, "input contains no groups")
require.Nil(t, inputs)
}
}
// TestModelsWithZeroRemainingWarnings 钉死两档 D 的归零告警分工:
// 默认 D 归零由 modelsWithZeroRemaining 报告;默认 D 仍有账号但最低有效 D
// (分组内存在更低的用户级倍率覆盖)归零的模型必须单独告警——那些用户的该
// 模型会全黑,只看默认 D 完全看不出来。两档都为 0 时不重复告警。
func TestModelsWithZeroRemainingWarnings(t *testing.T) {
report := service.ProfitPreviewGroupReport{
RemainingByModel: map[string]int{
"both-zero": 0,
"min-d-zero": 2,
"healthy": 3,
"min-d-zero-alt": 1,
},
RemainingByModelMinD: map[string]int{
"both-zero": 0,
"min-d-zero": 0,
"healthy": 3,
"min-d-zero-alt": 0,
},
}
if got := modelsWithZeroRemaining(report); len(got) != 1 || got[0] != "both-zero" {
t.Fatalf("默认D归零告警应只覆盖 both-zero,got %v", got)
}
got := modelsWithZeroRemainingUnderMinD(report)
want := []string{"min-d-zero", "min-d-zero-alt"}
if len(got) != len(want) {
t.Fatalf("最低有效D归零告警不符: got %v want %v", got, want)
}
for i := range want {
if got[i] != want[i] {
t.Fatalf("最低有效D归零告警不符(应按模型名排序): got %v want %v", got, want)
}
}
}
+1 -1
View File
@@ -1 +1 @@
0.1.162
0.1.169
+7
View File
@@ -111,6 +111,7 @@ func provideCleanup(
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
auditLog *service.AuditLogService,
promptAudit *securityaudit.PromptService,
) func() {
@@ -331,6 +332,12 @@ func provideCleanup(
}
return nil
}},
{"OllamaCloudUsageService", func() error {
if ollamaCloudUsage != nil {
ollamaCloudUsage.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{
+27 -8
View File
@@ -135,9 +135,11 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client)
compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository)
notificationEmailService := service.NewNotificationEmailService(settingRepository, emailService)
balanceNotifyService := service.ProvideBalanceNotifyService(emailService, settingRepository, accountRepository, notificationEmailService)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, balanceNotifyService, serviceUserPlatformQuotaRepository)
gatewayService := service.NewGatewayService(accountRepository, groupRepository, usageLogRepository, usageBillingRepository, userRepository, userSubscriptionRepository, userGroupRateRepository, gatewayCache, configConfig, schedulerSnapshotService, concurrencyService, billingService, rateLimitService, billingCacheService, identityService, httpUpstream, deferredService, claudeTokenProvider, sessionLimitCache, rpmCache, digestSessionStore, settingService, tlsFingerprintProfileService, channelService, modelPricingResolver, compositeRouteResolver, balanceNotifyService, serviceUserPlatformQuotaRepository)
openAIOAuthClient := repository.NewOpenAIOAuthClient()
privacyClientFactory := providePrivacyClientFactory()
openAIOAuthService := service.ProvideOpenAIOAuthService(proxyRepository, openAIOAuthClient, privacyClientFactory)
@@ -180,7 +182,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
@@ -251,9 +253,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
channelMonitorRequestTemplateHandler := admin.NewChannelMonitorRequestTemplateHandler(channelMonitorRequestTemplateService)
contentModerationRepository := repository.NewContentModerationRepository(db)
contentModerationHashCache := repository.NewContentModerationHashCache(redisClient)
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, apiKeyAuthCacheInvalidator, emailService)
contentModerationService := service.NewContentModerationService(settingRepository, contentModerationRepository, contentModerationHashCache, groupRepository, userRepository, proxyRepository, 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()
@@ -267,7 +269,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
@@ -277,9 +280,17 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
passkeyRepository := repository.NewPasskeyRepository(db)
passkeySessionStore := repository.NewPasskeySessionStore(redisClient)
passkeyService, err := service.NewPasskeyService(configConfig, passkeyRepository, passkeySessionStore, userRepository)
if err != nil {
return nil, err
}
passkeyHandler := handler.NewPasskeyHandler(passkeyService, authService, settingService)
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)
@@ -293,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, 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)
@@ -315,7 +327,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, auditLogService, promptService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
@@ -382,6 +394,7 @@ func provideCleanup(
channelMonitorRunner *service.ChannelMonitorRunner,
quotaFlusher *service.UserPlatformQuotaUsageFlusher,
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
auditLog *service.AuditLogService,
promptAudit *securityaudit.PromptService,
) func() {
@@ -601,6 +614,12 @@ func provideCleanup(
}
return nil
}},
{"OllamaCloudUsageService", func() error {
if ollamaCloudUsage != nil {
ollamaCloudUsage.Stop()
}
return nil
}},
}
infraSteps := []cleanupStep{
+1
View File
@@ -88,6 +88,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // channelMonitorRunner
nil, // quotaFlusher
nil, // upstreamBillingProbe
nil, // ollamaCloudUsage
nil, // auditLog
nil, // promptAudit
)
+175 -14
View File
@@ -29,6 +29,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate"
"github.com/Wei-Shaw/sub2api/ent/compositemodelroute"
"github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/idempotencyrecord"
@@ -90,6 +91,8 @@ type Client struct {
ChannelMonitorHistory *ChannelMonitorHistoryClient
// ChannelMonitorRequestTemplate is the client for interacting with the ChannelMonitorRequestTemplate builders.
ChannelMonitorRequestTemplate *ChannelMonitorRequestTemplateClient
// CompositeModelRoute is the client for interacting with the CompositeModelRoute builders.
CompositeModelRoute *CompositeModelRouteClient
// ErrorPassthroughRule is the client for interacting with the ErrorPassthroughRule builders.
ErrorPassthroughRule *ErrorPassthroughRuleClient
// Group is the client for interacting with the Group builders.
@@ -163,6 +166,7 @@ func (c *Client) init() {
c.ChannelMonitorDailyRollup = NewChannelMonitorDailyRollupClient(c.config)
c.ChannelMonitorHistory = NewChannelMonitorHistoryClient(c.config)
c.ChannelMonitorRequestTemplate = NewChannelMonitorRequestTemplateClient(c.config)
c.CompositeModelRoute = NewCompositeModelRouteClient(c.config)
c.ErrorPassthroughRule = NewErrorPassthroughRuleClient(c.config)
c.Group = NewGroupClient(c.config)
c.IdempotencyRecord = NewIdempotencyRecordClient(c.config)
@@ -293,6 +297,7 @@ func (c *Client) Tx(ctx context.Context) (*Tx, error) {
ChannelMonitorDailyRollup: NewChannelMonitorDailyRollupClient(cfg),
ChannelMonitorHistory: NewChannelMonitorHistoryClient(cfg),
ChannelMonitorRequestTemplate: NewChannelMonitorRequestTemplateClient(cfg),
CompositeModelRoute: NewCompositeModelRouteClient(cfg),
ErrorPassthroughRule: NewErrorPassthroughRuleClient(cfg),
Group: NewGroupClient(cfg),
IdempotencyRecord: NewIdempotencyRecordClient(cfg),
@@ -350,6 +355,7 @@ func (c *Client) BeginTx(ctx context.Context, opts *sql.TxOptions) (*Tx, error)
ChannelMonitorDailyRollup: NewChannelMonitorDailyRollupClient(cfg),
ChannelMonitorHistory: NewChannelMonitorHistoryClient(cfg),
ChannelMonitorRequestTemplate: NewChannelMonitorRequestTemplateClient(cfg),
CompositeModelRoute: NewCompositeModelRouteClient(cfg),
ErrorPassthroughRule: NewErrorPassthroughRuleClient(cfg),
Group: NewGroupClient(cfg),
IdempotencyRecord: NewIdempotencyRecordClient(cfg),
@@ -407,7 +413,7 @@ func (c *Client) Use(hooks ...Hook) {
c.AuthIdentity, c.AuthIdentityChannel, c.BatchImageEvent, c.BatchImageItem,
c.BatchImageJob, c.ChannelMonitor, c.ChannelMonitorDailyRollup,
c.ChannelMonitorHistory, c.ChannelMonitorRequestTemplate,
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord,
c.CompositeModelRoute, c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord,
c.IdentityAdoptionDecision, c.PaymentAuditLog, c.PaymentOrder,
c.PaymentProviderInstance, c.PendingAuthSession, c.PromoCode, c.PromoCodeUsage,
c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan,
@@ -427,7 +433,7 @@ func (c *Client) Intercept(interceptors ...Interceptor) {
c.AuthIdentity, c.AuthIdentityChannel, c.BatchImageEvent, c.BatchImageItem,
c.BatchImageJob, c.ChannelMonitor, c.ChannelMonitorDailyRollup,
c.ChannelMonitorHistory, c.ChannelMonitorRequestTemplate,
c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord,
c.CompositeModelRoute, c.ErrorPassthroughRule, c.Group, c.IdempotencyRecord,
c.IdentityAdoptionDecision, c.PaymentAuditLog, c.PaymentOrder,
c.PaymentProviderInstance, c.PendingAuthSession, c.PromoCode, c.PromoCodeUsage,
c.Proxy, c.RedeemCode, c.SecuritySecret, c.Setting, c.SubscriptionPlan,
@@ -470,6 +476,8 @@ func (c *Client) Mutate(ctx context.Context, m Mutation) (Value, error) {
return c.ChannelMonitorHistory.mutate(ctx, m)
case *ChannelMonitorRequestTemplateMutation:
return c.ChannelMonitorRequestTemplate.mutate(ctx, m)
case *CompositeModelRouteMutation:
return c.CompositeModelRoute.mutate(ctx, m)
case *ErrorPassthroughRuleMutation:
return c.ErrorPassthroughRule.mutate(ctx, m)
case *GroupMutation:
@@ -2724,6 +2732,157 @@ func (c *ChannelMonitorRequestTemplateClient) mutate(ctx context.Context, m *Cha
}
}
// CompositeModelRouteClient is a client for the CompositeModelRoute schema.
type CompositeModelRouteClient struct {
config
}
// NewCompositeModelRouteClient returns a client for the CompositeModelRoute from the given config.
func NewCompositeModelRouteClient(c config) *CompositeModelRouteClient {
return &CompositeModelRouteClient{config: c}
}
// Use adds a list of mutation hooks to the hooks stack.
// A call to `Use(f, g, h)` equals to `compositemodelroute.Hooks(f(g(h())))`.
func (c *CompositeModelRouteClient) Use(hooks ...Hook) {
c.hooks.CompositeModelRoute = append(c.hooks.CompositeModelRoute, hooks...)
}
// Intercept adds a list of query interceptors to the interceptors stack.
// A call to `Intercept(f, g, h)` equals to `compositemodelroute.Intercept(f(g(h())))`.
func (c *CompositeModelRouteClient) Intercept(interceptors ...Interceptor) {
c.inters.CompositeModelRoute = append(c.inters.CompositeModelRoute, interceptors...)
}
// Create returns a builder for creating a CompositeModelRoute entity.
func (c *CompositeModelRouteClient) Create() *CompositeModelRouteCreate {
mutation := newCompositeModelRouteMutation(c.config, OpCreate)
return &CompositeModelRouteCreate{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// CreateBulk returns a builder for creating a bulk of CompositeModelRoute entities.
func (c *CompositeModelRouteClient) CreateBulk(builders ...*CompositeModelRouteCreate) *CompositeModelRouteCreateBulk {
return &CompositeModelRouteCreateBulk{config: c.config, builders: builders}
}
// MapCreateBulk creates a bulk creation builder from the given slice. For each item in the slice, the function creates
// a builder and applies setFunc on it.
func (c *CompositeModelRouteClient) MapCreateBulk(slice any, setFunc func(*CompositeModelRouteCreate, int)) *CompositeModelRouteCreateBulk {
rv := reflect.ValueOf(slice)
if rv.Kind() != reflect.Slice {
return &CompositeModelRouteCreateBulk{err: fmt.Errorf("calling to CompositeModelRouteClient.MapCreateBulk with wrong type %T, need slice", slice)}
}
builders := make([]*CompositeModelRouteCreate, rv.Len())
for i := 0; i < rv.Len(); i++ {
builders[i] = c.Create()
setFunc(builders[i], i)
}
return &CompositeModelRouteCreateBulk{config: c.config, builders: builders}
}
// Update returns an update builder for CompositeModelRoute.
func (c *CompositeModelRouteClient) Update() *CompositeModelRouteUpdate {
mutation := newCompositeModelRouteMutation(c.config, OpUpdate)
return &CompositeModelRouteUpdate{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// UpdateOne returns an update builder for the given entity.
func (c *CompositeModelRouteClient) UpdateOne(_m *CompositeModelRoute) *CompositeModelRouteUpdateOne {
mutation := newCompositeModelRouteMutation(c.config, OpUpdateOne, withCompositeModelRoute(_m))
return &CompositeModelRouteUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// UpdateOneID returns an update builder for the given id.
func (c *CompositeModelRouteClient) UpdateOneID(id int64) *CompositeModelRouteUpdateOne {
mutation := newCompositeModelRouteMutation(c.config, OpUpdateOne, withCompositeModelRouteID(id))
return &CompositeModelRouteUpdateOne{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// Delete returns a delete builder for CompositeModelRoute.
func (c *CompositeModelRouteClient) Delete() *CompositeModelRouteDelete {
mutation := newCompositeModelRouteMutation(c.config, OpDelete)
return &CompositeModelRouteDelete{config: c.config, hooks: c.Hooks(), mutation: mutation}
}
// DeleteOne returns a builder for deleting the given entity.
func (c *CompositeModelRouteClient) DeleteOne(_m *CompositeModelRoute) *CompositeModelRouteDeleteOne {
return c.DeleteOneID(_m.ID)
}
// DeleteOneID returns a builder for deleting the given entity by its id.
func (c *CompositeModelRouteClient) DeleteOneID(id int64) *CompositeModelRouteDeleteOne {
builder := c.Delete().Where(compositemodelroute.ID(id))
builder.mutation.id = &id
builder.mutation.op = OpDeleteOne
return &CompositeModelRouteDeleteOne{builder}
}
// Query returns a query builder for CompositeModelRoute.
func (c *CompositeModelRouteClient) Query() *CompositeModelRouteQuery {
return &CompositeModelRouteQuery{
config: c.config,
ctx: &QueryContext{Type: TypeCompositeModelRoute},
inters: c.Interceptors(),
}
}
// Get returns a CompositeModelRoute entity by its id.
func (c *CompositeModelRouteClient) Get(ctx context.Context, id int64) (*CompositeModelRoute, error) {
return c.Query().Where(compositemodelroute.ID(id)).Only(ctx)
}
// GetX is like Get, but panics if an error occurs.
func (c *CompositeModelRouteClient) GetX(ctx context.Context, id int64) *CompositeModelRoute {
obj, err := c.Get(ctx, id)
if err != nil {
panic(err)
}
return obj
}
// QueryGroup queries the group edge of a CompositeModelRoute.
func (c *CompositeModelRouteClient) QueryGroup(_m *CompositeModelRoute) *GroupQuery {
query := (&GroupClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(compositemodelroute.Table, compositemodelroute.FieldID, id),
sqlgraph.To(group.Table, group.FieldID),
sqlgraph.Edge(sqlgraph.M2O, false, compositemodelroute.GroupTable, compositemodelroute.GroupColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
}
return query
}
// Hooks returns the client hooks.
func (c *CompositeModelRouteClient) Hooks() []Hook {
hooks := c.hooks.CompositeModelRoute
return append(hooks[:len(hooks):len(hooks)], compositemodelroute.Hooks[:]...)
}
// Interceptors returns the client interceptors.
func (c *CompositeModelRouteClient) Interceptors() []Interceptor {
inters := c.inters.CompositeModelRoute
return append(inters[:len(inters):len(inters)], compositemodelroute.Interceptors[:]...)
}
func (c *CompositeModelRouteClient) mutate(ctx context.Context, m *CompositeModelRouteMutation) (Value, error) {
switch m.Op() {
case OpCreate:
return (&CompositeModelRouteCreate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
case OpUpdate:
return (&CompositeModelRouteUpdate{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
case OpUpdateOne:
return (&CompositeModelRouteUpdateOne{config: c.config, hooks: c.Hooks(), mutation: m}).Save(ctx)
case OpDelete, OpDeleteOne:
return (&CompositeModelRouteDelete{config: c.config, hooks: c.Hooks(), mutation: m}).Exec(ctx)
default:
return nil, fmt.Errorf("ent: unknown CompositeModelRoute mutation op: %q", m.Op())
}
}
// ErrorPassthroughRuleClient is a client for the ErrorPassthroughRule schema.
type ErrorPassthroughRuleClient struct {
config
@@ -6669,23 +6828,25 @@ type (
APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity,
AuthIdentityChannel, BatchImageEvent, BatchImageItem, BatchImageJob,
ChannelMonitor, ChannelMonitorDailyRollup, ChannelMonitorHistory,
ChannelMonitorRequestTemplate, ErrorPassthroughRule, Group, IdempotencyRecord,
IdentityAdoptionDecision, PaymentAuditLog, PaymentOrder,
PaymentProviderInstance, PendingAuthSession, PromoCode, PromoCodeUsage, Proxy,
RedeemCode, SecuritySecret, Setting, SubscriptionPlan, TLSFingerprintProfile,
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
UserAttributeValue, UserPlatformQuota, UserSubscription []ent.Hook
ChannelMonitorRequestTemplate, CompositeModelRoute, ErrorPassthroughRule,
Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog,
PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode,
PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
UserAttributeDefinition, UserAttributeValue, UserPlatformQuota,
UserSubscription []ent.Hook
}
inters struct {
APIKey, Account, AccountGroup, Announcement, AnnouncementRead, AuthIdentity,
AuthIdentityChannel, BatchImageEvent, BatchImageItem, BatchImageJob,
ChannelMonitor, ChannelMonitorDailyRollup, ChannelMonitorHistory,
ChannelMonitorRequestTemplate, ErrorPassthroughRule, Group, IdempotencyRecord,
IdentityAdoptionDecision, PaymentAuditLog, PaymentOrder,
PaymentProviderInstance, PendingAuthSession, PromoCode, PromoCodeUsage, Proxy,
RedeemCode, SecuritySecret, Setting, SubscriptionPlan, TLSFingerprintProfile,
UsageCleanupTask, UsageLog, User, UserAllowedGroup, UserAttributeDefinition,
UserAttributeValue, UserPlatformQuota, UserSubscription []ent.Interceptor
ChannelMonitorRequestTemplate, CompositeModelRoute, ErrorPassthroughRule,
Group, IdempotencyRecord, IdentityAdoptionDecision, PaymentAuditLog,
PaymentOrder, PaymentProviderInstance, PendingAuthSession, PromoCode,
PromoCodeUsage, Proxy, RedeemCode, SecuritySecret, Setting, SubscriptionPlan,
TLSFingerprintProfile, UsageCleanupTask, UsageLog, User, UserAllowedGroup,
UserAttributeDefinition, UserAttributeValue, UserPlatformQuota,
UserSubscription []ent.Interceptor
}
)
+264
View File
@@ -0,0 +1,264 @@
// Code generated by ent, DO NOT EDIT.
package ent
import (
"fmt"
"strings"
"time"
"entgo.io/ent"
"entgo.io/ent/dialect/sql"
"github.com/Wei-Shaw/sub2api/ent/compositemodelroute"
"github.com/Wei-Shaw/sub2api/ent/group"
)
// CompositeModelRoute is the model entity for the CompositeModelRoute schema.
type CompositeModelRoute struct {
config `json:"-"`
// ID of the ent.
ID int64 `json:"id,omitempty"`
// CreatedAt holds the value of the "created_at" field.
CreatedAt time.Time `json:"created_at,omitempty"`
// UpdatedAt holds the value of the "updated_at" field.
UpdatedAt time.Time `json:"updated_at,omitempty"`
// DeletedAt holds the value of the "deleted_at" field.
DeletedAt *time.Time `json:"deleted_at,omitempty"`
// GroupID holds the value of the "group_id" field.
GroupID int64 `json:"group_id,omitempty"`
// Client-facing model identifier or prefix.
PublicModel string `json:"public_model,omitempty"`
// exact or prefix.
MatchType string `json:"match_type,omitempty"`
// Concrete provider platform.
TargetPlatform string `json:"target_platform,omitempty"`
// Provider model identifier; empty means public_model.
UpstreamModel string `json:"upstream_model,omitempty"`
// Endpoint scope such as any, messages, responses, chat_completions.
Endpoint string `json:"endpoint,omitempty"`
// Lower values win within the same match strength.
Priority int `json:"priority,omitempty"`
// Enabled holds the value of the "enabled" field.
Enabled bool `json:"enabled,omitempty"`
// Notes holds the value of the "notes" field.
Notes *string `json:"notes,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the CompositeModelRouteQuery when eager-loading is set.
Edges CompositeModelRouteEdges `json:"edges"`
selectValues sql.SelectValues
}
// CompositeModelRouteEdges holds the relations/edges for other nodes in the graph.
type CompositeModelRouteEdges struct {
// Group holds the value of the group edge.
Group *Group `json:"group,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [1]bool
}
// GroupOrErr returns the Group value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e CompositeModelRouteEdges) GroupOrErr() (*Group, error) {
if e.Group != nil {
return e.Group, nil
} else if e.loadedTypes[0] {
return nil, &NotFoundError{label: group.Label}
}
return nil, &NotLoadedError{edge: "group"}
}
// scanValues returns the types for scanning values from sql.Rows.
func (*CompositeModelRoute) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case compositemodelroute.FieldEnabled:
values[i] = new(sql.NullBool)
case compositemodelroute.FieldID, compositemodelroute.FieldGroupID, compositemodelroute.FieldPriority:
values[i] = new(sql.NullInt64)
case compositemodelroute.FieldPublicModel, compositemodelroute.FieldMatchType, compositemodelroute.FieldTargetPlatform, compositemodelroute.FieldUpstreamModel, compositemodelroute.FieldEndpoint, compositemodelroute.FieldNotes:
values[i] = new(sql.NullString)
case compositemodelroute.FieldCreatedAt, compositemodelroute.FieldUpdatedAt, compositemodelroute.FieldDeletedAt:
values[i] = new(sql.NullTime)
default:
values[i] = new(sql.UnknownType)
}
}
return values, nil
}
// assignValues assigns the values that were returned from sql.Rows (after scanning)
// to the CompositeModelRoute fields.
func (_m *CompositeModelRoute) assignValues(columns []string, values []any) error {
if m, n := len(values), len(columns); m < n {
return fmt.Errorf("mismatch number of scan values: %d != %d", m, n)
}
for i := range columns {
switch columns[i] {
case compositemodelroute.FieldID:
value, ok := values[i].(*sql.NullInt64)
if !ok {
return fmt.Errorf("unexpected type %T for field id", value)
}
_m.ID = int64(value.Int64)
case compositemodelroute.FieldCreatedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field created_at", values[i])
} else if value.Valid {
_m.CreatedAt = value.Time
}
case compositemodelroute.FieldUpdatedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field updated_at", values[i])
} else if value.Valid {
_m.UpdatedAt = value.Time
}
case compositemodelroute.FieldDeletedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field deleted_at", values[i])
} else if value.Valid {
_m.DeletedAt = new(time.Time)
*_m.DeletedAt = value.Time
}
case compositemodelroute.FieldGroupID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field group_id", values[i])
} else if value.Valid {
_m.GroupID = value.Int64
}
case compositemodelroute.FieldPublicModel:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field public_model", values[i])
} else if value.Valid {
_m.PublicModel = value.String
}
case compositemodelroute.FieldMatchType:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field match_type", values[i])
} else if value.Valid {
_m.MatchType = value.String
}
case compositemodelroute.FieldTargetPlatform:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field target_platform", values[i])
} else if value.Valid {
_m.TargetPlatform = value.String
}
case compositemodelroute.FieldUpstreamModel:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field upstream_model", values[i])
} else if value.Valid {
_m.UpstreamModel = value.String
}
case compositemodelroute.FieldEndpoint:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field endpoint", values[i])
} else if value.Valid {
_m.Endpoint = value.String
}
case compositemodelroute.FieldPriority:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field priority", values[i])
} else if value.Valid {
_m.Priority = int(value.Int64)
}
case compositemodelroute.FieldEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field enabled", values[i])
} else if value.Valid {
_m.Enabled = value.Bool
}
case compositemodelroute.FieldNotes:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field notes", values[i])
} else if value.Valid {
_m.Notes = new(string)
*_m.Notes = value.String
}
default:
_m.selectValues.Set(columns[i], values[i])
}
}
return nil
}
// Value returns the ent.Value that was dynamically selected and assigned to the CompositeModelRoute.
// This includes values selected through modifiers, order, etc.
func (_m *CompositeModelRoute) Value(name string) (ent.Value, error) {
return _m.selectValues.Get(name)
}
// QueryGroup queries the "group" edge of the CompositeModelRoute entity.
func (_m *CompositeModelRoute) QueryGroup() *GroupQuery {
return NewCompositeModelRouteClient(_m.config).QueryGroup(_m)
}
// Update returns a builder for updating this CompositeModelRoute.
// Note that you need to call CompositeModelRoute.Unwrap() before calling this method if this CompositeModelRoute
// was returned from a transaction, and the transaction was committed or rolled back.
func (_m *CompositeModelRoute) Update() *CompositeModelRouteUpdateOne {
return NewCompositeModelRouteClient(_m.config).UpdateOne(_m)
}
// Unwrap unwraps the CompositeModelRoute entity that was returned from a transaction after it was closed,
// so that all future queries will be executed through the driver which created the transaction.
func (_m *CompositeModelRoute) Unwrap() *CompositeModelRoute {
_tx, ok := _m.config.driver.(*txDriver)
if !ok {
panic("ent: CompositeModelRoute is not a transactional entity")
}
_m.config.driver = _tx.drv
return _m
}
// String implements the fmt.Stringer.
func (_m *CompositeModelRoute) String() string {
var builder strings.Builder
builder.WriteString("CompositeModelRoute(")
builder.WriteString(fmt.Sprintf("id=%v, ", _m.ID))
builder.WriteString("created_at=")
builder.WriteString(_m.CreatedAt.Format(time.ANSIC))
builder.WriteString(", ")
builder.WriteString("updated_at=")
builder.WriteString(_m.UpdatedAt.Format(time.ANSIC))
builder.WriteString(", ")
if v := _m.DeletedAt; v != nil {
builder.WriteString("deleted_at=")
builder.WriteString(v.Format(time.ANSIC))
}
builder.WriteString(", ")
builder.WriteString("group_id=")
builder.WriteString(fmt.Sprintf("%v", _m.GroupID))
builder.WriteString(", ")
builder.WriteString("public_model=")
builder.WriteString(_m.PublicModel)
builder.WriteString(", ")
builder.WriteString("match_type=")
builder.WriteString(_m.MatchType)
builder.WriteString(", ")
builder.WriteString("target_platform=")
builder.WriteString(_m.TargetPlatform)
builder.WriteString(", ")
builder.WriteString("upstream_model=")
builder.WriteString(_m.UpstreamModel)
builder.WriteString(", ")
builder.WriteString("endpoint=")
builder.WriteString(_m.Endpoint)
builder.WriteString(", ")
builder.WriteString("priority=")
builder.WriteString(fmt.Sprintf("%v", _m.Priority))
builder.WriteString(", ")
builder.WriteString("enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.Enabled))
builder.WriteString(", ")
if v := _m.Notes; v != nil {
builder.WriteString("notes=")
builder.WriteString(*v)
}
builder.WriteByte(')')
return builder.String()
}
// CompositeModelRoutes is a parsable slice of CompositeModelRoute.
type CompositeModelRoutes []*CompositeModelRoute
@@ -0,0 +1,200 @@
// Code generated by ent, DO NOT EDIT.
package compositemodelroute
import (
"time"
"entgo.io/ent"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
)
const (
// Label holds the string label denoting the compositemodelroute type in the database.
Label = "composite_model_route"
// FieldID holds the string denoting the id field in the database.
FieldID = "id"
// FieldCreatedAt holds the string denoting the created_at field in the database.
FieldCreatedAt = "created_at"
// FieldUpdatedAt holds the string denoting the updated_at field in the database.
FieldUpdatedAt = "updated_at"
// FieldDeletedAt holds the string denoting the deleted_at field in the database.
FieldDeletedAt = "deleted_at"
// FieldGroupID holds the string denoting the group_id field in the database.
FieldGroupID = "group_id"
// FieldPublicModel holds the string denoting the public_model field in the database.
FieldPublicModel = "public_model"
// FieldMatchType holds the string denoting the match_type field in the database.
FieldMatchType = "match_type"
// FieldTargetPlatform holds the string denoting the target_platform field in the database.
FieldTargetPlatform = "target_platform"
// FieldUpstreamModel holds the string denoting the upstream_model field in the database.
FieldUpstreamModel = "upstream_model"
// FieldEndpoint holds the string denoting the endpoint field in the database.
FieldEndpoint = "endpoint"
// FieldPriority holds the string denoting the priority field in the database.
FieldPriority = "priority"
// FieldEnabled holds the string denoting the enabled field in the database.
FieldEnabled = "enabled"
// FieldNotes holds the string denoting the notes field in the database.
FieldNotes = "notes"
// EdgeGroup holds the string denoting the group edge name in mutations.
EdgeGroup = "group"
// Table holds the table name of the compositemodelroute in the database.
Table = "composite_model_routes"
// GroupTable is the table that holds the group relation/edge.
GroupTable = "composite_model_routes"
// GroupInverseTable is the table name for the Group entity.
// It exists in this package in order to avoid circular dependency with the "group" package.
GroupInverseTable = "groups"
// GroupColumn is the table column denoting the group relation/edge.
GroupColumn = "group_id"
)
// Columns holds all SQL columns for compositemodelroute fields.
var Columns = []string{
FieldID,
FieldCreatedAt,
FieldUpdatedAt,
FieldDeletedAt,
FieldGroupID,
FieldPublicModel,
FieldMatchType,
FieldTargetPlatform,
FieldUpstreamModel,
FieldEndpoint,
FieldPriority,
FieldEnabled,
FieldNotes,
}
// ValidColumn reports if the column name is valid (part of the table columns).
func ValidColumn(column string) bool {
for i := range Columns {
if column == Columns[i] {
return true
}
}
return false
}
// Note that the variables below are initialized by the runtime
// package on the initialization of the application. Therefore,
// it should be imported in the main as follows:
//
// import _ "github.com/Wei-Shaw/sub2api/ent/runtime"
var (
Hooks [1]ent.Hook
Interceptors [1]ent.Interceptor
// DefaultCreatedAt holds the default value on creation for the "created_at" field.
DefaultCreatedAt func() time.Time
// DefaultUpdatedAt holds the default value on creation for the "updated_at" field.
DefaultUpdatedAt func() time.Time
// UpdateDefaultUpdatedAt holds the default value on update for the "updated_at" field.
UpdateDefaultUpdatedAt func() time.Time
// PublicModelValidator is a validator for the "public_model" field. It is called by the builders before save.
PublicModelValidator func(string) error
// DefaultMatchType holds the default value on creation for the "match_type" field.
DefaultMatchType string
// MatchTypeValidator is a validator for the "match_type" field. It is called by the builders before save.
MatchTypeValidator func(string) error
// DefaultTargetPlatform holds the default value on creation for the "target_platform" field.
DefaultTargetPlatform string
// TargetPlatformValidator is a validator for the "target_platform" field. It is called by the builders before save.
TargetPlatformValidator func(string) error
// DefaultUpstreamModel holds the default value on creation for the "upstream_model" field.
DefaultUpstreamModel string
// UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
UpstreamModelValidator func(string) error
// DefaultEndpoint holds the default value on creation for the "endpoint" field.
DefaultEndpoint string
// EndpointValidator is a validator for the "endpoint" field. It is called by the builders before save.
EndpointValidator func(string) error
// DefaultPriority holds the default value on creation for the "priority" field.
DefaultPriority int
// DefaultEnabled holds the default value on creation for the "enabled" field.
DefaultEnabled bool
)
// OrderOption defines the ordering options for the CompositeModelRoute queries.
type OrderOption func(*sql.Selector)
// ByID orders the results by the id field.
func ByID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldID, opts...).ToFunc()
}
// ByCreatedAt orders the results by the created_at field.
func ByCreatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCreatedAt, opts...).ToFunc()
}
// ByUpdatedAt orders the results by the updated_at field.
func ByUpdatedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpdatedAt, opts...).ToFunc()
}
// ByDeletedAt orders the results by the deleted_at field.
func ByDeletedAt(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldDeletedAt, opts...).ToFunc()
}
// ByGroupID orders the results by the group_id field.
func ByGroupID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldGroupID, opts...).ToFunc()
}
// ByPublicModel orders the results by the public_model field.
func ByPublicModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPublicModel, opts...).ToFunc()
}
// ByMatchType orders the results by the match_type field.
func ByMatchType(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMatchType, opts...).ToFunc()
}
// ByTargetPlatform orders the results by the target_platform field.
func ByTargetPlatform(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldTargetPlatform, opts...).ToFunc()
}
// ByUpstreamModel orders the results by the upstream_model field.
func ByUpstreamModel(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldUpstreamModel, opts...).ToFunc()
}
// ByEndpoint orders the results by the endpoint field.
func ByEndpoint(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldEndpoint, opts...).ToFunc()
}
// ByPriority orders the results by the priority field.
func ByPriority(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldPriority, opts...).ToFunc()
}
// ByEnabled orders the results by the enabled field.
func ByEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldEnabled, opts...).ToFunc()
}
// ByNotes orders the results by the notes field.
func ByNotes(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldNotes, opts...).ToFunc()
}
// ByGroupField orders the results by group field.
func ByGroupField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newGroupStep(), sql.OrderByField(field, opts...))
}
}
func newGroupStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(GroupInverseTable, FieldID),
sqlgraph.Edge(sqlgraph.M2O, false, GroupTable, GroupColumn),
)
}
+754
View File
@@ -0,0 +1,754 @@
// Code generated by ent, DO NOT EDIT.
package compositemodelroute
import (
"time"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"github.com/Wei-Shaw/sub2api/ent/predicate"
)
// ID filters vertices based on their ID field.
func ID(id int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldID, id))
}
// IDEQ applies the EQ predicate on the ID field.
func IDEQ(id int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldID, id))
}
// IDNEQ applies the NEQ predicate on the ID field.
func IDNEQ(id int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldID, id))
}
// IDIn applies the In predicate on the ID field.
func IDIn(ids ...int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldID, ids...))
}
// IDNotIn applies the NotIn predicate on the ID field.
func IDNotIn(ids ...int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldID, ids...))
}
// IDGT applies the GT predicate on the ID field.
func IDGT(id int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldID, id))
}
// IDGTE applies the GTE predicate on the ID field.
func IDGTE(id int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldID, id))
}
// IDLT applies the LT predicate on the ID field.
func IDLT(id int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldID, id))
}
// IDLTE applies the LTE predicate on the ID field.
func IDLTE(id int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldID, id))
}
// CreatedAt applies equality check predicate on the "created_at" field. It's identical to CreatedAtEQ.
func CreatedAt(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldCreatedAt, v))
}
// UpdatedAt applies equality check predicate on the "updated_at" field. It's identical to UpdatedAtEQ.
func UpdatedAt(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldUpdatedAt, v))
}
// DeletedAt applies equality check predicate on the "deleted_at" field. It's identical to DeletedAtEQ.
func DeletedAt(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldDeletedAt, v))
}
// GroupID applies equality check predicate on the "group_id" field. It's identical to GroupIDEQ.
func GroupID(v int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldGroupID, v))
}
// PublicModel applies equality check predicate on the "public_model" field. It's identical to PublicModelEQ.
func PublicModel(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldPublicModel, v))
}
// MatchType applies equality check predicate on the "match_type" field. It's identical to MatchTypeEQ.
func MatchType(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldMatchType, v))
}
// TargetPlatform applies equality check predicate on the "target_platform" field. It's identical to TargetPlatformEQ.
func TargetPlatform(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldTargetPlatform, v))
}
// UpstreamModel applies equality check predicate on the "upstream_model" field. It's identical to UpstreamModelEQ.
func UpstreamModel(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldUpstreamModel, v))
}
// Endpoint applies equality check predicate on the "endpoint" field. It's identical to EndpointEQ.
func Endpoint(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldEndpoint, v))
}
// Priority applies equality check predicate on the "priority" field. It's identical to PriorityEQ.
func Priority(v int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldPriority, v))
}
// Enabled applies equality check predicate on the "enabled" field. It's identical to EnabledEQ.
func Enabled(v bool) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldEnabled, v))
}
// Notes applies equality check predicate on the "notes" field. It's identical to NotesEQ.
func Notes(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldNotes, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldCreatedAt, v))
}
// CreatedAtNEQ applies the NEQ predicate on the "created_at" field.
func CreatedAtNEQ(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldCreatedAt, v))
}
// CreatedAtIn applies the In predicate on the "created_at" field.
func CreatedAtIn(vs ...time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldCreatedAt, vs...))
}
// CreatedAtNotIn applies the NotIn predicate on the "created_at" field.
func CreatedAtNotIn(vs ...time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldCreatedAt, vs...))
}
// CreatedAtGT applies the GT predicate on the "created_at" field.
func CreatedAtGT(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldCreatedAt, v))
}
// CreatedAtGTE applies the GTE predicate on the "created_at" field.
func CreatedAtGTE(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldCreatedAt, v))
}
// CreatedAtLT applies the LT predicate on the "created_at" field.
func CreatedAtLT(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldCreatedAt, v))
}
// CreatedAtLTE applies the LTE predicate on the "created_at" field.
func CreatedAtLTE(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldCreatedAt, v))
}
// UpdatedAtEQ applies the EQ predicate on the "updated_at" field.
func UpdatedAtEQ(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldUpdatedAt, v))
}
// UpdatedAtNEQ applies the NEQ predicate on the "updated_at" field.
func UpdatedAtNEQ(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldUpdatedAt, v))
}
// UpdatedAtIn applies the In predicate on the "updated_at" field.
func UpdatedAtIn(vs ...time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldUpdatedAt, vs...))
}
// UpdatedAtNotIn applies the NotIn predicate on the "updated_at" field.
func UpdatedAtNotIn(vs ...time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldUpdatedAt, vs...))
}
// UpdatedAtGT applies the GT predicate on the "updated_at" field.
func UpdatedAtGT(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldUpdatedAt, v))
}
// UpdatedAtGTE applies the GTE predicate on the "updated_at" field.
func UpdatedAtGTE(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldUpdatedAt, v))
}
// UpdatedAtLT applies the LT predicate on the "updated_at" field.
func UpdatedAtLT(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldUpdatedAt, v))
}
// UpdatedAtLTE applies the LTE predicate on the "updated_at" field.
func UpdatedAtLTE(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldUpdatedAt, v))
}
// DeletedAtEQ applies the EQ predicate on the "deleted_at" field.
func DeletedAtEQ(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldDeletedAt, v))
}
// DeletedAtNEQ applies the NEQ predicate on the "deleted_at" field.
func DeletedAtNEQ(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldDeletedAt, v))
}
// DeletedAtIn applies the In predicate on the "deleted_at" field.
func DeletedAtIn(vs ...time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldDeletedAt, vs...))
}
// DeletedAtNotIn applies the NotIn predicate on the "deleted_at" field.
func DeletedAtNotIn(vs ...time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldDeletedAt, vs...))
}
// DeletedAtGT applies the GT predicate on the "deleted_at" field.
func DeletedAtGT(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldDeletedAt, v))
}
// DeletedAtGTE applies the GTE predicate on the "deleted_at" field.
func DeletedAtGTE(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldDeletedAt, v))
}
// DeletedAtLT applies the LT predicate on the "deleted_at" field.
func DeletedAtLT(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldDeletedAt, v))
}
// DeletedAtLTE applies the LTE predicate on the "deleted_at" field.
func DeletedAtLTE(v time.Time) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldDeletedAt, v))
}
// DeletedAtIsNil applies the IsNil predicate on the "deleted_at" field.
func DeletedAtIsNil() predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIsNull(FieldDeletedAt))
}
// DeletedAtNotNil applies the NotNil predicate on the "deleted_at" field.
func DeletedAtNotNil() predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotNull(FieldDeletedAt))
}
// GroupIDEQ applies the EQ predicate on the "group_id" field.
func GroupIDEQ(v int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldGroupID, v))
}
// GroupIDNEQ applies the NEQ predicate on the "group_id" field.
func GroupIDNEQ(v int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldGroupID, v))
}
// GroupIDIn applies the In predicate on the "group_id" field.
func GroupIDIn(vs ...int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldGroupID, vs...))
}
// GroupIDNotIn applies the NotIn predicate on the "group_id" field.
func GroupIDNotIn(vs ...int64) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldGroupID, vs...))
}
// PublicModelEQ applies the EQ predicate on the "public_model" field.
func PublicModelEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldPublicModel, v))
}
// PublicModelNEQ applies the NEQ predicate on the "public_model" field.
func PublicModelNEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldPublicModel, v))
}
// PublicModelIn applies the In predicate on the "public_model" field.
func PublicModelIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldPublicModel, vs...))
}
// PublicModelNotIn applies the NotIn predicate on the "public_model" field.
func PublicModelNotIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldPublicModel, vs...))
}
// PublicModelGT applies the GT predicate on the "public_model" field.
func PublicModelGT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldPublicModel, v))
}
// PublicModelGTE applies the GTE predicate on the "public_model" field.
func PublicModelGTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldPublicModel, v))
}
// PublicModelLT applies the LT predicate on the "public_model" field.
func PublicModelLT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldPublicModel, v))
}
// PublicModelLTE applies the LTE predicate on the "public_model" field.
func PublicModelLTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldPublicModel, v))
}
// PublicModelContains applies the Contains predicate on the "public_model" field.
func PublicModelContains(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContains(FieldPublicModel, v))
}
// PublicModelHasPrefix applies the HasPrefix predicate on the "public_model" field.
func PublicModelHasPrefix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasPrefix(FieldPublicModel, v))
}
// PublicModelHasSuffix applies the HasSuffix predicate on the "public_model" field.
func PublicModelHasSuffix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasSuffix(FieldPublicModel, v))
}
// PublicModelEqualFold applies the EqualFold predicate on the "public_model" field.
func PublicModelEqualFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEqualFold(FieldPublicModel, v))
}
// PublicModelContainsFold applies the ContainsFold predicate on the "public_model" field.
func PublicModelContainsFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContainsFold(FieldPublicModel, v))
}
// MatchTypeEQ applies the EQ predicate on the "match_type" field.
func MatchTypeEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldMatchType, v))
}
// MatchTypeNEQ applies the NEQ predicate on the "match_type" field.
func MatchTypeNEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldMatchType, v))
}
// MatchTypeIn applies the In predicate on the "match_type" field.
func MatchTypeIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldMatchType, vs...))
}
// MatchTypeNotIn applies the NotIn predicate on the "match_type" field.
func MatchTypeNotIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldMatchType, vs...))
}
// MatchTypeGT applies the GT predicate on the "match_type" field.
func MatchTypeGT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldMatchType, v))
}
// MatchTypeGTE applies the GTE predicate on the "match_type" field.
func MatchTypeGTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldMatchType, v))
}
// MatchTypeLT applies the LT predicate on the "match_type" field.
func MatchTypeLT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldMatchType, v))
}
// MatchTypeLTE applies the LTE predicate on the "match_type" field.
func MatchTypeLTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldMatchType, v))
}
// MatchTypeContains applies the Contains predicate on the "match_type" field.
func MatchTypeContains(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContains(FieldMatchType, v))
}
// MatchTypeHasPrefix applies the HasPrefix predicate on the "match_type" field.
func MatchTypeHasPrefix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasPrefix(FieldMatchType, v))
}
// MatchTypeHasSuffix applies the HasSuffix predicate on the "match_type" field.
func MatchTypeHasSuffix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasSuffix(FieldMatchType, v))
}
// MatchTypeEqualFold applies the EqualFold predicate on the "match_type" field.
func MatchTypeEqualFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEqualFold(FieldMatchType, v))
}
// MatchTypeContainsFold applies the ContainsFold predicate on the "match_type" field.
func MatchTypeContainsFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContainsFold(FieldMatchType, v))
}
// TargetPlatformEQ applies the EQ predicate on the "target_platform" field.
func TargetPlatformEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldTargetPlatform, v))
}
// TargetPlatformNEQ applies the NEQ predicate on the "target_platform" field.
func TargetPlatformNEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldTargetPlatform, v))
}
// TargetPlatformIn applies the In predicate on the "target_platform" field.
func TargetPlatformIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldTargetPlatform, vs...))
}
// TargetPlatformNotIn applies the NotIn predicate on the "target_platform" field.
func TargetPlatformNotIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldTargetPlatform, vs...))
}
// TargetPlatformGT applies the GT predicate on the "target_platform" field.
func TargetPlatformGT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldTargetPlatform, v))
}
// TargetPlatformGTE applies the GTE predicate on the "target_platform" field.
func TargetPlatformGTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldTargetPlatform, v))
}
// TargetPlatformLT applies the LT predicate on the "target_platform" field.
func TargetPlatformLT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldTargetPlatform, v))
}
// TargetPlatformLTE applies the LTE predicate on the "target_platform" field.
func TargetPlatformLTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldTargetPlatform, v))
}
// TargetPlatformContains applies the Contains predicate on the "target_platform" field.
func TargetPlatformContains(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContains(FieldTargetPlatform, v))
}
// TargetPlatformHasPrefix applies the HasPrefix predicate on the "target_platform" field.
func TargetPlatformHasPrefix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasPrefix(FieldTargetPlatform, v))
}
// TargetPlatformHasSuffix applies the HasSuffix predicate on the "target_platform" field.
func TargetPlatformHasSuffix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasSuffix(FieldTargetPlatform, v))
}
// TargetPlatformEqualFold applies the EqualFold predicate on the "target_platform" field.
func TargetPlatformEqualFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEqualFold(FieldTargetPlatform, v))
}
// TargetPlatformContainsFold applies the ContainsFold predicate on the "target_platform" field.
func TargetPlatformContainsFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContainsFold(FieldTargetPlatform, v))
}
// UpstreamModelEQ applies the EQ predicate on the "upstream_model" field.
func UpstreamModelEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldUpstreamModel, v))
}
// UpstreamModelNEQ applies the NEQ predicate on the "upstream_model" field.
func UpstreamModelNEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldUpstreamModel, v))
}
// UpstreamModelIn applies the In predicate on the "upstream_model" field.
func UpstreamModelIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldUpstreamModel, vs...))
}
// UpstreamModelNotIn applies the NotIn predicate on the "upstream_model" field.
func UpstreamModelNotIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldUpstreamModel, vs...))
}
// UpstreamModelGT applies the GT predicate on the "upstream_model" field.
func UpstreamModelGT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldUpstreamModel, v))
}
// UpstreamModelGTE applies the GTE predicate on the "upstream_model" field.
func UpstreamModelGTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldUpstreamModel, v))
}
// UpstreamModelLT applies the LT predicate on the "upstream_model" field.
func UpstreamModelLT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldUpstreamModel, v))
}
// UpstreamModelLTE applies the LTE predicate on the "upstream_model" field.
func UpstreamModelLTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldUpstreamModel, v))
}
// UpstreamModelContains applies the Contains predicate on the "upstream_model" field.
func UpstreamModelContains(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContains(FieldUpstreamModel, v))
}
// UpstreamModelHasPrefix applies the HasPrefix predicate on the "upstream_model" field.
func UpstreamModelHasPrefix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasPrefix(FieldUpstreamModel, v))
}
// UpstreamModelHasSuffix applies the HasSuffix predicate on the "upstream_model" field.
func UpstreamModelHasSuffix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasSuffix(FieldUpstreamModel, v))
}
// UpstreamModelEqualFold applies the EqualFold predicate on the "upstream_model" field.
func UpstreamModelEqualFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEqualFold(FieldUpstreamModel, v))
}
// UpstreamModelContainsFold applies the ContainsFold predicate on the "upstream_model" field.
func UpstreamModelContainsFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContainsFold(FieldUpstreamModel, v))
}
// EndpointEQ applies the EQ predicate on the "endpoint" field.
func EndpointEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldEndpoint, v))
}
// EndpointNEQ applies the NEQ predicate on the "endpoint" field.
func EndpointNEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldEndpoint, v))
}
// EndpointIn applies the In predicate on the "endpoint" field.
func EndpointIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldEndpoint, vs...))
}
// EndpointNotIn applies the NotIn predicate on the "endpoint" field.
func EndpointNotIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldEndpoint, vs...))
}
// EndpointGT applies the GT predicate on the "endpoint" field.
func EndpointGT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldEndpoint, v))
}
// EndpointGTE applies the GTE predicate on the "endpoint" field.
func EndpointGTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldEndpoint, v))
}
// EndpointLT applies the LT predicate on the "endpoint" field.
func EndpointLT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldEndpoint, v))
}
// EndpointLTE applies the LTE predicate on the "endpoint" field.
func EndpointLTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldEndpoint, v))
}
// EndpointContains applies the Contains predicate on the "endpoint" field.
func EndpointContains(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContains(FieldEndpoint, v))
}
// EndpointHasPrefix applies the HasPrefix predicate on the "endpoint" field.
func EndpointHasPrefix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasPrefix(FieldEndpoint, v))
}
// EndpointHasSuffix applies the HasSuffix predicate on the "endpoint" field.
func EndpointHasSuffix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasSuffix(FieldEndpoint, v))
}
// EndpointEqualFold applies the EqualFold predicate on the "endpoint" field.
func EndpointEqualFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEqualFold(FieldEndpoint, v))
}
// EndpointContainsFold applies the ContainsFold predicate on the "endpoint" field.
func EndpointContainsFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContainsFold(FieldEndpoint, v))
}
// PriorityEQ applies the EQ predicate on the "priority" field.
func PriorityEQ(v int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldPriority, v))
}
// PriorityNEQ applies the NEQ predicate on the "priority" field.
func PriorityNEQ(v int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldPriority, v))
}
// PriorityIn applies the In predicate on the "priority" field.
func PriorityIn(vs ...int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldPriority, vs...))
}
// PriorityNotIn applies the NotIn predicate on the "priority" field.
func PriorityNotIn(vs ...int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldPriority, vs...))
}
// PriorityGT applies the GT predicate on the "priority" field.
func PriorityGT(v int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldPriority, v))
}
// PriorityGTE applies the GTE predicate on the "priority" field.
func PriorityGTE(v int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldPriority, v))
}
// PriorityLT applies the LT predicate on the "priority" field.
func PriorityLT(v int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldPriority, v))
}
// PriorityLTE applies the LTE predicate on the "priority" field.
func PriorityLTE(v int) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldPriority, v))
}
// EnabledEQ applies the EQ predicate on the "enabled" field.
func EnabledEQ(v bool) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldEnabled, v))
}
// EnabledNEQ applies the NEQ predicate on the "enabled" field.
func EnabledNEQ(v bool) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldEnabled, v))
}
// NotesEQ applies the EQ predicate on the "notes" field.
func NotesEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEQ(FieldNotes, v))
}
// NotesNEQ applies the NEQ predicate on the "notes" field.
func NotesNEQ(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNEQ(FieldNotes, v))
}
// NotesIn applies the In predicate on the "notes" field.
func NotesIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIn(FieldNotes, vs...))
}
// NotesNotIn applies the NotIn predicate on the "notes" field.
func NotesNotIn(vs ...string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotIn(FieldNotes, vs...))
}
// NotesGT applies the GT predicate on the "notes" field.
func NotesGT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGT(FieldNotes, v))
}
// NotesGTE applies the GTE predicate on the "notes" field.
func NotesGTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldGTE(FieldNotes, v))
}
// NotesLT applies the LT predicate on the "notes" field.
func NotesLT(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLT(FieldNotes, v))
}
// NotesLTE applies the LTE predicate on the "notes" field.
func NotesLTE(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldLTE(FieldNotes, v))
}
// NotesContains applies the Contains predicate on the "notes" field.
func NotesContains(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContains(FieldNotes, v))
}
// NotesHasPrefix applies the HasPrefix predicate on the "notes" field.
func NotesHasPrefix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasPrefix(FieldNotes, v))
}
// NotesHasSuffix applies the HasSuffix predicate on the "notes" field.
func NotesHasSuffix(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldHasSuffix(FieldNotes, v))
}
// NotesIsNil applies the IsNil predicate on the "notes" field.
func NotesIsNil() predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldIsNull(FieldNotes))
}
// NotesNotNil applies the NotNil predicate on the "notes" field.
func NotesNotNil() predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldNotNull(FieldNotes))
}
// NotesEqualFold applies the EqualFold predicate on the "notes" field.
func NotesEqualFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldEqualFold(FieldNotes, v))
}
// NotesContainsFold applies the ContainsFold predicate on the "notes" field.
func NotesContainsFold(v string) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.FieldContainsFold(FieldNotes, v))
}
// HasGroup applies the HasEdge predicate on the "group" edge.
func HasGroup() predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.M2O, false, GroupTable, GroupColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasGroupWith applies the HasEdge predicate on the "group" edge with a given conditions (other predicates).
func HasGroupWith(preds ...predicate.Group) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(func(s *sql.Selector) {
step := newGroupStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// And groups predicates with the AND operator between them.
func And(predicates ...predicate.CompositeModelRoute) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.AndPredicates(predicates...))
}
// Or groups predicates with the OR operator between them.
func Or(predicates ...predicate.CompositeModelRoute) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.OrPredicates(predicates...))
}
// Not applies the not operator on the given predicate.
func Not(p predicate.CompositeModelRoute) predicate.CompositeModelRoute {
return predicate.CompositeModelRoute(sql.NotPredicates(p))
}
File diff suppressed because it is too large Load Diff
+88
View File
@@ -0,0 +1,88 @@
// Code generated by ent, DO NOT EDIT.
package ent
import (
"context"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/compositemodelroute"
"github.com/Wei-Shaw/sub2api/ent/predicate"
)
// CompositeModelRouteDelete is the builder for deleting a CompositeModelRoute entity.
type CompositeModelRouteDelete struct {
config
hooks []Hook
mutation *CompositeModelRouteMutation
}
// Where appends a list predicates to the CompositeModelRouteDelete builder.
func (_d *CompositeModelRouteDelete) Where(ps ...predicate.CompositeModelRoute) *CompositeModelRouteDelete {
_d.mutation.Where(ps...)
return _d
}
// Exec executes the deletion query and returns how many vertices were deleted.
func (_d *CompositeModelRouteDelete) Exec(ctx context.Context) (int, error) {
return withHooks(ctx, _d.sqlExec, _d.mutation, _d.hooks)
}
// ExecX is like Exec, but panics if an error occurs.
func (_d *CompositeModelRouteDelete) ExecX(ctx context.Context) int {
n, err := _d.Exec(ctx)
if err != nil {
panic(err)
}
return n
}
func (_d *CompositeModelRouteDelete) sqlExec(ctx context.Context) (int, error) {
_spec := sqlgraph.NewDeleteSpec(compositemodelroute.Table, sqlgraph.NewFieldSpec(compositemodelroute.FieldID, field.TypeInt64))
if ps := _d.mutation.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
affected, err := sqlgraph.DeleteNodes(ctx, _d.driver, _spec)
if err != nil && sqlgraph.IsConstraintError(err) {
err = &ConstraintError{msg: err.Error(), wrap: err}
}
_d.mutation.done = true
return affected, err
}
// CompositeModelRouteDeleteOne is the builder for deleting a single CompositeModelRoute entity.
type CompositeModelRouteDeleteOne struct {
_d *CompositeModelRouteDelete
}
// Where appends a list predicates to the CompositeModelRouteDelete builder.
func (_d *CompositeModelRouteDeleteOne) Where(ps ...predicate.CompositeModelRoute) *CompositeModelRouteDeleteOne {
_d._d.mutation.Where(ps...)
return _d
}
// Exec executes the deletion query.
func (_d *CompositeModelRouteDeleteOne) Exec(ctx context.Context) error {
n, err := _d._d.Exec(ctx)
switch {
case err != nil:
return err
case n == 0:
return &NotFoundError{compositemodelroute.Label}
default:
return nil
}
}
// ExecX is like Exec, but panics if an error occurs.
func (_d *CompositeModelRouteDeleteOne) ExecX(ctx context.Context) {
if err := _d.Exec(ctx); err != nil {
panic(err)
}
}
+643
View File
@@ -0,0 +1,643 @@
// Code generated by ent, DO NOT EDIT.
package ent
import (
"context"
"fmt"
"math"
"entgo.io/ent"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/compositemodelroute"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
)
// CompositeModelRouteQuery is the builder for querying CompositeModelRoute entities.
type CompositeModelRouteQuery struct {
config
ctx *QueryContext
order []compositemodelroute.OrderOption
inters []Interceptor
predicates []predicate.CompositeModelRoute
withGroup *GroupQuery
modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
}
// Where adds a new predicate for the CompositeModelRouteQuery builder.
func (_q *CompositeModelRouteQuery) Where(ps ...predicate.CompositeModelRoute) *CompositeModelRouteQuery {
_q.predicates = append(_q.predicates, ps...)
return _q
}
// Limit the number of records to be returned by this query.
func (_q *CompositeModelRouteQuery) Limit(limit int) *CompositeModelRouteQuery {
_q.ctx.Limit = &limit
return _q
}
// Offset to start from.
func (_q *CompositeModelRouteQuery) Offset(offset int) *CompositeModelRouteQuery {
_q.ctx.Offset = &offset
return _q
}
// Unique configures the query builder to filter duplicate records on query.
// By default, unique is set to true, and can be disabled using this method.
func (_q *CompositeModelRouteQuery) Unique(unique bool) *CompositeModelRouteQuery {
_q.ctx.Unique = &unique
return _q
}
// Order specifies how the records should be ordered.
func (_q *CompositeModelRouteQuery) Order(o ...compositemodelroute.OrderOption) *CompositeModelRouteQuery {
_q.order = append(_q.order, o...)
return _q
}
// QueryGroup chains the current query on the "group" edge.
func (_q *CompositeModelRouteQuery) QueryGroup() *GroupQuery {
query := (&GroupClient{config: _q.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := _q.prepareQuery(ctx); err != nil {
return nil, err
}
selector := _q.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(compositemodelroute.Table, compositemodelroute.FieldID, selector),
sqlgraph.To(group.Table, group.FieldID),
sqlgraph.Edge(sqlgraph.M2O, false, compositemodelroute.GroupTable, compositemodelroute.GroupColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
}
return query
}
// First returns the first CompositeModelRoute entity from the query.
// Returns a *NotFoundError when no CompositeModelRoute was found.
func (_q *CompositeModelRouteQuery) First(ctx context.Context) (*CompositeModelRoute, error) {
nodes, err := _q.Limit(1).All(setContextOp(ctx, _q.ctx, ent.OpQueryFirst))
if err != nil {
return nil, err
}
if len(nodes) == 0 {
return nil, &NotFoundError{compositemodelroute.Label}
}
return nodes[0], nil
}
// FirstX is like First, but panics if an error occurs.
func (_q *CompositeModelRouteQuery) FirstX(ctx context.Context) *CompositeModelRoute {
node, err := _q.First(ctx)
if err != nil && !IsNotFound(err) {
panic(err)
}
return node
}
// FirstID returns the first CompositeModelRoute ID from the query.
// Returns a *NotFoundError when no CompositeModelRoute ID was found.
func (_q *CompositeModelRouteQuery) FirstID(ctx context.Context) (id int64, err error) {
var ids []int64
if ids, err = _q.Limit(1).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryFirstID)); err != nil {
return
}
if len(ids) == 0 {
err = &NotFoundError{compositemodelroute.Label}
return
}
return ids[0], nil
}
// FirstIDX is like FirstID, but panics if an error occurs.
func (_q *CompositeModelRouteQuery) FirstIDX(ctx context.Context) int64 {
id, err := _q.FirstID(ctx)
if err != nil && !IsNotFound(err) {
panic(err)
}
return id
}
// Only returns a single CompositeModelRoute entity found by the query, ensuring it only returns one.
// Returns a *NotSingularError when more than one CompositeModelRoute entity is found.
// Returns a *NotFoundError when no CompositeModelRoute entities are found.
func (_q *CompositeModelRouteQuery) Only(ctx context.Context) (*CompositeModelRoute, error) {
nodes, err := _q.Limit(2).All(setContextOp(ctx, _q.ctx, ent.OpQueryOnly))
if err != nil {
return nil, err
}
switch len(nodes) {
case 1:
return nodes[0], nil
case 0:
return nil, &NotFoundError{compositemodelroute.Label}
default:
return nil, &NotSingularError{compositemodelroute.Label}
}
}
// OnlyX is like Only, but panics if an error occurs.
func (_q *CompositeModelRouteQuery) OnlyX(ctx context.Context) *CompositeModelRoute {
node, err := _q.Only(ctx)
if err != nil {
panic(err)
}
return node
}
// OnlyID is like Only, but returns the only CompositeModelRoute ID in the query.
// Returns a *NotSingularError when more than one CompositeModelRoute ID is found.
// Returns a *NotFoundError when no entities are found.
func (_q *CompositeModelRouteQuery) OnlyID(ctx context.Context) (id int64, err error) {
var ids []int64
if ids, err = _q.Limit(2).IDs(setContextOp(ctx, _q.ctx, ent.OpQueryOnlyID)); err != nil {
return
}
switch len(ids) {
case 1:
id = ids[0]
case 0:
err = &NotFoundError{compositemodelroute.Label}
default:
err = &NotSingularError{compositemodelroute.Label}
}
return
}
// OnlyIDX is like OnlyID, but panics if an error occurs.
func (_q *CompositeModelRouteQuery) OnlyIDX(ctx context.Context) int64 {
id, err := _q.OnlyID(ctx)
if err != nil {
panic(err)
}
return id
}
// All executes the query and returns a list of CompositeModelRoutes.
func (_q *CompositeModelRouteQuery) All(ctx context.Context) ([]*CompositeModelRoute, error) {
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryAll)
if err := _q.prepareQuery(ctx); err != nil {
return nil, err
}
qr := querierAll[[]*CompositeModelRoute, *CompositeModelRouteQuery]()
return withInterceptors[[]*CompositeModelRoute](ctx, _q, qr, _q.inters)
}
// AllX is like All, but panics if an error occurs.
func (_q *CompositeModelRouteQuery) AllX(ctx context.Context) []*CompositeModelRoute {
nodes, err := _q.All(ctx)
if err != nil {
panic(err)
}
return nodes
}
// IDs executes the query and returns a list of CompositeModelRoute IDs.
func (_q *CompositeModelRouteQuery) IDs(ctx context.Context) (ids []int64, err error) {
if _q.ctx.Unique == nil && _q.path != nil {
_q.Unique(true)
}
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryIDs)
if err = _q.Select(compositemodelroute.FieldID).Scan(ctx, &ids); err != nil {
return nil, err
}
return ids, nil
}
// IDsX is like IDs, but panics if an error occurs.
func (_q *CompositeModelRouteQuery) IDsX(ctx context.Context) []int64 {
ids, err := _q.IDs(ctx)
if err != nil {
panic(err)
}
return ids
}
// Count returns the count of the given query.
func (_q *CompositeModelRouteQuery) Count(ctx context.Context) (int, error) {
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryCount)
if err := _q.prepareQuery(ctx); err != nil {
return 0, err
}
return withInterceptors[int](ctx, _q, querierCount[*CompositeModelRouteQuery](), _q.inters)
}
// CountX is like Count, but panics if an error occurs.
func (_q *CompositeModelRouteQuery) CountX(ctx context.Context) int {
count, err := _q.Count(ctx)
if err != nil {
panic(err)
}
return count
}
// Exist returns true if the query has elements in the graph.
func (_q *CompositeModelRouteQuery) Exist(ctx context.Context) (bool, error) {
ctx = setContextOp(ctx, _q.ctx, ent.OpQueryExist)
switch _, err := _q.FirstID(ctx); {
case IsNotFound(err):
return false, nil
case err != nil:
return false, fmt.Errorf("ent: check existence: %w", err)
default:
return true, nil
}
}
// ExistX is like Exist, but panics if an error occurs.
func (_q *CompositeModelRouteQuery) ExistX(ctx context.Context) bool {
exist, err := _q.Exist(ctx)
if err != nil {
panic(err)
}
return exist
}
// Clone returns a duplicate of the CompositeModelRouteQuery builder, including all associated steps. It can be
// used to prepare common query builders and use them differently after the clone is made.
func (_q *CompositeModelRouteQuery) Clone() *CompositeModelRouteQuery {
if _q == nil {
return nil
}
return &CompositeModelRouteQuery{
config: _q.config,
ctx: _q.ctx.Clone(),
order: append([]compositemodelroute.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.CompositeModelRoute{}, _q.predicates...),
withGroup: _q.withGroup.Clone(),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
}
}
// WithGroup tells the query-builder to eager-load the nodes that are connected to
// the "group" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *CompositeModelRouteQuery) WithGroup(opts ...func(*GroupQuery)) *CompositeModelRouteQuery {
query := (&GroupClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withGroup = query
return _q
}
// GroupBy is used to group vertices by one or more fields/columns.
// It is often used with aggregate functions, like: count, max, mean, min, sum.
//
// Example:
//
// var v []struct {
// CreatedAt time.Time `json:"created_at,omitempty"`
// Count int `json:"count,omitempty"`
// }
//
// client.CompositeModelRoute.Query().
// GroupBy(compositemodelroute.FieldCreatedAt).
// Aggregate(ent.Count()).
// Scan(ctx, &v)
func (_q *CompositeModelRouteQuery) GroupBy(field string, fields ...string) *CompositeModelRouteGroupBy {
_q.ctx.Fields = append([]string{field}, fields...)
grbuild := &CompositeModelRouteGroupBy{build: _q}
grbuild.flds = &_q.ctx.Fields
grbuild.label = compositemodelroute.Label
grbuild.scan = grbuild.Scan
return grbuild
}
// Select allows the selection one or more fields/columns for the given query,
// instead of selecting all fields in the entity.
//
// Example:
//
// var v []struct {
// CreatedAt time.Time `json:"created_at,omitempty"`
// }
//
// client.CompositeModelRoute.Query().
// Select(compositemodelroute.FieldCreatedAt).
// Scan(ctx, &v)
func (_q *CompositeModelRouteQuery) Select(fields ...string) *CompositeModelRouteSelect {
_q.ctx.Fields = append(_q.ctx.Fields, fields...)
sbuild := &CompositeModelRouteSelect{CompositeModelRouteQuery: _q}
sbuild.label = compositemodelroute.Label
sbuild.flds, sbuild.scan = &_q.ctx.Fields, sbuild.Scan
return sbuild
}
// Aggregate returns a CompositeModelRouteSelect configured with the given aggregations.
func (_q *CompositeModelRouteQuery) Aggregate(fns ...AggregateFunc) *CompositeModelRouteSelect {
return _q.Select().Aggregate(fns...)
}
func (_q *CompositeModelRouteQuery) prepareQuery(ctx context.Context) error {
for _, inter := range _q.inters {
if inter == nil {
return fmt.Errorf("ent: uninitialized interceptor (forgotten import ent/runtime?)")
}
if trv, ok := inter.(Traverser); ok {
if err := trv.Traverse(ctx, _q); err != nil {
return err
}
}
}
for _, f := range _q.ctx.Fields {
if !compositemodelroute.ValidColumn(f) {
return &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
}
}
if _q.path != nil {
prev, err := _q.path(ctx)
if err != nil {
return err
}
_q.sql = prev
}
return nil
}
func (_q *CompositeModelRouteQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*CompositeModelRoute, error) {
var (
nodes = []*CompositeModelRoute{}
_spec = _q.querySpec()
loadedTypes = [1]bool{
_q.withGroup != nil,
}
)
_spec.ScanValues = func(columns []string) ([]any, error) {
return (*CompositeModelRoute).scanValues(nil, columns)
}
_spec.Assign = func(columns []string, values []any) error {
node := &CompositeModelRoute{config: _q.config}
nodes = append(nodes, node)
node.Edges.loadedTypes = loadedTypes
return node.assignValues(columns, values)
}
if len(_q.modifiers) > 0 {
_spec.Modifiers = _q.modifiers
}
for i := range hooks {
hooks[i](ctx, _spec)
}
if err := sqlgraph.QueryNodes(ctx, _q.driver, _spec); err != nil {
return nil, err
}
if len(nodes) == 0 {
return nodes, nil
}
if query := _q.withGroup; query != nil {
if err := _q.loadGroup(ctx, query, nodes, nil,
func(n *CompositeModelRoute, e *Group) { n.Edges.Group = e }); err != nil {
return nil, err
}
}
return nodes, nil
}
func (_q *CompositeModelRouteQuery) loadGroup(ctx context.Context, query *GroupQuery, nodes []*CompositeModelRoute, init func(*CompositeModelRoute), assign func(*CompositeModelRoute, *Group)) error {
ids := make([]int64, 0, len(nodes))
nodeids := make(map[int64][]*CompositeModelRoute)
for i := range nodes {
fk := nodes[i].GroupID
if _, ok := nodeids[fk]; !ok {
ids = append(ids, fk)
}
nodeids[fk] = append(nodeids[fk], nodes[i])
}
if len(ids) == 0 {
return nil
}
query.Where(group.IDIn(ids...))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
nodes, ok := nodeids[n.ID]
if !ok {
return fmt.Errorf(`unexpected foreign-key "group_id" returned %v`, n.ID)
}
for i := range nodes {
assign(nodes[i], n)
}
}
return nil
}
func (_q *CompositeModelRouteQuery) sqlCount(ctx context.Context) (int, error) {
_spec := _q.querySpec()
if len(_q.modifiers) > 0 {
_spec.Modifiers = _q.modifiers
}
_spec.Node.Columns = _q.ctx.Fields
if len(_q.ctx.Fields) > 0 {
_spec.Unique = _q.ctx.Unique != nil && *_q.ctx.Unique
}
return sqlgraph.CountNodes(ctx, _q.driver, _spec)
}
func (_q *CompositeModelRouteQuery) querySpec() *sqlgraph.QuerySpec {
_spec := sqlgraph.NewQuerySpec(compositemodelroute.Table, compositemodelroute.Columns, sqlgraph.NewFieldSpec(compositemodelroute.FieldID, field.TypeInt64))
_spec.From = _q.sql
if unique := _q.ctx.Unique; unique != nil {
_spec.Unique = *unique
} else if _q.path != nil {
_spec.Unique = true
}
if fields := _q.ctx.Fields; len(fields) > 0 {
_spec.Node.Columns = make([]string, 0, len(fields))
_spec.Node.Columns = append(_spec.Node.Columns, compositemodelroute.FieldID)
for i := range fields {
if fields[i] != compositemodelroute.FieldID {
_spec.Node.Columns = append(_spec.Node.Columns, fields[i])
}
}
if _q.withGroup != nil {
_spec.Node.AddColumnOnce(compositemodelroute.FieldGroupID)
}
}
if ps := _q.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
if limit := _q.ctx.Limit; limit != nil {
_spec.Limit = *limit
}
if offset := _q.ctx.Offset; offset != nil {
_spec.Offset = *offset
}
if ps := _q.order; len(ps) > 0 {
_spec.Order = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
return _spec
}
func (_q *CompositeModelRouteQuery) sqlQuery(ctx context.Context) *sql.Selector {
builder := sql.Dialect(_q.driver.Dialect())
t1 := builder.Table(compositemodelroute.Table)
columns := _q.ctx.Fields
if len(columns) == 0 {
columns = compositemodelroute.Columns
}
selector := builder.Select(t1.Columns(columns...)...).From(t1)
if _q.sql != nil {
selector = _q.sql
selector.Select(selector.Columns(columns...)...)
}
if _q.ctx.Unique != nil && *_q.ctx.Unique {
selector.Distinct()
}
for _, m := range _q.modifiers {
m(selector)
}
for _, p := range _q.predicates {
p(selector)
}
for _, p := range _q.order {
p(selector)
}
if offset := _q.ctx.Offset; offset != nil {
// limit is mandatory for offset clause. We start
// with default value, and override it below if needed.
selector.Offset(*offset).Limit(math.MaxInt32)
}
if limit := _q.ctx.Limit; limit != nil {
selector.Limit(*limit)
}
return selector
}
// ForUpdate locks the selected rows against concurrent updates, and prevent them from being
// updated, deleted or "selected ... for update" by other sessions, until the transaction is
// either committed or rolled-back.
func (_q *CompositeModelRouteQuery) ForUpdate(opts ...sql.LockOption) *CompositeModelRouteQuery {
if _q.driver.Dialect() == dialect.Postgres {
_q.Unique(false)
}
_q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
s.ForUpdate(opts...)
})
return _q
}
// ForShare behaves similarly to ForUpdate, except that it acquires a shared mode lock
// on any rows that are read. Other sessions can read the rows, but cannot modify them
// until your transaction commits.
func (_q *CompositeModelRouteQuery) ForShare(opts ...sql.LockOption) *CompositeModelRouteQuery {
if _q.driver.Dialect() == dialect.Postgres {
_q.Unique(false)
}
_q.modifiers = append(_q.modifiers, func(s *sql.Selector) {
s.ForShare(opts...)
})
return _q
}
// CompositeModelRouteGroupBy is the group-by builder for CompositeModelRoute entities.
type CompositeModelRouteGroupBy struct {
selector
build *CompositeModelRouteQuery
}
// Aggregate adds the given aggregation functions to the group-by query.
func (_g *CompositeModelRouteGroupBy) Aggregate(fns ...AggregateFunc) *CompositeModelRouteGroupBy {
_g.fns = append(_g.fns, fns...)
return _g
}
// Scan applies the selector query and scans the result into the given value.
func (_g *CompositeModelRouteGroupBy) Scan(ctx context.Context, v any) error {
ctx = setContextOp(ctx, _g.build.ctx, ent.OpQueryGroupBy)
if err := _g.build.prepareQuery(ctx); err != nil {
return err
}
return scanWithInterceptors[*CompositeModelRouteQuery, *CompositeModelRouteGroupBy](ctx, _g.build, _g, _g.build.inters, v)
}
func (_g *CompositeModelRouteGroupBy) sqlScan(ctx context.Context, root *CompositeModelRouteQuery, v any) error {
selector := root.sqlQuery(ctx).Select()
aggregation := make([]string, 0, len(_g.fns))
for _, fn := range _g.fns {
aggregation = append(aggregation, fn(selector))
}
if len(selector.SelectedColumns()) == 0 {
columns := make([]string, 0, len(*_g.flds)+len(_g.fns))
for _, f := range *_g.flds {
columns = append(columns, selector.C(f))
}
columns = append(columns, aggregation...)
selector.Select(columns...)
}
selector.GroupBy(selector.Columns(*_g.flds...)...)
if err := selector.Err(); err != nil {
return err
}
rows := &sql.Rows{}
query, args := selector.Query()
if err := _g.build.driver.Query(ctx, query, args, rows); err != nil {
return err
}
defer rows.Close()
return sql.ScanSlice(rows, v)
}
// CompositeModelRouteSelect is the builder for selecting fields of CompositeModelRoute entities.
type CompositeModelRouteSelect struct {
*CompositeModelRouteQuery
selector
}
// Aggregate adds the given aggregation functions to the selector query.
func (_s *CompositeModelRouteSelect) Aggregate(fns ...AggregateFunc) *CompositeModelRouteSelect {
_s.fns = append(_s.fns, fns...)
return _s
}
// Scan applies the selector query and scans the result into the given value.
func (_s *CompositeModelRouteSelect) Scan(ctx context.Context, v any) error {
ctx = setContextOp(ctx, _s.ctx, ent.OpQuerySelect)
if err := _s.prepareQuery(ctx); err != nil {
return err
}
return scanWithInterceptors[*CompositeModelRouteQuery, *CompositeModelRouteSelect](ctx, _s.CompositeModelRouteQuery, _s, _s.inters, v)
}
func (_s *CompositeModelRouteSelect) sqlScan(ctx context.Context, root *CompositeModelRouteQuery, v any) error {
selector := root.sqlQuery(ctx)
aggregation := make([]string, 0, len(_s.fns))
for _, fn := range _s.fns {
aggregation = append(aggregation, fn(selector))
}
switch n := len(*_s.selector.flds); {
case n == 0 && len(aggregation) > 0:
selector.Select(aggregation...)
case n != 0 && len(aggregation) > 0:
selector.AppendSelect(aggregation...)
}
rows := &sql.Rows{}
query, args := selector.Query()
if err := _s.driver.Query(ctx, query, args, rows); err != nil {
return err
}
defer rows.Close()
return sql.ScanSlice(rows, v)
}
+767
View File
@@ -0,0 +1,767 @@
// Code generated by ent, DO NOT EDIT.
package ent
import (
"context"
"errors"
"fmt"
"time"
"entgo.io/ent/dialect/sql"
"entgo.io/ent/dialect/sql/sqlgraph"
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/compositemodelroute"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/predicate"
)
// CompositeModelRouteUpdate is the builder for updating CompositeModelRoute entities.
type CompositeModelRouteUpdate struct {
config
hooks []Hook
mutation *CompositeModelRouteMutation
}
// Where appends a list predicates to the CompositeModelRouteUpdate builder.
func (_u *CompositeModelRouteUpdate) Where(ps ...predicate.CompositeModelRoute) *CompositeModelRouteUpdate {
_u.mutation.Where(ps...)
return _u
}
// SetUpdatedAt sets the "updated_at" field.
func (_u *CompositeModelRouteUpdate) SetUpdatedAt(v time.Time) *CompositeModelRouteUpdate {
_u.mutation.SetUpdatedAt(v)
return _u
}
// SetDeletedAt sets the "deleted_at" field.
func (_u *CompositeModelRouteUpdate) SetDeletedAt(v time.Time) *CompositeModelRouteUpdate {
_u.mutation.SetDeletedAt(v)
return _u
}
// SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillableDeletedAt(v *time.Time) *CompositeModelRouteUpdate {
if v != nil {
_u.SetDeletedAt(*v)
}
return _u
}
// ClearDeletedAt clears the value of the "deleted_at" field.
func (_u *CompositeModelRouteUpdate) ClearDeletedAt() *CompositeModelRouteUpdate {
_u.mutation.ClearDeletedAt()
return _u
}
// SetGroupID sets the "group_id" field.
func (_u *CompositeModelRouteUpdate) SetGroupID(v int64) *CompositeModelRouteUpdate {
_u.mutation.SetGroupID(v)
return _u
}
// SetNillableGroupID sets the "group_id" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillableGroupID(v *int64) *CompositeModelRouteUpdate {
if v != nil {
_u.SetGroupID(*v)
}
return _u
}
// SetPublicModel sets the "public_model" field.
func (_u *CompositeModelRouteUpdate) SetPublicModel(v string) *CompositeModelRouteUpdate {
_u.mutation.SetPublicModel(v)
return _u
}
// SetNillablePublicModel sets the "public_model" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillablePublicModel(v *string) *CompositeModelRouteUpdate {
if v != nil {
_u.SetPublicModel(*v)
}
return _u
}
// SetMatchType sets the "match_type" field.
func (_u *CompositeModelRouteUpdate) SetMatchType(v string) *CompositeModelRouteUpdate {
_u.mutation.SetMatchType(v)
return _u
}
// SetNillableMatchType sets the "match_type" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillableMatchType(v *string) *CompositeModelRouteUpdate {
if v != nil {
_u.SetMatchType(*v)
}
return _u
}
// SetTargetPlatform sets the "target_platform" field.
func (_u *CompositeModelRouteUpdate) SetTargetPlatform(v string) *CompositeModelRouteUpdate {
_u.mutation.SetTargetPlatform(v)
return _u
}
// SetNillableTargetPlatform sets the "target_platform" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillableTargetPlatform(v *string) *CompositeModelRouteUpdate {
if v != nil {
_u.SetTargetPlatform(*v)
}
return _u
}
// SetUpstreamModel sets the "upstream_model" field.
func (_u *CompositeModelRouteUpdate) SetUpstreamModel(v string) *CompositeModelRouteUpdate {
_u.mutation.SetUpstreamModel(v)
return _u
}
// SetNillableUpstreamModel sets the "upstream_model" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillableUpstreamModel(v *string) *CompositeModelRouteUpdate {
if v != nil {
_u.SetUpstreamModel(*v)
}
return _u
}
// SetEndpoint sets the "endpoint" field.
func (_u *CompositeModelRouteUpdate) SetEndpoint(v string) *CompositeModelRouteUpdate {
_u.mutation.SetEndpoint(v)
return _u
}
// SetNillableEndpoint sets the "endpoint" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillableEndpoint(v *string) *CompositeModelRouteUpdate {
if v != nil {
_u.SetEndpoint(*v)
}
return _u
}
// SetPriority sets the "priority" field.
func (_u *CompositeModelRouteUpdate) SetPriority(v int) *CompositeModelRouteUpdate {
_u.mutation.ResetPriority()
_u.mutation.SetPriority(v)
return _u
}
// SetNillablePriority sets the "priority" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillablePriority(v *int) *CompositeModelRouteUpdate {
if v != nil {
_u.SetPriority(*v)
}
return _u
}
// AddPriority adds value to the "priority" field.
func (_u *CompositeModelRouteUpdate) AddPriority(v int) *CompositeModelRouteUpdate {
_u.mutation.AddPriority(v)
return _u
}
// SetEnabled sets the "enabled" field.
func (_u *CompositeModelRouteUpdate) SetEnabled(v bool) *CompositeModelRouteUpdate {
_u.mutation.SetEnabled(v)
return _u
}
// SetNillableEnabled sets the "enabled" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillableEnabled(v *bool) *CompositeModelRouteUpdate {
if v != nil {
_u.SetEnabled(*v)
}
return _u
}
// SetNotes sets the "notes" field.
func (_u *CompositeModelRouteUpdate) SetNotes(v string) *CompositeModelRouteUpdate {
_u.mutation.SetNotes(v)
return _u
}
// SetNillableNotes sets the "notes" field if the given value is not nil.
func (_u *CompositeModelRouteUpdate) SetNillableNotes(v *string) *CompositeModelRouteUpdate {
if v != nil {
_u.SetNotes(*v)
}
return _u
}
// ClearNotes clears the value of the "notes" field.
func (_u *CompositeModelRouteUpdate) ClearNotes() *CompositeModelRouteUpdate {
_u.mutation.ClearNotes()
return _u
}
// SetGroup sets the "group" edge to the Group entity.
func (_u *CompositeModelRouteUpdate) SetGroup(v *Group) *CompositeModelRouteUpdate {
return _u.SetGroupID(v.ID)
}
// Mutation returns the CompositeModelRouteMutation object of the builder.
func (_u *CompositeModelRouteUpdate) Mutation() *CompositeModelRouteMutation {
return _u.mutation
}
// ClearGroup clears the "group" edge to the Group entity.
func (_u *CompositeModelRouteUpdate) ClearGroup() *CompositeModelRouteUpdate {
_u.mutation.ClearGroup()
return _u
}
// Save executes the query and returns the number of nodes affected by the update operation.
func (_u *CompositeModelRouteUpdate) Save(ctx context.Context) (int, error) {
if err := _u.defaults(); err != nil {
return 0, err
}
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
}
// SaveX is like Save, but panics if an error occurs.
func (_u *CompositeModelRouteUpdate) SaveX(ctx context.Context) int {
affected, err := _u.Save(ctx)
if err != nil {
panic(err)
}
return affected
}
// Exec executes the query.
func (_u *CompositeModelRouteUpdate) Exec(ctx context.Context) error {
_, err := _u.Save(ctx)
return err
}
// ExecX is like Exec, but panics if an error occurs.
func (_u *CompositeModelRouteUpdate) ExecX(ctx context.Context) {
if err := _u.Exec(ctx); err != nil {
panic(err)
}
}
// defaults sets the default values of the builder before save.
func (_u *CompositeModelRouteUpdate) defaults() error {
if _, ok := _u.mutation.UpdatedAt(); !ok {
if compositemodelroute.UpdateDefaultUpdatedAt == nil {
return fmt.Errorf("ent: uninitialized compositemodelroute.UpdateDefaultUpdatedAt (forgotten import ent/runtime?)")
}
v := compositemodelroute.UpdateDefaultUpdatedAt()
_u.mutation.SetUpdatedAt(v)
}
return nil
}
// check runs all checks and user-defined validators on the builder.
func (_u *CompositeModelRouteUpdate) check() error {
if v, ok := _u.mutation.PublicModel(); ok {
if err := compositemodelroute.PublicModelValidator(v); err != nil {
return &ValidationError{Name: "public_model", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.public_model": %w`, err)}
}
}
if v, ok := _u.mutation.MatchType(); ok {
if err := compositemodelroute.MatchTypeValidator(v); err != nil {
return &ValidationError{Name: "match_type", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.match_type": %w`, err)}
}
}
if v, ok := _u.mutation.TargetPlatform(); ok {
if err := compositemodelroute.TargetPlatformValidator(v); err != nil {
return &ValidationError{Name: "target_platform", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.target_platform": %w`, err)}
}
}
if v, ok := _u.mutation.UpstreamModel(); ok {
if err := compositemodelroute.UpstreamModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.upstream_model": %w`, err)}
}
}
if v, ok := _u.mutation.Endpoint(); ok {
if err := compositemodelroute.EndpointValidator(v); err != nil {
return &ValidationError{Name: "endpoint", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.endpoint": %w`, err)}
}
}
if _u.mutation.GroupCleared() && len(_u.mutation.GroupIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "CompositeModelRoute.group"`)
}
return nil
}
func (_u *CompositeModelRouteUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if err := _u.check(); err != nil {
return _node, err
}
_spec := sqlgraph.NewUpdateSpec(compositemodelroute.Table, compositemodelroute.Columns, sqlgraph.NewFieldSpec(compositemodelroute.FieldID, field.TypeInt64))
if ps := _u.mutation.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
if value, ok := _u.mutation.UpdatedAt(); ok {
_spec.SetField(compositemodelroute.FieldUpdatedAt, field.TypeTime, value)
}
if value, ok := _u.mutation.DeletedAt(); ok {
_spec.SetField(compositemodelroute.FieldDeletedAt, field.TypeTime, value)
}
if _u.mutation.DeletedAtCleared() {
_spec.ClearField(compositemodelroute.FieldDeletedAt, field.TypeTime)
}
if value, ok := _u.mutation.PublicModel(); ok {
_spec.SetField(compositemodelroute.FieldPublicModel, field.TypeString, value)
}
if value, ok := _u.mutation.MatchType(); ok {
_spec.SetField(compositemodelroute.FieldMatchType, field.TypeString, value)
}
if value, ok := _u.mutation.TargetPlatform(); ok {
_spec.SetField(compositemodelroute.FieldTargetPlatform, field.TypeString, value)
}
if value, ok := _u.mutation.UpstreamModel(); ok {
_spec.SetField(compositemodelroute.FieldUpstreamModel, field.TypeString, value)
}
if value, ok := _u.mutation.Endpoint(); ok {
_spec.SetField(compositemodelroute.FieldEndpoint, field.TypeString, value)
}
if value, ok := _u.mutation.Priority(); ok {
_spec.SetField(compositemodelroute.FieldPriority, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedPriority(); ok {
_spec.AddField(compositemodelroute.FieldPriority, field.TypeInt, value)
}
if value, ok := _u.mutation.Enabled(); ok {
_spec.SetField(compositemodelroute.FieldEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.Notes(); ok {
_spec.SetField(compositemodelroute.FieldNotes, field.TypeString, value)
}
if _u.mutation.NotesCleared() {
_spec.ClearField(compositemodelroute.FieldNotes, field.TypeString)
}
if _u.mutation.GroupCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: false,
Table: compositemodelroute.GroupTable,
Columns: []string{compositemodelroute.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.GroupIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: false,
Table: compositemodelroute.GroupTable,
Columns: []string{compositemodelroute.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _node, err = sqlgraph.UpdateNodes(ctx, _u.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{compositemodelroute.Label}
} else if sqlgraph.IsConstraintError(err) {
err = &ConstraintError{msg: err.Error(), wrap: err}
}
return 0, err
}
_u.mutation.done = true
return _node, nil
}
// CompositeModelRouteUpdateOne is the builder for updating a single CompositeModelRoute entity.
type CompositeModelRouteUpdateOne struct {
config
fields []string
hooks []Hook
mutation *CompositeModelRouteMutation
}
// SetUpdatedAt sets the "updated_at" field.
func (_u *CompositeModelRouteUpdateOne) SetUpdatedAt(v time.Time) *CompositeModelRouteUpdateOne {
_u.mutation.SetUpdatedAt(v)
return _u
}
// SetDeletedAt sets the "deleted_at" field.
func (_u *CompositeModelRouteUpdateOne) SetDeletedAt(v time.Time) *CompositeModelRouteUpdateOne {
_u.mutation.SetDeletedAt(v)
return _u
}
// SetNillableDeletedAt sets the "deleted_at" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillableDeletedAt(v *time.Time) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetDeletedAt(*v)
}
return _u
}
// ClearDeletedAt clears the value of the "deleted_at" field.
func (_u *CompositeModelRouteUpdateOne) ClearDeletedAt() *CompositeModelRouteUpdateOne {
_u.mutation.ClearDeletedAt()
return _u
}
// SetGroupID sets the "group_id" field.
func (_u *CompositeModelRouteUpdateOne) SetGroupID(v int64) *CompositeModelRouteUpdateOne {
_u.mutation.SetGroupID(v)
return _u
}
// SetNillableGroupID sets the "group_id" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillableGroupID(v *int64) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetGroupID(*v)
}
return _u
}
// SetPublicModel sets the "public_model" field.
func (_u *CompositeModelRouteUpdateOne) SetPublicModel(v string) *CompositeModelRouteUpdateOne {
_u.mutation.SetPublicModel(v)
return _u
}
// SetNillablePublicModel sets the "public_model" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillablePublicModel(v *string) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetPublicModel(*v)
}
return _u
}
// SetMatchType sets the "match_type" field.
func (_u *CompositeModelRouteUpdateOne) SetMatchType(v string) *CompositeModelRouteUpdateOne {
_u.mutation.SetMatchType(v)
return _u
}
// SetNillableMatchType sets the "match_type" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillableMatchType(v *string) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetMatchType(*v)
}
return _u
}
// SetTargetPlatform sets the "target_platform" field.
func (_u *CompositeModelRouteUpdateOne) SetTargetPlatform(v string) *CompositeModelRouteUpdateOne {
_u.mutation.SetTargetPlatform(v)
return _u
}
// SetNillableTargetPlatform sets the "target_platform" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillableTargetPlatform(v *string) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetTargetPlatform(*v)
}
return _u
}
// SetUpstreamModel sets the "upstream_model" field.
func (_u *CompositeModelRouteUpdateOne) SetUpstreamModel(v string) *CompositeModelRouteUpdateOne {
_u.mutation.SetUpstreamModel(v)
return _u
}
// SetNillableUpstreamModel sets the "upstream_model" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillableUpstreamModel(v *string) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetUpstreamModel(*v)
}
return _u
}
// SetEndpoint sets the "endpoint" field.
func (_u *CompositeModelRouteUpdateOne) SetEndpoint(v string) *CompositeModelRouteUpdateOne {
_u.mutation.SetEndpoint(v)
return _u
}
// SetNillableEndpoint sets the "endpoint" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillableEndpoint(v *string) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetEndpoint(*v)
}
return _u
}
// SetPriority sets the "priority" field.
func (_u *CompositeModelRouteUpdateOne) SetPriority(v int) *CompositeModelRouteUpdateOne {
_u.mutation.ResetPriority()
_u.mutation.SetPriority(v)
return _u
}
// SetNillablePriority sets the "priority" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillablePriority(v *int) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetPriority(*v)
}
return _u
}
// AddPriority adds value to the "priority" field.
func (_u *CompositeModelRouteUpdateOne) AddPriority(v int) *CompositeModelRouteUpdateOne {
_u.mutation.AddPriority(v)
return _u
}
// SetEnabled sets the "enabled" field.
func (_u *CompositeModelRouteUpdateOne) SetEnabled(v bool) *CompositeModelRouteUpdateOne {
_u.mutation.SetEnabled(v)
return _u
}
// SetNillableEnabled sets the "enabled" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillableEnabled(v *bool) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetEnabled(*v)
}
return _u
}
// SetNotes sets the "notes" field.
func (_u *CompositeModelRouteUpdateOne) SetNotes(v string) *CompositeModelRouteUpdateOne {
_u.mutation.SetNotes(v)
return _u
}
// SetNillableNotes sets the "notes" field if the given value is not nil.
func (_u *CompositeModelRouteUpdateOne) SetNillableNotes(v *string) *CompositeModelRouteUpdateOne {
if v != nil {
_u.SetNotes(*v)
}
return _u
}
// ClearNotes clears the value of the "notes" field.
func (_u *CompositeModelRouteUpdateOne) ClearNotes() *CompositeModelRouteUpdateOne {
_u.mutation.ClearNotes()
return _u
}
// SetGroup sets the "group" edge to the Group entity.
func (_u *CompositeModelRouteUpdateOne) SetGroup(v *Group) *CompositeModelRouteUpdateOne {
return _u.SetGroupID(v.ID)
}
// Mutation returns the CompositeModelRouteMutation object of the builder.
func (_u *CompositeModelRouteUpdateOne) Mutation() *CompositeModelRouteMutation {
return _u.mutation
}
// ClearGroup clears the "group" edge to the Group entity.
func (_u *CompositeModelRouteUpdateOne) ClearGroup() *CompositeModelRouteUpdateOne {
_u.mutation.ClearGroup()
return _u
}
// Where appends a list predicates to the CompositeModelRouteUpdate builder.
func (_u *CompositeModelRouteUpdateOne) Where(ps ...predicate.CompositeModelRoute) *CompositeModelRouteUpdateOne {
_u.mutation.Where(ps...)
return _u
}
// Select allows selecting one or more fields (columns) of the returned entity.
// The default is selecting all fields defined in the entity schema.
func (_u *CompositeModelRouteUpdateOne) Select(field string, fields ...string) *CompositeModelRouteUpdateOne {
_u.fields = append([]string{field}, fields...)
return _u
}
// Save executes the query and returns the updated CompositeModelRoute entity.
func (_u *CompositeModelRouteUpdateOne) Save(ctx context.Context) (*CompositeModelRoute, error) {
if err := _u.defaults(); err != nil {
return nil, err
}
return withHooks(ctx, _u.sqlSave, _u.mutation, _u.hooks)
}
// SaveX is like Save, but panics if an error occurs.
func (_u *CompositeModelRouteUpdateOne) SaveX(ctx context.Context) *CompositeModelRoute {
node, err := _u.Save(ctx)
if err != nil {
panic(err)
}
return node
}
// Exec executes the query on the entity.
func (_u *CompositeModelRouteUpdateOne) Exec(ctx context.Context) error {
_, err := _u.Save(ctx)
return err
}
// ExecX is like Exec, but panics if an error occurs.
func (_u *CompositeModelRouteUpdateOne) ExecX(ctx context.Context) {
if err := _u.Exec(ctx); err != nil {
panic(err)
}
}
// defaults sets the default values of the builder before save.
func (_u *CompositeModelRouteUpdateOne) defaults() error {
if _, ok := _u.mutation.UpdatedAt(); !ok {
if compositemodelroute.UpdateDefaultUpdatedAt == nil {
return fmt.Errorf("ent: uninitialized compositemodelroute.UpdateDefaultUpdatedAt (forgotten import ent/runtime?)")
}
v := compositemodelroute.UpdateDefaultUpdatedAt()
_u.mutation.SetUpdatedAt(v)
}
return nil
}
// check runs all checks and user-defined validators on the builder.
func (_u *CompositeModelRouteUpdateOne) check() error {
if v, ok := _u.mutation.PublicModel(); ok {
if err := compositemodelroute.PublicModelValidator(v); err != nil {
return &ValidationError{Name: "public_model", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.public_model": %w`, err)}
}
}
if v, ok := _u.mutation.MatchType(); ok {
if err := compositemodelroute.MatchTypeValidator(v); err != nil {
return &ValidationError{Name: "match_type", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.match_type": %w`, err)}
}
}
if v, ok := _u.mutation.TargetPlatform(); ok {
if err := compositemodelroute.TargetPlatformValidator(v); err != nil {
return &ValidationError{Name: "target_platform", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.target_platform": %w`, err)}
}
}
if v, ok := _u.mutation.UpstreamModel(); ok {
if err := compositemodelroute.UpstreamModelValidator(v); err != nil {
return &ValidationError{Name: "upstream_model", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.upstream_model": %w`, err)}
}
}
if v, ok := _u.mutation.Endpoint(); ok {
if err := compositemodelroute.EndpointValidator(v); err != nil {
return &ValidationError{Name: "endpoint", err: fmt.Errorf(`ent: validator failed for field "CompositeModelRoute.endpoint": %w`, err)}
}
}
if _u.mutation.GroupCleared() && len(_u.mutation.GroupIDs()) > 0 {
return errors.New(`ent: clearing a required unique edge "CompositeModelRoute.group"`)
}
return nil
}
func (_u *CompositeModelRouteUpdateOne) sqlSave(ctx context.Context) (_node *CompositeModelRoute, err error) {
if err := _u.check(); err != nil {
return _node, err
}
_spec := sqlgraph.NewUpdateSpec(compositemodelroute.Table, compositemodelroute.Columns, sqlgraph.NewFieldSpec(compositemodelroute.FieldID, field.TypeInt64))
id, ok := _u.mutation.ID()
if !ok {
return nil, &ValidationError{Name: "id", err: errors.New(`ent: missing "CompositeModelRoute.id" for update`)}
}
_spec.Node.ID.Value = id
if fields := _u.fields; len(fields) > 0 {
_spec.Node.Columns = make([]string, 0, len(fields))
_spec.Node.Columns = append(_spec.Node.Columns, compositemodelroute.FieldID)
for _, f := range fields {
if !compositemodelroute.ValidColumn(f) {
return nil, &ValidationError{Name: f, err: fmt.Errorf("ent: invalid field %q for query", f)}
}
if f != compositemodelroute.FieldID {
_spec.Node.Columns = append(_spec.Node.Columns, f)
}
}
}
if ps := _u.mutation.predicates; len(ps) > 0 {
_spec.Predicate = func(selector *sql.Selector) {
for i := range ps {
ps[i](selector)
}
}
}
if value, ok := _u.mutation.UpdatedAt(); ok {
_spec.SetField(compositemodelroute.FieldUpdatedAt, field.TypeTime, value)
}
if value, ok := _u.mutation.DeletedAt(); ok {
_spec.SetField(compositemodelroute.FieldDeletedAt, field.TypeTime, value)
}
if _u.mutation.DeletedAtCleared() {
_spec.ClearField(compositemodelroute.FieldDeletedAt, field.TypeTime)
}
if value, ok := _u.mutation.PublicModel(); ok {
_spec.SetField(compositemodelroute.FieldPublicModel, field.TypeString, value)
}
if value, ok := _u.mutation.MatchType(); ok {
_spec.SetField(compositemodelroute.FieldMatchType, field.TypeString, value)
}
if value, ok := _u.mutation.TargetPlatform(); ok {
_spec.SetField(compositemodelroute.FieldTargetPlatform, field.TypeString, value)
}
if value, ok := _u.mutation.UpstreamModel(); ok {
_spec.SetField(compositemodelroute.FieldUpstreamModel, field.TypeString, value)
}
if value, ok := _u.mutation.Endpoint(); ok {
_spec.SetField(compositemodelroute.FieldEndpoint, field.TypeString, value)
}
if value, ok := _u.mutation.Priority(); ok {
_spec.SetField(compositemodelroute.FieldPriority, field.TypeInt, value)
}
if value, ok := _u.mutation.AddedPriority(); ok {
_spec.AddField(compositemodelroute.FieldPriority, field.TypeInt, value)
}
if value, ok := _u.mutation.Enabled(); ok {
_spec.SetField(compositemodelroute.FieldEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.Notes(); ok {
_spec.SetField(compositemodelroute.FieldNotes, field.TypeString, value)
}
if _u.mutation.NotesCleared() {
_spec.ClearField(compositemodelroute.FieldNotes, field.TypeString)
}
if _u.mutation.GroupCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: false,
Table: compositemodelroute.GroupTable,
Columns: []string{compositemodelroute.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.GroupIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.M2O,
Inverse: false,
Table: compositemodelroute.GroupTable,
Columns: []string{compositemodelroute.GroupColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(group.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
_node = &CompositeModelRoute{config: _u.config}
_spec.Assign = _node.assignValues
_spec.ScanValues = _node.scanValues
if err = sqlgraph.UpdateNode(ctx, _u.driver, _spec); err != nil {
if _, ok := err.(*sqlgraph.NotFoundError); ok {
err = &NotFoundError{compositemodelroute.Label}
} else if sqlgraph.IsConstraintError(err) {
err = &ConstraintError{msg: err.Error(), wrap: err}
}
return nil, err
}
_u.mutation.done = true
return _node, nil
}
+2
View File
@@ -26,6 +26,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate"
"github.com/Wei-Shaw/sub2api/ent/compositemodelroute"
"github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/idempotencyrecord"
@@ -124,6 +125,7 @@ func checkColumn(t, c string) error {
channelmonitordailyrollup.Table: channelmonitordailyrollup.ValidColumn,
channelmonitorhistory.Table: channelmonitorhistory.ValidColumn,
channelmonitorrequesttemplate.Table: channelmonitorrequesttemplate.ValidColumn,
compositemodelroute.Table: compositemodelroute.ValidColumn,
errorpassthroughrule.Table: errorpassthroughrule.ValidColumn,
group.Table: group.ValidColumn,
idempotencyrecord.Table: idempotencyrecord.ValidColumn,
+46 -2
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 已成功设置的账号
@@ -121,6 +123,12 @@ type Group struct {
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"`
// 最低毛利率,小数(0.30=30%);账号准入条件为 U <= D*(1-margin-buffer)
ProfitMinMargin float64 `json:"profit_min_margin,omitempty"`
// 安全缓冲,小数;与 margin 相加后从下游倍率中扣除,默认 0
ProfitSafetyBuffer float64 `json:"profit_safety_buffer,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
// The values are being populated by the GroupQuery when eager-loading is set.
Edges GroupEdges `json:"edges"`
@@ -229,9 +237,9 @@ 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, group.FieldProfitControlEnabled:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall:
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
values[i] = new(sql.NullFloat64)
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
values[i] = new(sql.NullInt64)
@@ -537,6 +545,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])
@@ -591,6 +605,24 @@ func (_m *Group) assignValues(columns []string, values []any) error {
return fmt.Errorf("unmarshal field reasoning_effort_mappings: %w", err)
}
}
case group.FieldProfitControlEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field profit_control_enabled", values[i])
} else if value.Valid {
_m.ProfitControlEnabled = value.Bool
}
case group.FieldProfitMinMargin:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field profit_min_margin", values[i])
} else if value.Valid {
_m.ProfitMinMargin = value.Float64
}
case group.FieldProfitSafetyBuffer:
if value, ok := values[i].(*sql.NullFloat64); !ok {
return fmt.Errorf("unexpected type %T for field profit_safety_buffer", values[i])
} else if value.Valid {
_m.ProfitSafetyBuffer = value.Float64
}
default:
_m.selectValues.Set(columns[i], values[i])
}
@@ -826,6 +858,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(", ")
@@ -849,6 +884,15 @@ func (_m *Group) String() string {
builder.WriteString(", ")
builder.WriteString("reasoning_effort_mappings=")
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
builder.WriteString(", ")
builder.WriteString("profit_control_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.ProfitControlEnabled))
builder.WriteString(", ")
builder.WriteString("profit_min_margin=")
builder.WriteString(fmt.Sprintf("%v", _m.ProfitMinMargin))
builder.WriteString(", ")
builder.WriteString("profit_safety_buffer=")
builder.WriteString(fmt.Sprintf("%v", _m.ProfitSafetyBuffer))
builder.WriteByte(')')
return builder.String()
}
+40
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.
@@ -118,6 +120,12 @@ const (
FieldMaxReasoningEffort = "max_reasoning_effort"
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
FieldReasoningEffortMappings = "reasoning_effort_mappings"
// FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database.
FieldProfitControlEnabled = "profit_control_enabled"
// FieldProfitMinMargin holds the string denoting the profit_min_margin field in the database.
FieldProfitMinMargin = "profit_min_margin"
// FieldProfitSafetyBuffer holds the string denoting the profit_safety_buffer field in the database.
FieldProfitSafetyBuffer = "profit_safety_buffer"
// EdgeAPIKeys holds the string denoting the api_keys edge name in mutations.
EdgeAPIKeys = "api_keys"
// EdgeRedeemCodes holds the string denoting the redeem_codes edge name in mutations.
@@ -236,6 +244,7 @@ var Columns = []string{
FieldSupportedModelScopes,
FieldSortOrder,
FieldAllowMessagesDispatch,
FieldAllowLive,
FieldRequireOauthOnly,
FieldRequirePrivacySet,
FieldDefaultMappedModel,
@@ -244,6 +253,9 @@ var Columns = []string{
FieldRpmLimit,
FieldMaxReasoningEffort,
FieldReasoningEffortMappings,
FieldProfitControlEnabled,
FieldProfitMinMargin,
FieldProfitSafetyBuffer,
}
var (
@@ -341,6 +353,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.
@@ -361,6 +375,12 @@ var (
MaxReasoningEffortValidator func(string) error
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
// DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field.
DefaultProfitControlEnabled bool
// DefaultProfitMinMargin holds the default value on creation for the "profit_min_margin" field.
DefaultProfitMinMargin float64
// DefaultProfitSafetyBuffer holds the default value on creation for the "profit_safety_buffer" field.
DefaultProfitSafetyBuffer float64
)
// OrderOption defines the ordering options for the Group queries.
@@ -576,6 +596,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()
@@ -601,6 +626,21 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
}
// ByProfitControlEnabled orders the results by the profit_control_enabled field.
func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc()
}
// ByProfitMinMargin orders the results by the profit_min_margin field.
func ByProfitMinMargin(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldProfitMinMargin, opts...).ToFunc()
}
// ByProfitSafetyBuffer orders the results by the profit_safety_buffer field.
func ByProfitSafetyBuffer(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldProfitSafetyBuffer, opts...).ToFunc()
}
// ByAPIKeysCount orders the results by api_keys count.
func ByAPIKeysCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
+120
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))
@@ -285,6 +290,21 @@ func MaxReasoningEffort(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
}
// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ.
func ProfitControlEnabled(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
}
// ProfitMinMargin applies equality check predicate on the "profit_min_margin" field. It's identical to ProfitMinMarginEQ.
func ProfitMinMargin(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitMinMargin, v))
}
// ProfitSafetyBuffer applies equality check predicate on the "profit_safety_buffer" field. It's identical to ProfitSafetyBufferEQ.
func ProfitSafetyBuffer(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitSafetyBuffer, v))
}
// CreatedAtEQ applies the EQ predicate on the "created_at" field.
func CreatedAtEQ(v time.Time) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldCreatedAt, v))
@@ -1985,6 +2005,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))
@@ -2175,6 +2205,96 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
}
// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field.
func ProfitControlEnabledEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
}
// ProfitControlEnabledNEQ applies the NEQ predicate on the "profit_control_enabled" field.
func ProfitControlEnabledNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldProfitControlEnabled, v))
}
// ProfitMinMarginEQ applies the EQ predicate on the "profit_min_margin" field.
func ProfitMinMarginEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitMinMargin, v))
}
// ProfitMinMarginNEQ applies the NEQ predicate on the "profit_min_margin" field.
func ProfitMinMarginNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldProfitMinMargin, v))
}
// ProfitMinMarginIn applies the In predicate on the "profit_min_margin" field.
func ProfitMinMarginIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldProfitMinMargin, vs...))
}
// ProfitMinMarginNotIn applies the NotIn predicate on the "profit_min_margin" field.
func ProfitMinMarginNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldProfitMinMargin, vs...))
}
// ProfitMinMarginGT applies the GT predicate on the "profit_min_margin" field.
func ProfitMinMarginGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldProfitMinMargin, v))
}
// ProfitMinMarginGTE applies the GTE predicate on the "profit_min_margin" field.
func ProfitMinMarginGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldProfitMinMargin, v))
}
// ProfitMinMarginLT applies the LT predicate on the "profit_min_margin" field.
func ProfitMinMarginLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldProfitMinMargin, v))
}
// ProfitMinMarginLTE applies the LTE predicate on the "profit_min_margin" field.
func ProfitMinMarginLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldProfitMinMargin, v))
}
// ProfitSafetyBufferEQ applies the EQ predicate on the "profit_safety_buffer" field.
func ProfitSafetyBufferEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitSafetyBuffer, v))
}
// ProfitSafetyBufferNEQ applies the NEQ predicate on the "profit_safety_buffer" field.
func ProfitSafetyBufferNEQ(v float64) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldProfitSafetyBuffer, v))
}
// ProfitSafetyBufferIn applies the In predicate on the "profit_safety_buffer" field.
func ProfitSafetyBufferIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldIn(FieldProfitSafetyBuffer, vs...))
}
// ProfitSafetyBufferNotIn applies the NotIn predicate on the "profit_safety_buffer" field.
func ProfitSafetyBufferNotIn(vs ...float64) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldProfitSafetyBuffer, vs...))
}
// ProfitSafetyBufferGT applies the GT predicate on the "profit_safety_buffer" field.
func ProfitSafetyBufferGT(v float64) predicate.Group {
return predicate.Group(sql.FieldGT(FieldProfitSafetyBuffer, v))
}
// ProfitSafetyBufferGTE applies the GTE predicate on the "profit_safety_buffer" field.
func ProfitSafetyBufferGTE(v float64) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldProfitSafetyBuffer, v))
}
// ProfitSafetyBufferLT applies the LT predicate on the "profit_safety_buffer" field.
func ProfitSafetyBufferLT(v float64) predicate.Group {
return predicate.Group(sql.FieldLT(FieldProfitSafetyBuffer, v))
}
// ProfitSafetyBufferLTE applies the LTE predicate on the "profit_safety_buffer" field.
func ProfitSafetyBufferLTE(v float64) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldProfitSafetyBuffer, v))
}
// HasAPIKeys applies the HasEdge predicate on the "api_keys" edge.
func HasAPIKeys() predicate.Group {
return predicate.Group(func(s *sql.Selector) {
+300
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)
@@ -711,6 +725,48 @@ func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapp
return _c
}
// SetProfitControlEnabled sets the "profit_control_enabled" field.
func (_c *GroupCreate) SetProfitControlEnabled(v bool) *GroupCreate {
_c.mutation.SetProfitControlEnabled(v)
return _c
}
// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil.
func (_c *GroupCreate) SetNillableProfitControlEnabled(v *bool) *GroupCreate {
if v != nil {
_c.SetProfitControlEnabled(*v)
}
return _c
}
// SetProfitMinMargin sets the "profit_min_margin" field.
func (_c *GroupCreate) SetProfitMinMargin(v float64) *GroupCreate {
_c.mutation.SetProfitMinMargin(v)
return _c
}
// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil.
func (_c *GroupCreate) SetNillableProfitMinMargin(v *float64) *GroupCreate {
if v != nil {
_c.SetProfitMinMargin(*v)
}
return _c
}
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
func (_c *GroupCreate) SetProfitSafetyBuffer(v float64) *GroupCreate {
_c.mutation.SetProfitSafetyBuffer(v)
return _c
}
// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil.
func (_c *GroupCreate) SetNillableProfitSafetyBuffer(v *float64) *GroupCreate {
if v != nil {
_c.SetProfitSafetyBuffer(*v)
}
return _c
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_c *GroupCreate) AddAPIKeyIDs(ids ...int64) *GroupCreate {
_c.mutation.AddAPIKeyIDs(ids...)
@@ -948,6 +1004,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)
@@ -980,6 +1040,18 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultReasoningEffortMappings
_c.mutation.SetReasoningEffortMappings(v)
}
if _, ok := _c.mutation.ProfitControlEnabled(); !ok {
v := group.DefaultProfitControlEnabled
_c.mutation.SetProfitControlEnabled(v)
}
if _, ok := _c.mutation.ProfitMinMargin(); !ok {
v := group.DefaultProfitMinMargin
_c.mutation.SetProfitMinMargin(v)
}
if _, ok := _c.mutation.ProfitSafetyBuffer(); !ok {
v := group.DefaultProfitSafetyBuffer
_c.mutation.SetProfitSafetyBuffer(v)
}
return nil
}
@@ -1101,6 +1173,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"`)}
}
@@ -1135,6 +1210,15 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)}
}
if _, ok := _c.mutation.ProfitControlEnabled(); !ok {
return &ValidationError{Name: "profit_control_enabled", err: errors.New(`ent: missing required field "Group.profit_control_enabled"`)}
}
if _, ok := _c.mutation.ProfitMinMargin(); !ok {
return &ValidationError{Name: "profit_min_margin", err: errors.New(`ent: missing required field "Group.profit_min_margin"`)}
}
if _, ok := _c.mutation.ProfitSafetyBuffer(); !ok {
return &ValidationError{Name: "profit_safety_buffer", err: errors.New(`ent: missing required field "Group.profit_safety_buffer"`)}
}
return nil
}
@@ -1334,6 +1418,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
@@ -1366,6 +1454,18 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
_node.ReasoningEffortMappings = value
}
if value, ok := _c.mutation.ProfitControlEnabled(); ok {
_spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value)
_node.ProfitControlEnabled = value
}
if value, ok := _c.mutation.ProfitMinMargin(); ok {
_spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value)
_node.ProfitMinMargin = value
}
if value, ok := _c.mutation.ProfitSafetyBuffer(); ok {
_spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
_node.ProfitSafetyBuffer = value
}
if nodes := _c.mutation.APIKeysIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -2224,6 +2324,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)
@@ -2326,6 +2438,54 @@ func (u *GroupUpsert) UpdateReasoningEffortMappings() *GroupUpsert {
return u
}
// SetProfitControlEnabled sets the "profit_control_enabled" field.
func (u *GroupUpsert) SetProfitControlEnabled(v bool) *GroupUpsert {
u.Set(group.FieldProfitControlEnabled, v)
return u
}
// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create.
func (u *GroupUpsert) UpdateProfitControlEnabled() *GroupUpsert {
u.SetExcluded(group.FieldProfitControlEnabled)
return u
}
// SetProfitMinMargin sets the "profit_min_margin" field.
func (u *GroupUpsert) SetProfitMinMargin(v float64) *GroupUpsert {
u.Set(group.FieldProfitMinMargin, v)
return u
}
// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create.
func (u *GroupUpsert) UpdateProfitMinMargin() *GroupUpsert {
u.SetExcluded(group.FieldProfitMinMargin)
return u
}
// AddProfitMinMargin adds v to the "profit_min_margin" field.
func (u *GroupUpsert) AddProfitMinMargin(v float64) *GroupUpsert {
u.Add(group.FieldProfitMinMargin, v)
return u
}
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
func (u *GroupUpsert) SetProfitSafetyBuffer(v float64) *GroupUpsert {
u.Set(group.FieldProfitSafetyBuffer, v)
return u
}
// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create.
func (u *GroupUpsert) UpdateProfitSafetyBuffer() *GroupUpsert {
u.SetExcluded(group.FieldProfitSafetyBuffer)
return u
}
// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field.
func (u *GroupUpsert) AddProfitSafetyBuffer(v float64) *GroupUpsert {
u.Add(group.FieldProfitSafetyBuffer, v)
return u
}
// UpdateNewValues updates the mutable fields using the new values that were set on create.
// Using this option is equivalent to using:
//
@@ -3193,6 +3353,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) {
@@ -3312,6 +3486,62 @@ func (u *GroupUpsertOne) UpdateReasoningEffortMappings() *GroupUpsertOne {
})
}
// SetProfitControlEnabled sets the "profit_control_enabled" field.
func (u *GroupUpsertOne) SetProfitControlEnabled(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetProfitControlEnabled(v)
})
}
// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateProfitControlEnabled() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateProfitControlEnabled()
})
}
// SetProfitMinMargin sets the "profit_min_margin" field.
func (u *GroupUpsertOne) SetProfitMinMargin(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetProfitMinMargin(v)
})
}
// AddProfitMinMargin adds v to the "profit_min_margin" field.
func (u *GroupUpsertOne) AddProfitMinMargin(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddProfitMinMargin(v)
})
}
// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateProfitMinMargin() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateProfitMinMargin()
})
}
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
func (u *GroupUpsertOne) SetProfitSafetyBuffer(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetProfitSafetyBuffer(v)
})
}
// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field.
func (u *GroupUpsertOne) AddProfitSafetyBuffer(v float64) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.AddProfitSafetyBuffer(v)
})
}
// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateProfitSafetyBuffer() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateProfitSafetyBuffer()
})
}
// Exec executes the query.
func (u *GroupUpsertOne) Exec(ctx context.Context) error {
if len(u.create.conflict) == 0 {
@@ -4345,6 +4575,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) {
@@ -4464,6 +4708,62 @@ func (u *GroupUpsertBulk) UpdateReasoningEffortMappings() *GroupUpsertBulk {
})
}
// SetProfitControlEnabled sets the "profit_control_enabled" field.
func (u *GroupUpsertBulk) SetProfitControlEnabled(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetProfitControlEnabled(v)
})
}
// UpdateProfitControlEnabled sets the "profit_control_enabled" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateProfitControlEnabled() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateProfitControlEnabled()
})
}
// SetProfitMinMargin sets the "profit_min_margin" field.
func (u *GroupUpsertBulk) SetProfitMinMargin(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetProfitMinMargin(v)
})
}
// AddProfitMinMargin adds v to the "profit_min_margin" field.
func (u *GroupUpsertBulk) AddProfitMinMargin(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddProfitMinMargin(v)
})
}
// UpdateProfitMinMargin sets the "profit_min_margin" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateProfitMinMargin() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateProfitMinMargin()
})
}
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
func (u *GroupUpsertBulk) SetProfitSafetyBuffer(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetProfitSafetyBuffer(v)
})
}
// AddProfitSafetyBuffer adds v to the "profit_safety_buffer" field.
func (u *GroupUpsertBulk) AddProfitSafetyBuffer(v float64) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.AddProfitSafetyBuffer(v)
})
}
// UpdateProfitSafetyBuffer sets the "profit_safety_buffer" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateProfitSafetyBuffer() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateProfitSafetyBuffer()
})
}
// Exec executes the query.
func (u *GroupUpsertBulk) Exec(ctx context.Context) error {
if u.create.err != nil {
+176
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)
@@ -939,6 +953,62 @@ func (_u *GroupUpdate) AppendReasoningEffortMappings(v []domain.ReasoningEffortM
return _u
}
// SetProfitControlEnabled sets the "profit_control_enabled" field.
func (_u *GroupUpdate) SetProfitControlEnabled(v bool) *GroupUpdate {
_u.mutation.SetProfitControlEnabled(v)
return _u
}
// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableProfitControlEnabled(v *bool) *GroupUpdate {
if v != nil {
_u.SetProfitControlEnabled(*v)
}
return _u
}
// SetProfitMinMargin sets the "profit_min_margin" field.
func (_u *GroupUpdate) SetProfitMinMargin(v float64) *GroupUpdate {
_u.mutation.ResetProfitMinMargin()
_u.mutation.SetProfitMinMargin(v)
return _u
}
// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableProfitMinMargin(v *float64) *GroupUpdate {
if v != nil {
_u.SetProfitMinMargin(*v)
}
return _u
}
// AddProfitMinMargin adds value to the "profit_min_margin" field.
func (_u *GroupUpdate) AddProfitMinMargin(v float64) *GroupUpdate {
_u.mutation.AddProfitMinMargin(v)
return _u
}
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
func (_u *GroupUpdate) SetProfitSafetyBuffer(v float64) *GroupUpdate {
_u.mutation.ResetProfitSafetyBuffer()
_u.mutation.SetProfitSafetyBuffer(v)
return _u
}
// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableProfitSafetyBuffer(v *float64) *GroupUpdate {
if v != nil {
_u.SetProfitSafetyBuffer(*v)
}
return _u
}
// AddProfitSafetyBuffer adds value to the "profit_safety_buffer" field.
func (_u *GroupUpdate) AddProfitSafetyBuffer(v float64) *GroupUpdate {
_u.mutation.AddProfitSafetyBuffer(v)
return _u
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *GroupUpdate) AddAPIKeyIDs(ids ...int64) *GroupUpdate {
_u.mutation.AddAPIKeyIDs(ids...)
@@ -1495,6 +1565,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)
}
@@ -1527,6 +1600,21 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
sqljson.Append(u, group.FieldReasoningEffortMappings, value)
})
}
if value, ok := _u.mutation.ProfitControlEnabled(); ok {
_spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.ProfitMinMargin(); ok {
_spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedProfitMinMargin(); ok {
_spec.AddField(group.FieldProfitMinMargin, field.TypeFloat64, value)
}
if value, ok := _u.mutation.ProfitSafetyBuffer(); ok {
_spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedProfitSafetyBuffer(); ok {
_spec.AddField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
}
if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
@@ -2627,6 +2715,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)
@@ -2744,6 +2846,62 @@ func (_u *GroupUpdateOne) AppendReasoningEffortMappings(v []domain.ReasoningEffo
return _u
}
// SetProfitControlEnabled sets the "profit_control_enabled" field.
func (_u *GroupUpdateOne) SetProfitControlEnabled(v bool) *GroupUpdateOne {
_u.mutation.SetProfitControlEnabled(v)
return _u
}
// SetNillableProfitControlEnabled sets the "profit_control_enabled" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableProfitControlEnabled(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetProfitControlEnabled(*v)
}
return _u
}
// SetProfitMinMargin sets the "profit_min_margin" field.
func (_u *GroupUpdateOne) SetProfitMinMargin(v float64) *GroupUpdateOne {
_u.mutation.ResetProfitMinMargin()
_u.mutation.SetProfitMinMargin(v)
return _u
}
// SetNillableProfitMinMargin sets the "profit_min_margin" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableProfitMinMargin(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetProfitMinMargin(*v)
}
return _u
}
// AddProfitMinMargin adds value to the "profit_min_margin" field.
func (_u *GroupUpdateOne) AddProfitMinMargin(v float64) *GroupUpdateOne {
_u.mutation.AddProfitMinMargin(v)
return _u
}
// SetProfitSafetyBuffer sets the "profit_safety_buffer" field.
func (_u *GroupUpdateOne) SetProfitSafetyBuffer(v float64) *GroupUpdateOne {
_u.mutation.ResetProfitSafetyBuffer()
_u.mutation.SetProfitSafetyBuffer(v)
return _u
}
// SetNillableProfitSafetyBuffer sets the "profit_safety_buffer" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableProfitSafetyBuffer(v *float64) *GroupUpdateOne {
if v != nil {
_u.SetProfitSafetyBuffer(*v)
}
return _u
}
// AddProfitSafetyBuffer adds value to the "profit_safety_buffer" field.
func (_u *GroupUpdateOne) AddProfitSafetyBuffer(v float64) *GroupUpdateOne {
_u.mutation.AddProfitSafetyBuffer(v)
return _u
}
// AddAPIKeyIDs adds the "api_keys" edge to the APIKey entity by IDs.
func (_u *GroupUpdateOne) AddAPIKeyIDs(ids ...int64) *GroupUpdateOne {
_u.mutation.AddAPIKeyIDs(ids...)
@@ -3330,6 +3488,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)
}
@@ -3362,6 +3523,21 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
sqljson.Append(u, group.FieldReasoningEffortMappings, value)
})
}
if value, ok := _u.mutation.ProfitControlEnabled(); ok {
_spec.SetField(group.FieldProfitControlEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.ProfitMinMargin(); ok {
_spec.SetField(group.FieldProfitMinMargin, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedProfitMinMargin(); ok {
_spec.AddField(group.FieldProfitMinMargin, field.TypeFloat64, value)
}
if value, ok := _u.mutation.ProfitSafetyBuffer(); ok {
_spec.SetField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
}
if value, ok := _u.mutation.AddedProfitSafetyBuffer(); ok {
_spec.AddField(group.FieldProfitSafetyBuffer, field.TypeFloat64, value)
}
if _u.mutation.APIKeysCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
+12
View File
@@ -177,6 +177,18 @@ func (f ChannelMonitorRequestTemplateFunc) Mutate(ctx context.Context, m ent.Mut
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.ChannelMonitorRequestTemplateMutation", m)
}
// The CompositeModelRouteFunc type is an adapter to allow the use of ordinary
// function as CompositeModelRoute mutator.
type CompositeModelRouteFunc func(context.Context, *ent.CompositeModelRouteMutation) (ent.Value, error)
// Mutate calls f(ctx, m).
func (f CompositeModelRouteFunc) Mutate(ctx context.Context, m ent.Mutation) (ent.Value, error) {
if mv, ok := m.(*ent.CompositeModelRouteMutation); ok {
return f(ctx, mv)
}
return nil, fmt.Errorf("unexpected mutation type %T. expect *ent.CompositeModelRouteMutation", m)
}
// The ErrorPassthroughRuleFunc type is an adapter to allow the use of ordinary
// function as ErrorPassthroughRule mutator.
type ErrorPassthroughRuleFunc func(context.Context, *ent.ErrorPassthroughRuleMutation) (ent.Value, error)
+30
View File
@@ -22,6 +22,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate"
"github.com/Wei-Shaw/sub2api/ent/compositemodelroute"
"github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/idempotencyrecord"
@@ -483,6 +484,33 @@ func (f TraverseChannelMonitorRequestTemplate) Traverse(ctx context.Context, q e
return fmt.Errorf("unexpected query type %T. expect *ent.ChannelMonitorRequestTemplateQuery", q)
}
// The CompositeModelRouteFunc type is an adapter to allow the use of ordinary function as a Querier.
type CompositeModelRouteFunc func(context.Context, *ent.CompositeModelRouteQuery) (ent.Value, error)
// Query calls f(ctx, q).
func (f CompositeModelRouteFunc) Query(ctx context.Context, q ent.Query) (ent.Value, error) {
if q, ok := q.(*ent.CompositeModelRouteQuery); ok {
return f(ctx, q)
}
return nil, fmt.Errorf("unexpected query type %T. expect *ent.CompositeModelRouteQuery", q)
}
// The TraverseCompositeModelRoute type is an adapter to allow the use of ordinary function as Traverser.
type TraverseCompositeModelRoute func(context.Context, *ent.CompositeModelRouteQuery) error
// Intercept is a dummy implementation of Intercept that returns the next Querier in the pipeline.
func (f TraverseCompositeModelRoute) Intercept(next ent.Querier) ent.Querier {
return next
}
// Traverse calls f(ctx, q).
func (f TraverseCompositeModelRoute) Traverse(ctx context.Context, q ent.Query) error {
if q, ok := q.(*ent.CompositeModelRouteQuery); ok {
return f(ctx, q)
}
return fmt.Errorf("unexpected query type %T. expect *ent.CompositeModelRouteQuery", q)
}
// The ErrorPassthroughRuleFunc type is an adapter to allow the use of ordinary function as a Querier.
type ErrorPassthroughRuleFunc func(context.Context, *ent.ErrorPassthroughRuleQuery) (ent.Value, error)
@@ -1162,6 +1190,8 @@ func NewQuery(q ent.Query) (Query, error) {
return &query[*ent.ChannelMonitorHistoryQuery, predicate.ChannelMonitorHistory, channelmonitorhistory.OrderOption]{typ: ent.TypeChannelMonitorHistory, tq: q}, nil
case *ent.ChannelMonitorRequestTemplateQuery:
return &query[*ent.ChannelMonitorRequestTemplateQuery, predicate.ChannelMonitorRequestTemplate, channelmonitorrequesttemplate.OrderOption]{typ: ent.TypeChannelMonitorRequestTemplate, tq: q}, nil
case *ent.CompositeModelRouteQuery:
return &query[*ent.CompositeModelRouteQuery, predicate.CompositeModelRoute, compositemodelroute.OrderOption]{typ: ent.TypeCompositeModelRoute, tq: q}, nil
case *ent.ErrorPassthroughRuleQuery:
return &query[*ent.ErrorPassthroughRuleQuery, predicate.ErrorPassthroughRule, errorpassthroughrule.OrderOption]{typ: ent.TypeErrorPassthroughRule, tq: q}, nil
case *ent.GroupQuery:
+71
View File
@@ -793,6 +793,68 @@ var (
},
},
}
// CompositeModelRoutesColumns holds the columns for the "composite_model_routes" table.
CompositeModelRoutesColumns = []*schema.Column{
{Name: "id", Type: field.TypeInt64, Increment: true},
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "deleted_at", Type: field.TypeTime, Nullable: true, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "public_model", Type: field.TypeString, Size: 200},
{Name: "match_type", Type: field.TypeString, Size: 20, Default: "exact"},
{Name: "target_platform", Type: field.TypeString, Size: 50, Default: "openai"},
{Name: "upstream_model", Type: field.TypeString, Size: 200, Default: ""},
{Name: "endpoint", Type: field.TypeString, Size: 50, Default: "any"},
{Name: "priority", Type: field.TypeInt, Default: 100},
{Name: "enabled", Type: field.TypeBool, Default: true},
{Name: "notes", Type: field.TypeString, Nullable: true, SchemaType: map[string]string{"postgres": "text"}},
{Name: "group_id", Type: field.TypeInt64},
}
// CompositeModelRoutesTable holds the schema information for the "composite_model_routes" table.
CompositeModelRoutesTable = &schema.Table{
Name: "composite_model_routes",
Columns: CompositeModelRoutesColumns,
PrimaryKey: []*schema.Column{CompositeModelRoutesColumns[0]},
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "composite_model_routes_groups_group",
Columns: []*schema.Column{CompositeModelRoutesColumns[12]},
RefColumns: []*schema.Column{GroupsColumns[0]},
OnDelete: schema.NoAction,
},
},
Indexes: []*schema.Index{
{
Name: "compositemodelroute_group_id",
Unique: false,
Columns: []*schema.Column{CompositeModelRoutesColumns[12]},
},
{
Name: "compositemodelroute_group_id_enabled",
Unique: false,
Columns: []*schema.Column{CompositeModelRoutesColumns[12], CompositeModelRoutesColumns[10]},
},
{
Name: "compositemodelroute_group_id_endpoint",
Unique: false,
Columns: []*schema.Column{CompositeModelRoutesColumns[12], CompositeModelRoutesColumns[8]},
},
{
Name: "compositemodelroute_group_id_target_platform",
Unique: false,
Columns: []*schema.Column{CompositeModelRoutesColumns[12], CompositeModelRoutesColumns[6]},
},
{
Name: "compositemodelroute_deleted_at",
Unique: false,
Columns: []*schema.Column{CompositeModelRoutesColumns[3]},
},
{
Name: "compositemodelroute_priority",
Unique: false,
Columns: []*schema.Column{CompositeModelRoutesColumns[9]},
},
},
}
// ErrorPassthroughRulesColumns holds the columns for the "error_passthrough_rules" table.
ErrorPassthroughRulesColumns = []*schema.Column{
{Name: "id", Type: field.TypeInt64, Increment: true},
@@ -876,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: ""},
@@ -884,6 +947,9 @@ var (
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "profit_control_enabled", Type: field.TypeBool, Default: false},
{Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
{Name: "profit_safety_buffer", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
}
// GroupsTable holds the schema information for the "groups" table.
GroupsTable = &schema.Table{
@@ -2014,6 +2080,7 @@ var (
ChannelMonitorDailyRollupsTable,
ChannelMonitorHistoriesTable,
ChannelMonitorRequestTemplatesTable,
CompositeModelRoutesTable,
ErrorPassthroughRulesTable,
GroupsTable,
IdempotencyRecordsTable,
@@ -2097,6 +2164,10 @@ func init() {
ChannelMonitorRequestTemplatesTable.Annotation = &entsql.Annotation{
Table: "channel_monitor_request_templates",
}
CompositeModelRoutesTable.ForeignKeys[0].RefTable = GroupsTable
CompositeModelRoutesTable.Annotation = &entsql.Annotation{
Table: "composite_model_routes",
}
ErrorPassthroughRulesTable.Annotation = &entsql.Annotation{
Table: "error_passthrough_rules",
}
+1336 -1
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -48,6 +48,9 @@ type ChannelMonitorHistory func(*sql.Selector)
// ChannelMonitorRequestTemplate is the predicate function for channelmonitorrequesttemplate builders.
type ChannelMonitorRequestTemplate func(*sql.Selector)
// CompositeModelRoute is the predicate function for compositemodelroute builders.
type CompositeModelRoute func(*sql.Selector)
// ErrorPassthroughRule is the predicate function for errorpassthroughrule builders.
type ErrorPassthroughRule func(*sql.Selector)
+94 -8
View File
@@ -19,6 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitordailyrollup"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorrequesttemplate"
"github.com/Wei-Shaw/sub2api/ent/compositemodelroute"
"github.com/Wei-Shaw/sub2api/ent/errorpassthroughrule"
"github.com/Wei-Shaw/sub2api/ent/group"
"github.com/Wei-Shaw/sub2api/ent/idempotencyrecord"
@@ -865,6 +866,75 @@ func init() {
channelmonitorrequesttemplate.DefaultBodyOverrideMode = channelmonitorrequesttemplateDescBodyOverrideMode.Default.(string)
// channelmonitorrequesttemplate.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save.
channelmonitorrequesttemplate.BodyOverrideModeValidator = channelmonitorrequesttemplateDescBodyOverrideMode.Validators[0].(func(string) error)
compositemodelrouteMixin := schema.CompositeModelRoute{}.Mixin()
compositemodelrouteMixinHooks1 := compositemodelrouteMixin[1].Hooks()
compositemodelroute.Hooks[0] = compositemodelrouteMixinHooks1[0]
compositemodelrouteMixinInters1 := compositemodelrouteMixin[1].Interceptors()
compositemodelroute.Interceptors[0] = compositemodelrouteMixinInters1[0]
compositemodelrouteMixinFields0 := compositemodelrouteMixin[0].Fields()
_ = compositemodelrouteMixinFields0
compositemodelrouteFields := schema.CompositeModelRoute{}.Fields()
_ = compositemodelrouteFields
// compositemodelrouteDescCreatedAt is the schema descriptor for created_at field.
compositemodelrouteDescCreatedAt := compositemodelrouteMixinFields0[0].Descriptor()
// compositemodelroute.DefaultCreatedAt holds the default value on creation for the created_at field.
compositemodelroute.DefaultCreatedAt = compositemodelrouteDescCreatedAt.Default.(func() time.Time)
// compositemodelrouteDescUpdatedAt is the schema descriptor for updated_at field.
compositemodelrouteDescUpdatedAt := compositemodelrouteMixinFields0[1].Descriptor()
// compositemodelroute.DefaultUpdatedAt holds the default value on creation for the updated_at field.
compositemodelroute.DefaultUpdatedAt = compositemodelrouteDescUpdatedAt.Default.(func() time.Time)
// compositemodelroute.UpdateDefaultUpdatedAt holds the default value on update for the updated_at field.
compositemodelroute.UpdateDefaultUpdatedAt = compositemodelrouteDescUpdatedAt.UpdateDefault.(func() time.Time)
// compositemodelrouteDescPublicModel is the schema descriptor for public_model field.
compositemodelrouteDescPublicModel := compositemodelrouteFields[1].Descriptor()
// compositemodelroute.PublicModelValidator is a validator for the "public_model" field. It is called by the builders before save.
compositemodelroute.PublicModelValidator = func() func(string) error {
validators := compositemodelrouteDescPublicModel.Validators
fns := [...]func(string) error{
validators[0].(func(string) error),
validators[1].(func(string) error),
}
return func(public_model string) error {
for _, fn := range fns {
if err := fn(public_model); err != nil {
return err
}
}
return nil
}
}()
// compositemodelrouteDescMatchType is the schema descriptor for match_type field.
compositemodelrouteDescMatchType := compositemodelrouteFields[2].Descriptor()
// compositemodelroute.DefaultMatchType holds the default value on creation for the match_type field.
compositemodelroute.DefaultMatchType = compositemodelrouteDescMatchType.Default.(string)
// compositemodelroute.MatchTypeValidator is a validator for the "match_type" field. It is called by the builders before save.
compositemodelroute.MatchTypeValidator = compositemodelrouteDescMatchType.Validators[0].(func(string) error)
// compositemodelrouteDescTargetPlatform is the schema descriptor for target_platform field.
compositemodelrouteDescTargetPlatform := compositemodelrouteFields[3].Descriptor()
// compositemodelroute.DefaultTargetPlatform holds the default value on creation for the target_platform field.
compositemodelroute.DefaultTargetPlatform = compositemodelrouteDescTargetPlatform.Default.(string)
// compositemodelroute.TargetPlatformValidator is a validator for the "target_platform" field. It is called by the builders before save.
compositemodelroute.TargetPlatformValidator = compositemodelrouteDescTargetPlatform.Validators[0].(func(string) error)
// compositemodelrouteDescUpstreamModel is the schema descriptor for upstream_model field.
compositemodelrouteDescUpstreamModel := compositemodelrouteFields[4].Descriptor()
// compositemodelroute.DefaultUpstreamModel holds the default value on creation for the upstream_model field.
compositemodelroute.DefaultUpstreamModel = compositemodelrouteDescUpstreamModel.Default.(string)
// compositemodelroute.UpstreamModelValidator is a validator for the "upstream_model" field. It is called by the builders before save.
compositemodelroute.UpstreamModelValidator = compositemodelrouteDescUpstreamModel.Validators[0].(func(string) error)
// compositemodelrouteDescEndpoint is the schema descriptor for endpoint field.
compositemodelrouteDescEndpoint := compositemodelrouteFields[5].Descriptor()
// compositemodelroute.DefaultEndpoint holds the default value on creation for the endpoint field.
compositemodelroute.DefaultEndpoint = compositemodelrouteDescEndpoint.Default.(string)
// compositemodelroute.EndpointValidator is a validator for the "endpoint" field. It is called by the builders before save.
compositemodelroute.EndpointValidator = compositemodelrouteDescEndpoint.Validators[0].(func(string) error)
// compositemodelrouteDescPriority is the schema descriptor for priority field.
compositemodelrouteDescPriority := compositemodelrouteFields[6].Descriptor()
// compositemodelroute.DefaultPriority holds the default value on creation for the priority field.
compositemodelroute.DefaultPriority = compositemodelrouteDescPriority.Default.(int)
// compositemodelrouteDescEnabled is the schema descriptor for enabled field.
compositemodelrouteDescEnabled := compositemodelrouteFields[7].Descriptor()
// compositemodelroute.DefaultEnabled holds the default value on creation for the enabled field.
compositemodelroute.DefaultEnabled = compositemodelrouteDescEnabled.Default.(bool)
errorpassthroughruleMixin := schema.ErrorPassthroughRule{}.Mixin()
errorpassthroughruleMixinFields0 := errorpassthroughruleMixin[0].Fields()
_ = errorpassthroughruleMixinFields0
@@ -1071,42 +1141,58 @@ 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)
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
groupDescProfitControlEnabled := groupFields[49].Descriptor()
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
groupDescProfitMinMargin := groupFields[50].Descriptor()
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
groupDescProfitSafetyBuffer := groupFields[51].Descriptor()
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
idempotencyrecordMixinFields0 := idempotencyrecordMixin[0].Fields()
_ = idempotencyrecordMixinFields0
@@ -0,0 +1,87 @@
package schema
import (
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/internal/domain"
"entgo.io/ent"
"entgo.io/ent/dialect"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
"entgo.io/ent/schema/edge"
"entgo.io/ent/schema/field"
"entgo.io/ent/schema/index"
)
// CompositeModelRoute holds model routing aliases for composite groups.
type CompositeModelRoute struct {
ent.Schema
}
func (CompositeModelRoute) Annotations() []schema.Annotation {
return []schema.Annotation{
entsql.Annotation{Table: "composite_model_routes"},
}
}
func (CompositeModelRoute) Mixin() []ent.Mixin {
return []ent.Mixin{
mixins.TimeMixin{},
mixins.SoftDeleteMixin{},
}
}
func (CompositeModelRoute) Fields() []ent.Field {
return []ent.Field{
field.Int64("group_id"),
field.String("public_model").
MaxLen(200).
NotEmpty().
Comment("Client-facing model identifier or prefix."),
field.String("match_type").
MaxLen(20).
Default("exact").
Comment("exact or prefix."),
field.String("target_platform").
MaxLen(50).
Default(domain.PlatformOpenAI).
Comment("Concrete provider platform."),
field.String("upstream_model").
MaxLen(200).
Default("").
Comment("Provider model identifier; empty means public_model."),
field.String("endpoint").
MaxLen(50).
Default("any").
Comment("Endpoint scope such as any, messages, responses, chat_completions."),
field.Int("priority").
Default(100).
Comment("Lower values win within the same match strength."),
field.Bool("enabled").
Default(true),
field.String("notes").
Optional().
Nillable().
SchemaType(map[string]string{dialect.Postgres: "text"}),
}
}
func (CompositeModelRoute) Edges() []ent.Edge {
return []ent.Edge{
edge.To("group", Group.Type).
Unique().
Required().
Field("group_id"),
}
}
func (CompositeModelRoute) Indexes() []ent.Index {
return []ent.Index{
index.Fields("group_id"),
index.Fields("group_id", "enabled"),
index.Fields("group_id", "endpoint"),
index.Fields("group_id", "target_platform"),
index.Fields("deleted_at"),
index.Fields("priority"),
}
}
+17
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 类型账号关联到此分组"),
@@ -231,6 +234,20 @@ func (Group) Fields() []ent.Field {
Default([]domain.ReasoningEffortMapping{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
// 的 token 分组可启用,composite 分组不能直接启用。
field.Bool("profit_control_enabled").
Default(false).
Comment("是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池"),
field.Float("profit_min_margin").
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
Default(0).
Comment("最低毛利率,小数(0.30=30%);账号准入条件为 U <= D*(1-margin-buffer)"),
field.Float("profit_safety_buffer").
SchemaType(map[string]string{dialect.Postgres: "decimal(10,4)"}).
Default(0).
Comment("安全缓冲,小数;与 margin 相加后从下游倍率中扣除,默认 0"),
}
}
+3
View File
@@ -42,6 +42,8 @@ type Tx struct {
ChannelMonitorHistory *ChannelMonitorHistoryClient
// ChannelMonitorRequestTemplate is the client for interacting with the ChannelMonitorRequestTemplate builders.
ChannelMonitorRequestTemplate *ChannelMonitorRequestTemplateClient
// CompositeModelRoute is the client for interacting with the CompositeModelRoute builders.
CompositeModelRoute *CompositeModelRouteClient
// ErrorPassthroughRule is the client for interacting with the ErrorPassthroughRule builders.
ErrorPassthroughRule *ErrorPassthroughRuleClient
// Group is the client for interacting with the Group builders.
@@ -235,6 +237,7 @@ func (tx *Tx) init() {
tx.ChannelMonitorDailyRollup = NewChannelMonitorDailyRollupClient(tx.config)
tx.ChannelMonitorHistory = NewChannelMonitorHistoryClient(tx.config)
tx.ChannelMonitorRequestTemplate = NewChannelMonitorRequestTemplateClient(tx.config)
tx.CompositeModelRoute = NewCompositeModelRouteClient(tx.config)
tx.ErrorPassthroughRule = NewErrorPassthroughRuleClient(tx.config)
tx.Group = NewGroupClient(tx.config)
tx.IdempotencyRecord = NewIdempotencyRecordClient(tx.config)
+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=
+115 -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,10 +924,14 @@ 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)
OpenAIHTTP2 GatewayOpenAIHTTP2Config `mapstructure:"openai_http2"`
// OpenAIProxyStreamCircuit: Responses SSE 代理断流熔断策略。
OpenAIProxyStreamCircuit GatewayOpenAIProxyStreamCircuitConfig `mapstructure:"openai_proxy_stream_circuit"`
// ImageConcurrency: 图片生成独立并发限制配置(默认关闭)
ImageConcurrency ImageConcurrencyConfig `mapstructure:"image_concurrency"`
@@ -997,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 {
@@ -1012,6 +1032,19 @@ type GatewayOpenAIHTTP2Config struct {
FallbackTTLSeconds int `mapstructure:"fallback_ttl_seconds"`
}
// GatewayOpenAIProxyStreamCircuitConfig controls the bounded, in-process
// proxy-ID circuit used for incomplete OpenAI Responses SSE streams.
type GatewayOpenAIProxyStreamCircuitConfig struct {
// Disabled: 完全关闭代理断流熔断(默认开启)。
Disabled bool `mapstructure:"disabled"`
// FailureThreshold: 统计窗口内多少次断流后隔离代理。
FailureThreshold int `mapstructure:"failure_threshold"`
// WindowSeconds: 断流统计窗口(秒)。
WindowSeconds int `mapstructure:"window_seconds"`
// TTLSeconds: 代理隔离持续时间(秒)。
TTLSeconds int `mapstructure:"ttl_seconds"`
}
// UserMessageQueueConfig 用户消息串行队列配置
// 用于 Anthropic OAuth/SetupToken 账号的用户消息串行化发送
type UserMessageQueueConfig struct {
@@ -1620,20 +1653,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()
@@ -1808,6 +1828,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)
@@ -1852,12 +1888,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",
@@ -2175,6 +2220,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)
@@ -2238,6 +2284,10 @@ func setDefaults() {
viper.SetDefault("gateway.openai_http2.fallback_error_threshold", 2)
viper.SetDefault("gateway.openai_http2.fallback_window_seconds", 60)
viper.SetDefault("gateway.openai_http2.fallback_ttl_seconds", 600)
viper.SetDefault("gateway.openai_proxy_stream_circuit.disabled", false)
viper.SetDefault("gateway.openai_proxy_stream_circuit.failure_threshold", 2)
viper.SetDefault("gateway.openai_proxy_stream_circuit.window_seconds", 60)
viper.SetDefault("gateway.openai_proxy_stream_circuit.ttl_seconds", 600)
viper.SetDefault("gateway.image_concurrency.enabled", false)
viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
@@ -2564,6 +2614,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")
}
@@ -3023,6 +3110,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:
@@ -3231,6 +3321,15 @@ func (c *Config) Validate() error {
if c.Gateway.OpenAIHTTP2.FallbackTTLSeconds < 0 {
return fmt.Errorf("gateway.openai_http2.fallback_ttl_seconds must be non-negative")
}
if c.Gateway.OpenAIProxyStreamCircuit.FailureThreshold < 0 {
return fmt.Errorf("gateway.openai_proxy_stream_circuit.failure_threshold must be non-negative")
}
if c.Gateway.OpenAIProxyStreamCircuit.WindowSeconds < 0 {
return fmt.Errorf("gateway.openai_proxy_stream_circuit.window_seconds must be non-negative")
}
if c.Gateway.OpenAIProxyStreamCircuit.TTLSeconds < 0 {
return fmt.Errorf("gateway.openai_proxy_stream_circuit.ttl_seconds must be non-negative")
}
weights := c.Gateway.OpenAIWS.SchedulerScoreWeights
for _, weight := range []float64{
weights.Priority, weights.Load, weights.Queue, weights.ErrorRate, weights.TTFT,
@@ -3452,14 +3551,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()
+110
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()
@@ -497,6 +545,25 @@ func TestLoadDefaultOpenAIHTTP2Enabled(t *testing.T) {
require.NoError(t, err)
require.True(t, cfg.Gateway.OpenAIHTTP2.Enabled)
require.True(t, cfg.Gateway.OpenAIHTTP2.AllowProxyFallbackToHTTP1)
require.False(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled)
require.Equal(t, 2, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold)
require.Equal(t, 60, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds)
require.Equal(t, 600, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds)
}
func TestLoadOpenAIProxyStreamCircuitFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_DISABLED", "true")
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_FAILURE_THRESHOLD", "3")
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_WINDOW_SECONDS", "90")
t.Setenv("GATEWAY_OPENAI_PROXY_STREAM_CIRCUIT_TTL_SECONDS", "420")
cfg, err := Load()
require.NoError(t, err)
require.True(t, cfg.Gateway.OpenAIProxyStreamCircuit.Disabled)
require.Equal(t, 3, cfg.Gateway.OpenAIProxyStreamCircuit.FailureThreshold)
require.Equal(t, 90, cfg.Gateway.OpenAIProxyStreamCircuit.WindowSeconds)
require.Equal(t, 420, cfg.Gateway.OpenAIProxyStreamCircuit.TTLSeconds)
}
func TestLoadOpenAIHTTP2DisabledFromEnv(t *testing.T) {
@@ -691,6 +758,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) {
@@ -1139,6 +1221,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")
@@ -1148,6 +1232,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)
@@ -1744,6 +1839,21 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.OpenAIHTTP2.FallbackTTLSeconds = -1 },
wantErr: "gateway.openai_http2.fallback_ttl_seconds",
},
{
name: "gateway openai proxy stream circuit threshold",
mutate: func(c *Config) { c.Gateway.OpenAIProxyStreamCircuit.FailureThreshold = -1 },
wantErr: "gateway.openai_proxy_stream_circuit.failure_threshold",
},
{
name: "gateway openai proxy stream circuit window",
mutate: func(c *Config) { c.Gateway.OpenAIProxyStreamCircuit.WindowSeconds = -1 },
wantErr: "gateway.openai_proxy_stream_circuit.window_seconds",
},
{
name: "gateway openai proxy stream circuit ttl",
mutate: func(c *Config) { c.Gateway.OpenAIProxyStreamCircuit.TTLSeconds = -1 },
wantErr: "gateway.openai_proxy_stream_circuit.ttl_seconds",
},
{
name: "gateway stream data interval range",
mutate: func(c *Config) { c.Gateway.StreamDataIntervalTimeout = 5 },
+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)
}
})
}
}
+2
View File
@@ -23,6 +23,7 @@ const (
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
PlatformComposite = "composite"
)
// Account type constants
@@ -132,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",
@@ -111,7 +111,8 @@ type codexJWTOpenAIClaims struct {
}
type codexAccountIndex struct {
accountsByKey map[string][]service.Account
accountsByKey map[string][]service.Account
keysByAccountID map[int64]map[string]struct{}
}
func (h *AccountHandler) ImportCodexSession(c *gin.Context) {
@@ -932,7 +933,10 @@ func buildCodexStoredIdentityKeys(accountID, userID, email, accessToken string)
}
func buildCodexAccountIndex(accounts []service.Account) *codexAccountIndex {
index := &codexAccountIndex{accountsByKey: map[string][]service.Account{}}
index := &codexAccountIndex{
accountsByKey: map[string][]service.Account{},
keysByAccountID: map[int64]map[string]struct{}{},
}
for _, account := range accounts {
index.Add(account)
}
@@ -946,40 +950,71 @@ func (i *codexAccountIndex) Add(account service.Account) {
if i.accountsByKey == nil {
i.accountsByKey = map[string][]service.Account{}
}
i.remove(account.ID)
if i.keysByAccountID == nil {
i.keysByAccountID = map[int64]map[string]struct{}{}
}
keys := buildCodexStoredIdentityKeys(
codexCredentialString(account.Credentials, "chatgpt_account_id"),
codexCredentialString(account.Credentials, "chatgpt_user_id"),
codexCredentialString(account.Credentials, "email"),
codexCredentialString(account.Credentials, "access_token"),
)
orderedKeys := make([]string, 0, len(keys)+1)
accountKeys := make(map[string]struct{}, len(keys)+1)
for _, key := range keys {
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
if _, exists := accountKeys[key]; exists {
continue
}
accountKeys[key] = struct{}{}
orderedKeys = append(orderedKeys, key)
}
if runtimeID := codexCredentialString(account.Credentials, "agent_runtime_id"); runtimeID != "" {
key := "agent:" + runtimeID
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
}
}
func (i *codexAccountIndex) remove(accountID int64) {
for key, accounts := range i.accountsByKey {
kept := accounts[:0]
for _, account := range accounts {
if account.ID != accountID {
kept = append(kept, account)
}
if _, exists := accountKeys[key]; !exists {
accountKeys[key] = struct{}{}
orderedKeys = append(orderedKeys, key)
}
if len(kept) == 0 {
delete(i.accountsByKey, key)
}
previousKeys := i.keysByAccountID[account.ID]
for key := range previousKeys {
if _, retained := accountKeys[key]; retained {
i.accountsByKey[key] = upsertCodexAccount(i.accountsByKey[key], account)
continue
}
i.accountsByKey[key] = kept
i.removeFromKey(key, account.ID)
}
for _, key := range orderedKeys {
if _, retained := previousKeys[key]; retained {
continue
}
i.accountsByKey[key] = append(i.accountsByKey[key], account)
}
if len(accountKeys) > 0 {
i.keysByAccountID[account.ID] = accountKeys
return
}
delete(i.keysByAccountID, account.ID)
}
// upsertCodexAccount 保留同一键下的全部候选账号(共享的 account: 键可对应
// 团队内多个账号),同一账号重复 Add 时原位替换为最新状态。
func (i *codexAccountIndex) removeFromKey(key string, accountID int64) {
accounts := i.accountsByKey[key]
kept := accounts[:0]
for _, account := range accounts {
if account.ID != accountID {
kept = append(kept, account)
}
}
if len(kept) == 0 {
delete(i.accountsByKey, key)
return
}
i.accountsByKey[key] = kept
}
// upsertCodexAccount keeps all candidates for shared keys while replacing an
// existing account in place so ambiguous legacy matches retain their order.
func upsertCodexAccount(accounts []service.Account, account service.Account) []service.Account {
for idx := range accounts {
if accounts[idx].ID == account.ID {
@@ -509,6 +509,73 @@ func TestCodexAccountIndexUpsertReplacesSameAccount(t *testing.T) {
}
}
func TestCodexAccountIndexUpdateRemovesAllPreviousKeys(t *testing.T) {
legacy := service.Account{
ID: 50,
Credentials: map[string]any{
"chatgpt_account_id": "team-old",
"chatgpt_user_id": "user-old",
"email": "old@example.com",
"access_token": "access-old",
"agent_runtime_id": "runtime-old",
},
}
index := buildCodexAccountIndex([]service.Account{legacy})
updated := service.Account{
ID: 50,
Credentials: map[string]any{
"chatgpt_account_id": "team-new",
"chatgpt_user_id": "user-new",
"email": "new@example.com",
"access_token": "access-new",
"agent_runtime_id": "runtime-new",
},
}
index.Add(updated)
oldKeys := append(buildCodexStoredIdentityKeys("team-old", "user-old", "old@example.com", "access-old"), "agent:runtime-old")
for _, key := range oldKeys {
if got, matchedKey := index.Find([]string{key}, "user-old"); got != nil {
t.Fatalf("stale account matched by %q: account ID %d", matchedKey, got.ID)
}
}
newKeys := append(buildCodexStoredIdentityKeys("team-new", "user-new", "new@example.com", "access-new"), "agent:runtime-new")
for _, key := range newKeys {
got, matchedKey := index.Find([]string{key}, "user-new")
if got == nil || got.ID != updated.ID {
t.Fatalf("updated account not found by %q: account=%v matched=%q", key, got, matchedKey)
}
}
}
func TestCodexAccountIndexUpdatePreservesSharedKeyCandidateOrder(t *testing.T) {
first := service.Account{
ID: 60,
Credentials: map[string]any{
"chatgpt_account_id": "team-shared",
"access_token": "access-first-old",
},
}
second := service.Account{
ID: 61,
Credentials: map[string]any{
"chatgpt_account_id": "team-shared",
"access_token": "access-second",
},
}
index := buildCodexAccountIndex([]service.Account{first, second})
first.Credentials["access_token"] = "access-first-new"
index.Add(first)
got, matchedKey := index.Find([]string{"account:team-shared"}, "")
if got == nil || got.ID != first.ID {
t.Fatalf("shared key candidate order changed after update: account=%v matched=%q", got, matchedKey)
}
}
func TestCodexIdentitySeenDistinguishesTeamMembers(t *testing.T) {
seen := map[string]codexSeenIdentity{}
member1 := buildCodexImportIdentityKeys("team-1", "user-1", "", "token-1", "refresh-1")
@@ -63,6 +63,7 @@ type AccountHandler struct {
tokenCacheInvalidator service.TokenCacheInvalidator
grokImportProber grokImportProber
upstreamBillingProbe *service.UpstreamBillingProbeService
ollamaCloudUsage *service.OllamaCloudUsageService
}
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
@@ -70,6 +71,10 @@ func (h *AccountHandler) SetUpstreamBillingProbeService(probe *service.UpstreamB
h.upstreamBillingProbe = probe
}
func (h *AccountHandler) SetOllamaCloudUsageService(usage *service.OllamaCloudUsageService) {
h.ollamaCloudUsage = usage
}
// NewAccountHandler creates a new admin account handler
func NewAccountHandler(
adminService service.AdminService,
@@ -142,6 +147,8 @@ type UpdateAccountRequest struct {
GroupIDs *[]int64 `json:"group_ids"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired *bool `json:"auto_pause_on_expired"`
ProbeEnabled *bool `json:"upstream_billing_probe_enabled"`
RateSyncEnabled *bool `json:"upstream_billing_rate_sync_enabled"`
ConfirmMixedChannelRisk *bool `json:"confirm_mixed_channel_risk"` // 用户确认混合渠道风险
}
@@ -208,9 +215,17 @@ type AccountSchedulerGroupScore struct {
const accountListGroupUngroupedQueryValue = "ungrouped"
func (h *AccountHandler) accountResponseFromService(account *service.Account) *dto.Account {
out := dto.AccountFromService(account)
if h != nil && h.ollamaCloudUsage != nil && out != nil {
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
}
return out
}
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
item := AccountWithConcurrency{
Account: dto.AccountFromService(account),
Account: h.accountResponseFromService(account),
CurrentConcurrency: 0,
}
if account == nil {
@@ -523,6 +538,16 @@ func (h *AccountHandler) List(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
if h.ollamaCloudUsage != nil && len(accounts) > 0 {
accountPointers := make([]*service.Account, len(accounts))
for index := range accounts {
accountPointers[index] = &accounts[index]
}
if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), accountPointers); err != nil {
response.ErrorFrom(c, err)
return
}
}
// Get current concurrency counts for all accounts
accountIDs := make([]int64, len(accounts))
@@ -626,7 +651,7 @@ func (h *AccountHandler) List(c *gin.Context) {
for i := range accounts {
acc := &accounts[i]
item := AccountWithConcurrency{
Account: dto.AccountFromService(acc),
Account: h.accountResponseFromService(acc),
CurrentConcurrency: concurrencyCounts[acc.ID],
SchedulerScore: schedulerScores[acc.ID],
SchedulerScores: schedulerGroupScores[acc.ID],
@@ -740,6 +765,12 @@ func (h *AccountHandler) GetByID(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
if h.ollamaCloudUsage != nil {
if err := h.ollamaCloudUsage.ResolveAccounts(c.Request.Context(), []*service.Account{account}); err != nil {
response.ErrorFrom(c, err)
return
}
}
response.Success(c, h.buildAccountResponseWithRuntime(c.Request.Context(), account))
}
@@ -958,6 +989,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
GroupIDs: req.GroupIDs,
ExpiresAt: req.ExpiresAt,
AutoPauseOnExpired: req.AutoPauseOnExpired,
ProbeEnabled: req.ProbeEnabled,
RateSyncEnabled: req.RateSyncEnabled,
SkipMixedChannelCheck: skipCheck,
})
if err != nil {
@@ -1502,6 +1535,141 @@ func (h *AccountHandler) RevertProxyFallback(c *gin.Context) {
response.Success(c, gin.H{"message": "reverted"})
}
// BatchDelete handles deleting multiple accounts with bounded concurrency.
// POST /api/v1/admin/accounts/batch-delete
func (h *AccountHandler) BatchDelete(c *gin.Context) {
var req struct {
AccountIDs []int64 `json:"account_ids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
accountIDs := normalizeInt64IDList(req.AccountIDs)
if len(accountIDs) == 0 {
response.BadRequest(c, "account_ids is required")
return
}
accounts, err := h.adminService.GetAccountsByIDs(c.Request.Context(), accountIDs)
if err != nil {
response.ErrorFrom(c, err)
return
}
type deleteError struct {
AccountID int64 `json:"account_id"`
Error string `json:"error"`
}
requestedIDs := make(map[int64]struct{}, len(accountIDs))
for _, accountID := range accountIDs {
requestedIDs[accountID] = struct{}{}
}
accountsByID := make(map[int64]*service.Account, len(accounts))
for _, account := range accounts {
if account != nil {
accountsByID[account.ID] = account
}
}
rootIDs := make([]int64, 0, len(accountIDs))
dependentIDs := make(map[int64][]int64)
failedIDs := make([]int64, 0)
errorsByAccount := make([]deleteError, 0)
for _, accountID := range accountIDs {
account := accountsByID[accountID]
if account == nil {
failedIDs = append(failedIDs, accountID)
errorsByAccount = append(errorsByAccount, deleteError{
AccountID: accountID,
Error: "account not found",
})
continue
}
rootID := accountID
visited := map[int64]struct{}{accountID: {}}
for {
current := accountsByID[rootID]
if current == nil || current.ParentAccountID == nil {
break
}
parentID := *current.ParentAccountID
if _, selected := requestedIDs[parentID]; !selected {
break
}
if _, exists := accountsByID[parentID]; !exists {
break
}
if _, cyclic := visited[parentID]; cyclic {
rootID = accountID
break
}
visited[parentID] = struct{}{}
rootID = parentID
}
if rootID != accountID {
dependentIDs[rootID] = append(dependentIDs[rootID], accountID)
continue
}
rootIDs = append(rootIDs, accountID)
}
const maxConcurrency = 5
g, gctx := errgroup.WithContext(c.Request.Context())
g.SetLimit(maxConcurrency)
var mu sync.Mutex
successIDs := make([]int64, 0, len(accountIDs))
// Every worker returns nil so one account failure does not cancel the remaining deletions.
for _, id := range rootIDs {
accountID := id
g.Go(func() error {
err := h.adminService.DeleteAccount(gctx, accountID)
mu.Lock()
defer mu.Unlock()
affectedIDs := append([]int64{accountID}, dependentIDs[accountID]...)
if err != nil {
for _, affectedID := range affectedIDs {
failedIDs = append(failedIDs, affectedID)
errorsByAccount = append(errorsByAccount, deleteError{
AccountID: affectedID,
Error: err.Error(),
})
}
return nil
}
successIDs = append(successIDs, affectedIDs...)
return nil
})
}
if err := g.Wait(); err != nil {
response.ErrorFrom(c, err)
return
}
sort.Slice(successIDs, func(i, j int) bool { return successIDs[i] < successIDs[j] })
sort.Slice(failedIDs, func(i, j int) bool { return failedIDs[i] < failedIDs[j] })
sort.Slice(errorsByAccount, func(i, j int) bool {
return errorsByAccount[i].AccountID < errorsByAccount[j].AccountID
})
response.Success(c, gin.H{
"total": len(accountIDs),
"success": len(successIDs),
"failed": len(failedIDs),
"success_ids": successIDs,
"failed_ids": failedIDs,
"errors": errorsByAccount,
})
}
// BatchClearError handles batch clearing account errors
// POST /api/v1/admin/accounts/batch-clear-error
func (h *AccountHandler) BatchClearError(c *gin.Context) {
@@ -213,6 +213,38 @@ func TestAccountHandlerGetAvailableModels_OpenAIOAuthPassthroughFallsBackToDefau
require.NotEqual(t, "gpt-5", resp.Data[0].ID)
}
func TestAccountHandlerGetAvailableModels_OpenAIAPIKeyDefaultsToConcreteGPT56Sol(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 46,
Name: "openai-apikey",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: service.StatusActive,
Credentials: map[string]any{
"api_key": "test-key",
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/46/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
require.NotEmpty(t, resp.Data)
require.Equal(t, "gpt-5.6-sol", resp.Data[0].ID)
}
func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(t *testing.T) {
parentID := int64(100)
svc := &availableModelsAdminService{
@@ -0,0 +1,173 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"sync"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type batchDeleteAdminService struct {
*stubAdminService
mu sync.Mutex
active int
maxActive int
deletedIDs []int64
deleteErrorsByID map[int64]error
accountsByID map[int64]*service.Account
}
func (s *batchDeleteAdminService) GetAccountsByIDs(_ context.Context, ids []int64) ([]*service.Account, error) {
accounts := make([]*service.Account, 0, len(ids))
for _, id := range ids {
if s.accountsByID != nil {
if account, ok := s.accountsByID[id]; ok {
accounts = append(accounts, account)
}
continue
}
accounts = append(accounts, &service.Account{ID: id})
}
return accounts, nil
}
func (s *batchDeleteAdminService) DeleteAccount(ctx context.Context, id int64) error {
s.mu.Lock()
s.active++
if s.active > s.maxActive {
s.maxActive = s.active
}
s.mu.Unlock()
select {
case <-ctx.Done():
s.mu.Lock()
s.active--
s.mu.Unlock()
return ctx.Err()
case <-time.After(10 * time.Millisecond):
}
s.mu.Lock()
defer s.mu.Unlock()
s.active--
s.deletedIDs = append(s.deletedIDs, id)
return s.deleteErrorsByID[id]
}
func setupAccountBatchDeleteRouter(adminSvc *batchDeleteAdminService) *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
handler := NewAccountHandler(adminSvc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router.POST("/api/v1/admin/accounts/batch-delete", handler.BatchDelete)
return router
}
func TestAccountHandlerBatchDeleteReturnsStablePerAccountResults(t *testing.T) {
adminSvc := &batchDeleteAdminService{
stubAdminService: newStubAdminService(),
deleteErrorsByID: map[int64]error{
3: errors.New("delete failed"),
},
}
router := setupAccountBatchDeleteRouter(adminSvc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/admin/accounts/batch-delete",
bytes.NewBufferString(`{"account_ids":[5,4,3,2,1,2,0,-1]}`),
)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var payload struct {
Data struct {
Total int `json:"total"`
Success int `json:"success"`
Failed int `json:"failed"`
SuccessIDs []int64 `json:"success_ids"`
FailedIDs []int64 `json:"failed_ids"`
Errors []struct {
AccountID int64 `json:"account_id"`
Error string `json:"error"`
} `json:"errors"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Equal(t, 5, payload.Data.Total)
require.Equal(t, 4, payload.Data.Success)
require.Equal(t, 1, payload.Data.Failed)
require.Equal(t, []int64{1, 2, 4, 5}, payload.Data.SuccessIDs)
require.Equal(t, []int64{3}, payload.Data.FailedIDs)
require.Equal(t, int64(3), payload.Data.Errors[0].AccountID)
require.Equal(t, "delete failed", payload.Data.Errors[0].Error)
require.LessOrEqual(t, adminSvc.maxActive, 5)
require.Greater(t, adminSvc.maxActive, 1)
}
func TestAccountHandlerBatchDeleteDoesNotRaceSelectedShadowWithParent(t *testing.T) {
parentID := int64(1)
adminSvc := &batchDeleteAdminService{
stubAdminService: newStubAdminService(),
accountsByID: map[int64]*service.Account{
1: {ID: 1},
2: {ID: 2, ParentAccountID: &parentID},
3: {ID: 3},
},
}
router := setupAccountBatchDeleteRouter(adminSvc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/admin/accounts/batch-delete",
bytes.NewBufferString(`{"account_ids":[1,2,3]}`),
)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var payload struct {
Data struct {
SuccessIDs []int64 `json:"success_ids"`
FailedIDs []int64 `json:"failed_ids"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &payload))
require.Equal(t, []int64{1, 2, 3}, payload.Data.SuccessIDs)
require.Empty(t, payload.Data.FailedIDs)
require.ElementsMatch(t, []int64{1, 3}, adminSvc.deletedIDs)
}
func TestAccountHandlerBatchDeleteRejectsEmptyNormalizedIDs(t *testing.T) {
adminSvc := &batchDeleteAdminService{
stubAdminService: newStubAdminService(),
}
router := setupAccountBatchDeleteRouter(adminSvc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(
http.MethodPost,
"/api/v1/admin/accounts/batch-delete",
bytes.NewBufferString(`{"account_ids":[0,-1]}`),
)
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
}
@@ -147,6 +147,28 @@ func TestAccountHandlerUpdateMixedChannelConflictSimplifiedResponse(t *testing.T
require.False(t, hasRequireConfirmation)
}
func TestAccountHandlerUpdateMapsUpstreamBillingRateSyncSettings(t *testing.T) {
adminSvc := newStubAdminService()
router := setupAccountMixedChannelRouter(adminSvc)
body, _ := json.Marshal(map[string]any{
"name": "gemini-key",
"upstream_billing_probe_enabled": true,
"upstream_billing_rate_sync_enabled": true,
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/api/v1/admin/accounts/42", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.NotNil(t, adminSvc.lastUpdateAccountInput)
require.NotNil(t, adminSvc.lastUpdateAccountInput.ProbeEnabled)
require.True(t, *adminSvc.lastUpdateAccountInput.ProbeEnabled)
require.NotNil(t, adminSvc.lastUpdateAccountInput.RateSyncEnabled)
require.True(t, *adminSvc.lastUpdateAccountInput.RateSyncEnabled)
}
func TestAccountHandlerBulkUpdateMixedChannelConflict(t *testing.T) {
adminSvc := newStubAdminService()
adminSvc.bulkUpdateAccountErr = &service.MixedChannelError{
@@ -0,0 +1,162 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
type ollamaCloudUsageSessionRequest struct {
Session string `json:"session" binding:"required"`
}
type ollamaCloudUsageAutoRefreshRequest struct {
Enabled *bool `json:"enabled" binding:"required"`
}
func (h *AccountHandler) GetOllamaCloudUsageSettings(c *gin.Context) {
if h.ollamaCloudUsage == nil {
response.ErrorFrom(c, service.ErrOllamaCloudUsageUnavailable)
return
}
settings, err := h.ollamaCloudUsage.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *AccountHandler) UpdateOllamaCloudUsageSettings(c *gin.Context) {
if h.ollamaCloudUsage == nil {
response.ErrorFrom(c, service.ErrOllamaCloudUsageUnavailable)
return
}
var req service.OllamaCloudUsageSettings
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := h.ollamaCloudUsage.UpdateSettings(c.Request.Context(), &req); err != nil {
response.ErrorFrom(c, err)
return
}
settings, err := h.ollamaCloudUsage.GetSettings(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, settings)
}
func (h *AccountHandler) GetOllamaCloudUsage(c *gin.Context) {
if !h.requireOllamaCloudUsage(c) {
return
}
accountID, ok := ollamaCloudUsageAccountID(c)
if !ok {
return
}
state, err := h.ollamaCloudUsage.GetState(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) SaveOllamaCloudUsageSession(c *gin.Context) {
if !h.requireOllamaCloudUsage(c) {
return
}
accountID, ok := ollamaCloudUsageAccountID(c)
if !ok {
return
}
var req ollamaCloudUsageSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
state, err := h.ollamaCloudUsage.SaveSession(c.Request.Context(), accountID, req.Session)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) DeleteOllamaCloudUsageSession(c *gin.Context) {
if !h.requireOllamaCloudUsage(c) {
return
}
accountID, ok := ollamaCloudUsageAccountID(c)
if !ok {
return
}
state, err := h.ollamaCloudUsage.DeleteSession(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) SetOllamaCloudUsageAutoRefresh(c *gin.Context) {
if !h.requireOllamaCloudUsage(c) {
return
}
accountID, ok := ollamaCloudUsageAccountID(c)
if !ok {
return
}
var req ollamaCloudUsageAutoRefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
state, err := h.ollamaCloudUsage.SetAutoRefresh(c.Request.Context(), accountID, *req.Enabled)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) RefreshOllamaCloudUsage(c *gin.Context) {
if !h.requireOllamaCloudUsage(c) {
return
}
accountID, ok := ollamaCloudUsageAccountID(c)
if !ok {
return
}
state, err := h.ollamaCloudUsage.Refresh(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, state)
}
func (h *AccountHandler) requireOllamaCloudUsage(c *gin.Context) bool {
if h != nil && h.ollamaCloudUsage != nil {
return true
}
response.ErrorFrom(c, service.ErrOllamaCloudUsageUnavailable)
return false
}
func ollamaCloudUsageAccountID(c *gin.Context) (int64, bool) {
if c == nil {
return 0, false
}
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || accountID <= 0 {
response.BadRequest(c, "Invalid account ID")
return 0, false
}
return accountID, true
}
@@ -0,0 +1,277 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type ollamaCloudUsageHandlerTestRepo struct {
service.AccountRepository
account *service.Account
accounts []*service.Account
groupResolveCalls int
}
func (r *ollamaCloudUsageHandlerTestRepo) GetByID(_ context.Context, id int64) (*service.Account, error) {
if r.account != nil && r.account.ID == id {
return r.account, nil
}
for _, account := range r.accounts {
if account.ID == id {
return account, nil
}
}
return nil, service.ErrAccountNotFound
}
func (r *ollamaCloudUsageHandlerTestRepo) ListOllamaCloudUsageGroupAccounts(_ context.Context, _ []*service.Account) ([]service.Account, error) {
r.groupResolveCalls++
result := make([]service.Account, 0, len(r.accounts)+1)
if r.account != nil {
result = append(result, *r.account)
}
for _, account := range r.accounts {
result = append(result, *account)
}
return result, nil
}
func (r *ollamaCloudUsageHandlerTestRepo) SaveOllamaCloudUsageSession(context.Context, *service.Account, string, bool) error {
return nil
}
func (r *ollamaCloudUsageHandlerTestRepo) DeleteOllamaCloudUsageSession(context.Context, *service.Account) error {
return nil
}
func (r *ollamaCloudUsageHandlerTestRepo) SetOllamaCloudUsageAutoRefresh(context.Context, *service.Account, bool) error {
return nil
}
func (r *ollamaCloudUsageHandlerTestRepo) UpdateOllamaCloudUsageSnapshot(context.Context, *service.Account, *service.OllamaCloudUsageSnapshot) error {
return nil
}
func (r *ollamaCloudUsageHandlerTestRepo) DisableOllamaCloudUsageAutoRefresh(context.Context, *service.Account) error {
return nil
}
func (r *ollamaCloudUsageHandlerTestRepo) ListDueOllamaCloudUsageAccounts(context.Context, time.Time, time.Duration, time.Duration, int) ([]service.Account, error) {
return nil, nil
}
func newOllamaCloudUsageHandlerTestService(t *testing.T) *service.OllamaCloudUsageService {
t.Helper()
svc := service.NewOllamaCloudUsageService(nil, nil, nil, nil, false)
t.Cleanup(svc.Stop)
return svc
}
func newOllamaCloudUsageHandlerContext(method, target, body, id string) (*gin.Context, *httptest.ResponseRecorder) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(method, target, bytes.NewBufferString(body))
request.Header.Set("Content-Type", "application/json")
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = request
if id != "" {
ctx.Params = gin.Params{{Key: "id", Value: id}}
}
return ctx, recorder
}
func TestOllamaCloudUsageHandlersValidateRequestsAndDependencies(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newOllamaCloudUsageHandlerTestService(t)
t.Run("invalid account id", func(t *testing.T) {
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/not-an-id/ollama-cloud-usage", "", "not-an-id")
(&AccountHandler{ollamaCloudUsage: svc}).GetOllamaCloudUsage(ctx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
})
t.Run("empty session", func(t *testing.T) {
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodPut, "/admin/accounts/7/ollama-cloud-usage/session", `{"session":""}`, "7")
(&AccountHandler{ollamaCloudUsage: svc}).SaveOllamaCloudUsageSession(ctx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
})
t.Run("missing enabled", func(t *testing.T) {
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodPut, "/admin/accounts/7/ollama-cloud-usage/auto-refresh", `{}`, "7")
(&AccountHandler{ollamaCloudUsage: svc}).SetOllamaCloudUsageAutoRefresh(ctx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
})
t.Run("service unavailable", func(t *testing.T) {
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/7/ollama-cloud-usage", "", "7")
(&AccountHandler{}).GetOllamaCloudUsage(ctx)
require.Equal(t, http.StatusServiceUnavailable, recorder.Code)
require.Contains(t, recorder.Body.String(), "OLLAMA_CLOUD_USAGE_UNAVAILABLE")
})
}
func TestOllamaCloudUsageEncryptionKeyStateConsistentAcrossAccountResponses(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, configured := range []bool{false, true} {
t.Run("configured="+strconv.FormatBool(configured), func(t *testing.T) {
account := &service.Account{
ID: 7,
Name: "ollama",
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "test-key"},
Extra: map[string]any{},
Status: service.StatusActive,
}
adminService := newStubAdminService()
adminService.accounts = []service.Account{*account}
adminService.getAccountResult = account
usageService := service.NewOllamaCloudUsageService(
&ollamaCloudUsageHandlerTestRepo{account: account}, nil, nil, nil, configured,
)
t.Cleanup(usageService.Stop)
handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler.SetOllamaCloudUsageService(usageService)
router := gin.New()
router.GET("/accounts", handler.List)
router.GET("/accounts/:id", handler.GetByID)
router.GET("/accounts/:id/ollama-cloud-usage", handler.GetOllamaCloudUsage)
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil))
require.Equal(t, http.StatusOK, listRecorder.Code)
var listPayload struct {
Data struct {
Items []struct {
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
} `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
require.Len(t, listPayload.Data.Items, 1)
require.NotNil(t, listPayload.Data.Items[0].OllamaCloudUsage)
detailRecorder := httptest.NewRecorder()
router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7", nil))
require.Equal(t, http.StatusOK, detailRecorder.Code)
var detailPayload struct {
Data struct {
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
require.NotNil(t, detailPayload.Data.OllamaCloudUsage)
stateRecorder := httptest.NewRecorder()
router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/7/ollama-cloud-usage", nil))
require.Equal(t, http.StatusOK, stateRecorder.Code)
var statePayload struct {
Data service.OllamaCloudUsageState `json:"data"`
}
require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload))
listConfigured := listPayload.Data.Items[0].OllamaCloudUsage.EncryptionKeyConfigured
detailConfigured := detailPayload.Data.OllamaCloudUsage.EncryptionKeyConfigured
require.Equal(t, configured, listConfigured)
require.Equal(t, statePayload.Data.EncryptionKeyConfigured, listConfigured)
require.Equal(t, statePayload.Data.EncryptionKeyConfigured, detailConfigured)
})
}
}
func TestOllamaCloudUsageSharedStateMatchesListDetailAndSpecialEndpointWithoutListNPlusOne(t *testing.T) {
gin.SetMode(gin.TestMode)
now := time.Now().UTC()
source := &service.Account{
ID: 7, Name: "source", Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "shared-secret-key"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "ciphertext-secret",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: &service.OllamaCloudUsageSnapshot{
Status: service.OllamaCloudUsageStatusOK, Data: &service.OllamaCloudUsageData{Plan: "pro"},
LastAttemptAt: now, NextRefreshAt: now.Add(time.Hour),
},
},
Status: service.StatusActive,
}
sibling := &service.Account{
ID: 8, Name: "sibling", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "HTTPS://WWW.OLLAMA.COM:443/v1", "api_key": "shared-secret-key"},
Extra: map[string]any{}, Status: service.StatusActive,
}
repo := &ollamaCloudUsageHandlerTestRepo{accounts: []*service.Account{source, sibling}}
adminService := newStubAdminService()
adminService.accounts = []service.Account{*source, *sibling}
adminService.getAccountResult = sibling
usageService := service.NewOllamaCloudUsageService(repo, nil, nil, nil, true)
t.Cleanup(usageService.Stop)
handler := NewAccountHandler(adminService, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
handler.SetOllamaCloudUsageService(usageService)
router := gin.New()
router.GET("/accounts", handler.List)
router.GET("/accounts/:id", handler.GetByID)
router.GET("/accounts/:id/ollama-cloud-usage", handler.GetOllamaCloudUsage)
listRecorder := httptest.NewRecorder()
router.ServeHTTP(listRecorder, httptest.NewRequest(http.MethodGet, "/accounts?page=1&page_size=20", nil))
require.Equal(t, http.StatusOK, listRecorder.Code)
require.Equal(t, 1, repo.groupResolveCalls, "the full list page must use one group-resolution batch")
var listPayload struct {
Data struct {
Items []struct {
ID int64 `json:"id"`
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
} `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(listRecorder.Body.Bytes(), &listPayload))
require.Len(t, listPayload.Data.Items, 2)
for _, item := range listPayload.Data.Items {
require.True(t, item.OllamaCloudUsage.Configured)
require.Equal(t, "pro", item.OllamaCloudUsage.Snapshot.Data.Plan)
}
detailRecorder := httptest.NewRecorder()
router.ServeHTTP(detailRecorder, httptest.NewRequest(http.MethodGet, "/accounts/8", nil))
require.Equal(t, http.StatusOK, detailRecorder.Code)
var detailPayload struct {
Data struct {
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(detailRecorder.Body.Bytes(), &detailPayload))
stateRecorder := httptest.NewRecorder()
router.ServeHTTP(stateRecorder, httptest.NewRequest(http.MethodGet, "/accounts/8/ollama-cloud-usage", nil))
require.Equal(t, http.StatusOK, stateRecorder.Code)
var statePayload struct {
Data service.OllamaCloudUsageState `json:"data"`
}
require.NoError(t, json.Unmarshal(stateRecorder.Body.Bytes(), &statePayload))
require.Equal(t, statePayload.Data.Configured, detailPayload.Data.OllamaCloudUsage.Configured)
require.Equal(t, statePayload.Data.Snapshot, detailPayload.Data.OllamaCloudUsage.Snapshot)
for _, body := range []string{listRecorder.Body.String(), detailRecorder.Body.String(), stateRecorder.Body.String()} {
require.NotContains(t, body, "shared-secret-key")
require.NotContains(t, body, "ciphertext-secret")
}
}
func TestGetOllamaCloudUsageSettingsHandlerSuccess(t *testing.T) {
gin.SetMode(gin.TestMode)
ctx, recorder := newOllamaCloudUsageHandlerContext(http.MethodGet, "/admin/accounts/ollama-cloud-usage/settings", "", "")
handler := &AccountHandler{ollamaCloudUsage: newOllamaCloudUsageHandlerTestService(t)}
handler.GetOllamaCloudUsageSettings(ctx)
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`)
}
@@ -34,6 +34,11 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
router.GET("/api/v1/admin/groups", groupHandler.List)
router.GET("/api/v1/admin/groups/all", groupHandler.GetAll)
router.GET("/api/v1/admin/groups/:id/models-list-candidates", groupHandler.GetModelsListCandidates)
router.GET("/api/v1/admin/groups/:id/composite-routes", groupHandler.ListCompositeRoutes)
router.POST("/api/v1/admin/groups/:id/composite-routes", groupHandler.CreateCompositeRoute)
router.POST("/api/v1/admin/groups/:id/composite-routes/preview", groupHandler.PreviewCompositeRoute)
router.PUT("/api/v1/admin/groups/:id/composite-routes/:route_id", groupHandler.UpdateCompositeRoute)
router.DELETE("/api/v1/admin/groups/:id/composite-routes/:route_id", groupHandler.DeleteCompositeRoute)
router.GET("/api/v1/admin/groups/:id", groupHandler.GetByID)
router.POST("/api/v1/admin/groups", groupHandler.Create)
router.PUT("/api/v1/admin/groups/:id", groupHandler.Update)
@@ -184,7 +189,54 @@ func TestGroupHandlerEndpoints(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), "gpt-5.5")
body, _ := json.Marshal(map[string]any{"name": "new", "platform": "anthropic", "subscription_type": "standard"})
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/2/composite-routes", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), "openrouter/gpt-5")
body, _ := json.Marshal(map[string]any{
"public_model": "openrouter/gpt-5",
"match_type": "exact",
"target_platform": "openai",
"upstream_model": "gpt-5",
"endpoint": "chat_completions",
"enabled": true,
})
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/2/composite-routes", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusCreated, rec.Code)
require.Contains(t, rec.Body.String(), "gpt-5")
body, _ = json.Marshal(map[string]any{
"public_model": "openrouter/gpt-5",
"target_platform": "openai",
"upstream_model": "gpt-5",
"endpoint": "responses",
"enabled": true,
})
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPut, "/api/v1/admin/groups/2/composite-routes/1", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
body, _ = json.Marshal(map[string]any{"model": "gpt-5", "endpoint": "responses"})
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups/2/composite-routes/preview", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), `"source":"detector"`)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodDelete, "/api/v1/admin/groups/2/composite-routes/1", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
body, _ = json.Marshal(map[string]any{"name": "new", "platform": "anthropic", "subscription_type": "standard"})
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodPost, "/api/v1/admin/groups", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
@@ -32,6 +32,7 @@ type stubAdminService struct {
createAccountErr error
createSparkShadowErr error
updateAccountErr error
lastUpdateAccountInput *service.UpdateAccountInput
bulkUpdateAccountErr error
lastBulkUpdateAccountInput *service.BulkUpdateAccountsInput
getAccountResult *service.Account
@@ -293,6 +294,64 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id
return []string{"claude-sonnet-4-6"}, nil
}
func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) {
return []service.CompositeModelRoute{
{
ID: 1,
GroupID: groupID,
PublicModel: "openrouter/gpt-5",
MatchType: service.CompositeRouteMatchExact,
TargetPlatform: service.PlatformOpenAI,
UpstreamModel: "gpt-5",
Endpoint: service.CompositeRouteEndpointAny,
Priority: 100,
Enabled: true,
},
}, nil
}
func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
return &service.CompositeModelRoute{
ID: 1,
GroupID: groupID,
PublicModel: input.PublicModel,
MatchType: input.MatchType,
TargetPlatform: input.TargetPlatform,
UpstreamModel: input.UpstreamModel,
Endpoint: input.Endpoint,
Priority: input.Priority,
Enabled: input.Enabled,
Notes: input.Notes,
}, nil
}
func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
return &service.CompositeModelRoute{
ID: routeID,
GroupID: groupID,
PublicModel: input.PublicModel,
MatchType: input.MatchType,
TargetPlatform: input.TargetPlatform,
UpstreamModel: input.UpstreamModel,
Endpoint: input.Endpoint,
Priority: input.Priority,
Enabled: input.Enabled,
Notes: input.Notes,
}, nil
}
func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
return nil
}
func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) {
decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint)
if err != nil {
return nil, err
}
return &decision, nil
}
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
return &group, nil
@@ -443,6 +502,7 @@ func (s *stubAdminService) RecoverDuplicateAccount(ctx context.Context, id int64
func (s *stubAdminService) UpdateAccount(ctx context.Context, id int64, input *service.UpdateAccountInput) (*service.Account, error) {
s.updateAccountCalls++
s.lastUpdateAccountInput = input
if s.updateAccountErr != nil {
return nil, s.updateAccountErr
}
@@ -20,10 +20,12 @@ func NewContentModerationHandler(svc *service.ContentModerationService) *Content
}
type contentModerationConfigRequest struct {
Enabled *bool `json:"enabled"`
Mode *string `json:"mode"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
Enabled *bool `json:"enabled"`
Mode *string `json:"mode"`
BaseURL *string `json:"base_url"`
Model *string `json:"model"`
// 审计请求使用的代理服务器:null 不修改;0 清除(直连);>0 指定代理。
ProxyID *int64 `json:"proxy_id"`
APIKey *string `json:"api_key"`
APIKeys *[]string `json:"api_keys"`
APIKeysMode string `json:"api_keys_mode"`
@@ -60,6 +62,7 @@ type contentModerationAPIKeyTestRequest struct {
BaseURL string `json:"base_url"`
Model string `json:"model"`
TimeoutMS int `json:"timeout_ms"`
ProxyID *int64 `json:"proxy_id"`
Prompt string `json:"prompt"`
Images []string `json:"images"`
}
@@ -88,6 +91,7 @@ func (h *ContentModerationHandler) UpdateConfig(c *gin.Context) {
Mode: req.Mode,
BaseURL: req.BaseURL,
Model: req.Model,
ProxyID: req.ProxyID,
APIKey: req.APIKey,
APIKeys: req.APIKeys,
APIKeysMode: req.APIKeysMode,
@@ -134,6 +138,7 @@ func (h *ContentModerationHandler) TestAPIKeys(c *gin.Context) {
BaseURL: req.BaseURL,
Model: req.Model,
TimeoutMS: req.TimeoutMS,
ProxyID: req.ProxyID,
Prompt: req.Prompt,
Images: req.Images,
})
+179 -2
View File
@@ -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
@@ -87,7 +98,7 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -107,6 +118,9 @@ type CreateGroupRequest struct {
PeakStart string `json:"peak_start"`
PeakEnd string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
@@ -125,6 +139,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"`
@@ -144,7 +159,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -165,6 +180,9 @@ type UpdateGroupRequest struct {
PeakStart *string `json:"peak_start"`
PeakEnd *string `json:"peak_end"`
PeakRateMultiplier *float64 `json:"peak_rate_multiplier"`
ProfitControlEnabled *bool `json:"profit_control_enabled"`
ProfitMinMargin *float64 `json:"profit_min_margin"`
ProfitSafetyBuffer *float64 `json:"profit_safety_buffer"`
ImagePrice1K *float64 `json:"image_price_1k"`
ImagePrice2K *float64 `json:"image_price_2k"`
ImagePrice4K *float64 `json:"image_price_4k"`
@@ -183,6 +201,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"`
@@ -198,6 +217,22 @@ type UpdateGroupRequest struct {
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
}
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
Enabled *bool `json:"enabled"`
Notes string `json:"notes"`
}
type CompositeRoutePreviewRequest struct {
Model string `json:"model" binding:"required"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
}
// List handles listing all groups with pagination
// GET /api/v1/admin/groups
func (h *GroupHandler) List(c *gin.Context) {
@@ -233,6 +268,133 @@ func (h *GroupHandler) List(c *gin.Context) {
response.Paginated(c, outGroups, total, page, pageSize)
}
// ListCompositeRoutes handles listing composite model routes for one group.
// GET /api/v1/admin/groups/:id/composite-routes
func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
}
routes, err := h.adminService.ListCompositeRoutes(c.Request.Context(), groupID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, routes)
}
// CreateCompositeRoute handles creating one composite model route.
// POST /api/v1/admin/groups/:id/composite-routes
func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
}
var req CompositeRouteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request body: "+err.Error())
return
}
route, err := h.adminService.CreateCompositeRoute(c.Request.Context(), groupID, compositeRouteRequestToInput(req, true))
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Created(c, route)
}
// UpdateCompositeRoute handles replacing one composite model route.
// PUT /api/v1/admin/groups/:id/composite-routes/:route_id
func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
}
routeID, ok := parsePositiveIDParam(c, "route_id")
if !ok {
return
}
var req CompositeRouteRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request body: "+err.Error())
return
}
route, err := h.adminService.UpdateCompositeRoute(c.Request.Context(), groupID, routeID, compositeRouteRequestToInput(req, true))
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, route)
}
// DeleteCompositeRoute handles deleting one composite model route.
// DELETE /api/v1/admin/groups/:id/composite-routes/:route_id
func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
}
routeID, ok := parsePositiveIDParam(c, "route_id")
if !ok {
return
}
if err := h.adminService.DeleteCompositeRoute(c.Request.Context(), groupID, routeID); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "Composite route deleted"})
}
// PreviewCompositeRoute resolves a model without mutating routes.
// POST /api/v1/admin/groups/:id/composite-routes/preview
func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) {
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
}
var req CompositeRoutePreviewRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request body: "+err.Error())
return
}
decision, err := h.adminService.PreviewCompositeRoute(c.Request.Context(), groupID, service.CompositeRoutePreviewRequest{
Model: req.Model,
Endpoint: req.Endpoint,
})
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, decision)
}
func compositeRouteRequestToInput(req CompositeRouteRequest, defaultEnabled bool) service.CompositeRouteInput {
enabled := defaultEnabled
if req.Enabled != nil {
enabled = *req.Enabled
}
return service.CompositeRouteInput{
PublicModel: req.PublicModel,
MatchType: req.MatchType,
TargetPlatform: req.TargetPlatform,
UpstreamModel: req.UpstreamModel,
Endpoint: req.Endpoint,
Priority: req.Priority,
Enabled: enabled,
Notes: req.Notes,
}
}
func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) {
raw := c.Param(name)
id, err := strconv.ParseInt(raw, 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "Invalid "+name)
return 0, false
}
return id, true
}
// GetAll handles getting all active groups without pagination.
// Pass ?include_inactive=true to also include disabled groups (used by the
// API Key group filter, which needs to surface groups that still have API keys
@@ -319,6 +481,13 @@ func (h *GroupHandler) Create(c *gin.Context) {
return
}
// platform 是 omitempty:预校验必须用与 CreateGroup 落库一致的归一化平台,
// 否则省略 platform 的请求会被误判成「平台不支持利润控制」。
if err := service.ValidateProfitControlConfig(service.NormalizeGroupPlatform(req.Platform), req.ProfitControlEnabled, float64ValueOrDefault(req.ProfitMinMargin, 0), float64ValueOrDefault(req.ProfitSafetyBuffer, 0)); err != nil {
response.BadRequest(c, err.Error())
return
}
group, err := h.adminService.CreateGroup(c.Request.Context(), &service.CreateGroupInput{
Name: req.Name,
Description: req.Description,
@@ -341,6 +510,9 @@ func (h *GroupHandler) Create(c *gin.Context) {
PeakStart: req.PeakStart,
PeakEnd: req.PeakEnd,
PeakRateMultiplier: req.PeakRateMultiplier,
ProfitControlEnabled: req.ProfitControlEnabled,
ProfitMinMargin: req.ProfitMinMargin,
ProfitSafetyBuffer: req.ProfitSafetyBuffer,
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
@@ -356,6 +528,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,
@@ -459,6 +632,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
PeakStart: req.PeakStart,
PeakEnd: req.PeakEnd,
PeakRateMultiplier: req.PeakRateMultiplier,
ProfitControlEnabled: req.ProfitControlEnabled,
ProfitMinMargin: req.ProfitMinMargin,
ProfitSafetyBuffer: req.ProfitSafetyBuffer,
ImagePrice1K: req.ImagePrice1K,
ImagePrice2K: req.ImagePrice2K,
ImagePrice4K: req.ImagePrice4K,
@@ -474,6 +650,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,
@@ -283,7 +283,68 @@ func (h *PaymentHandler) ListPlans(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
response.Success(c, plans)
groupInfo := h.configService.GetGroupInfoMap(c.Request.Context(), plans)
response.Success(c, adminSubscriptionPlansForResponse(plans, groupInfo))
}
type AdminSubscriptionPlanResult struct {
ID int64 `json:"id"`
GroupID int64 `json:"group_id"`
GroupPlatform string `json:"group_platform,omitempty"`
GroupName string `json:"group_name,omitempty"`
RateMultiplier float64 `json:"rate_multiplier,omitempty"`
DailyLimitUSD *float64 `json:"daily_limit_usd,omitempty"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd,omitempty"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd,omitempty"`
ModelScopes []string `json:"supported_model_scopes,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Price float64 `json:"price"`
OriginalPrice *float64 `json:"original_price,omitempty"`
Currency string `json:"currency,omitempty"`
ValidityDays int `json:"validity_days"`
ValidityUnit string `json:"validity_unit"`
Features string `json:"features"`
ProductName string `json:"product_name"`
ForSale bool `json:"for_sale"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at,omitempty"`
UpdatedAt time.Time `json:"updated_at,omitempty"`
}
func adminSubscriptionPlansForResponse(plans []*dbent.SubscriptionPlan, groupInfo map[int64]service.PlanGroupInfo) []AdminSubscriptionPlanResult {
result := make([]AdminSubscriptionPlanResult, 0, len(plans))
for _, p := range plans {
if p == nil {
continue
}
gi := groupInfo[p.GroupID]
result = append(result, AdminSubscriptionPlanResult{
ID: int64(p.ID),
GroupID: p.GroupID,
GroupPlatform: gi.Platform,
GroupName: gi.Name,
RateMultiplier: gi.RateMultiplier,
DailyLimitUSD: gi.DailyLimitUSD,
WeeklyLimitUSD: gi.WeeklyLimitUSD,
MonthlyLimitUSD: gi.MonthlyLimitUSD,
ModelScopes: gi.ModelScopes,
Name: p.Name,
Description: p.Description,
Price: p.Price,
OriginalPrice: p.OriginalPrice,
Currency: p.Currency,
ValidityDays: p.ValidityDays,
ValidityUnit: p.ValidityUnit,
Features: p.Features,
ProductName: p.ProductName,
ForSale: p.ForSale,
SortOrder: p.SortOrder,
CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
})
}
return result
}
// CreatePlan creates a new subscription plan.
@@ -7,6 +7,7 @@ import (
"time"
dbent "github.com/Wei-Shaw/sub2api/ent"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) {
@@ -46,3 +47,61 @@ func TestSanitizeAdminPaymentOrderForResponseAddsCurrency(t *testing.T) {
t.Fatalf("expected provider_snapshot to be omitted, got %s", string(body))
}
}
func TestAdminSubscriptionPlansForResponseIncludesCompositeGroupInfo(t *testing.T) {
weekly := 25.0
now := time.Now()
plans := []*dbent.SubscriptionPlan{
{
ID: 11,
GroupID: 7,
Name: "All models",
Description: "Composite access",
Price: 19.99,
Currency: "CNY",
ValidityDays: 30,
ValidityUnit: "days",
Features: "OpenAI\nClaude\nGemini\nGrok",
ProductName: "Sub2API",
ForSale: true,
SortOrder: 1,
CreatedAt: now,
UpdatedAt: now,
},
}
groupInfo := map[int64]service.PlanGroupInfo{
7: {
Platform: service.PlatformComposite,
Name: "Bucket 2 composite",
RateMultiplier: 1.5,
WeeklyLimitUSD: &weekly,
ModelScopes: []string{"openai", "claude", "gemini", "grok"},
},
}
got := adminSubscriptionPlansForResponse(plans, groupInfo)
if len(got) != 1 {
t.Fatalf("expected one plan, got %d", len(got))
}
if got[0].GroupPlatform != service.PlatformComposite {
t.Fatalf("expected composite group platform, got %q", got[0].GroupPlatform)
}
if got[0].GroupName != "Bucket 2 composite" {
t.Fatalf("expected group name to be included, got %q", got[0].GroupName)
}
if got[0].WeeklyLimitUSD == nil || *got[0].WeeklyLimitUSD != weekly {
t.Fatalf("expected weekly limit to be included, got %#v", got[0].WeeklyLimitUSD)
}
if strings.Join(got[0].ModelScopes, ",") != "openai,claude,gemini,grok" {
t.Fatalf("expected model scopes to be preserved, got %#v", got[0].ModelScopes)
}
// 投影必须保留 ent 原始响应的全部套餐字段:currency 丢失曾导致编辑保存时
// 静默清空套餐货币(PlanEditDialog 回传空串 → SetCurrency(""))。
if got[0].Currency != "CNY" {
t.Fatalf("expected currency to be preserved, got %q", got[0].Currency)
}
if !got[0].CreatedAt.Equal(now) || !got[0].UpdatedAt.Equal(now) {
t.Fatalf("expected created_at/updated_at to be preserved, got %v / %v", got[0].CreatedAt, got[0].UpdatedAt)
}
}
@@ -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,
@@ -228,6 +233,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ContactInfo: settings.ContactInfo,
DocURL: settings.DocURL,
HomeContent: settings.HomeContent,
CompactHomeEnabled: settings.CompactHomeEnabled,
HideCcsImportButton: settings.HideCcsImportButton,
PurchaseSubscriptionEnabled: settings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: settings.PurchaseSubscriptionURL,
@@ -339,12 +345,17 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
PaymentCancelRateLimitUnit: paymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: paymentCfg.CancelRateLimitMode,
PaymentAlipayForceQRCode: paymentCfg.AlipayForceQRCode,
PaymentAlipayMobilePrecreateDeepLink: paymentCfg.AlipayMobilePrecreateDeepLink,
ChannelMonitorEnabled: settings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
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")
}
@@ -296,6 +299,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.HomeContent != after.HomeContent {
changed = append(changed, "home_content")
}
if before.CompactHomeEnabled != after.CompactHomeEnabled {
changed = append(changed, "compact_home_enabled")
}
if before.HideCcsImportButton != after.HideCcsImportButton {
changed = append(changed, "hide_ccs_import_button")
}
@@ -525,6 +531,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])
}
@@ -64,6 +64,18 @@ func TestDiffSettings_NoChangeWhenEqual(t *testing.T) {
}
}
func TestDiffSettings_DetectsCompactHomeChange(t *testing.T) {
changed := diffSettings(
&service.SystemSettings{},
&service.SystemSettings{CompactHomeEnabled: true},
nil,
nil,
UpdateSettingsRequest{},
)
require.Contains(t, changed, service.SettingKeyCompactHomeEnabled)
}
func TestEqualNullableFloat(t *testing.T) {
five := 5.0
five2 := 5.0
@@ -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"` // 审计日志保留天数
@@ -139,6 +142,7 @@ type UpdateSettingsRequest struct {
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
CompactHomeEnabled bool `json:"compact_home_enabled"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled *bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL *string `json:"purchase_subscription_url"`
@@ -300,6 +304,8 @@ type UpdateSettingsRequest struct {
// Force Alipay mobile clients to use QR code payment instead of mobile redirect
PaymentAlipayForceQRCode *bool `json:"payment_alipay_force_qrcode"`
// Use Alipay face-to-face precreate and an app deep link on mobile clients.
PaymentAlipayMobilePrecreateDeepLink *bool `json:"payment_alipay_mobile_precreate_deep_link"`
// Channel Monitor feature switch
ChannelMonitorEnabled *bool `json:"channel_monitor_enabled"`
@@ -308,6 +314,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"`
@@ -373,12 +384,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 {
@@ -401,6 +470,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)...)
@@ -1251,6 +1331,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,
@@ -1350,6 +1431,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ContactInfo: req.ContactInfo,
DocURL: req.DocURL,
HomeContent: req.HomeContent,
CompactHomeEnabled: req.CompactHomeEnabled,
HideCcsImportButton: req.HideCcsImportButton,
PurchaseSubscriptionEnabled: purchaseEnabled,
PurchaseSubscriptionURL: purchaseURL,
@@ -1603,6 +1685,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
@@ -1690,7 +1790,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
}
@@ -1710,28 +1810,29 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
// Skip if no payment fields were provided (prevents accidental wipe).
if h.paymentConfigService != nil && hasPaymentFields(req) {
paymentReq := service.UpdatePaymentConfigRequest{
Enabled: req.PaymentEnabled,
MinAmount: req.PaymentMinAmount,
MaxAmount: req.PaymentMaxAmount,
DailyLimit: req.PaymentDailyLimit,
OrderTimeoutMin: req.PaymentOrderTimeoutMin,
MaxPendingOrders: req.PaymentMaxPendingOrders,
EnabledTypes: req.PaymentEnabledTypes,
BalanceDisabled: req.PaymentBalanceDisabled,
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
RechargeFeeRate: req.PaymentRechargeFeeRate,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
ProductNameSuffix: req.PaymentProductNameSuffix,
HelpImageURL: req.PaymentHelpImageURL,
HelpText: req.PaymentHelpText,
CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
CancelRateLimitMax: req.PaymentCancelRateLimitMax,
CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
CancelRateLimitUnit: req.PaymentCancelRateLimitUnit,
CancelRateLimitMode: req.PaymentCancelRateLimitMode,
AlipayForceQRCode: req.PaymentAlipayForceQRCode,
Enabled: req.PaymentEnabled,
MinAmount: req.PaymentMinAmount,
MaxAmount: req.PaymentMaxAmount,
DailyLimit: req.PaymentDailyLimit,
OrderTimeoutMin: req.PaymentOrderTimeoutMin,
MaxPendingOrders: req.PaymentMaxPendingOrders,
EnabledTypes: req.PaymentEnabledTypes,
BalanceDisabled: req.PaymentBalanceDisabled,
BalanceRechargeMultiplier: req.PaymentBalanceRechargeMultiplier,
SubscriptionUSDToCNYRate: req.PaymentSubscriptionUSDToCNYRate,
RechargeFeeRate: req.PaymentRechargeFeeRate,
LoadBalanceStrategy: req.PaymentLoadBalanceStrat,
ProductNamePrefix: req.PaymentProductNamePrefix,
ProductNameSuffix: req.PaymentProductNameSuffix,
HelpImageURL: req.PaymentHelpImageURL,
HelpText: req.PaymentHelpText,
CancelRateLimitEnabled: req.PaymentCancelRateLimitEnabled,
CancelRateLimitMax: req.PaymentCancelRateLimitMax,
CancelRateLimitWindow: req.PaymentCancelRateLimitWindow,
CancelRateLimitUnit: req.PaymentCancelRateLimitUnit,
CancelRateLimitMode: req.PaymentCancelRateLimitMode,
AlipayForceQRCode: req.PaymentAlipayForceQRCode,
AlipayMobilePrecreateDeepLink: req.PaymentAlipayMobilePrecreateDeepLink,
}
if err := h.paymentConfigService.UpdatePaymentConfig(c.Request.Context(), paymentReq); err != nil {
response.ErrorFrom(c, err)
@@ -1773,6 +1874,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,
@@ -1784,6 +1886,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,
@@ -1878,6 +1984,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ContactInfo: updatedSettings.ContactInfo,
DocURL: updatedSettings.DocURL,
HomeContent: updatedSettings.HomeContent,
CompactHomeEnabled: updatedSettings.CompactHomeEnabled,
HideCcsImportButton: updatedSettings.HideCcsImportButton,
PurchaseSubscriptionEnabled: updatedSettings.PurchaseSubscriptionEnabled,
PurchaseSubscriptionURL: updatedSettings.PurchaseSubscriptionURL,
@@ -1985,12 +2092,17 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
PaymentCancelRateLimitUnit: updatedPaymentCfg.CancelRateLimitUnit,
PaymentCancelRateLimitMode: updatedPaymentCfg.CancelRateLimitMode,
PaymentAlipayForceQRCode: updatedPaymentCfg.AlipayForceQRCode,
PaymentAlipayMobilePrecreateDeepLink: updatedPaymentCfg.AlipayMobilePrecreateDeepLink,
ChannelMonitorEnabled: updatedSettings.ChannelMonitorEnabled,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
RiskControlEnabled: updatedSettings.RiskControlEnabled,
@@ -2038,7 +2150,7 @@ func hasPaymentFields(req UpdateSettingsRequest) bool {
req.PaymentHelpText != nil || req.PaymentCancelRateLimitEnabled != nil ||
req.PaymentCancelRateLimitMax != nil || req.PaymentCancelRateLimitWindow != nil ||
req.PaymentCancelRateLimitUnit != nil || req.PaymentCancelRateLimitMode != nil ||
req.PaymentAlipayForceQRCode != nil
req.PaymentAlipayForceQRCode != nil || req.PaymentAlipayMobilePrecreateDeepLink != nil
}
// ensureDingTalkSyncAttributes 在保存 settings 后,按 admin 配置的 (attr key, attr name)
@@ -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"`
@@ -12,12 +12,11 @@ import (
// AvailableChannelHandler 处理用户侧「可用渠道」查询。
//
// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做三层过滤:
// 用户侧接口委托 ChannelService.ListAvailable,并在返回前做四层过滤:
// 1. 行过滤:只保留状态为 Active 且与当前用户可访问分组有交集的渠道;
// 2. 分组过滤:渠道的 Groups 只保留用户可访问的那些;
// 3. 平台过滤:渠道的 SupportedModels 只保留平台在用户可见 Groups 中出现过的模型,
// 防止"渠道同时挂在 antigravity / anthropic 两个平台的分组上,用户只访问
// antigravity,却看到 anthropic 模型"这类跨平台信息泄漏;
// 3. 平台过滤:普通分组只保留自身平台模型;Composite 分组按渠道已配置的具体模型平台
// 展开。这样既防止普通分组跨平台泄漏,也让 Composite 正确展示其多平台能力;
// 4. 字段白名单:仅返回用户需要的字段(省略 BillingModelSource / RestrictModels
// / 内部 ID / Status 等管理字段)。
type AvailableChannelHandler struct {
@@ -172,19 +171,47 @@ func (h *AvailableChannelHandler) List(c *gin.Context) {
}
// buildPlatformSections 把一个渠道按 visibleGroups 的平台集合拆成有序的 section 列表:
// 每个 section 对应一个平台,只包含该平台的 groups 和 supported_models。
// 每个 section 对应一个具体平台,只包含该平台的 groups 和 supported_models。
//
// Composite 分组可访问渠道中所有已配置的具体平台,因此会被展开到每个有支持模型的
// 平台 section。普通分组仍严格留在自身平台,避免跨平台模型信息泄漏。Composite 渠道
// 尚未配置任何模型时保留 composite section,以便前端继续展示该分组和“未配置模型”状态。
// 输出按 platform 字母序稳定排序,便于前端等效比较与回归测试。
func buildPlatformSections(
ch service.AvailableChannel,
visibleGroups []userAvailableGroup,
) []userChannelPlatformSection {
groupsByPlatform := make(map[string][]userAvailableGroup, 4)
compositeGroups := make([]userAvailableGroup, 0, 1)
for _, g := range visibleGroups {
if g.Platform == "" {
continue
}
if g.Platform == service.PlatformComposite {
compositeGroups = append(compositeGroups, g)
continue
}
groupsByPlatform[g.Platform] = append(groupsByPlatform[g.Platform], g)
}
if len(compositeGroups) > 0 {
modelPlatforms := make(map[string]struct{}, len(ch.SupportedModels))
for i := range ch.SupportedModels {
if platform := ch.SupportedModels[i].Platform; platform != "" {
modelPlatforms[platform] = struct{}{}
}
}
if len(modelPlatforms) == 0 {
groupsByPlatform[service.PlatformComposite] = append(
groupsByPlatform[service.PlatformComposite],
compositeGroups...,
)
} else {
for platform := range modelPlatforms {
groupsByPlatform[platform] = append(groupsByPlatform[platform], compositeGroups...)
}
}
}
if len(groupsByPlatform) == 0 {
return nil
}
@@ -155,3 +155,101 @@ func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
require.Len(t, sections[0].SupportedModels, 1)
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
}
func TestBuildPlatformSections_CompositeGroupExpandsAcrossConfiguredModelPlatforms(t *testing.T) {
anthropicPrice := 3e-6
openAIPrice := 2.5e-6
ch := service.AvailableChannel{
Name: "composite-channel",
SupportedModels: []service.SupportedModel{
{
Name: "claude-sonnet-4-6",
Platform: service.PlatformAnthropic,
Pricing: &service.ChannelModelPricing{InputPrice: &anthropicPrice},
},
{
Name: "gpt-5",
Platform: service.PlatformOpenAI,
Pricing: &service.ChannelModelPricing{InputPrice: &openAIPrice},
},
},
}
visible := []userAvailableGroup{
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
}
sections := buildPlatformSections(ch, visible)
require.Len(t, sections, 2)
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
require.Equal(t, service.PlatformOpenAI, sections[1].Platform)
for _, section := range sections {
require.Len(t, section.Groups, 1)
require.Equal(t, int64(9), section.Groups[0].ID)
require.Equal(t, service.PlatformComposite, section.Groups[0].Platform)
require.Len(t, section.SupportedModels, 1)
require.Equal(t, section.Platform, section.SupportedModels[0].Platform)
require.NotNil(t, section.SupportedModels[0].Pricing)
}
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
require.Equal(t, "gpt-5", sections[1].SupportedModels[0].Name)
}
func TestBuildPlatformSections_OrdinaryGroupRemainsPlatformIsolated(t *testing.T) {
ch := service.AvailableChannel{
SupportedModels: []service.SupportedModel{
{Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic},
{Name: "gpt-5", Platform: service.PlatformOpenAI},
},
}
visible := []userAvailableGroup{
{ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic},
}
sections := buildPlatformSections(ch, visible)
require.Len(t, sections, 1)
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
require.Len(t, sections[0].SupportedModels, 1)
require.Equal(t, "claude-sonnet-4-6", sections[0].SupportedModels[0].Name)
}
func TestBuildPlatformSections_CompositeAndOrdinaryGroupsShareConcreteSection(t *testing.T) {
ch := service.AvailableChannel{
SupportedModels: []service.SupportedModel{
{Name: "claude-sonnet-4-6", Platform: service.PlatformAnthropic},
{Name: "gpt-5", Platform: service.PlatformOpenAI},
},
}
visible := []userAvailableGroup{
{ID: 1, Name: "anthropic-only", Platform: service.PlatformAnthropic},
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
}
sections := buildPlatformSections(ch, visible)
require.Len(t, sections, 2)
require.Equal(t, service.PlatformAnthropic, sections[0].Platform)
require.Equal(t, []int64{1, 9}, []int64{
sections[0].Groups[0].ID,
sections[0].Groups[1].ID,
})
require.Equal(t, service.PlatformOpenAI, sections[1].Platform)
require.Len(t, sections[1].Groups, 1)
require.Equal(t, int64(9), sections[1].Groups[0].ID)
}
func TestBuildPlatformSections_CompositeWithoutModelsKeepsEmptyCompositeSection(t *testing.T) {
visible := []userAvailableGroup{
{ID: 9, Name: "composite", Platform: service.PlatformComposite},
}
sections := buildPlatformSections(service.AvailableChannel{
SupportedModels: []service.SupportedModel{{Name: "invalid-without-platform"}},
}, visible)
require.Len(t, sections, 1)
require.Equal(t, service.PlatformComposite, sections[0].Platform)
require.Len(t, sections[0].Groups, 1)
require.Empty(t, sections[0].SupportedModels)
}
@@ -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)
@@ -0,0 +1,57 @@
package handler
import (
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
func ensureCompositeTargetPlatform(c *gin.Context, apiKey *service.APIKey, model string) {
if c == nil || c.Request == nil || apiKey == nil || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformComposite {
return
}
if _, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
return
}
if platform, ok := service.DetectModelPlatform(model); ok {
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), platform))
}
}
func compositeTargetPlatformAllowed(c *gin.Context, apiKey *service.APIKey, model string, allowed ...string) bool {
if c == nil || c.Request == nil || apiKey == nil || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformComposite {
return true
}
ensureCompositeTargetPlatform(c, apiKey, model)
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
if !ok {
return false
}
for _, allowedPlatform := range allowed {
if platform == allowedPlatform {
return true
}
}
return false
}
func compositeTargetPlatformResolved(c *gin.Context, apiKey *service.APIKey, model string) bool {
if c == nil || c.Request == nil || apiKey == nil || apiKey.Group == nil || apiKey.Group.Platform != service.PlatformComposite {
return true
}
ensureCompositeTargetPlatform(c, apiKey, model)
_, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
return ok
}
func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
if c != nil && c.Request != nil {
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
return platform
}
}
if apiKey == nil || apiKey.Group == nil {
return ""
}
return apiKey.Group.Platform
}
@@ -0,0 +1,100 @@
package handler
import (
"net/http/httptest"
"testing"
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"
)
func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/embeddings", nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.True(t, compositeTargetPlatformAllowed(c, apiKey, "text-embedding-3-large", service.PlatformOpenAI))
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok)
require.Equal(t, service.PlatformOpenAI, platform)
}
func TestOpenAICompatibleTextTargetAllowsCompositeGrokModel(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, path := range []string{"/v1/messages", "/v1/chat/completions"} {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", path, nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, "grok-4.3"), "path=%s", path)
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok, "path=%s", path)
require.Equal(t, service.PlatformGrok, platform, "path=%s", path)
}
}
func TestCompositeTargetPlatformAllowedRejectsWrongOrUnknownModel(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name string
model string
}{
{name: "wrong provider", model: "claude-sonnet-4-5"},
{name: "unknown provider", model: "llama-4-maverick"},
} {
t.Run(tc.name, func(t *testing.T) {
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/embeddings", nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.False(t, compositeTargetPlatformAllowed(c, apiKey, tc.model, service.PlatformOpenAI))
})
}
}
func TestCompositeTargetPlatformResolvedRejectsUnknownModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.False(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
_, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.False(t, ok)
}
func TestCompositeTargetPlatformResolvedAllowsConcreteGroupWithoutResolution(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformAnthropic}}
require.True(t, compositeTargetPlatformResolved(c, apiKey, "llama-4-maverick"))
}
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
c.Request = c.Request.WithContext(service.WithCompositeRouteDecision(c.Request.Context(), service.CompositeRouteDecision{
Matched: true,
Source: service.CompositeRouteSourceExplicit,
PublicModel: "public-alias",
TargetPlatform: service.PlatformOpenAI,
UpstreamModel: "gpt-5",
}))
input := buildContentModerationInput(c, nil, middleware2.AuthSubject{UserID: 42}, service.ContentModerationProtocolOpenAIChat, "gpt-5", nil)
require.Equal(t, "public-alias", input.Model)
require.Equal(t, service.PlatformOpenAI, input.Provider)
fields := clientRequestedUsageFields(c, service.ChannelMappingResult{MappedModel: "gpt-5"}, "gpt-5", "gpt-5")
require.Equal(t, "public-alias", fields.OriginalModel)
require.Equal(t, "public-alias", fields.ChannelMappedModel)
require.Equal(t, "public-alias\u2192gpt-5", fields.ModelMappingChain)
}
@@ -23,6 +23,21 @@ func contentModerationErrorCode(decision *service.ContentModerationDecision) str
return "content_policy_violation"
}
func clientRequestedModel(c *gin.Context, fallback string) string {
fallback = strings.TrimSpace(fallback)
if c == nil || c.Request == nil {
return fallback
}
if model, ok := service.RequestedPublicModelFromContext(c.Request.Context()); ok {
return model
}
return fallback
}
func clientRequestedUsageFields(c *gin.Context, mapping service.ChannelMappingResult, fallbackModel, upstreamModel string) service.ChannelUsageFields {
return mapping.ToUsageFields(clientRequestedModel(c, fallbackModel), upstreamModel)
}
func runContentModeration(c *gin.Context, reqLog *zap.Logger, svc *service.ContentModerationService, apiKey *service.APIKey, subject middleware2.AuthSubject, protocol string, model string, body []byte) *service.ContentModerationDecision {
if svc == nil || c == nil || c.Request == nil {
return nil
@@ -71,10 +86,13 @@ func buildContentModerationInput(c *gin.Context, apiKey *service.APIKey, subject
UserID: subject.UserID,
Endpoint: GetInboundEndpoint(c),
Provider: contentModerationProvider(apiKey),
Model: strings.TrimSpace(model),
Model: clientRequestedModel(c, model),
Protocol: protocol,
Body: body,
}
if resolvedPlatform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
input.Provider = resolvedPlatform
}
if forcedPlatform, ok := middleware2.GetForcePlatformFromContext(c); ok {
input.Provider = strings.TrimSpace(forcedPlatform)
}
@@ -58,6 +58,41 @@ func TestAccountFromServiceShallow_RedactsSensitiveCredentials(t *testing.T) {
require.Equal(t, "rt-secret", src.Credentials["refresh_token"])
}
func TestAccountFromServiceShallow_RedactsOllamaCloudManagedExtra(t *testing.T) {
snapshot := map[string]any{
"status": service.OllamaCloudUsageStatusOK,
"last_attempt_at": "2026-07-22T12:00:00Z",
"next_refresh_at": "2026-07-22T13:00:00Z",
"data": map[string]any{"plan": "Pro"},
}
src := &service.Account{
ID: 9, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "https://ollama.com", "api_key": "secret-key"},
Extra: map[string]any{
service.OllamaCloudUsageSessionExtraKey: "ciphertext-secret",
service.OllamaCloudUsageAutoRefreshExtraKey: true,
service.OllamaCloudUsageSnapshotExtraKey: snapshot,
"ordinary": "kept",
},
}
got := AccountFromServiceShallow(src)
require.NotContains(t, got.Extra, service.OllamaCloudUsageSessionExtraKey)
require.NotContains(t, got.Extra, service.OllamaCloudUsageAutoRefreshExtraKey)
require.NotContains(t, got.Extra, service.OllamaCloudUsageSnapshotExtraKey)
require.Equal(t, "kept", got.Extra["ordinary"])
require.NotNil(t, got.OllamaCloudUsage)
require.True(t, got.OllamaCloudUsage.Configured)
require.True(t, got.OllamaCloudUsage.AutoRefreshEnabled)
require.Equal(t, "Pro", got.OllamaCloudUsage.Snapshot.Data.Plan)
raw, err := json.Marshal(got)
require.NoError(t, err)
require.NotContains(t, string(raw), "ciphertext-secret")
require.NotContains(t, string(raw), "secret-key")
require.Contains(t, src.Extra, service.OllamaCloudUsageSessionExtraKey)
}
func TestAccountFromServiceShallow_NilCredentialsOmitsStatus(t *testing.T) {
src := &service.Account{ID: 1, Name: "n", Platform: "anthropic", Type: "oauth"}
got := AccountFromServiceShallow(src)
@@ -0,0 +1,75 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
)
// profitControlJSONFields 是分组利润控制的三个 JSON 字段。它们与同响应中的
// rate_multiplier 相乘即可反推出运营方的上游采购成本上限,属于内部经营信息,
// 只能出现在管理员 DTO 中。
var profitControlJSONFields = []string{
"profit_control_enabled",
"profit_min_margin",
"profit_safety_buffer",
}
func profitControlServiceGroup() *service.Group {
return &service.Group{
ID: 7,
Name: "profit-gated",
Platform: service.PlatformAnthropic,
RateMultiplier: 2.0,
Status: service.StatusActive,
ProfitControlEnabled: true,
ProfitMinMargin: 0.3,
ProfitSafetyBuffer: 0.05,
}
}
func marshalToMap(t *testing.T, v any) map[string]any {
t.Helper()
raw, err := json.Marshal(v)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var out map[string]any
if err := json.Unmarshal(raw, &out); err != nil {
t.Fatalf("unmarshal: %v", err)
}
return out
}
// TestGroupFromServiceOmitsProfitControl 钉死普通用户侧的分组 DTO 不泄露利润控制配置。
func TestGroupFromServiceOmitsProfitControl(t *testing.T) {
for name, got := range map[string]any{
"GroupFromService": GroupFromService(profitControlServiceGroup()),
"GroupFromServiceShallow": GroupFromServiceShallow(profitControlServiceGroup()),
} {
fields := marshalToMap(t, got)
for _, f := range profitControlJSONFields {
if _, ok := fields[f]; ok {
t.Errorf("%s: 普通用户 DTO 不得包含 %q", name, f)
}
}
if _, ok := fields["rate_multiplier"]; !ok {
t.Errorf("%s: 应仍返回 rate_multiplier", name)
}
}
}
// TestGroupFromServiceAdminIncludesProfitControl 钉死管理端仍能读写利润控制配置。
func TestGroupFromServiceAdminIncludesProfitControl(t *testing.T) {
admin := GroupFromServiceAdmin(profitControlServiceGroup())
if admin.ProfitControlEnabled != true || admin.ProfitMinMargin != 0.3 || admin.ProfitSafetyBuffer != 0.05 {
t.Fatalf("管理员 DTO 未透传利润控制配置: %+v", admin)
}
fields := marshalToMap(t, admin)
for _, f := range profitControlJSONFields {
if _, ok := fields[f]; !ok {
t.Errorf("管理员 DTO 应包含 %q", f)
}
}
}
+30 -1
View File
@@ -146,6 +146,9 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
}
out := &AdminGroup{
Group: groupFromServiceBase(g),
ProfitControlEnabled: g.ProfitControlEnabled,
ProfitMinMargin: g.ProfitMinMargin,
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
ModelRouting: g.ModelRouting,
ModelRoutingEnabled: g.ModelRoutingEnabled,
MCPXMLInject: g.MCPXMLInject,
@@ -204,6 +207,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,
@@ -219,6 +223,11 @@ func AccountFromServiceShallow(a *service.Account) *Account {
return nil
}
redactedCreds, credsStatus := RedactCredentials(a.Credentials)
extra := redactAccountManagedExtra(a.Extra)
var ollamaCloudUsage *service.OllamaCloudUsageState
if state := service.OllamaCloudUsageStateFromAccount(a); state.Eligible {
ollamaCloudUsage = state
}
out := &Account{
ID: a.ID,
Name: a.Name,
@@ -227,7 +236,8 @@ func AccountFromServiceShallow(a *service.Account) *Account {
Type: a.Type,
Credentials: redactedCreds,
CredentialsStatus: credsStatus,
Extra: a.Extra,
Extra: extra,
OllamaCloudUsage: ollamaCloudUsage,
ProxyID: a.ProxyID,
ProxyFallbackOriginID: a.ProxyFallbackOriginID,
ProxyFallbackOriginName: a.ProxyFallbackOriginName,
@@ -385,6 +395,24 @@ func AccountFromServiceShallow(a *service.Account) *Account {
return out
}
func redactAccountManagedExtra(extra map[string]any) map[string]any {
if extra == nil {
return nil
}
redacted := make(map[string]any, len(extra))
for key, value := range extra {
switch key {
case service.OllamaCloudUsageSessionExtraKey,
service.OllamaCloudUsageAutoRefreshExtraKey,
service.OllamaCloudUsageSnapshotExtraKey:
continue
default:
redacted[key] = value
}
}
return redacted
}
func AccountFromService(a *service.Account) *Account {
if a == nil {
return nil
@@ -645,6 +673,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,
+29 -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"`
@@ -138,6 +142,7 @@ type SystemSettings struct {
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
CompactHomeEnabled bool `json:"compact_home_enabled"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
@@ -268,6 +273,8 @@ type SystemSettings struct {
// Force Alipay mobile clients to use QR code payment instead of mobile redirect
PaymentAlipayForceQRCode bool `json:"payment_alipay_force_qrcode"`
// Use Alipay face-to-face precreate and an app deep link on mobile clients.
PaymentAlipayMobilePrecreateDeepLink bool `json:"payment_alipay_mobile_precreate_deep_link"`
// 余额、订阅到期与账号限额通知
BalanceLowNotifyEnabled bool `json:"balance_low_notify_enabled"`
@@ -284,6 +291,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"`
@@ -318,6 +330,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"`
@@ -332,6 +345,7 @@ type PublicSettings struct {
ContactInfo string `json:"contact_info"`
DocURL string `json:"doc_url"`
HomeContent string `json:"home_content"`
CompactHomeEnabled bool `json:"compact_home_enabled"`
HideCcsImportButton bool `json:"hide_ccs_import_button"`
PurchaseSubscriptionEnabled bool `json:"purchase_subscription_enabled"`
PurchaseSubscriptionURL string `json:"purchase_subscription_url"`
@@ -367,6 +381,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"`
@@ -392,6 +409,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"`
+31 -17
View File
@@ -6,6 +6,7 @@ import (
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/service"
)
type User struct {
@@ -131,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"`
@@ -152,6 +155,13 @@ type Group struct {
type AdminGroup struct {
Group
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。
// 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出
// 运营方的上游成本上限,属于内部经营信息,不得下放到 dto.Group。
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin float64 `json:"profit_min_margin"`
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
ModelRoutingEnabled bool `json:"model_routing_enabled"`
@@ -183,23 +193,24 @@ type Account struct {
Type string `json:"type"`
// Credentials 经 RedactCredentials 处理后只含非敏感子键;敏感 token / api_key / 私钥
// 的存在性通过 CredentialsStatus(has_<key>)暴露,原始值不返回前端。
Credentials map[string]any `json:"credentials"`
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
Extra map[string]any `json:"extra"`
ProxyID *int64 `json:"proxy_id"`
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor,omitempty"`
Priority int `json:"priority"`
RateMultiplier float64 `json:"rate_multiplier"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Credentials map[string]any `json:"credentials"`
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
Extra map[string]any `json:"extra"`
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"`
ProxyID *int64 `json:"proxy_id"`
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor,omitempty"`
Priority int `json:"priority"`
RateMultiplier float64 `json:"rate_multiplier"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Schedulable bool `json:"schedulable"`
@@ -519,6 +530,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
+76 -4
View File
@@ -40,8 +40,18 @@ const (
// Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s),
// Handler 层只需短暂间隔后重新进入 Service 层即可。
singleAccountBackoffDelay = 2 * time.Second
// maxProfitVetoAttempts 单次请求内允许的分组利润门终检否决次数上限。
// 利润否决不产生上游请求,因此不会推进 SwitchCount;没有独立上限的话,
// 「选号 → 终检否决 → 重选」在候选池与账号快照短暂不一致时可以空转很久。
// 取值与 maxAccountSwitches 默认值一致:混合定价的大分组仍有充分重选机会,
// 同时把整池越线时的无谓选号开销限制在常数级。
maxProfitVetoAttempts = 10
)
// profitVetoExhaustedMessage 是利润否决次数耗尽时返回给客户端的文案。
// 语义上等同于「无可用账号」:候选账号都不满足分组的利润约束。
const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control"
// FailoverState 跨循环迭代共享的 failover 状态
type FailoverState struct {
SwitchCount int
@@ -51,18 +61,64 @@ type FailoverState struct {
LastFailoverErr *service.UpstreamFailoverError
ForceCacheBilling bool
hasBoundSession bool
// profitVetoedAccountIDs 记录被分组利润门终检否决的账号,是 FailedAccountIDs
// 的子集。之所以单独维护:HandleSelectionExhausted 的 503 退避分支会清空
// FailedAccountIDs,而利润否决在同一请求内的判定不会改变(下游倍率 D 已在
// 请求开始冻结),被清空的账号会被立即重选并再次否决,形成没有任何上游请求、
// SwitchCount 也不前进的活锁。清空后必须把它们放回排除集。
profitVetoedAccountIDs map[int64]struct{}
// profitVetoCount 本次请求累计的利润否决次数,用于 maxProfitVetoAttempts 上限。
profitVetoCount int
}
// NewFailoverState 创建 failover 状态
func NewFailoverState(maxSwitches int, hasBoundSession bool) *FailoverState {
return &FailoverState{
MaxSwitches: maxSwitches,
FailedAccountIDs: make(map[int64]struct{}),
SameAccountRetryCount: make(map[int64]int),
hasBoundSession: hasBoundSession,
MaxSwitches: maxSwitches,
FailedAccountIDs: make(map[int64]struct{}),
SameAccountRetryCount: make(map[int64]int),
hasBoundSession: hasBoundSession,
profitVetoedAccountIDs: make(map[int64]struct{}),
}
}
// RecordProfitVeto 记录一次分组利润门终检否决:把账号加入排除列表(同时登记到
// 利润否决集,使其不被 503 退避分支清掉)并递增否决计数。
//
// 返回 FailoverContinue 表示调用方可以继续重选下一个账号;返回 FailoverExhausted
// 表示本次请求的利润否决次数已达上限,调用方应按「无可用账号」终止,
// 不得继续 continue。
func (s *FailoverState) RecordProfitVeto(accountID int64) FailoverAction {
s.FailedAccountIDs[accountID] = struct{}{}
if s.profitVetoedAccountIDs == nil {
s.profitVetoedAccountIDs = make(map[int64]struct{})
}
s.profitVetoedAccountIDs[accountID] = struct{}{}
s.profitVetoCount++
if s.profitVetoCount >= maxProfitVetoAttempts {
return FailoverExhausted
}
return FailoverContinue
}
// ProfitVetoCount 返回本次请求累计的利润否决次数(供日志使用)。
func (s *FailoverState) ProfitVetoCount() int { return s.profitVetoCount }
// allExclusionsAreProfitVetoed 判断排除列表是否已全部由利润门否决贡献。
// 此时清空 FailedAccountIDs 会被原样恢复,退避重试不会带来任何新候选。
func (s *FailoverState) allExclusionsAreProfitVetoed() bool {
if len(s.profitVetoedAccountIDs) == 0 || len(s.FailedAccountIDs) == 0 {
return false
}
for id := range s.FailedAccountIDs {
if _, ok := s.profitVetoedAccountIDs[id]; !ok {
return false
}
}
return true
}
// HandleFailoverError 处理 UpstreamFailoverError,返回下一步动作。
// 包含:缓存计费判断、同账号重试、临时封禁、切换计数、Antigravity 延时。
func (s *FailoverState) HandleFailoverError(
@@ -156,6 +212,17 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc
s.LastFailoverErr.StatusCode == http.StatusServiceUnavailable &&
s.SwitchCount <= s.MaxSwitches {
// 排除列表全由利润门否决贡献时,清空后会被原样恢复:退避重试拿不到
// 任何新候选,而利润否决不推进 SwitchCount,退避条件将永远成立。
// 这里直接判定耗尽,避免每 2s 空转一轮的活锁。
if s.allExclusionsAreProfitVetoed() {
logger.FromContext(ctx).Warn("gateway.failover_selection_exhausted_by_profit_veto",
zap.Int("profit_veto_count", s.profitVetoCount),
zap.Int("excluded_accounts", len(s.FailedAccountIDs)),
)
return FailoverExhausted
}
logger.FromContext(ctx).Warn("gateway.failover_single_account_backoff",
zap.Duration("backoff_delay", singleAccountBackoffDelay),
zap.Int("switch_count", s.SwitchCount),
@@ -169,6 +236,11 @@ func (s *FailoverState) HandleSelectionExhausted(ctx context.Context) FailoverAc
zap.Int("max_switches", s.MaxSwitches),
)
s.FailedAccountIDs = make(map[int64]struct{})
// 利润门否决的账号不参与退避重试的解除:判定依据(冻结的下游倍率)在
// 同一请求内不变,放它们回池只会被再次否决。
for id := range s.profitVetoedAccountIDs {
s.FailedAccountIDs[id] = struct{}{}
}
return FailoverContinue
}
return FailoverExhausted
@@ -0,0 +1,139 @@
package handler
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/require"
)
// profitVetoLoopResult 记录一次模拟选号循环的终止方式与步数。
type profitVetoLoopResult struct {
outcome string // "forwarded" | "exhausted" | "budget_exceeded"
forwardedID int64
iterations int
backoffRetries int
}
// runProfitVetoLoop 模拟 handler 的「选号 → 利润终检 → 重选」循环,只保留与
// 活锁相关的状态机(FailoverState + 排除列表 + HandleSelectionExhausted),
// 不涉及真实调度器与上游转发。
//
// pool 按顺序给出候选账号;vetoed 中的账号每次终检都被利润门否决——这对应
// 「候选池快照与 per-account 快照短暂不一致」时门的确定性判定。
// maxIterations 是测试自身的预算:循环超过它即视为活锁。
func runProfitVetoLoop(t *testing.T, fs *FailoverState, pool []int64, vetoed map[int64]bool, maxIterations int) profitVetoLoopResult {
t.Helper()
res := profitVetoLoopResult{}
for res.iterations = 1; res.iterations <= maxIterations; res.iterations++ {
// 选号:返回第一个不在排除列表中的账号。
var picked int64
for _, id := range pool {
if _, excluded := fs.FailedAccountIDs[id]; !excluded {
picked = id
break
}
}
if picked == 0 {
// 选号耗尽,交给退避决策。
switch fs.HandleSelectionExhausted(context.Background()) {
case FailoverContinue:
res.backoffRetries++
continue
default:
res.outcome = "exhausted"
return res
}
}
if vetoed[picked] {
if fs.RecordProfitVeto(picked) == FailoverExhausted {
res.outcome = "exhausted"
return res
}
continue
}
res.outcome = "forwarded"
res.forwardedID = picked
return res
}
res.outcome = "budget_exceeded"
return res
}
// TestProfitVetoAfter503DoesNotLivelock 钉死 #4925 引入的活锁回归:
// 一次真实 503 之后,若调度器持续返回会被利润门否决的账号,
// 「选号 → 否决 → 排除 → 选号耗尽 → 清空排除 → 睡 2s → 选号」会无限循环,
// 因为利润否决不推进 SwitchCount,退避条件永远成立。
func TestProfitVetoAfter503DoesNotLivelock(t *testing.T) {
fs := NewFailoverState(10, false)
// 已经历一次真实 503(Antigravity 单账号分组 MODEL_CAPACITY_EXHAUSTED 是设计内路径)。
fs.LastFailoverErr = newTestFailoverErr(503, false, false)
fs.SwitchCount = 1
fs.FailedAccountIDs[1] = struct{}{}
start := time.Now()
res := runProfitVetoLoop(t, fs, []int64{1}, map[int64]bool{1: true}, 50)
elapsed := time.Since(start)
require.Equal(t, "exhausted", res.outcome, "整池被利润门否决时必须有限步终止")
require.LessOrEqual(t, res.backoffRetries, 1, "被否决的账号不得被退避分支复活并反复重选")
require.Less(t, elapsed, 10*time.Second, "不得每 2s 空转一轮")
}
// TestProfitVetoKeepsBackoffUsefulForHealthyAccount 钉死修复没有削弱既有的
// 503 退避语义:排除列表里仍有非利润否决的账号时,退避照常清空并重试,
// 只是被利润门否决的账号不再复活。
func TestProfitVetoKeepsBackoffUsefulForHealthyAccount(t *testing.T) {
fs := NewFailoverState(10, false)
fs.LastFailoverErr = newTestFailoverErr(503, false, false)
fs.SwitchCount = 1
// 账号 1 因真实 503 被排除;账号 2 会被利润门否决。
fs.FailedAccountIDs[1] = struct{}{}
res := runProfitVetoLoop(t, fs, []int64{2, 1}, map[int64]bool{2: true}, 50)
require.Equal(t, "forwarded", res.outcome)
require.Equal(t, int64(1), res.forwardedID, "退避清空后应重新可选账号 1")
require.Equal(t, 1, res.backoffRetries, "应发生且只发生一次退避重试")
require.Contains(t, fs.FailedAccountIDs, int64(2), "利润否决的账号在退避清空后必须被放回排除集")
}
// TestProfitVetoAttemptsCapped 钉死没有 503 参与时,大分组整池越线也会在
// 常数步内终止,而不是把整池逐个选一遍。
func TestProfitVetoAttemptsCapped(t *testing.T) {
fs := NewFailoverState(10, false)
pool := make([]int64, 0, 64)
vetoed := make(map[int64]bool, 64)
for id := int64(1); id <= 64; id++ {
pool = append(pool, id)
vetoed[id] = true
}
res := runProfitVetoLoop(t, fs, pool, vetoed, 200)
require.Equal(t, "exhausted", res.outcome)
require.Equal(t, maxProfitVetoAttempts, fs.ProfitVetoCount())
require.Equal(t, maxProfitVetoAttempts, res.iterations, "达到上限即终止,不应继续遍历候选池")
}
// TestRecordProfitVetoExcludesAccount 钉死 RecordProfitVeto 仍然把账号加入
// 调度排除列表(选号入参用的就是 FailedAccountIDs)。
func TestRecordProfitVetoExcludesAccount(t *testing.T) {
fs := NewFailoverState(10, false)
require.Equal(t, FailoverContinue, fs.RecordProfitVeto(42))
require.Contains(t, fs.FailedAccountIDs, int64(42))
require.Equal(t, 1, fs.ProfitVetoCount())
}
// TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto 钉死未启用利润控制的
// 请求(无任何利润否决)走的仍是原有退避语义:清空排除列表并重试。
func TestHandleSelectionExhaustedUnaffectedWithoutProfitVeto(t *testing.T) {
fs := NewFailoverState(3, false)
fs.LastFailoverErr = newTestFailoverErr(503, false, false)
fs.SwitchCount = 1
fs.FailedAccountIDs[100] = struct{}{}
require.Equal(t, FailoverContinue, fs.HandleSelectionExhausted(context.Background()))
require.Empty(t, fs.FailedAccountIDs, "无利润否决时排除列表应被完全清空")
}
+215 -79
View File
@@ -169,6 +169,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
body = parsedReq.Body.Bytes()
reqModel := parsedReq.Model
reqStream := parsedReq.Stream
ensureCompositeTargetPlatform(c, apiKey, reqModel)
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
// 解析渠道级模型映射
@@ -195,12 +196,18 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
c.Request = c.Request.WithContext(pricingCtx)
// 验证 model 必填
if reqModel == "" {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
if !compositeTargetPlatformResolved(c, apiKey, reqModel) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolAnthropicMessages, reqModel, body); decision != nil && !decision.AllowNextStage {
h.anthropicSecurityAuditError(c, decision)
@@ -259,10 +266,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.String("metadata_user_id_raw", parsedReq.MetadataUserID),
)
// 获取平台:优先使用强制平台(/antigravity 路由,中间件已设置 request.Context),否则使用分组平台
// 获取平台:优先使用强制平台(/antigravity 路由),其次使用 composite 解析出的目标平台,否则使用分组平台
platform := ""
if forcePlatform, ok := middleware2.GetForcePlatformFromContext(c); ok {
platform = forcePlatform
} else if resolvedPlatform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
platform = resolvedPlatform
} else if apiKey.Group != nil {
platform = apiKey.Group.Platform
}
@@ -414,8 +423,30 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
// Slot acquired: no longer waiting in queue.
releaseWait()
if err := h.gatewayService.BindStickySession(c.Request.Context(), apiKey.GroupID, sessionKey, account.ID); err != nil {
reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
// 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
if vetoed {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
reqLog.Debug("gateway.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
reqLog.Warn("gateway.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
markOpsRoutingCapacityLimited(c)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
return
}
continue
}
account = latest
selection.Account = latest
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
if selection.ProfitGateActive() || !selection.Acquired {
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionKey, account.ID); err != nil {
reqLog.Warn("gateway.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
}
// 账号槽位/等待计数需要在超时或断开时安全回收
@@ -528,6 +559,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,
@@ -536,14 +568,16 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
zap.String("component", "handler.gateway.messages"),
@@ -712,12 +746,30 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
// Slot acquired: no longer waiting in queue.
releaseWait()
reqLog.Info("sticky.bind_after_wait",
zap.String("session_key", sessionKey),
zap.Int64("account_id", account.ID),
)
if err := h.gatewayService.BindStickySession(c.Request.Context(), currentAPIKey.GroupID, sessionKey, account.ID); err != nil {
reqLog.Warn("gateway.bind_sticky_session_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
// 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
if vetoed {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
reqLog.Debug("gateway.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
reqLog.Warn("gateway.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
markOpsRoutingCapacityLimited(c)
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
return
}
continue
}
account = latest
selection.Account = latest
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
if selection.ProfitGateActive() || !selection.Acquired {
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, currentAPIKey.GroupID, sessionKey, account.ID); err != nil {
reqLog.Warn("gateway.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
}
// 账号槽位/等待计数需要在超时或断开时安全回收
@@ -818,6 +870,66 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
// 提交 usage 记录。成功路径与"流中断但 Forward 已观测到 usage 的部分结果"
// 错误路径共用:后者若不入账,上游已计量的请求会完全漏记漏计费(#5148)。
submitForwardUsage := func(result *service.ForwardResult) {
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
// Forward 内部可能继续改写 body,usage 去重指纹必须使用最终上游接受的当前 body。
requestPayloadHash := service.HashUsageRequestPayload(attemptParsedReq.Body.Bytes())
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)
}
// 同上(重试路径中的对称填充)。详见非重试路径同名注释。
if result.ReasoningEffort == nil && attemptParsedReq.ThinkingEnabled {
protocolModel := result.UpstreamModel
if protocolModel == "" {
protocolModel = result.Model
}
result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel)
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
// 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,
QuotaPlatform: quotaPlatform,
APIKey: currentAPIKey,
User: currentAPIKey.User,
Account: account,
Subscription: currentSubscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
zap.String("component", "handler.gateway.messages"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", currentAPIKey.ID),
zap.Any("group_id", currentAPIKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("gateway.record_usage_failed", zap.Error(err))
}
})
}
if err != nil {
// Beta policy block: return 400 immediately, no failover
var betaBlockedErr *service.BetaBlockedError
@@ -916,6 +1028,12 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
forwardFailedFields = append(forwardFailedFields, zap.Int64p("proxy_id", account.ProxyID))
}
reqLog.Error("gateway.forward_failed", forwardFailedFields...)
// Forward 与错误一起返回的部分结果:流中断前上游已计量的 usage 照常入账,
// 避免上游已产生消耗的请求完全漏记(#5148)。failover 错误恒定 result=nil,
// 不会走到这里重复计费。
if result != nil {
submitForwardUsage(result)
}
return
}
@@ -939,57 +1057,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
// 捕获请求信息(用于异步记录,避免在 goroutine 中访问 gin.Context)
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
// Forward 内部可能继续改写 body,usage 去重指纹必须使用最终上游接受的当前 body。
requestPayloadHash := service.HashUsageRequestPayload(attemptParsedReq.Body.Bytes())
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
if result.ReasoningEffort == nil {
result.ReasoningEffort = service.NormalizeClaudeOutputEffort(attemptParsedReq.OutputEffort)
}
// 同上(重试路径中的对称填充)。详见非重试路径同名注释。
if result.ReasoningEffort == nil && attemptParsedReq.ThinkingEnabled {
protocolModel := result.UpstreamModel
if protocolModel == "" {
protocolModel = result.Model
}
result.ReasoningEffort = service.DefaultEffortForThinkingEnabled(protocolModel)
}
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
QuotaPlatform: quotaPlatform,
APIKey: currentAPIKey,
User: currentAPIKey.User,
Account: account,
Subscription: currentSubscription,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
zap.String("component", "handler.gateway.messages"),
zap.Int64("user_id", subject.UserID),
zap.Int64("api_key_id", currentAPIKey.ID),
zap.Any("group_id", currentAPIKey.GroupID),
zap.String("model", reqModel),
zap.Int64("account_id", account.ID),
).Error("gateway.record_usage_failed", zap.Error(err))
}
})
submitForwardUsage(result)
return
}
if !retryWithFallback {
@@ -1016,6 +1084,21 @@ func (h *GatewayHandler) Models(c *gin.Context) {
platform = forcedPlatform
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(service.PlatformComposite), apiKey.Group.ModelsListConfig.Models)
writeCustomModelsList(c, service.PlatformComposite, availableModels)
return
}
if len(availableModels) > 0 {
writeModelsList(c, service.PlatformComposite, availableModels)
return
}
writeModelsList(c, service.PlatformComposite, defaultModelIDsForPlatform(service.PlatformComposite))
return
}
// Get available models from account configurations for the selected group platform.
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
@@ -1057,6 +1140,35 @@ func (h *GatewayHandler) Models(c *gin.Context) {
})
}
func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *int64) []string {
if h == nil || h.gatewayService == nil {
return nil
}
seen := make(map[string]struct{})
models := make([]string, 0)
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} {
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
if _, ok := schedulablePlatforms[platform]; ok {
platformModels = defaultModelIDsForPlatform(platform)
}
}
for _, model := range platformModels {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
models = append(models, model)
}
}
return models
}
func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
if platform == service.PlatformGrok {
writeGrokModelsList(c, modelIDs)
@@ -1257,6 +1369,19 @@ func defaultModelIDsForPlatform(platform string) []string {
return mergeModelIDs(ids, nil)
case service.PlatformGrok:
return xai.DefaultModelIDs()
case service.PlatformComposite:
ids := make([]string, 0)
seen := make(map[string]struct{})
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
ids = append(ids, id)
}
}
return ids
default:
ids := make([]string, 0, len(claude.DefaultModels))
for _, model := range claude.DefaultModels {
@@ -1889,6 +2014,7 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
body = parsedReq.Body.Bytes()
// count_tokens 走 messages 严格校验时,复用已解析请求,避免二次反序列化。
SetClaudeCodeClientContext(c, body, parsedReq)
ensureCompositeTargetPlatform(c, apiKey, parsedReq.Model)
reqLog = reqLog.With(zap.String("model", parsedReq.Model), zap.Bool("stream", parsedReq.Stream))
// 在请求上下文中记录 thinking 状态,供 Antigravity 最终模型 key 推导/模型维度限流使用
c.Request = c.Request.WithContext(service.WithThinkingEnabled(c.Request.Context(), parsedReq.ThinkingEnabled, h.metadataBridgeEnabled()))
@@ -1898,6 +2024,10 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "model is required")
return
}
if !compositeTargetPlatformResolved(c, apiKey, parsedReq.Model) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
setOpsRequestContext(c, parsedReq.Model, parsedReq.Stream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(parsedReq.Stream, false)))
@@ -2053,22 +2183,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
@@ -2078,7 +2208,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"}`,
@@ -2093,20 +2223,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 响应(用于请求拦截)
@@ -2116,7 +2246,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"
@@ -2126,13 +2256,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,
@@ -2141,6 +2271,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,
@@ -2150,7 +2281,6 @@ func sendMockInterceptResponse(c *gin.Context, model string, interceptType Inter
"ephemeral_1h_input_tokens": 0,
},
"output_tokens": outputTokens,
"total_tokens": 10 + outputTokens,
},
}
@@ -2263,10 +2393,16 @@ func (h *GatewayHandler) submitUsageRecordTask(parent context.Context, task serv
}
task = wrapUsageRecordTaskContext(parent, task)
if h.usageRecordWorkerPool != nil {
h.usageRecordWorkerPool.Submit(task)
return
if mode := h.usageRecordWorkerPool.Submit(task); mode != service.UsageRecordSubmitModeDroppedStopped {
return
}
// 池已停止(进程关停窗口):计费任务不能静默丢失,降级为内联同步执行。
// 显式配置的 drop/sample 溢出丢弃仍按配置语义保留。
logger.L().With(
zap.String("component", "handler.gateway.messages"),
).Warn("gateway.usage_record_task_stopped_sync_fallback")
}
// 回退路径:worker 池未注入时同步执行,避免退回到无界 goroutine 模式。
// 回退路径:worker 池未注入或已停止时同步执行,避免退回到无界 goroutine 模式。
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
defer func() {
@@ -41,7 +41,7 @@ func TestGatewayHandlerPreCancelledCompatibleRequestsDoNotSelectAccount(t *testi
schedulerSnapshot := service.NewSchedulerSnapshotService(schedulerCache, nil, nil, nil, nil)
gatewayService := service.NewGatewayService(
nil, &fakeGroupRepo{group: group}, nil, nil, nil, nil, nil, nil, nil,
schedulerSnapshot, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
schedulerSnapshot, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
)
cfg := &config.Config{RunMode: config.RunModeSimple}
billingCacheService := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
@@ -75,6 +75,11 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
return
}
reqModel := modelResult.String()
ensureCompositeTargetPlatform(c, apiKey, reqModel)
if !compositeTargetPlatformResolved(c, apiKey, reqModel) {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
h.chatCompletionsErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
@@ -88,6 +93,8 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
pricingCtx, pricingAt := service.WithGatewayTokenRequestPricing(c.Request.Context())
c.Request = c.Request.WithContext(pricingCtx)
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
@@ -147,15 +154,11 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
APIKeyID: apiKey.ID,
}
sessionHash := h.gatewayService.GenerateSessionHash(parsedReq)
groupPlatform := ""
if apiKey.Group != nil {
groupPlatform = apiKey.Group.Platform
}
groupPlatform := effectiveAPIKeyPlatform(c, apiKey)
selectionSessionHash := sessionHash
if groupPlatform == service.PlatformGemini && selectionSessionHash != "" {
selectionSessionHash = "gemini:" + selectionSessionHash
}
// 3. Account selection + failover loop
fs := NewFailoverState(h.maxAccountSwitches, false)
if groupPlatform == service.PlatformGemini {
@@ -221,6 +224,28 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
return
}
}
// 终检与准入后绑定使用选号结果携带的门(见 responses 同名注释)。
admissionCtx := service.ContextWithSelectionProfitGate(c.Request.Context(), selection)
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
if vetoed {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
reqLog.Debug("gateway.cc.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
reqLog.Warn("gateway.cc.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
h.chatCompletionsErrorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage)
return
}
continue
}
account = latest
selection.Account = latest
if selection.ProfitGateActive() {
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, selectionSessionHash, account.ID); err != nil {
reqLog.Warn("gateway.cc.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
}
accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc)
if groupPlatform == service.PlatformGemini && account.Platform != service.PlatformGemini {
@@ -238,6 +263,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")
@@ -247,6 +273,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)
}
@@ -296,6 +332,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,
@@ -304,13 +341,15 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
reqLog.Error("gateway.cc.record_usage_failed",
zap.Int64("account_id", account.ID),
@@ -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)
@@ -75,6 +75,11 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
return
}
reqModel := modelResult.String()
ensureCompositeTargetPlatform(c, apiKey, reqModel)
if !compositeTargetPlatformResolved(c, apiKey, reqModel) {
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by composite groups")
return
}
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
h.responsesErrorResponse(c, http.StatusBadRequest, "invalid_request_error", invalidStreamFieldTypeMessage)
@@ -85,9 +90,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
setOpsRequestContext(c, reqModel, reqStream)
setOpsEndpointContext(c, "", int16(service.RequestTypeFromLegacy(reqStream, false)))
requestCtx := c.Request.Context()
if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(apiKey)) {
// 定价上下文无条件装配:/v1/responses 是 token 计费端点,声明生图工具的
// 混合请求同样按 token 计费(外加图片部分),其 token 利润保护不因请求体
// 里的任何工具声明(含 Codex 被动 image_gen namespace)而关闭。生图意图
// 仅用于能力路由与图片计费;独立图片/视频端点才在利润门范围之外。
requestCtx, pricingAt := service.WithGatewayTokenRequestPricing(requestCtx)
if service.IsImageGenerationIntentForPlatform("/v1/responses", reqModel, body, openAICompatibleRequestPlatform(c.Request.Context(), apiKey)) {
requestCtx = service.WithOpenAIImageGenerationIntent(requestCtx)
}
c.Request = c.Request.WithContext(requestCtx)
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(requestCtx, apiKey.GroupID, reqModel)
@@ -163,7 +174,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
selection, err := h.gatewayService.SelectAccountWithLoadAwareness(requestCtx, apiKey.GroupID, sessionHash, reqModel, fs.FailedAccountIDs, "", int64(0))
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, service.PlatformAnthropic)
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey))
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -215,6 +226,30 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
return
}
}
// 终检与准入后绑定必须使用选号结果携带的门:门安装在调度栈的局部
// ctx 上(composite/fallback 还可能解析出与入口分组不同的门),直接用
// requestCtx 会退化为空操作。
admissionCtx := service.ContextWithSelectionProfitGate(requestCtx, selection)
latest, vetoed, reason := h.gatewayService.GatewayProfitControlVetoLatest(admissionCtx, account)
if vetoed {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
reqLog.Debug("gateway.responses.account_slot_profit_vetoed", zap.Int64("account_id", account.ID), zap.String("reason", reason))
if fs.RecordProfitVeto(account.ID) == FailoverExhausted {
reqLog.Warn("gateway.responses.profit_veto_attempts_exhausted", zap.Int("profit_veto_count", fs.ProfitVetoCount()))
h.responsesErrorResponse(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage)
return
}
continue
}
account = latest
selection.Account = latest
if selection.ProfitGateActive() {
if err := h.gatewayService.BindStickySessionAfterProfitAdmission(admissionCtx, apiKey.GroupID, sessionHash, account.ID); err != nil {
reqLog.Warn("gateway.responses.bind_sticky_session_after_profit_admission_failed", zap.Int64("account_id", account.ID), zap.Error(err))
}
}
accountReleaseFunc = wrapReleaseOnDone(c.Request.Context(), accountReleaseFunc)
// 5. Forward request
@@ -223,7 +258,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()
@@ -271,6 +320,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,
@@ -279,13 +329,15 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
ChannelUsageFields: channelMapping.ToUsageFields(reqModel, result.UpstreamModel),
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
reqLog.Error("gateway.responses.record_usage_failed",
zap.Int64("account_id", account.ID),
@@ -7,6 +7,7 @@ import (
"context"
"encoding/json"
"net/http/httptest"
"strings"
"testing"
"time"
@@ -187,6 +188,7 @@ func newTestGatewayHandler(t *testing.T, group *service.Group, accounts []*servi
nil, // tlsFPProfileService
nil, // channelService
nil, // resolver
nil, // compositeResolver
nil, // balanceNotifyService
nil, // userPlatformQuotaRepo
)
@@ -288,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)
@@ -377,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"])
}

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