Compare commits

..
Author SHA1 Message Date
shaw 9fb260439f fix(grok): use official CLI user agent 2026-08-24 22:14:45 +08:00
Wesley Liddick 07931bbb18 Merge pull request #6143 from akihitohyh/fix/rejected-status-strip-all
fix(openai): clear the rejected input status for the whole item type
2026-08-24 22:01:14 +08:00
Wesley Liddick 2307aa5ca7 Merge pull request #6148 from 759502416/fix/responses-lite-parallel-tool-calls
fix(openai): keep parallel_tool_calls for Responses Lite additional_tools
2026-08-24 21:49:01 +08:00
759502416andClaude Opus 5 1563db3f82 fix(openai): keep parallel_tool_calls for Responses Lite additional_tools
normalizeOpenAIParallelToolCallsWithoutTools only looked at the top-level
"tools" array, but normalizeOpenAIResponsesLiteTools moves namespace tools
into an input item of type "additional_tools" and deletes the top-level key.
A Responses Lite request that carries tools therefore looks like it has none,
and the parallel_tool_calls:false that ensureOpenAIResponsesLiteParallelToolCalls
had just pinned gets deleted on the way out.

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

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

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

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

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

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

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

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

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

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

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

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

- upstreamResponseModelObserver 同时观察响应侧档位:OpenAI 只信终止事件
  与无类型 body 里的 service_tier(response.created 为请求回显,忽略),
  Anthropic 读 usage.speed;查找仅在带 model 的帧上触发
- ResolveBillingServiceTier 只降不升:响应档位比请求便宜才采纳,更贵、
  未知或缺失一律沿用请求侧,与图片尺寸归并、response_model 计费的
  "不得更贵"不变式一致
- usage_logs.service_tier 记录实际计费档位,降级写
  billing.service_tier_downgraded 审计日志;HTTP、WS 及 ws_v2 relay 全覆盖
2026-08-23 16:53:28 +08:00
wucm667 4eadee1074 [verified] test(openai): update responses bridge signature 2026-08-23 08:45:04 +08:00
wucm667 31d5b67baa fix(openai): restore namespaced custom tool aliases 2026-08-23 08:11:20 +08:00
xiaxiaxaia 913ec5d74b fix(openai): sync models for OAuth accounts 2026-08-23 02:07:59 +08:00
lyen1688 77e0409f7c 新增渠道时间段定价工作日规则 2026-08-23 00:30:27 +08:00
anguobao123 6a1efda0cc fix(deploy): preserve gateway defaults in compose 2026-08-22 21:02:48 +08:00
wucm667 7498d8fdc8 fix(openai): enforce serial tool calls for Responses Lite 2026-08-22 19:40:19 +08:00
shunwang-cryptoandClaude Opus 4.8 cd05772e91 fix(ops): avoid mixing cgroup and host memory metrics
In Docker + cgroup v2 with no memory limit set, /sys/fs/cgroup/memory.current
returns a small container number while /sys/fs/cgroup/memory.max is "max".
readCgroupMemoryBytes then returned (used=<container>, total=0, ok=true).

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

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

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

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

Adds ops_metrics_collector_memory_test.go covering all branches.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-22 19:35:31 +08:00
wucm667 7a09a2eaf7 fix(responses): remove orphan deferred tool flags 2026-08-22 16:42:56 +08:00
Jarl d493ce0bb2 fix(openai): scope codex identity to oauth account 2026-08-22 16:06:04 +08:00
Yoga Sakti 5dfad32b87 fix(frontend): accept unlimited (0) user concurrency in the edit dialog
The admin user edit modal rejected concurrency < 1, so a user whose
concurrency is already 0 could not be saved at all — the guard runs
before the request, blocking notes, password, role and RPM edits on that
user too.

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

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

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

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

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

Confidence: high

Scope-risk: narrow

Reversibility: clean

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

  main   openAIHTTPPassthroughSSERateLimitUpstream
         TestOpenAIResponses_APIKeyPassthroughSSERateLimitUsesConfiguredPoolRetry
  branch openAIHTTPPassthroughAuthFailoverUpstream
         TestOpenAIResponses_APIKeyPassthroughPoolAuthFailureRetriesThenSwitchesToHealthyAccount

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Docs only, no code or workflow changes.
2026-08-09 18:10:46 +08:00
github-actions[bot] 48eb3766d2 chore: sync VERSION to 0.1.173 [skip ci] 2026-08-09 08:26:22 +00:00
Yuxin Qiao 280c1c8623 fix(openai): fail over empty response.completed streams instead of recording 0/0 success
A Responses stream that ends with response.completed but carries no output,
no usage and no error (only response.created + response.completed observed)
is a silent upstream refusal. It was previously treated as a successful
turn, recording 0 input / 0 output tokens without triggering failover, so
clients saw an empty reply and usage was never billed.

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

Strip non-rs reasoning ids while preserving valid rs_* ids, mirroring the
existing message/tool-call handling. Fixes #5410.
2026-08-08 14:34:53 +08:00
wucm667 9763765ebf fix(openai): fail over OAuth image stream errors 2026-08-08 01:05:29 +08:00
cyh 74fcdf3d42 fix(openai): preserve exponential capacity backoff 2026-08-08 01:02:02 +08:00
wucm667 e01c917a97 fix(risk-control): block prompts when risk control backend fails
- Change loadRuntimeSnapshot failure from fail-open to fail-closed
- Return block decision with 500 status when config cannot be loaded
- Add regression test for snapshot failure scenario
- Fixes #5388
2026-08-07 20:29:17 +08:00
wucm667 9b54b46b06 fix(admin): enable image generation permission for Composite groups
- Add 'composite' to imagePricingPlatforms set in groupsImagePricing.ts
- Allow Composite groups to toggle allow_image_generation in admin UI
- Backend already supports allow_image_generation field for all platforms
- Add test case to verify Composite platform is included

Fixes #5369
2026-08-07 16:22:37 +08:00
Rain 8ea68bd689 fix grok chat usage guard parity 2026-08-07 11:36:34 +08:00
wucm667 ab326c96eb fix(openai-ws): exclude terminal events from TTFT 2026-08-07 00:25:37 +08:00
li fe2c265c91 fix(identity): validate user-agent before persisting account fingerprint
IdentityService 把客户端 User-Agent 作为账号级持久身份缓存(Redis
fingerprint:<accountID>),覆盖规则是"只升不降"的纯数值版本比较,且对 UA 内容
不做任何合法性校验:

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

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

改动:

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

Fixes #5254
2026-08-06 15:01:28 +08:00
wucm667 df249ee5d5 fix(scheduler): diagnose legacy OpenAI exclusions 2026-08-06 02:54:16 +08:00
wucm667 8aa425d22f fix(apicompat): accept chat reasoning alias 2026-08-05 18:25:58 +08:00
Rain ba92d70422 fix grok usage guard for compatible accounts 2026-08-05 17:59:20 +08:00
wucm667 2f109e74ca fix: emit response.failed when compact keepalive commits headers but no SSE payload
When OpenAI Responses streaming has compact keepalive commit 200 headers
but no semantic SSE event has been written, upstream 4xx errors were
silently swallowed, causing clients to hang until timeout.

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

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

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

Before

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

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

Before

Width:  |  Height:  |  Size: 3.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+10
View File
@@ -121,6 +121,16 @@ linters:
# Default: true — must be true, ent generates 130K+ lines of code
generated-is-used: true
exclusions:
rules:
# G703/G704 污点分析在测试文件中只会命中 httptest mock 与测试者自设的
# 环境变量路径,不构成攻击面;且该分析跨环境结果不稳定(本地/CI 报告的
# 位置集合不同),逐点 nolint 无法收敛,故按路径豁免。生产代码不豁免,
# 必须逐点 //nolint:gosec 并写明信任边界。
- path: '_test\.go$'
linters: [ gosec ]
text: 'G70[34]'
formatters:
enable:
- gofmt
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.26.5-alpine
FROM golang:1.27.0-alpine
WORKDIR /app
+1 -1
View File
@@ -1 +1 @@
0.1.172
0.1.180
+5
View File
@@ -153,6 +153,11 @@ func runMainServer() {
log.Fatalf("Failed to initialize application: %v", err)
}
defer app.Cleanup()
if app.PluginManager != nil {
if err := app.PluginManager.Start(context.Background()); err != nil {
log.Printf("Plugin manager started in degraded state: %v", err)
}
}
if app.PromptAudit != nil {
if err := app.PromptAudit.Start(context.Background()); err != nil {
// Startup continues so unrelated APIs stay up. Fail-closed (unavailable)
+40 -10
View File
@@ -25,9 +25,10 @@ import (
)
type Application struct {
Server *http.Server
PromptAudit *securityaudit.PromptService
Cleanup func()
Server *http.Server
PromptAudit *securityaudit.PromptService
PluginManager *service.PluginManager
Cleanup func()
}
func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
@@ -51,12 +52,13 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// BuildInfo provider
provideServiceBuildInfo,
providePluginHostInfo,
// Cleanup function provider
provideCleanup,
// Application struct
wire.Struct(new(Application), "Server", "PromptAudit", "Cleanup"),
wire.Struct(new(Application), "Server", "PromptAudit", "PluginManager", "Cleanup"),
)
return nil, nil
}
@@ -72,6 +74,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
}
}
func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo {
return service.PluginHostInfo{
Version: buildInfo.Version,
BuildType: buildInfo.BuildType,
}
}
func provideCleanup(
entClient *ent.Client,
rdb *redis.Client,
@@ -88,6 +97,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
@@ -115,7 +125,9 @@ func provideCleanup(
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
auditLog *service.AuditLogService,
openAIAutoReset *service.OpenAIQuotaAutoResetService,
promptAudit *securityaudit.PromptService,
pluginManager *service.PluginManager,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -128,6 +140,18 @@ func provideCleanup(
// 应用层清理步骤可并行执行,基础设施资源(Redis/Ent)最后按顺序关闭。
parallelSteps := []cleanupStep{
{"PluginManager", func() error {
if pluginManager != nil {
pluginManager.Stop()
}
return nil
}},
{"OpenAIQuotaAutoResetService", func() error {
if openAIAutoReset != nil {
openAIAutoReset.Stop()
}
return nil
}},
{"OpsIngressRejectAggregator", func() error {
if opsIngressReject != nil {
opsIngressReject.Stop()
@@ -238,6 +262,12 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"CNProviderBalanceCheckService", func() error {
if cnProviderBalanceCheck != nil {
cnProviderBalanceCheck.Stop()
}
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
@@ -321,12 +351,12 @@ func provideCleanup(
return nil
}},
{"ChannelMonitorV2Aggregator", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorV2Aggregator != nil {
channelMonitorV2Aggregator.Stop()
}
return nil
}},
{"ChannelMonitorRunner", func() error {
if channelMonitorRunner != nil {
channelMonitorRunner.Stop()
}
+57 -16
View File
@@ -189,18 +189,21 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
adminAccountRepository := repository.NewAdminAccountRepository(client, db, schedulerCache)
proxyExitInfoProber := repository.NewProxyExitInfoProber(configConfig)
proxyLatencyCache := repository.NewProxyLatencyCache(redisClient)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver)
adminService := service.NewAdminService(userRepository, adminGroupRepository, adminAccountRepository, proxyRepository, apiKeyRepository, redeemCodeRepository, userGroupRateRepository, userRPMCache, billingCacheService, proxyExitInfoProber, proxyLatencyCache, apiKeyAuthCacheInvalidator, client, settingService, subscriptionService, userSubscriptionRepository, privacyClientFactory, openAIGatewayService, affiliateService, compositeModelRouteRepository, compositeRouteResolver, channelService)
adminUserHandler := admin.NewUserHandler(adminService, concurrencyService, serviceUserPlatformQuotaRepository, billingCache, totpService, userService, settingService)
groupCapacityService := service.NewGroupCapacityService(accountRepository, groupRepository, concurrencyService, sessionLimitCache, rpmCache)
groupHandler := admin.NewGroupHandler(adminService, dashboardService, groupCapacityService)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
grokQuotaService := service.ProvideGrokQuotaService(accountRepository, proxyRepository, grokTokenProvider, httpUpstream, configConfig, usageLogRepository, settingService)
openAIQuotaService := service.ProvideOpenAIQuotaService(accountRepository, proxyRepository, openAITokenProvider, privacyClientFactory, openAIGatewayService)
usageCache := service.NewUsageCache()
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService)
pluginRepository := repository.NewPluginRepository(db)
pluginHostInfo := providePluginHostInfo(buildInfo)
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
@@ -208,7 +211,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
dbDumper := repository.NewPgDumper(configConfig)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
imageStorageFactory := repository.ProvideImageStorageFactory()
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
backupHandler := admin.NewBackupHandler(backupService, userService, imageStorageSettingService)
@@ -218,6 +221,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
antigravityOAuthHandler := admin.NewAntigravityOAuthHandler(antigravityOAuthService)
tokenRefreshService := service.ProvideTokenRefreshService(accountRepository, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, compositeTokenCacheInvalidator, schedulerCache, configConfig, tempUnschedCache, privacyClientFactory, proxyRepository, oAuthRefreshAPI, openAIGatewayService)
grokOAuthHandler := admin.NewGrokOAuthHandler(grokOAuthService, adminService, grokQuotaService, tokenRefreshService)
cnProviderQuotaService := service.ProvideCNProviderQuotaService(accountRepository, proxyRepository, httpUpstream, configConfig)
cnProviderBalanceService := service.ProvideCNProviderBalanceService(accountRepository, proxyRepository, httpUpstream, configConfig)
cnProviderHandler := admin.NewCNProviderHandler(cnProviderQuotaService, cnProviderBalanceService)
proxyHandler := admin.NewProxyHandler(adminService)
adminRedeemHandler := admin.NewRedeemHandler(adminService, redeemService)
promoHandler := admin.NewPromoHandler(promoService)
@@ -248,6 +254,7 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
errorPassthroughService := service.NewErrorPassthroughService(errorPassthroughRepository, errorPassthroughCache)
errorPassthroughHandler := admin.NewErrorPassthroughHandler(errorPassthroughService)
tlsFingerprintProfileHandler := admin.NewTLSFingerprintProfileHandler(tlsFingerprintProfileService)
pluginHandler := admin.NewPluginHandler(pluginManager)
adminAPIKeyHandler := admin.NewAdminAPIKeyHandler(adminService)
scheduledTestPlanRepository := repository.NewScheduledTestPlanRepository(db)
scheduledTestResultRepository := repository.NewScheduledTestResultRepository(db)
@@ -277,14 +284,14 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
userMessageQueueService := service.ProvideUserMessageQueueService(userMsgQueueCache, rpmCache, configConfig)
legacyEngine := securityaudit.NewLegacyModerationAdapter(contentModerationService)
coordinator := securityaudit.NewCoordinator(legacyEngine, promptService)
gatewayHandler := handler.ProvideGatewayHandler(gatewayService, openAIGatewayService, geminiMessagesCompatService, antigravityGatewayService, userService, concurrencyService, billingCacheService, usageService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, userMessageQueueService, configConfig, settingService, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
openAIGatewayHandler := handler.ProvideOpenAIGatewayHandler(openAIGatewayService, pluginManager, concurrencyService, billingCacheService, apiKeyService, usageRecordWorkerPool, errorPassthroughService, contentModerationService, opsService, grokQuotaService, configConfig, coordinator)
handlerSettingHandler := handler.ProvideSettingHandler(settingService, buildInfo, notificationEmailService)
totpHandler := handler.NewTotpHandler(totpService)
passkeyRepository := repository.NewPasskeyRepository(db)
@@ -297,7 +304,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
handlerPaymentHandler := handler.NewPaymentHandler(paymentService, paymentConfigService)
paymentWebhookHandler := handler.NewPaymentWebhookHandler(paymentService, registry)
availableChannelHandler := handler.NewAvailableChannelHandler(channelService, apiKeyService, settingService)
modelPlazaHandler := handler.NewModelPlazaHandler(channelService, apiKeyService, settingService)
modelPlazaService := service.NewModelPlazaService(channelRepository, groupRepository, pricingService, billingService, modelPricingResolver)
modelPlazaHandler := handler.NewModelPlazaHandler(modelPlazaService, apiKeyService, settingService)
imageTaskStore := repository.NewImageTaskStore(redisClient)
imageTaskService := service.ProvideImageTaskService(imageTaskStore, imageStorageSettingService)
asyncImageHandler := handler.NewAsyncImageHandler(imageTaskService, openAIGatewayHandler)
@@ -311,7 +319,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
batchImageHandler := handler.ProvideBatchImageHandler(batchImagePublicService, batchImageDownloadService, batchImageCleanupService, openAIGatewayHandler)
idempotencyCoordinator := service.ProvideIdempotencyCoordinator(idempotencyRepository, configConfig)
idempotencyCleanupService := service.ProvideIdempotencyCleanupService(idempotencyRepository, configConfig)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService)
openAIQuotaAutoResetService := service.ProvideOpenAIQuotaAutoResetService(accountRepository, openAIQuotaService, rateLimitService, idempotencyCoordinator, auditLogService, settingService, leaderLockCache)
handlers := handler.ProvideHandlers(authHandler, userHandler, apiKeyHandler, usageHandler, redeemHandler, subscriptionHandler, announcementHandler, channelMonitorUserHandler, channelMonitorV2Handler, adminHandlers, gatewayHandler, openAIGatewayHandler, handlerSettingHandler, totpHandler, passkeyHandler, handlerPaymentHandler, paymentWebhookHandler, availableChannelHandler, modelPlazaHandler, asyncImageHandler, batchImageHandler, idempotencyCoordinator, idempotencyCleanupService, openAIQuotaAutoResetService)
jwtAuthMiddleware := middleware.NewJWTAuthMiddleware(authService, userService, settingService, auditLogService)
optionalJWTAuthMiddleware := middleware.NewOptionalJWTAuthMiddleware(authService, userService, settingService, auditLogService)
adminAuthMiddleware := middleware.NewAdminAuthMiddleware(authService, userService, settingService, auditLogService)
@@ -327,20 +336,23 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
opsScheduledReportService := service.ProvideOpsScheduledReportService(opsService, userService, emailService, redisClient, configConfig)
opsIngressRejectAggregator := service.ProvideOpsIngressRejectAggregator(opsRepository, opsService)
accountExpiryService := service.ProvideAccountExpiryService(accountRepository)
cnProviderBalanceCheckService := service.ProvideCNProviderBalanceCheckService(accountRepository, cnProviderBalanceService, cnProviderQuotaService, configConfig)
openAICodexVersionSyncService := service.ProvideOpenAICodexVersionSyncService(settingRepository, settingService, gitHubReleaseClient)
proxyExpiryService := service.ProvideProxyExpiryService(proxyRepository)
subscriptionExpiryService := service.ProvideSubscriptionExpiryService(userSubscriptionRepository, settingRepository, notificationEmailService, leaderLockCache, db)
batchImageWorkerRuntime := service.ProvideBatchImageWorkerRuntime(batchImageRepository, accountRepository, batchImageQueue, usageBillingRepository, usageLogRepository, batchImageModelPricingResolver, apiKeyAuthCacheInvalidator, configConfig)
scheduledTestRunnerService := service.ProvideScheduledTestRunnerService(scheduledTestPlanRepository, scheduledTestService, accountTestService, rateLimitService, configConfig)
paymentOrderExpiryService := service.ProvidePaymentOrderExpiryService(paymentService, leaderLockCache, db)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService)
channelMonitorQuotaFetcher := service.NewChannelMonitorQuotaFetcher(accountUsageService, cnProviderQuotaService, cnProviderBalanceService, accountRepository, configConfig)
channelMonitorRunner := service.ProvideChannelMonitorRunner(channelMonitorService, settingService, channelMonitorQuotaFetcher)
channelMonitorV2Aggregator := service.ProvideChannelMonitorV2Aggregator(channelMonitorV2Repository, db, settingService)
userPlatformQuotaUsageFlusher := service.ProvideUserPlatformQuotaUsageFlusher(configConfig, billingCache, serviceUserPlatformQuotaRepository, timingWheelService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, promptService)
v := provideCleanup(client, redisClient, opsMetricsCollector, opsAggregationService, opsAlertEvaluatorService, opsCleanupService, opsScheduledReportService, opsSystemLogSink, opsService, opsIngressRejectAggregator, apiKeyService, authCacheInvalidationWorker, schedulerSnapshotService, tokenRefreshService, accountExpiryService, cnProviderBalanceCheckService, openAICodexVersionSyncService, proxyExpiryService, subscriptionExpiryService, usageCleanupService, idempotencyCleanupService, batchImageCleanupService, batchImageWorkerRuntime, pricingService, emailQueueService, billingCacheService, usageRecordWorkerPool, subscriptionService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, openAIGatewayService, scheduledTestRunnerService, backupService, paymentOrderExpiryService, channelMonitorRunner, channelMonitorV2Aggregator, userPlatformQuotaUsageFlusher, upstreamBillingProbeService, ollamaCloudUsageService, auditLogService, openAIQuotaAutoResetService, promptService, pluginManager)
application := &Application{
Server: httpServer,
PromptAudit: promptService,
Cleanup: v,
Server: httpServer,
PromptAudit: promptService,
PluginManager: pluginManager,
Cleanup: v,
}
return application, nil
}
@@ -348,9 +360,10 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
// wire.go:
type Application struct {
Server *http.Server
PromptAudit *securityaudit.PromptService
Cleanup func()
Server *http.Server
PromptAudit *securityaudit.PromptService
PluginManager *service.PluginManager
Cleanup func()
}
func providePrivacyClientFactory() service.PrivacyClientFactory {
@@ -364,6 +377,13 @@ func provideServiceBuildInfo(buildInfo handler.BuildInfo) service.BuildInfo {
}
}
func providePluginHostInfo(buildInfo handler.BuildInfo) service.PluginHostInfo {
return service.PluginHostInfo{
Version: buildInfo.Version,
BuildType: buildInfo.BuildType,
}
}
func provideCleanup(
entClient *ent.Client,
rdb *redis.Client,
@@ -380,6 +400,7 @@ func provideCleanup(
schedulerSnapshot *service.SchedulerSnapshotService,
tokenRefresh *service.TokenRefreshService,
accountExpiry *service.AccountExpiryService,
cnProviderBalanceCheck *service.CNProviderBalanceCheckService,
codexVersionSync *service.OpenAICodexVersionSyncService,
proxyExpiry *service.ProxyExpiryService,
subscriptionExpiry *service.SubscriptionExpiryService,
@@ -407,7 +428,9 @@ func provideCleanup(
upstreamBillingProbe *service.UpstreamBillingProbeService,
ollamaCloudUsage *service.OllamaCloudUsageService,
auditLog *service.AuditLogService,
openAIAutoReset *service.OpenAIQuotaAutoResetService,
promptAudit *securityaudit.PromptService,
pluginManager *service.PluginManager,
) func() {
return func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -419,6 +442,18 @@ func provideCleanup(
}
parallelSteps := []cleanupStep{
{"PluginManager", func() error {
if pluginManager != nil {
pluginManager.Stop()
}
return nil
}},
{"OpenAIQuotaAutoResetService", func() error {
if openAIAutoReset != nil {
openAIAutoReset.Stop()
}
return nil
}},
{"OpsIngressRejectAggregator", func() error {
if opsIngressReject != nil {
opsIngressReject.Stop()
@@ -529,6 +564,12 @@ func provideCleanup(
accountExpiry.Stop()
return nil
}},
{"CNProviderBalanceCheckService", func() error {
if cnProviderBalanceCheck != nil {
cnProviderBalanceCheck.Stop()
}
return nil
}},
{"OpenAICodexVersionSyncService", func() error {
codexVersionSync.Stop()
return nil
+3
View File
@@ -66,6 +66,7 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
schedulerSnapshotSvc,
tokenRefreshSvc,
accountExpirySvc,
nil, // cnProviderBalanceCheck
codexVersionSyncSvc,
proxyExpirySvc,
subscriptionExpirySvc,
@@ -93,7 +94,9 @@ func TestProvideCleanup_WithMinimalDependencies_NoPanic(t *testing.T) {
nil, // upstreamBillingProbe
nil, // ollamaCloudUsage
nil, // auditLog
nil, // openAIAutoReset
nil, // promptAudit
nil, // pluginManager
)
require.NotPanics(t, func() {
+28 -3
View File
@@ -27,9 +27,13 @@ type ChannelMonitor struct {
Name string `json:"name,omitempty"`
// Provider holds the value of the "provider" field.
Provider channelmonitor.Provider `json:"provider,omitempty"`
// probe = LLM probe (default); quota = account usage only; quota_probe = both
CheckMode string `json:"check_mode,omitempty"`
// AccountID holds the value of the "account_id" field.
AccountID *int64 `json:"account_id,omitempty"`
// OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions
APIMode string `json:"api_mode,omitempty"`
// Provider base origin, e.g. https://api.openai.com
// Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors
Endpoint string `json:"endpoint,omitempty"`
// AES-256-GCM encrypted API key
APIKeyEncrypted string `json:"-"`
@@ -114,9 +118,9 @@ func (*ChannelMonitor) scanValues(columns []string) ([]any, error) {
values[i] = new([]byte)
case channelmonitor.FieldEnabled:
values[i] = new(sql.NullBool)
case channelmonitor.FieldID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
case channelmonitor.FieldID, channelmonitor.FieldAccountID, channelmonitor.FieldIntervalSeconds, channelmonitor.FieldJitterSeconds, channelmonitor.FieldCreatedBy, channelmonitor.FieldTemplateID:
values[i] = new(sql.NullInt64)
case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode:
case channelmonitor.FieldName, channelmonitor.FieldProvider, channelmonitor.FieldCheckMode, channelmonitor.FieldAPIMode, channelmonitor.FieldEndpoint, channelmonitor.FieldAPIKeyEncrypted, channelmonitor.FieldPrimaryModel, channelmonitor.FieldGroupName, channelmonitor.FieldBodyOverrideMode:
values[i] = new(sql.NullString)
case channelmonitor.FieldCreatedAt, channelmonitor.FieldUpdatedAt, channelmonitor.FieldLastCheckedAt:
values[i] = new(sql.NullTime)
@@ -165,6 +169,19 @@ func (_m *ChannelMonitor) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.Provider = channelmonitor.Provider(value.String)
}
case channelmonitor.FieldCheckMode:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field check_mode", values[i])
} else if value.Valid {
_m.CheckMode = value.String
}
case channelmonitor.FieldAccountID:
if value, ok := values[i].(*sql.NullInt64); !ok {
return fmt.Errorf("unexpected type %T for field account_id", values[i])
} else if value.Valid {
_m.AccountID = new(int64)
*_m.AccountID = value.Int64
}
case channelmonitor.FieldAPIMode:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field api_mode", values[i])
@@ -326,6 +343,14 @@ func (_m *ChannelMonitor) String() string {
builder.WriteString("provider=")
builder.WriteString(fmt.Sprintf("%v", _m.Provider))
builder.WriteString(", ")
builder.WriteString("check_mode=")
builder.WriteString(_m.CheckMode)
builder.WriteString(", ")
if v := _m.AccountID; v != nil {
builder.WriteString("account_id=")
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("api_mode=")
builder.WriteString(_m.APIMode)
builder.WriteString(", ")
+29 -5
View File
@@ -23,6 +23,10 @@ const (
FieldName = "name"
// FieldProvider holds the string denoting the provider field in the database.
FieldProvider = "provider"
// FieldCheckMode holds the string denoting the check_mode field in the database.
FieldCheckMode = "check_mode"
// FieldAccountID holds the string denoting the account_id field in the database.
FieldAccountID = "account_id"
// FieldAPIMode holds the string denoting the api_mode field in the database.
FieldAPIMode = "api_mode"
// FieldEndpoint holds the string denoting the endpoint field in the database.
@@ -91,6 +95,8 @@ var Columns = []string{
FieldUpdatedAt,
FieldName,
FieldProvider,
FieldCheckMode,
FieldAccountID,
FieldAPIMode,
FieldEndpoint,
FieldAPIKeyEncrypted,
@@ -127,6 +133,10 @@ var (
UpdateDefaultUpdatedAt func() time.Time
// NameValidator is a validator for the "name" field. It is called by the builders before save.
NameValidator func(string) error
// DefaultCheckMode holds the default value on creation for the "check_mode" field.
DefaultCheckMode string
// CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save.
CheckModeValidator func(string) error
// DefaultAPIMode holds the default value on creation for the "api_mode" field.
DefaultAPIMode string
// APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save.
@@ -164,10 +174,14 @@ type Provider string
// Provider values.
const (
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderAntigravity Provider = "antigravity"
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
)
func (pr Provider) String() string {
@@ -177,7 +191,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
return nil
default:
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
@@ -212,6 +226,16 @@ func ByProvider(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldProvider, opts...).ToFunc()
}
// ByCheckMode orders the results by the check_mode field.
func ByCheckMode(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldCheckMode, opts...).ToFunc()
}
// ByAccountID orders the results by the account_id field.
func ByAccountID(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAccountID, opts...).ToFunc()
}
// ByAPIMode orders the results by the api_mode field.
func ByAPIMode(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAPIMode, opts...).ToFunc()
+125
View File
@@ -70,6 +70,16 @@ func Name(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldName, v))
}
// CheckMode applies equality check predicate on the "check_mode" field. It's identical to CheckModeEQ.
func CheckMode(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v))
}
// AccountID applies equality check predicate on the "account_id" field. It's identical to AccountIDEQ.
func AccountID(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v))
}
// APIMode applies equality check predicate on the "api_mode" field. It's identical to APIModeEQ.
func APIMode(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v))
@@ -295,6 +305,121 @@ func ProviderNotIn(vs ...Provider) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldProvider, vs...))
}
// CheckModeEQ applies the EQ predicate on the "check_mode" field.
func CheckModeEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldCheckMode, v))
}
// CheckModeNEQ applies the NEQ predicate on the "check_mode" field.
func CheckModeNEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNEQ(FieldCheckMode, v))
}
// CheckModeIn applies the In predicate on the "check_mode" field.
func CheckModeIn(vs ...string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIn(FieldCheckMode, vs...))
}
// CheckModeNotIn applies the NotIn predicate on the "check_mode" field.
func CheckModeNotIn(vs ...string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldCheckMode, vs...))
}
// CheckModeGT applies the GT predicate on the "check_mode" field.
func CheckModeGT(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGT(FieldCheckMode, v))
}
// CheckModeGTE applies the GTE predicate on the "check_mode" field.
func CheckModeGTE(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGTE(FieldCheckMode, v))
}
// CheckModeLT applies the LT predicate on the "check_mode" field.
func CheckModeLT(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLT(FieldCheckMode, v))
}
// CheckModeLTE applies the LTE predicate on the "check_mode" field.
func CheckModeLTE(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldCheckMode, v))
}
// CheckModeContains applies the Contains predicate on the "check_mode" field.
func CheckModeContains(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldContains(FieldCheckMode, v))
}
// CheckModeHasPrefix applies the HasPrefix predicate on the "check_mode" field.
func CheckModeHasPrefix(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldHasPrefix(FieldCheckMode, v))
}
// CheckModeHasSuffix applies the HasSuffix predicate on the "check_mode" field.
func CheckModeHasSuffix(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldHasSuffix(FieldCheckMode, v))
}
// CheckModeEqualFold applies the EqualFold predicate on the "check_mode" field.
func CheckModeEqualFold(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEqualFold(FieldCheckMode, v))
}
// CheckModeContainsFold applies the ContainsFold predicate on the "check_mode" field.
func CheckModeContainsFold(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldContainsFold(FieldCheckMode, v))
}
// AccountIDEQ applies the EQ predicate on the "account_id" field.
func AccountIDEQ(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAccountID, v))
}
// AccountIDNEQ applies the NEQ predicate on the "account_id" field.
func AccountIDNEQ(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNEQ(FieldAccountID, v))
}
// AccountIDIn applies the In predicate on the "account_id" field.
func AccountIDIn(vs ...int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIn(FieldAccountID, vs...))
}
// AccountIDNotIn applies the NotIn predicate on the "account_id" field.
func AccountIDNotIn(vs ...int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotIn(FieldAccountID, vs...))
}
// AccountIDGT applies the GT predicate on the "account_id" field.
func AccountIDGT(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGT(FieldAccountID, v))
}
// AccountIDGTE applies the GTE predicate on the "account_id" field.
func AccountIDGTE(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldGTE(FieldAccountID, v))
}
// AccountIDLT applies the LT predicate on the "account_id" field.
func AccountIDLT(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLT(FieldAccountID, v))
}
// AccountIDLTE applies the LTE predicate on the "account_id" field.
func AccountIDLTE(v int64) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldLTE(FieldAccountID, v))
}
// AccountIDIsNil applies the IsNil predicate on the "account_id" field.
func AccountIDIsNil() predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldIsNull(FieldAccountID))
}
// AccountIDNotNil applies the NotNil predicate on the "account_id" field.
func AccountIDNotNil() predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldNotNull(FieldAccountID))
}
// APIModeEQ applies the EQ predicate on the "api_mode" field.
func APIModeEQ(v string) predicate.ChannelMonitor {
return predicate.ChannelMonitor(sql.FieldEQ(FieldAPIMode, v))
+168
View File
@@ -65,6 +65,34 @@ func (_c *ChannelMonitorCreate) SetProvider(v channelmonitor.Provider) *ChannelM
return _c
}
// SetCheckMode sets the "check_mode" field.
func (_c *ChannelMonitorCreate) SetCheckMode(v string) *ChannelMonitorCreate {
_c.mutation.SetCheckMode(v)
return _c
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_c *ChannelMonitorCreate) SetNillableCheckMode(v *string) *ChannelMonitorCreate {
if v != nil {
_c.SetCheckMode(*v)
}
return _c
}
// SetAccountID sets the "account_id" field.
func (_c *ChannelMonitorCreate) SetAccountID(v int64) *ChannelMonitorCreate {
_c.mutation.SetAccountID(v)
return _c
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_c *ChannelMonitorCreate) SetNillableAccountID(v *int64) *ChannelMonitorCreate {
if v != nil {
_c.SetAccountID(*v)
}
return _c
}
// SetAPIMode sets the "api_mode" field.
func (_c *ChannelMonitorCreate) SetAPIMode(v string) *ChannelMonitorCreate {
_c.mutation.SetAPIMode(v)
@@ -303,6 +331,10 @@ func (_c *ChannelMonitorCreate) defaults() {
v := channelmonitor.DefaultUpdatedAt()
_c.mutation.SetUpdatedAt(v)
}
if _, ok := _c.mutation.CheckMode(); !ok {
v := channelmonitor.DefaultCheckMode
_c.mutation.SetCheckMode(v)
}
if _, ok := _c.mutation.APIMode(); !ok {
v := channelmonitor.DefaultAPIMode
_c.mutation.SetAPIMode(v)
@@ -357,6 +389,14 @@ func (_c *ChannelMonitorCreate) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if _, ok := _c.mutation.CheckMode(); !ok {
return &ValidationError{Name: "check_mode", err: errors.New(`ent: missing required field "ChannelMonitor.check_mode"`)}
}
if v, ok := _c.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if _, ok := _c.mutation.APIMode(); !ok {
return &ValidationError{Name: "api_mode", err: errors.New(`ent: missing required field "ChannelMonitor.api_mode"`)}
}
@@ -473,6 +513,14 @@ func (_c *ChannelMonitorCreate) createSpec() (*ChannelMonitor, *sqlgraph.CreateS
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
_node.Provider = value
}
if value, ok := _c.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
_node.CheckMode = value
}
if value, ok := _c.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
_node.AccountID = &value
}
if value, ok := _c.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
_node.APIMode = value
@@ -666,6 +714,42 @@ func (u *ChannelMonitorUpsert) UpdateProvider() *ChannelMonitorUpsert {
return u
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsert) SetCheckMode(v string) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldCheckMode, v)
return u
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsert) UpdateCheckMode() *ChannelMonitorUpsert {
u.SetExcluded(channelmonitor.FieldCheckMode)
return u
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsert) SetAccountID(v int64) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldAccountID, v)
return u
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsert) UpdateAccountID() *ChannelMonitorUpsert {
u.SetExcluded(channelmonitor.FieldAccountID)
return u
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsert) AddAccountID(v int64) *ChannelMonitorUpsert {
u.Add(channelmonitor.FieldAccountID, v)
return u
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsert) ClearAccountID() *ChannelMonitorUpsert {
u.SetNull(channelmonitor.FieldAccountID)
return u
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsert) SetAPIMode(v string) *ChannelMonitorUpsert {
u.Set(channelmonitor.FieldAPIMode, v)
@@ -975,6 +1059,48 @@ func (u *ChannelMonitorUpsertOne) UpdateProvider() *ChannelMonitorUpsertOne {
})
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsertOne) SetCheckMode(v string) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetCheckMode(v)
})
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsertOne) UpdateCheckMode() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateCheckMode()
})
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsertOne) SetAccountID(v int64) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetAccountID(v)
})
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsertOne) AddAccountID(v int64) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddAccountID(v)
})
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsertOne) UpdateAccountID() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateAccountID()
})
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsertOne) ClearAccountID() *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
s.ClearAccountID()
})
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsertOne) SetAPIMode(v string) *ChannelMonitorUpsertOne {
return u.Update(func(s *ChannelMonitorUpsert) {
@@ -1487,6 +1613,48 @@ func (u *ChannelMonitorUpsertBulk) UpdateProvider() *ChannelMonitorUpsertBulk {
})
}
// SetCheckMode sets the "check_mode" field.
func (u *ChannelMonitorUpsertBulk) SetCheckMode(v string) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetCheckMode(v)
})
}
// UpdateCheckMode sets the "check_mode" field to the value that was provided on create.
func (u *ChannelMonitorUpsertBulk) UpdateCheckMode() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateCheckMode()
})
}
// SetAccountID sets the "account_id" field.
func (u *ChannelMonitorUpsertBulk) SetAccountID(v int64) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.SetAccountID(v)
})
}
// AddAccountID adds v to the "account_id" field.
func (u *ChannelMonitorUpsertBulk) AddAccountID(v int64) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.AddAccountID(v)
})
}
// UpdateAccountID sets the "account_id" field to the value that was provided on create.
func (u *ChannelMonitorUpsertBulk) UpdateAccountID() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.UpdateAccountID()
})
}
// ClearAccountID clears the value of the "account_id" field.
func (u *ChannelMonitorUpsertBulk) ClearAccountID() *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
s.ClearAccountID()
})
}
// SetAPIMode sets the "api_mode" field.
func (u *ChannelMonitorUpsertBulk) SetAPIMode(v string) *ChannelMonitorUpsertBulk {
return u.Update(func(s *ChannelMonitorUpsert) {
+116
View File
@@ -66,6 +66,47 @@ func (_u *ChannelMonitorUpdate) SetNillableProvider(v *channelmonitor.Provider)
return _u
}
// SetCheckMode sets the "check_mode" field.
func (_u *ChannelMonitorUpdate) SetCheckMode(v string) *ChannelMonitorUpdate {
_u.mutation.SetCheckMode(v)
return _u
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_u *ChannelMonitorUpdate) SetNillableCheckMode(v *string) *ChannelMonitorUpdate {
if v != nil {
_u.SetCheckMode(*v)
}
return _u
}
// SetAccountID sets the "account_id" field.
func (_u *ChannelMonitorUpdate) SetAccountID(v int64) *ChannelMonitorUpdate {
_u.mutation.ResetAccountID()
_u.mutation.SetAccountID(v)
return _u
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_u *ChannelMonitorUpdate) SetNillableAccountID(v *int64) *ChannelMonitorUpdate {
if v != nil {
_u.SetAccountID(*v)
}
return _u
}
// AddAccountID adds value to the "account_id" field.
func (_u *ChannelMonitorUpdate) AddAccountID(v int64) *ChannelMonitorUpdate {
_u.mutation.AddAccountID(v)
return _u
}
// ClearAccountID clears the value of the "account_id" field.
func (_u *ChannelMonitorUpdate) ClearAccountID() *ChannelMonitorUpdate {
_u.mutation.ClearAccountID()
return _u
}
// SetAPIMode sets the "api_mode" field.
func (_u *ChannelMonitorUpdate) SetAPIMode(v string) *ChannelMonitorUpdate {
_u.mutation.SetAPIMode(v)
@@ -453,6 +494,11 @@ func (_u *ChannelMonitorUpdate) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if v, ok := _u.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if v, ok := _u.mutation.APIMode(); ok {
if err := channelmonitor.APIModeValidator(v); err != nil {
return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)}
@@ -517,6 +563,18 @@ func (_u *ChannelMonitorUpdate) sqlSave(ctx context.Context) (_node int, err err
if value, ok := _u.mutation.Provider(); ok {
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
}
if value, ok := _u.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
}
if value, ok := _u.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if value, ok := _u.mutation.AddedAccountID(); ok {
_spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if _u.mutation.AccountIDCleared() {
_spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64)
}
if value, ok := _u.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
}
@@ -755,6 +813,47 @@ func (_u *ChannelMonitorUpdateOne) SetNillableProvider(v *channelmonitor.Provide
return _u
}
// SetCheckMode sets the "check_mode" field.
func (_u *ChannelMonitorUpdateOne) SetCheckMode(v string) *ChannelMonitorUpdateOne {
_u.mutation.SetCheckMode(v)
return _u
}
// SetNillableCheckMode sets the "check_mode" field if the given value is not nil.
func (_u *ChannelMonitorUpdateOne) SetNillableCheckMode(v *string) *ChannelMonitorUpdateOne {
if v != nil {
_u.SetCheckMode(*v)
}
return _u
}
// SetAccountID sets the "account_id" field.
func (_u *ChannelMonitorUpdateOne) SetAccountID(v int64) *ChannelMonitorUpdateOne {
_u.mutation.ResetAccountID()
_u.mutation.SetAccountID(v)
return _u
}
// SetNillableAccountID sets the "account_id" field if the given value is not nil.
func (_u *ChannelMonitorUpdateOne) SetNillableAccountID(v *int64) *ChannelMonitorUpdateOne {
if v != nil {
_u.SetAccountID(*v)
}
return _u
}
// AddAccountID adds value to the "account_id" field.
func (_u *ChannelMonitorUpdateOne) AddAccountID(v int64) *ChannelMonitorUpdateOne {
_u.mutation.AddAccountID(v)
return _u
}
// ClearAccountID clears the value of the "account_id" field.
func (_u *ChannelMonitorUpdateOne) ClearAccountID() *ChannelMonitorUpdateOne {
_u.mutation.ClearAccountID()
return _u
}
// SetAPIMode sets the "api_mode" field.
func (_u *ChannelMonitorUpdateOne) SetAPIMode(v string) *ChannelMonitorUpdateOne {
_u.mutation.SetAPIMode(v)
@@ -1155,6 +1254,11 @@ func (_u *ChannelMonitorUpdateOne) check() error {
return &ValidationError{Name: "provider", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.provider": %w`, err)}
}
}
if v, ok := _u.mutation.CheckMode(); ok {
if err := channelmonitor.CheckModeValidator(v); err != nil {
return &ValidationError{Name: "check_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.check_mode": %w`, err)}
}
}
if v, ok := _u.mutation.APIMode(); ok {
if err := channelmonitor.APIModeValidator(v); err != nil {
return &ValidationError{Name: "api_mode", err: fmt.Errorf(`ent: validator failed for field "ChannelMonitor.api_mode": %w`, err)}
@@ -1236,6 +1340,18 @@ func (_u *ChannelMonitorUpdateOne) sqlSave(ctx context.Context) (_node *ChannelM
if value, ok := _u.mutation.Provider(); ok {
_spec.SetField(channelmonitor.FieldProvider, field.TypeEnum, value)
}
if value, ok := _u.mutation.CheckMode(); ok {
_spec.SetField(channelmonitor.FieldCheckMode, field.TypeString, value)
}
if value, ok := _u.mutation.AccountID(); ok {
_spec.SetField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if value, ok := _u.mutation.AddedAccountID(); ok {
_spec.AddField(channelmonitor.FieldAccountID, field.TypeInt64, value)
}
if _u.mutation.AccountIDCleared() {
_spec.ClearField(channelmonitor.FieldAccountID, field.TypeInt64)
}
if value, ok := _u.mutation.APIMode(); ok {
_spec.SetField(channelmonitor.FieldAPIMode, field.TypeString, value)
}
+17
View File
@@ -3,6 +3,7 @@
package ent
import (
"encoding/json"
"fmt"
"strings"
"time"
@@ -11,6 +12,7 @@ import (
"entgo.io/ent/dialect/sql"
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistory is the model entity for the ChannelMonitorHistory schema.
@@ -30,6 +32,8 @@ type ChannelMonitorHistory struct {
PingLatencyMs *int `json:"ping_latency_ms,omitempty"`
// Message holds the value of the "message" field.
Message string `json:"message,omitempty"`
// Quota holds the value of the "quota" field.
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
// CheckedAt holds the value of the "checked_at" field.
CheckedAt time.Time `json:"checked_at,omitempty"`
// Edges holds the relations/edges for other nodes in the graph.
@@ -63,6 +67,8 @@ func (*ChannelMonitorHistory) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case channelmonitorhistory.FieldQuota:
values[i] = new([]byte)
case channelmonitorhistory.FieldID, channelmonitorhistory.FieldMonitorID, channelmonitorhistory.FieldLatencyMs, channelmonitorhistory.FieldPingLatencyMs:
values[i] = new(sql.NullInt64)
case channelmonitorhistory.FieldModel, channelmonitorhistory.FieldStatus, channelmonitorhistory.FieldMessage:
@@ -128,6 +134,14 @@ func (_m *ChannelMonitorHistory) assignValues(columns []string, values []any) er
} else if value.Valid {
_m.Message = value.String
}
case channelmonitorhistory.FieldQuota:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field quota", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.Quota); err != nil {
return fmt.Errorf("unmarshal field quota: %w", err)
}
}
case channelmonitorhistory.FieldCheckedAt:
if value, ok := values[i].(*sql.NullTime); !ok {
return fmt.Errorf("unexpected type %T for field checked_at", values[i])
@@ -197,6 +211,9 @@ func (_m *ChannelMonitorHistory) String() string {
builder.WriteString("message=")
builder.WriteString(_m.Message)
builder.WriteString(", ")
builder.WriteString("quota=")
builder.WriteString(fmt.Sprintf("%v", _m.Quota))
builder.WriteString(", ")
builder.WriteString("checked_at=")
builder.WriteString(_m.CheckedAt.Format(time.ANSIC))
builder.WriteByte(')')
@@ -27,6 +27,8 @@ const (
FieldPingLatencyMs = "ping_latency_ms"
// FieldMessage holds the string denoting the message field in the database.
FieldMessage = "message"
// FieldQuota holds the string denoting the quota field in the database.
FieldQuota = "quota"
// FieldCheckedAt holds the string denoting the checked_at field in the database.
FieldCheckedAt = "checked_at"
// EdgeMonitor holds the string denoting the monitor edge name in mutations.
@@ -51,6 +53,7 @@ var Columns = []string{
FieldLatencyMs,
FieldPingLatencyMs,
FieldMessage,
FieldQuota,
FieldCheckedAt,
}
@@ -365,6 +365,16 @@ func MessageContainsFold(v string) predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldContainsFold(FieldMessage, v))
}
// QuotaIsNil applies the IsNil predicate on the "quota" field.
func QuotaIsNil() predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldIsNull(FieldQuota))
}
// QuotaNotNil applies the NotNil predicate on the "quota" field.
func QuotaNotNil() predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldNotNull(FieldQuota))
}
// CheckedAtEQ applies the EQ predicate on the "checked_at" field.
func CheckedAtEQ(v time.Time) predicate.ChannelMonitorHistory {
return predicate.ChannelMonitorHistory(sql.FieldEQ(FieldCheckedAt, v))
@@ -13,6 +13,7 @@ import (
"entgo.io/ent/schema/field"
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistoryCreate is the builder for creating a ChannelMonitorHistory entity.
@@ -83,6 +84,12 @@ func (_c *ChannelMonitorHistoryCreate) SetNillableMessage(v *string) *ChannelMon
return _c
}
// SetQuota sets the "quota" field.
func (_c *ChannelMonitorHistoryCreate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryCreate {
_c.mutation.SetQuota(v)
return _c
}
// SetCheckedAt sets the "checked_at" field.
func (_c *ChannelMonitorHistoryCreate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryCreate {
_c.mutation.SetCheckedAt(v)
@@ -226,6 +233,10 @@ func (_c *ChannelMonitorHistoryCreate) createSpec() (*ChannelMonitorHistory, *sq
_spec.SetField(channelmonitorhistory.FieldMessage, field.TypeString, value)
_node.Message = value
}
if value, ok := _c.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
_node.Quota = value
}
if value, ok := _c.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
_node.CheckedAt = value
@@ -401,6 +412,24 @@ func (u *ChannelMonitorHistoryUpsert) ClearMessage() *ChannelMonitorHistoryUpser
return u
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsert) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsert {
u.Set(channelmonitorhistory.FieldQuota, v)
return u
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsert) UpdateQuota() *ChannelMonitorHistoryUpsert {
u.SetExcluded(channelmonitorhistory.FieldQuota)
return u
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsert) ClearQuota() *ChannelMonitorHistoryUpsert {
u.SetNull(channelmonitorhistory.FieldQuota)
return u
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsert) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsert {
u.Set(channelmonitorhistory.FieldCheckedAt, v)
@@ -572,6 +601,27 @@ func (u *ChannelMonitorHistoryUpsertOne) ClearMessage() *ChannelMonitorHistoryUp
})
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsertOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.SetQuota(v)
})
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsertOne) UpdateQuota() *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.UpdateQuota()
})
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsertOne) ClearQuota() *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.ClearQuota()
})
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsertOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertOne {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
@@ -909,6 +959,27 @@ func (u *ChannelMonitorHistoryUpsertBulk) ClearMessage() *ChannelMonitorHistoryU
})
}
// SetQuota sets the "quota" field.
func (u *ChannelMonitorHistoryUpsertBulk) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.SetQuota(v)
})
}
// UpdateQuota sets the "quota" field to the value that was provided on create.
func (u *ChannelMonitorHistoryUpsertBulk) UpdateQuota() *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.UpdateQuota()
})
}
// ClearQuota clears the value of the "quota" field.
func (u *ChannelMonitorHistoryUpsertBulk) ClearQuota() *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
s.ClearQuota()
})
}
// SetCheckedAt sets the "checked_at" field.
func (u *ChannelMonitorHistoryUpsertBulk) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpsertBulk {
return u.Update(func(s *ChannelMonitorHistoryUpsert) {
@@ -14,6 +14,7 @@ import (
"github.com/Wei-Shaw/sub2api/ent/channelmonitor"
"github.com/Wei-Shaw/sub2api/ent/channelmonitorhistory"
"github.com/Wei-Shaw/sub2api/ent/predicate"
"github.com/Wei-Shaw/sub2api/internal/domain"
)
// ChannelMonitorHistoryUpdate is the builder for updating ChannelMonitorHistory entities.
@@ -145,6 +146,18 @@ func (_u *ChannelMonitorHistoryUpdate) ClearMessage() *ChannelMonitorHistoryUpda
return _u
}
// SetQuota sets the "quota" field.
func (_u *ChannelMonitorHistoryUpdate) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdate {
_u.mutation.SetQuota(v)
return _u
}
// ClearQuota clears the value of the "quota" field.
func (_u *ChannelMonitorHistoryUpdate) ClearQuota() *ChannelMonitorHistoryUpdate {
_u.mutation.ClearQuota()
return _u
}
// SetCheckedAt sets the "checked_at" field.
func (_u *ChannelMonitorHistoryUpdate) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdate {
_u.mutation.SetCheckedAt(v)
@@ -267,6 +280,12 @@ func (_u *ChannelMonitorHistoryUpdate) sqlSave(ctx context.Context) (_node int,
if _u.mutation.MessageCleared() {
_spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString)
}
if value, ok := _u.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
}
if _u.mutation.QuotaCleared() {
_spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON)
}
if value, ok := _u.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
}
@@ -435,6 +454,18 @@ func (_u *ChannelMonitorHistoryUpdateOne) ClearMessage() *ChannelMonitorHistoryU
return _u
}
// SetQuota sets the "quota" field.
func (_u *ChannelMonitorHistoryUpdateOne) SetQuota(v *domain.MonitorQuotaSnapshot) *ChannelMonitorHistoryUpdateOne {
_u.mutation.SetQuota(v)
return _u
}
// ClearQuota clears the value of the "quota" field.
func (_u *ChannelMonitorHistoryUpdateOne) ClearQuota() *ChannelMonitorHistoryUpdateOne {
_u.mutation.ClearQuota()
return _u
}
// SetCheckedAt sets the "checked_at" field.
func (_u *ChannelMonitorHistoryUpdateOne) SetCheckedAt(v time.Time) *ChannelMonitorHistoryUpdateOne {
_u.mutation.SetCheckedAt(v)
@@ -587,6 +618,12 @@ func (_u *ChannelMonitorHistoryUpdateOne) sqlSave(ctx context.Context) (_node *C
if _u.mutation.MessageCleared() {
_spec.ClearField(channelmonitorhistory.FieldMessage, field.TypeString)
}
if value, ok := _u.mutation.Quota(); ok {
_spec.SetField(channelmonitorhistory.FieldQuota, field.TypeJSON, value)
}
if _u.mutation.QuotaCleared() {
_spec.ClearField(channelmonitorhistory.FieldQuota, field.TypeJSON)
}
if value, ok := _u.mutation.CheckedAt(); ok {
_spec.SetField(channelmonitorhistory.FieldCheckedAt, field.TypeTime, value)
}
@@ -100,10 +100,14 @@ type Provider string
// Provider values.
const (
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderOpenai Provider = "openai"
ProviderAnthropic Provider = "anthropic"
ProviderGemini Provider = "gemini"
ProviderGrok Provider = "grok"
ProviderAntigravity Provider = "antigravity"
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
)
func (pr Provider) String() string {
@@ -113,7 +117,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
return nil
default:
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
+27 -2
View File
@@ -4,6 +4,7 @@ package ent
import (
"encoding/json"
"encoding/json/jsontext"
"fmt"
"strings"
"time"
@@ -97,6 +98,10 @@ type Group struct {
AudioTtsPricePerMillionChars *float64 `json:"audio_tts_price_per_million_chars,omitempty"`
// STT 每小时价格(USD)
AudioSttPricePerHour *float64 `json:"audio_stt_price_per_hour,omitempty"`
// 是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价
LongContextPricingEnabled bool `json:"long_context_pricing_enabled,omitempty"`
// 分组逐模型定价;优先级高于渠道和内置定价
ModelPricing jsontext.Value `json:"model_pricing,omitempty"`
// 是否仅允许 Claude Code 客户端
ClaudeCodeOnly bool `json:"claude_code_only,omitempty"`
// 非 Claude Code 请求降级使用的分组 ID
@@ -245,9 +250,9 @@ func (*Group) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case group.FieldVideoModelPrices, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
values[i] = new([]byte)
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
case group.FieldPeakRateEnabled, group.FieldIsExclusive, group.FieldAllowImageGeneration, group.FieldAllowBatchImageGeneration, group.FieldImageRateIndependent, group.FieldVideoRateIndependent, group.FieldLongContextPricingEnabled, group.FieldClaudeCodeOnly, group.FieldModelRoutingEnabled, group.FieldMcpXMLInject, group.FieldAllowMessagesDispatch, group.FieldAllowLive, group.FieldRequireOauthOnly, group.FieldRequirePrivacySet, group.FieldProfitControlEnabled:
values[i] = new(sql.NullBool)
case group.FieldRateMultiplier, group.FieldPeakRateMultiplier, group.FieldDailyLimitUsd, group.FieldWeeklyLimitUsd, group.FieldMonthlyLimitUsd, group.FieldImageRateMultiplier, group.FieldImagePrice1k, group.FieldImagePrice2k, group.FieldImagePrice4k, group.FieldBatchImageDiscountMultiplier, group.FieldBatchImageHoldMultiplier, group.FieldVideoRateMultiplier, group.FieldVideoPrice480p, group.FieldVideoPrice720p, group.FieldVideoPrice1080p, group.FieldWebSearchPricePerCall, group.FieldSearchPricePer1k, group.FieldAudioRealtimePricePerMin, group.FieldAudioTtsPricePerMillionChars, group.FieldAudioSttPricePerHour, group.FieldProfitMinMargin, group.FieldProfitSafetyBuffer:
values[i] = new(sql.NullFloat64)
@@ -531,6 +536,20 @@ func (_m *Group) assignValues(columns []string, values []any) error {
_m.AudioSttPricePerHour = new(float64)
*_m.AudioSttPricePerHour = value.Float64
}
case group.FieldLongContextPricingEnabled:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field long_context_pricing_enabled", values[i])
} else if value.Valid {
_m.LongContextPricingEnabled = value.Bool
}
case group.FieldModelPricing:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field model_pricing", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.ModelPricing); err != nil {
return fmt.Errorf("unmarshal field model_pricing: %w", err)
}
}
case group.FieldClaudeCodeOnly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field claude_code_only", values[i])
@@ -896,6 +915,12 @@ func (_m *Group) String() string {
builder.WriteString(fmt.Sprintf("%v", *v))
}
builder.WriteString(", ")
builder.WriteString("long_context_pricing_enabled=")
builder.WriteString(fmt.Sprintf("%v", _m.LongContextPricingEnabled))
builder.WriteString(", ")
builder.WriteString("model_pricing=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelPricing))
builder.WriteString(", ")
builder.WriteString("claude_code_only=")
builder.WriteString(fmt.Sprintf("%v", _m.ClaudeCodeOnly))
builder.WriteString(", ")
+13
View File
@@ -94,6 +94,10 @@ const (
FieldAudioTtsPricePerMillionChars = "audio_tts_price_per_million_chars"
// FieldAudioSttPricePerHour holds the string denoting the audio_stt_price_per_hour field in the database.
FieldAudioSttPricePerHour = "audio_stt_price_per_hour"
// FieldLongContextPricingEnabled holds the string denoting the long_context_pricing_enabled field in the database.
FieldLongContextPricingEnabled = "long_context_pricing_enabled"
// FieldModelPricing holds the string denoting the model_pricing field in the database.
FieldModelPricing = "model_pricing"
// FieldClaudeCodeOnly holds the string denoting the claude_code_only field in the database.
FieldClaudeCodeOnly = "claude_code_only"
// FieldFallbackGroupID holds the string denoting the fallback_group_id field in the database.
@@ -250,6 +254,8 @@ var Columns = []string{
FieldAudioRealtimePricePerMin,
FieldAudioTtsPricePerMillionChars,
FieldAudioSttPricePerHour,
FieldLongContextPricingEnabled,
FieldModelPricing,
FieldClaudeCodeOnly,
FieldFallbackGroupID,
FieldFallbackGroupIDOnInvalidRequest,
@@ -364,6 +370,8 @@ var (
AudioTtsPricePerMillionCharsValidator func(float64) error
// AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
AudioSttPricePerHourValidator func(float64) error
// DefaultLongContextPricingEnabled holds the default value on creation for the "long_context_pricing_enabled" field.
DefaultLongContextPricingEnabled bool
// DefaultClaudeCodeOnly holds the default value on creation for the "claude_code_only" field.
DefaultClaudeCodeOnly bool
// DefaultModelRoutingEnabled holds the default value on creation for the "model_routing_enabled" field.
@@ -604,6 +612,11 @@ func ByAudioSttPricePerHour(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAudioSttPricePerHour, opts...).ToFunc()
}
// ByLongContextPricingEnabled orders the results by the long_context_pricing_enabled field.
func ByLongContextPricingEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldLongContextPricingEnabled, opts...).ToFunc()
}
// ByClaudeCodeOnly orders the results by the claude_code_only field.
func ByClaudeCodeOnly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldClaudeCodeOnly, opts...).ToFunc()
+25
View File
@@ -245,6 +245,11 @@ func AudioSttPricePerHour(v float64) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAudioSttPricePerHour, v))
}
// LongContextPricingEnabled applies equality check predicate on the "long_context_pricing_enabled" field. It's identical to LongContextPricingEnabledEQ.
func LongContextPricingEnabled(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldLongContextPricingEnabled, v))
}
// ClaudeCodeOnly applies equality check predicate on the "claude_code_only" field. It's identical to ClaudeCodeOnlyEQ.
func ClaudeCodeOnly(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
@@ -2045,6 +2050,26 @@ func AudioSttPricePerHourNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldAudioSttPricePerHour))
}
// LongContextPricingEnabledEQ applies the EQ predicate on the "long_context_pricing_enabled" field.
func LongContextPricingEnabledEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldLongContextPricingEnabled, v))
}
// LongContextPricingEnabledNEQ applies the NEQ predicate on the "long_context_pricing_enabled" field.
func LongContextPricingEnabledNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldLongContextPricingEnabled, v))
}
// ModelPricingIsNil applies the IsNil predicate on the "model_pricing" field.
func ModelPricingIsNil() predicate.Group {
return predicate.Group(sql.FieldIsNull(FieldModelPricing))
}
// ModelPricingNotNil applies the NotNil predicate on the "model_pricing" field.
func ModelPricingNotNil() predicate.Group {
return predicate.Group(sql.FieldNotNull(FieldModelPricing))
}
// ClaudeCodeOnlyEQ applies the EQ predicate on the "claude_code_only" field.
func ClaudeCodeOnlyEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldClaudeCodeOnly, v))
+136
View File
@@ -4,6 +4,7 @@ package ent
import (
"context"
"encoding/json/jsontext"
"errors"
"fmt"
"time"
@@ -559,6 +560,26 @@ func (_c *GroupCreate) SetNillableAudioSttPricePerHour(v *float64) *GroupCreate
return _c
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_c *GroupCreate) SetLongContextPricingEnabled(v bool) *GroupCreate {
_c.mutation.SetLongContextPricingEnabled(v)
return _c
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_c *GroupCreate) SetNillableLongContextPricingEnabled(v *bool) *GroupCreate {
if v != nil {
_c.SetLongContextPricingEnabled(*v)
}
return _c
}
// SetModelPricing sets the "model_pricing" field.
func (_c *GroupCreate) SetModelPricing(v jsontext.Value) *GroupCreate {
_c.mutation.SetModelPricing(v)
return _c
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_c *GroupCreate) SetClaudeCodeOnly(v bool) *GroupCreate {
_c.mutation.SetClaudeCodeOnly(v)
@@ -1042,6 +1063,10 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultVideoRateMultiplier
_c.mutation.SetVideoRateMultiplier(v)
}
if _, ok := _c.mutation.LongContextPricingEnabled(); !ok {
v := group.DefaultLongContextPricingEnabled
_c.mutation.SetLongContextPricingEnabled(v)
}
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
v := group.DefaultClaudeCodeOnly
_c.mutation.SetClaudeCodeOnly(v)
@@ -1237,6 +1262,9 @@ func (_c *GroupCreate) check() error {
return &ValidationError{Name: "audio_stt_price_per_hour", err: fmt.Errorf(`ent: validator failed for field "Group.audio_stt_price_per_hour": %w`, err)}
}
}
if _, ok := _c.mutation.LongContextPricingEnabled(); !ok {
return &ValidationError{Name: "long_context_pricing_enabled", err: errors.New(`ent: missing required field "Group.long_context_pricing_enabled"`)}
}
if _, ok := _c.mutation.ClaudeCodeOnly(); !ok {
return &ValidationError{Name: "claude_code_only", err: errors.New(`ent: missing required field "Group.claude_code_only"`)}
}
@@ -1484,6 +1512,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldAudioSttPricePerHour, field.TypeFloat64, value)
_node.AudioSttPricePerHour = &value
}
if value, ok := _c.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
_node.LongContextPricingEnabled = value
}
if value, ok := _c.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
_node.ModelPricing = value
}
if value, ok := _c.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
_node.ClaudeCodeOnly = value
@@ -2396,6 +2432,36 @@ func (u *GroupUpsert) ClearAudioSttPricePerHour() *GroupUpsert {
return u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsert) SetLongContextPricingEnabled(v bool) *GroupUpsert {
u.Set(group.FieldLongContextPricingEnabled, v)
return u
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsert) UpdateLongContextPricingEnabled() *GroupUpsert {
u.SetExcluded(group.FieldLongContextPricingEnabled)
return u
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsert) SetModelPricing(v jsontext.Value) *GroupUpsert {
u.Set(group.FieldModelPricing, v)
return u
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelPricing() *GroupUpsert {
u.SetExcluded(group.FieldModelPricing)
return u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsert) ClearModelPricing() *GroupUpsert {
u.SetNull(group.FieldModelPricing)
return u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsert) SetClaudeCodeOnly(v bool) *GroupUpsert {
u.Set(group.FieldClaudeCodeOnly, v)
@@ -3534,6 +3600,41 @@ func (u *GroupUpsertOne) ClearAudioSttPricePerHour() *GroupUpsertOne {
})
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsertOne) SetLongContextPricingEnabled(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetLongContextPricingEnabled(v)
})
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateLongContextPricingEnabled() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateLongContextPricingEnabled()
})
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsertOne) SetModelPricing(v jsontext.Value) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetModelPricing(v)
})
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelPricing() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelPricing()
})
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsertOne) ClearModelPricing() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.ClearModelPricing()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertOne) SetClaudeCodeOnly(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -4889,6 +4990,41 @@ func (u *GroupUpsertBulk) ClearAudioSttPricePerHour() *GroupUpsertBulk {
})
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (u *GroupUpsertBulk) SetLongContextPricingEnabled(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetLongContextPricingEnabled(v)
})
}
// UpdateLongContextPricingEnabled sets the "long_context_pricing_enabled" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateLongContextPricingEnabled() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateLongContextPricingEnabled()
})
}
// SetModelPricing sets the "model_pricing" field.
func (u *GroupUpsertBulk) SetModelPricing(v jsontext.Value) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetModelPricing(v)
})
}
// UpdateModelPricing sets the "model_pricing" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelPricing() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelPricing()
})
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (u *GroupUpsertBulk) ClearModelPricing() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.ClearModelPricing()
})
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (u *GroupUpsertBulk) SetClaudeCodeOnly(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+93
View File
@@ -4,6 +4,7 @@ package ent
import (
"context"
"encoding/json/jsontext"
"errors"
"fmt"
"time"
@@ -787,6 +788,38 @@ func (_u *GroupUpdate) ClearAudioSttPricePerHour() *GroupUpdate {
return _u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_u *GroupUpdate) SetLongContextPricingEnabled(v bool) *GroupUpdate {
_u.mutation.SetLongContextPricingEnabled(v)
return _u
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdate {
if v != nil {
_u.SetLongContextPricingEnabled(*v)
}
return _u
}
// SetModelPricing sets the "model_pricing" field.
func (_u *GroupUpdate) SetModelPricing(v jsontext.Value) *GroupUpdate {
_u.mutation.SetModelPricing(v)
return _u
}
// AppendModelPricing appends value to the "model_pricing" field.
func (_u *GroupUpdate) AppendModelPricing(v jsontext.Value) *GroupUpdate {
_u.mutation.AppendModelPricing(v)
return _u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (_u *GroupUpdate) ClearModelPricing() *GroupUpdate {
_u.mutation.ClearModelPricing()
return _u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdate) SetClaudeCodeOnly(v bool) *GroupUpdate {
_u.mutation.SetClaudeCodeOnly(v)
@@ -1697,6 +1730,20 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if _u.mutation.AudioSttPricePerHourCleared() {
_spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64)
}
if value, ok := _u.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedModelPricing(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, group.FieldModelPricing, value)
})
}
if _u.mutation.ModelPricingCleared() {
_spec.ClearField(group.FieldModelPricing, field.TypeJSON)
}
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
@@ -2862,6 +2909,38 @@ func (_u *GroupUpdateOne) ClearAudioSttPricePerHour() *GroupUpdateOne {
return _u
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (_u *GroupUpdateOne) SetLongContextPricingEnabled(v bool) *GroupUpdateOne {
_u.mutation.SetLongContextPricingEnabled(v)
return _u
}
// SetNillableLongContextPricingEnabled sets the "long_context_pricing_enabled" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableLongContextPricingEnabled(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetLongContextPricingEnabled(*v)
}
return _u
}
// SetModelPricing sets the "model_pricing" field.
func (_u *GroupUpdateOne) SetModelPricing(v jsontext.Value) *GroupUpdateOne {
_u.mutation.SetModelPricing(v)
return _u
}
// AppendModelPricing appends value to the "model_pricing" field.
func (_u *GroupUpdateOne) AppendModelPricing(v jsontext.Value) *GroupUpdateOne {
_u.mutation.AppendModelPricing(v)
return _u
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (_u *GroupUpdateOne) ClearModelPricing() *GroupUpdateOne {
_u.mutation.ClearModelPricing()
return _u
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (_u *GroupUpdateOne) SetClaudeCodeOnly(v bool) *GroupUpdateOne {
_u.mutation.SetClaudeCodeOnly(v)
@@ -3802,6 +3881,20 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if _u.mutation.AudioSttPricePerHourCleared() {
_spec.ClearField(group.FieldAudioSttPricePerHour, field.TypeFloat64)
}
if value, ok := _u.mutation.LongContextPricingEnabled(); ok {
_spec.SetField(group.FieldLongContextPricingEnabled, field.TypeBool, value)
}
if value, ok := _u.mutation.ModelPricing(); ok {
_spec.SetField(group.FieldModelPricing, field.TypeJSON, value)
}
if value, ok := _u.mutation.AppendedModelPricing(); ok {
_spec.AddModifier(func(u *sql.UpdateBuilder) {
sqljson.Append(u, group.FieldModelPricing, value)
})
}
if _u.mutation.ModelPricingCleared() {
_spec.ClearField(group.FieldModelPricing, field.TypeJSON)
}
if value, ok := _u.mutation.ClaudeCodeOnly(); ok {
_spec.SetField(group.FieldClaudeCodeOnly, field.TypeBool, value)
}
+21 -11
View File
@@ -623,7 +623,9 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "endpoint", Type: field.TypeString, Size: 500},
{Name: "api_key_encrypted", Type: field.TypeString},
@@ -648,7 +650,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "channel_monitors_channel_monitor_request_templates_request_template",
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
Columns: []*schema.Column{ChannelMonitorsColumns[21]},
RefColumns: []*schema.Column{ChannelMonitorRequestTemplatesColumns[0]},
OnDelete: schema.SetNull,
},
@@ -657,7 +659,7 @@ var (
{
Name: "channelmonitor_enabled_last_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[11], ChannelMonitorsColumns[14]},
Columns: []*schema.Column{ChannelMonitorsColumns[13], ChannelMonitorsColumns[16]},
},
{
Name: "channelmonitor_provider",
@@ -667,17 +669,22 @@ var (
{
Name: "channelmonitor_provider_api_mode",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[5]},
Columns: []*schema.Column{ChannelMonitorsColumns[4], ChannelMonitorsColumns[7]},
},
{
Name: "channelmonitor_group_name",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[10]},
Columns: []*schema.Column{ChannelMonitorsColumns[12]},
},
{
Name: "channelmonitor_template_id",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[19]},
Columns: []*schema.Column{ChannelMonitorsColumns[21]},
},
{
Name: "channelmonitor_account_id",
Unique: false,
Columns: []*schema.Column{ChannelMonitorsColumns[6]},
},
},
}
@@ -733,6 +740,7 @@ var (
{Name: "latency_ms", Type: field.TypeInt, Nullable: true},
{Name: "ping_latency_ms", Type: field.TypeInt, Nullable: true},
{Name: "message", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "quota", Type: field.TypeJSON, Nullable: true},
{Name: "checked_at", Type: field.TypeTime},
{Name: "monitor_id", Type: field.TypeInt64},
}
@@ -744,7 +752,7 @@ var (
ForeignKeys: []*schema.ForeignKey{
{
Symbol: "channel_monitor_histories_channel_monitors_history",
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8]},
RefColumns: []*schema.Column{ChannelMonitorsColumns[0]},
OnDelete: schema.Cascade,
},
@@ -753,12 +761,12 @@ var (
{
Name: "channelmonitorhistory_monitor_id_model_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[6]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[8], ChannelMonitorHistoriesColumns[1], ChannelMonitorHistoriesColumns[7]},
},
{
Name: "channelmonitorhistory_checked_at",
Unique: false,
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[6]},
Columns: []*schema.Column{ChannelMonitorHistoriesColumns[7]},
},
},
}
@@ -768,7 +776,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "extra_headers", Type: field.TypeJSON},
@@ -934,6 +942,8 @@ var (
{Name: "audio_realtime_price_per_min", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "audio_tts_price_per_million_chars", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "audio_stt_price_per_hour", Type: field.TypeFloat64, Nullable: true, SchemaType: map[string]string{"postgres": "decimal(20,8)"}},
{Name: "long_context_pricing_enabled", Type: field.TypeBool, Default: true},
{Name: "model_pricing", Type: field.TypeJSON, Nullable: true, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "claude_code_only", Type: field.TypeBool, Default: false},
{Name: "fallback_group_id", Type: field.TypeInt64, Nullable: true},
{Name: "fallback_group_id_on_invalid_request", Type: field.TypeInt64, Nullable: true},
@@ -990,7 +1000,7 @@ var (
{
Name: "group_sort_order",
Unique: false,
Columns: []*schema.Column{GroupsColumns[47]},
Columns: []*schema.Column{GroupsColumns[49]},
},
{
Name: "idx_groups_duplicate_operation_id_active",
+393 -15
View File
@@ -4,7 +4,7 @@ package ent
import (
"context"
"encoding/json"
"encoding/json/jsontext"
"errors"
"fmt"
"sync"
@@ -14608,6 +14608,9 @@ type ChannelMonitorMutation struct {
updated_at *time.Time
name *string
provider *channelmonitor.Provider
check_mode *string
account_id *int64
addaccount_id *int64
api_mode *string
endpoint *string
api_key_encrypted *string
@@ -14882,6 +14885,112 @@ func (m *ChannelMonitorMutation) ResetProvider() {
m.provider = nil
}
// SetCheckMode sets the "check_mode" field.
func (m *ChannelMonitorMutation) SetCheckMode(s string) {
m.check_mode = &s
}
// CheckMode returns the value of the "check_mode" field in the mutation.
func (m *ChannelMonitorMutation) CheckMode() (r string, exists bool) {
v := m.check_mode
if v == nil {
return
}
return *v, true
}
// OldCheckMode returns the old "check_mode" field's value of the ChannelMonitor entity.
// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorMutation) OldCheckMode(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldCheckMode is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldCheckMode requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldCheckMode: %w", err)
}
return oldValue.CheckMode, nil
}
// ResetCheckMode resets all changes to the "check_mode" field.
func (m *ChannelMonitorMutation) ResetCheckMode() {
m.check_mode = nil
}
// SetAccountID sets the "account_id" field.
func (m *ChannelMonitorMutation) SetAccountID(i int64) {
m.account_id = &i
m.addaccount_id = nil
}
// AccountID returns the value of the "account_id" field in the mutation.
func (m *ChannelMonitorMutation) AccountID() (r int64, exists bool) {
v := m.account_id
if v == nil {
return
}
return *v, true
}
// OldAccountID returns the old "account_id" field's value of the ChannelMonitor entity.
// If the ChannelMonitor object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorMutation) OldAccountID(ctx context.Context) (v *int64, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldAccountID is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldAccountID requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldAccountID: %w", err)
}
return oldValue.AccountID, nil
}
// AddAccountID adds i to the "account_id" field.
func (m *ChannelMonitorMutation) AddAccountID(i int64) {
if m.addaccount_id != nil {
*m.addaccount_id += i
} else {
m.addaccount_id = &i
}
}
// AddedAccountID returns the value that was added to the "account_id" field in this mutation.
func (m *ChannelMonitorMutation) AddedAccountID() (r int64, exists bool) {
v := m.addaccount_id
if v == nil {
return
}
return *v, true
}
// ClearAccountID clears the value of the "account_id" field.
func (m *ChannelMonitorMutation) ClearAccountID() {
m.account_id = nil
m.addaccount_id = nil
m.clearedFields[channelmonitor.FieldAccountID] = struct{}{}
}
// AccountIDCleared returns if the "account_id" field was cleared in this mutation.
func (m *ChannelMonitorMutation) AccountIDCleared() bool {
_, ok := m.clearedFields[channelmonitor.FieldAccountID]
return ok
}
// ResetAccountID resets all changes to the "account_id" field.
func (m *ChannelMonitorMutation) ResetAccountID() {
m.account_id = nil
m.addaccount_id = nil
delete(m.clearedFields, channelmonitor.FieldAccountID)
}
// SetAPIMode sets the "api_mode" field.
func (m *ChannelMonitorMutation) SetAPIMode(s string) {
m.api_mode = &s
@@ -15731,7 +15840,7 @@ func (m *ChannelMonitorMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ChannelMonitorMutation) Fields() []string {
fields := make([]string, 0, 19)
fields := make([]string, 0, 21)
if m.created_at != nil {
fields = append(fields, channelmonitor.FieldCreatedAt)
}
@@ -15744,6 +15853,12 @@ func (m *ChannelMonitorMutation) Fields() []string {
if m.provider != nil {
fields = append(fields, channelmonitor.FieldProvider)
}
if m.check_mode != nil {
fields = append(fields, channelmonitor.FieldCheckMode)
}
if m.account_id != nil {
fields = append(fields, channelmonitor.FieldAccountID)
}
if m.api_mode != nil {
fields = append(fields, channelmonitor.FieldAPIMode)
}
@@ -15805,6 +15920,10 @@ func (m *ChannelMonitorMutation) Field(name string) (ent.Value, bool) {
return m.Name()
case channelmonitor.FieldProvider:
return m.Provider()
case channelmonitor.FieldCheckMode:
return m.CheckMode()
case channelmonitor.FieldAccountID:
return m.AccountID()
case channelmonitor.FieldAPIMode:
return m.APIMode()
case channelmonitor.FieldEndpoint:
@@ -15852,6 +15971,10 @@ func (m *ChannelMonitorMutation) OldField(ctx context.Context, name string) (ent
return m.OldName(ctx)
case channelmonitor.FieldProvider:
return m.OldProvider(ctx)
case channelmonitor.FieldCheckMode:
return m.OldCheckMode(ctx)
case channelmonitor.FieldAccountID:
return m.OldAccountID(ctx)
case channelmonitor.FieldAPIMode:
return m.OldAPIMode(ctx)
case channelmonitor.FieldEndpoint:
@@ -15919,6 +16042,20 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error {
}
m.SetProvider(v)
return nil
case channelmonitor.FieldCheckMode:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetCheckMode(v)
return nil
case channelmonitor.FieldAccountID:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetAccountID(v)
return nil
case channelmonitor.FieldAPIMode:
v, ok := value.(string)
if !ok {
@@ -16032,6 +16169,9 @@ func (m *ChannelMonitorMutation) SetField(name string, value ent.Value) error {
// this mutation.
func (m *ChannelMonitorMutation) AddedFields() []string {
var fields []string
if m.addaccount_id != nil {
fields = append(fields, channelmonitor.FieldAccountID)
}
if m.addinterval_seconds != nil {
fields = append(fields, channelmonitor.FieldIntervalSeconds)
}
@@ -16049,6 +16189,8 @@ func (m *ChannelMonitorMutation) AddedFields() []string {
// was not set, or was not defined in the schema.
func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) {
switch name {
case channelmonitor.FieldAccountID:
return m.AddedAccountID()
case channelmonitor.FieldIntervalSeconds:
return m.AddedIntervalSeconds()
case channelmonitor.FieldJitterSeconds:
@@ -16064,6 +16206,13 @@ func (m *ChannelMonitorMutation) AddedField(name string) (ent.Value, bool) {
// type.
func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error {
switch name {
case channelmonitor.FieldAccountID:
v, ok := value.(int64)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.AddAccountID(v)
return nil
case channelmonitor.FieldIntervalSeconds:
v, ok := value.(int)
if !ok {
@@ -16093,6 +16242,9 @@ func (m *ChannelMonitorMutation) AddField(name string, value ent.Value) error {
// mutation.
func (m *ChannelMonitorMutation) ClearedFields() []string {
var fields []string
if m.FieldCleared(channelmonitor.FieldAccountID) {
fields = append(fields, channelmonitor.FieldAccountID)
}
if m.FieldCleared(channelmonitor.FieldGroupName) {
fields = append(fields, channelmonitor.FieldGroupName)
}
@@ -16119,6 +16271,9 @@ func (m *ChannelMonitorMutation) FieldCleared(name string) bool {
// error if the field is not defined in the schema.
func (m *ChannelMonitorMutation) ClearField(name string) error {
switch name {
case channelmonitor.FieldAccountID:
m.ClearAccountID()
return nil
case channelmonitor.FieldGroupName:
m.ClearGroupName()
return nil
@@ -16151,6 +16306,12 @@ func (m *ChannelMonitorMutation) ResetField(name string) error {
case channelmonitor.FieldProvider:
m.ResetProvider()
return nil
case channelmonitor.FieldCheckMode:
m.ResetCheckMode()
return nil
case channelmonitor.FieldAccountID:
m.ResetAccountID()
return nil
case channelmonitor.FieldAPIMode:
m.ResetAPIMode()
return nil
@@ -17756,6 +17917,7 @@ type ChannelMonitorHistoryMutation struct {
ping_latency_ms *int
addping_latency_ms *int
message *string
quota **domain.MonitorQuotaSnapshot
checked_at *time.Time
clearedFields map[string]struct{}
monitor *int64
@@ -18160,6 +18322,55 @@ func (m *ChannelMonitorHistoryMutation) ResetMessage() {
delete(m.clearedFields, channelmonitorhistory.FieldMessage)
}
// SetQuota sets the "quota" field.
func (m *ChannelMonitorHistoryMutation) SetQuota(dqs *domain.MonitorQuotaSnapshot) {
m.quota = &dqs
}
// Quota returns the value of the "quota" field in the mutation.
func (m *ChannelMonitorHistoryMutation) Quota() (r *domain.MonitorQuotaSnapshot, exists bool) {
v := m.quota
if v == nil {
return
}
return *v, true
}
// OldQuota returns the old "quota" field's value of the ChannelMonitorHistory entity.
// If the ChannelMonitorHistory object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *ChannelMonitorHistoryMutation) OldQuota(ctx context.Context) (v *domain.MonitorQuotaSnapshot, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldQuota is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldQuota requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldQuota: %w", err)
}
return oldValue.Quota, nil
}
// ClearQuota clears the value of the "quota" field.
func (m *ChannelMonitorHistoryMutation) ClearQuota() {
m.quota = nil
m.clearedFields[channelmonitorhistory.FieldQuota] = struct{}{}
}
// QuotaCleared returns if the "quota" field was cleared in this mutation.
func (m *ChannelMonitorHistoryMutation) QuotaCleared() bool {
_, ok := m.clearedFields[channelmonitorhistory.FieldQuota]
return ok
}
// ResetQuota resets all changes to the "quota" field.
func (m *ChannelMonitorHistoryMutation) ResetQuota() {
m.quota = nil
delete(m.clearedFields, channelmonitorhistory.FieldQuota)
}
// SetCheckedAt sets the "checked_at" field.
func (m *ChannelMonitorHistoryMutation) SetCheckedAt(t time.Time) {
m.checked_at = &t
@@ -18257,7 +18468,7 @@ func (m *ChannelMonitorHistoryMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *ChannelMonitorHistoryMutation) Fields() []string {
fields := make([]string, 0, 7)
fields := make([]string, 0, 8)
if m.monitor != nil {
fields = append(fields, channelmonitorhistory.FieldMonitorID)
}
@@ -18276,6 +18487,9 @@ func (m *ChannelMonitorHistoryMutation) Fields() []string {
if m.message != nil {
fields = append(fields, channelmonitorhistory.FieldMessage)
}
if m.quota != nil {
fields = append(fields, channelmonitorhistory.FieldQuota)
}
if m.checked_at != nil {
fields = append(fields, channelmonitorhistory.FieldCheckedAt)
}
@@ -18299,6 +18513,8 @@ func (m *ChannelMonitorHistoryMutation) Field(name string) (ent.Value, bool) {
return m.PingLatencyMs()
case channelmonitorhistory.FieldMessage:
return m.Message()
case channelmonitorhistory.FieldQuota:
return m.Quota()
case channelmonitorhistory.FieldCheckedAt:
return m.CheckedAt()
}
@@ -18322,6 +18538,8 @@ func (m *ChannelMonitorHistoryMutation) OldField(ctx context.Context, name strin
return m.OldPingLatencyMs(ctx)
case channelmonitorhistory.FieldMessage:
return m.OldMessage(ctx)
case channelmonitorhistory.FieldQuota:
return m.OldQuota(ctx)
case channelmonitorhistory.FieldCheckedAt:
return m.OldCheckedAt(ctx)
}
@@ -18375,6 +18593,13 @@ func (m *ChannelMonitorHistoryMutation) SetField(name string, value ent.Value) e
}
m.SetMessage(v)
return nil
case channelmonitorhistory.FieldQuota:
v, ok := value.(*domain.MonitorQuotaSnapshot)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetQuota(v)
return nil
case channelmonitorhistory.FieldCheckedAt:
v, ok := value.(time.Time)
if !ok {
@@ -18448,6 +18673,9 @@ func (m *ChannelMonitorHistoryMutation) ClearedFields() []string {
if m.FieldCleared(channelmonitorhistory.FieldMessage) {
fields = append(fields, channelmonitorhistory.FieldMessage)
}
if m.FieldCleared(channelmonitorhistory.FieldQuota) {
fields = append(fields, channelmonitorhistory.FieldQuota)
}
return fields
}
@@ -18471,6 +18699,9 @@ func (m *ChannelMonitorHistoryMutation) ClearField(name string) error {
case channelmonitorhistory.FieldMessage:
m.ClearMessage()
return nil
case channelmonitorhistory.FieldQuota:
m.ClearQuota()
return nil
}
return fmt.Errorf("unknown ChannelMonitorHistory nullable field %s", name)
}
@@ -18497,6 +18728,9 @@ func (m *ChannelMonitorHistoryMutation) ResetField(name string) error {
case channelmonitorhistory.FieldMessage:
m.ResetMessage()
return nil
case channelmonitorhistory.FieldQuota:
m.ResetQuota()
return nil
case channelmonitorhistory.FieldCheckedAt:
m.ResetCheckedAt()
return nil
@@ -21907,6 +22141,9 @@ type GroupMutation struct {
addaudio_tts_price_per_million_chars *float64
audio_stt_price_per_hour *float64
addaudio_stt_price_per_hour *float64
long_context_pricing_enabled *bool
model_pricing *jsontext.Value
appendmodel_pricing jsontext.Value
claude_code_only *bool
fallback_group_id *int64
addfallback_group_id *int64
@@ -24130,6 +24367,107 @@ func (m *GroupMutation) ResetAudioSttPricePerHour() {
delete(m.clearedFields, group.FieldAudioSttPricePerHour)
}
// SetLongContextPricingEnabled sets the "long_context_pricing_enabled" field.
func (m *GroupMutation) SetLongContextPricingEnabled(b bool) {
m.long_context_pricing_enabled = &b
}
// LongContextPricingEnabled returns the value of the "long_context_pricing_enabled" field in the mutation.
func (m *GroupMutation) LongContextPricingEnabled() (r bool, exists bool) {
v := m.long_context_pricing_enabled
if v == nil {
return
}
return *v, true
}
// OldLongContextPricingEnabled returns the old "long_context_pricing_enabled" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldLongContextPricingEnabled(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldLongContextPricingEnabled is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldLongContextPricingEnabled requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldLongContextPricingEnabled: %w", err)
}
return oldValue.LongContextPricingEnabled, nil
}
// ResetLongContextPricingEnabled resets all changes to the "long_context_pricing_enabled" field.
func (m *GroupMutation) ResetLongContextPricingEnabled() {
m.long_context_pricing_enabled = nil
}
// SetModelPricing sets the "model_pricing" field.
func (m *GroupMutation) SetModelPricing(j jsontext.Value) {
m.model_pricing = &j
m.appendmodel_pricing = nil
}
// ModelPricing returns the value of the "model_pricing" field in the mutation.
func (m *GroupMutation) ModelPricing() (r jsontext.Value, exists bool) {
v := m.model_pricing
if v == nil {
return
}
return *v, true
}
// OldModelPricing returns the old "model_pricing" field's value of the Group entity.
// If the Group object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *GroupMutation) OldModelPricing(ctx context.Context) (v jsontext.Value, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldModelPricing is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldModelPricing requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldModelPricing: %w", err)
}
return oldValue.ModelPricing, nil
}
// AppendModelPricing adds j to the "model_pricing" field.
func (m *GroupMutation) AppendModelPricing(j jsontext.Value) {
m.appendmodel_pricing = append(m.appendmodel_pricing, j...)
}
// AppendedModelPricing returns the list of values that were appended to the "model_pricing" field in this mutation.
func (m *GroupMutation) AppendedModelPricing() (jsontext.Value, bool) {
if len(m.appendmodel_pricing) == 0 {
return nil, false
}
return m.appendmodel_pricing, true
}
// ClearModelPricing clears the value of the "model_pricing" field.
func (m *GroupMutation) ClearModelPricing() {
m.model_pricing = nil
m.appendmodel_pricing = nil
m.clearedFields[group.FieldModelPricing] = struct{}{}
}
// ModelPricingCleared returns if the "model_pricing" field was cleared in this mutation.
func (m *GroupMutation) ModelPricingCleared() bool {
_, ok := m.clearedFields[group.FieldModelPricing]
return ok
}
// ResetModelPricing resets all changes to the "model_pricing" field.
func (m *GroupMutation) ResetModelPricing() {
m.model_pricing = nil
m.appendmodel_pricing = nil
delete(m.clearedFields, group.FieldModelPricing)
}
// SetClaudeCodeOnly sets the "claude_code_only" field.
func (m *GroupMutation) SetClaudeCodeOnly(b bool) {
m.claude_code_only = &b
@@ -25435,7 +25773,7 @@ func (m *GroupMutation) Type() string {
// order to get all numeric fields that were incremented/decremented, call
// AddedFields().
func (m *GroupMutation) Fields() []string {
fields := make([]string, 0, 60)
fields := make([]string, 0, 62)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -25553,6 +25891,12 @@ func (m *GroupMutation) Fields() []string {
if m.audio_stt_price_per_hour != nil {
fields = append(fields, group.FieldAudioSttPricePerHour)
}
if m.long_context_pricing_enabled != nil {
fields = append(fields, group.FieldLongContextPricingEnabled)
}
if m.model_pricing != nil {
fields = append(fields, group.FieldModelPricing)
}
if m.claude_code_only != nil {
fields = append(fields, group.FieldClaudeCodeOnly)
}
@@ -25702,6 +26046,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.AudioTtsPricePerMillionChars()
case group.FieldAudioSttPricePerHour:
return m.AudioSttPricePerHour()
case group.FieldLongContextPricingEnabled:
return m.LongContextPricingEnabled()
case group.FieldModelPricing:
return m.ModelPricing()
case group.FieldClaudeCodeOnly:
return m.ClaudeCodeOnly()
case group.FieldFallbackGroupID:
@@ -25831,6 +26179,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldAudioTtsPricePerMillionChars(ctx)
case group.FieldAudioSttPricePerHour:
return m.OldAudioSttPricePerHour(ctx)
case group.FieldLongContextPricingEnabled:
return m.OldLongContextPricingEnabled(ctx)
case group.FieldModelPricing:
return m.OldModelPricing(ctx)
case group.FieldClaudeCodeOnly:
return m.OldClaudeCodeOnly(ctx)
case group.FieldFallbackGroupID:
@@ -26155,6 +26507,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetAudioSttPricePerHour(v)
return nil
case group.FieldLongContextPricingEnabled:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetLongContextPricingEnabled(v)
return nil
case group.FieldModelPricing:
v, ok := value.(jsontext.Value)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetModelPricing(v)
return nil
case group.FieldClaudeCodeOnly:
v, ok := value.(bool)
if !ok {
@@ -26713,6 +27079,9 @@ func (m *GroupMutation) ClearedFields() []string {
if m.FieldCleared(group.FieldAudioSttPricePerHour) {
fields = append(fields, group.FieldAudioSttPricePerHour)
}
if m.FieldCleared(group.FieldModelPricing) {
fields = append(fields, group.FieldModelPricing)
}
if m.FieldCleared(group.FieldFallbackGroupID) {
fields = append(fields, group.FieldFallbackGroupID)
}
@@ -26790,6 +27159,9 @@ func (m *GroupMutation) ClearField(name string) error {
case group.FieldAudioSttPricePerHour:
m.ClearAudioSttPricePerHour()
return nil
case group.FieldModelPricing:
m.ClearModelPricing()
return nil
case group.FieldFallbackGroupID:
m.ClearFallbackGroupID()
return nil
@@ -26924,6 +27296,12 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldAudioSttPricePerHour:
m.ResetAudioSttPricePerHour()
return nil
case group.FieldLongContextPricingEnabled:
m.ResetLongContextPricingEnabled()
return nil
case group.FieldModelPricing:
m.ResetModelPricing()
return nil
case group.FieldClaudeCodeOnly:
m.ResetClaudeCodeOnly()
return nil
@@ -42773,8 +43151,8 @@ type UsageCleanupTaskMutation struct {
created_at *time.Time
updated_at *time.Time
status *string
filters *json.RawMessage
appendfilters json.RawMessage
filters *jsontext.Value
appendfilters jsontext.Value
created_by *int64
addcreated_by *int64
deleted_rows *int64
@@ -42998,13 +43376,13 @@ func (m *UsageCleanupTaskMutation) ResetStatus() {
}
// SetFilters sets the "filters" field.
func (m *UsageCleanupTaskMutation) SetFilters(jm json.RawMessage) {
m.filters = &jm
func (m *UsageCleanupTaskMutation) SetFilters(j jsontext.Value) {
m.filters = &j
m.appendfilters = nil
}
// Filters returns the value of the "filters" field in the mutation.
func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) {
func (m *UsageCleanupTaskMutation) Filters() (r jsontext.Value, exists bool) {
v := m.filters
if v == nil {
return
@@ -43015,7 +43393,7 @@ func (m *UsageCleanupTaskMutation) Filters() (r json.RawMessage, exists bool) {
// OldFilters returns the old "filters" field's value of the UsageCleanupTask entity.
// If the UsageCleanupTask object wasn't provided to the builder, the object is fetched from the database.
// An error is returned if the mutation operation is not UpdateOne, or the database query fails.
func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMessage, err error) {
func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v jsontext.Value, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldFilters is only allowed on UpdateOne operations")
}
@@ -43029,13 +43407,13 @@ func (m *UsageCleanupTaskMutation) OldFilters(ctx context.Context) (v json.RawMe
return oldValue.Filters, nil
}
// AppendFilters adds jm to the "filters" field.
func (m *UsageCleanupTaskMutation) AppendFilters(jm json.RawMessage) {
m.appendfilters = append(m.appendfilters, jm...)
// AppendFilters adds j to the "filters" field.
func (m *UsageCleanupTaskMutation) AppendFilters(j jsontext.Value) {
m.appendfilters = append(m.appendfilters, j...)
}
// AppendedFilters returns the list of values that were appended to the "filters" field in this mutation.
func (m *UsageCleanupTaskMutation) AppendedFilters() (json.RawMessage, bool) {
func (m *UsageCleanupTaskMutation) AppendedFilters() (jsontext.Value, bool) {
if len(m.appendfilters) == 0 {
return nil, false
}
@@ -43586,7 +43964,7 @@ func (m *UsageCleanupTaskMutation) SetField(name string, value ent.Value) error
m.SetStatus(v)
return nil
case usagecleanuptask.FieldFilters:
v, ok := value.(json.RawMessage)
v, ok := value.(jsontext.Value)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
+41 -45
View File
@@ -635,36 +635,28 @@ func init() {
return nil
}
}()
// channelmonitorDescCheckMode is the schema descriptor for check_mode field.
channelmonitorDescCheckMode := channelmonitorFields[2].Descriptor()
// channelmonitor.DefaultCheckMode holds the default value on creation for the check_mode field.
channelmonitor.DefaultCheckMode = channelmonitorDescCheckMode.Default.(string)
// channelmonitor.CheckModeValidator is a validator for the "check_mode" field. It is called by the builders before save.
channelmonitor.CheckModeValidator = channelmonitorDescCheckMode.Validators[0].(func(string) error)
// channelmonitorDescAPIMode is the schema descriptor for api_mode field.
channelmonitorDescAPIMode := channelmonitorFields[2].Descriptor()
channelmonitorDescAPIMode := channelmonitorFields[4].Descriptor()
// channelmonitor.DefaultAPIMode holds the default value on creation for the api_mode field.
channelmonitor.DefaultAPIMode = channelmonitorDescAPIMode.Default.(string)
// channelmonitor.APIModeValidator is a validator for the "api_mode" field. It is called by the builders before save.
channelmonitor.APIModeValidator = channelmonitorDescAPIMode.Validators[0].(func(string) error)
// channelmonitorDescEndpoint is the schema descriptor for endpoint field.
channelmonitorDescEndpoint := channelmonitorFields[3].Descriptor()
channelmonitorDescEndpoint := channelmonitorFields[5].Descriptor()
// channelmonitor.EndpointValidator is a validator for the "endpoint" field. It is called by the builders before save.
channelmonitor.EndpointValidator = func() func(string) error {
validators := channelmonitorDescEndpoint.Validators
fns := [...]func(string) error{
validators[0].(func(string) error),
validators[1].(func(string) error),
}
return func(endpoint string) error {
for _, fn := range fns {
if err := fn(endpoint); err != nil {
return err
}
}
return nil
}
}()
channelmonitor.EndpointValidator = channelmonitorDescEndpoint.Validators[0].(func(string) error)
// channelmonitorDescAPIKeyEncrypted is the schema descriptor for api_key_encrypted field.
channelmonitorDescAPIKeyEncrypted := channelmonitorFields[4].Descriptor()
channelmonitorDescAPIKeyEncrypted := channelmonitorFields[6].Descriptor()
// channelmonitor.APIKeyEncryptedValidator is a validator for the "api_key_encrypted" field. It is called by the builders before save.
channelmonitor.APIKeyEncryptedValidator = channelmonitorDescAPIKeyEncrypted.Validators[0].(func(string) error)
// channelmonitorDescPrimaryModel is the schema descriptor for primary_model field.
channelmonitorDescPrimaryModel := channelmonitorFields[5].Descriptor()
channelmonitorDescPrimaryModel := channelmonitorFields[7].Descriptor()
// channelmonitor.PrimaryModelValidator is a validator for the "primary_model" field. It is called by the builders before save.
channelmonitor.PrimaryModelValidator = func() func(string) error {
validators := channelmonitorDescPrimaryModel.Validators
@@ -682,35 +674,35 @@ func init() {
}
}()
// channelmonitorDescExtraModels is the schema descriptor for extra_models field.
channelmonitorDescExtraModels := channelmonitorFields[6].Descriptor()
channelmonitorDescExtraModels := channelmonitorFields[8].Descriptor()
// channelmonitor.DefaultExtraModels holds the default value on creation for the extra_models field.
channelmonitor.DefaultExtraModels = channelmonitorDescExtraModels.Default.([]string)
// channelmonitorDescGroupName is the schema descriptor for group_name field.
channelmonitorDescGroupName := channelmonitorFields[7].Descriptor()
channelmonitorDescGroupName := channelmonitorFields[9].Descriptor()
// channelmonitor.DefaultGroupName holds the default value on creation for the group_name field.
channelmonitor.DefaultGroupName = channelmonitorDescGroupName.Default.(string)
// channelmonitor.GroupNameValidator is a validator for the "group_name" field. It is called by the builders before save.
channelmonitor.GroupNameValidator = channelmonitorDescGroupName.Validators[0].(func(string) error)
// channelmonitorDescEnabled is the schema descriptor for enabled field.
channelmonitorDescEnabled := channelmonitorFields[8].Descriptor()
channelmonitorDescEnabled := channelmonitorFields[10].Descriptor()
// channelmonitor.DefaultEnabled holds the default value on creation for the enabled field.
channelmonitor.DefaultEnabled = channelmonitorDescEnabled.Default.(bool)
// channelmonitorDescIntervalSeconds is the schema descriptor for interval_seconds field.
channelmonitorDescIntervalSeconds := channelmonitorFields[9].Descriptor()
channelmonitorDescIntervalSeconds := channelmonitorFields[11].Descriptor()
// channelmonitor.IntervalSecondsValidator is a validator for the "interval_seconds" field. It is called by the builders before save.
channelmonitor.IntervalSecondsValidator = channelmonitorDescIntervalSeconds.Validators[0].(func(int) error)
// channelmonitorDescJitterSeconds is the schema descriptor for jitter_seconds field.
channelmonitorDescJitterSeconds := channelmonitorFields[10].Descriptor()
channelmonitorDescJitterSeconds := channelmonitorFields[12].Descriptor()
// channelmonitor.DefaultJitterSeconds holds the default value on creation for the jitter_seconds field.
channelmonitor.DefaultJitterSeconds = channelmonitorDescJitterSeconds.Default.(int)
// channelmonitor.JitterSecondsValidator is a validator for the "jitter_seconds" field. It is called by the builders before save.
channelmonitor.JitterSecondsValidator = channelmonitorDescJitterSeconds.Validators[0].(func(int) error)
// channelmonitorDescExtraHeaders is the schema descriptor for extra_headers field.
channelmonitorDescExtraHeaders := channelmonitorFields[14].Descriptor()
channelmonitorDescExtraHeaders := channelmonitorFields[16].Descriptor()
// channelmonitor.DefaultExtraHeaders holds the default value on creation for the extra_headers field.
channelmonitor.DefaultExtraHeaders = channelmonitorDescExtraHeaders.Default.(map[string]string)
// channelmonitorDescBodyOverrideMode is the schema descriptor for body_override_mode field.
channelmonitorDescBodyOverrideMode := channelmonitorFields[15].Descriptor()
channelmonitorDescBodyOverrideMode := channelmonitorFields[17].Descriptor()
// channelmonitor.DefaultBodyOverrideMode holds the default value on creation for the body_override_mode field.
channelmonitor.DefaultBodyOverrideMode = channelmonitorDescBodyOverrideMode.Default.(string)
// channelmonitor.BodyOverrideModeValidator is a validator for the "body_override_mode" field. It is called by the builders before save.
@@ -808,7 +800,7 @@ func init() {
// channelmonitorhistory.MessageValidator is a validator for the "message" field. It is called by the builders before save.
channelmonitorhistory.MessageValidator = channelmonitorhistoryDescMessage.Validators[0].(func(string) error)
// channelmonitorhistoryDescCheckedAt is the schema descriptor for checked_at field.
channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[6].Descriptor()
channelmonitorhistoryDescCheckedAt := channelmonitorhistoryFields[7].Descriptor()
// channelmonitorhistory.DefaultCheckedAt holds the default value on creation for the checked_at field.
channelmonitorhistory.DefaultCheckedAt = channelmonitorhistoryDescCheckedAt.Default.(func() time.Time)
channelmonitorrequesttemplateMixin := schema.ChannelMonitorRequestTemplate{}.Mixin()
@@ -1133,80 +1125,84 @@ func init() {
groupDescAudioSttPricePerHour := groupFields[35].Descriptor()
// group.AudioSttPricePerHourValidator is a validator for the "audio_stt_price_per_hour" field. It is called by the builders before save.
group.AudioSttPricePerHourValidator = groupDescAudioSttPricePerHour.Validators[0].(func(float64) error)
// groupDescLongContextPricingEnabled is the schema descriptor for long_context_pricing_enabled field.
groupDescLongContextPricingEnabled := groupFields[36].Descriptor()
// group.DefaultLongContextPricingEnabled holds the default value on creation for the long_context_pricing_enabled field.
group.DefaultLongContextPricingEnabled = groupDescLongContextPricingEnabled.Default.(bool)
// groupDescClaudeCodeOnly is the schema descriptor for claude_code_only field.
groupDescClaudeCodeOnly := groupFields[36].Descriptor()
groupDescClaudeCodeOnly := groupFields[38].Descriptor()
// group.DefaultClaudeCodeOnly holds the default value on creation for the claude_code_only field.
group.DefaultClaudeCodeOnly = groupDescClaudeCodeOnly.Default.(bool)
// groupDescModelRoutingEnabled is the schema descriptor for model_routing_enabled field.
groupDescModelRoutingEnabled := groupFields[40].Descriptor()
groupDescModelRoutingEnabled := groupFields[42].Descriptor()
// group.DefaultModelRoutingEnabled holds the default value on creation for the model_routing_enabled field.
group.DefaultModelRoutingEnabled = groupDescModelRoutingEnabled.Default.(bool)
// groupDescMcpXMLInject is the schema descriptor for mcp_xml_inject field.
groupDescMcpXMLInject := groupFields[41].Descriptor()
groupDescMcpXMLInject := groupFields[43].Descriptor()
// group.DefaultMcpXMLInject holds the default value on creation for the mcp_xml_inject field.
group.DefaultMcpXMLInject = groupDescMcpXMLInject.Default.(bool)
// groupDescSupportedModelScopes is the schema descriptor for supported_model_scopes field.
groupDescSupportedModelScopes := groupFields[42].Descriptor()
groupDescSupportedModelScopes := groupFields[44].Descriptor()
// group.DefaultSupportedModelScopes holds the default value on creation for the supported_model_scopes field.
group.DefaultSupportedModelScopes = groupDescSupportedModelScopes.Default.([]string)
// groupDescSortOrder is the schema descriptor for sort_order field.
groupDescSortOrder := groupFields[43].Descriptor()
groupDescSortOrder := groupFields[45].Descriptor()
// group.DefaultSortOrder holds the default value on creation for the sort_order field.
group.DefaultSortOrder = groupDescSortOrder.Default.(int)
// groupDescAllowMessagesDispatch is the schema descriptor for allow_messages_dispatch field.
groupDescAllowMessagesDispatch := groupFields[44].Descriptor()
groupDescAllowMessagesDispatch := groupFields[46].Descriptor()
// group.DefaultAllowMessagesDispatch holds the default value on creation for the allow_messages_dispatch field.
group.DefaultAllowMessagesDispatch = groupDescAllowMessagesDispatch.Default.(bool)
// groupDescAllowLive is the schema descriptor for allow_live field.
groupDescAllowLive := groupFields[45].Descriptor()
groupDescAllowLive := groupFields[47].Descriptor()
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[46].Descriptor()
groupDescRequireOauthOnly := groupFields[48].Descriptor()
// group.DefaultRequireOauthOnly holds the default value on creation for the require_oauth_only field.
group.DefaultRequireOauthOnly = groupDescRequireOauthOnly.Default.(bool)
// groupDescRequirePrivacySet is the schema descriptor for require_privacy_set field.
groupDescRequirePrivacySet := groupFields[47].Descriptor()
groupDescRequirePrivacySet := groupFields[49].Descriptor()
// group.DefaultRequirePrivacySet holds the default value on creation for the require_privacy_set field.
group.DefaultRequirePrivacySet = groupDescRequirePrivacySet.Default.(bool)
// groupDescDefaultMappedModel is the schema descriptor for default_mapped_model field.
groupDescDefaultMappedModel := groupFields[48].Descriptor()
groupDescDefaultMappedModel := groupFields[50].Descriptor()
// group.DefaultDefaultMappedModel holds the default value on creation for the default_mapped_model field.
group.DefaultDefaultMappedModel = groupDescDefaultMappedModel.Default.(string)
// group.DefaultMappedModelValidator is a validator for the "default_mapped_model" field. It is called by the builders before save.
group.DefaultMappedModelValidator = groupDescDefaultMappedModel.Validators[0].(func(string) error)
// groupDescMessagesDispatchModelConfig is the schema descriptor for messages_dispatch_model_config field.
groupDescMessagesDispatchModelConfig := groupFields[49].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[51].Descriptor()
// group.DefaultMessagesDispatchModelConfig holds the default value on creation for the messages_dispatch_model_config field.
group.DefaultMessagesDispatchModelConfig = groupDescMessagesDispatchModelConfig.Default.(domain.OpenAIMessagesDispatchModelConfig)
// groupDescModelsListConfig is the schema descriptor for models_list_config field.
groupDescModelsListConfig := groupFields[50].Descriptor()
groupDescModelsListConfig := groupFields[52].Descriptor()
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
groupDescRpmLimit := groupFields[51].Descriptor()
groupDescRpmLimit := groupFields[53].Descriptor()
// group.DefaultRpmLimit holds the default value on creation for the rpm_limit field.
group.DefaultRpmLimit = groupDescRpmLimit.Default.(int)
// groupDescMaxReasoningEffort is the schema descriptor for max_reasoning_effort field.
groupDescMaxReasoningEffort := groupFields[52].Descriptor()
groupDescMaxReasoningEffort := groupFields[54].Descriptor()
// group.DefaultMaxReasoningEffort holds the default value on creation for the max_reasoning_effort field.
group.DefaultMaxReasoningEffort = groupDescMaxReasoningEffort.Default.(string)
// group.MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
group.MaxReasoningEffortValidator = groupDescMaxReasoningEffort.Validators[0].(func(string) error)
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
groupDescReasoningEffortMappings := groupFields[53].Descriptor()
groupDescReasoningEffortMappings := groupFields[55].Descriptor()
// group.DefaultReasoningEffortMappings holds the default value on creation for the reasoning_effort_mappings field.
group.DefaultReasoningEffortMappings = groupDescReasoningEffortMappings.Default.([]domain.ReasoningEffortMapping)
// groupDescProfitControlEnabled is the schema descriptor for profit_control_enabled field.
groupDescProfitControlEnabled := groupFields[54].Descriptor()
groupDescProfitControlEnabled := groupFields[56].Descriptor()
// group.DefaultProfitControlEnabled holds the default value on creation for the profit_control_enabled field.
group.DefaultProfitControlEnabled = groupDescProfitControlEnabled.Default.(bool)
// groupDescProfitMinMargin is the schema descriptor for profit_min_margin field.
groupDescProfitMinMargin := groupFields[55].Descriptor()
groupDescProfitMinMargin := groupFields[57].Descriptor()
// group.DefaultProfitMinMargin holds the default value on creation for the profit_min_margin field.
group.DefaultProfitMinMargin = groupDescProfitMinMargin.Default.(float64)
// groupDescProfitSafetyBuffer is the schema descriptor for profit_safety_buffer field.
groupDescProfitSafetyBuffer := groupFields[56].Descriptor()
groupDescProfitSafetyBuffer := groupFields[58].Descriptor()
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
+21 -3
View File
@@ -35,15 +35,32 @@ func (ChannelMonitor) Fields() []ent.Field {
NotEmpty().
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok"),
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
// check_mode: 'probe' | 'quota' | 'quota_probe'
// probe - LLM 探活(默认,原有行为)
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
// quota_probe - 探活 + 配额并存(配额快照挂到主模型历史行)
// antigravity 无探活 adapter,仅允许 quota。
field.String("check_mode").
Default("probe").
MaxLen(32).
Comment("probe = LLM probe (default); quota = account usage only; quota_probe = both"),
// account_id: 配额模式的数据源账号(复用账号侧用量服务,不直接对接上游)。
// 普通字段而非 edge(FK 由 SQL 迁移管理);账号删除时数据库置空,
// 监控保留并报「账号未关联」。
field.Int64("account_id").
Optional().
Nillable(),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
Comment("OpenAI request protocol: chat_completions or responses; non-OpenAI uses chat_completions"),
// endpoint: 探活模式必填(service 层校验);quota 模式存空串
// (列保持 NOT NULL,去掉 NotEmpty 校验器即可)。
field.String("endpoint").
NotEmpty().
MaxLen(500).
Comment("Provider base origin, e.g. https://api.openai.com"),
Comment("Provider base origin, e.g. https://api.openai.com; empty for quota-only monitors"),
field.String("api_key_encrypted").
NotEmpty().
Sensitive().
@@ -115,5 +132,6 @@ func (ChannelMonitor) Indexes() []ent.Index {
index.Fields("provider", "api_mode"),
index.Fields("group_name"),
index.Fields("template_id"),
index.Fields("account_id"),
}
}
@@ -3,6 +3,8 @@ package schema
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"entgo.io/ent"
"entgo.io/ent/dialect/entsql"
"entgo.io/ent/schema"
@@ -43,6 +45,10 @@ func (ChannelMonitorHistory) Fields() []ent.Field {
Optional().
Default("").
MaxLen(500),
// quota: 配额模式(check_mode = quota / quota_probe)检测时附带的
// 归一化配额快照(domain.MonitorQuotaSnapshot,JSONB);探活模式为 NULL。
field.JSON("quota", &domain.MonitorQuotaSnapshot{}).
Optional(),
field.Time("checked_at").
Default(time.Now),
}
@@ -39,7 +39,8 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
NotEmpty().
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok"),
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+9
View File
@@ -1,6 +1,8 @@
package schema
import (
"encoding/json"
"github.com/Wei-Shaw/sub2api/ent/schema/mixins"
"github.com/Wei-Shaw/sub2api/internal/domain"
@@ -185,6 +187,13 @@ func (Group) Fields() []ent.Field {
Min(0).
SchemaType(map[string]string{dialect.Postgres: "decimal(20,8)"}).
Comment("STT 每小时价格(USD)"),
field.Bool("long_context_pricing_enabled").
Default(true).
Comment("是否按上下文长度应用模型阶梯价格;默认开启以保持官方/渠道长上下文价"),
field.JSON("model_pricing", json.RawMessage{}).
Optional().
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("分组逐模型定价;优先级高于渠道和内置定价"),
// Claude Code 客户端限制 (added by migration 029)
field.Bool("claude_code_only").
+2 -1
View File
@@ -41,7 +41,8 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 注意:平台列表的单一权威源为 service.AllowedQuotaPlatforms;
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity", "grok":
case "anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+2 -1
View File
@@ -4,6 +4,7 @@ package ent
import (
"encoding/json"
"encoding/json/jsontext"
"fmt"
"strings"
"time"
@@ -25,7 +26,7 @@ type UsageCleanupTask struct {
// Status holds the value of the "status" field.
Status string `json:"status,omitempty"`
// Filters holds the value of the "filters" field.
Filters json.RawMessage `json:"filters,omitempty"`
Filters jsontext.Value `json:"filters,omitempty"`
// CreatedBy holds the value of the "created_by" field.
CreatedBy int64 `json:"created_by,omitempty"`
// DeletedRows holds the value of the "deleted_rows" field.
+5 -5
View File
@@ -4,7 +4,7 @@ package ent
import (
"context"
"encoding/json"
"encoding/json/jsontext"
"errors"
"fmt"
"time"
@@ -58,7 +58,7 @@ func (_c *UsageCleanupTaskCreate) SetStatus(v string) *UsageCleanupTaskCreate {
}
// SetFilters sets the "filters" field.
func (_c *UsageCleanupTaskCreate) SetFilters(v json.RawMessage) *UsageCleanupTaskCreate {
func (_c *UsageCleanupTaskCreate) SetFilters(v jsontext.Value) *UsageCleanupTaskCreate {
_c.mutation.SetFilters(v)
return _c
}
@@ -375,7 +375,7 @@ func (u *UsageCleanupTaskUpsert) UpdateStatus() *UsageCleanupTaskUpsert {
}
// SetFilters sets the "filters" field.
func (u *UsageCleanupTaskUpsert) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsert {
func (u *UsageCleanupTaskUpsert) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsert {
u.Set(usagecleanuptask.FieldFilters, v)
return u
}
@@ -592,7 +592,7 @@ func (u *UsageCleanupTaskUpsertOne) UpdateStatus() *UsageCleanupTaskUpsertOne {
}
// SetFilters sets the "filters" field.
func (u *UsageCleanupTaskUpsertOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertOne {
func (u *UsageCleanupTaskUpsertOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertOne {
return u.Update(func(s *UsageCleanupTaskUpsert) {
s.SetFilters(v)
})
@@ -999,7 +999,7 @@ func (u *UsageCleanupTaskUpsertBulk) UpdateStatus() *UsageCleanupTaskUpsertBulk
}
// SetFilters sets the "filters" field.
func (u *UsageCleanupTaskUpsertBulk) SetFilters(v json.RawMessage) *UsageCleanupTaskUpsertBulk {
func (u *UsageCleanupTaskUpsertBulk) SetFilters(v jsontext.Value) *UsageCleanupTaskUpsertBulk {
return u.Update(func(s *UsageCleanupTaskUpsert) {
s.SetFilters(v)
})
+5 -5
View File
@@ -4,7 +4,7 @@ package ent
import (
"context"
"encoding/json"
"encoding/json/jsontext"
"errors"
"fmt"
"time"
@@ -51,13 +51,13 @@ func (_u *UsageCleanupTaskUpdate) SetNillableStatus(v *string) *UsageCleanupTask
}
// SetFilters sets the "filters" field.
func (_u *UsageCleanupTaskUpdate) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdate {
func (_u *UsageCleanupTaskUpdate) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdate {
_u.mutation.SetFilters(v)
return _u
}
// AppendFilters appends value to the "filters" field.
func (_u *UsageCleanupTaskUpdate) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdate {
func (_u *UsageCleanupTaskUpdate) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdate {
_u.mutation.AppendFilters(v)
return _u
}
@@ -374,13 +374,13 @@ func (_u *UsageCleanupTaskUpdateOne) SetNillableStatus(v *string) *UsageCleanupT
}
// SetFilters sets the "filters" field.
func (_u *UsageCleanupTaskUpdateOne) SetFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne {
func (_u *UsageCleanupTaskUpdateOne) SetFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne {
_u.mutation.SetFilters(v)
return _u
}
// AppendFilters appends value to the "filters" field.
func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v json.RawMessage) *UsageCleanupTaskUpdateOne {
func (_u *UsageCleanupTaskUpdateOne) AppendFilters(v jsontext.Value) *UsageCleanupTaskUpdateOne {
_u.mutation.AppendFilters(v)
return _u
}
+12 -7
View File
@@ -1,6 +1,6 @@
module github.com/Wei-Shaw/sub2api
go 1.26.5
go 1.27.0
require (
entgo.io/ent v0.14.5
@@ -24,6 +24,8 @@ require (
github.com/google/uuid v1.6.0
github.com/google/wire v0.7.0
github.com/gorilla/websocket v1.5.3
github.com/hashicorp/go-hclog v1.6.3
github.com/hashicorp/go-plugin v1.8.0
github.com/imroc/req/v3 v3.59.0
github.com/klauspost/compress v1.18.2
github.com/lib/pq v1.10.9
@@ -54,6 +56,8 @@ require (
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
google.golang.org/grpc v1.82.1
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.44.3
@@ -121,12 +125,14 @@ require (
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
github.com/go-webauthn/x v0.2.6 // indirect
github.com/goccy/go-json v0.10.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/google/go-cmp v0.7.0 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/google/go-tpm v0.9.8 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect
github.com/hashicorp/hcl/v2 v2.18.1 // indirect
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
@@ -149,6 +155,7 @@ require (
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/oklog/run v1.1.0 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
@@ -187,10 +194,9 @@ require (
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.41.0 // indirect
go.opentelemetry.io/otel/metric v1.41.0 // indirect
go.opentelemetry.io/otel/sdk v1.41.0 // indirect
go.opentelemetry.io/otel/trace v1.41.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
@@ -200,8 +206,7 @@ require (
golang.org/x/text v0.39.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/grpc v1.75.1 // indirect
google.golang.org/protobuf v1.36.10 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
+45 -26
View File
@@ -120,6 +120,8 @@ github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/bufbuild/protocompile v0.14.1 h1:iA73zAf/fyljNjQKwYzUHD6AD4R8KMasmwa/FBatYVw=
github.com/bufbuild/protocompile v0.14.1/go.mod h1:ppVdAIhbr2H8asPk6k4pY7t9zB1OU5DoEw9xY/FUi1c=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.9.1 h1:6iJ6NqdoxCDr6mbY8h18oSO+cShGSMRGCEo7F2h0x8s=
github.com/bytedance/sonic v1.9.1/go.mod h1:i736AoUSYt75HyZLoJW9ERYxcy6eaN6h4BZXU064P/U=
@@ -176,6 +178,7 @@ github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM=
github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
@@ -232,6 +235,8 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
@@ -250,8 +255,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -262,6 +265,10 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3 h1:NmZ1PKzSTQbuGHw9DGPFomqkkLWMC+vZCkfs+FHv1Vg=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.3/go.mod h1:zQrxl1YP88HQlA6i9c63DSVPFklWpGX4OWAc9bFuaH4=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
github.com/hashicorp/go-plugin v1.8.0 h1:ie8S6RRY8RvB2usYZv+AAZ/wBvx2AU5p5QeP5j/FORs=
github.com/hashicorp/go-plugin v1.8.0/go.mod h1:BExt6KEaIYx804z8k4gRzRLEvxKVb+kn0NMcihqOqb8=
github.com/hashicorp/golang-lru v0.5.4 h1:YDjusn29QI/Das2iO9M0BHnIbxPeyuCHsjMW+lJfyTc=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
@@ -269,6 +276,8 @@ github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/hashicorp/hcl/v2 v2.18.1 h1:6nxnOJFku1EuSawSD81fuviYUV8DxFr3fp2dUi3ZYSo=
github.com/hashicorp/hcl/v2 v2.18.1/go.mod h1:ThLC89FV4p9MPW804KVbe/cEXoQ8NZEh+JtMeeGErHE=
github.com/hashicorp/yamux v0.1.2 h1:XtB8kyFOyHXYVFnwT5C3+Bdo8gArse7j2AQ0DA0Uey8=
github.com/hashicorp/yamux v0.1.2/go.mod h1:C+zze2n6e/7wshOZep2A70/aQU6QBRWJO/G6FT1wIns=
github.com/icholy/digest v1.1.0 h1:HfGg9Irj7i+IX1o1QAmPfIBNu/Q5A5Tu3n/MED9k9H4=
github.com/icholy/digest v1.1.0/go.mod h1:QNrsSGQ5v7v9cReDI0+eyjsXGUoRSUZQHeQ5C4XLa0Y=
github.com/imroc/req/v3 v3.59.0 h1:PqKhJHyBmJYob47LVuTHwRZE00ZO6icbLHe5Zra13jo=
@@ -281,6 +290,8 @@ github.com/jackc/pgx/v5 v5.7.4 h1:9wKznZrhWa2QiHL+NjTSPP6yjl3451BX3imWDnokYlg=
github.com/jackc/pgx/v5 v5.7.4/go.mod h1:ncY89UGWxg82EykZUwSpUKEfccBGGYq1xjrOpsbsfGQ=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/jhump/protoreflect v1.17.0 h1:qOEr613fac2lOuTgWN4tPAtLL7fUSbuJL5X5XumQh94=
github.com/jhump/protoreflect v1.17.0/go.mod h1:h9+vUUL38jiBzck8ck+6G/aeMX8Z4QUY/NiJPwPNi+8=
github.com/json-iterator/go v1.1.10/go.mod h1:KdQUCv79m/52Kvf8AW2vK1V8akMuk1QjK/uOdHXbAo4=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
@@ -307,13 +318,15 @@ github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.9/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
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=
@@ -350,8 +363,8 @@ github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7P
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -386,8 +399,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -423,8 +434,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=
@@ -439,6 +448,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.2/go.mod h1:R6va5+xMeoiuVRoj+gSkQ7d3FALtqAAGI1FQKckRals=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
@@ -507,18 +517,20 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.41.0 h1:YlEwVsGAlCvczDILpUXpIpPSL/VPugt7zHThEMLce1c=
go.opentelemetry.io/otel v1.41.0/go.mod h1:Yt4UwgEKeT05QbLwbyHXEwhnjxNO6D8L5PQP51/46dE=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/metric v1.41.0 h1:rFnDcs4gRzBcsO9tS8LCpgR0dxg4aaxWlJxCno7JlTQ=
go.opentelemetry.io/otel/metric v1.41.0/go.mod h1:xPvCwd9pU0VN8tPZYzDZV/BMj9CM9vs00GuBjeKhJps=
go.opentelemetry.io/otel/sdk v1.41.0 h1:YPIEXKmiAwkGl3Gu1huk1aYWwtpRLeskpV+wPisxBp8=
go.opentelemetry.io/otel/sdk v1.41.0/go.mod h1:ahFdU0G5y8IxglBf0QBJXgSe7agzjE4GiTJ6HT9ud90=
go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0=
go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
@@ -608,6 +620,8 @@ golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200509044756-6aff5f38e54f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -615,6 +629,9 @@ golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -678,29 +695,31 @@ golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4 h1:8XJ4pajGwOlasW+L13MnEGA8W4115jJySQtVfS2/IBU=
google.golang.org/genproto/googleapis/api v0.0.0-20250929231259-57b25ae835d4/go.mod h1:NnuHhy+bxcg30o7FnVAZbXsPHUDQ9qKWAQKCD7VxFtk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4 h1:i8QOKZfYg6AbGVZzUAY3LrNWCKF8O6zFisU9Wl9RER4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250929231259-57b25ae835d4/go.mod h1:HSkG/KdJWusxU1F6CNrwNDjBMgisKxGnc5dAZfT0mjQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.75.1 h1:/ODCNEuf9VghjgO3rqLcfg8fiOP0nSluljWFlDxELLI=
google.golang.org/grpc v1.75.1/go.mod h1:JtPAzKiq4v1xcAB2hydNlWI2RnF85XXcV0mhKXr2ecQ=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
+123 -1
View File
@@ -32,7 +32,7 @@ const (
// DefaultCSPPolicy is the default Content-Security-Policy with nonce support
// __CSP_NONCE__ will be replaced with actual nonce at request time by the SecurityHeaders middleware
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
const DefaultCSPPolicy = "default-src 'self'; worker-src 'self' blob:; script-src 'self' __CSP_NONCE__ https://challenges.cloudflare.com https://*.alicdn.com https://static.cloudflareinsights.com https://turing.captcha.qcloud.com https://turing.captcha.gtimg.com https://ca.turing.captcha.qcloud.com https://global.turing.captcha.gtimg.com https://www.tycaptcha.com https://cloudcache.tencentcs.com https://*.stripe.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; style-src 'self' 'unsafe-inline' https://*.captcha.gtimg.com https://fonts.googleapis.com https://*.alicdn.com https://static.airwallex.com https://checkout.airwallex.com https://static-demo.airwallex.com https://checkout-demo.airwallex.com; img-src 'self' data: blob: https:; font-src 'self' data: https://fonts.gstatic.com; connect-src 'self' https://turing.captcha.qcloud.com https://www.tycaptcha.com https://rce.tencentrio.com https:; frame-src 'self' https://challenges.cloudflare.com https://turing.captcha.qcloud.com https://ca.turing.captcha.qcloud.com https://www.tycaptcha.com https://*.stripe.com https://checkout.airwallex.com https://checkout-demo.airwallex.com; frame-ancestors 'none'; base-uri 'self'; form-action 'self'"
// UMQ(用户消息队列)模式常量
const (
@@ -61,6 +61,10 @@ const (
// 可通过 gateway.upstream_response_read_max_bytes 配置项覆盖。
const DefaultUpstreamResponseReadMaxBytes int64 = 128 * 1024 * 1024
// DefaultModelsListReadMaxBytes 上游模型列表响应体的默认读取上限。
// 可通过 gateway.models_list_read_max_bytes 配置项覆盖。
const DefaultModelsListReadMaxBytes int64 = 8 * 1024 * 1024
type Config struct {
Server ServerConfig `mapstructure:"server"`
Log LogConfig `mapstructure:"log"`
@@ -99,6 +103,18 @@ type Config struct {
Idempotency IdempotencyConfig `mapstructure:"idempotency"`
BatchImage BatchImageConfig `mapstructure:"batch_image"`
ImageStorage ImageStorageConfig `mapstructure:"image_storage"`
Plugins PluginConfig `mapstructure:"plugins"`
}
// PluginConfig 控制管理员手动上传的本地进程插件。
// 默认不包含插件,也不允许安装未签名插件;TrustedPublishers 用于追加第三方发布者。
type PluginConfig struct {
DataDir string `mapstructure:"data_dir"`
AllowUnsigned bool `mapstructure:"allow_unsigned"`
TrustedPublishers map[string]string `mapstructure:"trusted_publishers"`
MaxUploadBytes int64 `mapstructure:"max_upload_bytes"`
MaxUncompressedBytes int64 `mapstructure:"max_uncompressed_bytes"`
StartTimeoutSeconds int `mapstructure:"start_timeout_seconds"`
}
type LogConfig struct {
@@ -830,6 +846,53 @@ type ProxyFallbackConfig struct {
type ProxyProbeConfig struct {
InsecureSkipVerify bool `mapstructure:"insecure_skip_verify"` // 已禁用:禁止跳过 TLS 证书验证
// URLs 按优先级排列的自定义探测 URL 列表。
// 留空时使用内置默认列表(ip-api → ipify)。
// 某些 AI API 专用代理只允许访问特定域名,配置多个备选可提高探测成功率。
URLs []ProbeURLConfig `mapstructure:"urls"`
}
// ProbeURLConfig 描述一个探测端点及其响应解析方式。
type ProbeURLConfig struct {
URL string `mapstructure:"url"`
Parser string `mapstructure:"parser"` // "ip-api" / "ipify" / "chatgpt-trace"
}
func normalizeProxyProbeURLs(targets []ProbeURLConfig) ([]ProbeURLConfig, error) {
if len(targets) == 0 {
return nil, nil
}
normalized := make([]ProbeURLConfig, 0, len(targets))
for i, target := range targets {
rawURL := strings.TrimSpace(target.URL)
parser := strings.ToLower(strings.TrimSpace(target.Parser))
if rawURL == "" {
return nil, fmt.Errorf("entry %d: url is required", i)
}
if parser == "" {
return nil, fmt.Errorf("entry %d: parser is required", i)
}
switch parser {
case "ip-api", "ipify", "chatgpt-trace":
default:
return nil, fmt.Errorf("entry %d: unsupported parser %q", i, target.Parser)
}
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Host == "" {
return nil, fmt.Errorf("entry %d: invalid url %q", i, target.URL)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return nil, fmt.Errorf("entry %d: url scheme must be http or https", i)
}
normalized = append(normalized, ProbeURLConfig{
URL: rawURL,
Parser: parser,
})
}
return normalized, nil
}
type BillingConfig struct {
@@ -887,6 +950,9 @@ type GatewayConfig struct {
// OpenAIResponseHeaderTimeout: OpenAI/Codex 上游等待响应头的超时时间(秒),0表示无超时
// OpenAI/Codex 请求可能在上游排队较久;默认不使用通用响应头超时截断。
OpenAIResponseHeaderTimeout int `mapstructure:"openai_response_header_timeout"`
// GrokResponseHeaderTimeout bounds the pre-first-byte wait for xAI/Grok.
// A zero value uses the provider-safe default instead of the generic gateway timeout.
GrokResponseHeaderTimeout int `mapstructure:"grok_response_header_timeout"`
// OpenAIFirstOutputTimeoutSeconds: native HTTP Responses 首个语义输出超时(秒),0表示禁用。
OpenAIFirstOutputTimeoutSeconds int `mapstructure:"openai_first_output_timeout_seconds"`
// OpenAIHighEffortFirstOutputTimeoutSeconds: high/xhigh/max 推理的首个语义输出超时(秒)。
@@ -898,6 +964,8 @@ type GatewayConfig struct {
TextMaxBodySize int64 `mapstructure:"text_max_body_size"`
// 非流式上游响应体读取上限(字节),用于防止无界读取导致内存放大
UpstreamResponseReadMaxBytes int64 `mapstructure:"upstream_response_read_max_bytes"`
// 上游模型列表响应体读取上限(字节)
ModelsListReadMaxBytes int64 `mapstructure:"models_list_read_max_bytes"`
// 代理探测响应体读取上限(字节)
ProxyProbeResponseReadMaxBytes int64 `mapstructure:"proxy_probe_response_read_max_bytes"`
// Gemini 上游响应头调试日志开关(默认关闭,避免高频日志开销)
@@ -1025,6 +1093,10 @@ type GatewayConfig struct {
// Grok: Grok/xAI gateway scheduling and free-tier soft-gate settings.
Grok GatewayGrokConfig `mapstructure:"grok"`
// CNProviders: 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。
// 仅作用于 payg(按量付费)账号:周期探测余额,低于阈值则临时停调。
CNProviders GatewayCNProvidersConfig `mapstructure:"cn_providers"`
}
// GatewayGrokConfig holds Grok-specific gateway scheduling knobs.
@@ -1057,6 +1129,18 @@ type GatewayGrokConfig struct {
FreeQuotaStatsCacheSeconds int `mapstructure:"free_quota_stats_cache_seconds"`
}
// GatewayCNProvidersConfig 国产 OpenAI 兼容供应商(kimi/zhipu/deepseek)的余额检测配置。
//
// 仅作用于 payg(按量付费)账号(kimi/deepseek 有公开余额端点;zhipu 无,仅靠响应式 429/402)。
// - balance_check_enabled: 是否启用周期余额检测(默认 true)
// - balance_threshold: 余额低于此值(账户货币单位,默认 0.5)触发临时停调
// - balance_check_interval_minutes: 余额检测周期(分钟,默认 10)
type GatewayCNProvidersConfig struct {
BalanceCheckEnabled bool `mapstructure:"balance_check_enabled"`
BalanceThreshold float64 `mapstructure:"balance_threshold"`
BalanceCheckIntervalMinutes int `mapstructure:"balance_check_interval_minutes"`
}
type GatewayLiveConfig struct {
// MaxSessionDurationSeconds 是 Live 会话的硬上限。
MaxSessionDurationSeconds int `mapstructure:"max_session_duration_seconds"`
@@ -1703,6 +1787,10 @@ func load(allowMissingJWTSecret bool) (*Config, error) {
// 环境变量支持
viper.AutomaticEnv()
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
if tz, ok := os.LookupEnv("TZ"); ok && strings.TrimSpace(tz) != "" {
// AutomaticEnv 会先把 timezone 映射到 TIMEZONE;显式 Set 保证标准 TZ 变量优先。
viper.Set("timezone", strings.TrimSpace(tz))
}
if err := viper.BindEnv("server.enable_server_timing", "ENABLE_SERVER_TIMING"); err != nil {
return nil, fmt.Errorf("bind ENABLE_SERVER_TIMING: %w", err)
}
@@ -2199,6 +2287,14 @@ func setDefaults() {
viper.SetDefault("pricing.update_interval_hours", 24)
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
// 本地进程插件。插件必须由管理员手动上传,项目默认不携带任何插件能力。
viper.SetDefault("plugins.data_dir", "")
viper.SetDefault("plugins.allow_unsigned", false)
viper.SetDefault("plugins.trusted_publishers", map[string]string{})
viper.SetDefault("plugins.max_upload_bytes", int64(128*1024*1024))
viper.SetDefault("plugins.max_uncompressed_bytes", int64(256*1024*1024))
viper.SetDefault("plugins.start_timeout_seconds", 15)
// Timezone (default to Asia/Shanghai for Chinese users)
viper.SetDefault("timezone", "Asia/Shanghai")
@@ -2260,6 +2356,7 @@ func setDefaults() {
// Gateway
viper.SetDefault("gateway.response_header_timeout", 600) // 600秒(10分钟)等待上游响应头,LLM高负载时可能排队较久
viper.SetDefault("gateway.openai_response_header_timeout", 0)
viper.SetDefault("gateway.grok_response_header_timeout", 120)
viper.SetDefault("gateway.openai_first_output_timeout_seconds", 0)
viper.SetDefault("gateway.openai_high_effort_first_output_timeout_seconds", 0)
viper.SetDefault("gateway.log_upstream_error_body", true)
@@ -2351,6 +2448,10 @@ func setDefaults() {
viper.SetDefault("gateway.grok.free_quota_soft_gate_percent", 95)
viper.SetDefault("gateway.grok.free_quota_window_hours", 24)
viper.SetDefault("gateway.grok.free_quota_stats_cache_seconds", 60)
// 国产供应商余额检测(kimi/deepseek payg;zhipu 无余额端点,仅靠响应式 429/402)。
viper.SetDefault("gateway.cn_providers.balance_check_enabled", true)
viper.SetDefault("gateway.cn_providers.balance_threshold", 0.5)
viper.SetDefault("gateway.cn_providers.balance_check_interval_minutes", 10)
viper.SetDefault("gateway.image_concurrency.enabled", false)
viper.SetDefault("gateway.image_concurrency.max_concurrent_requests", 0)
viper.SetDefault("gateway.image_concurrency.overflow_mode", ImageConcurrencyOverflowModeReject)
@@ -2361,6 +2462,7 @@ func setDefaults() {
viper.SetDefault("gateway.max_body_size", int64(256*1024*1024))
viper.SetDefault("gateway.text_max_body_size", int64(32*1024*1024))
viper.SetDefault("gateway.upstream_response_read_max_bytes", DefaultUpstreamResponseReadMaxBytes)
viper.SetDefault("gateway.models_list_read_max_bytes", DefaultModelsListReadMaxBytes)
viper.SetDefault("gateway.proxy_probe_response_read_max_bytes", int64(1024*1024))
viper.SetDefault("gateway.gemini_debug_response_headers", false)
viper.SetDefault("gateway.connection_pool_isolation", ConnectionPoolIsolationAccountProxy)
@@ -2545,6 +2647,20 @@ func (c *Config) Validate() error {
}
c.Security.ForwardedClientIPHeaders = forwardedClientIPHeaders
c.SetForwardedClientIPSettings(c.Security.TrustForwardedIPForAPIKeyACL, forwardedClientIPHeaders)
proxyProbeURLs, err := normalizeProxyProbeURLs(c.Security.ProxyProbe.URLs)
if err != nil {
return fmt.Errorf("security.proxy_probe.urls: %w", err)
}
c.Security.ProxyProbe.URLs = proxyProbeURLs
if c.Plugins.MaxUploadBytes <= 0 || c.Plugins.MaxUploadBytes > 1024*1024*1024 {
return fmt.Errorf("plugins.max_upload_bytes must be between 1 and 1073741824")
}
if c.Plugins.MaxUncompressedBytes < c.Plugins.MaxUploadBytes || c.Plugins.MaxUncompressedBytes > 2*1024*1024*1024 {
return fmt.Errorf("plugins.max_uncompressed_bytes must be between max_upload_bytes and 2147483648")
}
if c.Plugins.StartTimeoutSeconds < 1 || c.Plugins.StartTimeoutSeconds > 120 {
return fmt.Errorf("plugins.start_timeout_seconds must be between 1 and 120")
}
if c.Server.ReadHeaderTimeout < 1 || c.Server.ReadHeaderTimeout > 60 {
return fmt.Errorf("server.read_header_timeout must be between 1 and 60 seconds")
}
@@ -3156,6 +3272,9 @@ func (c *Config) Validate() error {
if c.Gateway.UpstreamResponseReadMaxBytes <= 0 {
return fmt.Errorf("gateway.upstream_response_read_max_bytes must be positive")
}
if c.Gateway.ModelsListReadMaxBytes <= 0 {
return fmt.Errorf("gateway.models_list_read_max_bytes must be positive")
}
if c.Gateway.ProxyProbeResponseReadMaxBytes <= 0 {
return fmt.Errorf("gateway.proxy_probe_response_read_max_bytes must be positive")
}
@@ -3165,6 +3284,9 @@ func (c *Config) Validate() error {
if c.Gateway.OpenAIResponseHeaderTimeout < 0 {
return fmt.Errorf("gateway.openai_response_header_timeout must be non-negative")
}
if c.Gateway.GrokResponseHeaderTimeout < 0 || c.Gateway.GrokResponseHeaderTimeout > 1800 {
return fmt.Errorf("gateway.grok_response_header_timeout must be between 0-1800 seconds")
}
if c.Gateway.OpenAIFirstOutputTimeoutSeconds < 0 || c.Gateway.OpenAIFirstOutputTimeoutSeconds > 600 ||
(c.Gateway.OpenAIFirstOutputTimeoutSeconds > 0 && c.Gateway.OpenAIFirstOutputTimeoutSeconds < 30) {
return fmt.Errorf("gateway.openai_first_output_timeout_seconds must be 0 or between 30-600 seconds")
+53
View File
@@ -23,6 +23,45 @@ func resetViperWithJWTSecret(t *testing.T) {
t.Setenv("JWT_SECRET", strings.Repeat("x", 32))
}
func TestLoadDefaultModelsListReadMaxBytes(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, DefaultModelsListReadMaxBytes, cfg.Gateway.ModelsListReadMaxBytes)
}
func TestLoadTimezonePrecedence(t *testing.T) {
tests := []struct {
name string
fileTimezone string
timezoneEnv string
tzEnv string
want string
}{
{name: "default", want: "Asia/Shanghai"},
{name: "config_file", fileTimezone: "Europe/London", want: "Europe/London"},
{name: "timezone_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", want: "UTC"},
{name: "tz_env", fileTimezone: "Europe/London", timezoneEnv: "UTC", tzEnv: "America/New_York", want: "America/New_York"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("TIMEZONE", tt.timezoneEnv)
t.Setenv("TZ", tt.tzEnv)
if tt.fileTimezone != "" {
configFile := filepath.Join(t.TempDir(), "config.yaml")
require.NoError(t, os.WriteFile(configFile, []byte("timezone: "+tt.fileTimezone+"\n"), 0o600))
t.Setenv("CONFIG_FILE", configFile)
}
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, tt.want, cfg.Timezone)
})
}
}
func TestLoadServerTimingConfig(t *testing.T) {
t.Run("disabled by default", func(t *testing.T) {
resetViperWithJWTSecret(t)
@@ -521,6 +560,15 @@ func TestLoadOpenAIWSClientFirstMessageTimeoutFromEnv(t *testing.T) {
require.Equal(t, 120, cfg.Gateway.OpenAIWS.ClientFirstMessageTimeoutSeconds)
}
func TestLoadOpenAIWSForceHTTPFromEnv(t *testing.T) {
resetViperWithJWTSecret(t)
t.Setenv("GATEWAY_OPENAI_WS_FORCE_HTTP", "true")
cfg, err := Load()
require.NoError(t, err)
require.True(t, cfg.Gateway.OpenAIWS.ForceHTTP)
}
func TestLoadDefaultOpenAICompactModel(t *testing.T) {
resetViperWithJWTSecret(t)
@@ -1762,6 +1810,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Gateway.TextMaxBodySize = c.Gateway.MaxBodySize + 1 },
wantErr: "gateway.text_max_body_size",
},
{
name: "gateway models list read limit",
mutate: func(c *Config) { c.Gateway.ModelsListReadMaxBytes = 0 },
wantErr: "gateway.models_list_read_max_bytes",
},
{
name: "gateway response header timeout",
mutate: func(c *Config) { c.Gateway.ResponseHeaderTimeout = -1 },
@@ -45,6 +45,20 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin
// is out of scope here — such settings need a config file either way.
continue
}
if ft.Kind() == reflect.Slice {
elem := ft.Elem()
for elem.Kind() == reflect.Ptr {
elem = elem.Elem()
}
if elem.Kind() == reflect.Struct {
// AutomaticEnv exposes one string value. Viper's string-to-slice
// hook can populate scalar slices, but it cannot decode a string
// into []struct. Registering a default would turn silent ignore
// into a startup unmarshal error, so structured slices remain
// config-file-only just like maps.
continue
}
}
out[strings.ToLower(key)] = ft.String()
}
}
@@ -62,7 +76,7 @@ func collectMapstructureKeys(t reflect.Type, prefix string, out map[string]strin
// were lost, silently disabling async image tasks for env-driven deployments.
//
// When this fails, register a zero-valued default in setEnvReachableDefaults
// for each reported key.
// for each reported scalar key. Maps and slices of structs are config-file-only.
func TestConfigKeysAreEnvReachable(t *testing.T) {
bound := map[string]string{}
collectMapstructureKeys(reflect.TypeOf(Config{}), "", bound)
@@ -0,0 +1,47 @@
//go:build unit
package config
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestNormalizeProxyProbeURLs(t *testing.T) {
t.Parallel()
got, err := normalizeProxyProbeURLs([]ProbeURLConfig{
{URL: " https://chatgpt.com/cdn-cgi/trace ", Parser: " CHATGPT-TRACE "},
{URL: "https://api64.ipify.org?format=json", Parser: "ipify"},
})
require.NoError(t, err)
require.Equal(t, []ProbeURLConfig{
{URL: "https://chatgpt.com/cdn-cgi/trace", Parser: "chatgpt-trace"},
{URL: "https://api64.ipify.org?format=json", Parser: "ipify"},
}, got)
}
func TestNormalizeProxyProbeURLsRejectsInvalidEntries(t *testing.T) {
t.Parallel()
tests := []struct {
name string
target ProbeURLConfig
wantErr string
}{
{name: "missing URL", target: ProbeURLConfig{Parser: "ipify"}, wantErr: "url is required"},
{name: "missing parser", target: ProbeURLConfig{URL: "https://example.com"}, wantErr: "parser is required"},
{name: "unknown parser", target: ProbeURLConfig{URL: "https://example.com", Parser: "ip_api"}, wantErr: "unsupported parser"},
{name: "relative URL", target: ProbeURLConfig{URL: "/cdn-cgi/trace", Parser: "chatgpt-trace"}, wantErr: "invalid url"},
{name: "unsupported scheme", target: ProbeURLConfig{URL: "ftp://example.com/file", Parser: "ipify"}, wantErr: "scheme must be http or https"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
_, err := normalizeProxyProbeURLs([]ProbeURLConfig{tt.target})
require.ErrorContains(t, err, tt.wantErr)
})
}
}
@@ -0,0 +1,68 @@
package domain
import "time"
// 渠道监控「配额模式」的归一化配额快照类型。
//
// 配额模式监控不直接对接上游,而是关联一个已有账号,复用账号侧的用量服务
// (AccountUsageService / CNProviderQuotaService / CNProviderBalanceService),
// 把各平台形态各异的用量数据归一成 MonitorQuotaSnapshot,随检测历史持久化
// 到 channel_monitor_histories.quota(JSONB),供管理端与用户端渲染。
//
// 类型放在 domain 包是因为 ent schema(internal/domain 的下游)需要引用它做
// field.JSON 序列化;service 不能被 ent import(会造成循环依赖)。
// MonitorQuotaTier 单个用量窗口的快照。
//
// Window 取值约定(与前端 monitorCommon.quota.windows.* 标签一一对应):
// - "5h" 5 小时滚动窗口(Claude/Codex/Kimi/Zhipu coding plan)
// - "7d" 7 天窗口(Claude/Codex)
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
// - "7d-fable" Claude 7 天 Fable 独立额度
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
// - "30d" 30 天窗口(Grok 月度)
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
//
// 同一 Window 可能出现多条(Gemini 多档日配额、Antigravity per-model、
// Grok requests/tokens),用 Label 区分:Label 是机器 token(requests/tokens/
// shared/pro/flash 或模型名),前端已知 token 走 i18n,未知原样展示。
type MonitorQuotaTier struct {
Window string `json:"window"`
Label string `json:"label,omitempty"`
UsedPercent float64 `json:"used_percent"` // 0-100+;仅有绝对值时按 used/limit 计算
Used float64 `json:"used,omitempty"`
Limit float64 `json:"limit,omitempty"`
ResetAt string `json:"reset_at,omitempty"` // RFC3339;未知时留空
}
// MonitorQuotaSnapshot 一次配额查询的完整快照。
//
// Source 取值:
// - "usage" 海外平台(AccountUsageService.GetUsage)
// - "cn_quota" 国产 Coding Plan(CNProviderQuotaService.QueryUsage)
// - "cn_balance" 国产按量付费余额(CNProviderBalanceService.QueryBalance)
type MonitorQuotaSnapshot struct {
Source string `json:"source"`
Success bool `json:"success"`
Tiers []MonitorQuotaTier `json:"tiers,omitempty"`
Balance *float64 `json:"balance,omitempty"` // cn_balance 主余额
Balances []MonitorBalance `json:"balances,omitempty"` // 多币种余额(如 DeepSeek CNY+USD)
Currency string `json:"currency,omitempty"` // 主余额币种
PlanLevel string `json:"plan_level,omitempty"` // 套餐等级(如智谱 level)
// BalanceLow 余额低于阈值或账号被上游标记不可用(仅 cn_balance 来源)。
// 抓取器按 Gateway.CNProviders.BalanceThreshold 判定,口径与账号停调
// (CNProviderBalanceCheckService.checkOne)一致:任一币种达标即健康。
BalanceLow bool `json:"balance_low,omitempty"`
// CredentialInvalid 上游 401/403 鉴权失败(区别于网络/解析错误),
// 检测状态据此推导 failed 而非 error。
CredentialInvalid bool `json:"credential_invalid,omitempty"`
Error string `json:"error,omitempty"` // Success=false 时的错误摘要
FetchedAt time.Time `json:"fetched_at"`
}
// MonitorBalance 单币种余额条目。
type MonitorBalance struct {
Currency string `json:"currency"`
Balance float64 `json:"balance"`
}
+22 -1
View File
@@ -23,7 +23,28 @@ const (
PlatformGemini = "gemini"
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
PlatformComposite = "composite"
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformComposite = "composite"
)
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
// 存储于 credentials["account_mode"],决定 base_url 预设与额度监控方式。
const (
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
)
// API protocol constants 国产供应商的上游 API 协议维度。存储于
// credentials["api_protocol"],与 account_mode 正交:协议决定转发端点与格式,
// 模式决定额度监控方式。同协议请求零转换直通;跨协议组合才走转换链。
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
// Account type constants
@@ -1025,7 +1025,8 @@ func (h *AccountHandler) Update(c *gin.Context) {
// 当前请求。探测错误仅记录日志,不向上下文传播:探测失败时标记保持缺失,
// 网关会按"现状即证据"默认走 Responses。
func (h *AccountHandler) scheduleOpenAIResponsesProbe(account *service.Account) {
if account == nil || account.Platform != service.PlatformOpenAI || account.Type != service.AccountTypeAPIKey {
if account == nil || account.Type != service.AccountTypeAPIKey ||
(account.Platform != service.PlatformOpenAI && !service.IsCNProvider(account.Platform)) {
return
}
if h.accountTestService == nil {
@@ -2613,8 +2614,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
// Handle Gemini accounts
if account.IsGemini() {
// For OAuth accounts: return default Gemini models
// Consumer Google One OAuth still uses the legacy Gemini CLI / Code
// Assist channel. Do not advertise newer 3.x or image models that the
// channel cannot serve.
if account.IsOAuth() {
if account.IsGeminiGoogleOne() {
response.Success(c, geminicli.GoogleOneModels)
return
}
response.Success(c, geminicli.DefaultModels)
return
}
@@ -287,6 +287,42 @@ func TestAccountHandlerGetAvailableModels_OpenAISparkShadowReturnsMappingModels(
}, ids, "影子可用模型由 model_mapping 派生(非写死)")
}
func TestAccountHandlerGetAvailableModels_GeminiGoogleOneUsesConservativeCatalog(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
account: service.Account{
ID: 45,
Name: "google-one",
Platform: service.PlatformGemini,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Credentials: map[string]any{
"oauth_type": "google_one",
},
},
}
router := setupAvailableModelsRouter(svc)
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts/45/models", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var resp struct {
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
ids := make([]string, 0, len(resp.Data))
for _, model := range resp.Data {
ids = append(ids, model.ID)
}
require.ElementsMatch(t, []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"}, ids)
require.NotContains(t, ids, "gemini-3.5-flash")
require.NotContains(t, ids, "gemini-2.5-flash-image")
}
func TestAccountHandlerSyncUpstreamModels_ConfigErrorReturnsBadRequest(t *testing.T) {
svc := &availableModelsAdminService{
stubAdminService: newStubAdminService(),
@@ -153,12 +153,12 @@ func (h *BackupHandler) GetDownloadURL(c *gin.Context) {
response.BadRequest(c, "backup ID is required")
return
}
url, err := h.backupService.GetBackupDownloadURL(c.Request.Context(), backupID)
download, err := h.backupService.GetBackupDownloadURL(c.Request.Context(), backupID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"url": url})
response.Success(c, download)
}
// ─── 恢复操作(需要重新输入管理员密码) ───
+173 -68
View File
@@ -33,7 +33,7 @@ type createChannelRequest struct {
GroupIDs []int64 `json:"group_ids"`
ModelPricing []channelModelPricingRequest `json:"model_pricing"`
ModelMapping map[string]map[string]string `json:"model_mapping"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped response_model"`
RestrictModels bool `json:"restrict_models"`
Features string `json:"features"`
FeaturesConfig map[string]any `json:"features_config"`
@@ -48,7 +48,7 @@ type updateChannelRequest struct {
GroupIDs *[]int64 `json:"group_ids"`
ModelPricing *[]channelModelPricingRequest `json:"model_pricing"`
ModelMapping map[string]map[string]string `json:"model_mapping"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped"`
BillingModelSource string `json:"billing_model_source" binding:"omitempty,oneof=requested upstream channel_mapped response_model"`
RestrictModels *bool `json:"restrict_models"`
Features *string `json:"features"`
FeaturesConfig map[string]any `json:"features_config"`
@@ -57,29 +57,48 @@ type updateChannelRequest struct {
}
type channelModelPricingRequest struct {
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
Platform string `json:"platform" binding:"omitempty,max=50"`
Models []string `json:"models" binding:"required,min=1,max=100"`
BillingMode string `json:"billing_mode" binding:"omitempty,oneof=token per_request image"`
InputPrice *float64 `json:"input_price" binding:"omitempty,min=0"`
OutputPrice *float64 `json:"output_price" binding:"omitempty,min=0"`
CacheWritePrice *float64 `json:"cache_write_price" binding:"omitempty,min=0"`
CacheReadPrice *float64 `json:"cache_read_price" binding:"omitempty,min=0"`
FastMultiplier *float64 `json:"fast_multiplier" binding:"omitempty,gt=0"`
FlexMultiplier *float64 `json:"flex_multiplier" binding:"omitempty,gt=0"`
ImageInputPrice *float64 `json:"image_input_price" binding:"omitempty,min=0"`
ImageOutputPrice *float64 `json:"image_output_price" binding:"omitempty,min=0"`
PerRequestPrice *float64 `json:"per_request_price" binding:"omitempty,min=0"`
Intervals []pricingIntervalRequest `json:"intervals"`
TimePricing *channelTimePricingRequest `json:"time_pricing"`
}
type channelTimePricingRequest struct {
Timezone string `json:"timezone"`
WeekdaysOnly bool `json:"weekdays_only"`
Periods []channelTimePricingPeriodRequest `json:"periods"`
}
type channelTimePricingPeriodRequest struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
type pricingIntervalRequest struct {
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
PerRequestPrice *float64 `json:"per_request_price"`
SortOrder int `json:"sort_order"`
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier" binding:"omitempty,gt=0"`
OutputMultiplier *float64 `json:"output_multiplier" binding:"omitempty,gt=0"`
CacheWriteMultiplier *float64 `json:"cache_write_multiplier" binding:"omitempty,gt=0"`
CacheReadMultiplier *float64 `json:"cache_read_multiplier" binding:"omitempty,gt=0"`
PerRequestPrice *float64 `json:"per_request_price"`
SortOrder int `json:"sort_order"`
}
type accountStatsPricingRuleRequest struct {
@@ -108,31 +127,50 @@ type channelResponse struct {
}
type channelModelPricingResponse struct {
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
ID int64 `json:"id"`
Platform string `json:"platform"`
Models []string `json:"models"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []pricingIntervalResponse `json:"intervals"`
TimePricing *channelTimePricingResponse `json:"time_pricing"`
}
type channelTimePricingResponse struct {
Timezone string `json:"timezone"`
WeekdaysOnly bool `json:"weekdays_only"`
Periods []channelTimePricingPeriodResponse `json:"periods"`
}
type channelTimePricingPeriodResponse struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
type pricingIntervalResponse struct {
ID int64 `json:"id"`
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
PerRequestPrice *float64 `json:"per_request_price"`
SortOrder int `json:"sort_order"`
ID int64 `json:"id"`
MinTokens int `json:"min_tokens"`
MaxTokens *int `json:"max_tokens"`
TierLabel string `json:"tier_label,omitempty"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier"`
OutputMultiplier *float64 `json:"output_multiplier"`
CacheWriteMultiplier *float64 `json:"cache_write_multiplier"`
CacheReadMultiplier *float64 `json:"cache_read_multiplier"`
PerRequestPrice *float64 `json:"per_request_price"`
SortOrder int `json:"sort_order"`
}
type accountStatsPricingRuleResponse struct {
@@ -224,29 +262,55 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheReadPrice: p.CacheReadPrice,
FastMultiplier: p.FastMultiplier,
FlexMultiplier: p.FlexMultiplier,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingToResponse(p.TimePricing),
}
}
func timePricingToResponse(value *service.ChannelTimePricing) *channelTimePricingResponse {
if value == nil {
return nil
}
periods := make([]channelTimePricingPeriodResponse, 0, len(value.Periods))
for _, period := range value.Periods {
periods = append(periods, channelTimePricingPeriodResponse{
StartTime: period.StartTime,
EndTime: period.EndTime,
Multiplier: period.Multiplier,
})
}
return &channelTimePricingResponse{
Timezone: value.Timezone,
WeekdaysOnly: value.WeekdaysOnly,
Periods: periods,
}
}
func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
return pricingIntervalResponse{
ID: iv.ID,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
PerRequestPrice: iv.PerRequestPrice,
SortOrder: iv.SortOrder,
ID: iv.ID,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: iv.InputMultiplier,
OutputMultiplier: iv.OutputMultiplier,
CacheWriteMultiplier: iv.CacheWriteMultiplier,
CacheReadMultiplier: iv.CacheReadMultiplier,
PerRequestPrice: iv.PerRequestPrice,
SortOrder: iv.SortOrder,
}
}
func pricingRequestToService(reqs []channelModelPricingRequest) []service.ChannelModelPricing {
func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMultipliers bool) []service.ChannelModelPricing {
result := make([]service.ChannelModelPricing, 0, len(reqs))
for _, r := range reqs {
billingMode := service.BillingMode(r.BillingMode)
@@ -256,18 +320,34 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
platform := r.Platform
intervals := make([]service.PricingInterval, 0, len(r.Intervals))
for _, iv := range r.Intervals {
var inputMultiplier, outputMultiplier, cacheWriteMultiplier, cacheReadMultiplier *float64
if allowChannelMultipliers {
inputMultiplier = iv.InputMultiplier
outputMultiplier = iv.OutputMultiplier
cacheWriteMultiplier = iv.CacheWriteMultiplier
cacheReadMultiplier = iv.CacheReadMultiplier
}
intervals = append(intervals, service.PricingInterval{
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
PerRequestPrice: iv.PerRequestPrice,
SortOrder: iv.SortOrder,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: inputMultiplier,
OutputMultiplier: outputMultiplier,
CacheWriteMultiplier: cacheWriteMultiplier,
CacheReadMultiplier: cacheReadMultiplier,
PerRequestPrice: iv.PerRequestPrice,
SortOrder: iv.SortOrder,
})
}
var fastMultiplier, flexMultiplier *float64
if allowChannelMultipliers {
fastMultiplier = r.FastMultiplier
flexMultiplier = r.FlexMultiplier
}
result = append(result, service.ChannelModelPricing{
Platform: platform,
Models: r.Models,
@@ -276,21 +356,43 @@ func pricingRequestToService(reqs []channelModelPricingRequest) []service.Channe
OutputPrice: r.OutputPrice,
CacheWritePrice: r.CacheWritePrice,
CacheReadPrice: r.CacheReadPrice,
FastMultiplier: fastMultiplier,
FlexMultiplier: flexMultiplier,
ImageInputPrice: r.ImageInputPrice,
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingRequestToService(r.TimePricing),
})
}
return result
}
func timePricingRequestToService(value *channelTimePricingRequest) *service.ChannelTimePricing {
if value == nil {
return nil
}
periods := make([]service.ChannelTimePricingPeriod, 0, len(value.Periods))
for _, period := range value.Periods {
periods = append(periods, service.ChannelTimePricingPeriod{
StartTime: period.StartTime,
EndTime: period.EndTime,
Multiplier: period.Multiplier,
})
}
return &service.ChannelTimePricing{
Timezone: value.Timezone,
WeekdaysOnly: value.WeekdaysOnly,
Periods: periods,
}
}
func accountStatsPricingRuleRequestToService(r accountStatsPricingRuleRequest) service.AccountStatsPricingRule {
return service.AccountStatsPricingRule{
Name: r.Name,
GroupIDs: r.GroupIDs,
AccountIDs: r.AccountIDs,
Pricing: pricingRequestToService(r.Pricing),
Pricing: pricingRequestToService(r.Pricing, false),
}
}
@@ -351,7 +453,7 @@ func (h *ChannelHandler) Create(c *gin.Context) {
return
}
pricing := pricingRequestToService(req.ModelPricing)
pricing := pricingRequestToService(req.ModelPricing, true)
// Main model_pricing requires a platform; default to anthropic for backward compatibility.
for i := range pricing {
if pricing[i].Platform == "" {
@@ -425,7 +527,7 @@ func (h *ChannelHandler) Update(c *gin.Context) {
ApplyPricingToAccountStats: req.ApplyPricingToAccountStats,
}
if req.ModelPricing != nil {
pricing := pricingRequestToService(*req.ModelPricing)
pricing := pricingRequestToService(*req.ModelPricing, true)
for i := range pricing {
if pricing[i].Platform == "" {
pricing[i].Platform = service.PlatformAnthropic
@@ -512,9 +614,12 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
var platformToLiteLLMProvider = map[string]string{
service.PlatformAnthropic: "anthropic",
service.PlatformOpenAI: "openai",
service.PlatformGemini: "google",
service.PlatformGemini: "gemini",
service.PlatformAntigravity: "anthropic",
service.PlatformGrok: "xai",
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -305,7 +305,7 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result := pricingRequestToService([]channelModelPricingRequest{tt.req})
result := pricingRequestToService([]channelModelPricingRequest{tt.req}, true)
require.Len(t, result, 1)
switch tt.wantField {
case "BillingMode":
@@ -332,7 +332,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
},
}
result := pricingRequestToService(reqs)
result := pricingRequestToService(reqs, true)
require.Len(t, result, 1)
r := result[0]
require.Equal(t, "openai", r.Platform)
@@ -373,7 +373,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
},
}
result := pricingRequestToService(reqs)
result := pricingRequestToService(reqs, true)
require.Len(t, result, 1)
require.Len(t, result[0].Intervals, 2)
@@ -396,7 +396,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
}
func TestPricingRequestToService_EmptySlice(t *testing.T) {
result := pricingRequestToService([]channelModelPricingRequest{})
result := pricingRequestToService([]channelModelPricingRequest{}, true)
require.NotNil(t, result)
require.Empty(t, result)
}
@@ -410,7 +410,7 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) {
},
}
result := pricingRequestToService(reqs)
result := pricingRequestToService(reqs, true)
require.Len(t, result, 1)
r := result[0]
require.Nil(t, r.InputPrice)
@@ -421,6 +421,89 @@ func TestPricingRequestToService_NilPriceFields(t *testing.T) {
require.Nil(t, r.PerRequestPrice)
}
func TestPricingRequestToService_TimePricing(t *testing.T) {
req := channelModelPricingRequest{
Models: []string{"gpt-5"},
BillingMode: "token",
TimePricing: &channelTimePricingRequest{
Timezone: "Asia/Shanghai",
WeekdaysOnly: true,
Periods: []channelTimePricingPeriodRequest{{
StartTime: "09:00", EndTime: "12:00", Multiplier: 2,
}},
},
}
got := pricingRequestToService([]channelModelPricingRequest{req}, true)
require.Equal(t, "Asia/Shanghai", got[0].TimePricing.Timezone)
require.True(t, got[0].TimePricing.WeekdaysOnly)
require.Equal(t, 2.0, got[0].TimePricing.Periods[0].Multiplier)
}
func TestPricingRequestToService_TimePricingNil(t *testing.T) {
got := pricingRequestToService([]channelModelPricingRequest{{Models: []string{"gpt-5"}}}, true)
require.Nil(t, got[0].TimePricing)
}
// 账号成本统计规则不支持倍率:allowChannelMultipliers=false 时必须丢弃,
// 避免渠道倍率意外污染账号成本口径。
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
req := channelModelPricingRequest{
Models: []string{"gpt-5"},
BillingMode: "token",
FastMultiplier: float64Ptr(2.5),
FlexMultiplier: float64Ptr(0.5),
Intervals: []pricingIntervalRequest{{
MinTokens: 272000,
InputMultiplier: float64Ptr(2),
OutputMultiplier: float64Ptr(1.5),
CacheWriteMultiplier: float64Ptr(2),
CacheReadMultiplier: float64Ptr(2),
}},
}
allowed := pricingRequestToService([]channelModelPricingRequest{req}, true)
require.Equal(t, float64Ptr(2.5), allowed[0].FastMultiplier)
require.Equal(t, float64Ptr(0.5), allowed[0].FlexMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].InputMultiplier)
require.Equal(t, float64Ptr(1.5), allowed[0].Intervals[0].OutputMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheWriteMultiplier)
require.Equal(t, float64Ptr(2), allowed[0].Intervals[0].CacheReadMultiplier)
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
require.Nil(t, dropped[0].FastMultiplier)
require.Nil(t, dropped[0].FlexMultiplier)
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
require.Nil(t, dropped[0].Intervals[0].CacheReadMultiplier)
// 非倍率字段不受开关影响
require.Equal(t, 272000, dropped[0].Intervals[0].MinTokens)
}
func TestPricingToResponse_TimePricing(t *testing.T) {
got := pricingToResponse(&service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
TimePricing: &service.ChannelTimePricing{
Timezone: "Asia/Shanghai",
WeekdaysOnly: true,
Periods: []service.ChannelTimePricingPeriod{{
StartTime: "14:00", EndTime: "18:00", Multiplier: 1.25,
}},
},
})
require.NotNil(t, got.TimePricing)
require.Equal(t, "Asia/Shanghai", got.TimePricing.Timezone)
require.True(t, got.TimePricing.WeekdaysOnly)
require.Equal(t, 1.25, got.TimePricing.Periods[0].Multiplier)
}
func TestPricingToResponse_TimePricingNil(t *testing.T) {
got := pricingToResponse(&service.ChannelModelPricing{})
require.Nil(t, got.TimePricing)
}
// ---------------------------------------------------------------------------
// 3. SyncPricingModels handler
// ---------------------------------------------------------------------------
@@ -459,7 +542,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
svc := service.NewPricingService(nil, nil)
router := setupSyncPricingModelsRouter(svc)
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity"} {
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -7,6 +7,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -39,10 +40,10 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"required,max=500"`
APIKey string `json:"api_key" binding:"required,max=2000"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
PrimaryModel string `json:"primary_model" binding:"max=200"`
ExtraModels []string `json:"extra_models"`
GroupName string `json:"group_name" binding:"max=100"`
@@ -53,11 +54,17 @@ type channelMonitorCreateRequest struct {
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
BodyOverride map[string]any `json:"body_override"`
// CheckMode: probe(默认)/ quota / quota_probe。quota 模式 endpoint/api_key
// 可空(条件必填校验在 service 层按模式分支)。
CheckMode string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
// AccountID: 配额模式关联的账号 ID。
AccountID *int64 `json:"account_id"`
}
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -72,6 +79,10 @@ type channelMonitorUpdateRequest struct {
ExtraHeaders *map[string]string `json:"extra_headers"`
BodyOverrideMode *string `json:"body_override_mode" binding:"omitempty,oneof=off merge replace"`
BodyOverride *map[string]any `json:"body_override"`
// CheckMode/AccountID:nil = 不更新;AccountID 指向 0 = 清空关联。
CheckMode *string `json:"check_mode" binding:"omitempty,oneof=probe quota quota_probe"`
AccountID *int64 `json:"account_id"`
}
type channelMonitorResponse struct {
@@ -101,25 +112,33 @@ type channelMonitorResponse struct {
ExtraHeaders map[string]string `json:"extra_headers"`
BodyOverrideMode string `json:"body_override_mode"`
BodyOverride map[string]any `json:"body_override"`
// 配额模式:check_mode + 关联账号 + 主模型最近配额快照
// (LatestQuota 由 List handler 批量聚合后填充;管理端不受 channel_monitor_show_quota 影响)。
CheckMode string `json:"check_mode"`
AccountID *int64 `json:"account_id"`
LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
}
type channelMonitorCheckResultResponse struct {
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
}
type channelMonitorHistoryItemResponse struct {
ID int64 `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
ID int64 `json:"id"`
Model string `json:"model"`
Status string `json:"status"`
LatencyMs *int `json:"latency_ms"`
PingLatencyMs *int `json:"ping_latency_ms"`
Message string `json:"message"`
CheckedAt string `json:"checked_at"`
Quota *domain.MonitorQuotaSnapshot `json:"quota,omitempty"`
}
// maskAPIKey 对 API Key 明文做脱敏:前 4 字符 + "***",长度 ≤ 4 时只显示 "***"。
@@ -163,7 +182,10 @@ func channelMonitorToResponse(m *service.ChannelMonitor) *channelMonitorResponse
ExtraHeaders: headers,
BodyOverrideMode: m.BodyOverrideMode,
BodyOverride: m.BodyOverride,
// PrimaryStatus / PrimaryLatencyMs / Availability7d 由 List handler 在批量聚合后填充。
CheckMode: m.CheckMode,
AccountID: m.AccountID,
// PrimaryStatus / PrimaryLatencyMs / Availability7d / LatestQuota
// 由 List handler 在批量聚合后填充。
}
if m.LastCheckedAt != nil {
s := m.LastCheckedAt.UTC().Format(time.RFC3339)
@@ -180,6 +202,7 @@ func checkResultToResponse(r *service.CheckResult) channelMonitorCheckResultResp
PingLatencyMs: r.PingLatencyMs,
Message: r.Message,
CheckedAt: r.CheckedAt.UTC().Format(time.RFC3339),
Quota: r.Quota,
}
}
@@ -192,6 +215,7 @@ func historyEntryToResponse(e *service.ChannelMonitorHistoryEntry) channelMonito
PingLatencyMs: e.PingLatencyMs,
Message: e.Message,
CheckedAt: e.CheckedAt.UTC().Format(time.RFC3339),
Quota: e.Quota,
}
}
@@ -270,6 +294,7 @@ func buildListItemResponse(m *service.ChannelMonitor, summary service.MonitorSta
resp.PrimaryStatus = summary.PrimaryStatus
resp.PrimaryLatencyMs = summary.PrimaryLatencyMs
resp.Availability7d = summary.Availability7d
resp.LatestQuota = summary.LatestQuota
resp.ExtraModelsStatus = make([]dto.ChannelMonitorExtraModelStatus, 0, len(summary.ExtraModels))
for _, e := range summary.ExtraModels {
resp.ExtraModelsStatus = append(resp.ExtraModelsStatus, dto.ChannelMonitorExtraModelStatus{
@@ -327,6 +352,8 @@ func (h *ChannelMonitorHandler) Create(c *gin.Context) {
ExtraHeaders: req.ExtraHeaders,
BodyOverrideMode: req.BodyOverrideMode,
BodyOverride: req.BodyOverride,
CheckMode: req.CheckMode,
AccountID: req.AccountID,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -421,6 +448,8 @@ func (h *ChannelMonitorHandler) Update(c *gin.Context) {
ExtraHeaders: req.ExtraHeaders,
BodyOverrideMode: req.BodyOverrideMode,
BodyOverride: req.BodyOverride,
CheckMode: req.CheckMode,
AccountID: req.AccountID,
})
if err != nil {
response.ErrorFrom(c, err)
@@ -0,0 +1,69 @@
package admin
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// CNProviderHandler 暴露国产供应商(kimi/zhipu/deepseek)的额度与余额查询端点。
//
// - GET /admin/cn-providers/accounts/:id/quota Coding Plan 滚动窗口用量(kimi/zhipu)
// - GET /admin/cn-providers/accounts/:id/balance payg 账号余额(kimi/deepseek)
//
// 智谱(zhipu)无余额端点,故同一账号仅 quota 或 balance 其一可用:服务端按账号
// platform + account_mode 校验并返回明确错误(见 CNProvider*Service 的 load*Account)。
type CNProviderHandler struct {
quotaService *service.CNProviderQuotaService
balanceService *service.CNProviderBalanceService
}
func NewCNProviderHandler(
quotaService *service.CNProviderQuotaService,
balanceService *service.CNProviderBalanceService,
) *CNProviderHandler {
return &CNProviderHandler{
quotaService: quotaService,
balanceService: balanceService,
}
}
// QueryQuota 查询 Coding Plan 滚动窗口用量(5h + weekly)。
func (h *CNProviderHandler) QueryQuota(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h == nil || h.quotaService == nil {
response.BadRequest(c, "cn provider quota service is not enabled")
return
}
result, err := h.quotaService.QueryUsage(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
// QueryBalance 查询 payg 账号余额。
func (h *CNProviderHandler) QueryBalance(c *gin.Context) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid account ID")
return
}
if h == nil || h.balanceService == nil {
response.BadRequest(c, "cn provider balance service is not enabled")
return
}
result, err := h.balanceService.QueryBalance(c.Request.Context(), accountID)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
@@ -615,8 +615,10 @@ func (h *GrokOAuthHandler) ResetQuota(c *gin.Context) {
response.BadRequest(c, "grok quota service is not enabled")
return
}
// ResetQuota 恒返回 GROK_QUOTA_RESET_UNSUPPORTED(xAI 无 OAuth 配额重置接口),err != nil 恒真为预期。
//nolint:staticcheck // SA4023
result, err := h.quotaService.ResetQuota(c.Request.Context(), accountID)
if err != nil {
if err != nil { //nolint:staticcheck // SA4023
response.ErrorFrom(c, err)
return
}
+32 -26
View File
@@ -8,11 +8,11 @@ import (
"log/slog"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/pkg/timezone"
"github.com/Wei-Shaw/sub2api/internal/platform/liveattestation"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -96,15 +96,17 @@ func NewGroupHandler(adminService service.AdminService, dashboardService *servic
// CreateGroupRequest represents create group request
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
ModelPricing []service.ChannelModelPricing `json:"model_pricing"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
AllowImageGeneration bool `json:"allow_image_generation"`
AllowBatchImageGeneration bool `json:"allow_batch_image_generation"`
@@ -162,16 +164,18 @@ type CreateGroupRequest struct {
// UpdateGroupRequest represents update group request
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
DailyLimitUSD optionalLimitField `json:"daily_limit_usd"`
WeeklyLimitUSD optionalLimitField `json:"weekly_limit_usd"`
MonthlyLimitUSD optionalLimitField `json:"monthly_limit_usd"`
LongContextPricingEnabled *bool `json:"long_context_pricing_enabled"`
ModelPricing *[]service.ChannelModelPricing `json:"model_pricing"`
// 图片生成计费配置(antigravity 和 gemini 平台使用,负数表示清除配置)
AllowImageGeneration *bool `json:"allow_image_generation"`
AllowBatchImageGeneration *bool `json:"allow_batch_image_generation"`
@@ -230,7 +234,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -508,6 +512,8 @@ func (h *GroupHandler) Create(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
LongContextPricingEnabled: req.LongContextPricingEnabled,
ModelPricing: req.ModelPricing,
AllowImageGeneration: req.AllowImageGeneration,
AllowBatchImageGeneration: req.AllowBatchImageGeneration,
ImageRateIndependent: req.ImageRateIndependent,
@@ -635,6 +641,8 @@ func (h *GroupHandler) Update(c *gin.Context) {
DailyLimitUSD: req.DailyLimitUSD.ToServiceInput(),
WeeklyLimitUSD: req.WeeklyLimitUSD.ToServiceInput(),
MonthlyLimitUSD: req.MonthlyLimitUSD.ToServiceInput(),
LongContextPricingEnabled: req.LongContextPricingEnabled,
ModelPricing: req.ModelPricing,
AllowImageGeneration: req.AllowImageGeneration,
AllowBatchImageGeneration: req.AllowBatchImageGeneration,
ImageRateIndependent: req.ImageRateIndependent,
@@ -726,12 +734,10 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
_ = groupID // TODO: implement actual stats
}
// GetUsageSummary returns today's and cumulative cost for all groups.
// GET /api/v1/admin/groups/usage-summary?timezone=Asia/Shanghai
// GetUsageSummary returns today's, yesterday's, and cumulative cost for all groups.
// GET /api/v1/admin/groups/usage-summary
func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
userTZ := c.Query("timezone")
now := timezone.NowInUserLocation(userTZ)
todayStart := timezone.StartOfDayInUserLocation(now, userTZ)
todayStart := service.GroupUsageTodayStart(time.Now())
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
if err != nil {
@@ -0,0 +1,81 @@
//go:build unit
package admin
import (
"bytes"
"fmt"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/", bytes.NewBufferString(body))
c.Request.Header.Set("Content-Type", "application/json")
return c.ShouldBindJSON(target)
}
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
var req CreateGroupRequest
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应通过 CreateGroupRequest 校验", platform)
require.Equal(t, platform, req.Platform)
})
t.Run("update_"+platform, func(t *testing.T) {
var req UpdateGroupRequest
body := fmt.Sprintf(`{"platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应通过 UpdateGroupRequest 校验", platform)
require.Equal(t, platform, req.Platform)
})
}
}
func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
invalid := []string{
"moonshot", // 厂商别名,不是平台标识
"Kimi", // 大小写敏感
"openai ", // 尾随空格
"glm",
"bogus",
}
for _, platform := range invalid {
t.Run("create_"+platform, func(t *testing.T) {
var req CreateGroupRequest
body := fmt.Sprintf(`{"name":"g","platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应被 CreateGroupRequest 拒绝", platform)
})
t.Run("update_"+platform, func(t *testing.T) {
var req UpdateGroupRequest
body := fmt.Sprintf(`{"platform":%q}`, platform)
require.Error(t, bindGroupPlatformJSON(t, &req, body),
"platform %q 应被 UpdateGroupRequest 拒绝", platform)
})
}
}
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
require.Equal(t, platform, req.TargetPlatform)
}
}
@@ -34,12 +34,6 @@ type openAIAccountStateRecoverer interface {
RecoverAccountState(ctx context.Context, accountID int64, options service.AccountRecoveryOptions) (*service.SuccessfulTestRecoveryResult, error)
}
const (
openAIQuotaResetWarningCacheRefreshFailed = "reset_credit_cache_refresh_failed"
openAIQuotaResetWarningAccountRecoveryFailed = "account_state_recovery_failed"
openAIQuotaResetWarningAccountRefreshFailed = "account_state_refresh_failed"
)
// openAIQuotaResetPostProcessTimeout bounds the work performed AFTER the
// (non-refundable) reset credit has already been consumed upstream. The whole
// request must stay comfortably inside the panel HTTP client timeout, otherwise
@@ -493,6 +487,7 @@ func (h *OpenAIOAuthHandler) QueryQuota(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
service.NotifyOpenAIAutoResetCredit(accountID)
response.Success(c, usage)
}
@@ -523,6 +518,7 @@ func (h *OpenAIOAuthHandler) RefreshQuota(c *gin.Context) {
response.Error(c, http.StatusInternalServerError, "openai quota query returned an empty result")
return
}
service.NotifyOpenAIAutoResetCredit(accountID)
refreshResponse := openAIQuotaRefreshResponse{OpenAIQuotaUsage: *usage}
// A failed snapshot write leaves the previous cache intact — report it as a
@@ -600,54 +596,19 @@ func (h *OpenAIOAuthHandler) ResetQuota(c *gin.Context) {
postCtx, cancelPost := openAIQuotaResetPostProcessContext(c.Request.Context())
defer cancelPost()
// Step 1 — unblocking the account is the whole point of consuming a credit
// (#3672 / #3740), so it runs FIRST and is never gated on the display cache.
// Recovery is DB-only and leaves the manual `schedulable` switch untouched.
if h.rateLimitService == nil {
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
response.Success(c, resetResponse)
return
postResult := service.RunOpenAIQuotaResetPostProcess(
postCtx,
accountID,
h.quotaService,
h.rateLimitService,
h.adminService.GetAccount,
)
resetResponse.Quota = postResult.Quota
resetResponse.CacheRefreshed = postResult.CacheRefreshed
resetResponse.AccountStateRecovered = postResult.AccountStateRecovered
resetResponse.WarningCode = postResult.WarningCode
if postResult.Account != nil {
resetResponse.Account = dto.AccountFromService(postResult.Account)
}
if _, err := h.rateLimitService.RecoverAccountState(postCtx, accountID, service.AccountRecoveryOptions{
InvalidateToken: true,
}); err != nil {
// Recovery failures are almost always storage-level; the remaining steps
// share that dependency, so stop here instead of compounding the failure.
slog.Warn("openai_quota_reset_account_recovery_failed", "account_id", accountID, "error", err)
resetResponse.WarningCode = openAIQuotaResetWarningAccountRecoveryFailed
response.Success(c, resetResponse)
return
}
resetResponse.AccountStateRecovered = true
// Step 2 — refresh the reset-credit display cache. A failure here is reported
// but must not hide the recovered account row produced by step 3.
usage, usageErr := h.quotaService.QueryUsage(postCtx, accountID)
switch {
case usageErr != nil || usage == nil:
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", usageErr)
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
default:
if err := h.quotaService.CacheResetCreditsSnapshot(postCtx, accountID, usage.RateLimitResetCredits); err != nil {
slog.Warn("openai_quota_reset_cache_refresh_failed", "account_id", accountID, "error", err)
resetResponse.WarningCode = openAIQuotaResetWarningCacheRefreshFailed
} else {
resetResponse.Quota = usage
resetResponse.CacheRefreshed = true
}
}
// Step 3 — hand back the post-recovery account row so the list drops the
// stale rate-limit badge without waiting for the next poll.
account, err := h.adminService.GetAccount(postCtx, accountID)
if err != nil {
slog.Warn("openai_quota_reset_account_refresh_failed", "account_id", accountID, "error", err)
if resetResponse.WarningCode == "" {
resetResponse.WarningCode = openAIQuotaResetWarningAccountRefreshFailed
}
response.Success(c, resetResponse)
return
}
resetResponse.Account = dto.AccountFromService(account)
response.Success(c, resetResponse)
}
@@ -215,7 +215,7 @@ func TestOpenAIResetQuota_RecoveryFailureStopsWorkflow(t *testing.T) {
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.False(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
@@ -237,7 +237,7 @@ func TestOpenAIResetQuota_MissingRecovererReportsRecoveryFailure(t *testing.T) {
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningAccountRecoveryFailed, envelope.Data.WarningCode)
require.False(t, envelope.Data.AccountStateRecovered)
require.Zero(t, quota.queryCalls)
require.Zero(t, adminService.calls)
@@ -260,7 +260,7 @@ func TestOpenAIResetQuota_QueryFailureStillRecoversAndReturnsAccount(t *testing.
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
@@ -285,7 +285,7 @@ func TestOpenAIResetQuota_CacheFailureStillRecoversAndReturnsAccount(t *testing.
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.False(t, envelope.Data.CacheRefreshed)
require.Nil(t, envelope.Data.Quota)
@@ -307,7 +307,7 @@ func TestOpenAIResetQuota_AccountRefreshFailureReportsRecoveredState(t *testing.
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningAccountRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.CacheRefreshed)
require.True(t, envelope.Data.AccountStateRecovered)
require.NotNil(t, envelope.Data.Quota)
@@ -331,7 +331,7 @@ func TestOpenAIResetQuota_CacheAndAccountFailureKeepsFirstWarning(t *testing.T)
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.Nil(t, envelope.Data.Account)
}
@@ -435,7 +435,7 @@ func TestOpenAIQuotaEmptyUsageIsHandledWithoutPanic(t *testing.T) {
status, envelope := performOpenAIQuotaResetRequest(t, handler)
require.Equal(t, http.StatusOK, status)
require.Equal(t, openAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.Equal(t, service.OpenAIQuotaResetWarningCacheRefreshFailed, envelope.Data.WarningCode)
require.True(t, envelope.Data.AccountStateRecovered)
require.NotNil(t, envelope.Data.Account)
require.Zero(t, quota.cacheCalls)
@@ -0,0 +1,257 @@
package admin
import (
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const pluginUISessionTTL = 30 * time.Minute
// PluginHandler 提供插件安装、生命周期、配置和隔离 UI 资源接口。
type PluginHandler struct {
manager *service.PluginManager
}
func NewPluginHandler(manager *service.PluginManager) *PluginHandler {
return &PluginHandler{manager: manager}
}
func (h *PluginHandler) List(c *gin.Context) {
plugins, err := h.manager.List(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, plugins)
}
func (h *PluginHandler) Get(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
plugin, err := h.manager.Get(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, plugin)
}
func (h *PluginHandler) Upload(c *gin.Context) {
maxBytes := h.manager.MaxUploadBytes()
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes+(1<<20))
file, header, err := c.Request.FormFile("plugin")
if err != nil {
response.BadRequest(c, "请选择有效的 .s2plugin 文件")
return
}
defer func() { _ = file.Close() }()
if !strings.HasSuffix(strings.ToLower(header.Filename), ".s2plugin") {
response.BadRequest(c, "插件包扩展名必须是 .s2plugin")
return
}
var installedBy *int64
if subject, ok := middleware.GetAuthSubjectFromContext(c); ok && subject.UserID > 0 {
userID := subject.UserID
installedBy = &userID
}
plugin, err := h.manager.Install(c.Request.Context(), file, installedBy)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Created(c, plugin)
}
type pluginEnableRequest struct {
AcceptUntested bool `json:"accept_untested"`
RolloutPercent int `json:"rollout_percent"`
}
func (h *PluginHandler) Enable(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
request := pluginEnableRequest{RolloutPercent: 100}
if err := c.ShouldBindJSON(&request); err != nil {
response.BadRequest(c, "启用参数无效")
return
}
plugin, err := h.manager.Enable(c.Request.Context(), id, request.AcceptUntested, request.RolloutPercent)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, plugin)
}
func (h *PluginHandler) Disable(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
plugin, err := h.manager.Disable(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, plugin)
}
func (h *PluginHandler) Delete(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
if err := h.manager.Delete(c.Request.Context(), id); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, gin.H{"message": "插件已卸载"})
}
func (h *PluginHandler) GetConfig(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
configJSON, err := h.manager.GetConfig(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", configJSON)
}
func (h *PluginHandler) SaveConfig(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
decoder := json.NewDecoder(http.MaxBytesReader(c.Writer, c.Request.Body, 4*1024*1024))
decoder.UseNumber()
var value any
if err := decoder.Decode(&value); err != nil {
response.BadRequest(c, "插件配置必须是有效 JSON")
return
}
if err := decoder.Decode(&struct{}{}); err != io.EOF {
response.BadRequest(c, "插件配置只能包含一个 JSON 值")
return
}
raw, err := json.Marshal(value)
if err != nil {
response.BadRequest(c, "插件配置无法序列化")
return
}
saved, err := h.manager.SaveConfig(c.Request.Context(), id, raw)
if err != nil {
response.BadRequest(c, err.Error())
return
}
c.Data(http.StatusOK, "application/json; charset=utf-8", saved)
}
func (h *PluginHandler) Test(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
result, err := h.manager.Test(c.Request.Context(), id)
if err != nil {
response.BadRequest(c, err.Error())
return
}
response.Success(c, result)
}
func (h *PluginHandler) CreateUISession(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
assetToken, expires, err := h.manager.CreateUIAssetToken(c.Request.Context(), id, pluginUISessionTTL)
if err != nil {
response.ErrorFrom(c, err)
return
}
bridgeToken, err := randomPluginToken()
if err != nil {
response.InternalError(c, "创建插件 UI Bridge 失败")
return
}
response.Success(c, gin.H{
"url": fmt.Sprintf("/api/v1/plugin-ui/%s/index.html#bridge_token=%s", assetToken, bridgeToken),
"bridge_token": bridgeToken,
"ui_bridge_version": 1,
"expires_at": expires,
})
}
// ServeUIAsset 使用短时随机能力 URL 提供插件静态资源,不向 iframe 暴露管理员凭据。
func (h *PluginHandler) ServeUIAsset(c *gin.Context) {
token := strings.TrimSpace(c.Param("token"))
pluginID, err := h.manager.ResolveUIAssetToken(token)
if err != nil {
c.Status(http.StatusGone)
return
}
relative := strings.TrimPrefix(c.Param("path"), "/")
data, logicalPath, err := h.manager.ReadUIAsset(c.Request.Context(), pluginID, relative)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
c.Status(http.StatusNotFound)
return
}
c.Status(http.StatusNotFound)
return
}
contentType := mime.TypeByExtension(filepath.Ext(logicalPath))
if contentType == "" {
contentType = "application/octet-stream"
}
c.Header("Cache-Control", "private, no-store")
c.Header("Referrer-Policy", "no-referrer")
c.Header("X-Content-Type-Options", "nosniff")
c.Header("X-Frame-Options", "SAMEORIGIN")
// sandbox iframe 没有 allow-same-origin,会以不透明来源加载自己的 CSS/JS。
// 资源 URL 由短时随机能力 Token 保护,Bridge Token 只存在于 fragment 中。
c.Header("Cross-Origin-Resource-Policy", "cross-origin")
c.Header("Content-Security-Policy", "default-src 'none'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'none'; base-uri 'none'; form-action 'none'; frame-ancestors 'self'; navigate-to 'none'")
c.Data(http.StatusOK, contentType, data)
}
func pluginIDParam(c *gin.Context) (int64, bool) {
id, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || id <= 0 {
response.BadRequest(c, "插件 ID 无效")
return 0, false
}
return id, true
}
func randomPluginToken() (string, error) {
buffer := make([]byte, 32)
if _, err := rand.Read(buffer); err != nil {
return "", err
}
return hex.EncodeToString(buffer), nil
}
@@ -374,6 +374,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorMode: settings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
GrokDefaultTextModel: settings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
@@ -381,9 +382,10 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
ModelPlazaDescription: settings.ModelPlazaDescription,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
PluginManagementEnabled: settings.PluginManagementEnabled,
ModelPlazaDescription: settings.ModelPlazaDescription,
AffiliateEnabled: settings.AffiliateEnabled,
@@ -332,6 +332,7 @@ type UpdateSettingsRequest struct {
ChannelMonitorMode *string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
// Grok model mapping policy
GrokDefaultTextModel *string `json:"grok_default_text_model"`
@@ -346,6 +347,9 @@ type UpdateSettingsRequest struct {
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
ModelPlazaDescription *string `json:"model_plaza_description"`
// Plugin management menu visibility switch; plugin runtime is unaffected.
PluginManagementEnabled *bool `json:"plugin_management_enabled"`
// Affiliate (邀请返利) feature switch
AffiliateEnabled *bool `json:"affiliate_enabled"`
@@ -437,7 +441,7 @@ func buildSettingKeyByJSONName() map[string]string {
out := make(map[string]string, t.NumField())
for i := 0; i < t.NumField(); i++ {
field := t.Field(i)
if field.Type.Kind() == reflect.Ptr {
if field.Type.Kind() == reflect.Pointer {
continue
}
name, _, _ := strings.Cut(field.Tag.Get("json"), ",")
@@ -1889,6 +1893,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.ChannelMonitorHideThroughput
}(),
ChannelMonitorShowQuota: func() bool {
if req.ChannelMonitorShowQuota != nil {
return *req.ChannelMonitorShowQuota
}
return previousSettings.ChannelMonitorShowQuota
}(),
GrokDefaultTextModel: func() string {
if req.GrokDefaultTextModel != nil {
return *req.GrokDefaultTextModel
@@ -1931,6 +1941,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.ModelPlazaDescription
}(),
PluginManagementEnabled: func() bool {
if req.PluginManagementEnabled != nil {
return *req.PluginManagementEnabled
}
return previousSettings.PluginManagementEnabled
}(),
AffiliateEnabled: func() bool {
if req.AffiliateEnabled != nil {
return *req.AffiliateEnabled
@@ -2342,6 +2358,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorMode: updatedSettings.ChannelMonitorMode,
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
@@ -2349,9 +2366,10 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
ModelPlazaDescription: updatedSettings.ModelPlazaDescription,
PluginManagementEnabled: updatedSettings.PluginManagementEnabled,
AffiliateEnabled: updatedSettings.AffiliateEnabled,
@@ -112,12 +112,13 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
if len(repo.upsertCalls) != 1 {
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
}
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != len(service.AllowedQuotaPlatforms) {
// upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 {
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
}
// 缓存失效:按全部允许平台统一失效。
if len(cache.deleteCalls) != 5 {
t.Errorf("expected 5 cache delete calls, got %d: %+v", len(cache.deleteCalls), cache.deleteCalls)
// 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。
if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) {
t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls)
}
}
@@ -3,6 +3,8 @@ package handler
import (
"context"
"errors"
"math"
"strconv"
"strings"
"time"
@@ -62,6 +64,43 @@ type UpdateAPIKeyRequest struct {
ResetRateLimitUsage *bool `json:"reset_rate_limit_usage"` // 重置限速用量
}
func validAPIKeyLimit(v float64) bool { return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 }
func validateAPIKeyCreateRequest(req CreateAPIKeyRequest) error {
if req.Quota != nil && !validAPIKeyLimit(*req.Quota) {
return errors.New("invalid quota")
}
if req.RateLimit5h != nil && !validAPIKeyLimit(*req.RateLimit5h) {
return errors.New("invalid rate_limit_5h")
}
if req.RateLimit1d != nil && !validAPIKeyLimit(*req.RateLimit1d) {
return errors.New("invalid rate_limit_1d")
}
if req.RateLimit7d != nil && !validAPIKeyLimit(*req.RateLimit7d) {
return errors.New("invalid rate_limit_7d")
}
if req.ExpiresInDays != nil && *req.ExpiresInDays <= 0 {
return errors.New("invalid expires_in_days")
}
return nil
}
func validateAPIKeyUpdateRequest(req UpdateAPIKeyRequest) error {
if req.Quota != nil && !validAPIKeyLimit(*req.Quota) {
return errors.New("invalid quota")
}
if req.RateLimit5h != nil && !validAPIKeyLimit(*req.RateLimit5h) {
return errors.New("invalid rate_limit_5h")
}
if req.RateLimit1d != nil && !validAPIKeyLimit(*req.RateLimit1d) {
return errors.New("invalid rate_limit_1d")
}
if req.RateLimit7d != nil && !validAPIKeyLimit(*req.RateLimit7d) {
return errors.New("invalid rate_limit_7d")
}
return nil
}
// List handles listing user's API keys with pagination
// GET /api/v1/api-keys
func (h *APIKeyHandler) List(c *gin.Context) {
@@ -152,6 +191,10 @@ func (h *APIKeyHandler) Create(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := validateAPIKeyCreateRequest(req); err != nil {
response.BadRequest(c, "Invalid request: numeric limits must be finite and non-negative, and expires_in_days must be greater than zero")
return
}
svcReq := service.CreateAPIKeyRequest{
Name: req.Name,
@@ -203,6 +246,10 @@ func (h *APIKeyHandler) Update(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := validateAPIKeyUpdateRequest(req); err != nil {
response.BadRequest(c, "Invalid request: numeric limits must be finite and non-negative")
return
}
svcReq := service.UpdateAPIKeyRequest{
IPWhitelist: req.IPWhitelist,
@@ -0,0 +1,43 @@
//go:build unit
package handler
import (
"math"
"testing"
"github.com/stretchr/testify/require"
)
func TestValidateAPIKeyCreateRequest(t *testing.T) {
zero, large, negative, nan, inf := 0.0, 1e100, -1.0, math.NaN(), math.Inf(1)
positiveDays, zeroDays, negativeDays := 1, 0, -1
require.NoError(t, validateAPIKeyCreateRequest(CreateAPIKeyRequest{}))
require.NoError(t, validateAPIKeyCreateRequest(CreateAPIKeyRequest{Quota: &zero, RateLimit5h: &large, ExpiresInDays: &positiveDays}))
for _, req := range []CreateAPIKeyRequest{
{Quota: &negative},
{Quota: &nan},
{RateLimit5h: &inf},
{RateLimit1d: &negative},
{RateLimit7d: &negative},
{ExpiresInDays: &zeroDays},
{ExpiresInDays: &negativeDays},
} {
require.Error(t, validateAPIKeyCreateRequest(req))
}
}
func TestValidateAPIKeyUpdateRequest(t *testing.T) {
zero, large, negative, nan, inf := 0.0, 1e100, -1.0, math.NaN(), math.Inf(-1)
require.NoError(t, validateAPIKeyUpdateRequest(UpdateAPIKeyRequest{Quota: &zero, RateLimit7d: &large}))
for _, req := range []UpdateAPIKeyRequest{
{Quota: &negative},
{RateLimit5h: &nan},
{RateLimit1d: &inf},
{RateLimit7d: &negative},
} {
require.Error(t, validateAPIKeyUpdateRequest(req))
}
}
+2 -2
View File
@@ -1144,10 +1144,10 @@ func (k oidcJWK) publicKey() (any, error) {
if err != nil {
return nil, fmt.Errorf("decode ec y: %w", err)
}
if !curve.IsOnCurve(x, y) {
if !curve.IsOnCurve(x, y) { //nolint:staticcheck // JWK 以裸坐标给出公钥;替换为 ecdsa.ParseUncompressedPublicKey 需改变点编码,待单独迁移
return nil, errors.New("ec point is not on curve")
}
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil
return &ecdsa.PublicKey{Curve: curve, X: x, Y: y}, nil //nolint:staticcheck // 同上
default:
return nil, fmt.Errorf("unsupported jwk kty: %s", k.Kty)
}
@@ -284,13 +284,13 @@ func toUserSupportedModels(
return out
}
// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
if p == nil {
// toUserPricingIntervals 将定价区间转换为用户 DTO 白名单形态;nil 入参返回 nil(JSON omitempty 可省略)。
func toUserPricingIntervals(src []service.PricingInterval) []userPricingIntervalDTO {
if src == nil {
return nil
}
intervals := make([]userPricingIntervalDTO, 0, len(p.Intervals))
for _, iv := range p.Intervals {
intervals := make([]userPricingIntervalDTO, 0, len(src))
for _, iv := range src {
intervals = append(intervals, userPricingIntervalDTO{
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
@@ -302,6 +302,19 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
PerRequestPrice: iv.PerRequestPrice,
})
}
return intervals
}
// toUserPricing 将 service 层定价转换为用户 DTO;入参为 nil 时返回 nil。
func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
if p == nil {
return nil
}
intervals := toUserPricingIntervals(p.Intervals)
if intervals == nil {
// 用户侧定价的 intervals 固定输出数组(空配置为 []),保持既有契约。
intervals = []userPricingIntervalDTO{}
}
billingMode := string(p.BillingMode)
if billingMode == "" {
billingMode = string(service.BillingModeToken)
@@ -3,6 +3,7 @@ package handler
import (
"time"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/admin"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
@@ -39,6 +40,15 @@ func (h *ChannelMonitorUserHandler) featureEnabled(c *gin.Context) bool {
return runtime.Enabled && runtime.Mode == service.ChannelMonitorModeV1
}
// quotaVisible 返回用户端是否展示配额/余额快照(channel_monitor_show_quota,
// fail-closed:未配置/非 "true" 一律视为关闭)。settingService 为 nil 时 fail-closed。
func (h *ChannelMonitorUserHandler) quotaVisible(c *gin.Context) bool {
if h.settingService == nil {
return false
}
return h.settingService.GetChannelMonitorRuntime(c.Request.Context()).ShowQuota
}
// --- Response ---
type channelMonitorUserListItem struct {
@@ -53,6 +63,9 @@ type channelMonitorUserListItem struct {
Availability7d float64 `json:"availability_7d"`
ExtraModels []dto.ChannelMonitorExtraModelStatus `json:"extra_models"`
Timeline []channelMonitorUserTimelinePoint `json:"timeline"`
// LatestQuota 主模型最近配额快照;channel_monitor_show_quota=false 时
// 由 userMonitorViewToItem 的调用方传入 false 剥离(服务端脱敏,非仅前端隐藏)。
LatestQuota *domain.MonitorQuotaSnapshot `json:"latest_quota,omitempty"`
}
// channelMonitorUserTimelinePoint 主模型最近一次检测的 timeline 点。
@@ -82,7 +95,7 @@ type channelMonitorUserModelStat struct {
AvgLatency7dMs *int `json:"avg_latency_7d_ms"`
}
func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListItem {
func userMonitorViewToItem(v *service.UserMonitorView, includeQuota bool) channelMonitorUserListItem {
extras := make([]dto.ChannelMonitorExtraModelStatus, 0, len(v.ExtraModels))
for _, e := range v.ExtraModels {
extras = append(extras, dto.ChannelMonitorExtraModelStatus{
@@ -100,7 +113,7 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte
CheckedAt: p.CheckedAt.UTC().Format(time.RFC3339),
})
}
return channelMonitorUserListItem{
item := channelMonitorUserListItem{
ID: v.ID,
Name: v.Name,
Provider: v.Provider,
@@ -113,6 +126,10 @@ func userMonitorViewToItem(v *service.UserMonitorView) channelMonitorUserListIte
ExtraModels: extras,
Timeline: timeline,
}
if includeQuota {
item.LatestQuota = v.LatestQuota
}
return item
}
func userMonitorDetailToResponse(d *service.UserMonitorDetail) *channelMonitorUserDetailResponse {
@@ -150,9 +167,10 @@ func (h *ChannelMonitorUserHandler) List(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
includeQuota := h.quotaVisible(c)
items := make([]channelMonitorUserListItem, 0, len(views))
for _, v := range views {
items = append(items, userMonitorViewToItem(v))
items = append(items, userMonitorViewToItem(v, includeQuota))
}
response.Success(c, gin.H{"items": items})
}
@@ -22,18 +22,42 @@ func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
require.Equal(t, service.PlatformOpenAI, platform)
}
func TestOpenAICompatibleTextTargetAllowsCompositeGrokModel(t *testing.T) {
func TestOpenAICompatibleTextTargetAllowsCompositeProviders(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}}
providers := []struct {
model string
platform string
}{
{model: "grok-4.3", platform: service.PlatformGrok},
{model: "kimi-k2-thinking", platform: service.PlatformKimi},
{model: "glm-5.2", platform: service.PlatformZhipu},
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
}
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
for _, provider := range providers {
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)
require.True(t, openAICompatibleTextTargetAllowed(c, apiKey, provider.model), "path=%s model=%s", path, provider.model)
platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context())
require.True(t, ok, "path=%s model=%s", path, provider.model)
require.Equal(t, provider.platform, platform, "path=%s model=%s", path, provider.model)
}
}
}
// WS ingress 对 CN 账号既过不了 transport 过滤、HTTP 桥也没有 Responses 转换,
// 放行只会把明确的策略拒绝换成 "no available account",因此 WS 白名单保持 openai+grok。
func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testing.T) {
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
for _, platform := range []string{
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformAnthropic, service.PlatformGemini,
} {
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
}
}
+2
View File
@@ -149,6 +149,7 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
ProfitControlEnabled: g.ProfitControlEnabled,
ProfitMinMargin: g.ProfitMinMargin,
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
ModelPricing: g.ModelPricing,
ModelRouting: g.ModelRouting,
ModelRoutingEnabled: g.ModelRoutingEnabled,
MCPXMLInject: g.MCPXMLInject,
@@ -184,6 +185,7 @@ func groupFromServiceBase(g *service.Group) Group {
DailyLimitUSD: g.DailyLimitUSD,
WeeklyLimitUSD: g.WeeklyLimitUSD,
MonthlyLimitUSD: g.MonthlyLimitUSD,
LongContextPricingEnabled: g.LongContextPricingEnabled,
AllowImageGeneration: g.AllowImageGeneration,
AllowBatchImageGeneration: g.AllowBatchImageGeneration,
ImageRateIndependent: g.ImageRateIndependent,
@@ -29,8 +29,6 @@ func TestPublicSettingsInjectionPayload_SchemaDoesNotDrift(t *testing.T) {
// Fields that legitimately live only on the DTO. Keep tiny; document each.
dtoOnlyFields := map[string]string{
// sora_client_enabled is an upstream-only field the fork does not surface.
"sora_client_enabled": "upstream-only field, not used on this fork",
// force_email_on_third_party_signup lives on the DTO but is not injected via SSR.
"force_email_on_third_party_signup": "auth-source default, not a feature flag",
}
+9 -6
View File
@@ -305,6 +305,7 @@ type SystemSettings struct {
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
GrokDefaultTextModel string `json:"grok_default_text_model"`
@@ -315,9 +316,10 @@ type SystemSettings struct {
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"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
ModelPlazaDescription string `json:"model_plaza_description"`
PluginManagementEnabled bool `json:"plugin_management_enabled"`
// 风控中心功能开关
RiskControlEnabled bool `json:"risk_control_enabled"`
@@ -397,7 +399,6 @@ type PublicSettings struct {
OIDCOAuthProviderName string `json:"oidc_oauth_provider_name"`
GitHubOAuthEnabled bool `json:"github_oauth_enabled"`
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
SoraClientEnabled bool `json:"sora_client_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
@@ -414,11 +415,13 @@ type PublicSettings struct {
ChannelMonitorMode string `json:"channel_monitor_mode"`
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
PluginManagementEnabled bool `json:"plugin_management_enabled"`
AffiliateEnabled bool `json:"affiliate_enabled"`
+9 -7
View File
@@ -96,10 +96,11 @@ type Group struct {
IsExclusive bool `json:"is_exclusive"`
Status string `json:"status"`
SubscriptionType string `json:"subscription_type"`
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
SubscriptionType string `json:"subscription_type"`
DailyLimitUSD *float64 `json:"daily_limit_usd"`
WeeklyLimitUSD *float64 `json:"weekly_limit_usd"`
MonthlyLimitUSD *float64 `json:"monthly_limit_usd"`
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
// 图片生成计费配置(仅 antigravity 平台使用)
AllowImageGeneration bool `json:"allow_image_generation"`
@@ -164,9 +165,10 @@ type AdminGroup struct {
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。
// 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出
// 运营方的上游成本上限,属于内部经营信息,不得下放到 dto.Group。
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin float64 `json:"profit_min_margin"`
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
ProfitControlEnabled bool `json:"profit_control_enabled"`
ProfitMinMargin float64 `json:"profit_min_margin"`
ProfitSafetyBuffer float64 `json:"profit_safety_buffer"`
ModelPricing []service.ChannelModelPricing `json:"model_pricing"`
// 模型路由配置(仅 anthropic 平台使用)
ModelRouting map[string][]int64 `json:"model_routing"`
+27 -15
View File
@@ -15,20 +15,21 @@ import (
// ──────────────────────────────────────────────────────────
const (
EndpointMessages = "/v1/messages"
EndpointChatCompletions = "/v1/chat/completions"
EndpointEmbeddings = "/v1/embeddings"
EndpointAlphaSearch = "/v1/alpha/search"
EndpointResponses = "/v1/responses"
EndpointResponsesCompact = "/v1/responses/compact"
EndpointImagesGenerations = "/v1/images/generations"
EndpointImagesEdits = "/v1/images/edits"
EndpointImageTasks = "/v1/images/tasks"
EndpointVideosGenerations = "/v1/videos/generations"
EndpointVideosEdits = "/v1/videos/edits"
EndpointVideosExtensions = "/v1/videos/extensions"
EndpointVideos = "/v1/videos"
EndpointGeminiModels = "/v1beta/models"
EndpointMessages = "/v1/messages"
EndpointChatCompletions = "/v1/chat/completions"
EndpointEmbeddings = "/v1/embeddings"
EndpointAlphaSearch = "/v1/alpha/search"
EndpointResponses = "/v1/responses"
EndpointResponsesCompact = "/v1/responses/compact"
EndpointResponsesInputTokens = "/v1/responses/input_tokens"
EndpointImagesGenerations = "/v1/images/generations"
EndpointImagesEdits = "/v1/images/edits"
EndpointImageTasks = "/v1/images/tasks"
EndpointVideosGenerations = "/v1/videos/generations"
EndpointVideosEdits = "/v1/videos/edits"
EndpointVideosExtensions = "/v1/videos/extensions"
EndpointVideos = "/v1/videos"
EndpointGeminiModels = "/v1beta/models"
)
const EndpointAntigravityGenerateContent = "/v1internal:streamGenerateContent"
@@ -80,6 +81,8 @@ const (
func NormalizeInboundEndpoint(path string) string {
path = strings.TrimSpace(path)
switch {
case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path):
return EndpointResponsesInputTokens
case strings.Contains(path, EndpointEmbeddings):
return EndpointEmbeddings
case strings.Contains(path, EndpointAlphaSearch) || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/alpha/search") || isBareOrSubpathOf(strings.TrimRight(path, "/"), "/backend-api/codex/alpha/search"):
@@ -113,6 +116,15 @@ func NormalizeInboundEndpoint(path string) string {
}
}
func isResponsesInputTokensAliasPath(path string) bool {
trimmed := strings.TrimRight(strings.TrimSpace(path), "/")
if trimmed == "" {
return false
}
return isBareOrSubpathOf(trimmed, "/responses/input_tokens") ||
isBareOrSubpathOf(trimmed, "/backend-api/codex/responses/input_tokens")
}
// isResponsesCompactAliasPath reports whether path is the bare/alias
// "compact" client endpoint — i.e. it is rooted at "/responses/compact"
// or "/backend-api/codex/responses/compact" (bare routes that serve
@@ -185,7 +197,7 @@ func DeriveUpstreamEndpoint(inbound, rawRequestPath, platform string) string {
switch platform {
case service.PlatformOpenAI, service.PlatformGrok:
if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos {
if inbound == EndpointEmbeddings || inbound == EndpointAlphaSearch || inbound == EndpointResponsesInputTokens || inbound == EndpointImagesGenerations || inbound == EndpointImagesEdits || inbound == EndpointVideosGenerations || inbound == EndpointVideosEdits || inbound == EndpointVideosExtensions || inbound == EndpointVideos {
return inbound
}
// OpenAI forwards everything to the Responses API.
@@ -27,6 +27,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
{"/v1/embeddings", EndpointEmbeddings},
{"/v1/alpha/search", EndpointAlphaSearch},
{"/v1/responses", EndpointResponses},
{"/v1/responses/input_tokens", EndpointResponsesInputTokens},
{"/v1/responses/compact", EndpointResponsesCompact},
{"/v1/responses/compact/detail", EndpointResponsesCompact},
{"/v1/images/generations", EndpointImagesGenerations},
@@ -50,6 +51,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
// Bare top-level alias route "/responses" — root vs. compact.
{"/responses", EndpointResponses},
{"/responses/input_tokens", EndpointResponsesInputTokens},
{"/responses/compact", EndpointResponsesCompact},
{"/responses/compact/detail", EndpointResponsesCompact},
{"/alpha/search", EndpointAlphaSearch},
@@ -57,6 +59,7 @@ func TestNormalizeInboundEndpoint(t *testing.T) {
// Bare Codex direct alias route — root vs. compact.
{"/backend-api/codex/responses", EndpointResponses},
{"/backend-api/codex/responses/input_tokens", EndpointResponsesInputTokens},
{"/backend-api/codex/responses/compact", EndpointResponsesCompact},
{"/backend-api/codex/responses/compact/detail", EndpointResponsesCompact},
{"/backend-api/codex/alpha/search", EndpointAlphaSearch},
@@ -100,6 +103,7 @@ func TestDeriveUpstreamEndpoint(t *testing.T) {
// OpenAI — root Responses.
{"openai responses root", EndpointResponses, "/v1/responses", service.PlatformOpenAI, EndpointResponses},
{"openai responses input tokens", EndpointResponsesInputTokens, "/v1/responses/input_tokens", service.PlatformOpenAI, EndpointResponsesInputTokens},
// OpenAI — compact, raw path carries the derivable "/compact"
// (or nested) suffix, which must be preserved on the upstream
+75 -3
View File
@@ -36,6 +36,9 @@ const (
maxSameAccountRetries = 3
// sameAccountRetryDelay 同账号重试间隔
sameAccountRetryDelay = 500 * time.Millisecond
// maxRequestScopedRetryDelay 限制请求级瞬时错误的指数退避上限,避免高重试配置
// 将单次请求拖入分钟级等待。
maxRequestScopedRetryDelay = 8 * time.Second
// singleAccountBackoffDelay 单账号分组 503 退避重试固定延时。
// Service 层在 SingleAccountRetry 模式下已做充分原地重试(最多 3 次、总等待 30s),
// Handler 层只需短暂间隔后重新进入 Service 层即可。
@@ -52,6 +55,72 @@ const (
// 语义上等同于「无可用账号」:候选账号都不满足分组的利润约束。
const profitVetoExhaustedMessage = "No available accounts: all candidates rejected by group profit control"
func sameAccountRetryDelayFor(failoverErr *service.UpstreamFailoverError, retryCount int) time.Duration {
if failoverErr == nil {
return sameAccountRetryDelay
}
if failoverErr.SameAccountRetryDelay > 0 {
return failoverErr.SameAccountRetryDelay
}
if !failoverErr.RequestScopedTransient || retryCount <= 1 {
return sameAccountRetryDelay
}
delay := sameAccountRetryDelay
for i := 1; i < retryCount; i++ {
if delay >= maxRequestScopedRetryDelay/2 {
return maxRequestScopedRetryDelay
}
delay *= 2
}
return delay
}
func sameAccountRetryAllowed(failoverErr *service.UpstreamFailoverError, retryCount, retryLimit int) bool {
if failoverErr == nil || !failoverErr.RetryableOnSameAccount {
return false
}
if !sameAccountRetryDeadlineAllows(failoverErr) {
return false
}
// Error-specific caps (Grok capacity/stream-idle) remain hard limits even
// when the error also carries a freshly reconstructed deadline.
if failoverErr.SameAccountRetryMax > 0 {
if retryLimit <= 0 {
return false
}
if failoverErr.SameAccountRetryMax < retryLimit {
retryLimit = failoverErr.SameAccountRetryMax
}
return retryCount < retryLimit
}
// OAuth 429 explicitly opts into a deadline window. It is intentionally not
// bounded by the ordinary/default pool retry count.
if !failoverErr.SameAccountRetryDeadline.IsZero() {
return true
}
return retryLimit > 0 && retryCount < retryLimit
}
// sameAccountRetryDeadlineAllows prevents a retry from starting after the
// service-provided same-account retry window has elapsed.
func sameAccountRetryDeadlineAllows(failoverErr *service.UpstreamFailoverError) bool {
return failoverErr == nil || failoverErr.SameAccountRetryDeadline.IsZero() || time.Now().Before(failoverErr.SameAccountRetryDeadline)
}
// effectiveSameAccountRetryLimit applies an error-specific cap without
// overriding an explicit account setting of zero (which disables retries).
func effectiveSameAccountRetryLimit(failoverErr *service.UpstreamFailoverError, account *service.Account) int {
if account == nil {
return 0
}
limit := account.GetPoolModeRetryCount()
if limit > 0 && failoverErr != nil && failoverErr.SameAccountRetryMax > 0 && failoverErr.SameAccountRetryMax < limit {
return failoverErr.SameAccountRetryMax
}
return limit
}
// FailoverState 跨循环迭代共享的 failover 状态
type FailoverState struct {
SwitchCount int
@@ -140,22 +209,25 @@ func (s *FailoverState) HandleFailoverError(
}
// 同账号重试不算切换账号,粘性会话仅在实际切换时强制缓存计费。
sameAccountRetry := failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit
retryCount := s.SameAccountRetryCount[accountID]
sameAccountRetry := sameAccountRetryAllowed(failoverErr, retryCount, retryLimit)
if needForceCacheBilling(s.hasBoundSession, failoverErr, sameAccountRetry) {
s.ForceCacheBilling = true
}
// 同账号重试:对 RetryableOnSameAccount 的临时性错误,先在同一账号上重试。
// 重试次数上限 retryLimit 由调用方传入(账号级 pool_mode_retry_count 配置)。
if failoverErr.RetryableOnSameAccount && s.SameAccountRetryCount[accountID] < retryLimit {
if sameAccountRetry {
s.SameAccountRetryCount[accountID]++
retryDelay := sameAccountRetryDelayFor(failoverErr, s.SameAccountRetryCount[accountID])
logger.FromContext(ctx).Warn("gateway.failover_same_account_retry",
zap.Int64("account_id", accountID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("same_account_retry_count", s.SameAccountRetryCount[accountID]),
zap.Int("same_account_retry_max", retryLimit),
zap.Duration("retry_delay", retryDelay),
)
if !sleepWithContext(ctx, sameAccountRetryDelay) {
if !sleepWithContext(ctx, retryDelay) {
return FailoverCanceled
}
return FailoverContinue
@@ -31,6 +31,89 @@ func (m *mockTempUnscheduler) TempUnscheduleRetryableError(_ context.Context, ac
m.calls = append(m.calls, tempUnscheduleCall{accountID: accountID, failoverErr: failoverErr})
}
func TestSameAccountRetryDelayFor(t *testing.T) {
capacityErr := &service.UpstreamFailoverError{RequestScopedTransient: true}
for _, tc := range []struct {
name string
retryCount int
want time.Duration
}{
{name: "first retry", retryCount: 1, want: 500 * time.Millisecond},
{name: "second retry", retryCount: 2, want: time.Second},
{name: "third retry", retryCount: 3, want: 2 * time.Second},
{name: "fourth retry", retryCount: 4, want: 4 * time.Second},
{name: "fifth retry", retryCount: 5, want: 8 * time.Second},
{name: "capped retry", retryCount: 10, want: 8 * time.Second},
} {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, sameAccountRetryDelayFor(capacityErr, tc.retryCount))
})
}
t.Run("non request scoped errors keep fixed delay", func(t *testing.T) {
require.Equal(t, 500*time.Millisecond, sameAccountRetryDelayFor(&service.UpstreamFailoverError{}, 10))
})
t.Run("nil error keeps fixed delay", func(t *testing.T) {
require.Equal(t, 500*time.Millisecond, sameAccountRetryDelayFor(nil, 10))
})
t.Run("explicit oauth delay wins", func(t *testing.T) {
err := &service.UpstreamFailoverError{SameAccountRetryDelay: 3 * time.Second}
require.Equal(t, 3*time.Second, sameAccountRetryDelayFor(err, 1))
})
}
func TestSameAccountRetryAllowedUsesDeadlineInsteadOfPoolCount(t *testing.T) {
err := &service.UpstreamFailoverError{
RetryableOnSameAccount: true,
SameAccountRetryDeadline: time.Now().Add(time.Minute),
}
require.True(t, sameAccountRetryAllowed(err, 100, 0))
require.True(t, sameAccountRetryAllowed(err, 100, maxSameAccountRetries))
err.SameAccountRetryDeadline = time.Now().Add(-time.Second)
require.False(t, sameAccountRetryAllowed(err, 0, 100))
}
func TestSameAccountRetryAllowedRequiresOptInAndDefaultsToCountLimit(t *testing.T) {
err := &service.UpstreamFailoverError{SameAccountRetryDeadline: time.Now().Add(time.Minute)}
require.False(t, sameAccountRetryAllowed(err, 0, maxSameAccountRetries))
err.RetryableOnSameAccount = true
err.SameAccountRetryDeadline = time.Time{}
require.True(t, sameAccountRetryAllowed(err, maxSameAccountRetries-1, maxSameAccountRetries))
require.False(t, sameAccountRetryAllowed(err, maxSameAccountRetries, maxSameAccountRetries))
}
func TestSameAccountRetryAllowedHonorsErrorMaxBeforeDeadline(t *testing.T) {
err := &service.UpstreamFailoverError{
RetryableOnSameAccount: true,
SameAccountRetryDeadline: time.Now().Add(time.Minute),
SameAccountRetryMax: 1,
}
require.True(t, sameAccountRetryAllowed(err, 0, maxSameAccountRetries))
require.False(t, sameAccountRetryAllowed(err, 1, maxSameAccountRetries))
require.False(t, sameAccountRetryAllowed(err, 0, 0), "an explicit zero retry budget remains disabled")
}
func TestSameAccountRetryDeadlineAllows(t *testing.T) {
require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{}))
require.True(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{
SameAccountRetryDeadline: time.Now().Add(time.Second),
}))
require.False(t, sameAccountRetryDeadlineAllows(&service.UpstreamFailoverError{
SameAccountRetryDeadline: time.Now().Add(-time.Second),
}))
}
func TestEffectiveSameAccountRetryLimitHonorsErrorCapAndDisabledAccount(t *testing.T) {
account := &service.Account{Type: service.AccountTypeAPIKey, Credentials: map[string]any{"pool_mode": true, "pool_mode_retry_count": float64(3)}}
require.Equal(t, 1, effectiveSameAccountRetryLimit(&service.UpstreamFailoverError{SameAccountRetryMax: 1}, account))
account.Credentials["pool_mode_retry_count"] = float64(0)
require.Equal(t, 0, effectiveSameAccountRetryLimit(&service.UpstreamFailoverError{SameAccountRetryMax: 1}, account))
}
// ---------------------------------------------------------------------------
// Helper
// ---------------------------------------------------------------------------
@@ -291,6 +374,22 @@ func TestHandleFailoverError_CacheBilling(t *testing.T) {
require.Zero(t, fs.SwitchCount)
})
t.Run("OAuth deadline存在时不按普通计数切换", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, true)
fs.SameAccountRetryCount[100] = maxSameAccountRetries
err := newTestFailoverErr(http.StatusTooManyRequests, true, false)
err.SameAccountRetryDeadline = time.Now().Add(time.Minute)
err.SameAccountRetryDelay = time.Nanosecond
fs.HandleFailoverError(context.Background(), mock, 100, "openai", maxSameAccountRetries, err)
require.False(t, fs.ForceCacheBilling)
require.Zero(t, fs.SwitchCount)
require.Equal(t, maxSameAccountRetries+1, fs.SameAccountRetryCount[100])
require.Empty(t, mock.calls)
})
t.Run("同账号重试耗尽并实际切换时设置ForceCacheBilling", func(t *testing.T) {
mock := &mockTempUnscheduler{}
fs := NewFailoverState(3, true)
+6 -4
View File
@@ -1147,10 +1147,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
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} {
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
if _, ok := schedulablePlatforms[platform]; ok {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) {
platformModels = defaultModelIDsForPlatform(platform)
}
}
@@ -1249,7 +1251,7 @@ func writeGrokModelsList(c *gin.Context, modelIDs []string) {
func grokModelSupportsConfigurableReasoning(modelID string) bool {
switch strings.ToLower(strings.TrimSpace(modelID)) {
case "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1":
case "grok-4.6", "grok-4.6-latest", "grok-4.5", "grok-4.5-latest", "grok", "grok-latest", "grok-build", "grok-build-latest", "grok-build-0.1":
return true
default:
return false
@@ -1372,7 +1374,7 @@ func defaultModelIDsForPlatform(platform string) []string {
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 _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
@@ -173,6 +174,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, groupPlatform)
cls = classifySelectionFailureError(err, cls)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -384,6 +386,14 @@ func (h *GatewayHandler) handleCCFailoverExhausted(c *gin.Context, lastErr *serv
h.chatCompletionsErrorResponse(c, status, "server_error", message)
return
}
if lastErr != nil && lastErr.IsOpenAICapacityShed() && strings.TrimSpace(lastErr.ClientMessage) != "" {
status := lastErr.ClientStatusCode
if status <= 0 {
status = http.StatusServiceUnavailable
}
h.chatCompletionsErrorResponse(c, status, "server_error", lastErr.ClientMessage)
return
}
statusCode := http.StatusBadGateway
if lastErr != nil && lastErr.StatusCode > 0 {
statusCode = lastErr.StatusCode
@@ -5,6 +5,7 @@ import (
"errors"
"net/http"
"strconv"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
@@ -175,6 +176,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
if err != nil {
if len(fs.FailedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, reqModel, reqModel, effectiveAPIKeyPlatform(c, apiKey))
cls = classifySelectionFailureError(err, cls)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -361,25 +363,38 @@ func (h *GatewayHandler) responsesErrorResponse(c *gin.Context, status int, code
// handleResponsesFailoverExhausted writes a failover-exhausted error in Responses format.
func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastErr *service.UpstreamFailoverError, streamStarted bool) {
if streamStarted {
return // Can't write error after stream started
}
if lastErr != nil {
copyFailoverRetryAfter(c, lastErr.ResponseHeaders)
}
if lastErr != nil && lastErr.IsCredentialFailure() {
status, message := credentialFailoverClientResponse(lastErr)
h.responsesErrorResponse(c, status, "server_error", message)
return
}
statusCode := http.StatusBadGateway
if lastErr != nil && lastErr.StatusCode > 0 {
statusCode = lastErr.StatusCode
}
if lastErr != nil && service.IsOpenAISilentRefusalErrorBody(lastErr.ResponseBody) {
status, code, message := statusCode, "server_error", "All available accounts exhausted"
if lastErr != nil && lastErr.IsCredentialFailure() {
status, message = credentialFailoverClientResponse(lastErr)
} else if lastErr != nil && lastErr.IsOpenAICapacityShed() && strings.TrimSpace(lastErr.ClientMessage) != "" {
status = lastErr.ClientStatusCode
if status <= 0 {
status = http.StatusServiceUnavailable
}
message = lastErr.ClientMessage
} else if lastErr != nil && service.IsOpenAISilentRefusalErrorBody(lastErr.ResponseBody) {
service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "")
h.responsesErrorResponse(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage())
status, code, message = http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage()
} else if lastErr != nil && statusCode == http.StatusTooManyRequests {
status, code, message = http.StatusTooManyRequests, "rate_limit_error", "All available accounts are currently rate-limited. Please retry later."
}
if streamStarted {
// A slot-wait heartbeat commits HTTP 200 before any upstream response.
// In that case a terminal frame is still required; once any semantic or
// official terminal bytes exist, preserve them without appending a second
// generic response.failed.
service.MarkOpsStreamError(c, code, message, status)
if c != nil && c.Writer != nil && (c.Writer.Size() <= 0 || gatewayStreamHasOnlyHeartbeats(c)) {
writeResponsesFailedSSE(c, code, message)
}
return
}
h.responsesErrorResponse(c, statusCode, "server_error", "All available accounts exhausted")
h.responsesErrorResponse(c, status, code, message)
}
+26 -1
View File
@@ -16,6 +16,29 @@ import (
"github.com/gin-gonic/gin"
)
const gatewayStreamHeartbeatBytesKey = "gateway_stream_heartbeat_bytes"
func recordGatewayStreamHeartbeat(c *gin.Context, written int) {
if c == nil || written <= 0 {
return
}
total, _ := c.Get(gatewayStreamHeartbeatBytesKey)
bytes, _ := total.(int)
c.Set(gatewayStreamHeartbeatBytesKey, bytes+written)
}
func gatewayStreamHasOnlyHeartbeats(c *gin.Context) bool {
if c == nil || c.Writer == nil {
return false
}
value, ok := c.Get(gatewayStreamHeartbeatBytesKey)
if !ok {
return false
}
heartbeatBytes, _ := value.(int)
return heartbeatBytes > 0 && c.Writer.Size() == heartbeatBytes
}
// claudeCodeValidator is a singleton validator for Claude Code client detection
var claudeCodeValidator = service.NewClaudeCodeValidator()
@@ -396,9 +419,11 @@ func (h *ConcurrencyHelper) waitForSlotWithPingTimeout(c *gin.Context, slotType
c.Header("X-Accel-Buffering", "no")
*streamStarted = true
}
if _, err := fmt.Fprint(c.Writer, string(h.pingFormat)); err != nil {
written, err := fmt.Fprint(c.Writer, string(h.pingFormat))
if err != nil {
return nil, err
}
recordGatewayStreamHeartbeat(c, written)
flusher.Flush()
case <-timer.C:
@@ -7,6 +7,7 @@ import (
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -321,6 +322,27 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
},
},
},
{
ID: 4,
Platform: service.PlatformKimi,
Credentials: map[string]any{
"model_mapping": map[string]any{"kimi-custom": "kimi-upstream"},
},
},
{
ID: 5,
Platform: service.PlatformZhipu,
Credentials: map[string]any{
"model_mapping": map[string]any{"glm-custom": "glm-upstream"},
},
},
{
ID: 6,
Platform: service.PlatformDeepseek,
Credentials: map[string]any{
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
},
},
},
},
},
@@ -335,7 +357,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
Platform: service.PlatformComposite,
ModelsListConfig: service.GroupModelsListConfig{
Enabled: true,
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5"},
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"},
},
},
})
@@ -346,7 +368,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5"}, modelIDsForTest(got.Data))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
@@ -385,6 +407,56 @@ func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t
require.NotContains(t, ids, "gemini-2.5-flash")
}
// CN 供应商没有静态默认模型列表:composite 下无映射的可调度 CN 账号不得把
// defaultModelIDsForPlatform default 分支的 Claude 列表挂到 CN 平台名下。
func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(35)
h := newGatewayModelsHandlerForTest(
&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{ID: 1, Platform: service.PlatformOpenAI},
{ID: 2, Platform: service.PlatformKimi},
{ID: 3, Platform: service.PlatformZhipu},
{ID: 4, Platform: service.PlatformDeepseek},
},
},
},
)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
ids := modelIDsForTest(got.Data)
require.Contains(t, ids, "gpt-5.5")
require.NotContains(t, ids, "claude-sonnet-4-6")
}
// 独立 CN 分组沿用 default 分支的 Claude 默认列表(Claude Code 客户端请求的
// 就是这些模型名并经账号 model_mapping 转换),composite 支持不得改变该回退。
func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) {
want := make([]string, 0, len(claude.DefaultModels))
for _, model := range claude.DefaultModels {
want = append(want, model.ID)
}
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
}
}
func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) {
gin.SetMode(gin.TestMode)
+56 -17
View File
@@ -28,12 +28,8 @@ const (
)
func (h *GatewayHandler) WebSearch(c *gin.Context) {
type webSearchReq struct {
Query string `json:"query" binding:"required"`
MaxResults int `json:"max_results"`
}
var req webSearchReq
isXSearch := c.GetBool("grok_x_search_endpoint")
var req grokStandaloneSearchRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error",
@@ -41,7 +37,28 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
}})
return
}
req.MaxResults = normalizeGrokWebSearchMaxResults(req.MaxResults)
query := strings.TrimSpace(req.Query)
if query == "" {
query = strings.TrimSpace(req.Input)
}
if query == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error",
"message": "query is required",
}})
return
}
req.Query = query
maxResults := 0
if req.MaxResults != nil {
maxResults = *req.MaxResults
}
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
searchModel := resolveGrokStandaloneSearchModel()
searchLabel := "web_search"
if isXSearch {
searchLabel = "x_search"
}
apiKey, ok := middleware2.GetAPIKeyFromContext(c)
if !ok || apiKey == nil {
@@ -55,7 +72,7 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
if apiKey.Group == nil || apiKey.Group.Platform != "grok" {
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error",
"message": "web search is only supported for grok groups",
"message": searchLabel + " is only supported for grok groups",
}})
return
}
@@ -79,7 +96,7 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
"role": "user", "content": req.Query,
}},
})
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, xai.DefaultTextModel, auditBody); decision != nil && !decision.AllowNextStage {
if decision := h.checkSecurityAudit(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIChat, searchModel, auditBody); decision != nil && !decision.AllowNextStage {
status := decision.HTTPStatus
if status == 0 {
status = http.StatusForbidden
@@ -123,7 +140,7 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
// First attempt + up to 3 failover accounts (max 4 total).
for attempt := 0; attempt < 4; attempt++ {
selected, selectErr := h.gatewayService.SelectAccountWithLoadAwareness(
c.Request.Context(), groupID, "", xai.DefaultTextModel, failedAccounts, "", 0,
c.Request.Context(), groupID, "", searchModel, failedAccounts, "", 0,
)
if selectErr != nil {
if attempt == 0 {
@@ -159,7 +176,11 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
account = selected.Account
accountReleaseFunc = release
nativeResp, providerName, err = h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, req.MaxResults)
if isXSearch {
nativeResp, providerName, err = h.doGrokNativeXSearch(c.Request.Context(), c, account, req, searchModel, maxResults)
} else {
nativeResp, providerName, err = h.doGrokNativeWebSearch(c.Request.Context(), c, account, req.Query, maxResults, searchModel)
}
if err == nil {
break
}
@@ -198,7 +219,7 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
// Request IDs are billing idempotency keys, so they must be unique per invocation.
// Query/IP/UA hashes would collapse repeated identical searches into one charge.
searchRequestID := "web_search:" + uuid.NewString()
searchRequestID := searchLabel + ":" + uuid.NewString()
if apiKey.Group != nil {
if p := apiKey.Group.GetSearchPricePer1k(); p != nil && *p == 0 {
logger.L().With(
@@ -211,7 +232,7 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: &service.ForwardResult{
RequestID: searchRequestID,
Model: "grok-web-search",
Model: "grok-" + strings.ReplaceAll(searchLabel, "_", "-"),
SearchCount: 1,
Duration: 0,
},
@@ -240,7 +261,7 @@ func (h *GatewayHandler) WebSearch(c *gin.Context) {
"query": req.Query,
"results": nativeResp.Results,
"provider": providerName,
"max_results": req.MaxResults,
"max_results": maxResults,
})
}
@@ -299,13 +320,13 @@ func (h *GatewayHandler) acquireWebSearchAccountSlot(
// doGrokNativeWebSearch executes web search using the Grok account's native capability
// by calling the responses endpoint with web_search tool, then normalizes sources to unified format.
func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Context, account *service.Account, query string, maxResults int) (*websearch.SearchResponse, string, error) {
func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Context, account *service.Account, query string, maxResults int, model string) (*websearch.SearchResponse, string, error) {
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
// Build a minimal responses request that triggers Grok web search tool.
// Ask for structured metadata because xAI action.sources commonly contains URLs only.
searchBody := map[string]any{
"model": xai.DefaultTextModel,
"model": xai.ResolveDefaultTextModel(model),
"input": buildGrokWebSearchPrompt(query, maxResults),
"tools": []map[string]any{{"type": "web_search"}},
"include": []string{"web_search_call.action.sources"},
@@ -329,6 +350,23 @@ func (h *GatewayHandler) doGrokNativeWebSearch(ctx context.Context, c *gin.Conte
}, "grok-native", nil
}
func (h *GatewayHandler) doGrokNativeXSearch(ctx context.Context, c *gin.Context, account *service.Account, req grokStandaloneSearchRequest, model string, maxResults int) (*websearch.SearchResponse, string, error) {
maxResults = normalizeGrokWebSearchMaxResults(maxResults)
bodyBytes, err := buildGrokXSearchResponsesBody(req, model)
if err != nil {
return nil, "", err
}
respBytes, err := h.gatewayService.DoGrokNativeResponsesJSON(ctx, account, bodyBytes)
if err != nil {
return nil, "", err
}
results := extractGrokWebSearchSources(respBytes, maxResults)
return &websearch.SearchResponse{
Results: results,
Query: req.Query,
}, "grok-native", nil
}
func normalizeGrokWebSearchMaxResults(maxResults int) int {
if maxResults <= 0 {
return defaultGrokWebSearchResults
@@ -377,7 +415,8 @@ func extractGrokWebSearchSources(body []byte, maxResults int) []websearch.Search
output := gjson.GetBytes(body, "output")
output.ForEach(func(_, item gjson.Result) bool {
if item.Get("type").String() == "web_search_call" {
callType := item.Get("type").String()
if callType == "web_search_call" || callType == "x_search_call" {
sources := item.Get("action.sources")
if sources.IsArray() {
sources.ForEach(func(_, src gjson.Result) bool {
@@ -569,6 +569,13 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
// 长上下文规则由计费服务统一持有(模型广场展示同源),入口只负责声明自己适用该规则。
var longContextThreshold int
var longContextMultiplier float64
if rule := h.gatewayService.LegacyLongContextRule(service.PlatformGemini); rule != nil {
longContextThreshold = rule.Threshold
longContextMultiplier = rule.Multiplier
}
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
Result: result,
@@ -583,8 +590,8 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
LongContextThreshold: 200000, // Gemini 200K 阈值
LongContextMultiplier: 2.0, // 超出部分双倍计费
LongContextThreshold: longContextThreshold,
LongContextMultiplier: longContextMultiplier,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
+88 -44
View File
@@ -44,39 +44,84 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
return
}
selection, _, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
c.Request.Context(),
apiKey.GroupID,
"",
"",
"grok-4.5",
nil,
service.OpenAIUpstreamTransportHTTPSSE,
// Grok only advertises chat_completions + media capabilities on HEAD.
service.OpenAIEndpointCapabilityChatCompletions,
false,
false,
false,
service.PlatformGrok,
)
if err != nil || selection == nil || selection.Account == nil {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
return
}
var streamStarted bool
reqLog := requestLogger(c, "handler.openai_gateway.grok_realtime")
release, slotStatus := h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", selection, true, &streamStarted, reqLog)
if slotStatus != openAISlotAcquireOK {
model := c.Query("model")
if strings.TrimSpace(model) == "" {
model = "grok-voice-latest"
}
// Keep the HTTP response uncommitted while selecting and probing an account.
// Realtime is not an HTTP streaming response; using reqStream=true here would
// let the wait queue flush an SSE ping before the WebSocket handshake succeeds.
failed := map[int64]struct{}{}
var selection *service.AccountSelectionResult
var release func()
var token string
var upstream *service.GrokRealtimeUpstream
var candidateSeen bool
for attempts := 0; attempts < 4; attempts++ {
// Realtime's voice model is not a text-model capability. Passing a
// concrete text model here would reject accounts mapped only to an
// older/default text model before the upstream handshake can decide.
// An empty requested model keeps account selection capability-based;
// the actual voice model remains in the upstream WS query below.
candidate, _, selectErr := h.gatewayService.SelectAccountWithSchedulerForCapability(
c.Request.Context(), apiKey.GroupID, "", "", "", failed,
service.OpenAIUpstreamTransportHTTPSSE,
service.OpenAIEndpointCapabilityChatCompletions,
false, false, false, service.PlatformGrok,
)
if selectErr != nil || candidate == nil || candidate.Account == nil {
break
}
candidateSeen = true
account := candidate.Account
var streamStarted bool
var slotStatus openAISlotAcquireResult
release, slotStatus = h.acquireResponsesAccountSlot(c, apiKey.GroupID, "", candidate, false, &streamStarted, reqLog)
if slotStatus != openAISlotAcquireOK {
if slotStatus == openAISlotAcquireFailed {
return
}
failed[account.ID] = struct{}{}
continue
}
var credErr error
token, _, credErr = h.gatewayService.GetRequestCredential(c.Request.Context(), c, account)
if credErr != nil {
release()
release = nil
failed[account.ID] = struct{}{}
continue
}
probeCtx, cancelProbe := context.WithTimeout(c.Request.Context(), service.DefaultGrokRealtimeDialTimeout)
candidateUpstream, openErr := h.gatewayService.OpenGrokRealtime(probeCtx, account, token, model)
cancelProbe()
if openErr != nil {
reqLog.Warn("grok_realtime.pre_accept_failed", zap.Int64("account_id", account.ID), zap.Error(openErr))
statusCode := http.StatusBadGateway
var dialErr *service.GrokRealtimeDialError
if errors.As(openErr, &dialErr) && dialErr.StatusCode > 0 {
statusCode = dialErr.StatusCode
}
h.gatewayService.HandleGrokRealtimeUpstreamError(c.Request.Context(), account, statusCode, []byte(openErr.Error()))
release()
release = nil
failed[account.ID] = struct{}{}
continue
}
selection, upstream = candidate, candidateUpstream
break
}
if selection == nil || selection.Account == nil || release == nil || upstream == nil {
if !candidateSeen {
h.errorResponse(c, http.StatusServiceUnavailable, "api_error", "No available Grok accounts")
} else {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok realtime upstream unavailable")
}
return
}
defer release()
token, _, err := h.gatewayService.GetRequestCredential(c.Request.Context(), c, selection.Account)
if err != nil {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Grok credential unavailable")
return
}
defer func() { _ = upstream.Close() }()
conn, err := coderws.Accept(c.Writer, c.Request, &coderws.AcceptOptions{CompressionMode: coderws.CompressionContextTakeover})
if err != nil {
@@ -84,12 +129,8 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
}
defer func() { _ = conn.CloseNow() }()
model := c.Query("model")
if strings.TrimSpace(model) == "" {
model = "grok-voice-latest"
}
started := time.Now()
proxyErr := h.gatewayService.ProxyGrokRealtime(c.Request.Context(), c, conn, selection.Account, token, model)
audioObserved, proxyErr := h.gatewayService.ProxyGrokRealtimeConn(c.Request.Context(), c, conn, upstream)
elapsed := time.Since(started)
if proxyErr != nil {
reqLog.Info("grok_realtime.proxy_failed", zap.Error(proxyErr))
@@ -98,20 +139,23 @@ func (h *OpenAIGatewayHandler) GrokRealtime(c *gin.Context) {
return
}
}
// A relay normally returns a close error when either side closes normally.
// Those sessions still consumed upstream audio time and must be billed.
if elapsed > 0 {
result := &service.OpenAIForwardResult{
// One durable id per WS session so retries cannot collapse or double under client ids.
RequestID: service.StableGrokRealtimeBillingRequestID(""),
Model: model,
Duration: elapsed,
AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()},
}
if result := grokRealtimeBillingResult(model, elapsed, audioObserved); result != nil {
h.recordGrokVoiceUsage(c, apiKey, selection.Account, subscription, "realtime", nil, result)
}
}
func grokRealtimeBillingResult(model string, elapsed time.Duration, audioObserved bool) *service.OpenAIForwardResult {
if !audioObserved || elapsed <= 0 {
return nil
}
return &service.OpenAIForwardResult{
RequestID: service.StableGrokRealtimeBillingRequestID(""),
Model: model,
Duration: elapsed,
AudioUsage: &service.AudioUsage{Mode: "realtime", DurationOrUnits: elapsed.Minutes()},
}
}
func isExpectedGrokRealtimeClose(err error) bool {
if err == nil {
return true
@@ -4,6 +4,7 @@ package handler
import (
"testing"
"time"
coderws "github.com/coder/websocket"
)
@@ -23,3 +24,29 @@ func TestIsExpectedGrokRealtimeClose(t *testing.T) {
t.Fatal("policy violations must not be treated as billable normal closes")
}
}
func TestGrokRealtimeBillingResultRequiresObservedAudio(t *testing.T) {
if grokRealtimeBillingResult("grok-voice-latest", time.Second, false) != nil {
t.Fatal("a session without observed audio must not be billed")
}
if grokRealtimeBillingResult("grok-voice-latest", 0, true) != nil {
t.Fatal("zero-duration sessions must not be billed")
}
}
func TestGrokRealtimeBillingResultUsesForcedUniqueID(t *testing.T) {
first := grokRealtimeBillingResult("grok-voice-latest", 90*time.Second, true)
second := grokRealtimeBillingResult("grok-voice-latest", 90*time.Second, true)
if first == nil || second == nil {
t.Fatal("observed audio sessions should be billable")
}
if first.RequestID == "" {
t.Fatalf("unexpected billing request ID %q", first.RequestID)
}
if first.RequestID == second.RequestID {
t.Fatal("independent realtime connections must not share a billing request ID")
}
if first.AudioUsage == nil || first.AudioUsage.Mode != "realtime" || first.AudioUsage.DurationOrUnits != 1.5 {
t.Fatalf("unexpected audio usage: %#v", first.AudioUsage)
}
}
+8 -6
View File
@@ -346,7 +346,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
if failoverErr.ShouldReportAccountScheduleFailure() {
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, nil), false, nil)
h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, nil), false, nil)
}
if c.Writer.Size() != writerSizeBeforeForward {
h.handleFailoverExhausted(c, failoverErr, true)
@@ -361,19 +361,21 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
if failoverErr.RetryableOnSameAccount {
retryLimit := account.GetPoolModeRetryCount()
if sameAccountRetryCount[account.ID] < retryLimit {
retryLimit := effectiveSameAccountRetryLimit(failoverErr, account)
if sameAccountRetryAllowed(failoverErr, sameAccountRetryCount[account.ID], retryLimit) {
sameAccountRetryCount[account.ID]++
retryDelay := sameAccountRetryDelayFor(failoverErr, sameAccountRetryCount[account.ID])
reqLog.Warn("grok_media.pool_mode_same_account_retry",
zap.Int64("account_id", account.ID),
zap.Int("upstream_status", failoverErr.StatusCode),
zap.Int("retry_limit", retryLimit),
zap.Int("retry_count", sameAccountRetryCount[account.ID]),
zap.Duration("retry_delay", retryDelay),
)
select {
case <-requestCtx.Done():
return
case <-time.After(sameAccountRetryDelay):
case <-time.After(retryDelay):
}
continue
}
@@ -398,7 +400,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
)
continue
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, nil), false, nil)
h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, nil), false, nil)
if !service.IsResponseCommitted(c) && c.Writer.Size() == writerSizeBeforeForward {
h.errorResponse(c, http.StatusBadGateway, "upstream_error", "Upstream request failed")
}
@@ -409,7 +411,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
return
}
h.gatewayService.ReportOpenAIAccountScheduleResult(account.ID, grokMediaScheduleModel(account, routingModel, result), true, nil)
h.gatewayService.ReportOpenAIAccountScheduleResult(account, grokMediaScheduleModel(account, routingModel, result), true, nil)
if isGrokVideoCreateEndpoint(endpoint) && strings.TrimSpace(result.ResponseID) != "" {
if err := h.gatewayService.BindGrokMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, result.ResponseID, subject.UserID, apiKey.ID, account.ID,
+2
View File
@@ -19,6 +19,7 @@ type AdminHandlers struct {
GeminiOAuth *admin.GeminiOAuthHandler
AntigravityOAuth *admin.AntigravityOAuthHandler
GrokOAuth *admin.GrokOAuthHandler
CNProvider *admin.CNProviderHandler
Proxy *admin.ProxyHandler
Redeem *admin.RedeemHandler
Promo *admin.PromoHandler
@@ -30,6 +31,7 @@ type AdminHandlers struct {
UserAttribute *admin.UserAttributeHandler
ErrorPassthrough *admin.ErrorPassthroughHandler
TLSFingerprintProfile *admin.TLSFingerprintProfileHandler
Plugin *admin.PluginHandler
APIKey *admin.AdminAPIKeyHandler
ScheduledTest *admin.ScheduledTestHandler
Channel *admin.ChannelHandler
+70 -27
View File
@@ -17,39 +17,60 @@ import (
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性)。
type ModelPlazaHandler struct {
channelService *service.ChannelService
plazaService *service.ModelPlazaService
apiKeyService *service.APIKeyService
settingService *service.SettingService
}
// NewModelPlazaHandler 创建模型广场 handler。
func NewModelPlazaHandler(
channelService *service.ChannelService,
plazaService *service.ModelPlazaService,
apiKeyService *service.APIKeyService,
settingService *service.SettingService,
) *ModelPlazaHandler {
return &ModelPlazaHandler{
channelService: channelService,
plazaService: plazaService,
apiKeyService: apiKeyService,
settingService: settingService,
}
}
// modelPlazaOfficialPricing LiteLLM 官方参考价(USD per token)。
// modelPlazaOfficialPricing 官方参考价(USD per token,与计费目录同源)。
type modelPlazaOfficialPricing struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price,omitempty"`
CacheReadPrice *float64 `json:"cache_read_price"`
// Intervals 官方长上下文阶梯,仅多档模型给出。
Intervals []userPricingIntervalDTO `json:"intervals,omitempty"`
}
// modelPlazaModel 广场模型条目:渠道定价(白名单形态)+ 官方参考价。
// modelPlazaTimePricingPeriod 分时倍率时段(配置时区当天 [start, end))。
type modelPlazaTimePricingPeriod struct {
StartTime string `json:"start_time"`
EndTime string `json:"end_time"`
Multiplier float64 `json:"multiplier"`
}
// modelPlazaTimePricing 计费会生效的分时倍率(仅倍率 ≠ 1 的时段)。
// WeekdaysOnly 为 true 时时段仅周一至周五生效,周末整天按标准价计费。
type modelPlazaTimePricing struct {
Timezone string `json:"timezone"`
WeekdaysOnly bool `json:"weekdays_only,omitempty"`
Periods []modelPlazaTimePricingPeriod `json:"periods"`
}
// modelPlazaModel 广场模型条目:实收口径展示定价(白名单形态)+ 官方参考价。
type modelPlazaModel struct {
Name string `json:"name"`
Platform string `json:"platform"`
Pricing *userSupportedModelPricing `json:"pricing"`
OfficialPricing *modelPlazaOfficialPricing `json:"official_pricing"`
// LongContextBasis 多档时的计价基准:"whole_request"(整单按档)| "marginal"(仅超出部分)。
LongContextBasis string `json:"long_context_basis,omitempty"`
// TimePricing 分时倍率时段,落在时段内的请求整单乘倍率;无分时省略。
TimePricing *modelPlazaTimePricing `json:"time_pricing,omitempty"`
}
// modelPlazaGroup 广场分组条目(白名单字段)。
@@ -68,9 +89,11 @@ type modelPlazaGroup struct {
IsExclusive bool `json:"is_exclusive"`
// 生图独立倍率:为 true 时图片计费模型的实付倍率取 ImageRateMultiplier,
// 不取分组/用户专属倍率。
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
Models []modelPlazaModel `json:"models"`
ImageRateIndependent bool `json:"image_rate_independent"`
ImageRateMultiplier float64 `json:"image_rate_multiplier"`
// 分组是否启用长上下文阶梯计费;关闭时模型实付列只展示最低档/基础价。
LongContextPricingEnabled bool `json:"long_context_pricing_enabled"`
Models []modelPlazaModel `json:"models"`
}
// modelPlazaResponse 广场页响应。
@@ -98,7 +121,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
return
}
groups, err := h.channelService.ListPlazaGroups(c.Request.Context())
groups, err := h.plazaService.ListGroups(c.Request.Context())
if err != nil {
response.ErrorFrom(c, err)
return
@@ -161,27 +184,30 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
for i := range g.Models {
m := &g.Models[i]
models = append(models, modelPlazaModel{
Name: m.Name,
Platform: m.Platform,
Pricing: toUserPricing(m.Pricing),
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
Name: m.Name,
Platform: m.Platform,
Pricing: toUserPricing(m.Pricing),
OfficialPricing: toModelPlazaOfficialPricing(m.OfficialPricing),
LongContextBasis: string(m.LongContextBasis),
TimePricing: toModelPlazaTimePricing(m.TimePricing),
})
}
dto := modelPlazaGroup{
ID: g.ID,
Name: g.Name,
Description: g.Description,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
ImageRateIndependent: g.ImageRateIndependent,
ImageRateMultiplier: g.ImageRateMultiplier,
Models: models,
ID: g.ID,
Name: g.Name,
Description: g.Description,
Platform: g.Platform,
SubscriptionType: g.SubscriptionType,
RateMultiplier: g.RateMultiplier,
PeakRateEnabled: g.PeakRateEnabled,
PeakStart: g.PeakStart,
PeakEnd: g.PeakEnd,
PeakRateMultiplier: g.PeakRateMultiplier,
IsExclusive: g.IsExclusive,
ImageRateIndependent: g.ImageRateIndependent,
ImageRateMultiplier: g.ImageRateMultiplier,
LongContextPricingEnabled: g.LongContextPricingEnabled,
Models: models,
}
if rate, ok := userRates[g.ID]; ok {
dto.UserRateMultiplier = &rate
@@ -189,6 +215,22 @@ func toModelPlazaGroupDTO(g *service.PlazaGroup, userRates map[int64]float64) mo
return dto
}
// toModelPlazaTimePricing 转换分时倍率;nil 透传(JSON 省略)。
func toModelPlazaTimePricing(p *service.TimePricingSchedule) *modelPlazaTimePricing {
if p == nil || len(p.Periods) == 0 {
return nil
}
periods := make([]modelPlazaTimePricingPeriod, 0, len(p.Periods))
for _, period := range p.Periods {
periods = append(periods, modelPlazaTimePricingPeriod{
StartTime: period.StartTime,
EndTime: period.EndTime,
Multiplier: period.Multiplier,
})
}
return &modelPlazaTimePricing{Timezone: p.Timezone, WeekdaysOnly: p.WeekdaysOnly, Periods: periods}
}
// toModelPlazaOfficialPricing 转换官方参考价;nil 透传(前端显示 "-")。
func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOfficialPricing {
if p == nil {
@@ -200,5 +242,6 @@ func toModelPlazaOfficialPricing(p *service.PlazaOfficialPricing) *modelPlazaOff
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
Intervals: toUserPricingIntervals(p.Intervals),
}
}

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