Compare commits

...
270 Commits
Author SHA1 Message Date
Wesley Liddick 5485f368b2 Merge pull request #6690 from alfadb/fix/astra-pro-mode
fix(openai): 保留 Codex OAuth 请求中的 Astra reasoning.mode
2026-09-07 20:42:37 +08:00
Wesley Liddick f8351e9e33 Merge pull request #6658 from ZeroClover/feat/group-model-allowlist
feat(groups)!: enforce model allowlists across gateway endpoints
2026-09-07 20:25:57 +08:00
shaw 2031c1d5ad fix(groups): merge main and resolve model allowlist conflicts
Resolve three conflicts from main's #6680 (supplementUnmappedOpenAIModels):

1. gateway_handler.go — keep PR's allowlist refactoring (ModelAllowlistEnabled,
   writeAllowlistedModelsList, modelListingSource); main's OpenAI branch in
   writeModelsList was auto-merged.
2. group_models_list.go (modify/delete) — delete file per PR intent; relocate
   supplementUnmappedOpenAIModels to group_model_allowlist.go where gateway_service
   and openai_codex_models_service can still call it.
3. openai_codex_models_service.go — combine main's supplementUnmappedOpenAIModels
   wrapping with PR's ModelAllowlistEnabled() guard.

Also fix two auto-merged test files that still referenced the old
GroupModelsListConfig / ModelsListConfig types.
2026-09-07 19:44:03 +08:00
Wesley Liddick 8193b80a59 Merge pull request #6743 from MarushiruDonato/fix/gpt6-astra-instructions
fix(openai): 修正 GPT-6 Astra 提示词选择
2026-09-07 19:22:43 +08:00
shaw f6d580200a fix: update #6688 test assertions for #6678 image modality and #6718 ultra level
- TestCodexAliasFailoverMappingHonorsModelRouting/no_routing_rule: expect
  ["text","image"] instead of ["text"] — image input is resolved per-account
  via isOpenAICodexImageInputModel, independent of routing rules
- TestCodexAliasFailoverMappingHonorsModelRouting/routing_rule: expect
  effort levels to include "ultra" (added by #6718)
2026-09-07 19:13:01 +08:00
shaw b491c3271b fix: rename redeemMaxErrorsPerHour to redeemMaxFailedAttempts in tests
#6669 renamed the constant from redeemMaxErrorsPerHour to
redeemMaxFailedAttempts (fixed 10-minute window), but #6670's tests
still referenced the old name, breaking compilation on main.
2026-09-07 19:00:04 +08:00
shaw d106b3b5a0 fix: resolve merge conflict with main and fix #6718 missing group parameter
- Resolve conflict in openai_codex_models_service_test.go: keep both
  GPT-6 Astra instructions template assertion (from this PR) and ultra
  reasoning level + multi_agent fields (from #6718 on main)
- Fix compilation error on main: add missing `group *Group` parameter
  to buildCodexModelsManifestForAccounts calls in
  openai_codex_model_metadata_test.go (lines 24, 32) — caused by #6688
  changing the function signature while #6718 added new callers with
  the old signature
2026-09-07 18:58:00 +08:00
Wesley Liddick 10fe591e54 Merge pull request #6660 from ouchihao/codex/issue-312-onboarding-button-shadow
fix(onboarding): remove button text shadows
2026-09-07 18:51:44 +08:00
Wesley Liddick 66cd8a72bc Merge pull request #6738 from Milesians/codex/fix-interval-multiplier-display
fix: 修正区间倍率展示并补齐模型广场 1h 缓存价格
2026-09-07 18:51:00 +08:00
Wesley Liddick dc46daa690 Merge pull request #6726 from DuskLin/feat/openai-reasoning-effort-mapping-deny
feat(groups): 推理强度映射转发值支持拒绝
2026-09-07 18:50:41 +08:00
Wesley Liddick 65246e69d7 Merge pull request #6643 from MarushiruDonato/fix/codex-manifest-reactive-vmodel
fix(frontend): 修复 Codex Manifest 编辑状态丢失
2026-09-07 18:50:27 +08:00
Wesley Liddick de69bf1e03 Merge pull request #6655 from ouchihao/codex/issue-275-account-expiry-presets
feat(accounts): add month and year expiry presets
2026-09-07 18:50:13 +08:00
Wesley Liddick eba85dc478 Merge pull request #6680 from cyhhao/fix/openai-mixed-account-model-list
fix(openai): retain default models in mixed account catalogs
2026-09-07 18:50:03 +08:00
Wesley Liddick abf70750fd Merge pull request #6669 from ssharkkky/fix/redeem-fixed-failure-window
fix(redeem): use a fixed 10-minute failure window
2026-09-07 18:49:51 +08:00
Wesley Liddick 88b697d5df Merge pull request #6673 from ssharkkky/fix/backup-migrations-advisory-lock
fix(backup): serialize pg_dump/psql with the migrations advisory lock
2026-09-07 18:49:34 +08:00
Wesley Liddick 3eb5a84ea1 Merge pull request #6700 from qiu121/codex/fix/deepseek-account-cost-peak
fix(billing): 修复 DeepSeek 高峰账号成本少计
2026-09-07 18:49:19 +08:00
Wesley Liddick 71e5a4baf7 Merge pull request #6486 from aofee/codex/fix-grok-media-inconclusive-eligibility
fix(grok): do not reject media accounts on incomplete billing
2026-09-07 18:48:39 +08:00
Wesley Liddick 4bfcbd0c78 Merge pull request #6498 from aofee/codex/fix-ws-pending-turn-terminal
fix(openai-ws): track pending passthrough turns
2026-09-07 18:48:04 +08:00
Wesley Liddick 2b1e5e7f5c Merge pull request #6429 from spin6lock/fix/billing-glm-5.3-flash-fallback-pricing
fix(billing): 补上 glm-5.3 / glm-5.3-flash 兜底价,避免被 glm-5 子串匹配抢走
2026-09-07 18:47:46 +08:00
Wesley Liddick cbb4b7e53e Merge pull request #6678 from gebdalaoli-arch/codex/stale-astra-input-modalities-v021
[codex] Fix stale official Astra input modalities
2026-09-07 18:47:31 +08:00
Wesley Liddick 81c0f11d7a Merge pull request #6736 from qiu121/codex/fix/openai-stream-failure-observability
fix(openai): 记录流式失败的上游诊断信息
2026-09-07 18:46:58 +08:00
Wesley Liddick 561fc1c3e4 Merge pull request #6718 from lyy0709/codex/astra-ultra-catalog
fix(codex): preserve Astra Ultra workflow metadata
2026-09-07 18:46:11 +08:00
Wesley Liddick 43491f67a1 Merge pull request #6733 from AndrewZhuCC/fix/codex-allowed-tools
fix(codex): preserve allowed_tools selection constraints
2026-09-07 18:45:48 +08:00
Wesley Liddick c05bc4d3cc Merge pull request #6705 from ouchihao/codex/issue-4827-usage-api-key-filter
fix(usage): load all API keys for record filters
2026-09-07 18:45:35 +08:00
Wesley Liddick b9ebc8f209 Merge pull request #6637 from varrge/fix/gemini-flash-tier-pricing
fix(billing): resolve Gemini 3.7/3.8 Flash thinking-tier prices
2026-09-07 18:44:55 +08:00
Wesley Liddick f7c48ba59a Merge pull request #6674 from wuji-labs/fix/antigravity-toolconfig-always-present
fix(antigravity): always set toolConfig for reasoning models without tools
2026-09-07 18:44:41 +08:00
Wesley Liddick 22c16d0517 Merge pull request #6708 from r266-tech/r266/fix-ws-turn-rate-limit-6679
fix(openai-ws): recover later passthrough turns from account quota exhaustion
2026-09-07 18:44:18 +08:00
Wesley Liddick 2cc1e7ef90 Merge pull request #6677 from Lynricsy/fix/claude-thinking-block-binding
fix(claude): support thinking block binding beta
2026-09-07 18:43:52 +08:00
Zero Clover 81ff9384cb fix(groups): merge main and resolve model allowlist conflicts 2026-09-07 18:33:39 +08:00
alfadb 0aaed397cc fix: preserve Astra reasoning mode for Codex OAuth 2026-09-07 17:58:20 +08:00
Wesley Liddick 909b8c7ef4 Merge pull request #6648 from ouchihao/codex/issue-3667-turnstile-loading
fix(auth): show a loading placeholder for Turnstile
2026-09-07 17:38:11 +08:00
Wesley Liddick b8ff74ad70 Merge pull request #6668 from ssharkkky/fix/channel-monitor-gemini-role
fix(monitors): send explicit user role in Gemini monitor probes
2026-09-07 17:38:00 +08:00
Wesley Liddick 96cf8bd76a Merge pull request #6665 from ssharkkky/fix/grok-cross-client-map-default-doc
fix(settings): describe Grok cross-client mapping default as enabled
2026-09-07 17:37:47 +08:00
Wesley Liddick 1f716aa181 Merge pull request #6667 from ssharkkky/fix/antigravity-user-agent-version
fix(antigravity): refresh default user agent version
2026-09-07 17:37:37 +08:00
Wesley Liddick 111c41cf3f Merge pull request #6684 from SakuraPuare/feat/i18n-build-gate
fix(i18n): fail builds on missing locale keys
2026-09-07 17:37:25 +08:00
Wesley Liddick 787a6a33df Merge pull request #6481 from alfadb/fix/claude-cli-version-floor
fix(claude): 让伪装的 Claude Code 版本真正生效,修复 Fable 5.1 仍然 400
2026-09-07 17:37:12 +08:00
Wesley Liddick f8e1e7fed6 Merge pull request #6711 from wucm667/fix/issue-6701-model-not-found-failover
Fix failover for model-not-found responses
2026-09-07 17:36:58 +08:00
Wesley Liddick f09a5b602e Merge pull request #6704 from aofee/codex/fix-http-bridge-session-isolation
fix(openai-ws): 修复 HTTP bridge 并发连接抢占与状态串用
2026-09-07 17:36:36 +08:00
Wesley Liddick 027e3f2d54 Merge pull request #6688 from suixincrazy/fix/codex-manifest-failover-alias
fix(openai): honor model_routing for Codex manifests and scheduling
2026-09-07 17:36:26 +08:00
Wesley Liddick 8116d235ef Merge pull request #6629 from superdav42/fix/preserve-function-arguments-done
fix(apicompat): preserve function arguments from done events
2026-09-07 17:36:10 +08:00
Wesley Liddick 959cbe3d87 Merge pull request #5453 from wucm667/fix/issue-5451-ws-passthrough-before-turn
fix(openai): reacquire passthrough turn slots
2026-09-07 17:35:57 +08:00
Wesley Liddick c0420e2b8a Merge pull request #6484 from aofee/codex/fix-fable-credits-required-scope
fix(anthropic): keep Fable credits_required model-scoped
2026-09-07 17:33:59 +08:00
Wesley Liddick 9578ddbddd Merge pull request #6687 from Chenning-Tao/codex/fix-native-delegation-ws
fix(openai): preserve named standalone Responses inputs
2026-09-07 17:31:17 +08:00
Wesley Liddick e274de45b7 Merge pull request #6702 from a1gaoshanlaiyige/fix/message-cache-json-escaping
fix(gateway): use JSON escaping for message cache promotion
2026-09-07 17:30:54 +08:00
Wesley Liddick ce328fb37a Merge pull request #6670 from ssharkkky/fix/payment-fulfillment-isolation
fix(payment): isolate fulfillment from redeem limits
2026-09-07 17:30:43 +08:00
Wesley Liddick 8f7f2b5d92 Merge pull request #6727 from wucm667/feat/issue-6723-subscription-usage-link
feat(admin): link subscriptions to user usage
2026-09-07 16:45:32 +08:00
alfadb 3efeba33a4 test(claude): 版本覆盖用例字面量随内置基线升至 2.1.259
这三个用例的字面量 2.1.251 写于内置基线为 2.1.220 的时期,本意是取一个
高于基线的补丁位验证"覆盖不得低于 pin"规则。基线升至 2.1.258 后,2.1.251
落在 pin 之下:IsSupportedCLIVersion 应判 false、resolveCLIVersion 应回落
基线,与用例原意相悖,CI 随之失败。

改为真正高于当前 pin 的最小补丁位 2.1.259,用例语义不变;实现未放宽,
constants_cli_version_test.go 中独立的 Fable 5.1 闸门下限 2.1.251 保持不变。
2026-09-07 16:34:35 +08:00
alfadb ce157b32ed fix(claude): 指纹自愈路径同样执行 claude-cli 版本下限
GetOrCreateFingerprint 的 heal 分支(畸形缓存自愈)此前在版本下限逻辑之外:
畸形缓存 + 合法但过旧的客户端 UA 会在首次读取时把过旧 UA 原样落库并返回,
下一次读取才被抬到下限——该窗口内账号仍以过旧版本撞上游客户端版本闸门
(PR #6481 review 指出的边界 case)。

修复:把 floorClaudeCLIUserAgentVersion 后置块从 healthy else 内移到
heal/healthy if-else 之后、TTL 续期检查之前,作为两条缓存命中路径的共同
后置条件;heal 分支 needWrite 本已为 true,单次落库语义不变。

测试:TestGetOrCreateFingerprintHealsPoisonedCacheUsingValidClientUA
期望改为首次返回/落库即 claude-cli/CLICurrentVersion(保留 ClientID 不变、
恰好一次落库断言);floor/UA 全部既有用例保持绿。
2026-09-07 16:34:35 +08:00
alfadb 3cb2381bdc fix(claude): 让伪装的 Claude Code 版本真正生效,修复 Fable 5.1 仍然 400
claude-fable-5-1 已登记进 DefaultModels,但 OAuth 账号实际调用仍然全部失败:

  400 claude_code_version_too_old
  "Claude Code 2.1.220 does not support this model;
   version 2.1.251 or newer is required."

两个独立的原因,缺一不可:

1. CLICurrentVersion 停留在 2.1.220,低于上游对该模型的客户端版本闸门。
   升到 2.1.258(当前官方 CLI 最新版,npm @anthropic-ai/claude-code)。

2. 只升常量对所有已有账号无效。上游请求实际发送的 User-Agent 来自账号级
   持久指纹(IdentityService,Redis fingerprint:<accountID>),不是
   DefaultHeaders;defaultFingerprint 只在首次创建指纹时使用。
   GetOrCreateFingerprint 的缓存命中路径只有两条改写分支:UA 畸形时自愈,
   或客户端送来更新版本时升级。指纹 7 天 TTL 但活跃账号每 24h 续期,实际
   近乎永不过期,系统内也没有重置入口——客户端版本比缓存旧时(例如缓存
   2.1.220、客户端 2.1.75),存量账号会永远对上游声称 2.1.220。

   生产实测:升级 CLICurrentVersion 并部署后仍报同样的 400,读 Redis 确认
   出问题账号的缓存值为 "claude-cli/2.1.220 (external, cli)"。

修复:新增 floorClaudeCLIUserAgentVersion,把 CLICurrentVersion 作为缓存
指纹里 claude-cli 版本的下限,缓存命中与首次创建两条路径共用。

  - 只对 claude-cli 产品生效,其它客户端一律不动
  - 只升不降:版本等于或高于下限(含客户端上报的更新版本)不做任何改动
  - 只替换版本号段,UA 其余部分原样保留,例如
    "(external, claude-desktop-3p, agent-sdk/0.3.100)" 不受影响
  - 与既有的"客户端更新版本"升级分支相互独立、二者取更新者
  - X-Stainless-* 维持既有 merge 语义,不被下限逻辑覆盖

cc_version 无需单独处理:billing attribution block 的版本已由
syncBillingHeaderVersion 跟随实际发送的 User-Agent 同步。

测试:identity_service_version_floor_test.go 覆盖 helper 的 8 种 UA 形态与
5 个 GetOrCreateFingerprint 场景,其中一个直接复现线上故障(缓存 2.1.220 +
客户端 2.1.75 → 写回 2.1.258,Stainless 字段保持不变)。
constants_cli_version_test.go 锁定常量与 DefaultHeaders 的 UA 同源,并断言
不低于 Fable 5.1 的闸门下限。回退实现后对应用例确认转红。

identity_service_user_agent_validation_test.go 中 3 个把版本钉死在旧字面量
的既有用例改为钉 CLICurrentVersion 或更高版本——它们断言的正是本次有意改变
的行为,各自的原始意图(哨兵拒绝、合法升级生效、健康缓存不重写)保持不变。
2026-09-07 16:34:35 +08:00
Wesley Liddick a7d2c782df Merge pull request #5717 from wucm667/feat/issue-2629-current-main
feat(simple-mode): allow basic account grouping
2026-09-07 15:43:22 +08:00
Wesley Liddick a3aa9bae6d Merge pull request #6662 from ZeroClover/fix/codex-manifest-pinned-accounts
fix(openai): reuse pinned accounts for model lists and fix manifest controls
2026-09-07 15:35:49 +08:00
Robert Guo b939fa9d4a fix(openai): select GPT-6 Astra instructions correctly 2026-09-07 14:12:21 +08:00
milesians d03c42d798 fix: size plaza pricing columns to fit cache prices 2026-09-07 11:56:53 +08:00
qiu121 6aabbdf547 fix(openai): 记录流式失败的上游诊断信息
- 在 Responses 流已经输出语义内容后记录 response.failed 的上游事件,使运维可以查询原始错误、账号和上游 request ID,定位 OpenAI 临时处理失败而不丢失诊断上下文。
- 保留已提交语义内容后禁止切号重放的安全边界,避免客户端收到重复或混合响应;补充原生与透传路径的回归测试,确保该错误可观测且行为一致。

Refs: #5165, #6714
2026-09-07 11:27:08 +08:00
lyy0709 ad4b2f30f5 test(codex): assert alias workflow effort before dereferencing 2026-09-07 11:23:11 +08:00
milesians ff758f37d2 fix: preserve plaza 1h pricing and align interval fallbacks 2026-09-07 11:07:35 +08:00
zhuanzhi 2e31d8b702 fix(codex): preserve allowed_tools selection constraints
Keep allowed_tools policies intact for upstream validation and rewrite their
function references alongside reserved tool-name aliases.
2026-09-07 10:47:51 +08:00
wucm667 8c8fd6e298 feat(admin): link subscriptions to user usage 2026-09-07 10:13:18 +08:00
刘佳林 f62ec2e4a0 feat(groups): 推理强度映射转发值支持拒绝
分组推理强度映射的转发值新增 deny。命中对应请求档位时直接拒绝请求,
返回 403 permission_error,而不是改写后转发。
2026-09-07 10:12:42 +08:00
milesians 415a0dc9d0 fix: display multiplier-only interval prices 2026-09-07 10:11:07 +08:00
wucm667 b59f3bf46c fix(simple-mode): enforce basic group boundaries 2026-09-07 09:20:48 +08:00
lyy0709 2db78bd3d6 fix(codex): preserve Astra Ultra workflow metadata 2026-09-07 01:12:55 +08:00
Zero Clover f88d62ad28 fix(openai): use live account models in connection tests 2026-09-07 00:23:49 +08:00
wucm667 8363d537e0 fix(gateway): fail over model-not-found responses 2026-09-06 20:38:17 +08:00
r266-tech b8ed245085 fix(openai-ws): recover later turns from quota exhaustion 2026-09-06 18:42:06 +08:00
chihao.ou dc6b318c34 fix(usage): load all API keys for record filters 2026-09-06 17:35:10 +08:00
aofee 96884dd1ae fix(openai-ws): isolate HTTP bridge connection state 2026-09-06 17:05:06 +08:00
a1gaoshanlaiyige 0b061c5fa1 fix(gateway): use JSON escaping for message cache promotion
Use encoding/json instead of Go string quoting when promoting message content to cached text blocks. Add regression coverage for control characters, Unicode, literal backslashes, text preservation, and idempotence.
2026-09-06 16:42:57 +08:00
qiu121 958a21ad6e fix(billing): 修复 DeepSeek 高峰账号成本少计
- 将两个用量记录入口的请求级定价时刻传递给账号成本统计,使跨峰谷请求的成本与客户计费采用同一时间基准。
- 默认目录成本改用统一计费入口并隔离分组与渠道售价,使 DeepSeek 高峰成本正确应用两倍价格,同时保留服务层级、长上下文和单次推理等级倍率。
- 增加 Flash 与 Pro 的峰谷边界、缓存用量、规则优先级及真实网关回归测试,确保账号成本按最终上游模型计算且客户扣款金额与次数保持一致。

验证:Go 1.27.0 下全量 unit 与 integration 测试命令通过;golangci-lint 2.13.0 报告 0 个问题。定向回归无失败或跳过,全量套件保留原有条件跳过项。
2026-09-06 15:15:21 +08:00
suixincrazy 28167bbf85 fix(openai): honor model_routing for Codex manifests and scheduling
An OpenAI group that keeps a failover account for a shared public alias
produced a Codex model descriptor with no reasoning levels and text-only
input, which leaves the client's reasoning-effort slider with nothing to
select.

Two accounts explicitly mapping one alias to different upstream models
makes codexExplicitModelTargetsConflictForPlatform true. With no
upstream_model_metadata snapshot to intersect, groupCodexModelMetadata
short-circuits to {reasoningConflict, inputModalitiesConflict}, so
DefaultReasoningLevel is nil (the key vanishes under omitempty) and
SupportedReasoningLevels is an empty array. mergeMissingCodexModelFields
fills only missing-or-null fields, so an empty array is never repaired.

Failing closed is right when the alias target is genuinely ambiguous, but
a group that declares model_routing for the alias has already resolved
that ambiguity: the operator stated which accounts serve it and which is
primary. Treating that as a conflict rejects a deliberate failover setup.

Manifest side: when a routing rule names the alias, restrict capability
declaration to the routed accounts and skip the conflict override.
Capabilities then fall back to the same by-name derivation used for a
single account without a metadata snapshot. Groups with no rule, a
disabled rule, or a rule for another alias keep failing closed.

Scheduling side: model_routing was stored and read platform-agnostically
(admin_group.go, Group.GetRoutingAccountIDs) but four gates in
gateway_scheduling.go only consulted it for Anthropic, so an OpenAI
group's rules were accepted and displayed yet never honored. Without
this, the manifest would honor a rule that scheduling ignores. Both
gates now share one predicate, with the allowed platform set defined in
a single place.

Selection inside a routing list is unchanged: the list is a membership
set, and the winner is still decided by Priority, then load, then
LastUsedAt.

Tests: alias-failover manifest recovery plus the three negative cases,
the platform predicate table, and an OpenAI routed selection that must
beat a higher-priority unrouted account.
2026-09-06 10:12:20 +08:00
Chenning-Tao 985428d9d2 fix(openai): preserve named standalone Responses inputs 2026-09-06 09:33:53 +08:00
wucm667 4a4fd35e0c fix(simple-mode): guard non-empty group deletion 2026-09-06 08:11:23 +08:00
wucm667 f46038820b fix(simple-mode): enforce group capability allowlist 2026-09-06 08:11:23 +08:00
wucm667 05ad6b49a1 fix(simple-mode): enforce basic group capabilities 2026-09-06 08:10:41 +08:00
wucm667 a3675552bd feat(simple-mode): allow basic account grouping 2026-09-06 08:10:41 +08:00
Steven Moder b3ad9b67cf fix(i18n): fail builds on missing locale keys 2026-09-06 04:16:06 +08:00
cyh f49a393566 fix(openai): retain default models in mixed account catalogs 2026-09-06 02:02:49 +08:00
DAlaoli 8a46947865 Fix stale official Astra input modalities 2026-09-06 00:50:01 +08:00
LynricsyandWine Fox 7ae0312091 fix(claude): 🐛 补齐 thinking block binding beta
Co-authored-by: Wine Fox <fox@ling.plus>
2026-09-06 00:34:05 +08:00
WUJI-Labs 58e35a4f32 fix(antigravity): always set toolConfig for reasoning models without tools
Upstream returns 400 INVALID_ARGUMENT when toolConfig is absent. The guard
`if !isReasoning || len(tools) > 0` skipped it for reasoning models called
without tools, contradicting the intent stated in the comment above it.
Set toolConfig unconditionally and cover the three cases with a unit test.
2026-09-06 00:25:21 +08:00
TT 95023e7d49 fix(backup): serialize pg_dump/psql with the migrations advisory lock 2026-09-06 00:11:56 +08:00
TT 7a70de401c fix(payment): isolate fulfillment from redeem limits
Payment and admin fulfillment shared the public Redeem entry point, so
fulfillment retries were blocked by, and contributed to, the user's
public redeem failure counter, and lookup errors were silently treated as
"code does not exist" on a partial re-run.

- resolveRedeemAction now distinguishes a genuine not-found lookup from
  other lookup errors (which now fail the fulfillment instead of being
  swallowed and re-created).
- Add validatePaymentRedeemCode: before reusing an existing code during
  fulfillment, verify it belongs to the order (code id, balance type,
  amount, user, status), so a stale or foreign code can never be
  credited against the wrong order/user.
- Split Redeem into a policy-based core: public redemption enforces the
  failure counter; payment fulfillment (redeemForPaymentFulfillment) and
  admin fulfillment (RedeemForAdminFulfillment) bypass it, while all code
  validation and transactional updates remain identical. Admin
  fulfillment keeps the normal redeem-level affiliate rebate.
- The admin CreateAndRedeem endpoint now uses RedeemForAdminFulfillment.

Tests: updated resolveRedeemAction cases, new TestValidatePaymentRedeemCode,
code-used-by-another-user rejection, payment/admin rate-limit bypass
coverage, and public-path regression coverage.
2026-09-05 23:56:59 +08:00
TT 666a5c6f87 fix(redeem): use a fixed 10-minute failure window
The redeem failure counter used INCR followed by Expire(24h) on every
attempt, so each failed attempt refreshed the key TTL and the window
never actually closed for a user who kept retrying within 24h.

- Count attempts in a fixed 10-minute window via a small Lua script
  (INCR, then PEXPIRE only when the key is created or has no TTL), which
  is atomic and also repairs keys that lost their TTL.
- Move the counter to the redeem:ratelimit:v2: key space so pre-existing
  24h counters do not seed the new window.
- Rename the service threshold to redeemMaxFailedAttempts (30) to reflect
  that it bounds attempts per window, not per hour.

Public redeem behavior is unchanged apart from the window semantics:
the check fails open when the counter read fails, and a failed
increment never masks the underlying domain error.
2026-09-05 23:38:50 +08:00
TT c7343d2aa2 fix(monitors): send explicit user role in Gemini monitor probes
The Antigravity native Gemini route (generateContent) expects each
contents entry to carry an explicit "user" role, matching the request
shape used by the established Antigravity account-test flow. Without it
the probe body is rejected with 400 INVALID_ARGUMENT, so Gemini channel
monitors report false failures.

Add the explicit role to the Gemini provider adapter body and cover it
with a regression test that parses the generated request body.
2026-09-05 23:30:45 +08:00
TT a6430bfe20 fix(antigravity): refresh default user agent version
The Antigravity service fingerprints clients by the version embedded in the User-Agent header. The built-in default (1.23.2) had drifted far behind current real client builds, so requests using the default UA started failing OAuth and probe flows.

Bump DefaultUserAgentVersion from 1.23.2 to 2.9.1 (current client build) and update the constant test accordingly. The default remains overridable via ANTIGRAVITY_USER_AGENT_VERSION and the admin setting.
2026-09-05 23:28:33 +08:00
TT 32bf3d0f33 fix(settings): describe Grok cross-client mapping default as enabled
The Grok cross-client model mapping is enabled by default: InitializeDefaultSettings writes "true", and the parser treats a missing/empty value as enabled ("Default true (missing/empty -> enabled)"). However the comment above the defaults and the admin settings UI hint (EN and ZH) both claimed "disabled by default", contradicting the actual behavior.

Align the comment and both UI hints with the real default. No behavior change.
2026-09-05 23:17:34 +08:00
Zero Clover cb31033972 feat(openai): reuse pinned accounts for standard model discovery 2026-09-05 19:58:47 +08:00
chihao.ou 3aeab296d8 fix(onboarding): remove button text shadows 2026-09-05 19:55:03 +08:00
Zero Clover f3bbb95310 fix(groups): react immediately to Codex manifest edits and unify dialog toggles 2026-09-05 19:46:25 +08:00
chihao.ou 00eabe8ab9 feat(accounts): add month and year expiry presets 2026-09-05 19:20:10 +08:00
chihao.ou a16070ccf6 fix(auth): show a loading placeholder for Turnstile 2026-09-05 18:25:51 +08:00
github-actions[bot] ab99d56e96 chore: sync VERSION to 0.2.1 [skip ci] 2026-09-05 09:45:17 +00:00
Robert Guo b1ce821c49 fix(frontend): preserve Codex manifest edit state 2026-09-05 17:18:01 +08:00
Wesley Liddick 578785ee7f Merge pull request #6640 from cyhhao/fix/claude-billing-fingerprint
fix(claude): align billing fingerprint with outbound User-Agent
2026-09-05 16:59:33 +08:00
Wesley Liddick ed7c8f2208 Merge pull request #6572 from lyy0709/codex/gpt-6-astra
feat(openai): complete GPT-6 Astra support
2026-09-05 16:50:40 +08:00
cyh 2e1c7c00a6 test(claude): check request body close errors 2026-09-05 16:43:54 +08:00
cyh 994ca26e92 fix(claude): align billing fingerprint with outbound user agent 2026-09-05 16:34:00 +08:00
Wesley Liddick d2f87c612e Merge pull request #6638 from feeeei/feat/images-url-to-b64json-backfill
feat(images): 账户开关将生图结果 url 下载回填为 b64_json
2026-09-05 16:24:40 +08:00
lyy0709 62cd63bfda fix(openai): retain capabilities when model listing is unavailable 2026-09-05 16:24:15 +08:00
lyy0709 dee35a841d merge: reconcile Astra fixes with upstream capability sync 2026-09-05 16:19:57 +08:00
Wesley Liddick fb44e2dd09 Merge pull request #6514 from StarryKira/codex/anthropic-reasoning-pricing
feat: add Anthropic reasoning effort and Fable 5.1 max pricing
2026-09-05 16:19:18 +08:00
Wesley Liddick c42d78e26c Merge pull request #6571 from DuskLin/feat/codex-ultrafast-tier
feat(codex): support ultrafast service tier
2026-09-05 16:18:16 +08:00
Wesley Liddick 2dc287f2e0 Merge pull request #6636 from specialpointcentral/cyber-ws-recording-fix
fix(openai): record WS cyber policy failures across paths
2026-09-05 16:17:33 +08:00
Wesley Liddick 83094abf21 Merge pull request #6536 from wucm667/fix/issue-6526-mapped-model-scheduling
fix(openai): schedule accounts with mapped model
2026-09-05 16:17:04 +08:00
Wesley Liddick aafe93b335 Merge pull request #6557 from Ritel-T/pr/compact-admin-account-list
perf(admin): return compact account lists for lite requests
2026-09-05 16:16:26 +08:00
Wesley Liddick cc52c93ad7 Merge pull request #6539 from creamtea47/codex/fix-delegation-bootstrap-resume
fix(openai): 兼容历史任务委派续聊
2026-09-05 16:16:00 +08:00
lyy0709 d7f048a265 fix(openai): preserve synced Astra capabilities and continuation 2026-09-05 16:13:28 +08:00
Zero Clover 55c5eed9fc test(repository): persist model allowlists in group fixtures 2026-09-05 16:11:46 +08:00
Wesley Liddick 0d9e7e1530 Merge pull request #6628 from baryon/contrib/openai-gpt6-astra-capability-sync
fix(openai): persist GPT-6 Astra Codex capabilities from upstream sync
2026-09-05 16:07:50 +08:00
Wesley Liddick 9b4fcfb897 Merge pull request #6606 from luckydududu/feat/claude-cli-version-env-override
feat(claude): allow operators to override the advertised CLI version
2026-09-05 16:05:33 +08:00
Zero Clover cff3f89850 feat(groups)!: enforce model allowlists across gateway endpoints 2026-09-05 16:05:17 +08:00
Wesley Liddick 9517f12cdd Merge pull request #6510 from wall-wxk/fix/release-session-slot-on-forward-failure
fix(gateway): 转发失败的请求立即释放会话槽,不再占用整个空闲超时窗口
2026-09-05 16:05:06 +08:00
Wesley Liddick 40f4d450be Merge pull request #6507 from wucm667/fix/issue-6501-glm53-thinking-effort
fix(gateway): normalize GLM-5.3 thinking effort
2026-09-05 16:04:16 +08:00
Wesley Liddick 620eb3fd00 Merge pull request #6581 from r266-tech/r266/forward-opencode-session
fix(openai): scope OpenCode session forwarding
2026-09-05 16:03:57 +08:00
Wesley Liddick c6ff69fd12 Merge pull request #6542 from wucm667/fix/issue-6530-gateway-concurrency-origin
fix(handler): distinguish gateway admission rejections
2026-09-05 16:03:17 +08:00
Wesley Liddick 7b271bbee9 Merge pull request #6529 from wucm667/fix/issue-6521-preserve-passthrough-reasoning
fix(openai): preserve reasoning effort in API-key passthrough
2026-09-05 16:02:56 +08:00
Wesley Liddick 68f099707e Merge pull request #6553 from wucm667/fix/issue-6547-heartbeat-bootstrap
fix(openai): normalize automation heartbeat bootstrap
2026-09-05 16:02:36 +08:00
Varrge 3bdb869fca fix(billing): resolve Gemini 3.7/3.8 Flash thinking-tier prices 2026-09-05 15:54:38 +08:00
feeeei c227863d51 fix(images): 生图 url 回填下载拒绝私网目的地并只信字节嗅探
账户开关 images_url_to_b64_json 下载的图片 url 来自上游响应,是不可信输入。此前沿用
base_url 的 security.url_allowlist 策略,默认配置下只做格式校验,上游返回
localhost:8080 之类地址时网关会真的去请求内网。

- 下载 URL 主机为 localhost、回环、私网、链路本地、未指定地址的字面量时直接拒绝,
  不受 security.url_allowlist 配置影响(urlvalidator 导出 IsBlockedHost)
- 新增 ctx 标记 WithHTTPUpstreamPublicHostsOnly:共享上游客户端对带标记的请求无视配置
  校验解析后 IP,并为其克隆客户端挂 redirectChecker,重定向保持跟随但每一跳都校验
- 下载内容改为只信 http.DetectContentType 嗅探结果且限 png/jpeg/webp/gif,
  响应头不再作为依据
- 下载失败仍只记日志并保留原样;超时与端口策略不变
2026-09-05 15:50:56 +08:00
lyy0709 3a8c9f16de Merge remote-tracking branch 'upstream/main' into codex/gpt-6-astra 2026-09-05 15:48:49 +08:00
Qi HU 2da31290a1 fix(openai): record WS cyber policy failures across paths
Cyber-policy detection only inspected response.failed, and the ingress check ran after error-specific early returns. As a result, error-shaped failures and the connection-pool, HTTP-bridge, and WSv2 paths could miss cyber-policy hits.

Centralize event marking and invoke it before retry and failover returns across the WebSocket implementations. Preserve the recorded guard during account failover and defer connection blocking until the logical failover chain completes, avoiding duplicate records while ensuring subsequent turns are blocked.
2026-09-05 15:32:29 +08:00
feeeei 3c53ba01a7 feat(images): 账户开关将生图结果 url 下载回填为 b64_json
部分第三方 GPT-image 上游把 Images 响应里的 b64_json 换成 url,按官方接口实现的
客户端读不到图。账户 extra.images_url_to_b64_json 开启后,OpenAI API Key 的
Images 非流式响应里 b64_json 缺失或为空且 url 非空的项,由网关下载 url 内容并以
base64 回填 b64_json,url 与其余字段原样保留;异步生图任务复用同一路径。

- 下载经账户代理发起,沿用 base_url 的出站 URL 校验策略(validateUpstreamBaseURL
  抽出 validateOutboundURL,nil cfg 按仅格式校验处理),单图上限 20MB 且须为图片
  内容(响应头或字节嗅探);data: URL 直接取 base64 载荷
- 单项下载失败仅记日志并保留原样;客户端显式 response_format=url 时不回填;
  流式响应不处理
- 创建/编辑账户弹窗在 OpenAI API Key 的 Responses 模式块后新增“生图结果 URL 转
  base64”开关,中英文案;新增 openai_images_b64_backfill_test.go
- 只被 unit 标签测试引用的定价目录测试辅助补上 //go:build unit(admin 包整文件加标签,
  service 包的 newPricingServiceFromModelPricing 搬到 pricing_stub_helpers_unit_test.go),
  消除 golangci-lint 在默认标签下的 unused 报错
2026-09-05 15:17:57 +08:00
Wesley Liddick 07bf8b92fd Merge pull request #6550 from weiness/fix/alipay-payment-reconciliation
修复支付宝回调延迟或丢失,已支付订单可能长时间停留在待支付状态的问题
2026-09-05 15:08:56 +08:00
Wesley Liddick 63dc24b5e8 Merge pull request #6626 from wucm667/feat/issue-6624-none-reasoning-mapping
feat(openai): allow none as reasoning mapping source
2026-09-05 15:06:32 +08:00
Wesley Liddick 8f1d6af3ed Merge pull request #6531 from wucm667/fix/issue-6524-continuation-unavailable-recovery
fix(openai): recover unavailable continuation responses
2026-09-05 15:06:02 +08:00
Wesley Liddick 85f431a072 Merge pull request #6620 from wucm667/fix/issue-6615-astra-messages-cache
fix(openai): enable Astra messages prompt caching
2026-09-05 15:04:13 +08:00
Wesley Liddick d7fb9b5d35 Merge pull request #6593 from wucm667/fix/issue-6584-chat-tool-discovery
fix(apicompat): preserve discovered tools in chat fallback
2026-09-05 15:03:52 +08:00
Wesley Liddick eba6ea5639 Merge pull request #6590 from wucm667/fix/issue-6582-search-tool-capability
fix(codex): advertise search capability for bridged routes
2026-09-05 15:03:31 +08:00
Wesley Liddick 2ad6e8fb75 Merge pull request #6599 from wucm667/fix/issue-6598-gemini-custom-models-list
fix(gemini): apply custom list to native models endpoint
2026-09-05 15:03:08 +08:00
Wesley Liddick 570bd084c2 Merge pull request #6594 from wucm667/fix/issue-6592-preserve-zero-system-metrics
fix(ops): preserve zero-valued system metrics
2026-09-05 15:02:40 +08:00
Wesley Liddick 15278fe2f9 Merge pull request #6580 from OG-Wang/fix/sidebar-collapse-when-active
fix(frontend): allow collapsing sidebar groups while a child route is active
2026-09-05 15:02:30 +08:00
Wesley Liddick c4e6dcfd80 Merge pull request #6602 from ZeroClover/feat/codex-manifest-pinned-accounts
feat(codex): pinned-accounts Codex model manifest for OpenAI groups
2026-09-05 14:51:17 +08:00
Wesley Liddick 701f844755 Merge pull request #6555 from feeeei/feat/usage-upstream-request-id
feat(usage): 用量记录落库直接上游的请求 ID,并支持按账户配置响应头名
2026-09-05 14:36:07 +08:00
Wesley Liddick 91cf660377 Merge pull request #6397 from feeeei/fix/openai-ws-replay-oom-6382
fix(openai-ws): eliminate O(T²) replay copying and invalid_encrypted_content retry storms
2026-09-05 14:32:11 +08:00
Wesley Liddick 78e1aaedb9 Merge pull request #6535 from feeeei/feat/pricing-custom-file-hot-reload
feat(pricing): fallback/override 定价文件按内容哈希热重载,改动无需重启生效
2026-09-05 14:31:09 +08:00
shaw 708b85a6a4 fix: complete upstream request id handling 2026-09-05 14:18:13 +08:00
shaw 1cab4d8c22 fix: make custom pricing reload coherent 2026-09-05 14:18:13 +08:00
Wesley Liddick d8b9a83f8c Merge pull request #6492 from feeeei/main
fix(gateway): 负载感知调度补齐渠道模型限制的逐账号检查
2026-09-05 14:06:21 +08:00
Long LiandCursor a016bd4ba5 style: gofmt upstream models capability sync tests
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 14:58:51 +09:00
Long LiandCursor 983a3db3af fix(openai): persist GPT-6 Astra Codex capabilities from upstream sync
Official OpenAI hosts and models.dev entries without an api field left
Astra falling back to reasoning effort none and empty Fast tiers. Persist
complete per-model metadata, advertise Astra reasoning/Fast locally, and
surface partial sync warnings when image models remain incomplete.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-05 14:45:06 +09:00
wucm667 7969751f1f feat(openai): allow none as reasoning mapping source 2026-09-05 12:32:30 +08:00
David Stone 6271c517da fix(apicompat): preserve arguments from done events 2026-09-04 21:18:36 -06:00
wucm667 4aaf5cb73b fix(openai): enable Astra messages prompt caching 2026-09-05 10:58:05 +08:00
LuckyandClaude Opus 5 e9bad40b10 feat(claude): allow operators to override the advertised CLI version
Anthropic gates newer models on a minimum Claude Code client version. With
CLICurrentVersion pinned at 2.1.220, claude-fable-5-1 fails every request with
"Claude Code 2.1.220 does not support this model; version 2.1.251 or newer is
required", and the only fix is a release that bumps one constant.

Add SUB2API_CLAUDE_CLI_VERSION, mirroring what internal/pkg/xai already does
with XAI_GROK_CLI_VERSION. CLICurrentVersion remains the built-in pin; the new
CLIVersion() is what every consumer reads. With no env var set the resolved
value equals the pin, so existing deployments are unaffected.

The value is resolved once at package init rather than per call: the User-Agent
header and the request body's cc_version are written by different code paths for
the same request, and reading different values would make that request
contradict itself.

Validation is strict (three numeric segments, not below the pin) because
identity_service.go documents what a malformed version does once it reaches an
account's persistent fingerprint: sustained 429s without reset headers, 5s
fallback cooldown, pool collapse into a 503 storm, and no reset entry point.
Invalid values warn and fall back rather than being silently dropped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0168uDMAj9anaJrnH8CeFze6
2026-09-04 18:01:34 +00:00
Zero Clover 1e28ef5942 feat(codex): pinned-accounts Codex model manifest for OpenAI groups 2026-09-05 00:10:24 +08:00
wucm667 28f673e4cd fix(gemini): honor custom native models list 2026-09-04 22:29:36 +08:00
wucm667 9ddf69698d fix(ops): preserve zero-valued system metrics 2026-09-04 18:51:24 +08:00
wucm667 51631d0324 fix(apicompat): preserve discovered tools in chat fallback 2026-09-04 18:33:23 +08:00
wucm667 c7538cd256 fix(codex): advertise search capability for bridged routes 2026-09-04 16:43:15 +08:00
r266-tech 6cf645f87c fix(openai): scope OpenCode session forwarding 2026-09-04 12:45:06 +08:00
Rick aa556c372f fix(frontend): allow collapsing sidebar groups while a child route is active
A collapsible sidebar group was force-expanded whenever the active route
was one of its children, so clicking the chevron did nothing: the toggle
only updated the internal set while isGroupActive() kept reporting the
group as expanded. Any click that produces no visible change reads as a
bug to users.

Track per-group expand/collapse overrides instead. A group without an
entry keeps the automatic behavior (expanded while active, so context is
not lost on load or navigation), but a chevron click records the user's
choice and that choice wins, letting an active group be collapsed and
re-expanded at will.
2026-09-04 12:42:20 +08:00
刘佳林 126ac24c8c feat(codex): support ultrafast service tier
Accept service_tier=ultrafast on the OpenAI/Codex gateway, advertise it
on GPT-5.6 Sol in the Codex model catalog, and include it in ChatGPT
OAuth routing hints so Codex clients can select Ultrafast.
2026-09-04 10:14:29 +08:00
lyy0709 3c8be00131 feat(openai): add GPT-6 Astra support 2026-09-04 10:04:44 +08:00
wucm667 f5ae32e53f fix(gateway): normalize GLM-5.3 thinking effort 2026-09-04 08:15:41 +08:00
Ritel-T a3a6a85b73 test(admin): assert nested credential redaction in lite lists 2026-09-03 23:32:34 +09:00
Ritel-T db15e0090a fix: keep account table on compact list responses 2026-09-03 21:20:09 +09:00
Ritel-T 994192ef4f fix: type admin account list responses 2026-09-03 21:20:09 +09:00
Ritel-T 3e60c3b86b fix: add compact admin account list DTO 2026-09-03 21:20:08 +09:00
feeeei de27905e88 feat(usage): 用量记录落库上游请求标识并支持按账户配置响应头名
- usage_logs 新增 upstream_request_id 列(迁移 232)与仅索引非空行的 CONCURRENTLY 部分索引(迁移 233,runner 登记失效索引清理)
- 账户 extra.upstream_request_id_header 指定要读取的上游响应头,只读该头;留空不记录;创建/更新/批量入口校验头名合法且不超过 64 字节
- ForwardResult/OpenAIForwardResult 携带 UpstreamHeaders,中央计费按账户解析落库,WS 轮次保持 NULL
- 管理端用量明细新增默认隐藏的"上游ID"列,带与请求ID列一致的复制按钮,并同步导出
- 创建/编辑账户弹窗新增"上游ID"输入(UpstreamRequestIdHeaderField),提示按接入类型与平台分条列出常见头名
- HelpTooltip 悬停模式下指针移入提示框时保持打开,便于选中复制提示内容
2026-09-03 18:02:15 +08:00
wucm667 28dde982cb fix(openai): normalize automation heartbeat bootstrap 2026-09-03 17:29:04 +08:00
weiness de8d756afa fix(payment): reconcile pending Alipay orders 2026-09-03 16:33:30 +08:00
shaw b1748c4ea9 chore: update sponsors 2026-09-03 15:40:51 +08:00
Wesley Liddick 432ebc498c Merge pull request #6179 from wwcchh0123/codex/upstream-error-proxy-attribution
fix(ops): preserve event-time proxy attribution for upstream errors
2026-09-03 15:34:59 +08:00
Wei-Shaw abc07bb079 test(ops): satisfy errcheck and gofmt in proxy attribution tests 2026-09-03 15:23:22 +08:00
wucm667 78fc7b171e test: skip live token comparison on network errors 2026-09-03 15:16:18 +08:00
Wei-Shaw 4c1f920d58 fix(ops): close proxy attribution gaps and bound queued upstream events
Follow-up to the event-time proxy snapshot: every upstream attempt now
carries attribution, the direct/unknown contract is enforced everywhere,
and the queued upstream_errors array has hard bounds again.

Attribution coverage
- compact fallback retry helper and the streaming compact failover event
  now stamp proxy_id/proxy_name; the non-streaming SSE compact retry
  previously dropped its first attempt on `continue` and now records it
- raw chat-completions stream truncation events (new on main) attributed
- Anthropic/Bedrock transport errors get attribution inside the shared
  handleUpstreamTransportError helper, so all five callers inherit it
- OpenAI first-output timeout takes attribution from the caller: HTTP
  callers pass the HTTP rule, the WS passthrough relay passes the WS rule
  (no managed proxy => unknown, never direct, because http.DefaultClient
  may honor environment proxies)

Contract hardening
- single opsUpstreamProxyAttribution returns (id, name) from one decision;
  a hydrated Proxy without a durable ID is unknown, not id=null+name
- both normalizers (struct at append time, JSON on detail reads) enforce
  proxy_id==null <=> sentinel name; blank managed name => "proxy"
- detail-read normalization edits only the attribution keys via sjson, so
  keys from older struct versions and stored key order are preserved

Queue bounds
- keep at most 256 attempts and ~512 KiB serialized per entry, newest
  first; the oldest retained event records dropped_earlier_attempts
- attempts outside the 16-event body window trim url/message to 512 bytes
- restore the empty-event drop rule, judged before the body window clears
  detail so older real attempts survive

Tests cover each site above (managed proxy, direct, WS unknown), the
normalizer invariants, key/order preservation, and the count/byte bounds.
2026-09-03 15:11:36 +08:00
wwcchh0123 e9e3c46cb7 fix(ops): snapshot proxy on upstream errors 2026-09-03 14:20:39 +08:00
wucm667 62198286e9 [verified] fix(handler): distinguish gateway admission rejections 2026-09-03 12:48:02 +08:00
NellPoi af90a9bd1f fix(openai): 兼容历史任务委派续聊 2026-09-03 11:39:23 +08:00
wucm667 5e4958c889 fix(openai): schedule accounts with mapped model 2026-09-03 10:51:12 +08:00
feeeei fdc2ec17e6 feat(pricing): fallback/override 定价文件按内容哈希随定时器热重载
PricingService 的定时器(hash_check_interval_minutes,默认 10 分钟)每轮先做
远程目录哈希同步,再比对 fallback_file/override_file 两个文件内容的联合 sha256
(customFilesHash),变化时从本地 model_pricing.json 重建叠加层,只替换内存数据
与指纹,不改远程同步锚点 localHash。只配置了自定义文件而 remote_url 为空时
调度器同样启动。

- 三条重建路径(启动加载、远程下载、热重载)统一走 buildPricingData,指纹在合并
  读取之前采样,并发改文件只会落后于数据、下一轮补重建
- 删除文件视为该层清空,下一轮生效;文件存在但不可读或不是 JSON 对象时保留旧数据、
  不更新指纹,每轮重试并告警
- config.example.yaml 同步生效说明;新增 pricing_service_hot_reload_test.go
2026-09-03 10:35:20 +08:00
wucm667 b6740c937f fix(openai): recover unavailable continuation responses 2026-09-03 10:26:12 +08:00
wucm667 10c8f523c4 fix(openai): preserve passthrough reasoning effort 2026-09-03 09:42:50 +08:00
haruka 8249ab37db feat: add Anthropic reasoning effort pricing controls 2026-09-02 20:16:57 +08:00
wall-wxkandClaude Opus 4.6 9be9c0b68c fix(gateway): 转发失败的请求立即释放会话槽,不再占用整个空闲超时窗口
会话数量限制(max_sessions)此前只有空闲超时自动过期,没有任何主动释放
机制:请求在选号阶段注册会话后,即使转发失败(上游错误/客户端断开/换号
耗尽),该 session hash 也会占住槽位直到空闲超时(默认 5 分钟),导致
max_sessions 受限的账号在窗口内拒绝所有新会话。

修复:
- SessionLimitCache 新增 UnregisterSession(Redis ZREM),立即移除会话注册
- GatewayService 新增 ReleaseAccountSession,适用条件与 checkAndRegisterSession 对齐
- Messages 主路径:记录 failover 链上注册过会话槽的账号,请求最终失败时
  统一释放;failover continue / profit veto / 兜底分组切换时按次即时释放;
  成功及"上游已计量部分结果"(#5148)保持既有空闲超时语义
- CountTokens:转发失败时同样释放选号时注册的会话槽

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-09-02 18:45:20 +08:00
aofee a7f1ed8fa4 fix(openai-ws): track pending passthrough turns 2026-09-02 14:59:13 +08:00
feeeei 9ad3865690 fix(gateway): 负载感知调度补齐渠道模型限制的逐账号检查
渠道 billing_model_source=upstream 时,限制模型只能按账号映射后的上游模型
逐账号判定。GatewayService.SelectAccountWithLoadAwareness 的负载感知各层
(模型路由候选、路由内粘性 gate、无路由粘性 gate、负载感知候选及其兜底)
此前都没有这一判定,load_batch_enabled 默认开启,anthropic/gemini/antigravity
分组的渠道白名单实际不生效。

现在入口算一次 needsUpstreamChannelRestrictionCheck,各层共用同一判定;
候选因渠道限制筛空时记 warn 并返回带 channel pricing restriction 的
ErrNoAvailableAccounts,与预检路径一致。
2026-09-02 11:46:37 +08:00
github-actions[bot] 5097b31457 chore: sync VERSION to 0.2.0 [skip ci] 2026-09-02 03:24:57 +00:00
Wesley Liddick aa23648835 Merge pull request #6480 from Wei-Shaw/fix/group-model-pricing-ui
fix(frontend): 优化分组模型定价弹窗布局
2026-09-02 11:09:55 +08:00
aofee a774230661 fix(grok): keep inconclusive billing accounts eligible 2026-09-02 11:09:21 +08:00
aofee 222181efd6 fix(anthropic): scope Fable credits errors to model 2026-09-02 11:02:38 +08:00
shaw 1a33dc8cc4 fix(frontend): 优化分组模型定价弹窗布局 2026-09-02 10:40:21 +08:00
Wesley Liddick 3510aa22b5 Merge pull request #6425 from DuskLin/feat/openai-reasoning-effort-model-scope
feat(group): OpenAI 推理强度映射支持按模型名/前缀/后缀匹配
2026-09-02 10:18:07 +08:00
刘佳林 77729e2723 merge: resolve conflicts with upstream/main
Keep model-scoped reasoning effort mappings together with the
deny-or-downgrade ceiling control that landed on main.
2026-09-02 10:01:42 +08:00
shaw 05ea883e26 fix(ent): correct group field indexes after merge 2026-09-02 10:01:33 +08:00
Wesley Liddick 6566039bc8 Merge pull request #6470 from aofee/codex/fix-ws-passthrough-missing-terminal
fix(openai-ws): reject passthrough close before terminal event
2026-09-02 09:42:13 +08:00
Wesley Liddick e50bffb7e3 Merge pull request #6469 from wucm667/fix/issue-6465-apikey-chat-cache-identity
fix(openai): preserve API key chat cache identity
2026-09-02 09:25:02 +08:00
Wesley Liddick f2804eb2c8 Merge pull request #6444 from Ritel-T/pr/group-free-openai-fast
Add group-level Standard billing for OpenAI Fast requests
2026-09-02 09:23:49 +08:00
Wesley Liddick 559960865a Merge pull request #6447 from DuskLin/feat/openai-reasoning-effort-over-limit
feat(groups): 推理强度上限增加超限自动降档与拒绝访问
2026-09-02 09:07:53 +08:00
Wesley Liddick a4fb58e428 Merge pull request #6443 from Ritel-T/pr/group-openai-fast
Add group-level OpenAI Fast enforcement
2026-09-02 09:07:05 +08:00
Wesley Liddick a668aa8b36 Merge pull request #6460 from HuntercodeT/fix/model-not-found-not-overridden-by-rate-limit
fix(gateway): stop a per-model cooldown from turning 404 model_not_found into 429
2026-09-02 09:06:17 +08:00
Wesley Liddick 8d0b5ede28 Merge pull request #6458 from HuntercodeT/fix/scheduler-projection-keeps-openai-passthrough
fix(scheduler): keep OpenAI passthrough flag in the scheduler snapshot projection
2026-09-02 09:05:51 +08:00
Wesley Liddick 2786ae9de6 Merge pull request #6474 from StarryKira/codex/fix-6472-fable-5-1
feat: support Claude Fable 5.1 and split cache-write pricing
2026-09-02 09:05:29 +08:00
haruka 34b8bf1a6e feat: support Claude Fable 5.1 2026-09-02 05:47:01 +08:00
Ritel-T 52c7d88340 chore: merge upstream v0.1.185 2026-09-02 03:29:52 +09:00
Ritel-T 9e2d97f250 chore: merge upstream v0.1.185 2026-09-02 03:28:24 +09:00
aofee bfe0a5a873 fix(openai-ws): reject close before terminal event 2026-09-01 20:44:11 +08:00
wucm667 504919a054 fix(openai): preserve API key chat cache identity 2026-09-01 19:10:37 +08:00
Wesley Liddick 0d27f45ead Merge pull request #6454 from alfadb/fix/anthropic-fallbacks-sanitize
fix(gateway): 缺 server-side-fallback beta 时剥离 Anthropic fallbacks 字段
2026-09-01 17:47:34 +08:00
Wesley Liddick 65380be9c8 Merge pull request #6463 from DuskLin/feat/kimi-native-responses
feat(kimi): 原生转发 OpenAI Responses API
2026-09-01 17:46:36 +08:00
Wesley Liddick d596d0844f Merge pull request #6450 from WizardPro/codex/fix-scheduled-automation-bootstrap
fix(openai): accept scheduled automation bootstrap without call id
2026-09-01 17:30:04 +08:00
shaw cd04848b97 chore: update sponsors 2026-09-01 17:19:36 +08:00
刘佳林 e377c4358c feat(kimi): forward native OpenAI Responses API
Moonshot pay-as-you-go and Kimi For Coding both expose native
/v1/responses. Stop converting Kimi Responses traffic to Chat
Completions, and treat Kimi like DeepSeek for protocol selection while
keeping /v1/responses URL construction.
2026-09-01 16:50:04 +08:00
HuntercodeT 3438580213 fix(gateway): stop a per-model cooldown from turning 404 model_not_found into 429
classifySelectionFailureError upgrades a selection failure to 429
rate_limit_error whenever the scheduler's compact reason string matches
model_rate_limited=(\d+) with a positive count. It does so unconditionally,
including when the fallback it was handed is the authoritative 404
model_not_found classification.

Selection failure reasons are additive, so both can appear at once, e.g.
"pool=9, filtered: model_not_supported=8 model_rate_limited=1": eight accounts
cannot serve the model at all and the ninth happens to sit in a per-model
cooldown. classifyNoAccountError only produces ModelNotFound through
DiagnoseModelAvailabilityForPlatform, a dedicated database query over
persistent eligibility (active + schedulable + model_mapping), so it has
already established that no account in the group can serve the model. One
transient cooldown does not make "All available accounts are currently
rate-limited" true.

The mislabel is not cosmetic:

- Retrying can never succeed, yet 429 explicitly invites a retry.
- Clients that treat 429 as a rate limit retry hard and swallow the body;
  Codex surfaces only "exceeded retry limit, last status: 429", discarding the
  one message that names the real problem. Operators then look for capacity
  and rate limits instead of a model whitelist.
- Ops attribution becomes self-contradictory: classifyNoAccountErrorFromGin
  has already marked the request business-limited for local model
  configuration, while call sites gate markOpsRoutingCapacityLimitedIfNoAvailable
  on ModelNotFound, which the override clears, so the same request is also
  counted as routing capacity limited.

Return the fallback untouched when it is ModelNotFound. The 429 upgrade is
preserved for every other fallback, which is the case it was written for:
accounts that can serve the model exist but are all cooling down, where the
fallback is 503 and retrying is meaningful.

Claude-Session: https://claude.ai/code/session_01LwvyJVV2sdqiu41Z9JDNUq
2026-09-01 15:18:30 +08:00
HuntercodeT e93e6368fc fix(scheduler): keep OpenAI passthrough flag in the scheduler snapshot projection
filterSchedulerExtra is an allowlist and never carried openai_passthrough /
openai_oauth_passthrough, while credentials.model_mapping is preserved in full.
Candidate filtering (ListSchedulableAccounts) reads that trimmed projection
(sched:meta:<id>), so there a passthrough account looks exactly like a
whitelist-mode one: Account.IsModelSupported falls back to the model_mapping
whitelist and the account is dropped as model_not_supported. Forwarding, which
hydrates the full account from sched:acc:<id>, still honors passthrough - hence
"testing the account directly succeeds, but the gateway reports no available
accounts supporting model X".

The practical impact is that a stale leftover whitelist silently shrinks the
candidate pool of accounts that were explicitly configured to let the upstream
decide. When no account in the group lists the requested model, the group is
fully dark for it even though every account can serve it.

This is not a regression of #4936: that fix corrected the predicate
(Account.IsModelSupported short-circuits passthrough before the mapping check),
this one restores the input the predicate is fed. The projection allowlist
predates #4936 by three and a half months and has never carried the flag, so
the fix simply never took effect on the snapshot path.

Add both keys to the allowlist, plus a regression test that round-trips the
projection through JSON (the real sched:meta write/read path) and asserts the
model gate still lets a non-whitelisted model through for a passthrough account.

Claude-Session: https://claude.ai/code/session_01LwvyJVV2sdqiu41Z9JDNUq
2026-09-01 15:13:11 +08:00
alfadb 200b1406d7 fix(gateway): strip Anthropic fallbacks unless server-side-fallback beta is present
Clients now send body.fallbacks; upstream rejects it without
anthropic-beta: server-side-fallback-2026-07-01. Mirror the
context_management sanitize so OAuth mimic / default API-key
beta paths no longer 400 with "fallbacks: Extra inputs are not permitted".
2026-09-01 14:23:33 +08:00
WizardPro 421a83282a fix(openai): accept scheduled automation bootstrap without call id 2026-09-01 11:36:26 +08:00
github-actions[bot] a2fb09260a chore: sync VERSION to 0.1.185 [skip ci] 2026-09-01 02:52:28 +00:00
刘佳林 aa7a811e66 feat(groups): add deny-or-downgrade control for reasoning effort ceiling
OpenAI/Composite groups can now reject requests that exceed the max
reasoning effort instead of always rewriting them down to the ceiling.
Existing groups keep the previous auto-downgrade behavior.
2026-09-01 10:15:45 +08:00
Wesley Liddick 2ac784c51a Merge pull request #6353 from feeeei/main
refactor(billing): 长上下文阶梯计价改为价格目录数据驱动
2026-09-01 09:32:06 +08:00
Wesley Liddick 6b39d0b453 Merge pull request #6379 from baryon/contrib/codex-priority-service-tier
fix(codex): advertise Fast service tier in generated catalogs
2026-09-01 09:02:24 +08:00
Wesley Liddick f218c8d40a Merge pull request #6394 from qiujun8023/fix/account-stats-deepseek-peak
fix(billing): apply model pricing policies to account stats cost
2026-09-01 08:58:49 +08:00
Wesley Liddick 1cce2b38ee Merge pull request #6417 from fengshao1227/fix/ws-ingress-capacity-shed-client-code
fix(openai): ctx_pool WS ingress 下发前改写上游容量降载错误码(Codex 会话被硬终止)
2026-09-01 08:54:00 +08:00
Wesley Liddick 9d9ed1cc62 Merge pull request #6415 from icebreaker-forked/fix/retry-transient-db-startup
fix(repository): PostgreSQL 恢复期间退避重试,避免首次启动退出
2026-09-01 08:53:25 +08:00
Wesley Liddick 2d7767eaf2 Merge pull request #6407 from wucm667/fix/issue-6402-delegation-bootstrap
fix(openai): accept delegation bootstrap without call id
2026-09-01 08:51:46 +08:00
Wesley Liddick 064d9b74e8 Merge pull request #6432 from okbexx/fix/codex-disabled-account-capabilities
fix(codex): ignore persistently disabled catalog accounts
2026-09-01 08:46:42 +08:00
Wesley Liddick e87c47c957 Merge pull request #6435 from wucm667/fix/issue-6420-apikey-responses-instructions
fix(openai): avoid synthesized instructions for API keys
2026-09-01 08:45:58 +08:00
Wesley Liddick bdedd6c544 Merge pull request #6433 from swjturay/codex/upstream-stale-ws-recycle
fix(openai-ws): recycle stale idle pool connections without reader liveness
2026-09-01 08:45:30 +08:00
wucm667 5fb5dfb34c fix(openai): reacquire passthrough turn slots 2026-09-01 08:13:29 +08:00
Ritel-T 9908d3ca2f Bill free Fast requests at Standard price 2026-09-01 04:34:24 +09:00
Ritel-T 4df6b0636b Add the free Fast group switch 2026-09-01 04:34:24 +09:00
Ritel-T 498e06c582 Localize the free Fast control 2026-09-01 04:34:24 +09:00
Ritel-T e6722126ba Add frontend free Fast types 2026-09-01 04:34:24 +09:00
Ritel-T e619ca386e Carry free Fast through auth snapshots 2026-09-01 04:34:24 +09:00
Ritel-T 8224434d21 Preserve free Fast when duplicating groups 2026-09-01 04:34:24 +09:00
Ritel-T fd55f3248f Manage free Fast through group lifecycle 2026-09-01 04:34:24 +09:00
Ritel-T 08d6c153b7 Return free Fast in admin responses 2026-09-01 04:34:24 +09:00
Ritel-T 636d6be690 Expose free Fast in admin requests 2026-09-01 04:34:24 +09:00
Ritel-T 7dcf728461 Project free Fast into API key auth 2026-09-01 04:34:24 +09:00
Ritel-T fb409787c6 Persist the free Fast group policy 2026-09-01 04:34:24 +09:00
Ritel-T e7caedc22a Regenerate Group persistence for free Fast 2026-09-01 04:34:24 +09:00
Ritel-T eebf4e3ffb Add the free Fast migration 2026-09-01 04:34:23 +09:00
Ritel-T c7cf84ad39 Preserve Group Fast in integration fixtures 2026-09-01 04:34:06 +09:00
Ritel-T 0681aa2566 Cover Group Fast in native Responses 2026-09-01 04:34:06 +09:00
Ritel-T 17747df842 Add the group Fast admin switch 2026-09-01 04:34:06 +09:00
Ritel-T 3fbce499d8 Add group Fast form helpers 2026-09-01 04:34:06 +09:00
Ritel-T 1a40e5690f Localize the group Fast control 2026-09-01 04:34:06 +09:00
Ritel-T 7903716ad1 Add frontend group Fast types 2026-09-01 04:34:06 +09:00
Ritel-T f8eae45048 Force OpenAI Fast across gateway paths 2026-09-01 04:34:06 +09:00
Ritel-T a44a1019f8 Carry group Fast through auth snapshots 2026-09-01 04:34:06 +09:00
Ritel-T 9fba8ed43b Return group Fast in admin responses 2026-09-01 04:34:06 +09:00
Ritel-T 7f5e915dd1 Expose group Fast in admin requests 2026-09-01 04:34:06 +09:00
Ritel-T 7e06816571 Preserve group Fast when duplicating groups 2026-09-01 04:34:06 +09:00
Ritel-T e9e66da5a8 Manage group Fast through group lifecycle 2026-09-01 04:34:06 +09:00
Ritel-T 94bb9354ce Accept group Fast in admin service inputs 2026-09-01 04:34:06 +09:00
Ritel-T 95982a508f Project group Fast into API key auth 2026-09-01 04:34:06 +09:00
Ritel-T 563979e91e Persist the group Fast setting 2026-09-01 04:34:06 +09:00
Ritel-T 1cb28fd429 Regenerate Group persistence for Fast mode 2026-09-01 04:34:06 +09:00
Ritel-T aa679efe41 Add the group Fast migration 2026-09-01 04:19:38 +09:00
feeeei e2cfaa46e7 fix(pricing): 目录 cache 侧字段契约哨兵与 Gemini pro 缓存写入价修正
阶梯计价数据驱动后,目录条目的字段缺失或版本拼接会直接变成计费偏差,
解析层补两道数据契约哨兵,并修正出厂快照中 Gemini pro 系的缓存写入价:

- orphanCacheTierFields / warnOrphanCacheTierFields:条目带 cache 侧
  above 档(含 _priority/_flex 变体与 above_1hr 形态)却没有基础价时打
  WARN——cache 侧 above 档不参与计费取值,按"基础价 × 输入倍率"计,基础价
  缺失即该分项按 0 计;变体缺自身基础价时沿 变体→1h→标准 回落链任一存在
  即不算孤儿,与计费回落一致
- isLopsidedLongContextLadder / warnLopsidedLongContextLadders:above 档
  折算出只有一侧带附加费的阶梯时打 WARN(基础价与 above 档来自不同价格
  版本的形态),显式 long_context_* 字段不告警
- deriveLongContextFromAboveTierFields 注释改为陈述契约:cache above 档
  须恰为基础价 × 输入倍率
- 出厂快照 6 个 Gemini pro 条目补 cache_creation_input_token_cost(= 标准
  输入价,Google 对缓存写入不另收费)、above_200k 改为输入 above 价、带
  priority 输入价的 4 条补 priority 孪生,与 model-price-repo 数据修正同值

测试:
- 解析层:孤儿字段 WARN(含 priority/1h 孤儿正例、1h 回落 5m 反例)、单侧
  阶梯 WARN、出厂快照契约(全条目无孤儿字段、无单侧阶梯、Gemini pro 缓存
  写入价 = 输入价)
- 用量契约:extractGeminiUsage 与 Antigravity 非流式/流式转换均不产生
  cache_creation token(Gemini usageMetadata 无该类别)
- 计费:gemini-2.5-pro 阶梯对 cache_read / cache_creation 分项生效,阈值
  计入缓存写入 token;阶梯表场景与夹具同步为现行目录值
2026-09-01 01:26:55 +08:00
feeeei 593fc93659 feat(pricing): 新增 override_file 价格目录覆盖补丁
新增 pricing.override_file 配置(默认关闭):稀疏补丁文件按 JSON 字段
浅合并覆盖目录/回退数据,作为最高优先级数据源,用于本地自定义官方
目录条目(如显式 long_context_input_token_threshold=0 关闭某模型阶梯)
而无需自建价格镜像:

- applyPricingOverrides 挂在 parsePricingData 解析入口,只修补已存在
  条目(补丁字段覆盖同名字段,值为 null 删除字段);目录、回退与灾备
  路径都经该入口,覆盖语义三处一致
- mergeOverrideOnlyModels 在回退合并后把目录/回退都没有的模型作为
  独立条目并入(复用主解析路径,须自带价格字段通过有效性过滤);
  不在主解析时抢先建条目,避免纯补丁挡住回退完整条目、其余分项价
  静默变 0
- 最终未生效的条目(模型名拼错、纯补丁落在不存在的模型上)打 WARN
  哨兵;override 文件缺失/损坏仅跳过合并,不影响目录加载
- 生效时机为重启或下次目录下载,与 fallback_file 一致;fallback_file
  只补缺失模型的语义不变
2026-09-01 01:26:55 +08:00
feeeei 530fb20f25 refactor(billing): 长上下文阶梯计价改为价格目录数据驱动
删除 GPT/Gemini 系列写死的阶梯规则,阶梯一律由目录数据驱动:

- 解析层折算 LiteLLM 的 *_above_XXXk_tokens 绝对价字段为阈值+倍率
  (阈值取自字段名,倍率 = above/基础价,cache 侧跟随输入倍率,
  xAI 按 provider 用达到即进高档语义);显式 long_context_* 字段
  按"字段存在"优先,显式 threshold=0 或 multiplier=1 可关闭阶梯
- 删除 OpenAI 272K 常量与 applyModelSpecificPricingPolicy 的长上下文
  强补分支、fallbackPrices/静态兜底条目的 LongContext 字段、Gemini 旧
  边际规则全链路(CalculateCostWithLongContext / RecordUsageWithLongContext /
  阶梯表 marginal 基准);保留 fastRatio 与 GPT-5.6 cache_write 1.25x
  防御分支、grok 静态兜底阶梯
- 渠道配置定价区间时完全以渠道区间为准,不再叠加目录阶梯
- 长上下文倍率 ≤0 在应用点按 1 计,防止显式字段残缺把分项乘 0 免费
- 目录重载时对比新旧数据,原有阶梯丢失打 WARN 哨兵告警

行为变化:Gemini 由边际 2x(仅 /v1beta、全模型)改为整单换档(数据
驱动、仅带 above_200k 的 pro 系),渠道平价之上叠加目录阶梯;Claude
sonnet-4/4.5 随目录数据获得 200K 整单阶梯(与官方定价一致);模型广场
官方参考价列与实付列同源展示目录阶梯。
2026-09-01 01:26:55 +08:00
Wesley Liddick 200602b41b Merge pull request #6438 from Brisbanehuang/fix/codex-custom-provider-image-input
fix(codex): preserve known GPT image input capabilities
2026-08-31 21:57:09 +08:00
Brisbanehuang e2624fb65c fix(codex): preserve known image input capabilities 2026-08-31 09:42:58 -04:00
shaw cc6a8e5174 chore: update sponsors 2026-08-31 21:29:13 +08:00
Ray 6d5f027848 fix(openai-ws): recycle stale idle pool connections 2026-08-31 21:04:29 +08:00
wucm667 e21b849a92 fix(openai): avoid synthesized instructions for API keys 2026-08-31 20:58:49 +08:00
Jarl ba345f1052 fix(codex): ignore persistently disabled catalog accounts 2026-08-31 20:26:29 +08:00
spin6lock c74a4f521c fix(billing): 补上 glm-5.3 / glm-5.3-flash 兜底价,避免被 glm-5 子串匹配抢走 2026-08-31 19:51:50 +08:00
刘佳林 7c01ec9be8 feat(groups): scope OpenAI reasoning effort mappings by model
Allow OpenAI/Composite group mappings to match by exact model name,
prefix, or suffix. Empty type and model still apply to every model.
The admin form groups multiple request-to-forward pairs under one
type+model scope.
2026-08-31 19:21:31 +08:00
github-actions[bot] 52374af940 chore: sync VERSION to 0.1.184 [skip ci] 2026-08-31 09:28:15 +00:00
ice breaker b6a7b8b7df fix(repository): allow eight transient database retries 2026-08-31 17:10:31 +08:00
li 1dc0a09009 fix(openai): ctx_pool WS ingress 下发前改写上游容量降载错误码
上游容量降载(server_is_overloaded / slow_down)被 ctx_pool 的 ingress 直写路径
原样转发给客户端。Codex CLI 按闭集判定,这两个码属致命集,客户端打印
"Selected model is at capacity" 并直接终止会话,不退避重试。

仓库已有 sanitizeOpenAICapacityShedErrorCodeForClient 处理这件事,且注释写明了
Codex 的闭集判定与"账号状态判定使用改写前原始 payload"的前提。它此前只在
HTTP/SSE(openai_gateway_response_handling.go)与 http_bridge
(openai_ws_http_bridge.go)两条路径被调用,ctx_pool ingress 直写路径是唯一漏掉
的一条——同一账号同一时刻切到 http_bridge 就能正常退避重试。

改写结果写入独立的 clientMessage,不动 upstreamMessage:写出点之后的
markOpenAIWSClientVisibleFailure 与 handleOpenAIWSTerminalTransientFailure
仍按原始 payload 判定账号状态,与 http_bridge 的写法一致。

新增用例覆盖改写生效与改写范围两侧(非容量类错误码必须原样下发)。
2026-08-31 16:53:57 +08:00
ice breaker 863667ce67 fix: retry transient database startup errors 2026-08-31 16:25:13 +08:00
wucm667 1be69e56aa fix(openai): accept delegation bootstrap without call id 2026-08-31 12:57:06 +08:00
qiujunandClaude Opus 5 e7c029875c test(billing): 图片输出按 output 子集计价
上一条改用统一定价管线后,TestTryModelFilePricing_WithImageOutput 挂在 0.3 vs
0.28:原算式把 ImageOutputTokens 当 OutputTokens 之外的额外量叠加,等于按文本
output 价和图片价各收一次,测试固化了这个重复计费。

ImageOutputTokens 本就是 OutputTokens 的子集(Gemini 取自 candidatesTokensDetails
的 IMAGE 档,OpenAI 取自 output_tokens_details),统一管线先扣除再按图片单价计,
未配图片价时回退到 output 价、总额不变,正是这个语义。期望改为 0.28。

影响:涉及图片输出的模型,账号统计成本会降低"图片 token × 文本 output 单价"。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-31 12:33:06 +08:00
feeeei 0e08c39999 fix(openai-ws): remember rejected encrypted content across turns
invalid_encrypted_content 恢复此前只清理当次请求体:同一失效密文随客户
端维护的会话历史在后续每一轮重新出现,反复触发"整包被拒→剥离→重试/
重连"(#6382 生产观测 2h 内 251 次 recovery、273 次 WS 重连),既是
QoS 放大器也是内存压力放大器。

按会话记录被上游拒绝过的 encrypted_content SHA-256 摘要(lineage):
- OpenAIWSStateStore 新增会话级摘要绑定,TTL 复用 session sticky,
  单会话 512 摘要为存储自保护,超出仅退化为既有单次 recovery
- 写点覆盖 forward WSv2 recovery、forward 非 WS 400 重试、ingress
  error 事件、bridge 400/流内 error;读点在 forward 进场与 ingress/
  bridge turn 循环入口按摘要预剥离,新密文摘要不同不受影响
- 只收剥离端可处理的项类型(reasoning/compaction/compaction_summary),
  避免记入永远剥不掉的摘要后每轮空转解码
- replay 历史序列同步剥离,避免与已剥离的当前 input 项错位导致
  prefix 复用失配

含端到端集成测试:turn1 上游拒绝即记录,turn2 上游收到的请求不再携带
失效密文且 reasoning 骨架保留。
2026-08-31 01:02:13 +08:00
feeeei ca9b4d73f2 fix(openai-ws): share immutable replay bodies to stop O(T²) copying
OpenAI Responses WS/HTTP-bridge 的 replay 历史此前每轮对全部正文做多次
深拷贝(构建、双份序列、保存点各一遍),长会话累计分配 O(T²),实测
128 turn x 10KiB 达 160MB+,是 #6382 内存持续增长直至 OOM 的主因。

改为共享不可变正文所有权:
- extract 经零拷贝 string 视图 + gjson Index 直接切 payload 子切片,
  并以 json.Valid 保持对断裂数组的既有错误语义
- 序列构建/sanitize/collector/保存点全部只传引用;跨持有者保存经
  combineOpenAIWSReplayItems 新建头数组,正文不复制
- bridge 每轮一次解析,正常 replay 与 account-failover 序列共享正文
- 前缀比较增加字节级快路径,避免逐轮全历史 decode+encode
- strict 状态可用时不再整包保留上一轮 payload

新增 128 turn 分配回归测试锚定上界(实测 2.5MB,深拷贝回归会击穿),
以及正文共享的所有权断言。
2026-08-31 01:01:20 +08:00
qiujunandClaude Opus 5 9eabd2a5b7 fix(billing): apply model pricing policies to account stats cost
resolveAccountStatsCost 的优先级 3 维护了第二份"单价 × token 数"实现,每加一个
定价特性都要手工镜像:service tier 与长上下文分别由 9261dd77、25e6688a 补齐,
而 b5827cfd 引入的 DeepSeek 官方峰谷一直没有生效,高峰时段账号成本最多低估一半。

改为复用 CalculateCostWithServiceTier。channelPricing 仍为 nil,优先级 3 的语义
不变:只取模型定价文件、不引入渠道自定义定价;低谷时段结果与原算式完全一致。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-30 22:57:53 +08:00
Long Li 57c76584a6 fix(codex): advertise priority tier for fast models 2026-08-30 14:21:08 +09:00
514 changed files with 32909 additions and 3722 deletions
+1
View File
@@ -1,6 +1,7 @@
.PHONY: build build-backend build-frontend test test-backend test-frontend test-frontend-critical
FRONTEND_CRITICAL_VITEST := \
src/i18n/__tests__/localeKeyCompleteness.spec.ts \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
+6 -12
View File
@@ -96,12 +96,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>Thanks to Suixiang AI Gateway for sponsoring this project! <a href="https://sui-xiang.com/">Suixiang AI Gateway</a> is a reliable and efficient API relay service provider offering relay services for Claude, Codex, Gemini, and more. A privacy-focused relay — no data reselling, no model dilution; privacy, transparency, and lightning-fast after-sales support. New accounts get ¥0.5 in trial credit daily by signing in; top-ups are 1:1, no subscription required, pay-as-you-go. Multi-line redundancy, cross-region disaster recovery, automatic failover, and uninterrupted long-link SSE. 99.9% availability — critical calls never fall behind.
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Thanks to Proxy4Free for sponsoring this project! Proxy4Free is a data proxy service provider for developers and AI applications, offering residential proxies, static residential proxies, ISP proxies, and datacenter proxies for scenarios such as Web Scraping, Browser Automation, and AI Agents. With global IP resources, stable connections, and flexible switching, it helps developers improve data collection success rates and reduce the risk of IP bans. Register via <a href="https://www.proxy4free.com/?keyword=4yjqecpc">this link</a> to get started and easily build more stable and efficient automation workflows.
@@ -127,11 +121,6 @@ Please read the following carefully before using this project:
<td><a href="https://nagora.ai/">Nagora</a> is a multi-model AI API gateway built for developers and teams. With a single account and API key, you can access more than 26 leading text and image models through one unified interface. It is compatible with OpenAI, Anthropic, and Gemini protocols and integrates seamlessly with development tools such as Claude Code, Codex, and Gemini CLI. The platform provides intelligent routing, automatic failover, transparent pricing, and consolidated billing, along with budget management, rate limiting, and concurrency controls. This makes AI usage more reliable and manageable across individual development, team collaboration, and production environments. No changes to your existing application are required. Simply replace the Base URL and API key to complete the integration in as little as one minute.</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>Thanks to <a href="https://www.novada.com/?sub2api/">Novada</a> for sponsoring this project! Novada provides residential, ISP, datacenter, and mobile proxies, along with Web Unlocker and Scraper APIs for developers building AI applications and automation workflows. With global IP coverage, flexible rotating and sticky sessions, and precise geo-targeting, Novada helps teams access web data reliably for AI agent workflows, cross-region testing, web research, and browser automation. Explore Novada to build more stable and scalable AI workflows.</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
<td>Thanks to Qiniu AI for sponsoring this project! Qiniu AI is the enterprise-grade large-model MaaS platform under Qiniu Cloud (02567.HK), offering one-stop access to 150+ mainstream models worldwide, compatible with the protocols of major global model providers, and covering full-modality capabilities including text, image, audio, video, and file processing, serving over 1.69 million enterprises and developers. Qiniu AI offers an exclusive benefit for Sub2API users: register via <a href="https://s.qiniu.com/u6rQrq">this link</a> — enterprise users get 12 million tokens free, and developers get 3 million tokens free.</td>
@@ -172,6 +161,11 @@ Please read the following carefully before using this project:
<td>Thanks to APIMart for sponsoring this project! <a href="https://go.apimart.ai/gh-sub2api">APIMart</a> is a low-cost API platform for AI image and video generation — GPT-Image-2 from $0.006 per image, with 160+ images per dollar. One async API covers both image and video: submit a task, get an ID, and retrieve results via polling or callback. Batch tens of thousands of images without timeouts, and switch models without changing code. Pay as you go with no monthly fee — <a href="https://go.apimart.ai/gh-sub2api">sign up here</a> to get started.</td>
</tr>
<tr>
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
<td>Thanks to AxisNow for sponsoring this project! <a href="https://www.axisnow.io/">AxisNow</a> protects and accelerates websites and APIs, delivering an optimal access experience across mainland China and globally, while extending acceleration and security capabilities to native/mobile apps through client SDKs — <strong>self-hosted private-deployment CDN</strong> | <strong>subscription-based DDoS-protected CDN</strong> | <strong>autonomous, flexibly composable CDN network</strong>.</td>
</tr>
</table>
## Overview
@@ -822,7 +816,7 @@ xAI quota is passive. Sub2API does not invent subscription quota values; it reco
`401` responses temporarily remove accounts with invalid credentials from scheduling. `403` responses are treated as access or entitlement failures instead of token-refresh loops. `429` responses use `Retry-After` or a short cooldown to temporarily remove the account from scheduling.
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts require positive paid-entitlement evidence from the xAI billing probe; Free, forbidden, missing, malformed, and inconclusive billing observations are excluded from new media generation. Unobserved OAuth accounts are probed before the first media request is forwarded, and imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
New Grok image and video generation requests use a media-specific eligibility check. API-key accounts remain eligible. OAuth accounts with explicit Free or forbidden billing evidence are excluded from new media generation. Missing or malformed observations are probed before dispatch; a successful but incomplete billing response is treated as `billing_inconclusive` and remains eligible for backwards compatibility, because an unknown billing schema is not proof that the account lacks media entitlement. Operators can quarantine a known-bad account with `extra.grok_media_eligible=false` or force-enable a verified account with `true`. Imports run the billing-first quota probe proactively. Chat requests and video status lookups are not affected by this media-only quarantine. If no eligible account remains, the media endpoint returns HTTP `503` with error type `grok_media_no_eligible_account`.
Administrators can override automatic media eligibility through the account create/update API by setting `extra.grok_media_eligible` to `false` (exclude) or `true` (force eligible). On update, set it to `null` to remove the override and return to automatic probe-based behavior; omitting the field preserves the current override. A weekly allowance period alone is not treated as a paid tier signal. Successful image responses must contain at least one actual image output; empty HTTP `200` responses trigger account failover instead of being counted and returned as successful generations.
+5 -11
View File
@@ -99,12 +99,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>感谢 随想AI网关 赞助本项目!<a href="https://sui-xiang.com/">随想AI网关</a> 是一家可靠高效的 API 中继服务提供商,提供 Claude、Codex、Gemini 等的中继服务。注重隐私的中转站·无数据倒卖·无模型掺水,隐私,透明,极速售后。新账户注册每日签到就送 0.5 元测试额度,充值额度 1:1,无需订阅,按量付费。多线路冗余、跨区域容灾、自动故障切换,长链路 SSE 不中断。99.9% 可用性,关键调用从不掉队。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>感谢 Proxy4Free 赞助本项目!Proxy4Free 是面向开发者和 AI 应用的数据代理服务商,提供住宅代理、静态住宅代理、ISP 代理及数据中心代理等多种代理解决方案,适用于 Web Scraping、Browser Automation、AI Agent 等场景。支持全球 IP 资源、稳定连接与灵活切换,帮助开发者提升数据采集成功率,降低 IP 封禁风险。通过<a href="https://www.proxy4free.com/?keyword=4yjqecpc">此链接注册</a>即可开始体验,轻松构建更稳定、高效的自动化工作流。
@@ -130,11 +124,6 @@
<td><a href="https://nagora.ai/">Nagora</a> 是专为开发者和团队打造的多模型 AI API 网关。通过一个账户和一枚 API Key,即可统一调用 26+ 款主流文本与图像模型,兼容 OpenAI、Anthropic 与 Gemini 协议,并可无缝接入 Claude Code、Codex、Gemini CLI 等开发工具。平台提供智能路由、自动故障转移、透明计费与统一账单,同时支持预算、限速、并发控制,让个人开发、团队协作和生产环境中的 AI 调用更稳定、更可控。无需改造现有应用,只需替换 Base URL 与 API Key,最快 1 分钟即可完成接入。</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>感谢 <a href="https://www.novada.com/?sub2api/">Novada</a> 赞助本项目!Novada 为构建 AI 应用与自动化工作流的开发者提供住宅代理、ISP 代理、数据中心代理与移动代理,以及 Web Unlocker 和 Scraper API。凭借全球 IP 覆盖、灵活的轮换与粘性会话以及精准的地理定位,Novada 帮助团队在 AI Agent 工作流、跨区域测试、网络调研与浏览器自动化等场景中稳定获取网络数据。立即体验 Novada,构建更稳定、更可扩展的 AI 工作流。</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="七牛云AI" width="150"></a></td>
<td>感谢 七牛云AI 赞助本项目!七牛云AI 是七牛云(02567.HK)旗下企业级大模型 MaaS 平台,一站式调用全球 150+ 主流模型,兼容全球主流模型厂商协议,覆盖文本、图像、音频、视频、文件处理等全模态处理能力,服务超过169万企业及开发者用户。七牛云 AI 为 Sub2API 的用户提供了专属福利:通过<a href="https://s.qiniu.com/u6rQrq">此链接</a>注册,企业用户免费领1200万Token,开发者免费领300万Token。</td>
@@ -175,6 +164,11 @@
<td>感谢 APIMart 赞助了本项目!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> 是专注于 AI 图片/视频生成的低价 API 平台,GPT-Image-2 低至 $0.006/张,1 美元可生成 160+ 张图片。图片、视频一套异步 API 通吃:提交任务获取 ID,通过轮询或回调获取结果;批量生成上万张图片也不会超时,切换模型无需修改代码。按量付费、无月费,通过<a href="https://go.apimart.ai/gh-sub2api">此注册链接</a>注册即可开始使用。</td>
</tr>
<tr>
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
<td>感谢 AxisNow 赞助了本项目!<a href="https://www.axisnow.io/">AxisNow</a> 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — <strong>自建私有部署 CDN</strong>|<strong>订阅式高防 CDN</strong>|<strong>自主可控、灵活组合的 CDN 网络</strong>。</td>
</tr>
</table>
## 项目概述
+5 -11
View File
@@ -98,12 +98,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="https://sui-xiang.com/"><img src="assets/partners/logos/sui-xiang.jpg" alt="sui-xiang" width="150"></a></td>
<td>随想AI ゲートウェイのご支援に感謝します!<a href="https://sui-xiang.com/">随想AI ゲートウェイ</a> は信頼性と効率に優れた API 中継サービスプロバイダーで、Claude、Codex、Gemini などの中継サービスを提供しています。プライバシー重視の中継ステーション・データ転売なし・モデル水増しなし、プライバシー・透明性・超高速アフターサービス。新規アカウント登録後、毎日サインインで 0.5 元のテストクレジットがもらえ、チャージは 1:1、サブスクリプション不要、従量課金。マルチライン冗長、クロスリージョン災害復旧、自動フェイルオーバー、長時間 SSE 接続が途切れません。99.9% の可用性、重要な呼び出しは決して遅れません。
</td>
</tr>
<tr>
<td width="180"><a href="https://www.proxy4free.com/?keyword=4yjqecpc"><img src="assets/partners/logos/proxy4free.png" alt="proxy4free" width="150"></a></td>
<td>Proxy4Free のご支援に感謝します!Proxy4Free は開発者と AI アプリケーション向けのデータプロキシサービスプロバイダーで、住宅プロキシ、静的住宅プロキシ、ISP プロキシ、データセンタープロキシなど多様なプロキシソリューションを提供しており、Web Scraping、Browser Automation、AI Agent などのシナリオに適しています。グローバル IP リソース、安定した接続、柔軟な切り替えをサポートし、開発者のデータ収集成功率の向上と IP ブロックリスクの低減を支援します。<a href="https://www.proxy4free.com/?keyword=4yjqecpc">こちらのリンクから登録</a>して、より安定した効率的な自動化ワークフローを簡単に構築しましょう。
@@ -129,11 +123,6 @@
<td><a href="https://nagora.ai/">Nagora</a>は、開発者やチーム向けに設計されたマルチモデルAI APIゲートウェイです。1つのアカウントと1つのAPIキーだけで、26種類以上の主要なテキストモデルおよび画像モデルを一元的に利用できます。OpenAI、Anthropic、Geminiの各プロトコルに対応し、Claude Code、Codex、Gemini CLIなどの開発ツールにもシームレスに接続できます。 プラットフォームには、インテリジェントルーティング、自動フェイルオーバー、透明性の高い料金体系、請求の一元管理に加え、予算管理、レート制限、同時実行数の制御機能が備わっています。これにより、個人開発、チームでの共同作業、本番環境におけるAI APIの利用を、より安定的かつ柔軟に管理できます。 既存のアプリケーションを改修する必要はありません。Base URLとAPIキーを置き換えるだけで、最短1分で導入を完了できます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.novada.com/?sub2api/"><img src="assets/partners/logos/novada.png" alt="Novada" width="150"></a></td>
<td>Novada のご支援に感謝します!<a href="https://www.novada.com/?sub2api/">Novada</a> は、AI アプリケーションや自動化ワークフローを構築する開発者向けに、レジデンシャル、ISP、データセンター、モバイルプロキシに加え、Web Unlocker と Scraper API を提供しています。グローバルな IP カバレッジ、柔軟なローテーション/スティッキーセッション、精密なジオターゲティングにより、AI エージェントワークフロー、クロスリージョンテスト、Web リサーチ、ブラウザ自動化などのシーンで、チームが Web データへ確実にアクセスできるよう支援します。Novada で、より安定しスケーラブルな AI ワークフローを構築しましょう。</td>
</tr>
<tr>
<td width="180"><a href="https://s.qiniu.com/u6rQrq"><img src="assets/partners/logos/qiniu.jpg" alt="Qiniu AI" width="150"></a></td>
<td>七牛云AI のご支援に感謝します!七牛云AI は、七牛云(02567.HK)傘下のエンタープライズ向け大規模モデル MaaS プラットフォームです。世界の主要モデル 150+ をワンストップで利用でき、グローバル主要モデルプロバイダーのプロトコルに対応し、テキスト、画像、音声、動画、ファイル処理などのフルモーダル処理能力をカバー。169万を超える企業・開発者ユーザーにサービスを提供しています。Sub2API ユーザー向けの限定特典として、<a href="https://s.qiniu.com/u6rQrq">こちらのリンク</a>から登録すると、企業ユーザーは 1200万 Token、開発者は 300万 Token を無料で獲得できます。</td>
@@ -174,6 +163,11 @@
<td>APIMart のご支援に感謝します!<a href="https://go.apimart.ai/gh-sub2api">APIMart</a> は AI 画像・動画生成に特化した低価格 API プラットフォームです。GPT-Image-2 は 1 枚 $0.006 から、1 ドルで 160 枚以上の画像を生成できます。画像と動画の両方に対応する非同期 API を 1 つで利用でき、タスクを送信して ID を取得し、ポーリングまたはコールバックで結果を取得できます。数万枚規模のバッチ処理でもタイムアウトせず、モデルを変更してもコードの変更は不要です。月額料金なしの従量課金制で、<a href="https://go.apimart.ai/gh-sub2api">こちらの登録リンク</a>からすぐに利用を開始できます。</td>
</tr>
<tr>
<td width="180"><a href="https://www.axisnow.io/"><img src="assets/partners/logos/axisnow.jpg" alt="AxisNow" width="150"></a></td>
<td>AxisNow のご支援に感謝します!<a href="https://www.axisnow.io/">AxisNow</a> はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — <strong>自社構築・プライベートデプロイ CDN</strong>|<strong>サブスクリプション型高防御 CDN</strong>|<strong>自律制御で柔軟に組み合わせられる CDN ネットワーク</strong>。</td>
</tr>
</table>
## 概要
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 42 KiB

+1 -1
View File
@@ -1 +1 @@
0.1.183
0.2.1
+4 -4
View File
@@ -189,10 +189,10 @@ 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, channelService)
adminService := service.NewAdminService(configConfig, 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)
groupHandler := admin.NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, configConfig)
claudeUsageFetcher := repository.NewClaudeUsageFetcher(httpUpstream)
antigravityQuotaFetcher := service.NewAntigravityQuotaFetcher(proxyRepository, configConfig)
grokQuotaFetcher := service.NewGrokQuotaFetcher()
@@ -205,12 +205,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
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)
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
adminAnnouncementHandler := admin.NewAnnouncementHandler(announcementService)
dataManagementService := service.NewDataManagementService()
dataManagementHandler := admin.NewDataManagementHandler(dataManagementService)
backupObjectStoreFactory := repository.NewS3BackupStoreFactory()
dbDumper := repository.NewPgDumper(configConfig)
dbDumper := repository.NewPgDumper(configConfig, db)
backupService := service.ProvideBackupService(settingRepository, configConfig, secretEncryptor, backupObjectStoreFactory, dbDumper, leaderLockCache, db)
imageStorageFactory := repository.ProvideImageStorageFactory()
imageStorageSettingService := service.ProvideImageStorageSettingService(settingRepository, secretEncryptor, backupService, imageStorageFactory, configConfig)
@@ -0,0 +1,136 @@
# Upstream error proxy attribution
## Scope
Gateway inference errors are stored in `ops_error_logs.upstream_errors`. Each
array item represents one upstream attempt, including same-account retries and
cross-account failover attempts. Proxy attribution is stored on that item; it
must not be reconstructed later from `accounts.proxy_id`.
This repository has one gateway implementation under `backend/internal/service`.
There is no separate `gateway/backend` source copy to synchronize.
## Event contract
Every newly persisted event contains these fields. `proxy_id` is emitted even
when its value is JSON `null`:
| Field | Meaning |
| --- | --- |
| `proxy_id` | Managed proxy ID used by the attempt; `null` for direct or unknown routes. |
| `proxy_name` | Snapshotted proxy name, `direct/no_proxy`, or `unknown`. |
Only proxy ID and name are stored. Proxy URL, protocol endpoint, host, port,
username, password, and authorization data are excluded.
The two sentinel names have strict meanings:
- `direct/no_proxy`: the transport was explicitly given no proxy.
- `unknown`: the route cannot be proven from event-time evidence. This includes
legacy events, pre-transport credential failures, and OpenAI WebSocket use of
the default HTTP client.
Invariant: `proxy_id` is `null` if and only if `proxy_name` is one of the two
sentinels. A managed proxy with a blank name (defensive only; `proxies.name` is
non-empty) is labeled `proxy`. Both the struct normalizer used at append time
and the JSON normalizer used on detail reads enforce this, so an event can never
carry `proxy_id=null` together with a real proxy name.
## Why the account snapshot is event-time evidence
Account selection loads `ProxyID` and `Proxy` into one in-memory snapshot. HTTP,
TLS-fingerprint, Bedrock, Gemini, Antigravity, OpenAI, and OpenAI WebSocket
forwarding use that snapshot for managed proxy routing.
Inference transports use a managed proxy only when both the binding ID and its
hydrated proxy object are present; the attribution accessors use the same rule.
OpenAI WebSocket additionally retains the existing default-client environment
proxy behavior when that snapshot does not yield a usable proxy. The transport
does not switch to a database backup proxy during a request.
Expired proxy fallback happens before scheduling by atomically rewriting the
account binding:
- backup proxy: `proxy_id` becomes the backup ID and
`proxy_fallback_origin_id` stores the expired proxy ID;
- direct fallback: `proxy_id` becomes null and
`proxy_fallback_origin_id` stores the expired proxy ID.
Custom Anthropic relays receive the same snapshotted proxy as their encoded
relay proxy parameter. OpenAI WebSocket requests keep the existing transport
behavior: configured account proxies use the proxy client, while an empty
account proxy leaves the HTTP client unset so `coder/websocket` uses
`http.DefaultClient`, including its `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY`
handling.
For WebSocket errors without a usable account proxy, the event stores
`proxy_id=null` and `proxy_name=unknown`. It does not infer whether the default
client selected an environment proxy or a direct connection after the fact.
This applies to every event produced while a request is on the WebSocket
transport, including WS handshake/fallback errors and the first-output timeout
raised by the WS passthrough relay; `opsUpstreamWSProxyAttribution` is the
accessor for those sites and never returns `direct/no_proxy`.
Credential acquisition failures that occur before the inference transport is
opened are also recorded as unknown. The account's proxy binding is not treated
as proof that the credential operation or an inference request used that route.
Each failure site constructs a complete `OpsUpstreamErrorEvent`. Its literal
sets `proxy_id` and `proxy_name` from the same route input used by the attempt.
Credential-free accessors copy only the managed proxy ID and name. A fallback
proxy needs no special event flag: its event-time ID and name are the historical
route evidence. Shared helpers that assemble the event on behalf of a caller
(`handleUpstreamTransportError`, `handleOpenAIUpstreamTransportError`,
`appendOpenAICompactFallbackRetryOps`, `recordOpenAIRawStreamTruncation`,
`newOpenAIFirstOutputTimeoutError`) stamp attribution themselves, so a caller
cannot forget it. Every non-test `OpsUpstreamErrorEvent{` literal in
`backend/internal/service` must either set `ProxyName` or be passed to one of
those helpers; grep for literals missing `ProxyName` before merging changes
that add a failure site.
`appendOpsUpstreamError` accepts only the completed event. It does not receive
an `Account`, query current account state, or infer proxy attribution while
appending. Once appended, the event contains only scalar snapshot values, so
later account mutation or failover cannot change an earlier event.
Queue sanitization keeps attempt events newest-first under hard bounds:
- the newest 16 attempts retain `detail` and `upstream_response_body` (each
capped at the queue body limit) plus `upstream_url`/`message` up to 2048
bytes;
- older attempts keep timestamps, account, proxy, status, kind, stage, scope,
reason and a `upstream_url`/`message` trimmed to 512 bytes; an attempt whose
only payload was a cleared `detail` is still retained;
- at most 256 attempts and roughly 512 KiB of serialized JSON are kept per
request. When either bound drops attempts, the oldest retained event carries
`dropped_earlier_attempts` with the number of discarded earlier attempts. The
newest attempt is always retained because it decides the request outcome.
## Legacy events and analytics
Legacy JSON is not rewritten in the database. Detail reads edit only the
`proxy_id`/`proxy_name` keys of events that lack valid attribution, so keys
written by older struct versions and the stored key order are preserved. The
shared parser applies the same rule to the decoded structs. Both materialize
missing attribution as:
```json
{
"proxy_id": null,
"proxy_name": "unknown"
}
```
Proxy and region aggregation must use the event fields first. A safe grouping
rule is:
1. A non-null event `proxy_id` groups by that ID and uses the event
`proxy_name` as the historical label. A join to `proxies` may enrich current
metadata but must not replace the event label.
2. `proxy_id=null` and `proxy_name=direct/no_proxy` groups as direct.
3. Missing attribution, or `proxy_id=null` and `proxy_name=unknown`, groups as
unknown. Do not join through the account's current `proxy_id` and present it
as historical attribution.
If an operator deliberately produces a current-account cohort for legacy data,
the report must be labeled as a current snapshot; it is not historical proxy
attribution.
+58 -12
View File
@@ -122,6 +122,10 @@ type Group struct {
AllowMessagesDispatch bool `json:"allow_messages_dispatch,omitempty"`
// 是否允许此 OpenAI 分组访问 Live 接口
AllowLive bool `json:"allow_live,omitempty"`
// 是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority
ForceOpenaiFast bool `json:"force_openai_fast,omitempty"`
// 是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费
FreeOpenaiFast bool `json:"free_openai_fast,omitempty"`
// 仅允许非 apikey 类型账号关联到此分组
RequireOauthOnly bool `json:"require_oauth_only,omitempty"`
// 调度时仅允许 privacy 已成功设置的账号
@@ -130,13 +134,17 @@ type Group struct {
DefaultMappedModel string `json:"default_mapped_model,omitempty"`
// OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config,omitempty"`
// 自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config,omitempty"`
// 分组模型白名单:同时约束模型列表接口与请求准入
ModelAllowlist domain.GroupModelAllowlist `json:"model_allowlist,omitempty"`
// 固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config,omitempty"`
// 分组 RPM 上限,0 表示不限制;设置后接管该分组用户的限流
RpmLimit int `json:"rpm_limit,omitempty"`
// OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max
MaxReasoningEffort string `json:"max_reasoning_effort,omitempty"`
// OpenAI reasoning effort 自定义精确映射;先映射再应用上限
// 超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit,omitempty"`
// OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings,omitempty"`
// 是否启用利润控制:调度时仅允许账号计费倍率满足毛利率要求的账号进入候选池
ProfitControlEnabled bool `json:"profit_control_enabled,omitempty"`
@@ -250,15 +258,15 @@ func (*Group) scanValues(columns []string) ([]any, error) {
values := make([]any, len(columns))
for i := range columns {
switch columns[i] {
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelsListConfig, group.FieldReasoningEffortMappings:
case group.FieldVideoModelPrices, group.FieldModelPricing, group.FieldModelRouting, group.FieldSupportedModelScopes, group.FieldMessagesDispatchModelConfig, group.FieldModelAllowlist, group.FieldCodexModelsManifestConfig, group.FieldReasoningEffortMappings:
values[i] = new([]byte)
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:
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.FieldForceOpenaiFast, group.FieldFreeOpenaiFast, 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)
case group.FieldID, group.FieldDefaultValidityDays, group.FieldFallbackGroupID, group.FieldFallbackGroupIDOnInvalidRequest, group.FieldSortOrder, group.FieldRpmLimit:
values[i] = new(sql.NullInt64)
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort:
case group.FieldName, group.FieldDescription, group.FieldPeakStart, group.FieldPeakEnd, group.FieldStatus, group.FieldDuplicateOperationID, group.FieldPlatform, group.FieldSubscriptionType, group.FieldDefaultMappedModel, group.FieldMaxReasoningEffort, group.FieldMaxReasoningEffortOverLimit:
values[i] = new(sql.NullString)
case group.FieldCreatedAt, group.FieldUpdatedAt, group.FieldDeletedAt:
values[i] = new(sql.NullTime)
@@ -616,6 +624,18 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.AllowLive = value.Bool
}
case group.FieldForceOpenaiFast:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field force_openai_fast", values[i])
} else if value.Valid {
_m.ForceOpenaiFast = value.Bool
}
case group.FieldFreeOpenaiFast:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field free_openai_fast", values[i])
} else if value.Valid {
_m.FreeOpenaiFast = value.Bool
}
case group.FieldRequireOauthOnly:
if value, ok := values[i].(*sql.NullBool); !ok {
return fmt.Errorf("unexpected type %T for field require_oauth_only", values[i])
@@ -642,12 +662,20 @@ func (_m *Group) assignValues(columns []string, values []any) error {
return fmt.Errorf("unmarshal field messages_dispatch_model_config: %w", err)
}
}
case group.FieldModelsListConfig:
case group.FieldModelAllowlist:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field models_list_config", values[i])
return fmt.Errorf("unexpected type %T for field model_allowlist", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.ModelsListConfig); err != nil {
return fmt.Errorf("unmarshal field models_list_config: %w", err)
if err := json.Unmarshal(*value, &_m.ModelAllowlist); err != nil {
return fmt.Errorf("unmarshal field model_allowlist: %w", err)
}
}
case group.FieldCodexModelsManifestConfig:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field codex_models_manifest_config", values[i])
} else if value != nil && len(*value) > 0 {
if err := json.Unmarshal(*value, &_m.CodexModelsManifestConfig); err != nil {
return fmt.Errorf("unmarshal field codex_models_manifest_config: %w", err)
}
}
case group.FieldRpmLimit:
@@ -662,6 +690,12 @@ func (_m *Group) assignValues(columns []string, values []any) error {
} else if value.Valid {
_m.MaxReasoningEffort = value.String
}
case group.FieldMaxReasoningEffortOverLimit:
if value, ok := values[i].(*sql.NullString); !ok {
return fmt.Errorf("unexpected type %T for field max_reasoning_effort_over_limit", values[i])
} else if value.Valid {
_m.MaxReasoningEffortOverLimit = value.String
}
case group.FieldReasoningEffortMappings:
if value, ok := values[i].(*[]byte); !ok {
return fmt.Errorf("unexpected type %T for field reasoning_effort_mappings", values[i])
@@ -955,6 +989,12 @@ func (_m *Group) String() string {
builder.WriteString("allow_live=")
builder.WriteString(fmt.Sprintf("%v", _m.AllowLive))
builder.WriteString(", ")
builder.WriteString("force_openai_fast=")
builder.WriteString(fmt.Sprintf("%v", _m.ForceOpenaiFast))
builder.WriteString(", ")
builder.WriteString("free_openai_fast=")
builder.WriteString(fmt.Sprintf("%v", _m.FreeOpenaiFast))
builder.WriteString(", ")
builder.WriteString("require_oauth_only=")
builder.WriteString(fmt.Sprintf("%v", _m.RequireOauthOnly))
builder.WriteString(", ")
@@ -967,8 +1007,11 @@ func (_m *Group) String() string {
builder.WriteString("messages_dispatch_model_config=")
builder.WriteString(fmt.Sprintf("%v", _m.MessagesDispatchModelConfig))
builder.WriteString(", ")
builder.WriteString("models_list_config=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelsListConfig))
builder.WriteString("model_allowlist=")
builder.WriteString(fmt.Sprintf("%v", _m.ModelAllowlist))
builder.WriteString(", ")
builder.WriteString("codex_models_manifest_config=")
builder.WriteString(fmt.Sprintf("%v", _m.CodexModelsManifestConfig))
builder.WriteString(", ")
builder.WriteString("rpm_limit=")
builder.WriteString(fmt.Sprintf("%v", _m.RpmLimit))
@@ -976,6 +1019,9 @@ func (_m *Group) String() string {
builder.WriteString("max_reasoning_effort=")
builder.WriteString(_m.MaxReasoningEffort)
builder.WriteString(", ")
builder.WriteString("max_reasoning_effort_over_limit=")
builder.WriteString(_m.MaxReasoningEffortOverLimit)
builder.WriteString(", ")
builder.WriteString("reasoning_effort_mappings=")
builder.WriteString(fmt.Sprintf("%v", _m.ReasoningEffortMappings))
builder.WriteString(", ")
+42 -5
View File
@@ -118,6 +118,10 @@ const (
FieldAllowMessagesDispatch = "allow_messages_dispatch"
// FieldAllowLive holds the string denoting the allow_live field in the database.
FieldAllowLive = "allow_live"
// FieldForceOpenaiFast holds the string denoting the force_openai_fast field in the database.
FieldForceOpenaiFast = "force_openai_fast"
// FieldFreeOpenaiFast holds the string denoting the free_openai_fast field in the database.
FieldFreeOpenaiFast = "free_openai_fast"
// FieldRequireOauthOnly holds the string denoting the require_oauth_only field in the database.
FieldRequireOauthOnly = "require_oauth_only"
// FieldRequirePrivacySet holds the string denoting the require_privacy_set field in the database.
@@ -126,12 +130,16 @@ const (
FieldDefaultMappedModel = "default_mapped_model"
// FieldMessagesDispatchModelConfig holds the string denoting the messages_dispatch_model_config field in the database.
FieldMessagesDispatchModelConfig = "messages_dispatch_model_config"
// FieldModelsListConfig holds the string denoting the models_list_config field in the database.
FieldModelsListConfig = "models_list_config"
// FieldModelAllowlist holds the string denoting the model_allowlist field in the database.
FieldModelAllowlist = "model_allowlist"
// FieldCodexModelsManifestConfig holds the string denoting the codex_models_manifest_config field in the database.
FieldCodexModelsManifestConfig = "codex_models_manifest_config"
// FieldRpmLimit holds the string denoting the rpm_limit field in the database.
FieldRpmLimit = "rpm_limit"
// FieldMaxReasoningEffort holds the string denoting the max_reasoning_effort field in the database.
FieldMaxReasoningEffort = "max_reasoning_effort"
// FieldMaxReasoningEffortOverLimit holds the string denoting the max_reasoning_effort_over_limit field in the database.
FieldMaxReasoningEffortOverLimit = "max_reasoning_effort_over_limit"
// FieldReasoningEffortMappings holds the string denoting the reasoning_effort_mappings field in the database.
FieldReasoningEffortMappings = "reasoning_effort_mappings"
// FieldProfitControlEnabled holds the string denoting the profit_control_enabled field in the database.
@@ -266,13 +274,17 @@ var Columns = []string{
FieldSortOrder,
FieldAllowMessagesDispatch,
FieldAllowLive,
FieldForceOpenaiFast,
FieldFreeOpenaiFast,
FieldRequireOauthOnly,
FieldRequirePrivacySet,
FieldDefaultMappedModel,
FieldMessagesDispatchModelConfig,
FieldModelsListConfig,
FieldModelAllowlist,
FieldCodexModelsManifestConfig,
FieldRpmLimit,
FieldMaxReasoningEffort,
FieldMaxReasoningEffortOverLimit,
FieldReasoningEffortMappings,
FieldProfitControlEnabled,
FieldProfitMinMargin,
@@ -386,6 +398,10 @@ var (
DefaultAllowMessagesDispatch bool
// DefaultAllowLive holds the default value on creation for the "allow_live" field.
DefaultAllowLive bool
// DefaultForceOpenaiFast holds the default value on creation for the "force_openai_fast" field.
DefaultForceOpenaiFast bool
// DefaultFreeOpenaiFast holds the default value on creation for the "free_openai_fast" field.
DefaultFreeOpenaiFast bool
// DefaultRequireOauthOnly holds the default value on creation for the "require_oauth_only" field.
DefaultRequireOauthOnly bool
// DefaultRequirePrivacySet holds the default value on creation for the "require_privacy_set" field.
@@ -396,14 +412,20 @@ var (
DefaultMappedModelValidator func(string) error
// DefaultMessagesDispatchModelConfig holds the default value on creation for the "messages_dispatch_model_config" field.
DefaultMessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig
// DefaultModelsListConfig holds the default value on creation for the "models_list_config" field.
DefaultModelsListConfig domain.GroupModelsListConfig
// DefaultModelAllowlist holds the default value on creation for the "model_allowlist" field.
DefaultModelAllowlist domain.GroupModelAllowlist
// DefaultCodexModelsManifestConfig holds the default value on creation for the "codex_models_manifest_config" field.
DefaultCodexModelsManifestConfig domain.GroupCodexModelsManifestConfig
// DefaultRpmLimit holds the default value on creation for the "rpm_limit" field.
DefaultRpmLimit int
// DefaultMaxReasoningEffort holds the default value on creation for the "max_reasoning_effort" field.
DefaultMaxReasoningEffort string
// MaxReasoningEffortValidator is a validator for the "max_reasoning_effort" field. It is called by the builders before save.
MaxReasoningEffortValidator func(string) error
// DefaultMaxReasoningEffortOverLimit holds the default value on creation for the "max_reasoning_effort_over_limit" field.
DefaultMaxReasoningEffortOverLimit string
// MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
MaxReasoningEffortOverLimitValidator func(string) error
// DefaultReasoningEffortMappings holds the default value on creation for the "reasoning_effort_mappings" field.
DefaultReasoningEffortMappings []domain.ReasoningEffortMapping
// DefaultProfitControlEnabled holds the default value on creation for the "profit_control_enabled" field.
@@ -657,6 +679,16 @@ func ByAllowLive(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldAllowLive, opts...).ToFunc()
}
// ByForceOpenaiFast orders the results by the force_openai_fast field.
func ByForceOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldForceOpenaiFast, opts...).ToFunc()
}
// ByFreeOpenaiFast orders the results by the free_openai_fast field.
func ByFreeOpenaiFast(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldFreeOpenaiFast, opts...).ToFunc()
}
// ByRequireOauthOnly orders the results by the require_oauth_only field.
func ByRequireOauthOnly(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldRequireOauthOnly, opts...).ToFunc()
@@ -682,6 +714,11 @@ func ByMaxReasoningEffort(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMaxReasoningEffort, opts...).ToFunc()
}
// ByMaxReasoningEffortOverLimit orders the results by the max_reasoning_effort_over_limit field.
func ByMaxReasoningEffortOverLimit(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldMaxReasoningEffortOverLimit, opts...).ToFunc()
}
// ByProfitControlEnabled orders the results by the profit_control_enabled field.
func ByProfitControlEnabled(opts ...sql.OrderTermOption) OrderOption {
return sql.OrderByField(FieldProfitControlEnabled, opts...).ToFunc()
+100
View File
@@ -290,6 +290,16 @@ func AllowLive(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldAllowLive, v))
}
// ForceOpenaiFast applies equality check predicate on the "force_openai_fast" field. It's identical to ForceOpenaiFastEQ.
func ForceOpenaiFast(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
}
// FreeOpenaiFast applies equality check predicate on the "free_openai_fast" field. It's identical to FreeOpenaiFastEQ.
func FreeOpenaiFast(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
}
// RequireOauthOnly applies equality check predicate on the "require_oauth_only" field. It's identical to RequireOauthOnlyEQ.
func RequireOauthOnly(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
@@ -315,6 +325,11 @@ func MaxReasoningEffort(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffort, v))
}
// MaxReasoningEffortOverLimit applies equality check predicate on the "max_reasoning_effort_over_limit" field. It's identical to MaxReasoningEffortOverLimitEQ.
func MaxReasoningEffortOverLimit(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
}
// ProfitControlEnabled applies equality check predicate on the "profit_control_enabled" field. It's identical to ProfitControlEnabledEQ.
func ProfitControlEnabled(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
@@ -2270,6 +2285,26 @@ func AllowLiveNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldAllowLive, v))
}
// ForceOpenaiFastEQ applies the EQ predicate on the "force_openai_fast" field.
func ForceOpenaiFastEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldForceOpenaiFast, v))
}
// ForceOpenaiFastNEQ applies the NEQ predicate on the "force_openai_fast" field.
func ForceOpenaiFastNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldForceOpenaiFast, v))
}
// FreeOpenaiFastEQ applies the EQ predicate on the "free_openai_fast" field.
func FreeOpenaiFastEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldFreeOpenaiFast, v))
}
// FreeOpenaiFastNEQ applies the NEQ predicate on the "free_openai_fast" field.
func FreeOpenaiFastNEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldFreeOpenaiFast, v))
}
// RequireOauthOnlyEQ applies the EQ predicate on the "require_oauth_only" field.
func RequireOauthOnlyEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldRequireOauthOnly, v))
@@ -2460,6 +2495,71 @@ func MaxReasoningEffortContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffort, v))
}
// MaxReasoningEffortOverLimitEQ applies the EQ predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitEQ(v string) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitNEQ applies the NEQ predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitNEQ(v string) predicate.Group {
return predicate.Group(sql.FieldNEQ(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitIn applies the In predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldIn(FieldMaxReasoningEffortOverLimit, vs...))
}
// MaxReasoningEffortOverLimitNotIn applies the NotIn predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitNotIn(vs ...string) predicate.Group {
return predicate.Group(sql.FieldNotIn(FieldMaxReasoningEffortOverLimit, vs...))
}
// MaxReasoningEffortOverLimitGT applies the GT predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitGT(v string) predicate.Group {
return predicate.Group(sql.FieldGT(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitGTE applies the GTE predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitGTE(v string) predicate.Group {
return predicate.Group(sql.FieldGTE(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitLT applies the LT predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitLT(v string) predicate.Group {
return predicate.Group(sql.FieldLT(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitLTE applies the LTE predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitLTE(v string) predicate.Group {
return predicate.Group(sql.FieldLTE(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitContains applies the Contains predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitContains(v string) predicate.Group {
return predicate.Group(sql.FieldContains(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitHasPrefix applies the HasPrefix predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitHasPrefix(v string) predicate.Group {
return predicate.Group(sql.FieldHasPrefix(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitHasSuffix applies the HasSuffix predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitHasSuffix(v string) predicate.Group {
return predicate.Group(sql.FieldHasSuffix(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitEqualFold applies the EqualFold predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitEqualFold(v string) predicate.Group {
return predicate.Group(sql.FieldEqualFold(FieldMaxReasoningEffortOverLimit, v))
}
// MaxReasoningEffortOverLimitContainsFold applies the ContainsFold predicate on the "max_reasoning_effort_over_limit" field.
func MaxReasoningEffortOverLimitContainsFold(v string) predicate.Group {
return predicate.Group(sql.FieldContainsFold(FieldMaxReasoningEffortOverLimit, v))
}
// ProfitControlEnabledEQ applies the EQ predicate on the "profit_control_enabled" field.
func ProfitControlEnabledEQ(v bool) predicate.Group {
return predicate.Group(sql.FieldEQ(FieldProfitControlEnabled, v))
+297 -32
View File
@@ -704,6 +704,34 @@ func (_c *GroupCreate) SetNillableAllowLive(v *bool) *GroupCreate {
return _c
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (_c *GroupCreate) SetForceOpenaiFast(v bool) *GroupCreate {
_c.mutation.SetForceOpenaiFast(v)
return _c
}
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
func (_c *GroupCreate) SetNillableForceOpenaiFast(v *bool) *GroupCreate {
if v != nil {
_c.SetForceOpenaiFast(*v)
}
return _c
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (_c *GroupCreate) SetFreeOpenaiFast(v bool) *GroupCreate {
_c.mutation.SetFreeOpenaiFast(v)
return _c
}
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
func (_c *GroupCreate) SetNillableFreeOpenaiFast(v *bool) *GroupCreate {
if v != nil {
_c.SetFreeOpenaiFast(*v)
}
return _c
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_c *GroupCreate) SetRequireOauthOnly(v bool) *GroupCreate {
_c.mutation.SetRequireOauthOnly(v)
@@ -760,16 +788,30 @@ func (_c *GroupCreate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
return _c
}
// SetModelsListConfig sets the "models_list_config" field.
func (_c *GroupCreate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupCreate {
_c.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_c *GroupCreate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupCreate {
_c.mutation.SetModelAllowlist(v)
return _c
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_c *GroupCreate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupCreate {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_c *GroupCreate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupCreate {
if v != nil {
_c.SetModelsListConfig(*v)
_c.SetModelAllowlist(*v)
}
return _c
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (_c *GroupCreate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupCreate {
_c.mutation.SetCodexModelsManifestConfig(v)
return _c
}
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
func (_c *GroupCreate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupCreate {
if v != nil {
_c.SetCodexModelsManifestConfig(*v)
}
return _c
}
@@ -802,6 +844,20 @@ func (_c *GroupCreate) SetNillableMaxReasoningEffort(v *string) *GroupCreate {
return _c
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (_c *GroupCreate) SetMaxReasoningEffortOverLimit(v string) *GroupCreate {
_c.mutation.SetMaxReasoningEffortOverLimit(v)
return _c
}
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
func (_c *GroupCreate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupCreate {
if v != nil {
_c.SetMaxReasoningEffortOverLimit(*v)
}
return _c
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (_c *GroupCreate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupCreate {
_c.mutation.SetReasoningEffortMappings(v)
@@ -1095,6 +1151,14 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultAllowLive
_c.mutation.SetAllowLive(v)
}
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
v := group.DefaultForceOpenaiFast
_c.mutation.SetForceOpenaiFast(v)
}
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
v := group.DefaultFreeOpenaiFast
_c.mutation.SetFreeOpenaiFast(v)
}
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
v := group.DefaultRequireOauthOnly
_c.mutation.SetRequireOauthOnly(v)
@@ -1111,9 +1175,13 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultMessagesDispatchModelConfig
_c.mutation.SetMessagesDispatchModelConfig(v)
}
if _, ok := _c.mutation.ModelsListConfig(); !ok {
v := group.DefaultModelsListConfig
_c.mutation.SetModelsListConfig(v)
if _, ok := _c.mutation.ModelAllowlist(); !ok {
v := group.DefaultModelAllowlist
_c.mutation.SetModelAllowlist(v)
}
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
v := group.DefaultCodexModelsManifestConfig
_c.mutation.SetCodexModelsManifestConfig(v)
}
if _, ok := _c.mutation.RpmLimit(); !ok {
v := group.DefaultRpmLimit
@@ -1123,6 +1191,10 @@ func (_c *GroupCreate) defaults() error {
v := group.DefaultMaxReasoningEffort
_c.mutation.SetMaxReasoningEffort(v)
}
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
v := group.DefaultMaxReasoningEffortOverLimit
_c.mutation.SetMaxReasoningEffortOverLimit(v)
}
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
v := group.DefaultReasoningEffortMappings
_c.mutation.SetReasoningEffortMappings(v)
@@ -1286,6 +1358,12 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.AllowLive(); !ok {
return &ValidationError{Name: "allow_live", err: errors.New(`ent: missing required field "Group.allow_live"`)}
}
if _, ok := _c.mutation.ForceOpenaiFast(); !ok {
return &ValidationError{Name: "force_openai_fast", err: errors.New(`ent: missing required field "Group.force_openai_fast"`)}
}
if _, ok := _c.mutation.FreeOpenaiFast(); !ok {
return &ValidationError{Name: "free_openai_fast", err: errors.New(`ent: missing required field "Group.free_openai_fast"`)}
}
if _, ok := _c.mutation.RequireOauthOnly(); !ok {
return &ValidationError{Name: "require_oauth_only", err: errors.New(`ent: missing required field "Group.require_oauth_only"`)}
}
@@ -1303,8 +1381,11 @@ func (_c *GroupCreate) check() error {
if _, ok := _c.mutation.MessagesDispatchModelConfig(); !ok {
return &ValidationError{Name: "messages_dispatch_model_config", err: errors.New(`ent: missing required field "Group.messages_dispatch_model_config"`)}
}
if _, ok := _c.mutation.ModelsListConfig(); !ok {
return &ValidationError{Name: "models_list_config", err: errors.New(`ent: missing required field "Group.models_list_config"`)}
if _, ok := _c.mutation.ModelAllowlist(); !ok {
return &ValidationError{Name: "model_allowlist", err: errors.New(`ent: missing required field "Group.model_allowlist"`)}
}
if _, ok := _c.mutation.CodexModelsManifestConfig(); !ok {
return &ValidationError{Name: "codex_models_manifest_config", err: errors.New(`ent: missing required field "Group.codex_models_manifest_config"`)}
}
if _, ok := _c.mutation.RpmLimit(); !ok {
return &ValidationError{Name: "rpm_limit", err: errors.New(`ent: missing required field "Group.rpm_limit"`)}
@@ -1317,6 +1398,14 @@ func (_c *GroupCreate) check() error {
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
}
}
if _, ok := _c.mutation.MaxReasoningEffortOverLimit(); !ok {
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: errors.New(`ent: missing required field "Group.max_reasoning_effort_over_limit"`)}
}
if v, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
}
}
if _, ok := _c.mutation.ReasoningEffortMappings(); !ok {
return &ValidationError{Name: "reasoning_effort_mappings", err: errors.New(`ent: missing required field "Group.reasoning_effort_mappings"`)}
}
@@ -1560,6 +1649,14 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
_node.AllowLive = value
}
if value, ok := _c.mutation.ForceOpenaiFast(); ok {
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
_node.ForceOpenaiFast = value
}
if value, ok := _c.mutation.FreeOpenaiFast(); ok {
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
_node.FreeOpenaiFast = value
}
if value, ok := _c.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
_node.RequireOauthOnly = value
@@ -1576,9 +1673,13 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
_node.MessagesDispatchModelConfig = value
}
if value, ok := _c.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
_node.ModelsListConfig = value
if value, ok := _c.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
_node.ModelAllowlist = value
}
if value, ok := _c.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
_node.CodexModelsManifestConfig = value
}
if value, ok := _c.mutation.RpmLimit(); ok {
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
@@ -1588,6 +1689,10 @@ func (_c *GroupCreate) createSpec() (*Group, *sqlgraph.CreateSpec) {
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
_node.MaxReasoningEffort = value
}
if value, ok := _c.mutation.MaxReasoningEffortOverLimit(); ok {
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
_node.MaxReasoningEffortOverLimit = value
}
if value, ok := _c.mutation.ReasoningEffortMappings(); ok {
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
_node.ReasoningEffortMappings = value
@@ -2618,6 +2723,30 @@ func (u *GroupUpsert) UpdateAllowLive() *GroupUpsert {
return u
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (u *GroupUpsert) SetForceOpenaiFast(v bool) *GroupUpsert {
u.Set(group.FieldForceOpenaiFast, v)
return u
}
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
func (u *GroupUpsert) UpdateForceOpenaiFast() *GroupUpsert {
u.SetExcluded(group.FieldForceOpenaiFast)
return u
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (u *GroupUpsert) SetFreeOpenaiFast(v bool) *GroupUpsert {
u.Set(group.FieldFreeOpenaiFast, v)
return u
}
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
func (u *GroupUpsert) UpdateFreeOpenaiFast() *GroupUpsert {
u.SetExcluded(group.FieldFreeOpenaiFast)
return u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsert) SetRequireOauthOnly(v bool) *GroupUpsert {
u.Set(group.FieldRequireOauthOnly, v)
@@ -2666,15 +2795,27 @@ func (u *GroupUpsert) UpdateMessagesDispatchModelConfig() *GroupUpsert {
return u
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsert) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsert {
u.Set(group.FieldModelsListConfig, v)
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsert) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsert {
u.Set(group.FieldModelAllowlist, v)
return u
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelsListConfig() *GroupUpsert {
u.SetExcluded(group.FieldModelsListConfig)
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsert) UpdateModelAllowlist() *GroupUpsert {
u.SetExcluded(group.FieldModelAllowlist)
return u
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (u *GroupUpsert) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsert {
u.Set(group.FieldCodexModelsManifestConfig, v)
return u
}
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
func (u *GroupUpsert) UpdateCodexModelsManifestConfig() *GroupUpsert {
u.SetExcluded(group.FieldCodexModelsManifestConfig)
return u
}
@@ -2708,6 +2849,18 @@ func (u *GroupUpsert) UpdateMaxReasoningEffort() *GroupUpsert {
return u
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (u *GroupUpsert) SetMaxReasoningEffortOverLimit(v string) *GroupUpsert {
u.Set(group.FieldMaxReasoningEffortOverLimit, v)
return u
}
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
func (u *GroupUpsert) UpdateMaxReasoningEffortOverLimit() *GroupUpsert {
u.SetExcluded(group.FieldMaxReasoningEffortOverLimit)
return u
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (u *GroupUpsert) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsert {
u.Set(group.FieldReasoningEffortMappings, v)
@@ -3817,6 +3970,34 @@ func (u *GroupUpsertOne) UpdateAllowLive() *GroupUpsertOne {
})
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (u *GroupUpsertOne) SetForceOpenaiFast(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetForceOpenaiFast(v)
})
}
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateForceOpenaiFast() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateForceOpenaiFast()
})
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (u *GroupUpsertOne) SetFreeOpenaiFast(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetFreeOpenaiFast(v)
})
}
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateFreeOpenaiFast() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateFreeOpenaiFast()
})
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsertOne) SetRequireOauthOnly(v bool) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -3873,17 +4054,31 @@ func (u *GroupUpsertOne) UpdateMessagesDispatchModelConfig() *GroupUpsertOne {
})
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsertOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertOne {
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsertOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetModelsListConfig(v)
s.SetModelAllowlist(v)
})
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelsListConfig() *GroupUpsertOne {
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateModelAllowlist() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelsListConfig()
s.UpdateModelAllowlist()
})
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (u *GroupUpsertOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetCodexModelsManifestConfig(v)
})
}
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateCodexModelsManifestConfig() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateCodexModelsManifestConfig()
})
}
@@ -3922,6 +4117,20 @@ func (u *GroupUpsertOne) UpdateMaxReasoningEffort() *GroupUpsertOne {
})
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (u *GroupUpsertOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.SetMaxReasoningEffortOverLimit(v)
})
}
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
func (u *GroupUpsertOne) UpdateMaxReasoningEffortOverLimit() *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
s.UpdateMaxReasoningEffortOverLimit()
})
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (u *GroupUpsertOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertOne {
return u.Update(func(s *GroupUpsert) {
@@ -5207,6 +5416,34 @@ func (u *GroupUpsertBulk) UpdateAllowLive() *GroupUpsertBulk {
})
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (u *GroupUpsertBulk) SetForceOpenaiFast(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetForceOpenaiFast(v)
})
}
// UpdateForceOpenaiFast sets the "force_openai_fast" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateForceOpenaiFast() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateForceOpenaiFast()
})
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (u *GroupUpsertBulk) SetFreeOpenaiFast(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetFreeOpenaiFast(v)
})
}
// UpdateFreeOpenaiFast sets the "free_openai_fast" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateFreeOpenaiFast() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateFreeOpenaiFast()
})
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (u *GroupUpsertBulk) SetRequireOauthOnly(v bool) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
@@ -5263,17 +5500,31 @@ func (u *GroupUpsertBulk) UpdateMessagesDispatchModelConfig() *GroupUpsertBulk {
})
}
// SetModelsListConfig sets the "models_list_config" field.
func (u *GroupUpsertBulk) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpsertBulk {
// SetModelAllowlist sets the "model_allowlist" field.
func (u *GroupUpsertBulk) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetModelsListConfig(v)
s.SetModelAllowlist(v)
})
}
// UpdateModelsListConfig sets the "models_list_config" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelsListConfig() *GroupUpsertBulk {
// UpdateModelAllowlist sets the "model_allowlist" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateModelAllowlist() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateModelsListConfig()
s.UpdateModelAllowlist()
})
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (u *GroupUpsertBulk) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetCodexModelsManifestConfig(v)
})
}
// UpdateCodexModelsManifestConfig sets the "codex_models_manifest_config" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateCodexModelsManifestConfig() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateCodexModelsManifestConfig()
})
}
@@ -5312,6 +5563,20 @@ func (u *GroupUpsertBulk) UpdateMaxReasoningEffort() *GroupUpsertBulk {
})
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (u *GroupUpsertBulk) SetMaxReasoningEffortOverLimit(v string) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.SetMaxReasoningEffortOverLimit(v)
})
}
// UpdateMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field to the value that was provided on create.
func (u *GroupUpsertBulk) UpdateMaxReasoningEffortOverLimit() *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
s.UpdateMaxReasoningEffortOverLimit()
})
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (u *GroupUpsertBulk) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpsertBulk {
return u.Update(func(s *GroupUpsert) {
+162 -16
View File
@@ -989,6 +989,34 @@ func (_u *GroupUpdate) SetNillableAllowLive(v *bool) *GroupUpdate {
return _u
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (_u *GroupUpdate) SetForceOpenaiFast(v bool) *GroupUpdate {
_u.mutation.SetForceOpenaiFast(v)
return _u
}
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableForceOpenaiFast(v *bool) *GroupUpdate {
if v != nil {
_u.SetForceOpenaiFast(*v)
}
return _u
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (_u *GroupUpdate) SetFreeOpenaiFast(v bool) *GroupUpdate {
_u.mutation.SetFreeOpenaiFast(v)
return _u
}
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableFreeOpenaiFast(v *bool) *GroupUpdate {
if v != nil {
_u.SetFreeOpenaiFast(*v)
}
return _u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_u *GroupUpdate) SetRequireOauthOnly(v bool) *GroupUpdate {
_u.mutation.SetRequireOauthOnly(v)
@@ -1045,16 +1073,30 @@ func (_u *GroupUpdate) SetNillableMessagesDispatchModelConfig(v *domain.OpenAIMe
return _u
}
// SetModelsListConfig sets the "models_list_config" field.
func (_u *GroupUpdate) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdate {
_u.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_u *GroupUpdate) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdate {
_u.mutation.SetModelAllowlist(v)
return _u
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdate {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdate {
if v != nil {
_u.SetModelsListConfig(*v)
_u.SetModelAllowlist(*v)
}
return _u
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (_u *GroupUpdate) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdate {
_u.mutation.SetCodexModelsManifestConfig(v)
return _u
}
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdate {
if v != nil {
_u.SetCodexModelsManifestConfig(*v)
}
return _u
}
@@ -1094,6 +1136,20 @@ func (_u *GroupUpdate) SetNillableMaxReasoningEffort(v *string) *GroupUpdate {
return _u
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (_u *GroupUpdate) SetMaxReasoningEffortOverLimit(v string) *GroupUpdate {
_u.mutation.SetMaxReasoningEffortOverLimit(v)
return _u
}
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
func (_u *GroupUpdate) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdate {
if v != nil {
_u.SetMaxReasoningEffortOverLimit(*v)
}
return _u
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (_u *GroupUpdate) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdate {
_u.mutation.SetReasoningEffortMappings(v)
@@ -1487,6 +1543,11 @@ func (_u *GroupUpdate) check() error {
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
}
}
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
}
}
return nil
}
@@ -1797,6 +1858,12 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.AllowLive(); ok {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
}
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
}
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
}
if value, ok := _u.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
}
@@ -1809,8 +1876,11 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
if value, ok := _u.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
}
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.RpmLimit(); ok {
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
@@ -1821,6 +1891,9 @@ func (_u *GroupUpdate) sqlSave(ctx context.Context) (_node int, err error) {
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
}
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
}
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
}
@@ -3110,6 +3183,34 @@ func (_u *GroupUpdateOne) SetNillableAllowLive(v *bool) *GroupUpdateOne {
return _u
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (_u *GroupUpdateOne) SetForceOpenaiFast(v bool) *GroupUpdateOne {
_u.mutation.SetForceOpenaiFast(v)
return _u
}
// SetNillableForceOpenaiFast sets the "force_openai_fast" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableForceOpenaiFast(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetForceOpenaiFast(*v)
}
return _u
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (_u *GroupUpdateOne) SetFreeOpenaiFast(v bool) *GroupUpdateOne {
_u.mutation.SetFreeOpenaiFast(v)
return _u
}
// SetNillableFreeOpenaiFast sets the "free_openai_fast" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableFreeOpenaiFast(v *bool) *GroupUpdateOne {
if v != nil {
_u.SetFreeOpenaiFast(*v)
}
return _u
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (_u *GroupUpdateOne) SetRequireOauthOnly(v bool) *GroupUpdateOne {
_u.mutation.SetRequireOauthOnly(v)
@@ -3166,16 +3267,30 @@ func (_u *GroupUpdateOne) SetNillableMessagesDispatchModelConfig(v *domain.OpenA
return _u
}
// SetModelsListConfig sets the "models_list_config" field.
func (_u *GroupUpdateOne) SetModelsListConfig(v domain.GroupModelsListConfig) *GroupUpdateOne {
_u.mutation.SetModelsListConfig(v)
// SetModelAllowlist sets the "model_allowlist" field.
func (_u *GroupUpdateOne) SetModelAllowlist(v domain.GroupModelAllowlist) *GroupUpdateOne {
_u.mutation.SetModelAllowlist(v)
return _u
}
// SetNillableModelsListConfig sets the "models_list_config" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableModelsListConfig(v *domain.GroupModelsListConfig) *GroupUpdateOne {
// SetNillableModelAllowlist sets the "model_allowlist" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableModelAllowlist(v *domain.GroupModelAllowlist) *GroupUpdateOne {
if v != nil {
_u.SetModelsListConfig(*v)
_u.SetModelAllowlist(*v)
}
return _u
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (_u *GroupUpdateOne) SetCodexModelsManifestConfig(v domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
_u.mutation.SetCodexModelsManifestConfig(v)
return _u
}
// SetNillableCodexModelsManifestConfig sets the "codex_models_manifest_config" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableCodexModelsManifestConfig(v *domain.GroupCodexModelsManifestConfig) *GroupUpdateOne {
if v != nil {
_u.SetCodexModelsManifestConfig(*v)
}
return _u
}
@@ -3215,6 +3330,20 @@ func (_u *GroupUpdateOne) SetNillableMaxReasoningEffort(v *string) *GroupUpdateO
return _u
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (_u *GroupUpdateOne) SetMaxReasoningEffortOverLimit(v string) *GroupUpdateOne {
_u.mutation.SetMaxReasoningEffortOverLimit(v)
return _u
}
// SetNillableMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field if the given value is not nil.
func (_u *GroupUpdateOne) SetNillableMaxReasoningEffortOverLimit(v *string) *GroupUpdateOne {
if v != nil {
_u.SetMaxReasoningEffortOverLimit(*v)
}
return _u
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (_u *GroupUpdateOne) SetReasoningEffortMappings(v []domain.ReasoningEffortMapping) *GroupUpdateOne {
_u.mutation.SetReasoningEffortMappings(v)
@@ -3621,6 +3750,11 @@ func (_u *GroupUpdateOne) check() error {
return &ValidationError{Name: "max_reasoning_effort", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort": %w`, err)}
}
}
if v, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
if err := group.MaxReasoningEffortOverLimitValidator(v); err != nil {
return &ValidationError{Name: "max_reasoning_effort_over_limit", err: fmt.Errorf(`ent: validator failed for field "Group.max_reasoning_effort_over_limit": %w`, err)}
}
}
return nil
}
@@ -3948,6 +4082,12 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.AllowLive(); ok {
_spec.SetField(group.FieldAllowLive, field.TypeBool, value)
}
if value, ok := _u.mutation.ForceOpenaiFast(); ok {
_spec.SetField(group.FieldForceOpenaiFast, field.TypeBool, value)
}
if value, ok := _u.mutation.FreeOpenaiFast(); ok {
_spec.SetField(group.FieldFreeOpenaiFast, field.TypeBool, value)
}
if value, ok := _u.mutation.RequireOauthOnly(); ok {
_spec.SetField(group.FieldRequireOauthOnly, field.TypeBool, value)
}
@@ -3960,8 +4100,11 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.MessagesDispatchModelConfig(); ok {
_spec.SetField(group.FieldMessagesDispatchModelConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.ModelsListConfig(); ok {
_spec.SetField(group.FieldModelsListConfig, field.TypeJSON, value)
if value, ok := _u.mutation.ModelAllowlist(); ok {
_spec.SetField(group.FieldModelAllowlist, field.TypeJSON, value)
}
if value, ok := _u.mutation.CodexModelsManifestConfig(); ok {
_spec.SetField(group.FieldCodexModelsManifestConfig, field.TypeJSON, value)
}
if value, ok := _u.mutation.RpmLimit(); ok {
_spec.SetField(group.FieldRpmLimit, field.TypeInt, value)
@@ -3972,6 +4115,9 @@ func (_u *GroupUpdateOne) sqlSave(ctx context.Context) (_node *Group, err error)
if value, ok := _u.mutation.MaxReasoningEffort(); ok {
_spec.SetField(group.FieldMaxReasoningEffort, field.TypeString, value)
}
if value, ok := _u.mutation.MaxReasoningEffortOverLimit(); ok {
_spec.SetField(group.FieldMaxReasoningEffortOverLimit, field.TypeString, value)
}
if value, ok := _u.mutation.ReasoningEffortMappings(); ok {
_spec.SetField(group.FieldReasoningEffortMappings, field.TypeJSON, value)
}
+5 -1
View File
@@ -954,13 +954,17 @@ var (
{Name: "sort_order", Type: field.TypeInt, Default: 0},
{Name: "allow_messages_dispatch", Type: field.TypeBool, Default: false},
{Name: "allow_live", Type: field.TypeBool, Default: false},
{Name: "force_openai_fast", Type: field.TypeBool, Default: false},
{Name: "free_openai_fast", Type: field.TypeBool, Default: false},
{Name: "require_oauth_only", Type: field.TypeBool, Default: false},
{Name: "require_privacy_set", Type: field.TypeBool, Default: false},
{Name: "default_mapped_model", Type: field.TypeString, Size: 100, Default: ""},
{Name: "messages_dispatch_model_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "models_list_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "model_allowlist", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "codex_models_manifest_config", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "rpm_limit", Type: field.TypeInt, Default: 0},
{Name: "max_reasoning_effort", Type: field.TypeString, Size: 20, Default: ""},
{Name: "max_reasoning_effort_over_limit", Type: field.TypeString, Size: 20, Default: "downgrade"},
{Name: "reasoning_effort_mappings", Type: field.TypeJSON, SchemaType: map[string]string{"postgres": "jsonb"}},
{Name: "profit_control_enabled", Type: field.TypeBool, Default: false},
{Name: "profit_min_margin", Type: field.TypeFloat64, Default: 0, SchemaType: map[string]string{"postgres": "decimal(10,4)"}},
+244 -28
View File
@@ -22158,14 +22158,18 @@ type GroupMutation struct {
addsort_order *int
allow_messages_dispatch *bool
allow_live *bool
force_openai_fast *bool
free_openai_fast *bool
require_oauth_only *bool
require_privacy_set *bool
default_mapped_model *string
messages_dispatch_model_config *domain.OpenAIMessagesDispatchModelConfig
models_list_config *domain.GroupModelsListConfig
model_allowlist *domain.GroupModelAllowlist
codex_models_manifest_config *domain.GroupCodexModelsManifestConfig
rpm_limit *int
addrpm_limit *int
max_reasoning_effort *string
max_reasoning_effort_over_limit *string
reasoning_effort_mappings *[]domain.ReasoningEffortMapping
appendreasoning_effort_mappings []domain.ReasoningEffortMapping
profit_control_enabled *bool
@@ -24944,6 +24948,78 @@ func (m *GroupMutation) ResetAllowLive() {
m.allow_live = nil
}
// SetForceOpenaiFast sets the "force_openai_fast" field.
func (m *GroupMutation) SetForceOpenaiFast(b bool) {
m.force_openai_fast = &b
}
// ForceOpenaiFast returns the value of the "force_openai_fast" field in the mutation.
func (m *GroupMutation) ForceOpenaiFast() (r bool, exists bool) {
v := m.force_openai_fast
if v == nil {
return
}
return *v, true
}
// OldForceOpenaiFast returns the old "force_openai_fast" 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) OldForceOpenaiFast(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldForceOpenaiFast is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldForceOpenaiFast requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldForceOpenaiFast: %w", err)
}
return oldValue.ForceOpenaiFast, nil
}
// ResetForceOpenaiFast resets all changes to the "force_openai_fast" field.
func (m *GroupMutation) ResetForceOpenaiFast() {
m.force_openai_fast = nil
}
// SetFreeOpenaiFast sets the "free_openai_fast" field.
func (m *GroupMutation) SetFreeOpenaiFast(b bool) {
m.free_openai_fast = &b
}
// FreeOpenaiFast returns the value of the "free_openai_fast" field in the mutation.
func (m *GroupMutation) FreeOpenaiFast() (r bool, exists bool) {
v := m.free_openai_fast
if v == nil {
return
}
return *v, true
}
// OldFreeOpenaiFast returns the old "free_openai_fast" 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) OldFreeOpenaiFast(ctx context.Context) (v bool, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldFreeOpenaiFast is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldFreeOpenaiFast requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldFreeOpenaiFast: %w", err)
}
return oldValue.FreeOpenaiFast, nil
}
// ResetFreeOpenaiFast resets all changes to the "free_openai_fast" field.
func (m *GroupMutation) ResetFreeOpenaiFast() {
m.free_openai_fast = nil
}
// SetRequireOauthOnly sets the "require_oauth_only" field.
func (m *GroupMutation) SetRequireOauthOnly(b bool) {
m.require_oauth_only = &b
@@ -25088,40 +25164,76 @@ func (m *GroupMutation) ResetMessagesDispatchModelConfig() {
m.messages_dispatch_model_config = nil
}
// SetModelsListConfig sets the "models_list_config" field.
func (m *GroupMutation) SetModelsListConfig(dmlc domain.GroupModelsListConfig) {
m.models_list_config = &dmlc
// SetModelAllowlist sets the "model_allowlist" field.
func (m *GroupMutation) SetModelAllowlist(dma domain.GroupModelAllowlist) {
m.model_allowlist = &dma
}
// ModelsListConfig returns the value of the "models_list_config" field in the mutation.
func (m *GroupMutation) ModelsListConfig() (r domain.GroupModelsListConfig, exists bool) {
v := m.models_list_config
// ModelAllowlist returns the value of the "model_allowlist" field in the mutation.
func (m *GroupMutation) ModelAllowlist() (r domain.GroupModelAllowlist, exists bool) {
v := m.model_allowlist
if v == nil {
return
}
return *v, true
}
// OldModelsListConfig returns the old "models_list_config" field's value of the Group entity.
// OldModelAllowlist returns the old "model_allowlist" 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) OldModelsListConfig(ctx context.Context) (v domain.GroupModelsListConfig, err error) {
func (m *GroupMutation) OldModelAllowlist(ctx context.Context) (v domain.GroupModelAllowlist, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldModelsListConfig is only allowed on UpdateOne operations")
return v, errors.New("OldModelAllowlist is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldModelsListConfig requires an ID field in the mutation")
return v, errors.New("OldModelAllowlist requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldModelsListConfig: %w", err)
return v, fmt.Errorf("querying old value for OldModelAllowlist: %w", err)
}
return oldValue.ModelsListConfig, nil
return oldValue.ModelAllowlist, nil
}
// ResetModelsListConfig resets all changes to the "models_list_config" field.
func (m *GroupMutation) ResetModelsListConfig() {
m.models_list_config = nil
// ResetModelAllowlist resets all changes to the "model_allowlist" field.
func (m *GroupMutation) ResetModelAllowlist() {
m.model_allowlist = nil
}
// SetCodexModelsManifestConfig sets the "codex_models_manifest_config" field.
func (m *GroupMutation) SetCodexModelsManifestConfig(dcmmc domain.GroupCodexModelsManifestConfig) {
m.codex_models_manifest_config = &dcmmc
}
// CodexModelsManifestConfig returns the value of the "codex_models_manifest_config" field in the mutation.
func (m *GroupMutation) CodexModelsManifestConfig() (r domain.GroupCodexModelsManifestConfig, exists bool) {
v := m.codex_models_manifest_config
if v == nil {
return
}
return *v, true
}
// OldCodexModelsManifestConfig returns the old "codex_models_manifest_config" 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) OldCodexModelsManifestConfig(ctx context.Context) (v domain.GroupCodexModelsManifestConfig, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldCodexModelsManifestConfig is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldCodexModelsManifestConfig requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldCodexModelsManifestConfig: %w", err)
}
return oldValue.CodexModelsManifestConfig, nil
}
// ResetCodexModelsManifestConfig resets all changes to the "codex_models_manifest_config" field.
func (m *GroupMutation) ResetCodexModelsManifestConfig() {
m.codex_models_manifest_config = nil
}
// SetRpmLimit sets the "rpm_limit" field.
@@ -25216,6 +25328,42 @@ func (m *GroupMutation) ResetMaxReasoningEffort() {
m.max_reasoning_effort = nil
}
// SetMaxReasoningEffortOverLimit sets the "max_reasoning_effort_over_limit" field.
func (m *GroupMutation) SetMaxReasoningEffortOverLimit(s string) {
m.max_reasoning_effort_over_limit = &s
}
// MaxReasoningEffortOverLimit returns the value of the "max_reasoning_effort_over_limit" field in the mutation.
func (m *GroupMutation) MaxReasoningEffortOverLimit() (r string, exists bool) {
v := m.max_reasoning_effort_over_limit
if v == nil {
return
}
return *v, true
}
// OldMaxReasoningEffortOverLimit returns the old "max_reasoning_effort_over_limit" 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) OldMaxReasoningEffortOverLimit(ctx context.Context) (v string, err error) {
if !m.op.Is(OpUpdateOne) {
return v, errors.New("OldMaxReasoningEffortOverLimit is only allowed on UpdateOne operations")
}
if m.id == nil || m.oldValue == nil {
return v, errors.New("OldMaxReasoningEffortOverLimit requires an ID field in the mutation")
}
oldValue, err := m.oldValue(ctx)
if err != nil {
return v, fmt.Errorf("querying old value for OldMaxReasoningEffortOverLimit: %w", err)
}
return oldValue.MaxReasoningEffortOverLimit, nil
}
// ResetMaxReasoningEffortOverLimit resets all changes to the "max_reasoning_effort_over_limit" field.
func (m *GroupMutation) ResetMaxReasoningEffortOverLimit() {
m.max_reasoning_effort_over_limit = nil
}
// SetReasoningEffortMappings sets the "reasoning_effort_mappings" field.
func (m *GroupMutation) SetReasoningEffortMappings(dem []domain.ReasoningEffortMapping) {
m.reasoning_effort_mappings = &dem
@@ -25773,7 +25921,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, 62)
fields := make([]string, 0, 66)
if m.created_at != nil {
fields = append(fields, group.FieldCreatedAt)
}
@@ -25927,6 +26075,12 @@ func (m *GroupMutation) Fields() []string {
if m.allow_live != nil {
fields = append(fields, group.FieldAllowLive)
}
if m.force_openai_fast != nil {
fields = append(fields, group.FieldForceOpenaiFast)
}
if m.free_openai_fast != nil {
fields = append(fields, group.FieldFreeOpenaiFast)
}
if m.require_oauth_only != nil {
fields = append(fields, group.FieldRequireOauthOnly)
}
@@ -25939,8 +26093,11 @@ func (m *GroupMutation) Fields() []string {
if m.messages_dispatch_model_config != nil {
fields = append(fields, group.FieldMessagesDispatchModelConfig)
}
if m.models_list_config != nil {
fields = append(fields, group.FieldModelsListConfig)
if m.model_allowlist != nil {
fields = append(fields, group.FieldModelAllowlist)
}
if m.codex_models_manifest_config != nil {
fields = append(fields, group.FieldCodexModelsManifestConfig)
}
if m.rpm_limit != nil {
fields = append(fields, group.FieldRpmLimit)
@@ -25948,6 +26105,9 @@ func (m *GroupMutation) Fields() []string {
if m.max_reasoning_effort != nil {
fields = append(fields, group.FieldMaxReasoningEffort)
}
if m.max_reasoning_effort_over_limit != nil {
fields = append(fields, group.FieldMaxReasoningEffortOverLimit)
}
if m.reasoning_effort_mappings != nil {
fields = append(fields, group.FieldReasoningEffortMappings)
}
@@ -26070,6 +26230,10 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.AllowMessagesDispatch()
case group.FieldAllowLive:
return m.AllowLive()
case group.FieldForceOpenaiFast:
return m.ForceOpenaiFast()
case group.FieldFreeOpenaiFast:
return m.FreeOpenaiFast()
case group.FieldRequireOauthOnly:
return m.RequireOauthOnly()
case group.FieldRequirePrivacySet:
@@ -26078,12 +26242,16 @@ func (m *GroupMutation) Field(name string) (ent.Value, bool) {
return m.DefaultMappedModel()
case group.FieldMessagesDispatchModelConfig:
return m.MessagesDispatchModelConfig()
case group.FieldModelsListConfig:
return m.ModelsListConfig()
case group.FieldModelAllowlist:
return m.ModelAllowlist()
case group.FieldCodexModelsManifestConfig:
return m.CodexModelsManifestConfig()
case group.FieldRpmLimit:
return m.RpmLimit()
case group.FieldMaxReasoningEffort:
return m.MaxReasoningEffort()
case group.FieldMaxReasoningEffortOverLimit:
return m.MaxReasoningEffortOverLimit()
case group.FieldReasoningEffortMappings:
return m.ReasoningEffortMappings()
case group.FieldProfitControlEnabled:
@@ -26203,6 +26371,10 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldAllowMessagesDispatch(ctx)
case group.FieldAllowLive:
return m.OldAllowLive(ctx)
case group.FieldForceOpenaiFast:
return m.OldForceOpenaiFast(ctx)
case group.FieldFreeOpenaiFast:
return m.OldFreeOpenaiFast(ctx)
case group.FieldRequireOauthOnly:
return m.OldRequireOauthOnly(ctx)
case group.FieldRequirePrivacySet:
@@ -26211,12 +26383,16 @@ func (m *GroupMutation) OldField(ctx context.Context, name string) (ent.Value, e
return m.OldDefaultMappedModel(ctx)
case group.FieldMessagesDispatchModelConfig:
return m.OldMessagesDispatchModelConfig(ctx)
case group.FieldModelsListConfig:
return m.OldModelsListConfig(ctx)
case group.FieldModelAllowlist:
return m.OldModelAllowlist(ctx)
case group.FieldCodexModelsManifestConfig:
return m.OldCodexModelsManifestConfig(ctx)
case group.FieldRpmLimit:
return m.OldRpmLimit(ctx)
case group.FieldMaxReasoningEffort:
return m.OldMaxReasoningEffort(ctx)
case group.FieldMaxReasoningEffortOverLimit:
return m.OldMaxReasoningEffortOverLimit(ctx)
case group.FieldReasoningEffortMappings:
return m.OldReasoningEffortMappings(ctx)
case group.FieldProfitControlEnabled:
@@ -26591,6 +26767,20 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetAllowLive(v)
return nil
case group.FieldForceOpenaiFast:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetForceOpenaiFast(v)
return nil
case group.FieldFreeOpenaiFast:
v, ok := value.(bool)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetFreeOpenaiFast(v)
return nil
case group.FieldRequireOauthOnly:
v, ok := value.(bool)
if !ok {
@@ -26619,12 +26809,19 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetMessagesDispatchModelConfig(v)
return nil
case group.FieldModelsListConfig:
v, ok := value.(domain.GroupModelsListConfig)
case group.FieldModelAllowlist:
v, ok := value.(domain.GroupModelAllowlist)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetModelsListConfig(v)
m.SetModelAllowlist(v)
return nil
case group.FieldCodexModelsManifestConfig:
v, ok := value.(domain.GroupCodexModelsManifestConfig)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetCodexModelsManifestConfig(v)
return nil
case group.FieldRpmLimit:
v, ok := value.(int)
@@ -26640,6 +26837,13 @@ func (m *GroupMutation) SetField(name string, value ent.Value) error {
}
m.SetMaxReasoningEffort(v)
return nil
case group.FieldMaxReasoningEffortOverLimit:
v, ok := value.(string)
if !ok {
return fmt.Errorf("unexpected type %T for field %s", value, name)
}
m.SetMaxReasoningEffortOverLimit(v)
return nil
case group.FieldReasoningEffortMappings:
v, ok := value.([]domain.ReasoningEffortMapping)
if !ok {
@@ -27332,6 +27536,12 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldAllowLive:
m.ResetAllowLive()
return nil
case group.FieldForceOpenaiFast:
m.ResetForceOpenaiFast()
return nil
case group.FieldFreeOpenaiFast:
m.ResetFreeOpenaiFast()
return nil
case group.FieldRequireOauthOnly:
m.ResetRequireOauthOnly()
return nil
@@ -27344,8 +27554,11 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldMessagesDispatchModelConfig:
m.ResetMessagesDispatchModelConfig()
return nil
case group.FieldModelsListConfig:
m.ResetModelsListConfig()
case group.FieldModelAllowlist:
m.ResetModelAllowlist()
return nil
case group.FieldCodexModelsManifestConfig:
m.ResetCodexModelsManifestConfig()
return nil
case group.FieldRpmLimit:
m.ResetRpmLimit()
@@ -27353,6 +27566,9 @@ func (m *GroupMutation) ResetField(name string) error {
case group.FieldMaxReasoningEffort:
m.ResetMaxReasoningEffort()
return nil
case group.FieldMaxReasoningEffortOverLimit:
m.ResetMaxReasoningEffortOverLimit()
return nil
case group.FieldReasoningEffortMappings:
m.ResetReasoningEffortMappings()
return nil
+32 -14
View File
@@ -1157,52 +1157,70 @@ func init() {
groupDescAllowLive := groupFields[47].Descriptor()
// group.DefaultAllowLive holds the default value on creation for the allow_live field.
group.DefaultAllowLive = groupDescAllowLive.Default.(bool)
// groupDescForceOpenaiFast is the schema descriptor for force_openai_fast field.
groupDescForceOpenaiFast := groupFields[48].Descriptor()
// group.DefaultForceOpenaiFast holds the default value on creation for the force_openai_fast field.
group.DefaultForceOpenaiFast = groupDescForceOpenaiFast.Default.(bool)
// groupDescFreeOpenaiFast is the schema descriptor for free_openai_fast field.
groupDescFreeOpenaiFast := groupFields[49].Descriptor()
// group.DefaultFreeOpenaiFast holds the default value on creation for the free_openai_fast field.
group.DefaultFreeOpenaiFast = groupDescFreeOpenaiFast.Default.(bool)
// groupDescRequireOauthOnly is the schema descriptor for require_oauth_only field.
groupDescRequireOauthOnly := groupFields[48].Descriptor()
groupDescRequireOauthOnly := groupFields[50].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[49].Descriptor()
groupDescRequirePrivacySet := groupFields[51].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[50].Descriptor()
groupDescDefaultMappedModel := groupFields[52].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[51].Descriptor()
groupDescMessagesDispatchModelConfig := groupFields[53].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[52].Descriptor()
// group.DefaultModelsListConfig holds the default value on creation for the models_list_config field.
group.DefaultModelsListConfig = groupDescModelsListConfig.Default.(domain.GroupModelsListConfig)
// groupDescModelAllowlist is the schema descriptor for model_allowlist field.
groupDescModelAllowlist := groupFields[54].Descriptor()
// group.DefaultModelAllowlist holds the default value on creation for the model_allowlist field.
group.DefaultModelAllowlist = groupDescModelAllowlist.Default.(domain.GroupModelAllowlist)
// groupDescCodexModelsManifestConfig is the schema descriptor for codex_models_manifest_config field.
groupDescCodexModelsManifestConfig := groupFields[55].Descriptor()
// group.DefaultCodexModelsManifestConfig holds the default value on creation for the codex_models_manifest_config field.
group.DefaultCodexModelsManifestConfig = groupDescCodexModelsManifestConfig.Default.(domain.GroupCodexModelsManifestConfig)
// groupDescRpmLimit is the schema descriptor for rpm_limit field.
groupDescRpmLimit := groupFields[53].Descriptor()
groupDescRpmLimit := groupFields[56].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[54].Descriptor()
groupDescMaxReasoningEffort := groupFields[57].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)
// groupDescMaxReasoningEffortOverLimit is the schema descriptor for max_reasoning_effort_over_limit field.
groupDescMaxReasoningEffortOverLimit := groupFields[58].Descriptor()
// group.DefaultMaxReasoningEffortOverLimit holds the default value on creation for the max_reasoning_effort_over_limit field.
group.DefaultMaxReasoningEffortOverLimit = groupDescMaxReasoningEffortOverLimit.Default.(string)
// group.MaxReasoningEffortOverLimitValidator is a validator for the "max_reasoning_effort_over_limit" field. It is called by the builders before save.
group.MaxReasoningEffortOverLimitValidator = groupDescMaxReasoningEffortOverLimit.Validators[0].(func(string) error)
// groupDescReasoningEffortMappings is the schema descriptor for reasoning_effort_mappings field.
groupDescReasoningEffortMappings := groupFields[55].Descriptor()
groupDescReasoningEffortMappings := groupFields[59].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[56].Descriptor()
groupDescProfitControlEnabled := groupFields[60].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[57].Descriptor()
groupDescProfitMinMargin := groupFields[61].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[58].Descriptor()
groupDescProfitSafetyBuffer := groupFields[62].Descriptor()
// group.DefaultProfitSafetyBuffer holds the default value on creation for the profit_safety_buffer field.
group.DefaultProfitSafetyBuffer = groupDescProfitSafetyBuffer.Default.(float64)
idempotencyrecordMixin := schema.IdempotencyRecord{}.Mixin()
+18 -4
View File
@@ -242,6 +242,12 @@ func (Group) Fields() []ent.Field {
field.Bool("allow_live").
Default(false).
Comment("是否允许此 OpenAI 分组访问 Live 接口"),
field.Bool("force_openai_fast").
Default(false).
Comment("是否强制此 OpenAI/Composite 分组请求使用 service_tier=priority"),
field.Bool("free_openai_fast").
Default(false).
Comment("是否让此 OpenAI/Composite 分组的 Fast 请求按 Standard 价格计费"),
field.Bool("require_oauth_only").
Default(false).
Comment("仅允许非 apikey 类型账号关联到此分组"),
@@ -256,10 +262,14 @@ func (Group) Fields() []ent.Field {
Default(domain.OpenAIMessagesDispatchModelConfig{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("OpenAI Messages 调度模型配置:按 Claude 系列/精确模型映射到目标 GPT 模型"),
field.JSON("models_list_config", domain.GroupModelsListConfig{}).
Default(domain.GroupModelsListConfig{}).
field.JSON("model_allowlist", domain.GroupModelAllowlist{}).
Default(domain.GroupModelAllowlist{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("自定义 /v1/models 展示列表配置;仅影响模型列表响应,不影响调度"),
Comment("分组模型白名单:同时约束模型列表接口与请求准入"),
field.JSON("codex_models_manifest_config", domain.GroupCodexModelsManifestConfig{}).
Default(domain.GroupCodexModelsManifestConfig{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("固定账号获取 Codex Model Manifest 配置;开启后 /models 请求只用选定账号拉取(仅 openai 平台)"),
// 分组级每分钟请求数上限(0 = 不限制)。设置后优先于用户级兜底生效。
field.Int("rpm_limit").
@@ -271,10 +281,14 @@ func (Group) Fields() []ent.Field {
MaxLen(20).
Default("").
Comment("OpenAI reasoning effort 上限;可选 minimal/low/medium/high/xhigh/max"),
field.String("max_reasoning_effort_over_limit").
MaxLen(20).
Default("downgrade").
Comment("超过推理强度上限时的访问控制:downgrade 自动降档,deny 拒绝访问"),
field.JSON("reasoning_effort_mappings", []domain.ReasoningEffortMapping{}).
Default([]domain.ReasoningEffortMapping{}).
SchemaType(map[string]string{dialect.Postgres: "jsonb"}).
Comment("OpenAI reasoning effort 自定义精确映射;先映射再应用上限"),
Comment("OpenAI reasoning effort 自定义映射;可按模型精确名、前缀或后缀限定,先映射再应用上限"),
// 分组利润控制(migration 192/193):openai/anthropic/gemini/grok/antigravity
// 的 token 分组可启用,composite 分组不能直接启用。
+4 -1
View File
@@ -667,6 +667,8 @@ type PricingConfig struct {
DataDir string `mapstructure:"data_dir"`
// 回退文件路径
FallbackFile string `mapstructure:"fallback_file"`
// 覆盖补丁文件路径(可选):条目按字段浅合并覆盖目录/回退数据,优先级最高
OverrideFile string `mapstructure:"override_file"`
// 更新间隔(小时)
UpdateIntervalHours int `mapstructure:"update_interval_hours"`
// 哈希校验间隔(分钟)
@@ -2279,11 +2281,12 @@ func setDefaults() {
viper.SetDefault("rate_limit.overload_cooldown_minutes", 10)
viper.SetDefault("rate_limit.oauth_401_cooldown_minutes", 10)
// Pricing - 从 model-price-repo 同步模型定价和上下文窗口数据(固定到 commit,避免分支漂移)
// Pricing - 从 model-price-repo main 分支同步模型定价和上下文窗口数据
viper.SetDefault("pricing.remote_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.json")
viper.SetDefault("pricing.hash_url", "https://raw.githubusercontent.com/Wei-Shaw/model-price-repo/main/model_prices_and_context_window.sha256")
viper.SetDefault("pricing.data_dir", "./data")
viper.SetDefault("pricing.fallback_file", "./resources/model-pricing/model_prices_and_context_window.json")
viper.SetDefault("pricing.override_file", "")
viper.SetDefault("pricing.update_interval_hours", 24)
viper.SetDefault("pricing.hash_check_interval_minutes", 10)
@@ -0,0 +1,15 @@
package domain
// GroupCodexModelsManifestConfig controls which accounts fetch the Codex
// models manifest for an OpenAI group. When enabled, the group's Codex
// /models requests bypass the scheduler and only use the pinned accounts.
type GroupCodexModelsManifestConfig struct {
Enabled bool `json:"enabled"`
// AccountIDs is the ordered list of pinned account IDs. Order decides
// merge precedence for duplicate model slugs.
AccountIDs []int64 `json:"account_ids,omitempty"`
// FallbackToScheduler controls the behavior when no pinned account is
// usable or all upstream fetches fail: fall back to the scheduler loop
// (true) or return the upstream error / 503 (false, default).
FallbackToScheduler bool `json:"fallback_to_scheduler,omitempty"`
}
+4 -2
View File
@@ -43,7 +43,7 @@ const (
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(仅 deepseek,适配 Codex)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
@@ -98,6 +98,7 @@ const AntigravityGemini31ProAgentModel = "gemini-pro-agent"
// 与前端 useModelWhitelist.ts 中的 antigravityDefaultMappings 保持一致
var DefaultAntigravityModelMapping = map[string]string{
// Claude 白名单
"claude-fable-5-1": "claude-fable-5-1", // 官方模型
"claude-fable-5": "claude-fable-5", // 官方模型
"claude-opus-4-8": "claude-opus-4-8", // 官方模型
"claude-opus-4-7": "claude-opus-4-7", // 官方模型
@@ -158,7 +159,8 @@ var DefaultAntigravityModelMapping = map[string]string{
// aws_region 自动调整为匹配的区域前缀(如 eu.、apac.、jp. 等)
var DefaultBedrockModelMapping = map[string]string{
// Claude Fable
"claude-fable-5": "anthropic.claude-fable-5",
"claude-fable-5-1": "anthropic.claude-fable-5-1",
"claude-fable-5": "anthropic.claude-fable-5",
// Claude Opus
"claude-opus-5": "us.anthropic.claude-opus-5-v1",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
+6 -4
View File
@@ -29,8 +29,9 @@ func TestDefaultAntigravityModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-fable-5": "claude-fable-5",
"claude-opus-4-8": "claude-opus-4-8",
"claude-fable-5-1": "claude-fable-5-1",
"claude-fable-5": "claude-fable-5",
"claude-opus-4-8": "claude-opus-4-8",
}
for from, want := range cases {
got, ok := DefaultAntigravityModelMapping[from]
@@ -92,8 +93,9 @@ func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
cases := map[string]string{
"claude-fable-5": "anthropic.claude-fable-5",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-fable-5-1": "anthropic.claude-fable-5-1",
"claude-fable-5": "anthropic.claude-fable-5",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
}
for from, want := range cases {
got, ok := DefaultBedrockModelMapping[from]
@@ -0,0 +1,8 @@
package domain
// GroupModelAllowlist 是分组级模型白名单:开启后既过滤模型列表类接口的返回内容,
// 也约束分组实际可调用的模型(网关准入在合成路由改写与调度之前完成)。
type GroupModelAllowlist struct {
Enabled bool `json:"enabled"`
Models []string `json:"models,omitempty"`
}
@@ -1,7 +0,0 @@
package domain
// GroupModelsListConfig controls the optional custom /v1/models response list.
type GroupModelsListConfig struct {
Enabled bool `json:"enabled"`
Models []string `json:"models,omitempty"`
}
+16 -3
View File
@@ -1,8 +1,21 @@
package domain
const (
ReasoningEffortMatchExact = "exact"
ReasoningEffortMatchPrefix = "prefix"
ReasoningEffortMatchSuffix = "suffix"
)
// ReasoningEffortMapping rewrites one explicit OpenAI/Codex reasoning effort
// value to another before the group ceiling is applied.
// value to another before the group ceiling is applied. To may also be "deny"
// to reject the request when the matching source value is present.
//
// Model and MatchType optionally scope the rewrite to a request model:
// exact matches the full model id, prefix/suffix match a model-id affix.
// Empty MatchType and empty Model mean the mapping applies to every model.
type ReasoningEffortMapping struct {
From string `json:"from"`
To string `json:"to"`
From string `json:"from"`
To string `json:"to"`
MatchType string `json:"match_type,omitempty"`
Model string `json:"model,omitempty"`
}
+225 -13
View File
@@ -17,6 +17,7 @@ import (
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
@@ -64,6 +65,7 @@ type AccountHandler struct {
grokImportProber grokImportProber
upstreamBillingProbe *service.UpstreamBillingProbeService
ollamaCloudUsage *service.OllamaCloudUsageService
cfg *config.Config
}
// SetUpstreamBillingProbeService attaches the optional remote billing probe service.
@@ -190,6 +192,7 @@ type CheckMixedChannelRequest struct {
// AccountWithConcurrency extends Account with real-time concurrency info
type AccountWithConcurrency struct {
*dto.Account
simpleMode bool `json:"-"`
CurrentConcurrency int `json:"current_concurrency"`
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
@@ -199,6 +202,123 @@ type AccountWithConcurrency struct {
CurrentRPM *int `json:"current_rpm,omitempty"` // 当前分钟 RPM 计数
}
// AccountListItemWithConcurrency is the compact account-list envelope used
// for lite=1. It embeds dto.AccountListItem instead of the full dto.Account,
// so groups/account_groups never appear in the list payload.
type AccountListItemWithConcurrency struct {
*dto.AccountListItem
CurrentConcurrency int `json:"current_concurrency"`
SchedulerScore *AccountSchedulerScore `json:"scheduler_score,omitempty"`
SchedulerScores []AccountSchedulerGroupScore `json:"scheduler_scores,omitempty"`
CurrentWindowCost *float64 `json:"current_window_cost,omitempty"`
ActiveSessions *int `json:"active_sessions,omitempty"`
CurrentRPM *int `json:"current_rpm,omitempty"`
}
type simpleModeGroupReference struct {
ID int64 `json:"id"`
Name string `json:"name"`
Platform string `json:"platform"`
Status string `json:"status"`
}
type simpleModeAccountGroupReference struct {
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
Priority int `json:"priority"`
CreatedAt time.Time `json:"created_at"`
Group *simpleModeGroupReference `json:"group,omitempty"`
}
func simpleModeGroupReferenceFromDTO(group *dto.Group) *simpleModeGroupReference {
if group == nil {
return nil
}
return &simpleModeGroupReference{ID: group.ID, Name: group.Name, Platform: group.Platform, Status: group.Status}
}
func simpleModeCompositeGroupIDs(account *dto.Account) map[int64]struct{} {
hidden := make(map[int64]struct{})
if account == nil {
return hidden
}
for _, group := range account.Groups {
if group != nil && group.Platform == service.PlatformComposite {
hidden[group.ID] = struct{}{}
}
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
hidden[accountGroup.GroupID] = struct{}{}
}
}
return hidden
}
func filterSimpleModeGroupIDs(groupIDs []int64, hidden map[int64]struct{}) []int64 {
visible := make([]int64, 0, len(groupIDs))
for _, groupID := range groupIDs {
if _, ok := hidden[groupID]; !ok {
visible = append(visible, groupID)
}
}
return visible
}
func simpleModeCompositeServiceGroupIDs(account *service.Account) map[int64]struct{} {
hidden := make(map[int64]struct{})
if account == nil {
return hidden
}
for _, group := range account.Groups {
if group != nil && group.Platform == service.PlatformComposite {
hidden[group.ID] = struct{}{}
}
}
for _, accountGroup := range account.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
hidden[accountGroup.GroupID] = struct{}{}
}
}
return hidden
}
func (a AccountWithConcurrency) MarshalJSON() ([]byte, error) {
type alias AccountWithConcurrency
if !a.simpleMode || a.Account == nil {
return json.Marshal(alias(a))
}
groups := make([]simpleModeGroupReference, 0, len(a.Groups))
compositeIDs := simpleModeCompositeGroupIDs(a.Account)
for _, group := range a.Groups {
if group != nil && group.Platform == service.PlatformComposite {
continue
}
if ref := simpleModeGroupReferenceFromDTO(group); ref != nil {
groups = append(groups, *ref)
}
}
accountGroups := make([]simpleModeAccountGroupReference, 0, len(a.AccountGroups))
for _, accountGroup := range a.AccountGroups {
if accountGroup.Group != nil && accountGroup.Group.Platform == service.PlatformComposite {
continue
}
if _, hidden := compositeIDs[accountGroup.GroupID]; hidden {
continue
}
accountGroups = append(accountGroups, simpleModeAccountGroupReference{
AccountID: accountGroup.AccountID, GroupID: accountGroup.GroupID, Priority: accountGroup.Priority,
CreatedAt: accountGroup.CreatedAt, Group: simpleModeGroupReferenceFromDTO(accountGroup.Group),
})
}
return json.Marshal(struct {
alias
GroupIDs []int64 `json:"group_ids,omitempty"`
Groups []simpleModeGroupReference `json:"groups"`
AccountGroups []simpleModeAccountGroupReference `json:"account_groups"`
}{alias: alias(a), GroupIDs: filterSimpleModeGroupIDs(a.GroupIDs, compositeIDs), Groups: groups, AccountGroups: accountGroups})
}
type AccountSchedulerScore struct {
BaseScore float64 `json:"base_score"`
StickyScore float64 `json:"sticky_score"`
@@ -223,9 +343,25 @@ func (h *AccountHandler) accountResponseFromService(account *service.Account) *d
return out
}
func (h *AccountHandler) accountListResponseFromService(account *service.Account) *dto.Account {
out := dto.AccountFromServiceShallow(account)
if out != nil && account != nil {
out.Proxy = dto.ProxyFromService(account.Proxy)
}
if h != nil && h.ollamaCloudUsage != nil && out != nil {
h.ollamaCloudUsage.EnrichState(out.OllamaCloudUsage)
}
return out
}
func (h *AccountHandler) isSimpleMode() bool {
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
}
func (h *AccountHandler) buildAccountResponseWithRuntime(ctx context.Context, account *service.Account) AccountWithConcurrency {
item := AccountWithConcurrency{
Account: h.accountResponseFromService(account),
simpleMode: h.isSimpleMode(),
CurrentConcurrency: 0,
}
if account == nil {
@@ -650,8 +786,16 @@ func (h *AccountHandler) List(c *gin.Context) {
result := make([]AccountWithConcurrency, len(accounts))
for i := range accounts {
acc := &accounts[i]
accountResponse := h.accountResponseFromService(acc)
if lite {
accountResponse = h.accountListResponseFromService(acc)
if h.isSimpleMode() {
accountResponse.GroupIDs = filterSimpleModeGroupIDs(accountResponse.GroupIDs, simpleModeCompositeServiceGroupIDs(acc))
}
}
item := AccountWithConcurrency{
Account: h.accountResponseFromService(acc),
Account: accountResponse,
simpleMode: h.isSimpleMode(),
CurrentConcurrency: concurrencyCounts[acc.ID],
SchedulerScore: schedulerScores[acc.ID],
SchedulerScores: schedulerGroupScores[acc.ID],
@@ -683,7 +827,34 @@ func (h *AccountHandler) List(c *gin.Context) {
h.enrichShadowParents(c.Request.Context(), result)
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, lite)
if lite {
compact := make([]AccountListItemWithConcurrency, len(result))
for i := range result {
item := result[i]
compact[i] = AccountListItemWithConcurrency{
AccountListItem: dto.AccountListItemFromAccount(item.Account),
CurrentConcurrency: item.CurrentConcurrency,
SchedulerScore: item.SchedulerScore,
SchedulerScores: item.SchedulerScores,
CurrentWindowCost: item.CurrentWindowCost,
ActiveSessions: item.ActiveSessions,
CurrentRPM: item.CurrentRPM,
}
}
etag := buildAccountsListETag(compact, total, page, pageSize, platform, accountType, status, search, true)
if etag != "" {
c.Header("ETag", etag)
c.Header("Vary", "If-None-Match")
if ifNoneMatchMatched(c.GetHeader("If-None-Match"), etag) {
c.Status(http.StatusNotModified)
return
}
}
response.Paginated(c, compact, total, page, pageSize)
return
}
etag := buildAccountsListETag(result, total, page, pageSize, platform, accountType, status, search, false)
if etag != "" {
c.Header("ETag", etag)
c.Header("Vary", "If-None-Match")
@@ -696,23 +867,23 @@ func (h *AccountHandler) List(c *gin.Context) {
response.Paginated(c, result, total, page, pageSize)
}
func buildAccountsListETag(
items []AccountWithConcurrency,
func buildAccountsListETag[T any](
items []T,
total int64,
page, pageSize int,
platform, accountType, status, search string,
lite bool,
) string {
payload := struct {
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Platform string `json:"platform"`
AccountType string `json:"type"`
Status string `json:"status"`
Search string `json:"search"`
Lite bool `json:"lite"`
Items []AccountWithConcurrency `json:"items"`
Total int64 `json:"total"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Platform string `json:"platform"`
AccountType string `json:"type"`
Status string `json:"status"`
Search string `json:"search"`
Lite bool `json:"lite"`
Items []T `json:"items"`
}{
Total: total,
Page: page,
@@ -837,6 +1008,10 @@ func (h *AccountHandler) Create(c *gin.Context) {
}
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(req.Extra)
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
@@ -970,6 +1145,10 @@ func (h *AccountHandler) Update(c *gin.Context) {
}
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(req.Extra)
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
@@ -1424,6 +1603,10 @@ func (h *AccountHandler) ApplyOAuthCredentials(c *gin.Context) {
response.ErrorFrom(c, err)
return
}
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
// Drop SSO/password residue; re-auth must leave only OAuth tokens on disk.
req.Credentials = service.SanitizeStoredCredentials(existing.Platform, req.Credentials)
@@ -1875,6 +2058,14 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
return
}
}
groupIDs := make([]int64, 0)
for _, item := range req.Accounts {
groupIDs = append(groupIDs, item.GroupIDs...)
}
if err := h.adminService.ValidateAccountGroupBindings(c.Request.Context(), groupIDs); err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSON(c, "admin.accounts.batch_create", req, service.DefaultWriteIdempotencyTTL(), func(ctx context.Context) (any, error) {
success := 0
@@ -1897,6 +2088,15 @@ func (h *AccountHandler) BatchCreate(c *gin.Context) {
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(item.Extra)
if err := service.ValidateUpstreamRequestIDHeaderExtra(item.Extra); err != nil {
failed++
results = append(results, gin.H{
"name": item.Name,
"success": false,
"error": err.Error(),
})
continue
}
skipCheck := item.ConfirmMixedChannelRisk != nil && *item.ConfirmMixedChannelRisk
@@ -2091,6 +2291,10 @@ func (h *AccountHandler) BulkUpdate(c *gin.Context) {
}
// base_rpm 输入校验:负值归零,超过 10000 截断
sanitizeExtraBaseRPM(req.Extra)
if err := service.ValidateUpstreamRequestIDHeaderExtra(req.Extra); err != nil {
response.ErrorFrom(c, err)
return
}
// 确定是否跳过混合渠道检查
skipCheck := req.ConfirmMixedChannelRisk != nil && *req.ConfirmMixedChannelRisk
@@ -2576,6 +2780,14 @@ func (h *AccountHandler) GetAvailableModels(c *gin.Context) {
// Handle OpenAI accounts
if account.IsOpenAI() {
// Prefer the shared, account-keyed upstream catalog. If discovery fails,
// retain the legacy local catalog below so the test dialog remains usable.
if h.accountTestService != nil {
if models, fetchErr := h.accountTestService.FetchOpenAIAccountModels(c.Request.Context(), account); fetchErr == nil {
response.Success(c, models)
return
}
}
// OpenAI 自动透传会绕过常规模型改写,测试/模型列表也应回落到默认模型集。
if account.IsOpenAIPassthroughEnabled() {
response.Success(c, openai.DefaultModels)
@@ -1,9 +1,12 @@
package admin
import (
"bytes"
"compress/gzip"
"encoding/json"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
@@ -13,6 +16,101 @@ import (
"github.com/stretchr/testify/require"
)
func TestAccountHandlerListLiteUsesCompactDTOAndETag(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
groupID := int64(77)
adminSvc.accounts = []service.Account{{
ID: 501, Name: "compact-account", Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Credentials: map[string]any{"email": "compact@example.com", "access_token": strings.Repeat("x", 4096)},
Extra: map[string]any{"privacy_mode": "training_off"}, Status: service.StatusActive,
Schedulable: true, Concurrency: 4, GroupIDs: []int64{groupID},
Groups: []*service.Group{{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}},
AccountGroups: []service.AccountGroup{{AccountID: 501, GroupID: groupID, Priority: 2, Group: &service.Group{ID: groupID, Name: "codex", Platform: service.PlatformOpenAI}}},
CreatedAt: now, UpdatedAt: now,
}}
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.NotEmpty(t, rec.Header().Get("ETag"))
var litePayload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &litePayload))
require.Len(t, litePayload.Data.Items, 1)
liteItem := litePayload.Data.Items[0]
require.Equal(t, float64(501), liteItem["id"])
require.Equal(t, []any{float64(groupID)}, liteItem["group_ids"])
require.Equal(t, true, liteItem["schedulable"])
require.NotContains(t, liteItem, "groups")
require.NotContains(t, liteItem, "account_groups")
credentials, ok := liteItem["credentials"].(map[string]any)
require.True(t, ok)
require.Equal(t, "compact@example.com", credentials["email"])
require.NotContains(t, credentials, "access_token")
credentialsStatus, ok := liteItem["credentials_status"].(map[string]any)
require.True(t, ok)
require.Equal(t, true, credentialsStatus["has_access_token"])
// The ETag must represent the same compact body and return 304 on refresh.
rec304 := httptest.NewRecorder()
req304 := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
req304.Header.Set("If-None-Match", rec.Header().Get("ETag"))
router.ServeHTTP(rec304, req304)
require.Equal(t, http.StatusNotModified, rec304.Code)
// Omitting lite preserves the legacy full response shape.
recFull := httptest.NewRecorder()
reqFull := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20", nil)
router.ServeHTTP(recFull, reqFull)
require.Equal(t, http.StatusOK, recFull.Code)
var fullPayload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(recFull.Body.Bytes(), &fullPayload))
require.Contains(t, fullPayload.Data.Items[0], "groups")
require.Contains(t, fullPayload.Data.Items[0], "account_groups")
}
func TestAccountHandlerListLiteStaysBelowResponseBudget(t *testing.T) {
router, adminSvc := setupAccountListRouter()
now := time.Now().UTC()
accounts := make([]service.Account, 20)
for i := range accounts {
id := int64(600 + i)
groupID := int64(800 + i)
accounts[i] = service.Account{
ID: id, Name: "account-" + strconv.Itoa(i), Platform: service.PlatformOpenAI,
Type: service.AccountTypeOAuth, Status: service.StatusActive, Schedulable: true,
Concurrency: 4, GroupIDs: []int64{groupID},
Groups: []*service.Group{{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}},
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID, Group: &service.Group{ID: groupID, Name: "group-" + strconv.Itoa(i), Description: strings.Repeat("description ", 20)}}},
CreatedAt: now, UpdatedAt: now,
}
}
adminSvc.accounts = accounts
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/accounts?page=1&page_size=20&lite=1", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Less(t, rec.Body.Len(), 80*1024)
var compressed bytes.Buffer
zw := gzip.NewWriter(&compressed)
_, err := zw.Write(rec.Body.Bytes())
require.NoError(t, err)
require.NoError(t, zw.Close())
require.Less(t, compressed.Len(), 15*1024)
}
func setupAccountListRouter() (*gin.Engine, *stubAdminService) {
gin.SetMode(gin.TestMode)
router := gin.New()
@@ -0,0 +1,240 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type simpleModeAccountService struct {
*stubAdminService
account service.Account
createCalls int
updateCalls int
bulkCalls int
}
func (s *simpleModeAccountService) GetAccount(context.Context, int64) (*service.Account, error) {
return &s.account, nil
}
func (s *simpleModeAccountService) CreateAccount(ctx context.Context, input *service.CreateAccountInput) (*service.Account, error) {
if err := s.ValidateAccountGroupBindings(ctx, input.GroupIDs); err != nil {
return nil, err
}
s.createCalls++
return &s.account, nil
}
func (s *simpleModeAccountService) UpdateAccount(ctx context.Context, _ int64, input *service.UpdateAccountInput) (*service.Account, error) {
if input.GroupIDs != nil {
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
s.updateCalls++
return &s.account, nil
}
func (s *simpleModeAccountService) BulkUpdateAccounts(ctx context.Context, input *service.BulkUpdateAccountsInput) (*service.BulkUpdateAccountsResult, error) {
if input.GroupIDs != nil {
if err := s.ValidateAccountGroupBindings(ctx, *input.GroupIDs); err != nil {
return nil, err
}
}
s.bulkCalls++
return &service.BulkUpdateAccountsResult{}, nil
}
func (s *simpleModeAccountService) ValidateAccountGroupBindings(_ context.Context, groupIDs []int64) error {
for _, id := range groupIDs {
for i := range s.groups {
if s.groups[i].ID == id && s.groups[i].Platform == service.PlatformComposite {
return infraerrors.BadRequest("SIMPLE_MODE_GROUP_NOT_BINDABLE", "composite groups cannot be bound in simple mode")
}
}
}
return nil
}
func TestAccountHandlerSimpleModeUsesMinimalGroupReferences(t *testing.T) {
gin.SetMode(gin.TestMode)
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9, IsExclusive: true, SubscriptionType: service.SubscriptionTypeSubscription, RPMLimit: 99}
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
account := service.Account{
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
GroupIDs: []int64{7, 9},
Groups: []*service.Group{richGroup, historicalComposite},
AccountGroups: []service.AccountGroup{
{AccountID: 3, GroupID: 7, Priority: 2, Group: richGroup, Account: &service.Account{ID: 3, Extra: map[string]any{"secret": true}}},
{AccountID: 3, GroupID: 9, Priority: 3, Group: historicalComposite},
},
}
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
svc.accounts = []service.Account{account}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.GET("/accounts", h.List)
r.GET("/accounts/:id", h.GetByID)
r.POST("/accounts", h.Create)
r.PUT("/accounts/:id", h.Update)
tests := []struct {
method string
path string
body string
}{
{http.MethodGet, "/accounts", ""},
{http.MethodGet, "/accounts/3", ""},
{http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"}}`},
{http.MethodPut, "/accounts/3", `{"name":"account"}`},
}
for _, tt := range tests {
t.Run(tt.method+tt.path, func(t *testing.T) {
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
var payload map[string]any
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
data, ok := payload["data"].(map[string]any)
require.True(t, ok)
if items, ok := data["items"].([]any); ok {
require.Len(t, items, 1)
data, ok = items[0].(map[string]any)
require.True(t, ok)
}
groups, ok := data["groups"].([]any)
require.True(t, ok)
require.Len(t, groups, 1)
require.Equal(t, []any{float64(7)}, data["group_ids"])
group, ok := groups[0].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(group))
accountGroups, ok := data["account_groups"].([]any)
require.True(t, ok)
require.Len(t, accountGroups, 1)
accountGroup, ok := accountGroups[0].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"account_id", "group_id", "priority", "created_at", "group"}, mapKeys(accountGroup))
nestedGroup, ok := accountGroup["group"].(map[string]any)
require.True(t, ok)
require.ElementsMatch(t, []string{"id", "name", "platform", "status"}, mapKeys(nestedGroup))
})
}
}
func TestAccountHandlerSimpleModeLitePreservesCompactShapeAndETag(t *testing.T) {
richGroup := &service.Group{ID: 7, Name: "basic", Platform: service.PlatformAnthropic, Status: service.StatusActive, RateMultiplier: 9}
historicalComposite := &service.Group{ID: 9, Name: "historical composite", Platform: service.PlatformComposite, Status: service.StatusActive}
account := service.Account{
ID: 3, Name: "account", Platform: service.PlatformAnthropic, Type: service.AccountTypeAPIKey, Status: service.StatusActive,
GroupIDs: []int64{7, 9}, Groups: []*service.Group{richGroup, historicalComposite},
AccountGroups: []service.AccountGroup{{AccountID: 3, GroupID: 7, Group: richGroup}, {AccountID: 3, GroupID: 9, Group: historicalComposite}},
}
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: account}
svc.accounts = []service.Account{account}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.GET("/accounts", h.List)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil))
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
require.NotEmpty(t, res.Header().Get("ETag"))
var payload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
require.Equal(t, []any{float64(7)}, payload.Data.Items[0]["group_ids"])
require.NotContains(t, payload.Data.Items[0], "groups")
require.NotContains(t, payload.Data.Items[0], "account_groups")
notModified := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/accounts?lite=1", nil)
req.Header.Set("If-None-Match", res.Header().Get("ETag"))
r.ServeHTTP(notModified, req)
require.Equal(t, http.StatusNotModified, notModified.Code)
}
func TestAccountHandlerSimpleModeRejectsCompositeGroupBindingsBeforeWrites(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tt := range []struct{ name, method, path, body string }{
{"create", http.MethodPost, "/accounts", `{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[9]}`},
{"update", http.MethodPut, "/accounts/3", `{"group_ids":[9]}`},
{"bulk", http.MethodPost, "/accounts/bulk-update", `{"account_ids":[3],"group_ids":[9]}`},
} {
t.Run(tt.name, func(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts", h.Create)
r.PUT("/accounts/:id", h.Update)
r.POST("/accounts/bulk-update", h.BulkUpdate)
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
require.Zero(t, svc.createCalls)
require.Zero(t, svc.updateCalls)
require.Zero(t, svc.bulkCalls)
})
}
}
func TestAccountHandlerSimpleModePreservesBasicGroupBinding(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService(), account: service.Account{ID: 3}}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts", h.Create)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/accounts", bytes.NewBufferString(`{"name":"account","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
require.Equal(t, 1, svc.createCalls)
}
func TestAccountHandlerSimpleModeBatchPrevalidatesAllGroupsAtomically(t *testing.T) {
svc := &simpleModeAccountService{stubAdminService: newStubAdminService()}
svc.groups = []service.Group{{ID: 7, Platform: service.PlatformAnthropic}, {ID: 9, Platform: service.PlatformComposite}}
h := NewAccountHandler(svc, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
h.cfg = &config.Config{RunMode: config.RunModeSimple}
r := gin.New()
r.POST("/accounts/batch", h.BatchCreate)
res := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPost, "/accounts/batch", bytes.NewBufferString(`{"accounts":[{"name":"first","platform":"anthropic","type":"apikey","credentials":{"key":"x"},"group_ids":[7]},{"name":"second","platform":"anthropic","type":"apikey","credentials":{"key":"y"},"group_ids":[9]}]}`))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code, res.Body.String())
require.Zero(t, svc.createCalls)
}
func TestAccountHandlerAdvancedModeKeepsFullGroupReferences(t *testing.T) {
group := &service.Group{ID: 7, Name: "advanced", RateMultiplier: 9, SubscriptionType: service.SubscriptionTypeSubscription}
h := NewAccountHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
raw, err := json.Marshal(h.buildAccountResponseWithRuntime(context.Background(), &service.Account{Groups: []*service.Group{group}}))
require.NoError(t, err)
require.Contains(t, string(raw), `"rate_multiplier":9`)
require.Contains(t, string(raw), `"subscription_type":"subscription"`)
}
@@ -33,7 +33,7 @@ func setupAdminRouter() (*gin.Engine, *stubAdminService) {
router.GET("/api/v1/admin/groups", groupHandler.List)
router.GET("/api/v1/admin/groups/all", groupHandler.GetAll)
router.GET("/api/v1/admin/groups/:id/models-list-candidates", groupHandler.GetModelsListCandidates)
router.GET("/api/v1/admin/groups/:id/model-allowlist-candidates", groupHandler.GetGroupModelAllowlistCandidates)
router.GET("/api/v1/admin/groups/:id/composite-routes", groupHandler.ListCompositeRoutes)
router.POST("/api/v1/admin/groups/:id/composite-routes", groupHandler.CreateCompositeRoute)
router.POST("/api/v1/admin/groups/:id/composite-routes/preview", groupHandler.PreviewCompositeRoute)
@@ -184,7 +184,7 @@ func TestGroupHandlerEndpoints(t *testing.T) {
require.Equal(t, http.StatusOK, rec.Code)
rec = httptest.NewRecorder()
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/models-list-candidates?platform=openai", nil)
req = httptest.NewRequest(http.MethodGet, "/api/v1/admin/groups/0/model-allowlist-candidates?platform=openai", nil)
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
require.Contains(t, rec.Body.String(), "gpt-5.5")
@@ -278,13 +278,15 @@ func TestOpenAIFastPolicySettingsFromDTO_NormalizesServiceTier(t *testing.T) {
Rules: []dto.OpenAIFastPolicyRule{
{ServiceTier: "priority", Action: "filter", Scope: "all"},
{ServiceTier: "flex", Action: "block", Scope: "oauth"},
{ServiceTier: "ultrafast", Action: "pass", Scope: "all"},
{ServiceTier: "all", Action: "pass", Scope: "apikey"},
},
}
out := openaiFastPolicySettingsFromDTO(in)
require.Len(t, out.Rules, 3)
require.Len(t, out.Rules, 4)
require.Equal(t, service.OpenAIFastTierPriority, out.Rules[0].ServiceTier)
require.Equal(t, service.OpenAIFastTierFlex, out.Rules[1].ServiceTier)
require.Equal(t, service.OpenAIFastTierAny, out.Rules[2].ServiceTier)
require.Equal(t, service.OpenAIFastTierUltrafast, out.Rules[2].ServiceTier)
require.Equal(t, service.OpenAIFastTierAny, out.Rules[3].ServiceTier)
})
}
@@ -24,6 +24,13 @@ type stubAdminService struct {
boundAuthIdentity *service.AdminBindAuthIdentityInput
boundAuthIdentityFor int64
createdAccounts []*service.CreateAccountInput
createdGroups []*service.CreateGroupInput
updatedGroups []*service.UpdateGroupInput
deletedGroupIDs []int64
guardedDeletedGroupIDs []int64
deleteGroupIfEmptyErr error
advancedGroupOperationCalls int
lastListGroupsIsExclusive *bool
createdProxies []*service.CreateProxyInput
updatedProxyIDs []int64
updatedProxies []*service.UpdateProxyInput
@@ -267,6 +274,7 @@ func (s *stubAdminService) BindUserAuthIdentity(ctx context.Context, userID int6
}
func (s *stubAdminService) ListGroups(ctx context.Context, page, pageSize int, platform, status, search string, isExclusive *bool, sortBy, sortOrder string) ([]service.Group, int64, error) {
s.lastListGroupsIsExclusive = isExclusive
return s.groups, int64(len(s.groups)), nil
}
@@ -295,6 +303,7 @@ func (s *stubAdminService) GetGroupModelsListCandidates(ctx context.Context, id
}
func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int64) ([]service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return []service.CompositeModelRoute{
{
ID: 1,
@@ -311,6 +320,7 @@ func (s *stubAdminService) ListCompositeRoutes(ctx context.Context, groupID int6
}
func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return &service.CompositeModelRoute{
ID: 1,
GroupID: groupID,
@@ -326,6 +336,7 @@ func (s *stubAdminService) CreateCompositeRoute(ctx context.Context, groupID int
}
func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, routeID int64, input service.CompositeRouteInput) (*service.CompositeModelRoute, error) {
s.advancedGroupOperationCalls++
return &service.CompositeModelRoute{
ID: routeID,
GroupID: groupID,
@@ -341,10 +352,12 @@ func (s *stubAdminService) UpdateCompositeRoute(ctx context.Context, groupID, ro
}
func (s *stubAdminService) DeleteCompositeRoute(ctx context.Context, groupID, routeID int64) error {
s.advancedGroupOperationCalls++
return nil
}
func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID int64, input service.CompositeRoutePreviewRequest) (*service.CompositeRouteDecision, error) {
s.advancedGroupOperationCalls++
decision, err := service.NewCompositeRouteResolver(nil).Resolve(ctx, groupID, input.Model, input.Endpoint)
if err != nil {
return nil, err
@@ -353,11 +366,13 @@ func (s *stubAdminService) PreviewCompositeRoute(ctx context.Context, groupID in
}
func (s *stubAdminService) CreateGroup(ctx context.Context, input *service.CreateGroupInput) (*service.Group, error) {
s.createdGroups = append(s.createdGroups, input)
group := service.Group{ID: 200, Name: input.Name, Status: service.StatusActive}
return &group, nil
}
func (s *stubAdminService) DuplicateGroup(ctx context.Context, id int64, actorScope, operationKey string) (*service.Group, error) {
s.advancedGroupOperationCalls++
group := service.Group{ID: 201, Name: "group (Copy)", Status: "inactive"}
return &group, nil
}
@@ -367,14 +382,21 @@ func (s *stubAdminService) RecoverDuplicateGroup(ctx context.Context, id int64,
}
func (s *stubAdminService) UpdateGroup(ctx context.Context, id int64, input *service.UpdateGroupInput) (*service.Group, error) {
s.updatedGroups = append(s.updatedGroups, input)
group := service.Group{ID: id, Name: input.Name, Status: service.StatusActive}
return &group, nil
}
func (s *stubAdminService) DeleteGroup(ctx context.Context, id int64) error {
s.deletedGroupIDs = append(s.deletedGroupIDs, id)
return nil
}
func (s *stubAdminService) DeleteGroupIfEmpty(ctx context.Context, id int64) error {
s.guardedDeletedGroupIDs = append(s.guardedDeletedGroupIDs, id)
return s.deleteGroupIfEmptyErr
}
func (s *stubAdminService) GetGroupAPIKeys(ctx context.Context, groupID int64, page, pageSize int) ([]service.APIKey, int64, error) {
return s.apiKeys, int64(len(s.apiKeys)), nil
}
@@ -384,10 +406,12 @@ func (s *stubAdminService) GetGroupRateMultipliers(_ context.Context, _ int64) (
}
func (s *stubAdminService) ClearGroupRateMultipliers(_ context.Context, _ int64) error {
s.advancedGroupOperationCalls++
return nil
}
func (s *stubAdminService) BatchSetGroupRateMultipliers(_ context.Context, _ int64, _ []service.GroupRateMultiplierInput) error {
s.advancedGroupOperationCalls++
return nil
}
@@ -396,6 +420,7 @@ func (s *stubAdminService) ClearGroupRPMOverrides(_ context.Context, _ int64) er
}
func (s *stubAdminService) BatchSetGroupRPMOverrides(_ context.Context, _ int64, _ []service.GroupRPMOverrideInput) error {
s.advancedGroupOperationCalls++
return nil
}
@@ -491,6 +516,8 @@ func (s *stubAdminService) CreateAccount(ctx context.Context, input *service.Cre
return &account, nil
}
func (s *stubAdminService) ValidateAccountGroupBindings(context.Context, []int64) error { return nil }
func (s *stubAdminService) DuplicateAccount(ctx context.Context, id int64, actorScope, operationKey string) (*service.Account, error) {
account := service.Account{ID: 301, Name: "account (Copy)", Status: service.StatusActive, Schedulable: false}
return &account, nil
@@ -57,20 +57,22 @@ 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"`
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"`
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"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_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 {
@@ -92,6 +94,7 @@ type pricingIntervalRequest struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
@@ -127,21 +130,23 @@ 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"`
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"`
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"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
FastMultiplier *float64 `json:"fast_multiplier"`
FlexMultiplier *float64 `json:"flex_multiplier"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_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 {
@@ -164,6 +169,7 @@ type pricingIntervalResponse struct {
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
InputMultiplier *float64 `json:"input_multiplier"`
OutputMultiplier *float64 `json:"output_multiplier"`
@@ -254,21 +260,23 @@ func pricingToResponse(p *service.ChannelModelPricing) channelModelPricingRespon
intervals = append(intervals, intervalToResponse(iv))
}
return channelModelPricingResponse{
ID: p.ID,
Platform: platform,
Models: models,
BillingMode: billingMode,
InputPrice: p.InputPrice,
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),
ID: p.ID,
Platform: platform,
Models: models,
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
FastMultiplier: p.FastMultiplier,
FlexMultiplier: p.FlexMultiplier,
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingToResponse(p.TimePricing),
}
}
@@ -300,6 +308,7 @@ func intervalToResponse(iv service.PricingInterval) pricingIntervalResponse {
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: iv.InputMultiplier,
OutputMultiplier: iv.OutputMultiplier,
@@ -334,6 +343,7 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: inputMultiplier,
OutputMultiplier: outputMultiplier,
@@ -343,26 +353,29 @@ func pricingRequestToService(reqs []channelModelPricingRequest, allowChannelMult
SortOrder: iv.SortOrder,
})
}
var fastMultiplier, flexMultiplier *float64
var fastMultiplier, flexMultiplier, maxReasoningEffortMultiplier *float64
if allowChannelMultipliers {
fastMultiplier = r.FastMultiplier
flexMultiplier = r.FlexMultiplier
maxReasoningEffortMultiplier = r.MaxReasoningEffortMultiplier
}
result = append(result, service.ChannelModelPricing{
Platform: platform,
Models: r.Models,
BillingMode: billingMode,
InputPrice: r.InputPrice,
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),
Platform: platform,
Models: r.Models,
BillingMode: billingMode,
InputPrice: r.InputPrice,
OutputPrice: r.OutputPrice,
CacheWritePrice: r.CacheWritePrice,
CacheWrite1hPrice: r.CacheWrite1hPrice,
CacheReadPrice: r.CacheReadPrice,
FastMultiplier: fastMultiplier,
FlexMultiplier: flexMultiplier,
MaxReasoningEffortMultiplier: maxReasoningEffortMultiplier,
ImageInputPrice: r.ImageInputPrice,
ImageOutputPrice: r.ImageOutputPrice,
PerRequestPrice: r.PerRequestPrice,
Intervals: intervals,
TimePricing: timePricingRequestToService(r.TimePricing),
})
}
return result
@@ -597,15 +610,25 @@ func (h *ChannelHandler) GetModelDefaultPricing(c *gin.Context) {
response.Success(c, gin.H{"found": false})
return
}
cacheWritePrice := pricing.CacheCreationPricePerToken
var cacheWrite1hPrice *float64
if pricing.SupportsCacheBreakdown {
if pricing.CacheCreation5mPrice > 0 {
cacheWritePrice = pricing.CacheCreation5mPrice
}
cacheWrite1hPrice = &pricing.CacheCreation1hPrice
}
response.Success(c, gin.H{
"found": true,
"input_price": pricing.InputPricePerToken,
"output_price": pricing.OutputPricePerToken,
"cache_write_price": pricing.CacheCreationPricePerToken,
"cache_read_price": pricing.CacheReadPricePerToken,
"image_input_price": pricing.ImageInputPricePerToken,
"image_output_price": pricing.ImageOutputPricePerToken,
"found": true,
"input_price": pricing.InputPricePerToken,
"output_price": pricing.OutputPricePerToken,
"cache_write_price": cacheWritePrice,
"cache_write_1h_price": cacheWrite1hPrice,
"cache_read_price": pricing.CacheReadPricePerToken,
"max_reasoning_effort_multiplier": pricing.MaxReasoningEffortMultiplier,
"image_input_price": pricing.ImageInputPricePerToken,
"image_output_price": pricing.ImageOutputPricePerToken,
})
}
@@ -320,15 +320,16 @@ func TestPricingRequestToService_Defaults(t *testing.T) {
func TestPricingRequestToService_WithAllFields(t *testing.T) {
reqs := []channelModelPricingRequest{
{
Platform: "openai",
Models: []string{"gpt-4", "gpt-4o"},
BillingMode: "per_request",
InputPrice: float64Ptr(0.01),
OutputPrice: float64Ptr(0.03),
CacheWritePrice: float64Ptr(0.005),
CacheReadPrice: float64Ptr(0.002),
ImageOutputPrice: float64Ptr(0.04),
PerRequestPrice: float64Ptr(0.5),
Platform: "openai",
Models: []string{"gpt-4", "gpt-4o"},
BillingMode: "per_request",
InputPrice: float64Ptr(0.01),
OutputPrice: float64Ptr(0.03),
CacheWritePrice: float64Ptr(0.005),
CacheWrite1hPrice: float64Ptr(0.008),
CacheReadPrice: float64Ptr(0.002),
ImageOutputPrice: float64Ptr(0.04),
PerRequestPrice: float64Ptr(0.5),
},
}
@@ -341,6 +342,7 @@ func TestPricingRequestToService_WithAllFields(t *testing.T) {
require.Equal(t, float64Ptr(0.01), r.InputPrice)
require.Equal(t, float64Ptr(0.03), r.OutputPrice)
require.Equal(t, float64Ptr(0.005), r.CacheWritePrice)
require.Equal(t, float64Ptr(0.008), r.CacheWrite1hPrice)
require.Equal(t, float64Ptr(0.002), r.CacheReadPrice)
require.Equal(t, float64Ptr(0.04), r.ImageOutputPrice)
require.Equal(t, float64Ptr(0.5), r.PerRequestPrice)
@@ -353,15 +355,16 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
BillingMode: "per_request",
Intervals: []pricingIntervalRequest{
{
MinTokens: 0,
MaxTokens: intPtr(2000),
TierLabel: "small",
InputPrice: float64Ptr(0.01),
OutputPrice: float64Ptr(0.02),
CacheWritePrice: float64Ptr(0.003),
CacheReadPrice: float64Ptr(0.001),
PerRequestPrice: float64Ptr(0.1),
SortOrder: 1,
MinTokens: 0,
MaxTokens: intPtr(2000),
TierLabel: "small",
InputPrice: float64Ptr(0.01),
OutputPrice: float64Ptr(0.02),
CacheWritePrice: float64Ptr(0.003),
CacheWrite1hPrice: float64Ptr(0.006),
CacheReadPrice: float64Ptr(0.001),
PerRequestPrice: float64Ptr(0.1),
SortOrder: 1,
},
{
MinTokens: 2000,
@@ -384,6 +387,7 @@ func TestPricingRequestToService_WithIntervals(t *testing.T) {
require.Equal(t, float64Ptr(0.01), iv0.InputPrice)
require.Equal(t, float64Ptr(0.02), iv0.OutputPrice)
require.Equal(t, float64Ptr(0.003), iv0.CacheWritePrice)
require.Equal(t, float64Ptr(0.006), iv0.CacheWrite1hPrice)
require.Equal(t, float64Ptr(0.001), iv0.CacheReadPrice)
require.Equal(t, float64Ptr(0.1), iv0.PerRequestPrice)
require.Equal(t, 1, iv0.SortOrder)
@@ -449,10 +453,11 @@ func TestPricingRequestToService_TimePricingNil(t *testing.T) {
// 避免渠道倍率意外污染账号成本口径。
func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
req := channelModelPricingRequest{
Models: []string{"gpt-5"},
BillingMode: "token",
FastMultiplier: float64Ptr(2.5),
FlexMultiplier: float64Ptr(0.5),
Models: []string{"gpt-5"},
BillingMode: "token",
FastMultiplier: float64Ptr(2.5),
FlexMultiplier: float64Ptr(0.5),
MaxReasoningEffortMultiplier: float64Ptr(3),
Intervals: []pricingIntervalRequest{{
MinTokens: 272000,
InputMultiplier: float64Ptr(2),
@@ -465,6 +470,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
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(3), allowed[0].MaxReasoningEffortMultiplier)
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)
@@ -473,6 +479,7 @@ func TestPricingRequestToService_MultipliersGatedByFlag(t *testing.T) {
dropped := pricingRequestToService([]channelModelPricingRequest{req}, false)
require.Nil(t, dropped[0].FastMultiplier)
require.Nil(t, dropped[0].FlexMultiplier)
require.Nil(t, dropped[0].MaxReasoningEffortMultiplier)
require.Nil(t, dropped[0].Intervals[0].InputMultiplier)
require.Nil(t, dropped[0].Intervals[0].OutputMultiplier)
require.Nil(t, dropped[0].Intervals[0].CacheWriteMultiplier)
@@ -558,3 +565,53 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
require.NotNil(t, body.Data.Models, "models must not be null for platform=%s", platform)
}
}
func setupModelDefaultPricingRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
router := gin.New()
h := &ChannelHandler{billingService: service.NewBillingService(nil, nil)}
router.GET("/channels/model-pricing", h.GetModelDefaultPricing)
return router
}
func TestGetModelDefaultPricing_ReturnsFable51CacheTTLs(t *testing.T) {
router := setupModelDefaultPricingRouter()
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-fable-5-1", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Data struct {
Found bool `json:"found"`
CacheWritePrice float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body.Data.Found)
require.InDelta(t, 12.5e-6, body.Data.CacheWritePrice, 1e-12)
require.NotNil(t, body.Data.CacheWrite1hPrice)
require.InDelta(t, 20e-6, *body.Data.CacheWrite1hPrice, 1e-12)
require.NotNil(t, body.Data.MaxReasoningEffortMultiplier)
require.Equal(t, 3.0, *body.Data.MaxReasoningEffortMultiplier)
}
func TestGetModelDefaultPricing_OmitsUnsupportedCache1hPrice(t *testing.T) {
router := setupModelDefaultPricingRouter()
req := httptest.NewRequest(http.MethodGet, "/channels/model-pricing?model=claude-sonnet-4", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code)
var body struct {
Data struct {
Found bool `json:"found"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
require.True(t, body.Data.Found)
require.Nil(t, body.Data.CacheWrite1hPrice)
}
@@ -6,6 +6,7 @@ import (
"sync"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
)
@@ -194,6 +195,7 @@ func (h *GrokOAuthHandler) scheduleGrokImportProbe(account *service.Account) {
// ProvideAccountHandler injects the Grok active prober for production while
// keeping NewAccountHandler convenient for focused unit tests.
func ProvideAccountHandler(
cfg *config.Config,
adminService service.AdminService,
oauthService *service.OAuthService,
openaiOAuthService *service.OpenAIOAuthService,
@@ -227,5 +229,6 @@ func ProvideAccountHandler(
tokenCacheInvalidator,
)
handler.grokImportProber = grokQuotaService
handler.cfg = cfg
return handler
}
+233 -12
View File
@@ -10,6 +10,7 @@ import (
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"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"
@@ -24,10 +25,14 @@ type GroupHandler struct {
adminService service.AdminService
dashboardService *service.DashboardService
groupCapacityService *service.GroupCapacityService
cfg *config.Config
}
// GetLiveCapability 返回当前服务端是否具备生成 Live attestation 的运行环境。
func (h *GroupHandler) GetLiveCapability(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "live_capability") {
return
}
err := liveattestation.NewProvider().Check(c.Request.Context())
result := gin.H{"supported": err == nil}
if err != nil {
@@ -87,13 +92,94 @@ func (f optionalLimitField) ToServiceInput() *float64 {
// NewGroupHandler creates a new admin group handler
func NewGroupHandler(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService) *GroupHandler {
return NewGroupHandlerWithConfig(adminService, dashboardService, groupCapacityService, nil)
}
func NewGroupHandlerWithConfig(adminService service.AdminService, dashboardService *service.DashboardService, groupCapacityService *service.GroupCapacityService, cfg *config.Config) *GroupHandler {
return &GroupHandler{
adminService: adminService,
dashboardService: dashboardService,
groupCapacityService: groupCapacityService,
cfg: cfg,
}
}
func (h *GroupHandler) isSimpleMode() bool {
return h != nil && h.cfg != nil && h.cfg.RunMode == config.RunModeSimple
}
type simpleModeGroupOperation string
const (
simpleModeGroupList simpleModeGroupOperation = "list"
simpleModeGroupGetAll simpleModeGroupOperation = "get_all"
simpleModeGroupGet simpleModeGroupOperation = "get"
simpleModeGroupCreate simpleModeGroupOperation = "create"
simpleModeGroupUpdate simpleModeGroupOperation = "update"
simpleModeGroupDelete simpleModeGroupOperation = "delete"
)
var simpleModeGroupOperations = map[simpleModeGroupOperation]struct{}{
simpleModeGroupList: {}, simpleModeGroupGetAll: {}, simpleModeGroupGet: {},
simpleModeGroupCreate: {}, simpleModeGroupUpdate: {}, simpleModeGroupDelete: {},
}
func (h *GroupHandler) rejectUnsupportedSimpleModeOperation(c *gin.Context, operation simpleModeGroupOperation) bool {
if _, allowed := simpleModeGroupOperations[operation]; allowed {
return false
}
if err := service.ValidateSimpleModeGroupOperation(h.cfg, service.AdminGroupOperation(operation)); err != nil {
response.ErrorFrom(c, err)
return true
}
return false
}
type simpleModeGroupResponse struct {
ID int64 `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
Status string `json:"status"`
AccountCount int64 `json:"account_count,omitempty"`
ActiveAccountCount int64 `json:"active_account_count,omitempty"`
RateLimitedAccountCount int64 `json:"rate_limited_account_count,omitempty"`
SortOrder int `json:"sort_order"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func groupForSimpleMode(group *service.Group) *simpleModeGroupResponse {
if group == nil {
return nil
}
return &simpleModeGroupResponse{
ID: group.ID, Name: group.Name, Description: group.Description, Platform: group.Platform,
Status: group.Status,
AccountCount: group.AccountCount,
ActiveAccountCount: group.ActiveAccountCount, RateLimitedAccountCount: group.RateLimitedAccountCount,
SortOrder: group.SortOrder, CreatedAt: group.CreatedAt, UpdatedAt: group.UpdatedAt,
}
}
func sanitizeCreateGroupRequestForSimpleMode(req *CreateGroupRequest) {
if req == nil {
return
}
allowed := CreateGroupRequest{Name: req.Name, Description: req.Description, Platform: req.Platform}
allowed.RateMultiplier = 1
allowed.SubscriptionType = service.SubscriptionTypeStandard
*req = allowed
}
func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
if req == nil {
return
}
*req = UpdateGroupRequest{Name: req.Name, Description: req.Description}
}
// CreateGroupRequest represents create group request
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
@@ -147,16 +233,22 @@ type CreateGroupRequest struct {
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch bool `json:"allow_messages_dispatch"`
AllowLive bool `json:"allow_live"`
ForceOpenAIFast bool `json:"force_openai_fast"`
FreeOpenAIFast bool `json:"free_openai_fast"`
RequireOAuthOnly bool `json:"require_oauth_only"`
RequirePrivacySet bool `json:"require_privacy_set"`
DefaultMappedModel string `json:"default_mapped_model"`
MessagesDispatchModelConfig service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
ModelsListConfig service.GroupModelsListConfig `json:"models_list_config"`
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
// 固定账号 manifest 配置;创建路径禁止开启,仅编辑可配置。
CodexModelsManifestConfig service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
// 分组 RPM 上限(0 = 不限制)
RPMLimit int `json:"rpm_limit"`
// OpenAI/Codex 请求推理强度上限,空字符串表示不限制。
// Anthropic/OpenAI 请求推理强度上限,空字符串表示不限制。
MaxReasoningEffort string `json:"max_reasoning_effort"`
// OpenAI/Codex 推理强度精确映射。
// 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
// Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
// 从指定分组复制账号(创建后自动绑定)
CopyAccountsFromGroupIDs []int64 `json:"copy_accounts_from_group_ids"`
@@ -216,15 +308,21 @@ type UpdateGroupRequest struct {
// OpenAI Messages 调度配置(仅 openai 平台使用)
AllowMessagesDispatch *bool `json:"allow_messages_dispatch"`
AllowLive *bool `json:"allow_live"`
ForceOpenAIFast *bool `json:"force_openai_fast"`
FreeOpenAIFast *bool `json:"free_openai_fast"`
RequireOAuthOnly *bool `json:"require_oauth_only"`
RequirePrivacySet *bool `json:"require_privacy_set"`
DefaultMappedModel *string `json:"default_mapped_model"`
MessagesDispatchModelConfig *service.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
ModelsListConfig *service.GroupModelsListConfig `json:"models_list_config"`
ModelAllowlist *service.GroupModelAllowlist `json:"model_allowlist"`
// 固定账号 manifest 配置;nil 表示不修改。
CodexModelsManifestConfig *service.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
// 分组 RPM 上限(0 = 不限制);nil 表示未提供不改动
RPMLimit *int `json:"rpm_limit"`
// OpenAI/Codex 请求推理强度上限;空字符串清除,nil 不修改。
// Anthropic/OpenAI 请求推理强度上限;空字符串清除,nil 不修改。
MaxReasoningEffort *string `json:"max_reasoning_effort"`
// 超过上限时的访问控制;空字符串视为 downgrade,nil 不修改。
MaxReasoningEffortOverLimit *string `json:"max_reasoning_effort_over_limit"`
// nil 不修改,空数组清空,非空数组替换。
ReasoningEffortMappings *[]service.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
// 从指定分组复制账号(同步操作:先清空当前分组的账号绑定,再绑定源分组的账号)
@@ -250,6 +348,9 @@ type CompositeRoutePreviewRequest struct {
// List handles listing all groups with pagination
// GET /api/v1/admin/groups
func (h *GroupHandler) List(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupList) {
return
}
page, pageSize := response.ParsePagination(c)
platform := c.Query("platform")
status := c.Query("status")
@@ -264,7 +365,7 @@ func (h *GroupHandler) List(c *gin.Context) {
sortOrder := c.DefaultQuery("sort_order", "asc")
var isExclusive *bool
if isExclusiveStr != "" {
if !h.isSimpleMode() && isExclusiveStr != "" {
val := isExclusiveStr == "true"
isExclusive = &val
}
@@ -275,6 +376,16 @@ func (h *GroupHandler) List(c *gin.Context) {
return
}
if h.isSimpleMode() {
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
for i := range groups {
if service.IsGroupBindableInSimpleMode(&groups[i]) {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
}
}
response.Paginated(c, simpleGroups, total, page, pageSize)
return
}
outGroups := make([]dto.AdminGroup, 0, len(groups))
for i := range groups {
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
@@ -285,6 +396,9 @@ func (h *GroupHandler) List(c *gin.Context) {
// ListCompositeRoutes handles listing composite model routes for one group.
// GET /api/v1/admin/groups/:id/composite-routes
func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -300,6 +414,9 @@ func (h *GroupHandler) ListCompositeRoutes(c *gin.Context) {
// CreateCompositeRoute handles creating one composite model route.
// POST /api/v1/admin/groups/:id/composite-routes
func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -320,6 +437,9 @@ func (h *GroupHandler) CreateCompositeRoute(c *gin.Context) {
// UpdateCompositeRoute handles replacing one composite model route.
// PUT /api/v1/admin/groups/:id/composite-routes/:route_id
func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -344,6 +464,9 @@ func (h *GroupHandler) UpdateCompositeRoute(c *gin.Context) {
// DeleteCompositeRoute handles deleting one composite model route.
// DELETE /api/v1/admin/groups/:id/composite-routes/:route_id
func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -362,6 +485,9 @@ func (h *GroupHandler) DeleteCompositeRoute(c *gin.Context) {
// PreviewCompositeRoute resolves a model without mutating routes.
// POST /api/v1/admin/groups/:id/composite-routes/preview
func (h *GroupHandler) PreviewCompositeRoute(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, ok := parsePositiveIDParam(c, "id")
if !ok {
return
@@ -415,6 +541,9 @@ func parsePositiveIDParam(c *gin.Context, name string) (int64, bool) {
// bound to them even after the group is disabled).
// GET /api/v1/admin/groups/all
func (h *GroupHandler) GetAll(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGetAll) {
return
}
platform := c.Query("platform")
includeInactive := c.Query("include_inactive") == "true"
@@ -434,6 +563,16 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
return
}
if h.isSimpleMode() {
simpleGroups := make([]simpleModeGroupResponse, 0, len(groups))
for i := range groups {
if service.IsGroupBindableInSimpleMode(&groups[i]) {
simpleGroups = append(simpleGroups, *groupForSimpleMode(&groups[i]))
}
}
response.Success(c, simpleGroups)
return
}
outGroups := make([]dto.AdminGroup, 0, len(groups))
for i := range groups {
outGroups = append(outGroups, *dto.GroupFromServiceAdmin(&groups[i]))
@@ -444,6 +583,9 @@ func (h *GroupHandler) GetAll(c *gin.Context) {
// GetByID handles getting a group by ID
// GET /api/v1/admin/groups/:id
func (h *GroupHandler) GetByID(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupGet) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -456,12 +598,19 @@ func (h *GroupHandler) GetByID(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// GetModelsListCandidates handles getting candidate model IDs for custom /v1/models list.
// GET /api/v1/admin/groups/:id/models-list-candidates
func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
// GetGroupModelAllowlistCandidates handles getting candidate model IDs for the group model allowlist.
// GET /api/v1/admin/groups/:id/model-allowlist-candidates
func (h *GroupHandler) GetGroupModelAllowlistCandidates(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || groupID < 0 {
response.BadRequest(c, "Invalid group ID")
@@ -484,11 +633,21 @@ func (h *GroupHandler) GetModelsListCandidates(c *gin.Context) {
// Create handles creating a new group
// POST /api/v1/admin/groups
func (h *GroupHandler) Create(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupCreate) {
return
}
var req CreateGroupRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if h.isSimpleMode() && req.Platform == service.PlatformComposite {
response.BadRequest(c, "Platform is not supported in simple mode")
return
}
if h.isSimpleMode() {
sanitizeCreateGroupRequestForSimpleMode(&req)
}
if err := service.ValidatePeakRateConfig(req.SubscriptionType, req.PeakRateEnabled, req.PeakStart, req.PeakEnd, float64ValueOrDefault(req.PeakRateMultiplier, 1.0)); err != nil {
response.BadRequest(c, err.Error())
@@ -550,13 +709,17 @@ func (h *GroupHandler) Create(c *gin.Context) {
SupportedModelScopes: req.SupportedModelScopes,
AllowMessagesDispatch: req.AllowMessagesDispatch,
AllowLive: req.AllowLive,
ForceOpenAIFast: req.ForceOpenAIFast,
FreeOpenAIFast: req.FreeOpenAIFast,
RequireOAuthOnly: req.RequireOAuthOnly,
RequirePrivacySet: req.RequirePrivacySet,
DefaultMappedModel: req.DefaultMappedModel,
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
ModelsListConfig: req.ModelsListConfig,
ModelAllowlist: req.ModelAllowlist,
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
RPMLimit: req.RPMLimit,
MaxReasoningEffort: req.MaxReasoningEffort,
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
ReasoningEffortMappings: req.ReasoningEffortMappings,
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
})
@@ -565,12 +728,19 @@ func (h *GroupHandler) Create(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// Duplicate handles creating an inactive group copy with the source account bindings.
// POST /api/v1/admin/groups/:id/duplicate
func (h *GroupHandler) Duplicate(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil || groupID <= 0 {
response.BadRequest(c, "Invalid group ID")
@@ -618,6 +788,9 @@ func (h *GroupHandler) Duplicate(c *gin.Context) {
// Update handles updating a group
// PUT /api/v1/admin/groups/:id
func (h *GroupHandler) Update(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupUpdate) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -629,6 +802,9 @@ func (h *GroupHandler) Update(c *gin.Context) {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if h.isSimpleMode() {
sanitizeUpdateGroupRequestForSimpleMode(&req)
}
group, err := h.adminService.UpdateGroup(c.Request.Context(), groupID, &service.UpdateGroupInput{
Name: req.Name,
@@ -679,13 +855,17 @@ func (h *GroupHandler) Update(c *gin.Context) {
SupportedModelScopes: req.SupportedModelScopes,
AllowMessagesDispatch: req.AllowMessagesDispatch,
AllowLive: req.AllowLive,
ForceOpenAIFast: req.ForceOpenAIFast,
FreeOpenAIFast: req.FreeOpenAIFast,
RequireOAuthOnly: req.RequireOAuthOnly,
RequirePrivacySet: req.RequirePrivacySet,
DefaultMappedModel: req.DefaultMappedModel,
MessagesDispatchModelConfig: req.MessagesDispatchModelConfig,
ModelsListConfig: req.ModelsListConfig,
ModelAllowlist: req.ModelAllowlist,
CodexModelsManifestConfig: req.CodexModelsManifestConfig,
RPMLimit: req.RPMLimit,
MaxReasoningEffort: req.MaxReasoningEffort,
MaxReasoningEffortOverLimit: req.MaxReasoningEffortOverLimit,
ReasoningEffortMappings: req.ReasoningEffortMappings,
CopyAccountsFromGroupIDs: req.CopyAccountsFromGroupIDs,
})
@@ -694,19 +874,30 @@ func (h *GroupHandler) Update(c *gin.Context) {
return
}
if h.isSimpleMode() {
response.Success(c, groupForSimpleMode(group))
return
}
response.Success(c, dto.GroupFromServiceAdmin(group))
}
// Delete handles deleting a group
// DELETE /api/v1/admin/groups/:id
func (h *GroupHandler) Delete(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, simpleModeGroupDelete) {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
return
}
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
if h.isSimpleMode() {
err = h.adminService.DeleteGroupIfEmpty(c.Request.Context(), groupID)
} else {
err = h.adminService.DeleteGroup(c.Request.Context(), groupID)
}
if err != nil {
response.ErrorFrom(c, err)
return
@@ -718,6 +909,9 @@ func (h *GroupHandler) Delete(c *gin.Context) {
// GetStats handles getting group statistics
// GET /api/v1/admin/groups/:id/stats
func (h *GroupHandler) GetStats(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "stats") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -737,6 +931,9 @@ func (h *GroupHandler) GetStats(c *gin.Context) {
// 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) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
todayStart := service.GroupUsageTodayStart(time.Now())
results, err := h.dashboardService.GetGroupUsageSummary(c.Request.Context(), todayStart)
@@ -751,6 +948,9 @@ func (h *GroupHandler) GetUsageSummary(c *gin.Context) {
// GetCapacitySummary returns aggregated capacity (concurrency/sessions/RPM) for all active groups.
// GET /api/v1/admin/groups/capacity-summary
func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
results, err := h.groupCapacityService.GetAllGroupCapacity(c.Request.Context())
if err != nil {
response.Error(c, 500, "Failed to get group capacity summary")
@@ -762,6 +962,9 @@ func (h *GroupHandler) GetCapacitySummary(c *gin.Context) {
// GetGroupAPIKeys handles getting API keys in a group
// GET /api/v1/admin/groups/:id/api-keys
func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "api_keys") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -786,6 +989,9 @@ func (h *GroupHandler) GetGroupAPIKeys(c *gin.Context) {
// GetGroupRateMultipliers handles getting rate multipliers for users in a group
// GET /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -807,6 +1013,9 @@ func (h *GroupHandler) GetGroupRateMultipliers(c *gin.Context) {
// ClearGroupRateMultipliers handles clearing all rate multipliers for a group
// DELETE /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) ClearGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -829,6 +1038,9 @@ type BatchSetGroupRateMultipliersRequest struct {
// BatchSetGroupRateMultipliers handles batch setting rate multipliers for a group
// PUT /api/v1/admin/groups/:id/rate-multipliers
func (h *GroupHandler) BatchSetGroupRateMultipliers(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -857,6 +1069,9 @@ type BatchSetGroupRPMOverridesRequest struct {
// BatchSetGroupRPMOverrides handles batch setting rpm_override for users in a group
// PUT /api/v1/admin/groups/:id/rpm-overrides
func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -880,6 +1095,9 @@ func (h *GroupHandler) BatchSetGroupRPMOverrides(c *gin.Context) {
// ClearGroupRPMOverrides handles clearing all rpm_override for a group
// DELETE /api/v1/admin/groups/:id/rpm-overrides
func (h *GroupHandler) ClearGroupRPMOverrides(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
groupID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
response.BadRequest(c, "Invalid group ID")
@@ -905,6 +1123,9 @@ type UpdateSortOrderRequest struct {
// UpdateSortOrder handles updating group sort orders
// PUT /api/v1/admin/groups/sort-order
func (h *GroupHandler) UpdateSortOrder(c *gin.Context) {
if h.rejectUnsupportedSimpleModeOperation(c, "advanced") {
return
}
var req UpdateSortOrderRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
@@ -0,0 +1,27 @@
package admin
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestGroupRequestsDecodeForceOpenAIFast(t *testing.T) {
var createReq CreateGroupRequest
require.NoError(t, json.Unmarshal([]byte(`{"name":"fast","force_openai_fast":true,"free_openai_fast":true}`), &createReq))
require.True(t, createReq.ForceOpenAIFast)
require.True(t, createReq.FreeOpenAIFast)
var updateReq UpdateGroupRequest
require.NoError(t, json.Unmarshal([]byte(`{"force_openai_fast":false,"free_openai_fast":false}`), &updateReq))
require.NotNil(t, updateReq.ForceOpenAIFast)
require.False(t, *updateReq.ForceOpenAIFast)
require.NotNil(t, updateReq.FreeOpenAIFast)
require.False(t, *updateReq.FreeOpenAIFast)
var omitted UpdateGroupRequest
require.NoError(t, json.Unmarshal([]byte(`{}`), &omitted))
require.Nil(t, omitted.ForceOpenAIFast)
require.Nil(t, omitted.FreeOpenAIFast)
}
@@ -29,4 +29,15 @@ func TestUpdateGroupRequestReasoningEffortMappingsTriState(t *testing.T) {
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
require.Equal(t, "xhigh", (*req.ReasoningEffortMappings)[0].To)
})
t.Run("accepts model scoped mappings", func(t *testing.T) {
var req UpdateGroupRequest
require.NoError(t, json.Unmarshal([]byte(`{"reasoning_effort_mappings":[{"from":"max","to":"low","match_type":"prefix","model":"gpt"}]}`), &req))
require.NotNil(t, req.ReasoningEffortMappings)
require.Len(t, *req.ReasoningEffortMappings, 1)
require.Equal(t, "max", (*req.ReasoningEffortMappings)[0].From)
require.Equal(t, "low", (*req.ReasoningEffortMappings)[0].To)
require.Equal(t, "prefix", (*req.ReasoningEffortMappings)[0].MatchType)
require.Equal(t, "gpt", (*req.ReasoningEffortMappings)[0].Model)
})
}
@@ -0,0 +1,309 @@
package admin
import (
"bytes"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newSimpleModeGroupRouter(svc *stubAdminService) *gin.Engine {
h := NewGroupHandlerWithConfig(svc, nil, nil, &config.Config{RunMode: config.RunModeSimple})
r := gin.New()
r.GET("/groups", h.List)
r.GET("/groups/all", h.GetAll)
r.GET("/groups/live-capability", h.GetLiveCapability)
r.GET("/groups/usage-summary", h.GetUsageSummary)
r.GET("/groups/capacity-summary", h.GetCapacitySummary)
r.PUT("/groups/sort-order", h.UpdateSortOrder)
r.GET("/groups/:id", h.GetByID)
r.GET("/groups/:id/stats", h.GetStats)
r.GET("/groups/:id/api-keys", h.GetGroupAPIKeys)
r.GET("/groups/:id/model-allowlist-candidates", h.GetGroupModelAllowlistCandidates)
r.POST("/groups", h.Create)
r.PUT("/groups/:id", h.Update)
r.POST("/groups/:id/duplicate", h.Duplicate)
r.GET("/groups/:id/composite-routes", h.ListCompositeRoutes)
r.POST("/groups/:id/composite-routes", h.CreateCompositeRoute)
r.POST("/groups/:id/composite-routes/preview", h.PreviewCompositeRoute)
r.PUT("/groups/:id/composite-routes/:route_id", h.UpdateCompositeRoute)
r.DELETE("/groups/:id/composite-routes/:route_id", h.DeleteCompositeRoute)
r.GET("/groups/:id/rate-multipliers", h.GetGroupRateMultipliers)
r.PUT("/groups/:id/rate-multipliers", h.BatchSetGroupRateMultipliers)
r.DELETE("/groups/:id/rate-multipliers", h.ClearGroupRateMultipliers)
r.PUT("/groups/:id/rpm-overrides", h.BatchSetGroupRPMOverrides)
r.DELETE("/groups/:id/rpm-overrides", h.ClearGroupRPMOverrides)
r.DELETE("/groups/:id", h.Delete)
return r
}
func TestGroupHandlerSimpleModeSanitizesCommercialFields(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
create := `{"name":"simple","description":"basic grouping","platform":"anthropic","rate_multiplier":7,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":10,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"rpm_limit":99}`
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(create))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Len(t, svc.createdGroups, 1)
created := svc.createdGroups[0]
require.Equal(t, "basic grouping", created.Description)
require.Equal(t, 1.0, created.RateMultiplier)
require.False(t, created.IsExclusive)
require.Equal(t, service.SubscriptionTypeStandard, created.SubscriptionType)
require.Nil(t, created.DailyLimitUSD)
require.False(t, created.AllowImageGeneration)
require.False(t, created.LongContextPricingEnabled)
require.Empty(t, created.ModelPricing)
require.False(t, created.AllowBatchImageGeneration)
require.Nil(t, created.VideoPrice720P)
require.Nil(t, created.WebSearchPricePerCall)
require.Nil(t, created.AudioRealtimePricePerMin)
require.Zero(t, created.RPMLimit)
update := `{"name":"renamed","description":"still basic","rate_multiplier":9,"is_exclusive":true,"subscription_type":"subscription","daily_limit_usd":12,"long_context_pricing_enabled":true,"model_pricing":[{"model":"claude","input_price":1}],"allow_image_generation":true,"allow_batch_image_generation":true,"video_price_720p":2,"web_search_price_per_call":3,"audio_realtime_price_per_min":4,"status":"inactive","rpm_limit":123}`
req = httptest.NewRequest(http.MethodPut, "/groups/2", bytes.NewBufferString(update))
req.Header.Set("Content-Type", "application/json")
res = httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
require.Len(t, svc.updatedGroups, 1)
updated := svc.updatedGroups[0]
require.NotNil(t, updated.Description)
require.Equal(t, "still basic", *updated.Description)
require.Nil(t, updated.RateMultiplier)
require.Nil(t, updated.IsExclusive)
require.Empty(t, updated.SubscriptionType)
require.Nil(t, updated.DailyLimitUSD)
require.Nil(t, updated.AllowImageGeneration)
require.Nil(t, updated.LongContextPricingEnabled)
require.Nil(t, updated.ModelPricing)
require.Nil(t, updated.AllowBatchImageGeneration)
require.Nil(t, updated.VideoPrice720P)
require.Nil(t, updated.WebSearchPricePerCall)
require.Nil(t, updated.AudioRealtimePricePerMin)
require.Empty(t, updated.Status)
require.Nil(t, updated.RPMLimit)
}
func TestGroupHandlerSimpleModeRejectsCompositeBeforeService(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
req := httptest.NewRequest(http.MethodPost, "/groups", bytes.NewBufferString(`{"name":"composite","platform":"composite"}`))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
r.ServeHTTP(res, req)
require.Equal(t, http.StatusBadRequest, res.Code)
require.Empty(t, svc.createdGroups)
}
func TestGroupHandlerSimpleModeDeleteRejectsNonEmptyGroupBeforeCascade(t *testing.T) {
svc := newStubAdminService()
svc.deleteGroupIfEmptyErr = service.ErrGroupNotEmpty
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/7", nil))
require.Equal(t, http.StatusConflict, res.Code)
require.Equal(t, []int64{7}, svc.guardedDeletedGroupIDs)
require.Empty(t, svc.deletedGroupIDs)
}
func TestGroupHandlerSimpleModeDeletePermitsEmptyGroup(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodDelete, "/groups/8", nil))
require.Equal(t, http.StatusOK, res.Code)
require.Equal(t, []int64{8}, svc.guardedDeletedGroupIDs)
require.Empty(t, svc.deletedGroupIDs)
}
func TestGroupHandlerSimpleModeIgnoresExclusiveFilter(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups?is_exclusive=true", nil))
require.Equal(t, http.StatusOK, res.Code)
require.Nil(t, svc.lastListGroupsIsExclusive)
}
func float64PtrForSimpleModeTest(value float64) *float64 { return &value }
func TestGroupHandlerSimpleModeResponseUsesFieldAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
svc.groups = []service.Group{{
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic,
AccountCount: 3, ActiveAccountCount: 2, RateLimitedAccountCount: 1,
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
LongContextPricingEnabled: true,
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
AllowBatchImageGeneration: true, VideoPrice720P: float64PtrForSimpleModeTest(2),
WebSearchPricePerCall: float64PtrForSimpleModeTest(3), AudioRealtimePricePerMin: float64PtrForSimpleModeTest(4),
ModelRouting: map[string][]int64{"claude": {2}},
}}
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, "/groups", nil))
require.Equal(t, http.StatusOK, res.Code)
var payload struct {
Data struct {
Items []map[string]any `json:"items"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
require.Len(t, payload.Data.Items, 1)
item := payload.Data.Items[0]
require.Equal(t, "basic", item["name"])
require.Equal(t, "allowed", item["description"])
require.Equal(t, "anthropic", item["platform"])
require.Equal(t, float64(3), item["account_count"])
require.ElementsMatch(t, []string{
"id", "name", "description", "platform", "status", "account_count",
"active_account_count", "rate_limited_account_count", "sort_order", "created_at", "updated_at",
}, mapKeys(item))
for _, forbidden := range []string{
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
"audio_realtime_price_per_min", "model_routing",
} {
_, exposed := item[forbidden]
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
}
}
func TestGroupHandlerSimpleModeListsOnlyBindableGroups(t *testing.T) {
svc := newStubAdminService()
svc.groups = []service.Group{{ID: 1, Name: "basic", Platform: service.PlatformAnthropic}, {ID: 2, Name: "composite", Platform: service.PlatformComposite}}
r := newSimpleModeGroupRouter(svc)
for _, path := range []string{"/groups", "/groups/all"} {
res := httptest.NewRecorder()
r.ServeHTTP(res, httptest.NewRequest(http.MethodGet, path, nil))
require.Equal(t, http.StatusOK, res.Code)
require.Contains(t, res.Body.String(), `"name":"basic"`)
require.NotContains(t, res.Body.String(), `"name":"composite"`)
}
}
func mapKeys(values map[string]any) []string {
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
return keys
}
func TestGroupHandlerSimpleModeAllReadAndWriteResponsesUseFieldAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newStubAdminService()
svc.groups = []service.Group{{
ID: 1, Name: "basic", Description: "allowed", Platform: service.PlatformAnthropic, AccountCount: 3,
Status: service.StatusActive, RateMultiplier: 9, RPMLimit: 42,
LongContextPricingEnabled: true,
ModelPricing: []service.ChannelModelPricing{{Models: []string{"claude"}}},
}}
r := newSimpleModeGroupRouter(svc)
assertNoAdvancedFields := func(t *testing.T, item map[string]any) {
t.Helper()
for _, forbidden := range []string{
"rate_multiplier", "rpm_limit", "long_context_pricing_enabled", "model_pricing",
"allow_batch_image_generation", "video_price_720p", "web_search_price_per_call",
"audio_realtime_price_per_min", "model_routing",
} {
_, exposed := item[forbidden]
require.Falsef(t, exposed, "simple mode exposed %s", forbidden)
}
}
for _, tt := range []struct {
name string
method string
path string
body string
list bool
}{
{name: "get all", method: http.MethodGet, path: "/groups/all", list: true},
{name: "get by id", method: http.MethodGet, path: "/groups/1"},
{name: "create", method: http.MethodPost, path: "/groups", body: `{"name":"created","platform":"anthropic","model_pricing":[{"models":["claude"]}]}`},
{name: "update", method: http.MethodPut, path: "/groups/1", body: `{"name":"updated","model_pricing":[{"models":["claude"]}]}`},
} {
t.Run(tt.name, func(t *testing.T) {
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code)
var payload struct {
Data json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(res.Body.Bytes(), &payload))
if tt.list {
var items []map[string]any
require.NoError(t, json.Unmarshal(payload.Data, &items))
require.Len(t, items, 1)
assertNoAdvancedFields(t, items[0])
return
}
var item map[string]any
require.NoError(t, json.Unmarshal(payload.Data, &item))
assertNoAdvancedFields(t, item)
})
}
}
func TestGroupHandlerSimpleModeBlocksAdvancedOperations(t *testing.T) {
gin.SetMode(gin.TestMode)
tests := []struct {
method string
path string
body string
}{
{http.MethodPost, "/groups/1/duplicate", ""},
{http.MethodGet, "/groups/1/model-allowlist-candidates", ""},
{http.MethodGet, "/groups/1/composite-routes", ""},
{http.MethodPost, "/groups/1/composite-routes", `{"public_model":"x","target_platform":"openai"}`},
{http.MethodPost, "/groups/1/composite-routes/preview", `{"model":"x"}`},
{http.MethodPut, "/groups/1/composite-routes/2", `{"public_model":"x","target_platform":"openai"}`},
{http.MethodDelete, "/groups/1/composite-routes/2", ""},
{http.MethodGet, "/groups/1/rate-multipliers", ""},
{http.MethodPut, "/groups/1/rate-multipliers", `{"entries":[]}`},
{http.MethodDelete, "/groups/1/rate-multipliers", ""},
{http.MethodPut, "/groups/1/rpm-overrides", `{"entries":[]}`},
{http.MethodDelete, "/groups/1/rpm-overrides", ""},
{http.MethodGet, "/groups/1/stats", ""},
{http.MethodGet, "/groups/1/api-keys", ""},
{http.MethodGet, "/groups/live-capability", ""},
{http.MethodGet, "/groups/usage-summary", ""},
{http.MethodGet, "/groups/capacity-summary", ""},
{http.MethodPut, "/groups/sort-order", `{"updates":[{"id":1,"sort_order":1}]}`},
}
for _, tt := range tests {
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
svc := newStubAdminService()
r := newSimpleModeGroupRouter(svc)
res := httptest.NewRecorder()
req := httptest.NewRequest(tt.method, tt.path, bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
r.ServeHTTP(res, req)
require.Equal(t, http.StatusForbidden, res.Code)
require.Zero(t, svc.advancedGroupOperationCalls)
})
}
}
@@ -228,7 +228,7 @@ func (h *RedeemHandler) CreateAndRedeem(c *gin.Context) {
return nil, createErr
}
redeemed, redeemErr := h.redeemService.Redeem(ctx, req.UserID, req.Code)
redeemed, redeemErr := h.redeemService.RedeemForAdminFulfillment(ctx, req.UserID, req.Code)
if redeemErr != nil {
return nil, redeemErr
}
@@ -246,7 +246,7 @@ func (h *RedeemHandler) resolveCreateAndRedeemExisting(ctx context.Context, exis
return nil, service.ErrRedeemCodeExpired
}
if existing.CanUse() {
redeemed, err := h.redeemService.Redeem(ctx, userID, existing.Code)
redeemed, err := h.redeemService.RedeemForAdminFulfillment(ctx, userID, existing.Code)
if err == nil {
return gin.H{"redeem_code": dto.RedeemCodeFromServiceAdmin(redeemed)}, nil
}
@@ -29,19 +29,19 @@ func TestAuthHandlerGetCurrentUserReturnsProfileCompatibilityFields(t *testing.T
AvatarURL: "https://cdn.example.com/linuxdo.png",
AvatarSource: "remote_url",
},
identities: []service.UserAuthIdentityRecord{
{
ProviderType: "linuxdo",
ProviderKey: "linuxdo",
ProviderSubject: "linuxdo-subject-31",
VerifiedAt: &verifiedAt,
Metadata: map[string]any{
"username": "linuxdo-handle",
"avatar_url": "https://cdn.example.com/linuxdo.png",
},
identities: []service.UserAuthIdentityRecord{
{
ProviderType: "linuxdo",
ProviderKey: "linuxdo",
ProviderSubject: "linuxdo-subject-31",
VerifiedAt: &verifiedAt,
Metadata: map[string]any{
"username": "linuxdo-handle",
"avatar_url": "https://cdn.example.com/linuxdo.png",
},
},
}
},
}
handler := &AuthHandler{
userService: service.NewUserService(repo, nil, nil, nil),
@@ -66,27 +66,34 @@ type userAvailableGroup struct {
// userSupportedModelPricing 用户可见的定价字段白名单。
type userSupportedModelPricing struct {
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 []userPricingIntervalDTO `json:"intervals"`
BillingMode string `json:"billing_mode"`
InputPrice *float64 `json:"input_price"`
OutputPrice *float64 `json:"output_price"`
CacheWritePrice *float64 `json:"cache_write_price"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_price"`
CacheReadPrice *float64 `json:"cache_read_price"`
MaxReasoningEffortMultiplier *float64 `json:"max_reasoning_effort_multiplier,omitempty"`
ImageInputPrice *float64 `json:"image_input_price"`
ImageOutputPrice *float64 `json:"image_output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
Intervals []userPricingIntervalDTO `json:"intervals"`
}
// userPricingIntervalDTO 定价区间白名单(去掉内部 ID、SortOrder 等前端不渲染的字段)。
type userPricingIntervalDTO struct {
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"`
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"`
CacheWrite1hPrice *float64 `json:"cache_write_1h_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"`
}
// userSupportedModel 用户可见的支持模型条目。
@@ -292,14 +299,19 @@ func toUserPricingIntervals(src []service.PricingInterval) []userPricingInterval
intervals := make([]userPricingIntervalDTO, 0, len(src))
for _, iv := range src {
intervals = append(intervals, userPricingIntervalDTO{
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheReadPrice: iv.CacheReadPrice,
PerRequestPrice: iv.PerRequestPrice,
MinTokens: iv.MinTokens,
MaxTokens: iv.MaxTokens,
TierLabel: iv.TierLabel,
InputPrice: iv.InputPrice,
OutputPrice: iv.OutputPrice,
CacheWritePrice: iv.CacheWritePrice,
CacheWrite1hPrice: iv.CacheWrite1hPrice,
CacheReadPrice: iv.CacheReadPrice,
InputMultiplier: iv.InputMultiplier,
OutputMultiplier: iv.OutputMultiplier,
CacheWriteMultiplier: iv.CacheWriteMultiplier,
CacheReadMultiplier: iv.CacheReadMultiplier,
PerRequestPrice: iv.PerRequestPrice,
})
}
return intervals
@@ -320,14 +332,16 @@ func toUserPricing(p *service.ChannelModelPricing) *userSupportedModelPricing {
billingMode = string(service.BillingModeToken)
}
return &userSupportedModelPricing{
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheReadPrice: p.CacheReadPrice,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
BillingMode: billingMode,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
CacheWritePrice: p.CacheWritePrice,
CacheWrite1hPrice: p.CacheWrite1hPrice,
CacheReadPrice: p.CacheReadPrice,
MaxReasoningEffortMultiplier: p.MaxReasoningEffortMultiplier,
ImageInputPrice: p.ImageInputPrice,
ImageOutputPrice: p.ImageOutputPrice,
PerRequestPrice: p.PerRequestPrice,
Intervals: intervals,
}
}
@@ -113,10 +113,18 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
}
// pricing interval 白名单:不应暴露 id / sort_order。
inputMultiplier := 2.0
outputMultiplier := 1.5
cacheWriteMultiplier := 2.0
cacheReadMultiplier := 2.0
pricing := toUserPricing(&service.ChannelModelPricing{
BillingMode: service.BillingModeToken,
Intervals: []service.PricingInterval{
{ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3},
{
ID: 7, MinTokens: 0, MaxTokens: nil, SortOrder: 3,
InputMultiplier: &inputMultiplier, OutputMultiplier: &outputMultiplier,
CacheWriteMultiplier: &cacheWriteMultiplier, CacheReadMultiplier: &cacheReadMultiplier,
},
},
})
require.NotNil(t, pricing)
@@ -129,6 +137,14 @@ func TestUserAvailableChannel_FieldWhitelist(t *testing.T) {
_, exists := ivDecoded[key]
require.Falsef(t, exists, "user pricing interval must not expose %q", key)
}
for key, want := range map[string]float64{
"input_multiplier": inputMultiplier, "output_multiplier": outputMultiplier,
"cache_write_multiplier": cacheWriteMultiplier, "cache_read_multiplier": cacheReadMultiplier,
} {
got, exists := ivDecoded[key]
require.Truef(t, exists, "user pricing interval must expose %q", key)
require.InDelta(t, want, got.(float64), 1e-12)
}
}
func TestBuildPlatformSections_GroupsByPlatform(t *testing.T) {
@@ -139,9 +139,28 @@ func (h *BatchImageHandler) Models(c *gin.Context) {
batchImageError(c, err)
return
}
// 分组级模型白名单:ListModels 已按分组账号枚举,这里再按白名单过滤一次。
if apiKey, ok := middleware.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil {
got.Data = filterBatchImageModelsByAllowlist(got.Data, apiKey.Group.ModelAllowlist)
}
c.JSON(http.StatusOK, got)
}
// filterBatchImageModelsByAllowlist 按分组模型白名单过滤批量生图模型列表。
// 白名单未开启时原样返回。
func filterBatchImageModelsByAllowlist(models []service.BatchImagePublicModel, allowlist service.GroupModelAllowlist) []service.BatchImagePublicModel {
if !allowlist.Enabled {
return models
}
filtered := make([]service.BatchImagePublicModel, 0, len(models))
for _, model := range models {
if allowlist.Allows(model.ID) {
filtered = append(filtered, model)
}
}
return filtered
}
func (h *BatchImageHandler) Items(c *gin.Context) {
owner, ok := batchImageOwnerFromContext(c)
if !ok {
+62 -13
View File
@@ -1,6 +1,7 @@
package handler
import (
"net/http"
"strings"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -59,17 +60,49 @@ func effectiveAPIKeyPlatform(c *gin.Context, apiKey *service.APIKey) string {
return apiKey.Group.Platform
}
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, bool) {
func openAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
if apiKey == nil || apiKey.Group == nil {
return "", nil, false
return "", nil, "", false
}
if apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
return "", nil, false
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformOpenAI && apiKey.Group.Platform != service.PlatformComposite {
return "", nil, "", false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformOpenAI {
return "", nil, false
effectivePlatform := effectiveAPIKeyPlatform(c, apiKey)
if effectivePlatform != service.PlatformAnthropic && effectivePlatform != service.PlatformOpenAI {
return "", nil, "", false
}
return apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings, true
maxEffort, mappings := apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings
if effectivePlatform == service.PlatformAnthropic {
maxEffort, mappings = anthropicCompatibleReasoningEffortPolicy(maxEffort, mappings)
}
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
}
func anthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey) (string, []service.ReasoningEffortMapping, string, bool) {
if apiKey == nil || apiKey.Group == nil {
return "", nil, "", false
}
if apiKey.Group.Platform != service.PlatformAnthropic && apiKey.Group.Platform != service.PlatformComposite {
return "", nil, "", false
}
if effectiveAPIKeyPlatform(c, apiKey) != service.PlatformAnthropic {
return "", nil, "", false
}
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy(apiKey.Group.MaxReasoningEffort, apiKey.Group.ReasoningEffortMappings)
return maxEffort, mappings, apiKey.Group.MaxReasoningEffortOverLimit, true
}
func anthropicCompatibleReasoningEffortPolicy(maxEffort string, mappings []service.ReasoningEffortMapping) (string, []service.ReasoningEffortMapping) {
if service.NormalizeMaxReasoningEffort(maxEffort) == "minimal" {
maxEffort = "low"
}
normalizedMappings := append([]service.ReasoningEffortMapping(nil), mappings...)
for i := range normalizedMappings {
if service.NormalizeMaxReasoningEffort(normalizedMappings[i].To) == "minimal" {
normalizedMappings[i].To = "low"
}
}
return maxEffort, normalizedMappings
}
func bindRequestedReasoningEffort(c *gin.Context, body []byte, model string) {
@@ -100,13 +133,29 @@ func stampForwardRequestedReasoningEffort(result *service.ForwardResult, request
result.RequestedReasoningEffort = requested
}
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool) {
func applyOpenAIReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
bindRequestedReasoningEffort(c, body, strings.TrimSpace(gjson.GetBytes(body, "model").String()))
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return body, false
return body, false, nil
}
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings)
return service.ApplyOpenAIReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
}
func respondOpenAIReasoningEffortPolicyError(c *gin.Context, err error, write func(*gin.Context, int, string, string)) {
if c == nil || err == nil || write == nil {
return
}
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalPolicyDenied)
write(c, http.StatusForbidden, "permission_error", err.Error())
}
func applyAnthropicReasoningEffortPolicyForRequest(c *gin.Context, apiKey *service.APIKey, body []byte) ([]byte, bool, error) {
maxEffort, mappings, overLimit, ok := anthropicReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return body, false, nil
}
return service.ApplyReasoningEffortPolicy(body, maxEffort, mappings, overLimit)
}
func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *service.APIKey, body []byte) {
@@ -121,9 +170,9 @@ func bindOpenAIReasoningEffortPolicyForMessagesRequest(c *gin.Context, apiKey *s
if !effort.Exists() || effort.Type != gjson.String || strings.TrimSpace(effort.String()) == "" {
return
}
maxEffort, mappings, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
maxEffort, mappings, overLimit, ok := openAIReasoningEffortPolicyForRequest(c, apiKey)
if !ok {
return
}
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings))
c.Request = c.Request.WithContext(service.WithOpenAIReasoningEffortPolicy(c.Request.Context(), maxEffort, mappings, overLimit))
}
@@ -8,6 +8,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestCompositeTargetPlatformAllowedResolvesKnownAllowedModel(t *testing.T) {
@@ -117,7 +118,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
openAICtx, _ := gin.CreateTestContext(httptest.NewRecorder())
openAICtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
openAICtx.Request = openAICtx.Request.WithContext(service.WithResolvedTargetPlatform(openAICtx.Request.Context(), service.PlatformOpenAI))
got, changed := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(openAICtx, apiKey, body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(got))
requested := service.RequestedReasoningEffortFromContext(openAICtx.Request.Context())
@@ -125,7 +127,8 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
require.Equal(t, "max", *requested)
bindOpenAIReasoningEffortPolicyForMessagesRequest(openAICtx, apiKey, []byte(`{"output_config":{"effort":"max"}}`))
bound, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
bound, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(openAICtx.Request.Context(), body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"reasoning":{"effort":"medium"}}`, string(bound))
@@ -133,18 +136,117 @@ func TestOpenAIReasoningEffortPolicyForCompositeTarget(t *testing.T) {
omittedCtx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
omittedCtx.Request = omittedCtx.Request.WithContext(service.WithResolvedTargetPlatform(omittedCtx.Request.Context(), service.PlatformOpenAI))
bindOpenAIReasoningEffortPolicyForMessagesRequest(omittedCtx, apiKey, []byte(`{"model":"gpt-5"}`))
omitted, changed := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
omitted, changed, err := service.ApplyOpenAIReasoningEffortPolicyFromContext(omittedCtx.Request.Context(), body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, omitted)
denyGroup := *group
denyGroup.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
denyAPIKey := &service.APIKey{Group: &denyGroup}
denyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
denyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
denyCtx.Request = denyCtx.Request.WithContext(service.WithResolvedTargetPlatform(denyCtx.Request.Context(), service.PlatformOpenAI))
_, _, err = applyOpenAIReasoningEffortPolicyForRequest(denyCtx, denyAPIKey, body)
require.Error(t, err)
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
mappingDenyGroup := *group
mappingDenyGroup.MaxReasoningEffort = ""
mappingDenyGroup.ReasoningEffortMappings = []service.ReasoningEffortMapping{
{From: "max", To: service.ReasoningEffortMappingDeny},
}
mappingDenyAPIKey := &service.APIKey{Group: &mappingDenyGroup}
mappingDenyCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
mappingDenyCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
mappingDenyCtx.Request = mappingDenyCtx.Request.WithContext(service.WithResolvedTargetPlatform(mappingDenyCtx.Request.Context(), service.PlatformOpenAI))
_, changed, err = applyOpenAIReasoningEffortPolicyForRequest(mappingDenyCtx, mappingDenyAPIKey, body)
require.Error(t, err)
require.False(t, changed)
var mappingDenied *service.ReasoningEffortMappingDeniedError
require.ErrorAs(t, err, &mappingDenied)
require.Equal(t, "max", mappingDenied.Requested)
require.Contains(t, mappingDenied.Error(), "denied by this group's mapping policy")
grokCtx, _ := gin.CreateTestContext(httptest.NewRecorder())
grokCtx.Request = httptest.NewRequest("POST", "/v1/responses", nil)
grokCtx.Request = grokCtx.Request.WithContext(service.WithResolvedTargetPlatform(grokCtx.Request.Context(), service.PlatformGrok))
got, changed = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
got, changed, err = applyOpenAIReasoningEffortPolicyForRequest(grokCtx, apiKey, body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, got)
}
func TestOpenAIShapedReasoningEffortPolicyForAnthropicGroup(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/responses", nil)
apiKey := &service.APIKey{Group: &service.Group{
Platform: service.PlatformAnthropic,
MaxReasoningEffort: "xhigh",
}}
got, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, []byte(`{"reasoning":{"effort":"max"}}`))
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "xhigh", gjson.GetBytes(got, "reasoning.effort").String())
}
func TestAnthropicReasoningEffortPolicyForConcreteAndCompositeTargets(t *testing.T) {
gin.SetMode(gin.TestMode)
body := []byte(`{"model":"claude-fable-5-1","output_config":{"effort":"max"}}`)
policy := func(platform string) *service.APIKey {
return &service.APIKey{Group: &service.Group{
Platform: platform,
MaxReasoningEffort: "xhigh",
ReasoningEffortMappings: []service.ReasoningEffortMapping{
{From: "max", To: "xhigh"},
},
}}
}
concrete, _ := gin.CreateTestContext(httptest.NewRecorder())
concrete.Request = httptest.NewRequest("POST", "/v1/messages", nil)
got, changed, err := applyAnthropicReasoningEffortPolicyForRequest(concrete, policy(service.PlatformAnthropic), body)
require.NoError(t, err)
require.True(t, changed)
require.JSONEq(t, `{"model":"claude-fable-5-1","output_config":{"effort":"xhigh"}}`, string(got))
denyMax := policy(service.PlatformAnthropic)
denyMax.Group.ReasoningEffortMappings = nil
denyMax.Group.MaxReasoningEffortOverLimit = service.ReasoningEffortOverLimitDeny
_, changed, err = applyAnthropicReasoningEffortPolicyForRequest(concrete, denyMax, body)
require.Error(t, err)
require.False(t, changed)
var overLimit *service.ReasoningEffortOverLimitError
require.ErrorAs(t, err, &overLimit)
require.Equal(t, "max", overLimit.Requested)
require.Equal(t, "xhigh", overLimit.Max)
composite, _ := gin.CreateTestContext(httptest.NewRecorder())
composite.Request = httptest.NewRequest("POST", "/v1/messages", nil)
composite.Request = composite.Request.WithContext(service.WithResolvedTargetPlatform(composite.Request.Context(), service.PlatformAnthropic))
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(composite, policy(service.PlatformComposite), body)
require.NoError(t, err)
require.True(t, changed)
require.Equal(t, "xhigh", gjson.GetBytes(got, "output_config.effort").String())
openAI, _ := gin.CreateTestContext(httptest.NewRecorder())
openAI.Request = httptest.NewRequest("POST", "/v1/messages", nil)
openAI.Request = openAI.Request.WithContext(service.WithResolvedTargetPlatform(openAI.Request.Context(), service.PlatformOpenAI))
got, changed, err = applyAnthropicReasoningEffortPolicyForRequest(openAI, policy(service.PlatformComposite), body)
require.NoError(t, err)
require.False(t, changed)
require.Equal(t, body, got)
}
func TestAnthropicCompositePolicyClampsUnsupportedMinimalToLow(t *testing.T) {
maxEffort, mappings := anthropicCompatibleReasoningEffortPolicy("minimal", []service.ReasoningEffortMapping{{From: "max", To: "minimal"}})
require.Equal(t, "low", maxEffort)
require.Equal(t, []service.ReasoningEffortMapping{{From: "max", To: "low"}}, mappings)
}
func TestClientRequestedModelUsesCompositePublicModel(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
@@ -9,10 +9,15 @@ import (
const statusClientClosedRequest = 499
func concurrencyErrorResponse(err error, slotType string) (int, string, string) {
const (
gatewayQueueFullCode = "gateway_queue_full"
gatewayConcurrencyLimitCode = "gateway_concurrency_limit"
)
func concurrencyErrorResponse(err error, slotType string) (int, string, string, string) {
var waitQueueFullErr *WaitQueueFullError
if errors.As(err, &waitQueueFullErr) {
return http.StatusTooManyRequests, "rate_limit_error",
return http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode,
"Too many pending requests, please retry later"
}
@@ -21,13 +26,13 @@ func concurrencyErrorResponse(err error, slotType string) (int, string, string)
if concurrencyErr.SlotType != "" {
slotType = concurrencyErr.SlotType
}
return http.StatusTooManyRequests, "rate_limit_error",
return http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode,
fmt.Sprintf("Concurrency limit exceeded for %s, please retry later", slotType)
}
if errors.Is(err, context.Canceled) {
return statusClientClosedRequest, "api_error", "context canceled"
return statusClientClosedRequest, "api_error", "", "context canceled"
}
return http.StatusServiceUnavailable, "api_error", "Service temporarily unavailable, please retry later"
return http.StatusServiceUnavailable, "api_error", "", "Service temporarily unavailable, please retry later"
}
@@ -16,6 +16,7 @@ func TestConcurrencyErrorResponse(t *testing.T) {
slotType string
wantStatus int
wantType string
wantCode string
wantMessage string
}{
{
@@ -24,8 +25,18 @@ func TestConcurrencyErrorResponse(t *testing.T) {
slotType: "user",
wantStatus: http.StatusTooManyRequests,
wantType: "rate_limit_error",
wantCode: gatewayConcurrencyLimitCode,
wantMessage: "Concurrency limit exceeded for account, please retry later",
},
{
name: "full local wait queue has gateway code",
err: &WaitQueueFullError{SlotType: "account"},
slotType: "account",
wantStatus: http.StatusTooManyRequests,
wantType: "rate_limit_error",
wantCode: gatewayQueueFullCode,
wantMessage: "Too many pending requests, please retry later",
},
{
name: "client cancellation is not classified as concurrency limit",
err: context.Canceled,
@@ -54,9 +65,10 @@ func TestConcurrencyErrorResponse(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
status, errType, message := concurrencyErrorResponse(tt.err, tt.slotType)
status, errType, code, message := concurrencyErrorResponse(tt.err, tt.slotType)
require.Equal(t, tt.wantStatus, status)
require.Equal(t, tt.wantType, errType)
require.Equal(t, tt.wantCode, code)
require.Equal(t, tt.wantMessage, message)
})
}
@@ -0,0 +1,41 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// 固定账号 manifest 配置必须在管理端 DTO 完整序列化:漏映射会让编辑对话框回显
// 关闭状态,管理员保存无关字段时会把已开启的配置静默关掉。用户侧 DTO 不携带该字段。
func TestGroupMapperRoundTripsCodexModelsManifestConfig(t *testing.T) {
group := &service.Group{
ID: 31, Name: "codex-manifest-dto", Platform: service.PlatformOpenAI, Status: service.StatusActive,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{101, 202},
FallbackToScheduler: true,
},
}
adminJSON, err := json.Marshal(GroupFromServiceAdmin(group))
require.NoError(t, err)
var adminEnvelope struct {
CodexModelsManifestConfig *struct {
Enabled bool `json:"enabled"`
AccountIDs []int64 `json:"account_ids"`
FallbackToScheduler bool `json:"fallback_to_scheduler"`
} `json:"codex_models_manifest_config"`
}
require.NoError(t, json.Unmarshal(adminJSON, &adminEnvelope))
require.NotNil(t, adminEnvelope.CodexModelsManifestConfig, "GroupFromServiceAdmin 漏映射 codex_models_manifest_config")
require.True(t, adminEnvelope.CodexModelsManifestConfig.Enabled)
require.Equal(t, []int64{101, 202}, adminEnvelope.CodexModelsManifestConfig.AccountIDs)
require.True(t, adminEnvelope.CodexModelsManifestConfig.FallbackToScheduler)
userJSON, err := json.Marshal(GroupFromService(group))
require.NoError(t, err)
require.NotContains(t, string(userJSON), "codex_models_manifest_config", "用户侧分组 DTO 不得暴露管理端 manifest 配置")
}
@@ -0,0 +1,25 @@
package dto
import (
"encoding/json"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestGroupMapperExposesForceOpenAIFastOnlyToAdmins(t *testing.T) {
group := &service.Group{
ID: 7, Name: "fast", Platform: service.PlatformOpenAI, Status: service.StatusActive, ForceOpenAIFast: true, FreeOpenAIFast: true,
}
userJSON, err := json.Marshal(GroupFromService(group))
require.NoError(t, err)
require.NotContains(t, string(userJSON), "force_openai_fast")
require.NotContains(t, string(userJSON), "free_openai_fast")
adminJSON, err := json.Marshal(GroupFromServiceAdmin(group))
require.NoError(t, err)
require.Contains(t, string(adminJSON), `"force_openai_fast":true`)
require.Contains(t, string(adminJSON), `"free_openai_fast":true`)
}
+47 -1
View File
@@ -148,6 +148,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
}
out := &AdminGroup{
Group: groupFromServiceBase(g),
ForceOpenAIFast: g.ForceOpenAIFast,
FreeOpenAIFast: g.FreeOpenAIFast,
ProfitControlEnabled: g.ProfitControlEnabled,
ProfitMinMargin: g.ProfitMinMargin,
ProfitSafetyBuffer: g.ProfitSafetyBuffer,
@@ -157,7 +159,8 @@ func GroupFromServiceAdmin(g *service.Group) *AdminGroup {
MCPXMLInject: g.MCPXMLInject,
DefaultMappedModel: g.DefaultMappedModel,
MessagesDispatchModelConfig: g.MessagesDispatchModelConfig,
ModelsListConfig: g.ModelsListConfig,
ModelAllowlist: g.ModelAllowlist,
CodexModelsManifestConfig: g.CodexModelsManifestConfig,
SupportedModelScopes: g.SupportedModelScopes,
AccountCount: g.AccountCount,
ActiveAccountCount: g.ActiveAccountCount,
@@ -221,6 +224,7 @@ func groupFromServiceBase(g *service.Group) Group {
RequirePrivacySet: g.RequirePrivacySet,
RPMLimit: g.RPMLimit,
MaxReasoningEffort: g.MaxReasoningEffort,
MaxReasoningEffortOverLimit: g.MaxReasoningEffortOverLimit,
ReasoningEffortMappings: g.ReasoningEffortMappings,
CreatedAt: g.CreatedAt,
UpdatedAt: g.UpdatedAt,
@@ -444,6 +448,47 @@ func AccountFromService(a *service.Account) *Account {
return out
}
// AccountListItemFromAccount projects a full account response into the
// compact shape used by the paginated admin account list. Keeping this
// projection separate from Account preserves the existing detail API.
func AccountListItemFromAccount(a *Account) *AccountListItem {
if a == nil {
return nil
}
return &AccountListItem{
ID: a.ID, Name: a.Name, Notes: a.Notes, Platform: a.Platform, Type: a.Type,
Credentials: a.Credentials, CredentialsStatus: a.CredentialsStatus, Extra: a.Extra,
OllamaCloudUsage: a.OllamaCloudUsage,
ProxyID: a.ProxyID, ProxyFallbackOriginID: a.ProxyFallbackOriginID, ProxyFallbackOriginName: a.ProxyFallbackOriginName,
Concurrency: a.Concurrency, LoadFactor: a.LoadFactor, Priority: a.Priority, RateMultiplier: a.RateMultiplier,
Status: a.Status, ErrorMessage: a.ErrorMessage, LastUsedAt: a.LastUsedAt, ExpiresAt: a.ExpiresAt,
AutoPauseOnExpired: a.AutoPauseOnExpired, CreatedAt: a.CreatedAt, UpdatedAt: a.UpdatedAt,
Schedulable: a.Schedulable, RateLimitedAt: a.RateLimitedAt, RateLimitResetAt: a.RateLimitResetAt,
OverloadUntil: a.OverloadUntil, TempUnschedulableUntil: a.TempUnschedulableUntil,
TempUnschedulableReason: a.TempUnschedulableReason, SessionWindowStart: a.SessionWindowStart,
SessionWindowEnd: a.SessionWindowEnd, SessionWindowStatus: a.SessionWindowStatus,
WindowCostLimit: a.WindowCostLimit, WindowCostStickyReserve: a.WindowCostStickyReserve,
MaxSessions: a.MaxSessions, SessionIdleTimeoutMin: a.SessionIdleTimeoutMin, BaseRPM: a.BaseRPM,
RPMStrategy: a.RPMStrategy, RPMStickyBuffer: a.RPMStickyBuffer, UserMsgQueueMode: a.UserMsgQueueMode,
EnableTLSFingerprint: a.EnableTLSFingerprint, TLSFingerprintProfileID: a.TLSFingerprintProfileID,
EnableSessionIDMasking: a.EnableSessionIDMasking, CacheTTLOverrideEnabled: a.CacheTTLOverrideEnabled,
CacheTTLOverrideTarget: a.CacheTTLOverrideTarget, CustomBaseURLEnabled: a.CustomBaseURLEnabled,
CustomBaseURL: a.CustomBaseURL, QuotaLimit: a.QuotaLimit, QuotaUsed: a.QuotaUsed,
QuotaDailyLimit: a.QuotaDailyLimit, QuotaDailyUsed: a.QuotaDailyUsed, QuotaWeeklyLimit: a.QuotaWeeklyLimit,
QuotaWeeklyUsed: a.QuotaWeeklyUsed, QuotaDailyResetMode: a.QuotaDailyResetMode,
QuotaDailyResetHour: a.QuotaDailyResetHour, QuotaWeeklyResetMode: a.QuotaWeeklyResetMode,
QuotaWeeklyResetDay: a.QuotaWeeklyResetDay, QuotaWeeklyResetHour: a.QuotaWeeklyResetHour,
QuotaResetTimezone: a.QuotaResetTimezone, QuotaDailyResetAt: a.QuotaDailyResetAt,
QuotaWeeklyResetAt: a.QuotaWeeklyResetAt, QuotaNotifyDailyEnabled: a.QuotaNotifyDailyEnabled,
QuotaNotifyDailyThreshold: a.QuotaNotifyDailyThreshold, QuotaNotifyWeeklyEnabled: a.QuotaNotifyWeeklyEnabled,
QuotaNotifyWeeklyThreshold: a.QuotaNotifyWeeklyThreshold, QuotaNotifyTotalEnabled: a.QuotaNotifyTotalEnabled,
QuotaNotifyTotalThreshold: a.QuotaNotifyTotalThreshold, ParentAccountID: a.ParentAccountID,
QuotaDimension: a.QuotaDimension, ParentEmail: a.ParentEmail, ParentPlanType: a.ParentPlanType,
ParentPrivacyMode: a.ParentPrivacyMode, ParentSubscriptionExpiresAt: a.ParentSubscriptionExpiresAt,
ParentChatGPTAccountID: a.ParentChatGPTAccountID, Proxy: a.Proxy, GroupIDs: a.GroupIDs,
}
}
func timeToUnixSeconds(value *time.Time) *int64 {
if value == nil {
return nil
@@ -720,6 +765,7 @@ func UsageLogFromServiceAdmin(l *service.UsageLog) *AdminUsageLog {
UpstreamModelMismatch: l.UpstreamModelMismatch,
ChannelID: l.ChannelID,
ModelMappingChain: l.ModelMappingChain,
UpstreamRequestID: l.UpstreamRequestID,
BillingTier: l.BillingTier,
AccountRateMultiplier: l.AccountRateMultiplier,
AccountStatsCost: l.AccountStatsCost,
+109 -3
View File
@@ -151,9 +151,11 @@ type Group struct {
// RPMLimit 分组级每分钟请求数上限(0 = 不限制),设置后覆盖用户级 rpm_limit。
RPMLimit int `json:"rpm_limit"`
// MaxReasoningEffort OpenAI/Codex 请求的推理强度上限,空字符串表示不限制。
// MaxReasoningEffort Anthropic/OpenAI 请求的推理强度上限,空字符串表示不限制。
MaxReasoningEffort string `json:"max_reasoning_effort"`
// ReasoningEffortMappings OpenAI/Codex 推理强度精确映射。
// MaxReasoningEffortOverLimit 超过上限时的访问控制:downgrade(默认)或 deny。
MaxReasoningEffortOverLimit string `json:"max_reasoning_effort_over_limit"`
// ReasoningEffortMappings Anthropic/OpenAI 推理强度映射,可按模型精确名、前缀或后缀限定。
ReasoningEffortMappings []domain.ReasoningEffortMapping `json:"reasoning_effort_mappings"`
CreatedAt time.Time `json:"created_at"`
@@ -164,6 +166,10 @@ type Group struct {
// 注意:普通用户接口不得返回 model_routing/account_count/account_groups 等内部信息。
type AdminGroup struct {
Group
// ForceOpenAIFast 是管理端请求策略,用户侧分组 DTO 无需暴露。
ForceOpenAIFast bool `json:"force_openai_fast"`
// FreeOpenAIFast 是管理端计费策略,用户侧分组 DTO 无需暴露。
FreeOpenAIFast bool `json:"free_openai_fast"`
// 分组利润控制(五个 token 平台分组可启用;margin/buffer 为小数存储)。
// 仅管理员可见:这三个字段与同响应中的 rate_multiplier 相乘即可反推出
@@ -183,7 +189,9 @@ type AdminGroup struct {
// OpenAI Messages 调度配置(仅 openai 平台使用)
DefaultMappedModel string `json:"default_mapped_model"`
MessagesDispatchModelConfig domain.OpenAIMessagesDispatchModelConfig `json:"messages_dispatch_model_config"`
ModelsListConfig domain.GroupModelsListConfig `json:"models_list_config"`
ModelAllowlist service.GroupModelAllowlist `json:"model_allowlist"`
// 固定账号获取 Codex Model Manifest 配置(仅 openai 平台使用)。
CodexModelsManifestConfig domain.GroupCodexModelsManifestConfig `json:"codex_models_manifest_config"`
// 支持的模型系列(仅 antigravity 平台使用)
SupportedModelScopes []string `json:"supported_model_scopes"`
@@ -316,6 +324,102 @@ type Account struct {
Groups []*Group `json:"groups,omitempty"`
}
// AccountListItem is the compact representation returned by the admin account
// list when the lite query parameter is enabled. It contains the fields used
// by the account table and runtime indicators, but intentionally omits the
// repeated account_groups and groups object graphs. Fetch /admin/accounts/:id
// for the complete Account DTO when editing or inspecting an account.
type AccountListItem struct {
ID int64 `json:"id"`
Name string `json:"name"`
Notes *string `json:"notes"`
Platform string `json:"platform"`
Type string `json:"type"`
Credentials map[string]any `json:"credentials,omitempty"`
CredentialsStatus map[string]bool `json:"credentials_status,omitempty"`
Extra map[string]any `json:"extra,omitempty"`
OllamaCloudUsage *service.OllamaCloudUsageState `json:"ollama_cloud_usage,omitempty"`
ProxyID *int64 `json:"proxy_id"`
ProxyFallbackOriginID *int64 `json:"proxy_fallback_origin_id"`
ProxyFallbackOriginName *string `json:"proxy_fallback_origin_name,omitempty"`
Concurrency int `json:"concurrency"`
LoadFactor *int `json:"load_factor,omitempty"`
Priority int `json:"priority"`
RateMultiplier float64 `json:"rate_multiplier"`
Status string `json:"status"`
ErrorMessage string `json:"error_message"`
LastUsedAt *time.Time `json:"last_used_at"`
ExpiresAt *int64 `json:"expires_at"`
AutoPauseOnExpired bool `json:"auto_pause_on_expired"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Schedulable bool `json:"schedulable"`
RateLimitedAt *time.Time `json:"rate_limited_at"`
RateLimitResetAt *time.Time `json:"rate_limit_reset_at"`
OverloadUntil *time.Time `json:"overload_until"`
TempUnschedulableUntil *time.Time `json:"temp_unschedulable_until"`
TempUnschedulableReason string `json:"temp_unschedulable_reason"`
SessionWindowStart *time.Time `json:"session_window_start"`
SessionWindowEnd *time.Time `json:"session_window_end"`
SessionWindowStatus string `json:"session_window_status"`
WindowCostLimit *float64 `json:"window_cost_limit,omitempty"`
WindowCostStickyReserve *float64 `json:"window_cost_sticky_reserve,omitempty"`
MaxSessions *int `json:"max_sessions,omitempty"`
SessionIdleTimeoutMin *int `json:"session_idle_timeout_minutes,omitempty"`
BaseRPM *int `json:"base_rpm,omitempty"`
RPMStrategy *string `json:"rpm_strategy,omitempty"`
RPMStickyBuffer *int `json:"rpm_sticky_buffer,omitempty"`
UserMsgQueueMode *string `json:"user_msg_queue_mode,omitempty"`
EnableTLSFingerprint *bool `json:"enable_tls_fingerprint,omitempty"`
TLSFingerprintProfileID *int64 `json:"tls_fingerprint_profile_id,omitempty"`
EnableSessionIDMasking *bool `json:"session_id_masking_enabled,omitempty"`
CacheTTLOverrideEnabled *bool `json:"cache_ttl_override_enabled,omitempty"`
CacheTTLOverrideTarget *string `json:"cache_ttl_override_target,omitempty"`
CustomBaseURLEnabled *bool `json:"custom_base_url_enabled,omitempty"`
CustomBaseURL *string `json:"custom_base_url,omitempty"`
QuotaLimit *float64 `json:"quota_limit,omitempty"`
QuotaUsed *float64 `json:"quota_used,omitempty"`
QuotaDailyLimit *float64 `json:"quota_daily_limit,omitempty"`
QuotaDailyUsed *float64 `json:"quota_daily_used,omitempty"`
QuotaWeeklyLimit *float64 `json:"quota_weekly_limit,omitempty"`
QuotaWeeklyUsed *float64 `json:"quota_weekly_used,omitempty"`
QuotaDailyResetMode *string `json:"quota_daily_reset_mode,omitempty"`
QuotaDailyResetHour *int `json:"quota_daily_reset_hour,omitempty"`
QuotaWeeklyResetMode *string `json:"quota_weekly_reset_mode,omitempty"`
QuotaWeeklyResetDay *int `json:"quota_weekly_reset_day,omitempty"`
QuotaWeeklyResetHour *int `json:"quota_weekly_reset_hour,omitempty"`
QuotaResetTimezone *string `json:"quota_reset_timezone,omitempty"`
QuotaDailyResetAt *string `json:"quota_daily_reset_at,omitempty"`
QuotaWeeklyResetAt *string `json:"quota_weekly_reset_at,omitempty"`
QuotaNotifyDailyEnabled *bool `json:"quota_notify_daily_enabled,omitempty"`
QuotaNotifyDailyThreshold *float64 `json:"quota_notify_daily_threshold,omitempty"`
QuotaNotifyWeeklyEnabled *bool `json:"quota_notify_weekly_enabled,omitempty"`
QuotaNotifyWeeklyThreshold *float64 `json:"quota_notify_weekly_threshold,omitempty"`
QuotaNotifyTotalEnabled *bool `json:"quota_notify_total_enabled,omitempty"`
QuotaNotifyTotalThreshold *float64 `json:"quota_notify_total_threshold,omitempty"`
ParentAccountID *int64 `json:"parent_account_id,omitempty"`
QuotaDimension string `json:"quota_dimension,omitempty"`
ParentEmail string `json:"parent_email,omitempty"`
ParentPlanType string `json:"parent_plan_type,omitempty"`
ParentPrivacyMode string `json:"parent_privacy_mode,omitempty"`
ParentSubscriptionExpiresAt string `json:"parent_subscription_expires_at,omitempty"`
ParentChatGPTAccountID string `json:"parent_chatgpt_account_id,omitempty"`
Proxy *Proxy `json:"proxy,omitempty"`
GroupIDs []int64 `json:"group_ids,omitempty"`
}
type AccountGroup struct {
AccountID int64 `json:"account_id"`
GroupID int64 `json:"group_id"`
@@ -582,6 +686,8 @@ type AdminUsageLog struct {
ChannelID *int64 `json:"channel_id,omitempty"`
// ModelMappingChain 模型映射链,如 "a→b→c"
ModelMappingChain *string `json:"model_mapping_chain,omitempty"`
// UpstreamRequestID 是直接上游声明的请求标识,仅管理端可见。
UpstreamRequestID *string `json:"upstream_request_id,omitempty"`
// BillingTier 计费层级标签(per_request/image 模式)
BillingTier *string `json:"billing_tier,omitempty"`
+1 -1
View File
@@ -279,7 +279,7 @@ func InboundEndpointMiddleware() gin.HandlerFunc {
// ──────────────────────────────────────────────────────────
// Context helpers — used by handlers before building
// RecordUsageInput / RecordUsageLongContextInput.
// RecordUsageInput.
// ──────────────────────────────────────────────────────────
// GetInboundEndpoint returns the canonical inbound endpoint stored by
+122 -90
View File
@@ -171,6 +171,19 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
reqStream := parsedReq.Stream
bindRequestedReasoningEffort(c, body, reqModel)
ensureCompositeTargetPlatform(c, apiKey, reqModel)
if policyBody, changed, err := applyAnthropicReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
return
} else if changed {
if err := parsedReq.ReplaceBody(policyBody); err != nil {
reqLog.Warn("gateway.reasoning_effort_policy_parse_failed", zap.Error(err))
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Failed to apply reasoning effort policy")
return
}
body = parsedReq.Body.Bytes()
reqModel = parsedReq.Model
reqStream = parsedReq.Stream
}
reqLog = reqLog.With(zap.String("model", reqModel), zap.Bool("stream", reqStream))
// 解析渠道级模型映射
@@ -395,7 +408,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.Int64("account_id", account.ID),
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
)
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted)
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted)
return
}
if err == nil && canWait {
@@ -610,6 +623,23 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
c.Request = c.Request.WithContext(ctx)
}
// 会话槽失败释放:failover 链上每个选中的 Anthropic OAuth 账号都注册过同一会话
// (checkAndRegisterSession)。若请求最终失败(转发失败/客户端中断/换号耗尽),
// 须立即释放本次会话注册——上游从未真正服务该会话,继续占槽会让 max_sessions
// 受限的账号被失败请求的 session hash 卡满整个空闲窗口,后续新会话全部被拒。
// 成功请求保持既有空闲超时语义(会话按最后活动时间过期)。
sessionSlotAccounts := make(map[int64]*service.Account)
upstreamServedSession := false
defer func() {
if upstreamServedSession {
return
}
// 客户端可能已断开、请求 ctx 已取消,用独立 ctx 执行释放
for _, acc := range sessionSlotAccounts {
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
}
}()
for {
fs := NewFailoverState(h.maxAccountSwitches, hasBoundSession)
retryWithFallback := false
@@ -719,7 +749,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
zap.Int64("account_id", account.ID),
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
)
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later", streamStarted)
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later", streamStarted)
return
}
if err == nil && canWait {
@@ -763,10 +793,15 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
h.handleStreamingAwareError(c, http.StatusServiceUnavailable, "api_error", profitVetoExhaustedMessage, streamStarted)
return
}
// 尝试被否决(从未转发),立即释放该账号的会话注册
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
delete(sessionSlotAccounts, account.ID)
continue
}
account = latest
selection.Account = latest
// 记录本请求注册过会话槽的账号(profit 准入后账号已定)
sessionSlotAccounts[account.ID] = account
// 等待路径保持既有 eager 绑定(无门时 helper 直接绑定);调度器已
// 抢槽的直达路径无门时由选号内部绑定,这里只在门下补准入后绑定。
if selection.ProfitGateActive() || !selection.Acquired {
@@ -983,6 +1018,11 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
currentSubscription = nil
fallbackUsed = true
retryWithFallback = true
// 原分组账号已确定性失败(prompt too long),先释放其会话注册再走兜底分组
for _, acc := range sessionSlotAccounts {
h.gatewayService.ReleaseAccountSession(context.Background(), acc, sessionKey)
}
sessionSlotAccounts = make(map[int64]*service.Account)
break
}
_ = h.antigravityGatewayService.WriteMappedClaudeError(c, account, promptTooLongErr.StatusCode, promptTooLongErr.RequestID, promptTooLongErr.Body)
@@ -998,6 +1038,9 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
action := fs.HandleFailoverError(c.Request.Context(), h.gatewayService, account.ID, account.Platform, account.GetPoolModeRetryCount(), failoverErr)
switch action {
case FailoverContinue:
// 本次尝试已确定性失败,立即释放该账号的会话注册
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionKey)
delete(sessionSlotAccounts, account.ID)
continue
case FailoverExhausted:
h.handleFailoverExhausted(c, fs.LastFailoverErr, account.Platform, streamStarted)
@@ -1036,6 +1079,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// 不会走到这里重复计费。
if result != nil {
submitForwardUsage(result)
// 上游已接受并计量本次会话(流中断),会话槽保持既有语义
upstreamServedSession = true
}
return
}
@@ -1061,6 +1106,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
submitForwardUsage(result)
// 转发成功,会话槽保持既有空闲超时语义
upstreamServedSession = true
return
}
if !retryWithFallback {
@@ -1087,11 +1134,20 @@ func (h *GatewayHandler) Models(c *gin.Context) {
platform = forcedPlatform
}
if platform == service.PlatformOpenAI && apiKey != nil && apiKey.Group != nil &&
apiKey.Group.Platform == service.PlatformOpenAI && apiKey.Group.CodexModelsManifestConfig.Enabled {
h.pinnedOpenAIModels(c, apiKey.Group)
return
}
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(c.Request.Context(), groupID)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
availableModels = filterModelsByCustomList(availableModels, defaultModelIDsForPlatform(service.PlatformComposite), apiKey.Group.ModelsListConfig.Models)
writeCustomModelsList(c, service.PlatformComposite, availableModels)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
source := availableModels
if len(source) == 0 {
source = defaultModelIDsForPlatform(service.PlatformComposite)
}
writeAllowlistedModelsList(c, service.PlatformComposite, apiKey.Group.ModelAllowlist.FilterForListing(source))
return
}
if len(availableModels) > 0 {
@@ -1104,10 +1160,9 @@ func (h *GatewayHandler) Models(c *gin.Context) {
// Get available models from account configurations for the selected group platform.
availableModels := h.gatewayService.GetAvailableModels(c.Request.Context(), groupID, platform)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.CustomModelsListEnabled() {
fallbackModels := defaultModelIDsForPlatform(platform)
availableModels = filterModelsByCustomList(customModelsListSource(platform, availableModels, fallbackModels), fallbackModels, apiKey.Group.ModelsListConfig.Models)
writeCustomModelsList(c, platform, availableModels)
if apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
source := modelListingSource(platform, availableModels, defaultModelIDsForPlatform(platform))
writeAllowlistedModelsList(c, platform, apiKey.Group.ModelAllowlist.FilterForListing(source))
return
}
@@ -1192,8 +1247,12 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
if platform == service.PlatformComposite {
availableModels := h.compositeAvailableModels(ctx, groupID)
fallbackModels := defaultCodexModelIDsForPlatform(service.PlatformComposite)
if group.CustomModelsListEnabled() {
return filterModelsByCustomList(availableModels, fallbackModels, group.ModelsListConfig.Models)
if group.ModelAllowlistEnabled() {
source := availableModels
if len(source) == 0 {
source = fallbackModels
}
return group.ModelAllowlist.FilterForListing(source)
}
if len(availableModels) > 0 {
return availableModels
@@ -1203,12 +1262,8 @@ func (h *GatewayHandler) codexModelIDsForGroup(ctx context.Context, group *servi
availableModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
fallbackModels := defaultCodexModelIDsForPlatform(platform)
if group.CustomModelsListEnabled() {
return filterModelsByCustomList(
customModelsListSource(platform, availableModels, fallbackModels),
fallbackModels,
group.ModelsListConfig.Models,
)
if group.ModelAllowlistEnabled() {
return group.ModelAllowlist.FilterForListing(modelListingSource(platform, availableModels, fallbackModels))
}
if len(availableModels) > 0 {
return availableModels
@@ -1248,6 +1303,10 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
}
func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
if platform == service.PlatformOpenAI {
writeOpenAIModelsList(c, modelIDs)
return
}
if platform == service.PlatformGrok {
writeGrokModelsList(c, modelIDs)
return
@@ -1267,7 +1326,7 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
})
}
func writeCustomModelsList(c *gin.Context, platform string, modelIDs []string) {
func writeAllowlistedModelsList(c *gin.Context, platform string, modelIDs []string) {
if platform == service.PlatformOpenAI {
writeOpenAIModelsList(c, modelIDs)
return
@@ -1365,72 +1424,19 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
})
}
func customModelsListSource(platform string, availableModels, fallbackModels []string) []string {
if platform == service.PlatformAnthropic && len(availableModels) > 0 {
// modelListingSource 汇总模型列表过滤的候选来源:账号映射键(availableModels)
// 与平台默认列表(fallbackModels)。账号映射为空时回落默认列表;Anthropic
// 平台两者取并集,其余平台以账号映射键为准。
func modelListingSource(platform string, availableModels, fallbackModels []string) []string {
if len(availableModels) == 0 {
return fallbackModels
}
if platform == service.PlatformAnthropic {
return mergeModelIDs(availableModels, fallbackModels)
}
return availableModels
}
func filterModelsByCustomList(availableModels, fallbackModels, selectedModels []string) []string {
if len(selectedModels) == 0 {
return availableModels
}
source := availableModels
if len(source) == 0 {
source = fallbackModels
}
if len(source) == 0 {
return nil
}
allowed := make([]string, 0, len(source))
for _, model := range source {
model = strings.TrimSpace(model)
if model != "" {
allowed = append(allowed, model)
}
}
seen := make(map[string]struct{}, len(selectedModels))
filtered := make([]string, 0, len(selectedModels))
for _, model := range selectedModels {
model = strings.TrimSpace(model)
if model == "" {
continue
}
if !customModelsListAllowsModel(allowed, model) {
continue
}
if _, ok := seen[model]; ok {
continue
}
seen[model] = struct{}{}
filtered = append(filtered, model)
}
return filtered
}
func customModelsListAllowsModel(availablePatterns []string, model string) bool {
for _, pattern := range availablePatterns {
if pattern == model {
return true
}
if strings.HasSuffix(pattern, "*") && strings.HasPrefix(model, strings.TrimSuffix(pattern, "*")) {
return true
}
}
normalizedClaudeModel := claude.NormalizeModelID(strings.TrimSuffix(model, "-thinking"))
if normalizedClaudeModel != model {
for _, pattern := range availablePatterns {
if pattern == normalizedClaudeModel {
return true
}
}
}
return false
}
func defaultCodexModelIDsForPlatform(platform string) []string {
switch platform {
case service.PlatformDeepseek:
@@ -1504,10 +1510,21 @@ func mergeModelIDs(primary, secondary []string) []string {
// AntigravityModels 返回 Antigravity 支持的全部模型
// GET /antigravity/models
// 分组级模型白名单开启时按白名单过滤。
func (h *GatewayHandler) AntigravityModels(c *gin.Context) {
models := antigravity.DefaultModels()
if apiKey, ok := middleware2.GetAPIKeyFromContext(c); ok && apiKey != nil && apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
filtered := make([]antigravity.ClaudeModel, 0, len(models))
for _, model := range models {
if apiKey.Group.ModelAllowlist.Allows(model.ID) {
filtered = append(filtered, model)
}
}
models = filtered
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": antigravity.DefaultModels(),
"data": models,
})
}
@@ -1853,8 +1870,8 @@ func (h *GatewayHandler) calculateSubscriptionRemaining(group *service.Group, su
// handleConcurrencyError handles concurrency-related acquire errors.
func (h *GatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
status, errType, message := concurrencyErrorResponse(err, slotType)
h.handleStreamingAwareError(c, status, errType, message, streamStarted)
status, errType, code, message := concurrencyErrorResponse(err, slotType)
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted)
}
func (h *GatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, platform string, streamStarted bool) {
@@ -1925,6 +1942,10 @@ func (h *GatewayHandler) mapUpstreamError(statusCode int) (int, string, string)
// handleStreamingAwareError handles errors that may occur after streaming has started
func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, errType, message string, streamStarted bool) {
h.handleStreamingAwareErrorWithCode(c, status, errType, "", message, streamStarted)
}
func (h *GatewayHandler) handleStreamingAwareErrorWithCode(c *gin.Context, status int, errType, code, message string, streamStarted bool) {
if streamStarted {
// 响应状态码已固化为 200(ping/部分数据已 flush),错误只能就地以 SSE 帧回传。
// 标记本次流内错误,供 ops_error_logger 补记——否则该中间件按 status>=400 采集,
@@ -1935,7 +1956,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
// response.completed/failed/incomplete/cancelled 集合。
// Anthropic-backed Responses 路径同样会因为通用 error 帧被拒。
if inboundIsResponses(c) {
if writeResponsesFailedSSE(c, errType, message) {
if writeResponsesFailedSSE(c, errType, code, message) {
return
}
}
@@ -1943,7 +1964,11 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
flusher, ok := c.Writer.(http.Flusher)
if ok {
// SSE 错误事件固定 schema,使用 Quote 直拼可避免额外 Marshal 分配。
errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + `,"message":` + strconv.Quote(message) + `}}` + "\n\n"
errorCode := ""
if code != "" {
errorCode = `,"code":` + strconv.Quote(code)
}
errorEvent := `data: {"type":"error","error":{"type":` + strconv.Quote(errType) + errorCode + `,"message":` + strconv.Quote(message) + `}}` + "\n\n"
if _, err := fmt.Fprint(c.Writer, errorEvent); err != nil {
_ = c.Error(err)
}
@@ -1953,7 +1978,7 @@ func (h *GatewayHandler) handleStreamingAwareError(c *gin.Context, status int, e
}
// Normal case: return JSON response with proper status code
h.errorResponse(c, status, errType, message)
h.errorResponseWithCode(c, status, errType, code, message)
}
// ensureForwardErrorResponse 在 Forward 返回错误但尚未写响应时补写统一错误响应。
@@ -2045,12 +2070,17 @@ func (h *GatewayHandler) checkClaudeCodeVersion(c *gin.Context) bool {
// errorResponse 返回Claude API格式的错误响应
func (h *GatewayHandler) errorResponse(c *gin.Context, status int, errType, message string) {
h.errorResponseWithCode(c, status, errType, "", message)
}
func (h *GatewayHandler) errorResponseWithCode(c *gin.Context, status int, errType, code, message string) {
errorObject := gin.H{"type": errType, "message": message}
if code != "" {
errorObject["code"] = code
}
c.JSON(status, gin.H{
"type": "error",
"error": gin.H{
"type": errType,
"message": message,
},
"type": "error",
"error": errorObject,
})
}
@@ -2163,6 +2193,8 @@ func (h *GatewayHandler) CountTokens(c *gin.Context) {
if err := h.gatewayService.ForwardCountTokens(c.Request.Context(), c, account, parsedReq); err != nil {
reqLog.Error("gateway.count_tokens_forward_failed", zap.Int64("account_id", account.ID), zap.Error(err))
// 错误响应已在 ForwardCountTokens 中处理
// 上游未服务该会话,立即释放选号时注册的会话槽(客户端可能已断开,用独立 ctx)
h.gatewayService.ReleaseAccountSession(context.Background(), account, sessionHash)
return
}
}
@@ -394,7 +394,7 @@ func (h *GatewayHandler) handleResponsesFailoverExhausted(c *gin.Context, lastEr
// 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)
writeResponsesFailedSSE(c, code, "", message)
}
return
}
@@ -0,0 +1,245 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// 通配条目在 /v1/models 中展开为候选来源中所有匹配项,保持来源顺序。
func TestGatewayModels_ModelAllowlistWildcardExpandsAgainstSource(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(31)
h := newGatewayModelsHandlerForTest(
&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
groupID: {
{
ID: 1,
Platform: service.PlatformOpenAI,
Credentials: map[string]any{
"model_mapping": map[string]any{
"gpt-5.4": "gpt-5.4",
"gpt-5.5-codex": "gpt-5.5-codex",
"gpt-5.5-mini": "gpt-5.5-mini",
"other-foo": "other-foo",
},
},
},
},
},
},
)
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.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5-*", "gpt-5.4"},
},
},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gpt-5.5-codex", "gpt-5.5-mini", "gpt-5.4"}, modelIDsForTest(got.Data))
}
// geminiAllowlistAccountRepoStub 在 gatewayModelsAccountRepoStub 之上补充
// Gemini 兼容层用到的按平台过滤查询(分组内无任何账号,触发 fallback 列表)。
type geminiAllowlistAccountRepoStub struct {
gatewayModelsAccountRepoStub
}
func (s *geminiAllowlistAccountRepoStub) ListSchedulableByGroupIDAndPlatforms(ctx context.Context, groupID int64, platforms []string) ([]service.Account, error) {
allowed := make(map[string]struct{}, len(platforms))
for _, platform := range platforms {
allowed[platform] = struct{}{}
}
accounts := s.byGroup[groupID]
filtered := make([]service.Account, 0, len(accounts))
for _, account := range accounts {
if _, ok := allowed[account.Platform]; ok {
filtered = append(filtered, account)
}
}
return filtered, nil
}
// Gemini 原生 /v1beta/models:白名单开启时过滤 fallback 列表的 models[].name。
func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
// 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。
41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}},
},
}}
h := &GatewayHandler{
geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil),
}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
geminiGroupID := int64(41)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
GroupID: &geminiGroupID,
Group: &service.Group{
ID: geminiGroupID,
Platform: service.PlatformGemini,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-2.5-pro", "gemini-3-*"},
},
},
})
h.GeminiV1BetaListModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got struct {
Models []struct {
Name string `json:"name"`
} `json:"models"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
names := make([]string, 0, len(got.Models))
for _, model := range got.Models {
names = append(names, model.Name)
}
// models/ 前缀的候选形式也应命中条目。
require.Contains(t, names, "models/gemini-2.5-pro")
require.Contains(t, names, "models/gemini-3-pro-preview")
require.NotContains(t, names, "models/gemini-2.5-flash")
require.NotContains(t, names, "models/gemini-2.0-flash")
}
// Antigravity /antigravity/models:白名单开启时按条目过滤静态列表。
func TestAntigravityModels_FiltersByAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
h := &GatewayHandler{}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/models", nil)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
ID: 51,
Platform: service.PlatformAntigravity,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-2.5-flash"},
},
},
})
h.AntigravityModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got struct {
Object string `json:"object"`
Data []struct {
ID string `json:"id"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, "list", got.Object)
// -thinking 后缀的宽容规则:gemini-2.5-flash-thinking 也命中 gemini-2.5-flash 条目。
require.Len(t, got.Data, 2)
require.Equal(t, "gemini-2.5-flash", got.Data[0].ID)
require.Equal(t, "gemini-2.5-flash-thinking", got.Data[1].ID)
}
func TestFilterUpstreamGeminiModelsBody(t *testing.T) {
allowlist := service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}}
t.Run("full hit returns original body with dropped=false", func(t *testing.T) {
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-pro"}]}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
require.True(t, ok)
require.False(t, dropped, "full hit must keep the original response so headers pass through")
require.Equal(t, string(body), string(filtered))
})
t.Run("partial hit filters models and preserves envelope fields", func(t *testing.T) {
body := []byte(`{"nextPageToken":"tok","models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"other":"kept"}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
require.True(t, ok)
require.True(t, dropped)
require.JSONEq(t, `{"nextPageToken":"tok","other":"kept","models":[{"name":"models/gemini-2.5-pro"}]}`, string(filtered))
})
t.Run("models prefix stripped candidate form matches entry", func(t *testing.T) {
body := []byte(`{"models":[{"name":"gemini-2.5-pro"}]}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
require.True(t, ok)
require.False(t, dropped)
require.Equal(t, string(body), string(filtered))
})
t.Run("missing models field passes through", func(t *testing.T) {
body := []byte(`{"error":"odd upstream"}`)
_, dropped, ok := filterUpstreamGeminiModelsBody(body, allowlist)
require.True(t, ok)
require.False(t, dropped)
})
t.Run("invalid JSON signals passthrough", func(t *testing.T) {
_, dropped, ok := filterUpstreamGeminiModelsBody([]byte(`not-json`), allowlist)
require.False(t, ok)
require.False(t, dropped)
})
}
func TestFilterBatchImageModelsByAllowlist(t *testing.T) {
models := []service.BatchImagePublicModel{
{ID: "gemini-2.5-flash-image", Object: "image.batch.model", Provider: "gemini_api"},
{ID: "gemini-3-pro-image", Object: "image.batch.model", Provider: "vertex"},
{ID: "gpt-image-1", Object: "image.batch.model", Provider: "openai"},
}
t.Run("disabled allowlist keeps everything", func(t *testing.T) {
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{Enabled: false})
require.Equal(t, models, got)
})
t.Run("exact and wildcard entries filter by model id", func(t *testing.T) {
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-3-*", "gpt-image-1"},
})
require.Equal(t, []string{"gemini-3-pro-image", "gpt-image-1"}, []string{got[0].ID, got[1].ID})
require.Len(t, got, 2)
})
t.Run("no match yields empty list", func(t *testing.T) {
got := filterBatchImageModelsByAllowlist(models, service.GroupModelAllowlist{
Enabled: true,
Models: []string{"claude-*"},
})
require.Empty(t, got)
})
}
+120 -14
View File
@@ -8,6 +8,7 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
@@ -72,6 +73,13 @@ func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID
return s.ListSchedulableByGroupID(ctx, groupID)
}
func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]service.Account, error) {
if groupID == nil {
return nil, nil
}
return s.ListSchedulableByGroupID(ctx, *groupID)
}
func newGatewayModelsHandlerForTest(repo service.AccountRepository) *GatewayHandler {
return &GatewayHandler{
gatewayService: service.NewGatewayService(
@@ -174,7 +182,7 @@ func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
}
}
// Scenario: Composite manifests aggregate only administrator-configured models.
// Composite manifests include defaults from unmapped accounts and explicit mappings.
func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 120
@@ -218,7 +226,105 @@ func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T
require.Equal(t, http.StatusOK, rec.Code)
var got codexModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gpt-5.5", "grok-4.6"}, codexModelSlugsForTest(got.Models))
want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil)
require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models))
}
func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 28
const sparkModel = "gpt-5.3-codex-spark"
const alias = "team-coder"
parentID := int64(1)
accounts := []service.Account{
{ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth},
{
ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
ParentAccountID: &parentID, QuotaDimension: "spark",
Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}},
},
{
ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}},
},
}
tests := []struct {
name string
accounts []service.Account
config service.GroupModelAllowlist
want []string
}{
{
name: "unmapped parent and Spark shadow retain defaults and aliases",
accounts: accounts,
want: append(openai.DefaultModelIDs(), alias),
},
{
name: "unmapped API key account also contributes defaults",
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...),
want: append(openai.DefaultModelIDs(), alias),
},
{
name: "unmapped accounts alone retain default response shape",
accounts: accounts[:1],
want: openai.DefaultModelIDs(),
},
{
name: "custom list can select defaults and aliases",
accounts: accounts,
config: service.GroupModelAllowlist{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}},
want: []string{alias, "gpt-5.6-sol", sparkModel},
},
{
name: "unavailable custom selection remains empty",
accounts: accounts,
config: service.GroupModelAllowlist{Enabled: true, Models: []string{"unknown-model"}},
want: []string{},
},
{
name: "mapped accounts alone do not gain defaults",
accounts: accounts[1:],
want: []string{sparkModel, alias},
},
{
name: "unmapped accounts from another platform do not add defaults",
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...),
want: []string{sparkModel, alias},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{groupID: tt.accounts},
})
for range 2 {
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.PlatformOpenAI, ModelAllowlist: tt.config},
})
h.Models(c)
require.Equal(t, http.StatusOK, rec.Code)
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, "list", got.Object)
require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data))
for _, model := range got.Data {
require.Equal(t, "model", model.Object, model.ID)
require.Positive(t, model.Created, model.ID)
require.Equal(t, "openai", model.OwnedBy, model.ID)
require.Empty(t, model.CreatedAt, model.ID)
}
if tt.config.Enabled {
require.Equal(t, tt.want, modelIDsForTest(got.Data))
}
}
})
}
require.Empty(t, accounts[0].GetModelMapping())
require.True(t, accounts[0].IsModelSupported("gpt-future-model"))
require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol"))
}
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
@@ -260,7 +366,7 @@ func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
require.Equal(t, etag, second.Header().Get("ETag"))
}
// Scenario: group models_list_config limits the generated Codex manifest.
// Scenario: group model_allowlist limits the generated Codex manifest.
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
gin.SetMode(gin.TestMode)
const groupID int64 = 121
@@ -292,7 +398,7 @@ func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.
Group: &service.Group{
ID: groupID,
Platform: service.PlatformComposite,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"grok-4.6"},
},
@@ -565,7 +671,7 @@ func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T)
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: false,
Models: []string{"gpt-5.5"},
},
@@ -612,7 +718,7 @@ func TestGatewayModels_CustomModelsListFiltersAndOrdersMappedModels(t *testing.T
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5", "missing-model", "gpt-5.4"},
},
@@ -697,7 +803,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
Group: &service.Group{
ID: groupID,
Platform: service.PlatformComposite,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"},
},
@@ -911,7 +1017,7 @@ func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMappin
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"claude-sonnet-4-6"},
},
@@ -962,7 +1068,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeep
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"},
},
@@ -1013,7 +1119,7 @@ func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: false,
Models: []string{"claude-fable-5", "deepseek-v4-pro"},
},
@@ -1054,7 +1160,7 @@ func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappin
Group: &service.Group{
ID: groupID,
Platform: service.PlatformAnthropic,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"},
},
@@ -1099,7 +1205,7 @@ func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5"},
},
@@ -1136,7 +1242,7 @@ func TestGatewayModels_CustomModelsListFiltersDefaultFallbackModels(t *testing.T
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5", "legacy-gpt-2024", "gpt-5.4"},
},
@@ -1173,7 +1279,7 @@ func TestGatewayModels_OpenAICustomModelsListKeepsOpenAIResponseShapeForDefaultF
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gpt-5.5", "gpt-5.4"},
},
@@ -46,8 +46,33 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
// 分组级模型白名单开启时过滤 models[].name(名字形如 models/xxx)。
filterGeminiModels := func(models []gemini.Model) []gemini.Model {
if apiKey.Group == nil || !apiKey.Group.ModelAllowlistEnabled() {
return models
}
filtered := make([]gemini.Model, 0, len(models))
for _, model := range models {
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
filtered = append(filtered, model)
}
}
return filtered
}
// 强制 antigravity 模式:返回 antigravity 支持的模型列表
if forcePlatform == service.PlatformAntigravity {
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
agModels := antigravity.DefaultGeminiModels()
filtered := make([]antigravity.GeminiModel, 0, len(agModels))
for _, model := range agModels {
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
filtered = append(filtered, model)
}
}
c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered})
return
}
c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList())
return
}
@@ -58,7 +83,7 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
if hasAntigravity {
// antigravity 账户使用静态模型列表
c.JSON(http.StatusOK, gemini.FallbackModelsList())
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
return
}
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
@@ -72,12 +97,66 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
if shouldFallbackGeminiModels(res) {
c.JSON(http.StatusOK, gemini.FallbackModelsList())
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
return
}
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped {
// 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应,
// 统一经 writeUpstreamResponse 写出(保留全部上游响应头)。
res.Body = filtered
}
}
writeUpstreamResponse(c, res)
}
// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的
// models[].name,其余信封字段(如 nextPageToken)原样保留。
// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时
// 为 false,调用方应保持原始响应以完整透传上游头);ok=false 表示解析失败,
// 调用方同样应透传原始响应。
func filterUpstreamGeminiModelsBody(body []byte, allowlist service.GroupModelAllowlist) (filtered []byte, dropped bool, ok bool) {
var envelope map[string]json.RawMessage
if err := json.Unmarshal(body, &envelope); err != nil {
return nil, false, false
}
rawModels, hasModels := envelope["models"]
if !hasModels {
return body, false, true
}
type geminiModelName struct {
Name string `json:"name"`
}
var models []json.RawMessage
if err := json.Unmarshal(rawModels, &models); err != nil {
return nil, false, false
}
kept := make([]json.RawMessage, 0, len(models))
for _, raw := range models {
var model geminiModelName
if err := json.Unmarshal(raw, &model); err != nil {
return nil, false, false
}
if allowlist.Allows(model.Name) {
kept = append(kept, raw)
}
}
if len(kept) == len(models) {
// 全部命中时直接透传原始响应体。
return body, false, true
}
mergedModels, err := json.Marshal(kept)
if err != nil {
return nil, false, false
}
envelope["models"] = mergedModels
merged, err := json.Marshal(envelope)
if err != nil {
return nil, false, false
}
return merged, true, true
}
// GeminiV1BetaGetModel proxies:
// GET /v1beta/models/{model}
func (h *GatewayHandler) GeminiV1BetaGetModel(c *gin.Context) {
@@ -569,33 +648,25 @@ 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,
QuotaPlatform: quotaPlatform,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
LongContextThreshold: longContextThreshold,
LongContextMultiplier: longContextMultiplier,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
QuotaPlatform: quotaPlatform,
APIKey: apiKey,
User: apiKey.User,
Account: account,
Subscription: subscription,
PricingAt: pricingAt,
InboundEndpoint: inboundEndpoint,
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
zap.String("component", "handler.gemini_v1beta.models"),
@@ -3,13 +3,58 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/antigravity"
"github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestGeminiV1BetaListModels_AllowlistFiltersNativeResponse(t *testing.T) {
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"},{"name":"models/gemini-2.5-flash"}],"nextPageToken":"next"}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-2.5-pro"}})
require.True(t, ok)
require.True(t, dropped)
require.JSONEq(t, `{"models":[{"name":"models/gemini-2.5-pro"}],"nextPageToken":"next"}`, string(filtered))
}
func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/antigravity/v1beta/models", nil)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{
Group: &service.Group{
Platform: service.PlatformGemini,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-custom"},
},
},
})
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
(&GatewayHandler{}).GeminiV1BetaListModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got antigravity.GeminiModelsListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Empty(t, got.Models)
}
func TestGeminiModelAllowlist_DisabledPreservesNativeResponse(t *testing.T) {
body := []byte(`{"models":[{"name":"models/gemini-2.5-pro"}]}`)
filtered, dropped, ok := filterUpstreamGeminiModelsBody(body, service.GroupModelAllowlist{Enabled: false, Models: []string{"other"}})
require.True(t, ok)
require.False(t, dropped)
require.Equal(t, body, filtered)
}
// TestGeminiV1BetaHandler_PlatformRoutingInvariant 文档化并验证 Handler 层的平台路由逻辑不变量
// 该测试确保 gemini 和 antigravity 平台的路由逻辑符合预期
func TestGeminiV1BetaHandler_PlatformRoutingInvariant(t *testing.T) {
@@ -42,6 +42,23 @@ func classifySelectionFailureError(err error, fallback noAccountErrorClassificat
if err == nil {
return fallback
}
// A 404 model_not_found fallback is authoritative and must not be downgraded
// to a rate-limit verdict. classifyNoAccountError only reaches it through
// DiagnoseModelAvailabilityForPlatform, a dedicated database query over
// persistent eligibility (active + schedulable + model_mapping) that already
// established no account in the group can serve this model at all. A transient
// per-model cooldown on one of the remaining candidates does not make "all
// available accounts are rate-limited" true.
//
// Reporting 429 here is actively harmful: retrying can never succeed, and
// clients that treat 429 as a rate limit retry hard and swallow the body
// (Codex surfaces only "exceeded retry limit, last status: 429"), losing the
// one message that names the real problem. It also flips the ops attribution
// from a local model-configuration issue to routing capacity, because call
// sites gate markOpsRoutingCapacityLimitedIfNoAvailable on ModelNotFound.
if fallback.ModelNotFound {
return fallback
}
match := selectionModelRateLimitedPattern.FindStringSubmatch(strings.ToLower(err.Error()))
if len(match) != 2 {
return fallback
@@ -234,3 +234,72 @@ func TestClassifyNoAccountError_FromGin_NilContextStillSafe(t *testing.T) {
require.False(t, service.HasOpsClientBusinessLimited(nil))
require.Empty(t, service.OpsClientBusinessLimitedReason(nil))
}
// 权威的 404 model_not_found 不能被"账号被限流"的 429 盖掉。
//
// 选号失败的错误串同时携带多种过滤原因,例如
// "pool=9, filtered: model_not_supported=8 model_rate_limited=1":8 个账号根本不支持该模型,
// 剩下 1 个恰好处于模型级冷却。此时 classifyNoAccountError 已通过持久化判据确认整个分组
// 没有账号能服务该模型(ModelNotFound=true),改判成 429 "All available accounts are
// currently rate-limited" 是错误诊断——重试永远不会成功,而把 429 当限流的客户端会反复
// 重试并吞掉 body(Codex 只显示 "exceeded retry limit"),恰好丢掉唯一说明真实原因的信息。
func TestClassifySelectionFailureError_ModelNotFoundIsNotOverriddenByRateLimited(t *testing.T) {
modelNotFound := noAccountErrorClassification{
Status: http.StatusNotFound,
ErrType: "model_not_found",
Message: `Model "gpt-5.3-codex" is not supported by any configured account in this group`,
ModelNotFound: true,
}
got := classifySelectionFailureError(
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
modelNotFound,
)
require.Equal(t, modelNotFound, got,
"分组里没有任何账号能服务该模型时,模型级冷却不该把 404 改判成 429")
}
// 真实调用点的顺序:先 classifyNoAccountErrorFromGin,再 classifySelectionFailureError。
// 覆盖这条链路是为了同时锁住 ops 归因——调用点用 ModelNotFound 决定是否标记
// routing capacity limited,一旦 404 被改判成 429,同一个请求会既被标成
// local model configuration 又被标成容量问题,自相矛盾。
func TestClassifySelectionFailureError_CallSiteChainKeepsModelNotFoundAttribution(t *testing.T) {
c := newTestGinContextWithRequest()
fd := &fakeDiagnoser{resp: service.ModelAvailabilityDiagnosis{HasAccountsInPool: true, HasModelSupport: false}}
apiKey := &service.APIKey{GroupID: ptrInt64(43)}
cls := classifyNoAccountErrorFromGin(c, fd, apiKey, "gpt-5.3-codex", "gpt-5.3-codex", service.PlatformOpenAI)
cls = classifySelectionFailureError(
fmt.Errorf("no available OpenAI accounts supporting model: gpt-5.3-codex "+
"(pool=9, filtered: model_not_supported=8 model_rate_limited=1)"),
cls,
)
require.Equal(t, http.StatusNotFound, cls.Status)
require.Equal(t, "model_not_found", cls.ErrType)
require.True(t, cls.ModelNotFound)
require.Contains(t, cls.Message, "gpt-5.3-codex")
require.True(t, service.HasOpsClientBusinessLimited(c))
require.Equal(t, service.OpsClientBusinessLimitedReasonLocalModelConfiguration, service.OpsClientBusinessLimitedReason(c))
}
// 池子里确实存在能服务该模型、只是全部在冷却的账号时,429 改判仍需保留:
// 这种情况 fallback 是 503(HasModelSupport=true),重试是有意义的。
func TestClassifySelectionFailureError_StillUpgradesNonModelNotFoundFallback(t *testing.T) {
fallback := noAccountErrorClassification{
Status: http.StatusServiceUnavailable,
ErrType: "api_error",
Message: "Service temporarily unavailable",
}
got := classifySelectionFailureError(
fmt.Errorf("no available accounts supporting model: gpt-5.6-sol (total=3 eligible=0 model_rate_limited=3)"),
fallback,
)
require.Equal(t, http.StatusTooManyRequests, got.Status)
require.Equal(t, "rate_limit_error", got.ErrType)
require.False(t, got.ModelNotFound)
}
@@ -0,0 +1,168 @@
package handler
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const automationBootstrapPrompt = "Review the project and report any important changes."
func codexAutomationBootstrap(automationID, lastRun, prompt string) string {
return "Automation: Scheduled project review\n" +
"Automation ID: " + automationID + "\n" +
"Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md\n" +
"Last run: " + lastRun + "\n\n" + prompt
}
func codexAutomationBootstrapBody(t *testing.T, output, callID string) []byte {
t.Helper()
return []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` +
mustJSON(t, output) + callID + `}]}`)
}
func TestNormalizeCodexAutomationBootstrapSupportedLastRunValues(t *testing.T) {
tests := []struct {
name string
lastRun string
crlf bool
}{
{name: "never", lastRun: "never"},
{name: "timestamp", lastRun: "2026-09-01T02:06:34.536Z (1788228394536)"},
{name: "crlf", lastRun: "never", crlf: true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
output := codexAutomationBootstrap("wiki-maintenance", tt.lastRun, automationBootstrapPrompt)
if tt.crlf {
output = strings.ReplaceAll(output, "\n", "\r\n")
}
got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
require.True(t, changed)
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
})
}
}
func TestNormalizeCodexAutomationBootstrapHeartbeat(t *testing.T) {
output := `<heartbeat><automation_id>wiki</automation_id></heartbeat>`
got, changed := normalizeCodexAutomationBootstrap(codexAutomationBootstrapBody(t, output, ""))
require.True(t, changed)
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
again, changedAgain := normalizeCodexAutomationBootstrap(got)
require.False(t, changedAgain)
require.Equal(t, got, again)
}
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeShapes(t *testing.T) {
validOutput := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
tests := []struct {
name string
body []byte
}{
{
name: "ordinary missing call id",
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"other","output":` + mustJSON(t, validOutput) + `}]}`),
},
{
name: "tui namespace",
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_tui","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
},
{
name: "valid call id",
body: codexAutomationBootstrapBody(t, validOutput, `,"call_id":"call-1"`),
},
{
name: "previous response",
body: []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `}]}`),
},
{
name: "real call context",
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"function_call","call_id":"call-1"}]}`),
},
{
name: "mismatched memory id",
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "/wiki/memory.md", "/other/memory.md", 1), ""),
},
{
name: "unsafe automation id",
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("../wiki", "never", automationBootstrapPrompt), ""),
},
{
name: "invalid timestamp",
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "yesterday", automationBootstrapPrompt), ""),
},
{
name: "mismatched timestamp epoch",
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "2026-09-01T02:06:34.536Z (1)", automationBootstrapPrompt), ""),
},
{
name: "missing separator",
body: codexAutomationBootstrapBody(t, strings.Replace(validOutput, "\n\n"+automationBootstrapPrompt, "\n"+automationBootstrapPrompt, 1), ""),
},
{
name: "empty prompt",
body: codexAutomationBootstrapBody(t, codexAutomationBootstrap("wiki", "never", " \n"), ""),
},
{
name: "mixed missing call id output",
body: []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, validOutput) + `},{"type":"computer_call_output","output":"done"}]}`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, changed := normalizeCodexAutomationBootstrap(tt.body)
require.False(t, changed)
require.Equal(t, tt.body, got)
})
}
}
func TestNormalizeCodexAutomationBootstrapRejectsUnsafeHeartbeatShapes(t *testing.T) {
tests := []struct {
name string
output string
}{
{name: "arbitrary tool output", output: `<result><automation_id>wiki</automation_id></result>`},
{name: "root attribute", output: `<heartbeat status="ok"><automation_id>wiki</automation_id></heartbeat>`},
{name: "namespaced root", output: `<heartbeat xmlns="urn:codex"><automation_id>wiki</automation_id></heartbeat>`},
{name: "extra child", output: `<heartbeat><automation_id>wiki</automation_id><status>ok</status></heartbeat>`},
{name: "nested id content", output: `<heartbeat><automation_id><value>wiki</value></automation_id></heartbeat>`},
{name: "padded id", output: `<heartbeat><automation_id> wiki </automation_id></heartbeat>`},
{name: "unsafe id", output: `<heartbeat><automation_id>../wiki</automation_id></heartbeat>`},
{name: "comment", output: `<heartbeat><!-- ok --><automation_id>wiki</automation_id></heartbeat>`},
{name: "trailing content", output: `<heartbeat><automation_id>wiki</automation_id></heartbeat>extra`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := codexAutomationBootstrapBody(t, tt.output, "")
got, changed := normalizeCodexAutomationBootstrap(body)
require.False(t, changed)
require.Equal(t, body, got)
})
}
}
func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
output := codexAutomationBootstrap("wiki", "never", automationBootstrapPrompt)
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_app","name":"automation_update","output":` + mustJSON(t, output) + `},{"type":"message","role":"user","content":"after"}]}`)
got, changed := normalizeCodexAutomationBootstrap(body)
require.True(t, changed)
require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
require.Equal(t, output, gjson.GetBytes(got, "input.1.content.0.text").String())
require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
again, changedAgain := normalizeCodexAutomationBootstrap(got)
require.False(t, changedAgain)
require.Equal(t, got, again)
}
@@ -81,7 +81,10 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
return
}
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
return
} else if changed {
body = cappedBody
}
reqStream, ok := parseOpenAICompatibleStream(body)
@@ -113,6 +116,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
// 解析渠道级模型映射
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
if h.errorPassthroughService != nil {
service.BindErrorPassthroughService(c, h.errorPassthroughService)
@@ -167,7 +171,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
apiKey.GroupID,
"",
sessionHash,
reqModel,
forwardModel,
failedAccountIDs,
service.OpenAIUpstreamTransportAny,
service.OpenAIEndpointCapabilityChatCompletions,
@@ -1,6 +1,7 @@
package handler
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
@@ -15,7 +16,8 @@ import (
// Codex CLI and the Codex desktop app refresh their model picker from
// GET {base_url}/models?client_version=... (custom provider mode) or
// GET /backend-api/codex/models (chatgpt_base_url mode). Both routes land
// here. Groups with explicit account model mappings are generated locally;
// here. Pinned discovery takes precedence over local account model mappings;
// when disabled, groups with explicit mappings are generated locally;
// otherwise ChatGPT manifests are proxied verbatim and custom API key manifests
// receive provider-compatibility normalization plus short-lived caching.
func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
@@ -33,21 +35,60 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
}
ifNoneMatch := c.GetHeader("If-None-Match")
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
ifNoneMatch,
)
if err != nil {
if c.Request.Context().Err() != nil {
// 固定账号分支:开启后只用选定账号拉取 manifest,不经过调度器;
// 全部不可用/全部失败时按 FallbackToScheduler 决定回退调度器或返回错误。
if apiKey.Group.Platform == service.PlatformOpenAI &&
apiKey.Group.CodexModelsManifestConfig.Enabled {
pinnedManifest, pinnedAccount, pinnedErr := h.gatewayService.FetchPinnedCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
c.Query("client_version"),
)
if pinnedErr != nil {
if c.Request.Context().Err() != nil {
return
}
if !apiKey.Group.CodexModelsManifestConfig.FallbackToScheduler {
if errors.Is(pinnedErr, service.ErrNoPinnedCodexModelsAccounts) {
h.errorResponse(c, http.StatusServiceUnavailable, "upstream_error", "No available pinned OpenAI accounts")
return
}
h.errorResponse(c, infraerrors.Code(pinnedErr), "upstream_error", infraerrors.Message(pinnedErr))
return
}
// 回退开启:跌入下方调度器循环。
} else {
// 让 ops 错误日志携带实际拉取成功的首个固定账号。
setOpsSelectedAccount(c, pinnedAccount.ID, pinnedAccount.Platform)
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, pinnedManifest, ifNoneMatch); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if c.Request.Context().Err() != nil {
return
}
writeOpenAIModelsResponse(c, pinnedManifest)
return
}
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if configured {
writeCodexModelsManifestResponse(c, configuredManifest)
return
if !apiKey.Group.CodexModelsManifestConfig.Enabled {
configuredManifest, configured, err := h.gatewayService.BuildGroupConfiguredCodexModelsManifest(
c.Request.Context(),
apiKey.Group,
ifNoneMatch,
)
if err != nil {
if c.Request.Context().Err() != nil {
return
}
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
}
if configured {
writeOpenAIModelsResponse(c, configuredManifest)
return
}
}
maxAccountSwitches := h.maxAccountSwitches
@@ -94,6 +135,10 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to complete Codex models manifest")
return
}
if err := service.ApplyPinnedCodexModelsMapping(manifest, account, apiKey.Group); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to apply model mappings")
return
}
if err := h.gatewayService.MergeGroupConfiguredCodexModels(c.Request.Context(), apiKey.Group, manifest, ifNoneMatch); err != nil {
h.errorResponse(c, http.StatusInternalServerError, "api_error", "Failed to build Codex models manifest")
return
@@ -102,19 +147,7 @@ func (h *OpenAIGatewayHandler) CodexModels(c *gin.Context) {
return
}
writeCodexModelsManifestResponse(c, manifest)
writeOpenAIModelsResponse(c, manifest)
return
}
}
func writeCodexModelsManifestResponse(c *gin.Context, manifest *service.CodexModelsManifest) {
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
if manifest.NotModified {
c.Status(http.StatusNotModified)
c.Writer.WriteHeaderNow()
return
}
c.Data(http.StatusOK, "application/json", manifest.Body)
}
@@ -9,9 +9,11 @@ import (
"net"
"net/http"
"net/http/httptest"
"sort"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
@@ -53,6 +55,10 @@ func (r codexModelsFailoverAccountRepo) ListByGroup(_ context.Context, _ int64)
return append([]service.Account(nil), r.accounts...), nil
}
func (r codexModelsFailoverAccountRepo) ListModelAvailabilityCandidates(_ context.Context, _ *int64, _ []string, _ bool) ([]service.Account, error) {
return append([]service.Account(nil), r.accounts...), nil
}
type codexModelsFailoverHTTPUpstream struct {
service.HTTPUpstream
mu sync.Mutex
@@ -157,7 +163,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
group := &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"codex-auto-review", "gpt-5.6"},
},
@@ -175,7 +181,7 @@ func TestCodexModelsAppliesLocalFiltersBeforeClientETag(t *testing.T) {
t.Fatal("first response did not include an ETag")
}
group.ModelsListConfig.Enabled = false
group.ModelAllowlist.Enabled = false
second := performCodexModelsRequestForGroup(t, handler, group, oldETag)
if second.Code != http.StatusOK {
t.Fatalf("second status: got %d, want %d; body=%s", second.Code, http.StatusOK, second.Body.String())
@@ -226,7 +232,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
groupA := &service.Group{
ID: 91,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"model-a"},
},
@@ -234,7 +240,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
groupB := &service.Group{
ID: 92,
Platform: service.PlatformOpenAI,
ModelsListConfig: service.GroupModelsListConfig{
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"model-b"},
},
@@ -286,8 +292,7 @@ func TestCodexModelsAPIKeyCacheDoesNotLeakGroupFilters(t *testing.T) {
require.True(t, sawGroupB)
}
// Scenario: OpenAI 分组内混用 OAuth 和第三方 API Key 时,管理员模型配置优先。
func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
func TestCodexModelsSupplementsConfiguredModelsWithUnmappedAccountDefaults(t *testing.T) {
gin.SetMode(gin.TestMode)
groupID := int64(44)
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
@@ -348,12 +353,59 @@ func TestCodexModelsUsesConfiguredModelsBeforeUpstreamDiscovery(t *testing.T) {
if err := json.Unmarshal(recorder.Body.Bytes(), &envelope); err != nil {
t.Fatalf("decode body: %v; body=%s", err, recorder.Body.String())
}
if len(envelope.Models) != 1 || envelope.Models[0]["slug"] != "glm-5.3" {
t.Fatalf("models: got %v, want only glm-5.3", envelope.Models)
require.Contains(t, codexHandlerManifestSlugs(t, recorder), "gpt-5.6-sol")
require.Contains(t, codexHandlerManifestSlugs(t, recorder), "glm-5.3")
for _, model := range envelope.Models {
require.Contains(t, model, "supported_reasoning_levels")
}
if _, ok := envelope.Models[0]["supported_reasoning_levels"]; !ok {
t.Fatalf("configured model is missing the Codex descriptor contract: %v", envelope.Models[0])
}
func TestCodexModelsUnmappedParentAndSparkShadowHonorCustomListAndETag(t *testing.T) {
gin.SetMode(gin.TestMode)
const sparkModel = "gpt-5.3-codex-spark"
parentID := int64(1)
repo := &codexModelsFailoverAccountRepo{accounts: []service.Account{
{
ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Status: service.StatusActive, Schedulable: true,
},
{
ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
Status: service.StatusActive, Schedulable: true,
ParentAccountID: &parentID, QuotaDimension: "spark",
Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}},
},
}}
upstream := &codexModelsFailoverHTTPUpstream{firstStatus: http.StatusNotFound}
gatewayService := service.NewOpenAIGatewayService(
repo,
nil, nil, nil, nil, nil, nil, &config.Config{RunMode: config.RunModeSimple}, nil, nil, nil, nil, nil,
upstream,
nil, nil, nil, nil, nil, nil, nil, nil,
)
handler := &OpenAIGatewayHandler{gatewayService: gatewayService}
group := &service.Group{ID: 45, Platform: service.PlatformOpenAI}
first := performCodexModelsRequestForGroup(t, handler, group, "")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
slugs := codexHandlerManifestSlugs(t, first)
require.Contains(t, slugs, "gpt-5.6-sol")
require.Contains(t, slugs, sparkModel)
require.NotContains(t, slugs, "gpt-image-2")
require.NotContains(t, slugs, "codex-auto-review")
firstETag := first.Header().Get("ETag")
require.NotEmpty(t, firstETag)
group.ModelAllowlist = service.GroupModelAllowlist{
Enabled: true, Models: []string{"gpt-5.6-sol", sparkModel, "unknown-model"},
}
second := performCodexModelsRequestForGroup(t, handler, group, firstETag)
require.Equal(t, http.StatusOK, second.Code, second.Body.String())
require.ElementsMatch(t, []string{"gpt-5.6-sol", sparkModel}, codexHandlerManifestSlugs(t, second))
require.NotEqual(t, firstETag, second.Header().Get("ETag"))
third := performCodexModelsRequestForGroup(t, handler, group, second.Header().Get("ETag"))
require.Equal(t, http.StatusNotModified, third.Code)
require.Empty(t, third.Body.Bytes())
require.Empty(t, upstream.calls())
}
func TestCompositeCodexModelsReusesExistingManifestSelection(t *testing.T) {
@@ -620,8 +672,8 @@ func requireCompleteCodexModelsHandlerResponse(t *testing.T, recorder *httptest.
t.Fatalf("truncation_policy must be populated: %v", model["truncation_policy"])
}
modalities, ok := model["input_modalities"].([]any)
if !ok || len(modalities) != 1 || modalities[0] != "text" {
t.Fatalf("custom OpenAI-compatible endpoint modalities: got %v, want [text]", model["input_modalities"])
if !ok || len(modalities) != 2 || modalities[0] != "text" || modalities[1] != "image" {
t.Fatalf("known GPT model modalities: got %v, want [text image]", model["input_modalities"])
}
}
@@ -636,3 +688,316 @@ func equalInt64Slices(got, want []int64) bool {
}
return true
}
// --- 固定账号 manifest 模式 ---
// codexModelsPinnedHTTPUpstream 按账号返回不同 manifest/状态的测试上游。
type codexModelsPinnedHTTPUpstream struct {
service.HTTPUpstream
mu sync.Mutex
calls []int64
bodies map[int64]string
statuses map[int64]int
}
func (u *codexModelsPinnedHTTPUpstream) Do(_ *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
u.mu.Lock()
u.calls = append(u.calls, accountID)
u.mu.Unlock()
if status, ok := u.statuses[accountID]; ok {
return &http.Response{
StatusCode: status,
Status: http.StatusText(status),
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"upstream boom"}}`)),
}, nil
}
body, ok := u.bodies[accountID]
if !ok {
body = `{"models":[]}`
}
return &http.Response{
StatusCode: http.StatusOK,
Status: "200 OK",
Header: make(http.Header),
Body: io.NopCloser(strings.NewReader(body)),
}, nil
}
func (u *codexModelsPinnedHTTPUpstream) accountIDs() []int64 {
u.mu.Lock()
defer u.mu.Unlock()
ids := append([]int64(nil), u.calls...)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
return ids
}
func newPinnedCodexAccount(id int64, status string, schedulable bool, rateLimited bool) service.Account {
account := service.Account{
ID: id,
Name: fmt.Sprintf("pinned-%d", id),
Platform: service.PlatformOpenAI,
Type: service.AccountTypeAPIKey,
Status: status,
Schedulable: schedulable,
Priority: int(id),
Concurrency: 1,
Credentials: map[string]any{
"api_key": fmt.Sprintf("sk-pinned-%d", id),
"base_url": fmt.Sprintf("https://pinned-%d.example/v1", id),
},
}
if rateLimited {
reset := time.Now().Add(10 * time.Minute)
account.RateLimitResetAt = &reset
}
return account
}
func newPinnedCodexTestHandler(accounts []service.Account, upstream *codexModelsPinnedHTTPUpstream, maxSwitches int) *OpenAIGatewayHandler {
gin.SetMode(gin.TestMode)
cfg := &config.Config{RunMode: config.RunModeSimple}
gatewayService := service.NewOpenAIGatewayService(
codexModelsFailoverAccountRepo{accounts: accounts},
nil, nil, nil, nil, nil, nil, cfg, nil, nil, nil, nil, nil,
upstream,
nil, nil, nil, nil, nil, nil, nil, nil,
)
return &OpenAIGatewayHandler{gatewayService: gatewayService, maxAccountSwitches: maxSwitches}
}
func performPinnedCodexModelsRequest(t *testing.T, handler *OpenAIGatewayHandler, group *service.Group, etag string) *httptest.ResponseRecorder {
return performCodexModelsRequestForGroup(t, handler, group, etag)
}
func TestCodexModelsPinnedAccountsMergeUnionWithoutScheduler(t *testing.T) {
// 账号 1 优先级最高(调度器会先选它);固定配置只用 2、3,
// 返回并集且不打账号 1,即可证明没有经过调度器。
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, true, false),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
1: `{"models":[{"slug":"scheduler-only"}]}`,
2: `{"models":[{"slug":"model-a"}]}`,
3: `{"models":[{"slug":"model-a"},{"slug":"model-c"}]}`,
}}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 77,
Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2, 3},
},
}
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"model-a", "model-c"}, codexHandlerManifestSlugs(t, recorder))
require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "固定账号模式不得调用调度器或非固定账号")
}
func TestCodexModelsPinnedAccountsUseRateLimitedAccountAndSkipUnavailable(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(2, service.StatusActive, true, true), // 限流中:仍被使用
newPinnedCodexAccount(3, service.StatusActive, false, false), // 调度开关关闭:跳过
newPinnedCodexAccount(4, service.StatusDisabled, true, false), // 停用:跳过
newPinnedCodexAccount(5, service.StatusActive, true, false), // 正常
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
2: `{"models":[{"slug":"from-rate-limited"}]}`,
5: `{"models":[{"slug":"model-five"}]}`,
}}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 78,
Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2, 3, 4, 5, 99}, // 99 不在分组:跳过
},
}
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"from-rate-limited", "model-five"}, codexHandlerManifestSlugs(t, recorder))
require.Equal(t, []int64{2, 5}, upstream.accountIDs())
}
func TestCodexModelsPinnedAccountsPartialFailureStillSucceeds(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(2, service.StatusActive, true, false),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
upstream := &codexModelsPinnedHTTPUpstream{
bodies: map[int64]string{2: `{"models":[{"slug":"model-a"}]}`},
statuses: map[int64]int{3: http.StatusServiceUnavailable},
}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 79,
Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2, 3},
},
}
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"model-a"}, codexHandlerManifestSlugs(t, recorder))
}
func TestCodexModelsPinnedAccountsAllUnavailableReturns503ByDefault(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(2, service.StatusActive, false, false),
}
upstream := &codexModelsPinnedHTTPUpstream{}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 80,
Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2},
},
}
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusServiceUnavailable, recorder.Code, recorder.Body.String())
require.Empty(t, upstream.accountIDs())
}
func TestCodexModelsPinnedAccountsAllFailedReturnsUpstreamError(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(2, service.StatusActive, true, false),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
upstream := &codexModelsPinnedHTTPUpstream{statuses: map[int64]int{
2: http.StatusServiceUnavailable,
3: http.StatusGatewayTimeout,
}}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 81,
Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2, 3},
},
}
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusBadGateway, recorder.Code, recorder.Body.String())
require.Contains(t, recorder.Body.String(), "upstream error 504", "全部失败时返回最后一个上游错误")
}
func TestCodexModelsPinnedAccountsFallbackToScheduler(t *testing.T) {
// 固定账号 2 不可用且回退开启:跌入调度器,选中优先级最高的账号 1。
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, false, false),
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
1: `{"models":[{"slug":"from-scheduler"}]}`,
}}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 82,
Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2},
FallbackToScheduler: true,
},
}
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder))
require.Equal(t, []int64{1}, upstream.accountIDs())
}
func TestCodexModelsPinnedAccountsFallbackToSchedulerOnAllFailed(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, true, false),
}
upstream := &codexModelsPinnedHTTPUpstream{
bodies: map[int64]string{1: `{"models":[{"slug":"from-scheduler"}]}`},
statuses: map[int64]int{2: http.StatusServiceUnavailable},
}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 83,
Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2},
FallbackToScheduler: true,
},
}
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"from-scheduler"}, codexHandlerManifestSlugs(t, recorder))
}
func TestCodexModelsPinnedAccountsStillApplyCustomModelsListFilter(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(2, service.StatusActive, true, false),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
2: `{"models":[{"slug":"model-a"}]}`,
3: `{"models":[{"slug":"model-b"}]}`,
}}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 84,
Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"model-b"},
},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2, 3},
},
}
recorder := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"model-b"}, codexHandlerManifestSlugs(t, recorder), "分组自定义模型列表过滤仍生效")
}
func TestCodexModelsPinnedAccountsETagMatchReturns304(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(2, service.StatusActive, true, false),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
2: `{"models":[{"slug":"model-a"}]}`,
3: `{"models":[{"slug":"model-c"}]}`,
}}
handler := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{
ID: 85,
Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{
Enabled: true,
AccountIDs: []int64{2, 3},
},
}
first := performPinnedCodexModelsRequest(t, handler, group, "")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
etag := first.Header().Get("ETag")
require.NotEmpty(t, etag)
second := performPinnedCodexModelsRequest(t, handler, group, etag)
require.Equal(t, http.StatusNotModified, second.Code, second.Body.String())
require.Empty(t, second.Body.Bytes())
}
@@ -0,0 +1,184 @@
package handler
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
const delegationEnvelope = `<codex_delegation><source_thread_id>thread-1</source_thread_id><input>do the work</input></codex_delegation>`
func TestNormalizeCodexDelegationBootstrapSupportedTools(t *testing.T) {
tests := []struct {
name string
namespace string
tool string
callID string
}{
{name: "app create missing call id", namespace: "codex_app", tool: "create_thread"},
{name: "app send empty call id", namespace: "codex_app", tool: "send_message_to_thread", callID: `,"call_id":""`},
{name: "tui create whitespace call id", namespace: "codex_tui", tool: "create_thread", callID: `,"call_id":" "`},
{name: "tui send missing call id", namespace: "codex_tui", tool: "send_message_to_thread"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"` + tt.namespace + `","name":"` + tt.tool + `","output":"` + delegationEnvelope + `"` + tt.callID + `}]}`)
got, changed := normalizeCodexDelegationBootstrap(body)
require.True(t, changed)
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.0.content.0.text").String())
require.False(t, gjson.GetBytes(got, "input.0.call_id").Exists())
})
}
}
func TestNormalizeCodexDelegationBootstrapRejectsUnsafeShapes(t *testing.T) {
tests := []struct {
name string
item string
extra string
}{
{name: "ordinary missing call id", item: `{"type":"function_call_output","name":"other","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
{name: "valid call id", item: `{"type":"function_call_output","call_id":"call-1","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
{name: "ambiguous call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call"}`},
{name: "ambiguous built-in call context", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call"}`},
{name: "mixed missing call id output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"computer_call_output","output":"done"}`},
{name: "mixed tool search output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"tool_search_output","output":"done"}`},
{name: "mixed invalid delegation output", item: `{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"},{"type":"function_call_output","name":"create_thread","namespace":"codex_app","output":"not an envelope"}`},
{name: "non string call id", item: `{"type":"function_call_output","call_id":42,"name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
{name: "padded type", item: `{"type":" function_call_output ","name":"create_thread","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
{name: "padded name", item: `{"type":"function_call_output","name":" create_thread ","namespace":"codex_app","output":"` + delegationEnvelope + `"}`},
{name: "padded namespace", item: `{"type":"function_call_output","name":"create_thread","namespace":" codex_app ","output":"` + delegationEnvelope + `"}`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := []byte(`{"model":"gpt-5","input":[` + tt.item + `]` + tt.extra + `}`)
got, changed := normalizeCodexDelegationBootstrap(body)
require.False(t, changed)
require.Equal(t, body, got)
})
}
}
func TestNormalizeCodexDelegationBootstrapWithHistoricalContext(t *testing.T) {
body := []byte(`{"model":"gpt-5","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"function_call","call_id":"call-1"},{"type":"function_call_output","call_id":"call-1","output":"done"},{"type":"item_reference","id":"item-1"},{"type":"computer_call","call_id":"call-2"}]}`)
got, changed := normalizeCodexDelegationBootstrap(body)
require.True(t, changed)
require.Equal(t, "message", gjson.GetBytes(got, "input.0.type").String())
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
require.Equal(t, "resp-1", gjson.GetBytes(got, "previous_response_id").String())
require.Equal(t, "call-1", gjson.GetBytes(got, "input.2.call_id").String())
require.Equal(t, "item-1", gjson.GetBytes(got, "input.3.id").String())
}
func TestNormalizeCodexDelegationBootstrapPreviousResponseID(t *testing.T) {
tests := []struct {
name string
value string
changed bool
}{
{name: "absent", changed: true},
{name: "blank string", value: `,"previous_response_id":" "`, changed: true},
{name: "non-empty string", value: `,"previous_response_id":"resp-1"`, changed: true},
{name: "null", value: `,"previous_response_id":null`},
{name: "number", value: `,"previous_response_id":42`},
{name: "boolean", value: `,"previous_response_id":false`},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]` + tt.value + `}`)
got, changed := normalizeCodexDelegationBootstrap(body)
require.Equal(t, tt.changed, changed)
if !tt.changed {
require.Equal(t, body, got)
}
})
}
}
func TestNormalizeCodexDelegationBootstrapRejectsDuplicateMembers(t *testing.T) {
tests := []struct {
name string
body string
}{
{
name: "top-level previous response id",
body: `{"model":"gpt-5","previous_response_id":"","previous_response_id":"resp-1","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
},
{
name: "candidate discriminator",
body: `{"model":"gpt-5","input":[{"type":"message","type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`,
},
{
name: "candidate call id",
body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","call_id":"call-1","call_id":"","output":"` + delegationEnvelope + `"}]}`,
},
{
name: "candidate output",
body: `{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"not a delegation","output":"` + delegationEnvelope + `"}]}`,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
body := []byte(tt.body)
got, changed := normalizeCodexDelegationBootstrap(body)
require.False(t, changed)
require.Equal(t, body, got)
})
}
}
func TestNormalizeCodexDelegationBootstrapPreservesExactNumbers(t *testing.T) {
body := []byte(`{"model":"gpt-5","metadata":{"integer":9007199254740993},"input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":"` + delegationEnvelope + `"}]}`)
got, changed := normalizeCodexDelegationBootstrap(body)
require.True(t, changed)
require.Equal(t, "9007199254740993", gjson.GetBytes(got, "metadata.integer").Raw)
}
func TestNormalizeCodexDelegationBootstrapRequiresCompleteEnvelope(t *testing.T) {
malformed := []string{
`<codex_delegation><source_thread_id>thread-1</source_thread_id></codex_delegation>`,
`<codex_delegation><source_thread_id></source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input> </input></codex_delegation>`,
`prefix<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>suffix`,
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input><extra>x</extra></codex_delegation>`,
`<codex_delegation><source_thread_id>thread-1</source_thread_id><input><nested>work</nested></input></codex_delegation>`,
`<x:codex_delegation xmlns:x="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
`<x:codex_delegation><source_thread_id>thread-1</source_thread_id><input>work</input></x:codex_delegation>`,
`<codex_delegation xmlns="urn:test"><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation xmlns:x="urn:test"><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation><x:source_thread_id>thread-1</x:source_thread_id><input>work</input></codex_delegation>`,
`<codex_delegation xmlns=""><source_thread_id>thread-1</source_thread_id><input>work</input></codex_delegation>`,
}
for _, envelope := range malformed {
body := []byte(`{"model":"gpt-5","input":[{"type":"function_call_output","namespace":"codex_app","name":"create_thread","output":` + mustJSON(t, envelope) + `}]}`)
got, changed := normalizeCodexDelegationBootstrap(body)
require.False(t, changed, envelope)
require.Equal(t, body, got)
}
}
func TestNormalizeCodexDelegationBootstrapPreservesOrderAndIsIdempotent(t *testing.T) {
body := []byte(`{"model":"gpt-5","input":[{"type":"message","role":"user","content":"before"},{"type":"function_call_output","namespace":"codex_tui","name":"send_message_to_thread","output":"` + delegationEnvelope + `"},{"type":"message","role":"user","content":"after"}]}`)
got, changed := normalizeCodexDelegationBootstrap(body)
require.True(t, changed)
require.Equal(t, "before", gjson.GetBytes(got, "input.0.content").String())
require.Equal(t, delegationEnvelope, gjson.GetBytes(got, "input.1.content.0.text").String())
require.Equal(t, "after", gjson.GetBytes(got, "input.2.content").String())
again, changedAgain := normalizeCodexDelegationBootstrap(got)
require.False(t, changedAgain)
require.Equal(t, got, again)
}
func mustJSON(t *testing.T, value string) string {
t.Helper()
encoded, err := json.Marshal(value)
require.NoError(t, err)
return string(encoded)
}
@@ -85,6 +85,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
}
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
subscription, _ := middleware2.GetSubscriptionFromContext(c)
service.SetOpsLatencyMs(c, service.OpsAuthLatencyMsKey, time.Since(requestStart).Milliseconds())
@@ -127,7 +128,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
apiKey.GroupID,
"",
"",
reqModel,
forwardModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
service.OpenAIEndpointCapabilityEmbeddings,
@@ -128,6 +128,44 @@ func TestOpenAICapacityFailoverExhaustionPreservesMessageAsServerError(t *testin
})
}
func TestOpenAIManagedSingleAccountModelNotFoundExhaustionPreservesStructured400(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"error":{"type":"invalid_request_error","code":"model_not_found","param":"model","message":"The requested model is unavailable on this account"}}`)
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
StatusCode: http.StatusBadRequest,
ResponseBody: body,
}, false)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.Equal(t, "invalid_request_error", gjson.Get(recorder.Body.String(), "error.type").String())
require.Equal(t, "model_not_found", gjson.Get(recorder.Body.String(), "error.code").String())
require.Equal(t, "model", gjson.Get(recorder.Body.String(), "error.param").String())
require.Equal(t, "The requested model is unavailable on this account", gjson.Get(recorder.Body.String(), "error.message").String())
}
func TestOpenAIManagedModelNotFoundExhaustionSanitizesMessage(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
body := []byte(`{"error":{"code":"model_not_found","message":"Model not found; inspect https://upstream.example/debug?access_token=super-secret-value&model=x"}}`)
(&OpenAIGatewayHandler{}).handleFailoverExhausted(c, &service.UpstreamFailoverError{
StatusCode: http.StatusBadRequest,
ResponseBody: body,
}, false)
require.Equal(t, http.StatusBadRequest, recorder.Code)
require.NotContains(t, recorder.Body.String(), "super-secret-value")
require.Contains(t, gjson.Get(recorder.Body.String(), "error.message").String(), "access_token=***")
recorded, ok := c.Get(service.OpsUpstreamErrorMessageKey)
require.True(t, ok)
require.NotContains(t, recorded, "super-secret-value")
require.Contains(t, recorded, "access_token=***")
}
func TestResponsesFailoverExhaustedAfterForwardedTerminalMarksOpsWithoutDuplicateFrame(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
@@ -1,6 +1,7 @@
package handler
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
@@ -102,6 +103,44 @@ func TestClearCyberPolicyTurnState(t *testing.T) {
require.Equal(t, "turn2", service.GetOpsCyberPolicy(c).Message)
}
func TestAdvanceOpenAIWSCyberBlockStateDefersBlockAcrossFailover(t *testing.T) {
failoverErr := &service.UpstreamFailoverError{StatusCode: http.StatusTooManyRequests}
blocked, pending := advanceOpenAIWSCyberBlockState(false, false, true, failoverErr)
require.False(t, blocked, "the replacement account must receive the current turn")
require.True(t, pending, "the cyber hit must still block later turns")
blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, failoverErr)
require.False(t, blocked, "additional failover attempts must remain eligible")
require.True(t, pending)
blocked, pending = advanceOpenAIWSCyberBlockState(blocked, pending, false, nil)
require.True(t, blocked, "the next client turn must be blocked after failover finishes")
require.False(t, pending)
}
func TestClearCyberPolicyAttemptStatePreservesRecordedGuardDuringFailover(t *testing.T) {
c := newTestGinContext()
h := &OpenAIGatewayHandler{}
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-a", UpstreamStatus: http.StatusOK})
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
require.True(t, c.GetBool(cyberPolicyRecordedKey))
clearCyberPolicyAttemptState(c, false)
require.Nil(t, service.GetOpsCyberPolicy(c))
require.True(t, c.GetBool(cyberPolicyRecordedKey), "the same logical turn must not record again after failover")
service.MarkOpsCyberPolicy(c, service.CyberPolicyMark{Message: "account-b", UpstreamStatus: http.StatusOK})
h.recordCyberPolicyIfMarked(c, nil, nil, nil, "gpt-5", true, nil, service.ChannelUsageFields{}, "")
require.Equal(t, "account-b", service.GetOpsCyberPolicy(c).Message)
require.True(t, c.GetBool(cyberPolicyRecordedKey))
clearCyberPolicyAttemptState(c, true)
require.Nil(t, service.GetOpsCyberPolicy(c))
require.False(t, c.GetBool(cyberPolicyRecordedKey), "a completed logical turn must reset the guard")
}
// TestBuildCyberSessionBlockedOpsEntry verifies the locally-rejected request is
// auditable: 403 / phase=request / type=cyber_policy_session_blocked — distinct
// from upstream cyber_policy hits, and it must NOT touch moderation/violation.
@@ -1,10 +1,13 @@
package handler
import (
"bytes"
"context"
"encoding/json"
"encoding/xml"
"errors"
"fmt"
"io"
"net/http"
"runtime/debug"
"strconv"
@@ -17,6 +20,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/ip"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/pkg/requestmodel"
"github.com/Wei-Shaw/sub2api/internal/securityaudit"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -50,6 +54,21 @@ type openAIWSTurnChannelMappingSnapshot struct {
mapping service.ChannelMappingResult
}
func advanceOpenAIWSCyberBlockState(blocked, pending, marked bool, turnErr error) (bool, bool) {
var failoverErr *service.UpstreamFailoverError
isFailover := errors.As(turnErr, &failoverErr)
if marked {
if isFailover {
return false, true
}
return true, false
}
if pending && !isFailover {
return true, false
}
return blocked, pending
}
var errOpenAIWSUnsupportedModelSwitch = errors.New("selected account does not support websocket model switch")
func newOpenAIWSUnsupportedModelSwitchError(model string) error {
@@ -137,6 +156,18 @@ func openAIWSTurnBillingModel(result *service.OpenAIForwardResult, mapping servi
return billingModel
}
// openAIChannelForwardModel returns the model used for upstream capability
// routing. The client-requested model remains separate for logs, billing, and
// responses.
func openAIChannelForwardModel(mapping service.ChannelMappingResult, requestedModel string) string {
if mapping.Mapped {
if mappedModel := strings.TrimSpace(mapping.MappedModel); mappedModel != "" {
return mappedModel
}
}
return requestedModel
}
type grokMediaEligibilityProber interface {
ProbeMediaEligibility(ctx context.Context, accountID int64) (bool, string, error)
}
@@ -432,9 +463,24 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", "Model is not supported by this OpenAI-compatible endpoint for composite groups")
return
}
if cappedBody, changed := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); changed {
if cappedBody, changed, err := applyOpenAIReasoningEffortPolicyForRequest(c, apiKey, body); err != nil {
respondOpenAIReasoningEffortPolicyError(c, err, h.errorResponse)
return
} else if changed {
body = cappedBody
}
if normalizedBody, changed := normalizeCodexAutomationBootstrap(body); changed {
body = normalizedBody
reqLog.Info("openai.codex_automation_bootstrap_normalized",
zap.String("normalization", "call_output_to_user_message"),
)
}
if normalizedBody, changed := normalizeCodexDelegationBootstrap(body); changed {
body = normalizedBody
reqLog.Info("openai.codex_delegation_bootstrap_normalized",
zap.String("normalization", "call_output_to_user_message"),
)
}
reqStream, ok := parseOpenAICompatibleStream(body)
if !ok {
@@ -515,10 +561,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
channelMapping, _ := h.gatewayService.ResolveChannelMappingAndRestrict(c.Request.Context(), apiKey.GroupID, reqModel)
forwardBody := openAIModelMappedBody(body, channelMapping.Mapped, channelMapping.MappedModel, h.gatewayService.ReplaceModelInBody)
seedOpenAIForwardImageIntentHint(c, channelMapping.Mapped, imageIntent)
forwardModel := reqModel
if channelMapping.Mapped {
forwardModel = channelMapping.MappedModel
}
forwardModel := openAIChannelForwardModel(channelMapping, reqModel)
c.Request = c.Request.WithContext(service.WithOpenAIForwardModel(
c.Request.Context(),
forwardModel,
@@ -611,7 +654,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
apiKey.GroupID,
previousResponseID,
sessionHash,
reqModel,
forwardModel,
failedAccountIDs,
service.OpenAIUpstreamTransportAny,
requiredCapability,
@@ -1581,6 +1624,373 @@ func (h *OpenAIGatewayHandler) validateFunctionCallOutputRequest(c *gin.Context,
return false
}
func normalizeCodexDelegationBootstrap(body []byte) ([]byte, bool) {
// 已有任务通过 send_message_to_thread 唤醒时会携带 previous_response_id;
// 完整历史回放还会带有已配对的调用项。delegation 仍是客户端注入的用户输入,
// 不属于这些历史调用的结果,因此允许它与可明确配对的历史上下文共存。
return normalizeCodexCallOutputBootstrap(body, isCodexDelegationCandidate, true)
}
func normalizeCodexAutomationBootstrap(body []byte) ([]byte, bool) {
return normalizeCodexCallOutputBootstrap(body, isCodexAutomationCandidate, false)
}
func normalizeCodexCallOutputBootstrap(body []byte, isCandidate func(map[string]any) bool, allowHistoricalContext bool) ([]byte, bool) {
if !hasUniqueJSONMembers(body) {
return body, false
}
decoder := json.NewDecoder(bytes.NewReader(body))
decoder.UseNumber()
var request map[string]any
if err := decoder.Decode(&request); err != nil {
return body, false
}
if previousResponseID, exists := request["previous_response_id"]; exists {
value, ok := previousResponseID.(string)
if !ok || (!allowHistoricalContext && strings.TrimSpace(value) != "") {
return body, false
}
}
input, ok := request["input"].([]any)
if !ok {
return body, false
}
// Responses built-ins follow the *_call / *_call_output naming convention,
// so classify by the wire type shape instead of maintaining an incomplete
// allowlist. Delegation may coexist with historical anchors only when their
// IDs make them unambiguous; automation retains the bootstrap-only boundary.
for _, raw := range input {
item, ok := raw.(map[string]any)
if !ok {
continue
}
typ := stringField(item, "type")
if isCandidate(item) {
callIDValue, exists := item["call_id"]
callID, isString := callIDValue.(string)
if exists && (!isString || strings.TrimSpace(callID) != "") {
return body, false
}
continue
}
if typ == "item_reference" {
if allowHistoricalContext && strings.TrimSpace(stringField(item, "id")) != "" {
continue
}
return body, false
}
if strings.HasSuffix(typ, "_call") || isResponsesCallOutputType(typ) {
if allowHistoricalContext && strings.TrimSpace(stringField(item, "call_id")) != "" {
continue
}
return body, false
}
}
changed := false
for i, raw := range input {
item, ok := raw.(map[string]any)
if !ok || !isCandidate(item) {
continue
}
output, ok := item["output"].(string)
if !ok {
continue
}
input[i] = map[string]any{
"type": "message",
"role": "user",
"content": []any{map[string]any{
"type": "input_text",
"text": output,
}},
}
changed = true
}
if !changed {
return body, false
}
normalized, err := json.Marshal(request)
if err != nil {
return body, false
}
return normalized, true
}
func hasUniqueJSONMembers(body []byte) bool {
decoder := json.NewDecoder(bytes.NewReader(body))
if !consumeUniqueJSONValue(decoder) {
return false
}
_, err := decoder.Token()
return err == io.EOF
}
func consumeUniqueJSONValue(decoder *json.Decoder) bool {
token, err := decoder.Token()
if err != nil {
return false
}
delim, ok := token.(json.Delim)
if !ok {
return true
}
switch delim {
case '{':
members := make(map[string]struct{})
for decoder.More() {
keyToken, err := decoder.Token()
if err != nil {
return false
}
key, ok := keyToken.(string)
if !ok {
return false
}
if _, duplicate := members[key]; duplicate {
return false
}
members[key] = struct{}{}
if !consumeUniqueJSONValue(decoder) {
return false
}
}
end, err := decoder.Token()
return err == nil && end == json.Delim('}')
case '[':
for decoder.More() {
if !consumeUniqueJSONValue(decoder) {
return false
}
}
end, err := decoder.Token()
return err == nil && end == json.Delim(']')
default:
return false
}
}
func isResponsesCallOutputType(typ string) bool {
return strings.HasSuffix(typ, "_call_output") || typ == "tool_search_output"
}
func isCodexDelegationCandidate(item map[string]any) bool {
if stringField(item, "type") != "function_call_output" ||
!isCodexDelegationTool(stringField(item, "namespace"), stringField(item, "name")) {
return false
}
output, ok := item["output"].(string)
return ok && validCodexDelegationEnvelope(output)
}
func isCodexAutomationCandidate(item map[string]any) bool {
if stringField(item, "type") != "function_call_output" ||
stringField(item, "namespace") != "codex_app" ||
stringField(item, "name") != "automation_update" {
return false
}
output, ok := item["output"].(string)
return ok && (validCodexAutomationBootstrap(output) || validCodexAutomationHeartbeat(output))
}
func stringField(item map[string]any, key string) string {
value, _ := item[key].(string)
return value
}
func isCodexDelegationTool(namespace, name string) bool {
return (namespace == "codex_app" || namespace == "codex_tui") &&
(name == "create_thread" || name == "send_message_to_thread")
}
func validCodexAutomationBootstrap(value string) bool {
normalized := strings.ReplaceAll(value, "\r\n", "\n")
if strings.ContainsRune(normalized, '\r') {
return false
}
lines := strings.Split(normalized, "\n")
if len(lines) < 6 {
return false
}
if _, ok := codexAutomationHeaderValue(lines[0], "Automation: "); !ok {
return false
}
automationID, ok := codexAutomationHeaderValue(lines[1], "Automation ID: ")
if !ok || !validCodexAutomationID(automationID) {
return false
}
expectedMemory := "Automation memory: $CODEX_HOME/automations/" + automationID + "/memory.md"
if lines[2] != expectedMemory {
return false
}
lastRun, ok := codexAutomationHeaderValue(lines[3], "Last run: ")
if !ok || !validCodexAutomationLastRun(lastRun) || lines[4] != "" {
return false
}
return strings.TrimSpace(strings.Join(lines[5:], "\n")) != ""
}
func codexAutomationHeaderValue(line, prefix string) (string, bool) {
if !strings.HasPrefix(line, prefix) {
return "", false
}
value := strings.TrimPrefix(line, prefix)
return value, value != "" && strings.TrimSpace(value) == value
}
func validCodexAutomationID(value string) bool {
if len(value) == 0 || len(value) > 128 || value == "." || value == ".." {
return false
}
for i := 0; i < len(value); i++ {
c := value[i]
if (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-' || c == '_' || c == '.' {
continue
}
return false
}
return true
}
func validCodexAutomationLastRun(value string) bool {
if value == "never" {
return true
}
separator := strings.LastIndex(value, " (")
if separator <= 0 || !strings.HasSuffix(value, ")") {
return false
}
runAt, err := time.Parse(time.RFC3339Nano, value[:separator])
if err != nil {
return false
}
epochMillis, err := strconv.ParseInt(value[separator+2:len(value)-1], 10, 64)
return err == nil && runAt.UnixMilli() == epochMillis
}
func validCodexAutomationHeartbeat(value string) bool {
decoder := xml.NewDecoder(strings.NewReader(value))
var rootSeen, automationIDSeen bool
var automationID bytes.Buffer
depth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
id := automationID.String()
return rootSeen && automationIDSeen && depth == 0 &&
strings.TrimSpace(id) == id && validCodexAutomationID(id)
}
if err != nil {
return false
}
switch current := token.(type) {
case xml.StartElement:
depth++
if current.Name.Space != "" || len(current.Attr) != 0 || depth > 2 {
return false
}
if depth == 1 {
if rootSeen || current.Name.Local != "heartbeat" {
return false
}
rootSeen = true
} else if automationIDSeen || current.Name.Local != "automation_id" {
return false
}
automationIDSeen = depth == 2
case xml.EndElement:
if current.Name.Space != "" {
return false
}
depth--
if depth < 0 {
return false
}
case xml.CharData:
if depth == 2 {
_, _ = automationID.Write(current)
} else if len(bytes.TrimSpace(current)) != 0 {
return false
}
case xml.Comment, xml.ProcInst, xml.Directive:
return false
}
}
}
func validCodexDelegationEnvelope(value string) bool {
decoder := xml.NewDecoder(strings.NewReader(value))
var rootSeen, sourceSeen, inputSeen bool
var childName string
var childText bytes.Buffer
depth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
return rootSeen && depth == 0 && sourceSeen && inputSeen
}
if err != nil {
return false
}
switch current := token.(type) {
case xml.StartElement:
depth++
if current.Name.Space != "" || len(current.Attr) != 0 || (depth == 1 && current.Name.Local != "codex_delegation") || depth > 2 {
return false
}
if depth == 1 {
if rootSeen {
return false
}
rootSeen = true
continue
}
if current.Name.Local != "source_thread_id" && current.Name.Local != "input" {
return false
}
childName = current.Name.Local
childText.Reset()
case xml.EndElement:
if current.Name.Space != "" {
return false
}
if depth == 2 {
if current.Name.Local != childName || strings.TrimSpace(childText.String()) == "" {
return false
}
if childName == "source_thread_id" {
if sourceSeen {
return false
}
sourceSeen = true
} else {
if inputSeen {
return false
}
inputSeen = true
}
childName = ""
}
depth--
if depth < 0 {
return false
}
case xml.CharData:
if depth == 2 {
_, _ = childText.Write(current)
} else if len(bytes.TrimSpace(current)) != 0 {
return false
}
case xml.Comment:
return false
case xml.ProcInst, xml.Directive:
return false
}
}
}
func (h *OpenAIGatewayHandler) acquireResponsesUserSlot(
c *gin.Context,
userID int64,
@@ -1616,10 +2026,10 @@ const (
// 由 BeforeTurn 在每个 turn 开始时冻结,AfterTurn 的用量提交读取它;turn 在
// 连接内串行推进,互斥锁只为跨用量提交 goroutine 的读取安全。
//
// ws_v2 passthrough ingress 没有 BeforeTurn,因此本值会保持零;AfterTurn 必须
// 以 TurnStarted 已记录的所属 turn 开始时刻为回退,而不是用建连或记录时刻。
// 这样每个 passthrough turn 都按自己的开始时刻计价,但不改变其仅在建连时执行
// 准入门、没有 turn 级利润复核的既有行为。
// 零值语义(重要):首轮准入由握手路径完成,不调用 BeforeTurn,因此首轮保持
// 零值并回退到 TurnStarted 记录的首轮开始时刻。后续 turn 在 response.create
// 写入上游前调用 BeforeTurn,按当时的利润门复核并冻结定价。绝不能用建连时刻
// 初始化,否则会把长连接的所有 turn 钉死在建连时的峰谷因子。
type openAIWSTurnPricing struct {
mu sync.Mutex
at time.Time
@@ -1680,7 +2090,7 @@ func (h *OpenAIGatewayHandler) acquireResponsesAccountSlot(
return h.acquireOpenAIAccountSlot(c, groupID, sessionHash, selection, reqStream, streamStarted, reqLog, nil)
}
type openAISlotErrorWriter func(status int, errType, message string)
type openAISlotErrorWriter func(status int, errType, code, message string)
// acquireOpenAIAccountSlot centralizes scheduler selection admission. The
// optional error writer lets non-Responses endpoints retain their wire format
@@ -1696,13 +2106,13 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
writeError openAISlotErrorWriter,
) (func(), openAISlotAcquireResult) {
if writeError == nil {
writeError = func(status int, errType, message string) {
h.handleStreamingAwareError(c, status, errType, message, *streamStarted)
writeError = func(status int, errType, code, message string) {
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, *streamStarted, false)
}
}
if selection == nil || selection.Account == nil {
markOpsRoutingCapacityLimited(c)
writeError(http.StatusServiceUnavailable, "api_error", "No available accounts")
writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts")
return nil, openAISlotAcquireFailed
}
@@ -1732,7 +2142,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
}
if selection.WaitPlan == nil {
markOpsRoutingCapacityLimited(c)
writeError(http.StatusServiceUnavailable, "api_error", "No available accounts")
writeError(http.StatusServiceUnavailable, "api_error", "", "No available accounts")
return nil, openAISlotAcquireFailed
}
@@ -1743,8 +2153,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
)
if err != nil {
reqLog.Warn("openai.account_slot_quick_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
status, errType, message := concurrencyErrorResponse(err, "account")
writeError(status, errType, message)
status, errType, code, message := concurrencyErrorResponse(err, "account")
writeError(status, errType, code, message)
return nil, openAISlotAcquireFailed
}
if fastAcquired {
@@ -1774,7 +2184,7 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
zap.Int64("account_id", account.ID),
zap.Int("max_waiting", selection.WaitPlan.MaxWaiting),
)
writeError(http.StatusTooManyRequests, "rate_limit_error", "Too many pending requests, please retry later")
writeError(http.StatusTooManyRequests, "rate_limit_error", gatewayQueueFullCode, "Too many pending requests, please retry later")
return nil, openAISlotAcquireFailed
}
@@ -1797,8 +2207,8 @@ func (h *OpenAIGatewayHandler) acquireOpenAIAccountSlot(
)
if err != nil {
reqLog.Warn("openai.account_slot_acquire_failed", zap.Int64("account_id", account.ID), zap.Error(err))
status, errType, message := concurrencyErrorResponse(err, "account")
writeError(status, errType, message)
status, errType, code, message := concurrencyErrorResponse(err, "account")
writeError(status, errType, code, message)
return nil, openAISlotAcquireFailed
}
@@ -1939,6 +2349,16 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, "model is required in first response.create payload")
return
}
// 分组级模型白名单:首帧校验客户端模型,不通过则关闭连接并标记运维原因。
// 必须在 ensureCompositeTargetPlatform(合成路由改写)之前执行。
// 与 HTTP 准入一致:帧内重复 model 键/大小写变体可能被上游按末值绑定,
// 全部候选值逐一校验,任一未命中即拒绝。
if blocked := blockedModelAllowlistCandidate(apiKey.Group, requestmodel.FromBodyCandidates("", "application/json", firstMessage)); blocked != "" {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
closeOpenAIClientWS(wsConn, coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked))
return
}
ensureCompositeTargetPlatform(c, apiKey, reqModel)
ctx = c.Request.Context()
if apiKey.Group != nil && apiKey.Group.Platform == service.PlatformComposite {
@@ -1986,6 +2406,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
cyberBlockedThisConn := false
cyberBlockPendingAfterFailover := false
var cyberTurnBodiesMu sync.Mutex
cyberTurnBodies := map[int][]byte{1: append([]byte(nil), firstMessage...)}
setCyberTurnBody := func(turn int, payload []byte) {
@@ -2003,10 +2424,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// 解析渠道级模型映射
channelMappingWS, _ := h.gatewayService.ResolveChannelMappingAndRestrict(ctx, apiKey.GroupID, reqModel)
wsForwardModel := reqModel
if channelMappingWS.Mapped && strings.TrimSpace(channelMappingWS.MappedModel) != "" {
wsForwardModel = strings.TrimSpace(channelMappingWS.MappedModel)
}
wsForwardModel := openAIChannelForwardModel(channelMappingWS, reqModel)
var currentUserRelease func()
var currentAccountRelease func()
@@ -2169,7 +2587,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
apiKey.GroupID,
previousResponseID,
sessionHash,
reqModel,
wsForwardModel,
failedAccountIDs,
requiredTransport,
requiredCapability,
@@ -2298,7 +2716,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
zap.Int("candidate_count", scheduleDecision.CandidateCount),
)
maxReasoningEffort, reasoningEffortMappings, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
maxReasoningEffort, reasoningEffortMappings, maxReasoningEffortOverLimit, _ := openAIReasoningEffortPolicyForRequest(c, apiKey)
var requestPayloadHash string
var turnStartsMu sync.Mutex
turnStarts := make(map[int]time.Time, 4)
@@ -2321,24 +2739,24 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// turn-tagged slot preserves the exact mapping used for the in-flight request.
var turnChannelMapping atomic.Pointer[openAIWSTurnChannelMappingSnapshot]
turnChannelMapping.Store(&openAIWSTurnChannelMappingSnapshot{turn: 1, mapping: channelMappingWS})
// turn 级定价:BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号;
// passthrough 没有 BeforeTurn 时,AfterTurn 回退到 TurnStarted 的所属 turn 时刻。
// turn 级定价:首轮回退到 TurnStarted 的所属 turn 时刻;后续 turn 由
// BeforeTurn 重新冻结 pricingAt 并按最新门复核当前账号。
var turnPricing openAIWSTurnPricing
hooks := &service.OpenAIWSIngressHooks{
ClientLifecycleContext: clientLifecycleCtx,
InitialRequestModel: reqModel,
InitialTurnStartedAt: firstTurnStartedAt,
MaxReasoningEffort: maxReasoningEffort,
ReasoningEffortMappings: reasoningEffortMappings,
TurnStarted: recordTurnStart,
ClientLifecycleContext: clientLifecycleCtx,
InitialRequestModel: reqModel,
InitialTurnStartedAt: firstTurnStartedAt,
MaxReasoningEffort: maxReasoningEffort,
MaxReasoningEffortOverLimit: maxReasoningEffortOverLimit,
ReasoningEffortMappings: reasoningEffortMappings,
TurnStarted: recordTurnStart,
BeforeRequest: func(turn int, payload []byte, originalModel string) error {
c.Set(securityAuditWSTurnContextKey, turn)
service.BeginOpsStreamTurn(c, turn)
setCyberTurnBody(turn, payload)
// Passthrough ingress intentionally skips BeforeTurn, so enforce only
// the connection-level cyber session gate here as well. Native ingress
// visits this hook first and gets the same side-effect-free close error;
// its original BeforeTurn guard remains as defense in depth.
// 连接级 cyber session gate 也在 BeforeRequest 先执行,使 native 与
// passthrough ingress 都能在 BeforeTurn 及上游写入前无副作用地拒绝。
// BeforeTurn 中保留同一检查作为防御式兜底。
if cyberBlockedThisConn {
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, cyberSessionBlockedClientMsg, nil)
}
@@ -2355,6 +2773,17 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
if model == "" {
model = reqModel
}
// 分组级模型白名单:后续 turn 同样校验客户端模型(省略 model 时
// 沿用会话实际生效模型,含 session.update 轮换后的模型),不通过
// 则关闭整条连接,与推理强度 deny 一致。实际生效模型始终参与校验;
// 帧内重复 model 键/大小写变体/嵌套 session.model 额外逐一校验,
// 防止候选集非空时掩盖被轮换掉的禁用模型。
candidates := append([]string{model}, requestmodel.FromBodyCandidates("", "application/json", payload)...)
if blocked := blockedModelAllowlistCandidate(apiKey.Group, candidates); blocked != "" {
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
return service.NewOpenAIWSClientCloseError(coderws.StatusPolicyViolation, fmt.Sprintf("Model %q is not available for this group", blocked), nil)
}
if decision := h.checkSecurityAuditStage(c, reqLog, apiKey, subject, service.ContentModerationProtocolOpenAIResponses, model, payload, "subsequent_turn"); decision != nil && !decision.AllowNextStage {
writeSecurityAuditWSError(ctx, wsConn, decision)
return service.NewOpenAIWSClientCloseError(securityAuditWSCloseStatus(decision), securityAuditWSCloseReason(decision), nil)
@@ -2428,10 +2857,12 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
AfterTurn: func(turn int, result *service.OpenAIForwardResult, turnErr error) {
turnStart := getTurnStart(turn)
cyberBlockBody := takeCyberTurnBody(turn)
// F1: cyber 标记按 turn 生命周期清理——defer 保证任意早返回路径都执行;
// CyberBlocked 必须在 submit 前同步预捕获(task 闭包由 worker 池异步执行,
// 届时 defer 已清除标记)。
defer clearCyberPolicyTurnState(c)
// 每次 attempt 都清 cyber mark;failover 链结束前保留 recorded guard,
// 避免同一逻辑 turn 换号后重复落风控。CyberBlocked 必须在 submit 前
// 同步预捕获(task 闭包由 worker 池异步执行,届时 mark 已清除)。
defer func() {
clearCyberPolicyAttemptState(c, !cyberBlockPendingAfterFailover)
}()
releaseTurnSlots()
turnRequestedModel := reqModel
turnUpstreamModel := ""
@@ -2453,10 +2884,14 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
turnUpstreamModel = turnRequestedModel
}
turnUsageFields := turnMapping.ToUsageFields(turnRequestedModel, turnUpstreamModel)
cyberMarked := service.GetOpsCyberPolicy(c) != nil
h.recordCyberPolicyIfMarked(c, apiKey, account, subscription, turnRequestedModel, turnErr != nil, cyberBlockBody, turnUsageFields, requestPayloadHash)
if service.GetOpsCyberPolicy(c) != nil {
cyberBlockedThisConn = true
}
cyberBlockedThisConn, cyberBlockPendingAfterFailover = advanceOpenAIWSCyberBlockState(
cyberBlockedThisConn,
cyberBlockPendingAfterFailover,
cyberMarked,
turnErr,
)
if turnErr != nil {
if result == nil || result.ImageCount <= 0 {
return
@@ -2847,14 +3282,14 @@ func (h *OpenAIGatewayHandler) acquireImageGenerationSlot(c *gin.Context, stream
if acquired {
return release, true
}
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "Image generation concurrency limit exceeded, please retry later", streamStarted)
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error", gatewayConcurrencyLimitCode, "Image generation concurrency limit exceeded, please retry later", streamStarted, false)
return nil, false
}
// handleConcurrencyError handles concurrency-related acquire errors.
func (h *OpenAIGatewayHandler) handleConcurrencyError(c *gin.Context, err error, slotType string, streamStarted bool) {
status, errType, message := concurrencyErrorResponse(err, slotType)
h.handleStreamingAwareError(c, status, errType, message, streamStarted)
status, errType, code, message := concurrencyErrorResponse(err, slotType)
h.handleStreamingAwareErrorWithCode(c, status, errType, code, message, streamStarted, false)
}
func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverErr *service.UpstreamFailoverError, streamStarted bool) {
@@ -2897,6 +3332,12 @@ func (h *OpenAIGatewayHandler) handleFailoverExhausted(c *gin.Context, failoverE
}
statusCode := failoverErr.StatusCode
responseBody := failoverErr.ResponseBody
if statusCode == http.StatusBadRequest && service.IsOpenAICompatibleModelNotFound400(responseBody) && !streamStarted {
upstreamMsg := service.SanitizeUpstreamErrorMessage(service.ExtractUpstreamErrorMessage(responseBody))
service.SetOpsUpstreamError(c, statusCode, upstreamMsg, "")
service.WriteOpenAIUpstreamClientError(c, statusCode, responseBody, upstreamMsg)
return
}
if service.IsOpenAISilentRefusalErrorBody(responseBody) {
service.SetOpsUpstreamError(c, statusCode, service.OpenAISilentRefusalClientMessage(), "")
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", service.OpenAISilentRefusalClientMessage(), streamStarted)
@@ -3034,7 +3475,7 @@ func (h *OpenAIGatewayHandler) handleStreamingAwareErrorWithCode(
// 通用 `event: error` 帧不被识别为终止事件,会导致
// "stream closed before response.completed"。
if inboundIsResponses(c) {
if writeResponsesFailedSSE(c, errType, message) {
if writeResponsesFailedSSE(c, errType, code, message) {
return
}
}
@@ -3201,7 +3642,7 @@ func (h *OpenAIGatewayHandler) errorResponse(c *gin.Context, status int, errType
// 提交的 SSE 流交错,必须降级为 response.failed 终止事件(#3887)。
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
service.MarkOpsStreamError(c, errType, message, status)
if writeResponsesFailedSSE(c, errType, message) {
if writeResponsesFailedSSE(c, errType, "", message) {
return
}
}
@@ -3256,6 +3697,21 @@ func isOpenAIWSUpgradeRequest(r *http.Request) bool {
return strings.Contains(strings.ToLower(strings.TrimSpace(r.Header.Get("Connection"))), "upgrade")
}
// blockedModelAllowlistCandidate 对全部候选模型逐一校验分组白名单,返回第一个
// 未命中的值(全部命中或白名单未开启返回空串)。WS 帧与 HTTP 请求体共用该
// 规则:重复 model 键/大小写变体可能被上游按末值绑定,任一未命中即拒绝。
func blockedModelAllowlistCandidate(group *service.Group, candidates []string) string {
if group == nil || !group.ModelAllowlistEnabled() {
return ""
}
for _, candidate := range candidates {
if !group.ModelAllowlist.Allows(candidate) {
return candidate
}
}
return ""
}
func closeOpenAIClientWS(conn *coderws.Conn, status coderws.StatusCode, reason string) {
if conn == nil {
return
@@ -3519,7 +3975,7 @@ func (h *OpenAIGatewayHandler) rejectIfCyberSessionBlocked(c *gin.Context, apiKe
// 写 JSON(#3887)。
if service.StopOpenAICompactSSEKeepaliveCommitted(c) {
service.MarkOpsStreamError(c, "permission_error", cyberSessionBlockedClientMsg, http.StatusForbidden)
if writeResponsesFailedSSE(c, "permission_error", cyberSessionBlockedClientMsg) {
if writeResponsesFailedSSE(c, "permission_error", "", cyberSessionBlockedClientMsg) {
h.enqueueCyberSessionBlockedOpsEntry(c, apiKey, model, key)
return true
}
@@ -3763,15 +4219,20 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
}()
}
// clearCyberPolicyTurnState resets the cyber mark and the per-request recorded
// guard. WS-only: called at the END of AfterTurn, after recordCyberPolicyIfMarked
// and RecordUsage (which reads CyberBlocked) have both consumed the mark.
// clearCyberPolicyTurnState resets the cyber mark and recorded guard after a
// logical WS turn has finished.
func clearCyberPolicyTurnState(c *gin.Context) {
clearCyberPolicyAttemptState(c, true)
}
func clearCyberPolicyAttemptState(c *gin.Context, resetRecorded bool) {
if c == nil {
return
}
service.ClearOpsCyberPolicy(c)
c.Set(cyberPolicyRecordedKey, false)
if resetRecorded {
c.Set(cyberPolicyRecordedKey, false)
}
}
func summarizeWSCloseErrorForLog(err error) (string, string) {
@@ -1530,7 +1530,7 @@ func TestOpenAIResponsesWebSocket_PassthroughTracksModelPerTurn(t *testing.T) {
"each turn must be billed with its own channel-mapped model")
}
func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKeysRemainsValid(t *testing.T) {
func TestOpenAIResponsesWebSocket_ChannelMappedTargetSelectsAccountWithoutRequestedAlias(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"public-alias","stream":false}`,
secondPayload: `{"type":"response.create","stream":false}`,
@@ -1538,7 +1538,7 @@ func TestOpenAIResponsesWebSocket_UnchangedChannelTargetOutsideAccountMappingKey
"public-alias": "gpt-5.6-sol",
},
accountModelMapping: map[string]any{
"public-alias": "gpt-5.6-terra",
"gpt-5.6-sol": "gpt-5.6-sol",
},
})
@@ -1710,6 +1710,40 @@ func TestOpenAIAccountScheduleModelUsesActualOrSharedResolver(t *testing.T) {
require.Equal(t, "attempt-actual", openAIAccountScheduleModel(c, account, "public", true, nil))
}
func TestOpenAIChannelForwardModelForScheduler(t *testing.T) {
tests := []struct {
name string
mapping service.ChannelMappingResult
requested string
want string
}{
{
name: "mapped model is used for capability filtering",
mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " gpt-forward "},
requested: "client-alias",
want: "gpt-forward",
},
{
name: "unmapped request preserves client model",
mapping: service.ChannelMappingResult{MappedModel: "client-model"},
requested: "client-model",
want: "client-model",
},
{
name: "empty mapped model safely preserves client model",
mapping: service.ChannelMappingResult{Mapped: true, MappedModel: " "},
requested: "client-model",
want: "client-model",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, openAIChannelForwardModel(tt.mapping, tt.requested))
})
}
}
func TestShouldReportOpenAIWSProxyAccountFailure(t *testing.T) {
t.Run("unsupported client model switch does not penalize account", func(t *testing.T) {
err := fmt.Errorf("wrapped ingress turn: %w", newOpenAIWSUnsupportedModelSwitchError("gpt-unsupported"))
@@ -1882,7 +1916,10 @@ func newOpenAIWSHandlerTestServer(t *testing.T, h *OpenAIGatewayHandler, subject
}
type openAIResponsesWSUsageLogCase struct {
firstPayload string
firstPayload string
// midPayload 在首个 turn 完成后发送(如 session.update),上游桩会为它
// 回一个 response.completed,客户端按普通事件读取。
midPayload string
secondPayload string
userAgent *string
ingressMode string
@@ -1890,6 +1927,12 @@ type openAIResponsesWSUsageLogCase struct {
billingModelSource string
accountModelMapping map[string]any
afterFirstUpstreamRequest func(channelSvc *service.ChannelService) error
// group 覆盖 apiKey.Group(分组级模型白名单测试用);nil 保持原有无分组行为。
group *service.Group
// firstFrameCloseExpected:首帧即被拒(连接被 1008 关闭),不期待任何响应帧。
firstFrameCloseExpected bool
// secondTurnCloseExpected:第二个 turn 被拒(连接被 1008 关闭)。
secondTurnCloseExpected bool
}
type openAIResponsesWSUsageLogResult struct {
@@ -2809,8 +2852,11 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
gin.SetMode(gin.TestMode)
turnCount := 1
if strings.TrimSpace(tc.midPayload) != "" {
turnCount++
}
if strings.TrimSpace(tc.secondPayload) != "" {
turnCount = 2
turnCount++
}
upstreamPayloadCh := make(chan []byte, turnCount)
upstreamErrCh := make(chan error, 1)
@@ -2963,6 +3009,9 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
GroupID: &groupID,
User: &service.User{ID: 1701, Status: service.StatusActive},
}
if tc.group != nil {
apiKey.Group = tc.group
}
router := gin.New()
router.Use(func(c *gin.Context) {
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
@@ -2994,6 +3043,19 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
cancelWrite()
require.NoError(t, err)
if tc.firstFrameCloseExpected {
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
_, _, readErr := clientConn.Read(readCtx)
cancelRead()
require.Error(t, readErr, "first frame should have been rejected with a close")
var closeErr coderws.CloseError
require.ErrorAs(t, readErr, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
require.Contains(t, closeErr.Reason, "not available for this group")
_ = clientConn.CloseNow()
return openAIResponsesWSUsageLogResult{}
}
clientEvents := make([][]byte, 0, turnCount)
readCompleted := func() {
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
@@ -3004,11 +3066,30 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
clientEvents = append(clientEvents, append([]byte(nil), event...))
}
readCompleted()
if turnCount == 2 {
if tc.midPayload != "" {
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.midPayload))
cancelWrite()
require.NoError(t, err)
readCompleted()
}
if strings.TrimSpace(tc.secondPayload) != "" && (turnCount >= 2) {
writeCtx, cancelWrite = context.WithTimeout(context.Background(), 3*time.Second)
err = clientConn.Write(writeCtx, coderws.MessageText, []byte(tc.secondPayload))
cancelWrite()
require.NoError(t, err)
if tc.secondTurnCloseExpected {
readCtx, cancelRead := context.WithTimeout(context.Background(), 3*time.Second)
_, _, readErr := clientConn.Read(readCtx)
cancelRead()
require.Error(t, readErr, "second turn should have been rejected with a close")
var closeErr coderws.CloseError
require.ErrorAs(t, readErr, &closeErr)
require.Equal(t, coderws.StatusPolicyViolation, closeErr.Code)
require.Contains(t, closeErr.Reason, "not available for this group")
_ = clientConn.CloseNow()
return openAIResponsesWSUsageLogResult{}
}
readCompleted()
}
_ = clientConn.Close(coderws.StatusNormalClosure, "done")
@@ -0,0 +1,179 @@
package handler
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
)
func wsAllowlistGroup(enabled bool, models ...string) *service.Group {
return &service.Group{
ID: 4201,
Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: enabled,
Models: models,
},
}
}
// 首帧拒绝:模型不在白名单时连接被 1008 关闭(passthrough relay 模式)。
func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_Passthrough(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModePassthrough,
firstFrameCloseExpected: true,
})
}
// 首帧拒绝:原生 ingress 模式。
func TestOpenAIResponsesWebSocket_FirstFrameModelNotAllowedCloses_NativeIngress(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModeDedicated,
firstFrameCloseExpected: true,
})
}
// 首帧命中白名单:连接正常建立并收到 response.completed(passthrough)。
func TestOpenAIResponsesWebSocket_FirstFrameAllowlistedModelProceeds(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
})
if len(got.clientEvents) != 1 {
t.Fatalf("expected one completed event, got %d", len(got.clientEvents))
}
}
// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(passthrough relay 模式)。
func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_Passthrough(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModePassthrough,
secondTurnCloseExpected: true,
})
}
// 后续 turn 切换到白名单外的模型:连接被 1008 关闭(原生 ingress 模式)。
func TestOpenAIResponsesWebSocket_SubsequentTurnModelNotAllowedCloses_NativeIngress(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModeDedicated,
secondTurnCloseExpected: true,
})
}
// 后续 turn 省略 model:沿用会话初始模型,白名单校验通过。
func TestOpenAIResponsesWebSocket_SubsequentTurnOmittedModelUsesSessionModel(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
})
if len(got.clientEvents) != 2 {
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
}
if len(got.upstreamPayloads) != 2 {
t.Fatalf("expected two upstream frames, got %d", len(got.upstreamPayloads))
}
}
// 白名单关闭:不受任何影响。
func TestOpenAIResponsesWebSocket_DisabledAllowlistDoesNotInterfere(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-4.1","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
group: wsAllowlistGroup(false, "gpt-5.4"),
})
if len(got.clientEvents) != 2 {
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
}
}
// 首帧含重复 model 键(首个在白名单、末个不在):passthrough 会原样转发两个字段,
// 上游按末值绑定时得到禁用模型,必须在入口拒绝。
func TestOpenAIResponsesWebSocket_FirstFrameDuplicateModelKeysRejected(t *testing.T) {
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
t.Run(mode, func(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: mode,
firstFrameCloseExpected: true,
})
})
}
}
// 首帧混用大小写变体键({"model":"allowed","Model":"blocked"}):绑定系上游
// 大小写不敏感且末值生效会绑定 blocked,必须在入口拒绝。仅含变体键的帧则会在
// 「model is required」处更早被关闭。
func TestOpenAIResponsesWebSocket_FirstFrameCaseVariantModelKeyRejected(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","Model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModePassthrough,
firstFrameCloseExpected: true,
})
}
// 后续 turn 含重复 model 键(首个在白名单、末个不在):拒绝并关闭连接。
func TestOpenAIResponsesWebSocket_SubsequentTurnDuplicateModelKeysRejected(t *testing.T) {
for _, mode := range []string{service.OpenAIWSIngressModePassthrough, service.OpenAIWSIngressModeDedicated} {
t.Run(mode, func(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-4.1","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: mode,
secondTurnCloseExpected: true,
})
})
}
}
// 重复但同值的 model 键不误伤,连接正常完成两个 turn。
func TestOpenAIResponsesWebSocket_DuplicateIdenticalModelKeysAllowed(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","model":"gpt-5.4","stream":false}`,
secondPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
})
if len(got.clientEvents) != 2 {
t.Fatalf("expected two completed events, got %d", len(got.clientEvents))
}
}
// session.update 轮换绕过:首帧用白名单内模型建立会话,session.update 把会话
// 模型改为白名单外的模型,随后 response.create 携带嵌套 session.model(白名单
// 内)让帧内候选非空。实际生效模型(轮换后的会话模型)必须始终参与校验。
func TestOpenAIResponsesWebSocket_SessionUpdateRotationBypassRejected(t *testing.T) {
runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
midPayload: `{"type":"session.update","session":{"model":"gpt-4.1"}}`,
secondPayload: `{"type":"response.create","session":{"model":"gpt-5.4"},"stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
ingressMode: service.OpenAIWSIngressModePassthrough,
secondTurnCloseExpected: true,
})
}
// 轮换后的会话模型本身在白名单内时,省略 model 的后续 turn 正常放行。
func TestOpenAIResponsesWebSocket_SessionUpdateToAllowedModelStillWorks(t *testing.T) {
got := runOpenAIResponsesWebSocketUsageLogCase(t, openAIResponsesWSUsageLogCase{
firstPayload: `{"type":"response.create","model":"gpt-5.4","stream":false}`,
midPayload: `{"type":"session.update","session":{"model":"gpt-5.4"}}`,
secondPayload: `{"type":"response.create","stream":false}`,
group: wsAllowlistGroup(true, "gpt-5.4"),
})
if len(got.clientEvents) < 2 {
t.Fatalf("expected at least two completed events, got %d", len(got.clientEvents))
}
}
@@ -0,0 +1,52 @@
package handler
import (
"errors"
"net/http"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group) {
if c.Request.Context().Err() != nil {
return
}
if h.openAIGatewayService == nil {
writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "OpenAI model discovery is not configured")
return
}
response, account, err := h.openAIGatewayService.FetchPinnedOpenAIModelsList(
c.Request.Context(), group, h.maxAccountSwitches, c.GetHeader("If-None-Match"),
)
if c.Request.Context().Err() != nil {
return
}
if err != nil {
if errors.Is(err, service.ErrNoPinnedCodexModelsAccounts) {
writeOpenAIModelsError(c, http.StatusServiceUnavailable, "upstream_error", "No available OpenAI model discovery accounts")
return
}
writeOpenAIModelsError(c, infraerrors.Code(err), "upstream_error", infraerrors.Message(err))
return
}
setOpsSelectedAccount(c, account.ID, account.Platform)
writeOpenAIModelsResponse(c, response)
}
func writeOpenAIModelsError(c *gin.Context, status int, errorType, message string) {
c.JSON(status, gin.H{"error": gin.H{"type": errorType, "message": message}})
}
func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) {
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
if manifest.NotModified {
c.Status(http.StatusNotModified)
c.Writer.WriteHeaderNow()
return
}
c.Data(http.StatusOK, "application/json", manifest.Body)
}
@@ -0,0 +1,217 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func performOrdinaryPinnedModelsRequest(t *testing.T, codex *OpenAIGatewayHandler, group *service.Group, path, etag string) *httptest.ResponseRecorder {
t.Helper()
h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodGet, path, nil)
c.Request.Header.Set("If-None-Match", etag)
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
h.Models(c)
return recorder
}
func ordinaryPinnedModelIDs(t *testing.T, recorder *httptest.ResponseRecorder) []string {
t.Helper()
var response gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(recorder.Body.Bytes(), &response))
require.NotNil(t, response.Data, "an empty catalog must be an array, not null")
return modelIDsForTest(response.Data)
}
func TestOrdinaryPinnedModelsUsesSelectedAccountsAndFinalETag(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, true, true),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
1: `{"data":[{"id":"unselected"}]}`,
2: `{"data":[{"id":"special-model","owned_by":"first","created":123},{"id":"gpt-image-1"}]}`,
3: `{"data":[{"id":"special-model","owned_by":"second"},{"id":"text-embedding-3-large"}]}`,
}}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 91, Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2, 3}}}
first := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
require.Equal(t, []string{"special-model", "gpt-image-1", "text-embedding-3-large"}, ordinaryPinnedModelIDs(t, first))
var response gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &response))
require.Equal(t, "model", response.Data[0].Object)
require.Equal(t, "first", response.Data[0].OwnedBy)
require.EqualValues(t, 123, response.Data[0].Created)
require.Equal(t, []int64{2, 3}, upstream.accountIDs())
alias := performOrdinaryPinnedModelsRequest(t, h, group, "/models", first.Header().Get("ETag"))
require.Equal(t, http.StatusNotModified, alias.Code)
require.Empty(t, alias.Body.String())
require.Equal(t, []int64{2, 3}, upstream.accountIDs(), "fresh requests must not hit upstream")
otherGroup := *group
otherGroup.ID++
otherGroup.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"text-embedding-3-large", "special-model"}}
filtered := performOrdinaryPinnedModelsRequest(t, h, &otherGroup, "/v1/models", first.Header().Get("ETag"))
require.Equal(t, http.StatusOK, filtered.Code)
require.Equal(t, []string{"text-embedding-3-large", "special-model"}, ordinaryPinnedModelIDs(t, filtered))
require.NotEqual(t, first.Header().Get("ETag"), filtered.Header().Get("ETag"))
again := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, first.Body.String(), again.Body.String(), "group filters must not modify account cache")
require.Equal(t, []int64{2, 3}, upstream.accountIDs())
}
func TestOrdinaryPinnedModelsFailureAndEmptyPolicies(t *testing.T) {
for _, tc := range []struct {
name string
accountIDs []int64
bodies map[int64]string
statuses map[int64]int
fallback bool
selected []string
wantStatus int
wantIDs []string
}{
{name: "partial failure", accountIDs: []int64{2, 3}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, statuses: map[int64]int{3: 503}, wantStatus: 200, wantIDs: []string{"ok"}},
{name: "all failed", accountIDs: []int64{2, 3}, statuses: map[int64]int{2: 429, 3: 504}, wantStatus: 502},
{name: "missing members", accountIDs: []int64{99}, wantStatus: 503},
{name: "empty config", wantStatus: 503},
{name: "fallback from missing members", accountIDs: []int64{99}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
{name: "fallback from failure", accountIDs: []int64{2}, statuses: map[int64]int{2: 503}, fallback: true, wantStatus: 200, wantIDs: []string{"from-scheduler"}},
{name: "valid empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[]}`}, fallback: true, wantStatus: 200},
{name: "filtered empty", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":[{"id":"ok"}]}`}, selected: []string{"absent"}, fallback: true, wantStatus: 200},
{name: "invalid envelope", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"error":"denied"}`}, wantStatus: 502},
{name: "null data", accountIDs: []int64{2}, bodies: map[int64]string{2: `{"data":null}`}, wantStatus: 502},
} {
t.Run(tc.name, func(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, true, false),
newPinnedCodexAccount(3, service.StatusActive, true, false),
}
bodies := map[int64]string{1: `{"data":[{"id":"from-scheduler"}]}`}
for id, body := range tc.bodies {
bodies[id] = body
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: bodies, statuses: tc.statuses}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 92, Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: tc.accountIDs, FallbackToScheduler: tc.fallback}}
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, tc.wantStatus, recorder.Code, recorder.Body.String())
if tc.wantStatus == 200 {
require.Equal(t, len(tc.wantIDs), len(ordinaryPinnedModelIDs(t, recorder)))
if len(tc.wantIDs) > 0 {
require.Equal(t, tc.wantIDs, ordinaryPinnedModelIDs(t, recorder))
} else {
require.NotContains(t, upstream.accountIDs(), int64(1), "empty success must not trigger fallback")
}
}
})
}
}
func TestOrdinaryPinnedModelsSkipsPersistentlyUnavailableAccounts(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, true),
newPinnedCodexAccount(2, service.StatusActive, false, false),
newPinnedCodexAccount(3, service.StatusDisabled, true, false),
newPinnedCodexAccount(4, service.StatusActive, true, false),
}
expired := time.Now().Add(-time.Hour)
accounts[3].ExpiresAt = &expired
accounts[3].AutoPauseOnExpired = true
overloaded := time.Now().Add(time.Hour)
accounts[0].OverloadUntil = &overloaded
accounts[0].TempUnschedulableUntil = &overloaded
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: `{"data":[{"id":"available"}]}`}}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 93, Platform: service.PlatformOpenAI,
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1, 2, 3, 4, 99}}}
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []int64{1}, upstream.accountIDs())
}
func TestPinnedModelsAllowlistExpandsWildcardsForBothRepresentations(t *testing.T) {
for _, codex := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
accounts := []service.Account{newPinnedCodexAccount(1, service.StatusActive, true, false)}
accounts[0].Credentials["model_mapping"] = map[string]any{
"public-a": "gpt-5.5", "public-b": "gpt-5.5", "blocked": "gpt-5.5",
}
body := `{"data":[{"id":"gpt-5.5","owned_by":"provider"}]}`
if codex {
body = `{"models":[{"slug":"gpt-5.5","context_window":424242}]}`
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{1: body}}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 95, Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"public-b", "public-*"}},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{1}}}
if codex {
recorder := performPinnedCodexModelsRequest(t, h, group, "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"public-b", "public-a"}, codexHandlerManifestSlugs(t, recorder))
require.Contains(t, recorder.Body.String(), `"context_window":424242`)
} else {
recorder := performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []string{"public-b", "public-a"}, ordinaryPinnedModelIDs(t, recorder))
require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
}
})
}
}
func TestPinnedModelsMappingFollowsUpstreamDiscoveryForBothRepresentations(t *testing.T) {
for _, codex := range []bool{false, true} {
t.Run(map[bool]string{false: "ordinary", true: "codex"}[codex], func(t *testing.T) {
accounts := []service.Account{
newPinnedCodexAccount(1, service.StatusActive, true, false),
newPinnedCodexAccount(2, service.StatusActive, true, false),
}
accounts[0].Credentials["model_mapping"] = map[string]any{"unselected-alias": "gpt-5.5"}
accounts[1].Credentials["model_mapping"] = map[string]any{
"public-alias": "gpt-5.5", "missing-alias": "missing-upstream", "custom-*": "gpt-5.5",
}
body := `{"data":[{"id":"gpt-5.5","owned_by":"provider","created":123},{"id":"unmapped-upstream"}]}`
if codex {
body = `{"models":[{"slug":"gpt-5.5","context_window":424242},{"slug":"unmapped-upstream"}]}`
}
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{2: body}}
h := newPinnedCodexTestHandler(accounts, upstream, 3)
group := &service.Group{ID: 94, Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: []string{"custom-concrete", "public-alias", "unselected-alias", "missing-alias"}},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}}
var recorder *httptest.ResponseRecorder
if codex {
recorder = performPinnedCodexModelsRequest(t, h, group, "")
} else {
recorder = performOrdinaryPinnedModelsRequest(t, h, group, "/v1/models", "")
}
require.Equal(t, http.StatusOK, recorder.Code, recorder.Body.String())
require.Equal(t, []int64{2}, upstream.accountIDs(), "mappings must not bypass pinned discovery")
if codex {
require.Equal(t, []string{"custom-concrete", "public-alias"}, codexHandlerManifestSlugs(t, recorder))
require.Contains(t, recorder.Body.String(), `"context_window":424242`)
} else {
require.Equal(t, []string{"custom-concrete", "public-alias"}, ordinaryPinnedModelIDs(t, recorder))
require.Contains(t, recorder.Body.String(), `"owned_by":"provider"`)
}
})
}
}
@@ -0,0 +1,205 @@
//go:build unit
package handler
import (
"bytes"
"io"
"net/http"
"net/http/httptest"
"sync"
"testing"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// astraProRequestBody is the inbound OpenAI Responses request that must survive
// the account-switch loop unchanged: gpt-6-astra with the official top-level
// reasoning.mode/effort preserved by the service guard
// (normalizeOpenAIResponsesReasoningMode), never downgraded mode -> effort.
const astraProRequestBody = `{"model":"gpt-6-astra","stream":false,"input":"hello","reasoning":{"mode":"pro","effort":"max"}}`
// astraProCodexURL is the OAuth codex Responses exit every forwarded attempt must hit.
const astraProCodexURL = "https://chatgpt.com/backend-api/codex/responses"
// astraProSuccessResponse is the winning account's non-stream success; it echoes
// the structured reasoning.mode=pro so the client response must keep mode=pro.
const astraProSuccessResponse = `{"id":"resp_astra","model":"gpt-6-astra","status":"completed",` +
`"output":[{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],` +
`"usage":{"input_tokens":1,"output_tokens":2},` +
`"reasoning":{"mode":"pro","effort":"max"}}`
// astraProForbiddenResponse: a plain upstream 403 that stays failover-eligible but
// is not reclassified as access-state/credential failure nor a silent refusal.
const astraProForbiddenResponse = `{"error":{"type":"forbidden_error","code":"forbidden","message":"Forbidden"}}`
// astraProCapturedUpstream records URL/accountID/body per forwarded attempt.
type astraProCapturedUpstream struct {
service.HTTPUpstream
mu sync.Mutex
urls []string
accountIDs []int64
bodies [][]byte
answers []*http.Response
}
func newAstraProCapturedUpstream(answers ...*http.Response) *astraProCapturedUpstream {
return &astraProCapturedUpstream{answers: answers}
}
func (u *astraProCapturedUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
u.mu.Lock()
defer u.mu.Unlock()
body, _ := io.ReadAll(req.Body)
u.urls = append(u.urls, req.URL.String())
u.accountIDs = append(u.accountIDs, accountID)
u.bodies = append(u.bodies, body)
var resp *http.Response
if len(u.answers) > 0 {
resp = u.answers[0]
u.answers = u.answers[1:]
}
if resp == nil {
resp = &http.Response{StatusCode: http.StatusInternalServerError, Body: io.NopCloser(bytes.NewReader(nil))}
}
return resp, nil
}
func (u *astraProCapturedUpstream) snapshot() ([]string, []int64, [][]byte) {
u.mu.Lock()
defer u.mu.Unlock()
return append([]string(nil), u.urls...), append([]int64(nil), u.accountIDs...), append([][]byte(nil), u.bodies...)
}
// newAstraProFailoverContext builds the gin context that drives handler.Responses
// through the real failover loop for the astra pro request.
func newAstraProFailoverContext(t *testing.T, body string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
groupID := int64(3132)
req := httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader([]byte(body)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = req
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
ID: 99,
GroupID: &groupID,
Group: &service.Group{
ID: groupID,
Platform: service.PlatformOpenAI,
},
User: &service.User{ID: 100},
})
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 100, Concurrency: 0})
return c, rec
}
func astra403() *http.Response {
return &http.Response{
StatusCode: http.StatusForbidden,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(astraProForbiddenResponse)),
}
}
func astra200() *http.Response {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(bytes.NewBufferString(astraProSuccessResponse)),
}
}
// assertAstraProWire: every forwarded attempt must hit the codex responses URL and
// keep model=gpt-6-astra + reasoning.mode=pro + effort=max (no silent downgrade).
func assertAstraProWire(t *testing.T, urls []string, bodies [][]byte) {
t.Helper()
require.NotEmpty(t, urls, "failover loop must reach the upstream transport")
for i, u := range urls {
require.Equal(t, astraProCodexURL, u, "attempt %d must target the codex responses URL", i)
}
for i, body := range bodies {
require.Equal(t, "gpt-6-astra", gjson.GetBytes(body, "model").String(), "attempt %d must keep model", i)
require.Equal(t, "pro", gjson.GetBytes(body, "reasoning.mode").String(), "attempt %d must keep mode=pro", i)
require.Equal(t, "max", gjson.GetBytes(body, "reasoning.effort").String(), "attempt %d must keep effort=max", i)
}
}
// assertAstraProAccountSwitch: the failover loop must have actually touched both
// distinct OAuth fixture accounts (IDs 1 and 2), not replayed the same one.
func assertAstraProAccountSwitch(t *testing.T, accountIDs []int64) {
t.Helper()
require.Len(t, accountIDs, 2, "failover must attempt two accounts")
require.ElementsMatch(t, []int64{1, 2}, accountIDs, "both OAuth accounts must be reached")
}
// 403 then a second OAuth account succeeding: wire bodies keep pro+max/model and the
// winning account's structured reasoning.mode=pro reaches the client unchanged.
func TestOpenAIGatewayHandlerResponses_AstraProFirst403SecondSucceeds(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := newAstraProCapturedUpstream(astra403(), astra200())
handler := newOpenAIResponsesFailoverTestHandler(t, upstream)
c, rec := newAstraProFailoverContext(t, astraProRequestBody)
handler.Responses(c)
urls, accountIDs, bodies := upstream.snapshot()
assertAstraProWire(t, urls, bodies)
assertAstraProAccountSwitch(t, accountIDs)
require.Equal(t, http.StatusOK, rec.Code, "winning account must yield a 200 to the client")
require.Equal(t, "completed", gjson.GetBytes(rec.Body.Bytes(), "status").String())
require.Equal(t, "pro", gjson.GetBytes(rec.Body.Bytes(), "reasoning.mode").String(), "client must keep the winning response's mode=pro")
require.Equal(t, "max", gjson.GetBytes(rec.Body.Bytes(), "reasoning.effort").String())
}
// both OAuth accounts 403: exhausted keeps every wire body pro+max/model, is not 200,
// and surfaces the existing-policy 502 upstream_error (unchanged 403-masking policy).
func TestOpenAIGatewayHandlerResponses_AstraProBoth403NoDowngrade(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := newAstraProCapturedUpstream(astra403(), astra403())
handler := newOpenAIResponsesFailoverTestHandler(t, upstream)
c, rec := newAstraProFailoverContext(t, astraProRequestBody)
handler.Responses(c)
urls, accountIDs, bodies := upstream.snapshot()
assertAstraProWire(t, urls, bodies)
assertAstraProAccountSwitch(t, accountIDs)
require.NotEqual(t, http.StatusOK, rec.Code, "all-403 must never surface a 200 to the client")
require.Equal(t, http.StatusBadGateway, rec.Code)
require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
}
// passthrough -> non-passthrough drops only the encrypted *input* reasoning item;
// the official top-level reasoning.mode/effort survive verbatim.
func TestDeriveOpenAIForwardAttemptBody_AstraCrossModeKeepsTopLevelReasoningMode(t *testing.T) {
h := &OpenAIGatewayHandler{}
canonical := []byte(`{"model":"gpt-6-astra","stream":false,"reasoning":{"mode":"pro","effort":"max"},"input":[` +
`{"type":"message","role":"user","content":"hello"},` +
`{"type":"reasoning","id":"rs_kiro_abc","encrypted_content":"ENC_BLOB","summary":[{"type":"summary_text","text":"thinking"}]}` +
`]}`)
canonicalSnapshot := append([]byte(nil), canonical...)
state := &openAIPassthroughFailoverState{}
kiro := newOpenAIPassthroughAccount(1, true) // passthrough first
bedrock := newOpenAIPassthroughAccount(2, false) // non-passthrough after
firstBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, kiro, state)
require.Equal(t, 1, reasoningItemCount(t, firstBody), "first passthrough attempt keeps the encrypted input reasoning item")
require.Equal(t, "pro", gjson.GetBytes(firstBody, "reasoning.mode").String())
require.Equal(t, "max", gjson.GetBytes(firstBody, "reasoning.effort").String())
secondBody := h.deriveOpenAIForwardAttemptBody(nil, canonical, bedrock, state)
require.Equal(t, 0, reasoningItemCount(t, secondBody), "cross-mode attempt drops the encrypted input reasoning item")
require.Equal(t, "pro", gjson.GetBytes(secondBody, "reasoning.mode").String(), "mode=pro must survive cross-mode sanitization")
require.Equal(t, "max", gjson.GetBytes(secondBody, "reasoning.effort").String(), "effort=max must survive cross-mode sanitization")
require.Equal(t, "gpt-6-astra", gjson.GetBytes(secondBody, "model").String())
require.JSONEq(t, string(canonicalSnapshot), string(canonical), "canonical forwardBody must never be mutated")
require.True(t, gjson.GetBytes(canonical, "input.1.encrypted_content").Exists())
}
@@ -17,7 +17,7 @@ import (
// 上以 OpenAI 兼容错误结构返回 HTTP 400。这些用例在 handler 的 service_tier
// 校验处短路,不会进入账号选择/重试。
//
// 合法值(fast/priority/flex/auto/default/scale)与省略/null 的接受语义由
// 合法值(fast/priority/flex/auto/default/scale/ultrafast)与省略/null 的接受语义由
// service 层纯校验函数 TestValidateOpenAIServiceTierField 覆盖,避免 handler
// 测试走入真实账号选择/重试路径。
+10 -1
View File
@@ -2189,9 +2189,17 @@ func classifyOpsErrorLog(c *gin.Context, errType, message, code string, status i
routingCapacityLimited := isOpsRoutingCapacityLimited(c)
clientBusinessLimited := service.HasOpsClientBusinessLimited(c)
localModelConfiguration := clientBusinessLimited && service.OpsClientBusinessLimitedReason(c) == service.OpsClientBusinessLimitedReasonLocalModelConfiguration
// 分组模型白名单的入口拒绝发生在调度之前(本地面请求策略,业务限流原因与
// 账号模型映射复用 local_model_configuration):保持 classifyOpsPhase 的
// 自然分类(not_found/invalid_request → request,owner=client),不落到
// routing;上游归因仍由 suppressOpsUpstreamAttributionForLocalModelConfiguration 清空。
ingressModelNotAllowed := false
if reason, rejected := middleware2.GetIngressRejectReason(c); rejected && reason == middleware2.IngressRejectModelNotAllowed {
ingressModelNotAllowed = true
}
upstreamError := hasOpsUpstreamErrorContext(c)
accountAuthFailure := hasOpsAccountAuthFailure(c)
if localModelConfiguration {
if localModelConfiguration && !ingressModelNotAllowed {
phase = "routing"
} else if accountAuthFailure && !routingCapacityLimited {
phase = "account_auth"
@@ -2287,6 +2295,7 @@ func isOpsLocalBusinessLimitError(code string, msg string) bool {
strings.Contains(msg, "this group is restricted to claude code clients") ||
strings.Contains(msg, "this group does not allow /v1/messages dispatch") ||
strings.Contains(msg, "image generation is not enabled for this group") ||
(strings.Contains(msg, "reasoning effort") && strings.Contains(msg, "exceeds this group's limit")) ||
strings.Contains(msg, "token counting is not supported for this platform") ||
strings.Contains(msg, "images api is not supported for this platform") ||
(strings.Contains(msg, "model ") && strings.Contains(msg, " not in whitelist")) ||
@@ -1243,6 +1243,15 @@ func TestClassifyOpsLocalBusinessLimitErrorsExcludedFromSLA(t *testing.T) {
wantErrType: "permission_error",
wantPhase: "request",
},
{
name: "group reasoning effort over limit deny",
errType: "permission_error",
message: `reasoning effort "high" exceeds this group's limit of "low"`,
code: "",
status: http.StatusForbidden,
wantErrType: "permission_error",
wantPhase: "request",
},
{
name: "route token counting platform unsupported",
errType: "not_found_error",
@@ -1504,6 +1513,12 @@ func TestClassifyOpsUpstreamAuthTextStillCountsForSLA(t *testing.T) {
code: "403",
status: http.StatusForbidden,
},
{
name: "provider reasoning effort over limit shaped error",
message: `reasoning effort "high" exceeds this group's limit of "low"`,
code: "403",
status: http.StatusForbidden,
},
{
name: "provider token counting unsupported shaped error",
message: "Token counting is not supported for this platform",
@@ -1939,3 +1954,45 @@ func TestGetOpsAPIKeyPrefersPrimaryContextKey(t *testing.T) {
require.NotNil(t, got)
require.Equal(t, int64(1), got.ID, "已鉴权请求应优先使用正式 api key")
}
// 分组模型白名单入口拒绝:业务限流原因复用 local_model_configuration,但
// 携带 ingress 拒绝原因 model_not_allowed,阶段应保持自然分类(request /
// client),不落到 routing。
func TestClassifyOpsIngressModelNotAllowedKeepsRequestPhase(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
middleware2.MarkIngressRejected(c, middleware2.IngressRejectModelNotAllowed)
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(
c,
"not_found_error",
`Model "gpt-4.1" is not available for this group`,
"model_not_found",
http.StatusNotFound,
)
require.Equal(t, "request", phase)
require.True(t, isBusinessLimited)
require.Equal(t, "client", errorOwner)
require.Equal(t, "client_request", errorSource)
}
// 调度阶段的账号模型映射拒绝(无 ingress 标记)仍归类为 routing。
func TestClassifyOpsLocalModelConfigurationWithoutIngressMarkStaysRouting(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
service.MarkOpsClientBusinessLimited(c, service.OpsClientBusinessLimitedReasonLocalModelConfiguration)
phase, isBusinessLimited, errorOwner, _ := classifyOpsErrorLog(
c,
"model_not_found",
"Model \"gpt-missing\" is not supported by any configured account in this group",
"",
http.StatusNotFound,
)
require.Equal(t, "routing", phase)
require.True(t, isBusinessLimited)
require.Equal(t, "platform", errorOwner)
}
@@ -56,7 +56,7 @@ type responsesFailedEvent struct {
// 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
// 此时 caller 也无法回退到 JSON(HTTP 200 已固化),通常意味着连接已经损坏,
// 应当让请求处理函数 return,由上层关闭连接。
func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
func writeResponsesFailedSSE(c *gin.Context, errType, code, message string) bool {
flusher, ok := c.Writer.(http.Flusher)
if !ok {
return false
@@ -72,7 +72,7 @@ func writeResponsesFailedSSE(c *gin.Context, errType, message string) bool {
Status: "failed",
Output: []any{},
Error: responsesFailedError{
Code: mapResponsesErrorCode(errType),
Code: mapResponsesErrorCode(errType, code),
Message: message,
},
},
@@ -155,7 +155,10 @@ func requestModel(c *gin.Context) string {
// mapResponsesErrorCode 把内部 errType 映射为 Responses 协议常见的 error.code。
// 无明确映射时原样返回,保证至少可读。
func mapResponsesErrorCode(errType string) string {
func mapResponsesErrorCode(errType, code string) string {
if code != "" {
return code
}
switch errType {
case "rate_limit_error":
return "rate_limit_exceeded"
@@ -12,6 +12,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// Regression for the production incident on 2026-05-24 around 9:13 CST:
@@ -77,6 +78,60 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(t
assert.Equal(t, "Concurrency limit exceeded for user, please retry later", errObj["message"])
}
func TestOpenAIAdmissionError_SynchronousNonResponsesIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayQueueFullCode, "Too many pending requests, please retry later", false, false)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
}
func TestOpenAIAdmissionError_SynchronousResponsesIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", false, false)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
assert.Equal(t, gatewayConcurrencyLimitCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
}
func TestOpenAIAdmissionError_ResponsesFailedIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true, false)
_, errObj := parseResponsesFailedSSE(t, w.Body.String())
assert.Equal(t, gatewayConcurrencyLimitCode, errObj["code"])
}
func TestOpenAIAdmissionError_StreamingNonResponsesIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayQueueFullCode, "Too many pending requests, please retry later", true, false)
body := w.Body.String()
assert.True(t, strings.HasPrefix(body, "event: error\n"))
payload := body[strings.Index(body, "{"):]
assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
assert.Equal(t, gatewayQueueFullCode, gjson.Get(payload, "error.code").String())
}
func TestOpenAIUpstreamRateLimitWithoutLocalCodeRemainsUnchanged(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointChatCompletions)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareError(c, http.StatusTooManyRequests, "rate_limit_error", "upstream limited", false)
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
assert.False(t, gjson.GetBytes(w.Body.Bytes(), "error.code").Exists())
}
// 当 setOpsRequestContext 写过 model,合成事件应回填该字段(与 codebase 已有 makeResponsesCompletedEvent 对齐)。
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingIncludesModel(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
@@ -164,6 +219,41 @@ func TestGatewayHandleStreamingAwareError_ResponsesStreamingEmitsResponseFailed(
assert.Equal(t, "upstream gone", errObj["message"])
}
func TestGatewayAdmissionError_SynchronousIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointMessages)
h := &GatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayQueueFullCode, "Too many pending requests, please retry later", false)
assert.Equal(t, http.StatusTooManyRequests, w.Code)
assert.Equal(t, "error", gjson.GetBytes(w.Body.Bytes(), "type").String())
assert.Equal(t, "rate_limit_error", gjson.GetBytes(w.Body.Bytes(), "error.type").String())
assert.Equal(t, gatewayQueueFullCode, gjson.GetBytes(w.Body.Bytes(), "error.code").String())
}
func TestGatewayAdmissionError_MessagesStreamingIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointMessages)
h := &GatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayConcurrencyLimitCode, "Concurrency limit exceeded for account, please retry later", true)
body := w.Body.String()
assert.True(t, strings.HasPrefix(body, `data: {"type":"error"`))
payload := body[strings.Index(body, "{"):]
assert.Equal(t, "rate_limit_error", gjson.Get(payload, "error.type").String())
assert.Equal(t, gatewayConcurrencyLimitCode, gjson.Get(payload, "error.code").String())
}
func TestGatewayAdmissionError_BareResponsesFailedIncludesGatewayCode(t *testing.T) {
c, w := newGinContextForEndpoint(t, "/responses")
h := &GatewayHandler{}
h.handleStreamingAwareErrorWithCode(c, http.StatusTooManyRequests, "rate_limit_error",
gatewayQueueFullCode, "Too many pending requests, please retry later", true)
_, errObj := parseResponsesFailedSSE(t, w.Body.String())
assert.Equal(t, gatewayQueueFullCode, errObj["code"])
}
// Gateway handler: /v1/messages preserves the legacy data:{type:error,...} format
// (Anthropic spec accepts a type:"error" stream event).
func TestGatewayHandleStreamingAwareError_MessagesStreamingKeepsLegacy(t *testing.T) {
@@ -264,6 +354,7 @@ func TestMapResponsesErrorCode(t *testing.T) {
{"custom_thing", "custom_thing"},
}
for _, tc := range cases {
assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in), "in=%q", tc.in)
assert.Equal(t, tc.out, mapResponsesErrorCode(tc.in, ""), "in=%q", tc.in)
}
assert.Equal(t, gatewayQueueFullCode, mapResponsesErrorCode("rate_limit_error", gatewayQueueFullCode))
}
+1 -1
View File
@@ -250,7 +250,7 @@ var ProviderSet = wire.NewSet(
// Admin handlers
admin.NewDashboardHandler,
admin.NewUserHandler,
admin.NewGroupHandler,
admin.NewGroupHandlerWithConfig,
admin.ProvideAccountHandler,
admin.NewAnnouncementHandler,
admin.NewDataManagementHandler,
@@ -153,6 +153,7 @@ type modelDef struct {
// Antigravity 支持的 Claude 模型
var claudeModels = []modelDef{
{ID: "claude-fable-5-1", DisplayName: "Claude Fable 5.1", CreatedAt: "2026-09-01T00:00:00Z"},
{ID: "claude-fable-5", DisplayName: "Claude Fable 5", CreatedAt: "2026-06-09T00:00:00Z"},
{ID: "claude-opus-4-5-thinking", DisplayName: "Claude Opus 4.5 Thinking", CreatedAt: "2025-11-01T00:00:00Z"},
{ID: "claude-sonnet-4-5", DisplayName: "Claude Sonnet 4.5", CreatedAt: "2025-09-29T00:00:00Z"},
@@ -12,6 +12,7 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
}
requiredIDs := []string{
"claude-fable-5-1",
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-6-thinking",
+1 -1
View File
@@ -34,7 +34,7 @@ const (
AntigravityUserAgentVersionEnv = "ANTIGRAVITY_USER_AGENT_VERSION"
// DefaultUserAgentVersion 是未通过环境变量或后台设置覆盖时使用的默认版本号。
DefaultUserAgentVersion = "1.23.2"
DefaultUserAgentVersion = "2.9.1"
// 固定的 redirect_uri(用户需手动复制 code)
RedirectURI = "http://localhost:8085/callback"
@@ -693,7 +693,7 @@ func TestConstants_值正确(t *testing.T) {
if RedirectURI != "http://localhost:8085/callback" {
t.Errorf("RedirectURI 不匹配: got %s", RedirectURI)
}
if GetUserAgent() != "antigravity/1.23.2 windows/amd64" {
if GetUserAgent() != "antigravity/2.9.1 windows/amd64" {
t.Errorf("UserAgent 不匹配: got %s", GetUserAgent())
}
if SessionTTL != 30*time.Minute {
@@ -148,21 +148,20 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
}
// 针对 Gemini Reasoning 模型(如 gemini-3.1-pro-high等)过滤强制空 ToolConfig
isReasoning := IsGeminiReasoningModel(targetModel)
if !isReasoning || len(tools) > 0 {
// 总是设置 toolConfig,与官方客户端一致
innerRequest.ToolConfig = &GeminiToolConfig{
FunctionCallingConfig: &GeminiFunctionCallingConfig{
Mode: "VALIDATED",
},
}
// 内置工具(googleSearch)与函数调用混用时,上游要求显式开启
// includeServerSideToolInvocations,否则返回 400(issue #5709)。
// 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。
if hasMixedToolInvocations(tools) {
enabled := true
innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled
}
// toolConfig must always be present: upstream rejects requests without it,
// including reasoning models called without any tools.
// 总是设置 toolConfig,与官方客户端一致
innerRequest.ToolConfig = &GeminiToolConfig{
FunctionCallingConfig: &GeminiFunctionCallingConfig{
Mode: "VALIDATED",
},
}
// 内置工具(googleSearch)与函数调用混用时,上游要求显式开启
// includeServerSideToolInvocations,否则返回 400(issue #5709)。
// 与 raw 透传路的 enableMixedGeminiToolInvocations 注入保持同一语义。
if hasMixedToolInvocations(tools) {
enabled := true
innerRequest.ToolConfig.IncludeServerSideToolInvocations = &enabled
}
if systemInstruction != nil {
@@ -222,6 +221,7 @@ type modelInfo struct {
// 只有在此映射表中的模型才会注入身份提示词
// 注意:模型映射逻辑在网关层完成;这里仅用于按模型前缀判断是否注入身份提示词。
var modelInfoMap = map[string]modelInfo{
"claude-fable-5-1": {DisplayName: "Claude Fable 5.1", CanonicalID: "claude-fable-5-1"},
"claude-fable-5": {DisplayName: "Claude Fable 5", CanonicalID: "claude-fable-5"},
"claude-opus-4-8": {DisplayName: "Claude Opus 4.8", CanonicalID: "claude-opus-4-8"},
"claude-opus-4-7": {DisplayName: "Claude Opus 4.7", CanonicalID: "claude-opus-4-7"},
@@ -621,3 +621,38 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
require.NotContains(t, raw, "includeServerSideToolInvocations")
})
}
// TestToolConfigAlwaysPresent ensures toolConfig is always emitted, including for
// reasoning models without any tools: upstream rejects requests that omit it.
func TestToolConfigAlwaysPresent(t *testing.T) {
cases := []struct {
name string
model string
tools []ClaudeTool
}{
{name: "reasoning model without tools", model: "gemini-3.1-pro-high"},
{name: "reasoning model with tools", model: "gemini-3.1-pro-high", tools: []ClaudeTool{{
Name: "web_search",
Description: "Search the web",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{}},
}}},
{name: "non-reasoning model without tools", model: "gemini-3.1-pro"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
claudeReq := &ClaudeRequest{
Model: tc.model,
Messages: []ClaudeMessage{{Role: "user", Content: json.RawMessage(`"Hello"`)}},
Tools: tc.tools,
}
body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", tc.model, DefaultTransformOptions())
require.NoError(t, err)
var req V1InternalRequest
require.NoError(t, json.Unmarshal(body, &req))
require.NotNil(t, req.Request.ToolConfig, "toolConfig must be present")
require.NotNil(t, req.Request.ToolConfig.FunctionCallingConfig)
require.Equal(t, "VALIDATED", req.Request.ToolConfig.FunctionCallingConfig.Mode)
})
}
}
@@ -0,0 +1,40 @@
//go:build unit
package antigravity
import (
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// Gemini usageMetadata 只有 cachedContentTokenCount(缓存命中),没有缓存写入的 token 类别:
// 两条转换路径产出的 Claude usage 中 cache_creation_input_tokens 恒为 0,缓存命中计入
// cache_read_input_tokens 并从 input_tokens 中扣除。
func TestGeminiUsageMapping_NoCacheCreationTokens(t *testing.T) {
const geminiBody = `{"candidates":[{"content":{"parts":[{"text":"hi"}],"role":"model"},"finishReason":"STOP"}],` +
`"usageMetadata":{"promptTokenCount":100,"candidatesTokenCount":20,"cachedContentTokenCount":30,"thoughtsTokenCount":5}}`
t.Run("non-stream", func(t *testing.T) {
_, usage, err := TransformGeminiToClaude([]byte(geminiBody), "gemini-3.1-pro-preview")
require.NoError(t, err)
require.NotNil(t, usage)
require.Equal(t, 70, usage.InputTokens)
require.Equal(t, 25, usage.OutputTokens)
require.Equal(t, 30, usage.CacheReadInputTokens)
require.Zero(t, usage.CacheCreationInputTokens)
})
t.Run("stream", func(t *testing.T) {
p := NewStreamingProcessor("gemini-3.1-pro-preview")
out := p.ProcessLine(`data: {"response":` + geminiBody + `}`)
require.True(t, strings.Contains(string(out), `"message_start"`))
require.NotContains(t, string(out), `"cache_creation_input_tokens"`, "message_start 的 usage 不应携带缓存写入分项")
_, usage := p.Finish()
require.NotNil(t, usage)
require.Equal(t, 70, usage.InputTokens)
require.Equal(t, 30, usage.CacheReadInputTokens)
require.Zero(t, usage.CacheCreationInputTokens)
})
}
@@ -141,6 +141,36 @@ func EffectiveResponsesTools(req *ResponsesRequest) ([]ResponsesTool, error) {
}
tools = append(tools, item.Tools...)
}
// A completed client tool search can introduce tools for the remainder of
// the turn. Promote them before lowering to ChatCompletions, using the same
// dedupe, conflict, namespace, and custom-tool rules as the native adapter.
toolsRaw, err := json.Marshal(tools)
if err != nil {
return nil, fmt.Errorf("encode responses tools for discovery promotion: %w", err)
}
var rawTools, rawInput []any
if err := json.Unmarshal(toolsRaw, &rawTools); err != nil {
return nil, fmt.Errorf("decode responses tools for discovery promotion: %w", err)
}
if err := json.Unmarshal(inputRaw, &rawInput); err != nil {
return nil, fmt.Errorf("parse responses input for discovery promotion: %w", err)
}
promoted, err := promotedResponsesToolSearchDiscoveries(rawTools, rawInput)
if err != nil {
return nil, err
}
if len(promoted) > 0 {
promotedRaw, err := json.Marshal(promoted)
if err != nil {
return nil, fmt.Errorf("encode promoted responses tools: %w", err)
}
var discovered []ResponsesTool
if err := json.Unmarshal(promotedRaw, &discovered); err != nil {
return nil, fmt.Errorf("decode promoted responses tools: %w", err)
}
tools = append(tools, discovered...)
}
return tools, nil
}
@@ -456,6 +486,11 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
continue
case "function_call_output", "custom_tool_call_output", "tool_search_output":
outputRaw := bytesTrimSpace(item["output"])
if itemType == "tool_search_output" && (len(outputRaw) == 0 || string(outputRaw) == "null") {
// Newer clients return discoveries in tools[] without a separate
// output field. Keep that useful result in Chat tool history.
outputRaw = bytesTrimSpace(item["tools"])
}
callID := rawString(item["call_id"])
if callID == "" && invalidEmptyFunctionCallOutputs > 0 {
invalidEmptyFunctionCallOutputs--
@@ -597,6 +597,65 @@ func TestResponsesInputToChatMessages_ToolSearchCallHistory(t *testing.T) {
assert.JSONEq(t, `"{\"groups\":[\"gmail\"]}"`, string(messages[2].Content))
}
func TestResponsesToChatCompletionsRequest_PromotesCompletedToolSearchDiscoveries(t *testing.T) {
var req ResponsesRequest
require.NoError(t, json.Unmarshal([]byte(`{
"model":"gpt-5","tools":[
{"type":"tool_search"},
{"type":"function","name":"inspect","parameters":{"type":"object"}}
],"input":[
{"type":"tool_search_call","call_id":"search_1","arguments":{"query":"workspace"}},
{"type":"tool_search_output","call_id":"search_1","status":"completed","execution":"client","tools":[
{"type":"function","name":"inspect","parameters":{"type":"object"}},
{"type":"custom","name":"exec"},
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}
]}
]}`), &req))
effective, err := EffectiveResponsesTools(&req)
require.NoError(t, err)
require.Len(t, effective, 4, "the identical static discovery must be deduplicated")
assert.True(t, CustomToolNames(effective)["exec"])
namespaces := NamespaceToolNames(effective)
assert.Equal(t, NamespacedToolName{Namespace: "collaboration", Name: "spawn_agent"}, namespaces["collaboration__spawn_agent"])
chatReq, err := ResponsesToChatCompletionsRequest(&req)
require.NoError(t, err)
require.Len(t, chatReq.Tools, 4)
assert.Equal(t, []string{"tool_search", "inspect", "exec", "collaboration__spawn_agent"}, []string{
chatReq.Tools[0].Function.Name, chatReq.Tools[1].Function.Name,
chatReq.Tools[2].Function.Name, chatReq.Tools[3].Function.Name,
})
require.Len(t, chatReq.Messages, 2)
assert.Equal(t, "tool", chatReq.Messages[1].Role)
var discoveryPayload []map[string]any
var serialized string
require.NoError(t, json.Unmarshal(chatReq.Messages[1].Content, &serialized))
require.NoError(t, json.Unmarshal([]byte(serialized), &discoveryPayload))
require.Len(t, discoveryPayload, 3)
responses := ChatCompletionsResponseToResponses(&ChatCompletionsResponse{Choices: []ChatChoice{{
Message: ChatMessage{Role: "assistant", ToolCalls: []ChatToolCall{
{ID: "call_1", Type: "function", Function: ChatFunctionCall{Name: "collaboration__spawn_agent", Arguments: `{}`}},
{ID: "call_2", Type: "function", Function: ChatFunctionCall{Name: "exec", Arguments: `{"input":"pwd"}`}},
}},
}}}, req.Model, CustomToolNames(effective), FunctionToolNames(effective), HasToolSearchTool(effective), namespaces)
require.Len(t, responses.Output, 2)
assert.Equal(t, "function_call", responses.Output[0].Type)
assert.Equal(t, "spawn_agent", responses.Output[0].Name)
assert.Equal(t, "collaboration", responses.Output[0].Namespace)
assert.Equal(t, "custom_tool_call", responses.Output[1].Type)
assert.Equal(t, "exec", responses.Output[1].Name)
assert.Equal(t, "pwd", responses.Output[1].Input)
}
func TestEffectiveResponsesTools_RejectsDiscoveredConflict(t *testing.T) {
var req ResponsesRequest
require.NoError(t, json.Unmarshal([]byte(`{"tools":[{"type":"tool_search"},{"type":"function","name":"inspect","parameters":{"type":"object"}}],"input":[{"type":"tool_search_output","status":"completed","tools":[{"type":"function","name":"inspect","parameters":{"type":"string"}}]}]}`), &req))
_, err := EffectiveResponsesTools(&req)
require.ErrorContains(t, err, "conflicts")
}
func TestResponsesInputToChatMessages_NamespacedFunctionCallHistory(t *testing.T) {
input := json.RawMessage(`[
{"type":"function_call","call_id":"call_n","name":"send","namespace":"gmail","arguments":"{\"to\":\"a\"}"},
@@ -1621,6 +1621,108 @@ func TestBufferedResponseAccumulator_ToolCalls(t *testing.T) {
assert.Equal(t, `{"city":"NYC"}`, output[0].Arguments)
}
func TestResponsesEventToChatChunks_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-5-codex"
state.SentRole = true
chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{
Type: "function_call",
CallID: "call_lookup",
Name: "lookup_item",
Arguments: "",
},
}, state)
require.Len(t, chunks, 1)
chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 1,
CallID: "call_lookup",
Name: "lookup_item",
Arguments: `{"id":1}`,
}, state)
require.Len(t, chunks, 1)
require.Len(t, chunks[0].Choices[0].Delta.ToolCalls, 1)
assert.Equal(t, `{"id":1}`, chunks[0].Choices[0].Delta.ToolCalls[0].Function.Arguments)
}
func TestResponsesEventToChatChunks_FunctionArgumentsDoneDoesNotDuplicateDeltas(t *testing.T) {
state := NewResponsesEventToChatState()
state.Model = "gpt-5-codex"
state.SentRole = true
ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
}, state)
chunks := ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"id":1}`,
}, state)
require.Len(t, chunks, 1)
chunks = ResponsesEventToChatChunks(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"id":1}`,
}, state)
assert.Empty(t, chunks)
}
func TestBufferedResponseAccumulator_FunctionArgumentsDoneWithoutDeltas(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 1,
Arguments: `{"id":1}`,
})
resp := &ResponsesResponse{Output: []ResponsesOutput{{
Type: "function_call",
CallID: "call_lookup",
Name: "lookup_item",
Arguments: "",
}}}
acc.SupplementResponseOutput(resp)
require.Len(t, resp.Output, 1)
assert.Equal(t, `{"id":1}`, resp.Output[0].Arguments)
}
func TestBufferedResponseAccumulator_FunctionArgumentsDoneReplacesDeltas(t *testing.T) {
acc := NewBufferedResponseAccumulator()
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_lookup", Name: "lookup_item"},
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.delta",
OutputIndex: 0,
Delta: `{"id":1}`,
})
acc.ProcessEvent(&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"id":1}`,
})
output := acc.BuildOutput()
require.Len(t, output, 1)
assert.Equal(t, `{"id":1}`, output[0].Arguments)
}
func TestBufferedResponseAccumulator_Reasoning(t *testing.T) {
acc := NewBufferedResponseAccumulator()
@@ -126,6 +126,7 @@ type ResponsesEventToChatState struct {
Finalized bool // true after finish chunk has been emitted
NextToolCallIndex int // next sequential tool_call index to assign
OutputIndexToToolIndex map[int]int // Responses output_index → Chat tool_calls index
OutputIndexToArguments map[int]string
IncludeUsage bool
Usage *ChatUsage
}
@@ -136,6 +137,7 @@ func NewResponsesEventToChatState() *ResponsesEventToChatState {
ID: generateChatCmplID(),
Created: time.Now().Unix(),
OutputIndexToToolIndex: make(map[int]int),
OutputIndexToArguments: make(map[int]string),
}
}
@@ -154,6 +156,8 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent
// 均按 OutputIndex 累加到对应工具调用。
"response.custom_tool_call_input.delta":
return resToChatHandleFuncArgsDelta(evt, state)
case "response.function_call_arguments.done", "response.custom_tool_call_input.done":
return resToChatHandleFuncArgsDone(evt, state)
case "response.reasoning_summary_text.delta",
// 原始推理文本增量(真实 Codex 客户端消费的 reasoning_text.delta),
// 与 reasoning summary 一样映射为 reasoning_content。
@@ -277,6 +281,7 @@ func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
if !ok {
return nil
}
state.OutputIndexToArguments[evt.OutputIndex] += evt.Delta
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
ToolCalls: []ChatToolCall{{
@@ -288,6 +293,33 @@ func resToChatHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEve
})}
}
func resToChatHandleFuncArgsDone(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
idx, ok := state.OutputIndexToToolIndex[evt.OutputIndex]
if !ok {
return nil
}
completed := evt.Arguments
if evt.Type == "response.custom_tool_call_input.done" {
completed = evt.Input
}
current := state.OutputIndexToArguments[evt.OutputIndex]
if completed == "" || !strings.HasPrefix(completed, current) || completed == current {
return nil
}
remainder := completed[len(current):]
state.OutputIndexToArguments[evt.OutputIndex] = completed
return []ChatCompletionsChunk{makeChatDeltaChunk(state, ChatDelta{
ToolCalls: []ChatToolCall{{
Index: &idx,
Function: ChatFunctionCall{
Arguments: remainder,
},
}},
})}
}
func resToChatHandleReasoningDelta(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk {
if evt.Delta == "" {
return nil
@@ -453,9 +485,10 @@ func generateChatCmplID() string {
// ---------------------------------------------------------------------------
type bufferedFuncCall struct {
CallID string
Name string
Args strings.Builder
OutputIndex int
CallID string
Name string
Args strings.Builder
}
// BufferedResponseAccumulator collects content from Responses SSE delta events
@@ -489,8 +522,9 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent)
idx := len(a.funcCalls)
a.outputIndexToFuncIdx[event.OutputIndex] = idx
a.funcCalls = append(a.funcCalls, bufferedFuncCall{
CallID: event.Item.CallID,
Name: event.Item.Name,
OutputIndex: event.OutputIndex,
CallID: event.Item.CallID,
Name: event.Item.Name,
})
}
case "response.function_call_arguments.delta", "response.custom_tool_call_input.delta":
@@ -499,6 +533,17 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent)
_, _ = a.funcCalls[idx].Args.WriteString(event.Delta)
}
}
case "response.function_call_arguments.done", "response.custom_tool_call_input.done":
completed := event.Arguments
if event.Type == "response.custom_tool_call_input.done" {
completed = event.Input
}
if completed != "" {
if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok {
a.funcCalls[idx].Args.Reset()
_, _ = a.funcCalls[idx].Args.WriteString(completed)
}
}
case "response.reasoning_summary_text.delta", "response.reasoning_text.delta":
if event.Delta != "" {
_, _ = a.reasoning.WriteString(event.Delta)
@@ -550,15 +595,35 @@ func (a *BufferedResponseAccumulator) BuildOutput() []ResponsesOutput {
return out
}
// SupplementResponseOutput fills resp.Output from accumulated delta content
// when the terminal event delivered an empty output array. If resp.Output is
// already populated, this is a no-op (preserves backward compatibility).
// SupplementResponseOutput fills resp.Output from accumulated stream content
// when the terminal event delivered an empty output array. It also fills empty
// function-call arguments from authoritative argument-done events.
func (a *BufferedResponseAccumulator) SupplementResponseOutput(resp *ResponsesResponse) {
if resp == nil || len(resp.Output) > 0 {
if resp == nil {
return
}
if !a.HasContent() {
if len(resp.Output) == 0 {
if a.HasContent() {
resp.Output = a.BuildOutput()
}
return
}
resp.Output = a.BuildOutput()
for outputIndex := range resp.Output {
item := &resp.Output[outputIndex]
if item.Type != "function_call" || item.Arguments != "" {
continue
}
for funcIndex := range a.funcCalls {
call := &a.funcCalls[funcIndex]
matchesCallID := item.CallID != "" && item.CallID == call.CallID
if !matchesCallID && call.OutputIndex != outputIndex {
continue
}
if call.Args.Len() > 0 {
item.Arguments = call.Args.String()
}
break
}
}
}
@@ -27,6 +27,21 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) {
if !ok || len(input) == 0 {
return false, nil
}
promoted, err := promotedResponsesToolSearchDiscoveries(tools, input)
if err != nil {
return false, err
}
if len(promoted) == 0 {
return false, nil
}
req["tools"] = append(tools, promoted...)
return true, nil
}
func promotedResponsesToolSearchDiscoveries(tools, input []any) ([]any, error) {
if len(tools) == 0 || !hasResponsesToolSearchDeclaration(tools) || len(input) == 0 {
return nil, nil
}
known := make(map[string]responsesDiscoveredToolIdentity)
for _, raw := range tools {
@@ -60,7 +75,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) {
}
appendTool, err := admitResponsesDiscoveredTool(known, identity.name, identity)
if err != nil {
return false, err
return nil, err
}
if appendTool {
promoted = append(promoted, copy)
@@ -74,7 +89,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) {
for _, candidate := range identities {
appendTool, err := admitResponsesDiscoveredTool(known, candidate.flat, candidate.identity)
if err != nil {
return false, err
return nil, err
}
if appendTool {
children = append(children, candidate.child)
@@ -88,11 +103,7 @@ func promoteResponsesToolSearchDiscoveries(req map[string]any) (bool, error) {
}
}
}
if len(promoted) == 0 {
return false, nil
}
req["tools"] = append(tools, promoted...)
return true, nil
return promoted, nil
}
func hasResponsesToolSearchDeclaration(tools []any) bool {
@@ -0,0 +1,79 @@
package claude
import (
"log/slog"
"os"
"strings"
"golang.org/x/mod/semver"
)
// CLIVersionEnv 是 CLICurrentVersion 的可选运维覆盖。
//
// 存在的理由:Anthropic 会对新模型设客户端版本下限(例如 claude-fable-5-1 要求
// claude-cli >= 2.1.251),命中时上游直接返回
// `Claude Code X.Y.Z does not support this model; version A.B.C or newer is required`。
// 在没有本开关之前,这类模型必须等 sub2api 发一个新版本才能使用,
// 而改动本身只是一个常量。xai 包的 XAI_GROK_CLI_VERSION 已经是同样的做法。
const CLIVersionEnv = "SUB2API_CLAUDE_CLI_VERSION"
// resolvedCLIVersion 在包初始化时解析一次。
//
// ⚠️ 故意不做成"每次调用读一次环境变量":伪装身份必须在一个进程的生命周期内保持恒定。
// User-Agent 头与请求体 billing attribution 块里的 cc_version 由不同代码路径写入,
// 若两次读到不同的值(例如进程运行中有人改了环境变量),同一个请求就会自相矛盾,
// 被上游判为非正版客户端。
var resolvedCLIVersion = resolveCLIVersion(os.Getenv(CLIVersionEnv))
// CLIVersion 返回对外伪装的 Claude Code CLI 版本号(三段 semver)。
//
// 所有需要该版本号的位置都必须走本函数,不要直接引用 CLICurrentVersion——
// 后者只是"没有覆盖时的内置基线"。
func CLIVersion() string {
return resolvedCLIVersion
}
// IsSupportedCLIVersion 判断运维给的覆盖值是否可用。
//
// 判据有两条,缺一不可:
// 1. 严格三段纯数字("2.1.251")。带 -local / -dev / +build 等后缀的版本号会被
// identity_service 的 fingerprintUserAgentPattern 拒绝,一旦漏进去,该账号的
// 持久指纹会被写成一个不存在的客户端版本,此后所有上游请求都声称这个版本,
// 被判非正版并持续 429——而系统内没有指纹重置入口。
// 2. 不低于内置基线 CLICurrentVersion。向下覆盖没有任何使用场景,
// 却会让 identity_service 的主版本超前检查基准跟着一起降。
func IsSupportedCLIVersion(version string) bool {
version = strings.TrimSpace(version)
if version == "" {
return false
}
// semver 允许 "v1.2" 与预发布/构建元数据,这里都不接受:
// Canonical 相等可排除省略段,再显式排除预发布与构建元数据。
canonical := "v" + version
if !semver.IsValid(canonical) || semver.Canonical(canonical) != canonical {
return false
}
if semver.Prerelease(canonical) != "" || semver.Build(canonical) != "" {
return false
}
return semver.Compare(canonical, "v"+CLICurrentVersion) >= 0
}
// resolveCLIVersion 把环境变量的原始值解析成可用的版本号。
// 空值静默回落(未配置是正常状态);非空但不合法则回落并告警——
// 静默忽略一个显式配置会让运维以为已经生效。
func resolveCLIVersion(raw string) string {
version := strings.TrimSpace(raw)
if version == "" {
return CLICurrentVersion
}
if !IsSupportedCLIVersion(version) {
slog.Warn("ignoring invalid Claude CLI version override; falling back to the built-in pin",
"env", CLIVersionEnv,
"value", version,
"builtin", CLICurrentVersion,
"requirement", "strict three-part semver, not older than the built-in pin")
return CLICurrentVersion
}
return version
}
@@ -0,0 +1,73 @@
package claude
import "testing"
func TestIsSupportedCLIVersion(t *testing.T) {
cases := []struct {
name string
version string
want bool
}{
{"内置基线本身", CLICurrentVersion, true},
{"高于基线的补丁位", "2.1.259", true},
{"高于基线的次版本", "2.2.0", true},
{"高于基线的主版本", "3.0.0", true},
{"低于基线", "2.1.219", false},
{"远低于基线", "1.0.0", false},
{"空值", "", false},
{"只有两段", "2.2", false},
{"带 v 前缀", "v2.2.0", false},
{"预发布后缀", "2.2.0-local", false},
{"构建元数据", "2.2.0+build1", false},
{"哨兵版本带后缀", "999.0.0-local", false},
{"纯数字哨兵仍然合法(形态没问题,运维自负)", "999.0.0", true},
{"非数字", "abc", false},
{"多余的段", "2.2.0.1", false},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := IsSupportedCLIVersion(tc.version); got != tc.want {
t.Fatalf("IsSupportedCLIVersion(%q) = %v, want %v", tc.version, got, tc.want)
}
})
}
}
func TestResolveCLIVersion(t *testing.T) {
cases := []struct {
name string
raw string
want string
}{
{"未配置时用内置基线", "", CLICurrentVersion},
{"只有空白等同未配置", " ", CLICurrentVersion},
{"合法覆盖生效", "2.1.259", "2.1.259"},
{"两侧空白被裁掉", " 2.1.259 ", "2.1.259"},
{"非法值回落基线", "not-a-version", CLICurrentVersion},
{"向下覆盖被拒", "2.0.0", CLICurrentVersion},
{"预发布后缀被拒(会毒化账号指纹)", "2.2.0-local", CLICurrentVersion},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := resolveCLIVersion(tc.raw); got != tc.want {
t.Fatalf("resolveCLIVersion(%q) = %q, want %q", tc.raw, got, tc.want)
}
})
}
}
// 伪装身份的自洽性:User-Agent 头里的版本号必须与 CLIVersion() 一致。
// 两者由不同代码路径写入同一个请求,不一致会被上游判为非正版客户端。
func TestDefaultHeadersUserAgentMatchesCLIVersion(t *testing.T) {
want := "claude-cli/" + CLIVersion() + " (external, cli)"
if got := DefaultHeaders["User-Agent"]; got != want {
t.Fatalf("DefaultHeaders[User-Agent] = %q, want %q", got, want)
}
}
// 没有配置覆盖时,CLIVersion() 必须等于内置基线——保证本 PR 对既有部署零行为变化。
func TestCLIVersionDefaultsToBuiltinPin(t *testing.T) {
if got := CLIVersion(); got != CLICurrentVersion {
t.Fatalf("CLIVersion() = %q, want built-in pin %q (测试进程未设置 %s)", got, CLICurrentVersion, CLIVersionEnv)
}
}
+30 -8
View File
@@ -20,11 +20,23 @@ const (
BetaFastMode = "fast-mode-2026-02-01"
// 新增(对齐官方 CLI 2.1.9x 以来的流量)
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
BetaEffort = "effort-2025-11-24"
BetaRedactThinking = "redact-thinking-2026-02-12"
BetaContextManagement = "context-management-2025-06-27"
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
BetaPromptCachingScope = "prompt-caching-scope-2026-01-05"
BetaEffort = "effort-2025-11-24"
BetaRedactThinking = "redact-thinking-2026-02-12"
BetaContextManagement = "context-management-2025-06-27"
BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01"
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
// server-side refusal fallback beta 字段族(beta Messages API 专有)。
// 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks /
// body.fallback_credit_token,上游仅在 anthropic-beta 携带对应 token 时接受;
// 缺 token 时 Pydantic 拒收:"fallbacks: Extra inputs are not permitted"。
// 仅用于 sanitize 的条件判断(strip-or-keep),禁止加入
// FullClaudeCodeMimicryBetas / DefaultBetaHeader / APIKeyBetaHeader /
// Bedrock 白名单:server-side fallback 会换模型、改计费,不能默认打开。
BetaServerSideFallback = "server-side-fallback-2026-07-01"
BetaFallbackCredit = "fallback-credit-2026-07-01"
BetaFallbackCreditLegacy = "fallback-credit-2026-06-01"
)
// DroppedBetas 是转发时需要从 anthropic-beta header 中移除的 beta token 列表。
@@ -63,10 +75,13 @@ const APIKeyHaikuBetaHeader = BetaInterleavedThinking
// 客户端缺省时统一使用 5m",这样既不浪费 1h 缓存额度,也保留客户端自定义能力。
const DefaultCacheControlTTL = "5m"
// CLICurrentVersion 是 sub2api 当前对外伪装的 Claude Code CLI 版本号(三段 semver)。
// CLICurrentVersion 是内置的 Claude Code CLI 伪装版本号基线(三段 semver)。
// 用于 billing attribution block 中的 cc_version=X.Y.Z.{fp} 前缀以及 fingerprint 计算。
// 必须与 DefaultHeaders["User-Agent"] 中的版本号严格一致;不一致会被 Anthropic 判第三方。
const CLICurrentVersion = "2.1.220"
//
// ⚠️ 读取实际生效的版本号请用 CLIVersion(),它会叠加 SUB2API_CLAUDE_CLI_VERSION 覆盖。
// 直接引用本常量只在"表达内置基线"时才正确(例如覆盖值的下限校验)。
const CLICurrentVersion = "2.1.258"
// FullClaudeCodeMimicryBetas 返回最"像"真实 Claude Code CLI 的完整 beta 列表,
// 用于 OAuth 账号伪装成 Claude Code 时使用。
@@ -85,6 +100,7 @@ func FullClaudeCodeMimicryBetas() []string {
BetaPromptCachingScope,
BetaEffort,
BetaContextManagement,
BetaThinkingBindingControls,
BetaExtendedCacheTTL,
}
}
@@ -94,7 +110,7 @@ var DefaultHeaders = map[string]string{
// Keep these in sync with recent Claude CLI traffic to reduce the chance
// that Claude Code-scoped OAuth credentials are rejected as "non-CLI" usage.
// 版本参考:对齐 Parrot (src/transform/cc_mimicry.py:49) 的 CLI_USER_AGENT。
"User-Agent": "claude-cli/" + CLICurrentVersion + " (external, cli)",
"User-Agent": "claude-cli/" + CLIVersion() + " (external, cli)",
"X-Stainless-Lang": "js",
"X-Stainless-Package-Version": "0.94.0",
"X-Stainless-OS": "Linux",
@@ -117,6 +133,12 @@ type Model struct {
// DefaultModels Claude Code 客户端支持的默认模型列表
var DefaultModels = []Model{
{
ID: "claude-fable-5-1",
Type: "model",
DisplayName: "Claude Fable 5.1",
CreatedAt: "2026-09-01T00:00:00Z",
},
{
ID: "claude-fable-5",
Type: "model",
@@ -0,0 +1,70 @@
package claude
import (
"strconv"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
// fable51MinCLIVersion 是上游对 claude-fable-5-1 的客户端版本闸门下限。
// 低于该版本时上游直接 400:
//
// claude_code_version_too_old
// "Claude Code <ver> does not support this model; version 2.1.251 or newer is required."
const fable51MinCLIVersion = "2.1.251"
// TestCLICurrentVersionMatchesDefaultUserAgent 锁定"常量与 UA 同源"这条约束。
// 两者不一致会被 Anthropic 判为第三方客户端,且只改一处是历史上的常见疏漏,
// 所以这里从 UA 里反解版本号来比对,而不是并列两个字面量。
func TestCLICurrentVersionMatchesDefaultUserAgent(t *testing.T) {
t.Parallel()
ua := DefaultHeaders["User-Agent"]
const prefix = "claude-cli/"
require.True(t, strings.HasPrefix(ua, prefix), "unexpected User-Agent: %q", ua)
rest := strings.TrimPrefix(ua, prefix)
version := rest
if idx := strings.IndexByte(rest, ' '); idx >= 0 {
version = rest[:idx]
}
require.Equal(t, CLICurrentVersion, version,
"DefaultHeaders User-Agent version must match CLICurrentVersion")
}
// TestCLICurrentVersionSatisfiesFable51Gate 锁定伪装版本不低于 claude-fable-5-1
// 的闸门下限。DefaultModels 里登记了该模型却发着更旧的版本号,等于模型清单可见
// 但一调就 400。
func TestCLICurrentVersionSatisfiesFable51Gate(t *testing.T) {
t.Parallel()
require.GreaterOrEqual(t, compareSemver(t, CLICurrentVersion, fable51MinCLIVersion), 0,
"CLICurrentVersion %s is below the claude-fable-5-1 gate %s",
CLICurrentVersion, fable51MinCLIVersion)
}
// compareSemver 比较两个三段 semver,返回 -1 / 0 / 1。
func compareSemver(t *testing.T, a, b string) int {
t.Helper()
as, bs := strings.Split(a, "."), strings.Split(b, ".")
require.Len(t, as, 3, "not a three-part semver: %q", a)
require.Len(t, bs, 3, "not a three-part semver: %q", b)
for i := range as {
ai, err := strconv.Atoi(as[i])
require.NoError(t, err, "non-numeric segment in %q", a)
bi, err := strconv.Atoi(bs[i])
require.NoError(t, err, "non-numeric segment in %q", b)
switch {
case ai > bi:
return 1
case ai < bi:
return -1
}
}
return 0
}
@@ -0,0 +1,20 @@
package claude
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestDefaultModelsContainsClaudeFable51(t *testing.T) {
t.Parallel()
for _, model := range DefaultModels {
if model.ID == "claude-fable-5-1" {
require.Equal(t, "Claude Fable 5.1", model.DisplayName)
require.Equal(t, "2026-09-01T00:00:00Z", model.CreatedAt)
return
}
}
t.Fatal("claude-fable-5-1 missing from DefaultModels")
}
+41
View File
@@ -22,13 +22,54 @@ const (
maxDecompressedBodySize = 64 << 20
)
// PrereadBody 回填已读取完成的请求体:作为 io.ReadCloser 可被再次顺序消费
// (multipart 流式解析),同时暴露 Bytes() 让 ReadRequestBodyWithPrealloc
// 直接返回原始切片,避免二次分配与复制。
//
// 注意:ReadRequestBodyWithPrealloc 对 PrereadBody 的快速路径不检查内部
// reader 是否已被(部分)消费——包装的字节完整且不可变,即使 reader 已被
// 流式消费过,Bytes() 也始终返回完整请求体。
type PrereadBody struct {
body []byte
reader *bytes.Reader
}
// NewPrereadBody 包装一段已读取的请求体。
func NewPrereadBody(body []byte) *PrereadBody {
return &PrereadBody{body: body, reader: bytes.NewReader(body)}
}
// Read 实现 io.Reader(转发给内部 bytes.Reader)。
func (p *PrereadBody) Read(b []byte) (int, error) {
if p == nil {
return 0, io.EOF
}
return p.reader.Read(b)
}
// Close 实现 io.Closer;请求体已在内存中,无需释放资源。
func (p *PrereadBody) Close() error { return nil }
// Bytes 返回完整的原始请求体切片。
func (p *PrereadBody) Bytes() []byte {
if p == nil {
return nil
}
return p.body
}
// ReadRequestBodyWithPrealloc reads request body with preallocated buffer based
// on content length, transparently decoding any Content-Encoding the upstream
// client used to compress the body (zstd, gzip, deflate).
// 已由 PrereadBody 回填的请求体直接返回其完整切片(零拷贝),不检查内部
// reader 是否已被消费——见 PrereadBody 的文档说明。
func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) {
if req == nil || req.Body == nil {
return nil, nil
}
if preread, ok := req.Body.(*PrereadBody); ok {
return preread.Bytes(), nil
}
capHint := requestBodyReadInitCap
if req.ContentLength > 0 {
@@ -0,0 +1,97 @@
package httputil
import (
"bytes"
"io"
"net/http"
"strings"
"testing"
)
func TestPrereadBodyRoundtrip(t *testing.T) {
body := []byte(`{"model":"gpt-5.4"}`)
preread := NewPrereadBody(body)
got, err := io.ReadAll(preread)
if err != nil {
t.Fatalf("read preread body: %v", err)
}
if !bytes.Equal(got, body) {
t.Fatalf("body mismatch: got %q want %q", got, body)
}
if err := preread.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if !bytes.Equal(preread.Bytes(), body) {
t.Fatalf("Bytes() mismatch after close: %q", preread.Bytes())
}
}
func TestPrereadBodyReadableTwiceFromFreshWrapper(t *testing.T) {
// ResetRequestBody 每次都回填新的 PrereadBody;同一实例只被顺序消费一次。
body := []byte("payload")
first := NewPrereadBody(body)
got1, _ := io.ReadAll(first)
if !bytes.Equal(got1, body) {
t.Fatalf("first read mismatch: %q", got1)
}
second := NewPrereadBody(first.Bytes())
got2, _ := io.ReadAll(second)
if !bytes.Equal(got2, body) {
t.Fatalf("second read mismatch: %q", got2)
}
}
func TestReadRequestBodyWithPreallocReturnsPrereadBodySliceWithoutCopy(t *testing.T) {
body := []byte(`{"model":"claude-sonnet-4.5"}`)
req, err := http.NewRequest(http.MethodPost, "/v1/messages", NewPrereadBody(body))
if err != nil {
t.Fatalf("new request: %v", err)
}
req.Header.Set("Content-Length", "0") // 故意与实际不符,验证不依赖 ContentLength
got, err := ReadRequestBodyWithPrealloc(req)
if err != nil {
t.Fatalf("ReadRequestBodyWithPrealloc: %v", err)
}
if &got[0] != &body[0] {
t.Fatalf("expected zero-copy return of the same slice")
}
if !bytes.Equal(got, body) {
t.Fatalf("body mismatch: got %q want %q", got, body)
}
}
func TestReadRequestBodyWithPreallocHandlesNilPrereadBody(t *testing.T) {
req, err := http.NewRequest(http.MethodPost, "/v1/messages", nil)
if err != nil {
t.Fatalf("new request: %v", err)
}
got, err := ReadRequestBodyWithPrealloc(req)
if err != nil || got != nil {
t.Fatalf("expected nil body, got (%#v, %v)", got, err)
}
}
func TestPrereadBodyPartialConsumeThenPreallocReturnsFullBody(t *testing.T) {
body := []byte(strings.Repeat("x", 64))
preread := NewPrereadBody(body)
head := make([]byte, 16)
if _, err := io.ReadFull(preread, head); err != nil {
t.Fatalf("partial read: %v", err)
}
req, err := http.NewRequest(http.MethodPost, "/v1/responses", preread)
if err != nil {
t.Fatalf("new request: %v", err)
}
got, err := ReadRequestBodyWithPrealloc(req)
if err != nil {
t.Fatalf("ReadRequestBodyWithPrealloc: %v", err)
}
if !bytes.Equal(got, body) {
t.Fatalf("expected full body after partial consume, got %d bytes", len(got))
}
}

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