Compare commits

...
Author SHA1 Message Date
Wesley Liddick 325213ae66 Revert "fix(batch-image): scope access cache to the current user" 2026-09-15 08:59:52 +08:00
Wesley Liddick 0a215c9318 Merge pull request #7110 from ouchihao/codex/bug-batch-image-access-session
fix(batch-image): scope access cache to the current user
2026-09-15 08:59:23 +08:00
Wesley Liddick 2061508ad8 Merge pull request #7072 from Brisbanehuang/codex/fix-scheduler-metrics
fix(scheduler): 修复选号耗时返回值和粘性命中率重复计数
2026-09-15 08:58:58 +08:00
Wesley Liddick fb90414c05 Merge pull request #7126 from xO-ox-ai/codex/fix-responses-lite-namespace
fix(openai): 保留 Responses Lite namespace 工具调用
2026-09-15 08:58:25 +08:00
Wesley Liddick 9fade3e8c2 Merge pull request #6769 from alfadb/feature/ollama-rate-limit-reset
feat(ollama): 根据用量窗口异步更新限流恢复时间
2026-09-15 08:57:43 +08:00
Wesley Liddick 1920a7d687 Merge pull request #7091 from SavitarC/feat/subscription-bulk-actions
feat: 支持订阅管理批量操作
2026-09-15 08:57:27 +08:00
Wesley Liddick a444551a6d Merge pull request #6654 from ouchihao/codex/issue-2229-register-confirm-password
feat(auth): require password confirmation during registration
2026-09-15 08:56:56 +08:00
Wesley Liddick 0bba06d64a Merge pull request #6691 from yan9651688/feat/api-key-bulk-edit
feat(keys): add bulk editing to API key management
2026-09-15 08:56:07 +08:00
Wesley Liddick 150c52b8de Merge pull request #6689 from tunsuy/fix/antigravity-codex-web-search-mixed-tools
fix(antigravity): drop mixed web_search when client tools present
2026-09-15 08:54:40 +08:00
Wesley Liddick 3e4b6a09b2 Merge pull request #6925 from reed-yang/fix/recover-responses-text
fix(apicompat): recover Responses text from done and terminal events
2026-09-15 08:54:21 +08:00
Wesley Liddick dd2e6b3680 Merge pull request #7076 from Shen-An/fix/antigravity-refresh-warning
fix(admin): surface partial Antigravity refresh warnings
2026-09-15 08:54:06 +08:00
Wesley Liddick d4439129a7 Merge pull request #7094 from liuyuzhou666/fix/responses-chat-developer-role-mapping
fix(apicompat): 修复 Responses 降级 Chat Completions 时的 system 消息顺序问题
2026-09-15 08:53:50 +08:00
Wesley Liddick 1e1d15cca5 Merge pull request #7082 from ggllzz04/fix/antigravity-token-cache-isolation
fix(antigravity): isolate OAuth token cache key by account ID to prevent 429 collision
2026-09-15 08:53:32 +08:00
Wesley Liddick f7e959ae5f Merge pull request #7111 from ouchihao/codex/bug-proxy-clear-credentials
fix(proxy): allow explicitly clearing stored credentials
2026-09-15 08:52:58 +08:00
Wesley Liddick 008391f699 Merge pull request #7085 from r266-tech/codex/sub2api-f352188cb784
fix(openai): strip internal metadata from OAuth input items
2026-09-15 08:52:30 +08:00
Wesley Liddick a4c517917b Merge pull request #7073 from Brisbanehuang/codex/fix-batch-image-priority
fix(batch-image): 修正批量生图账号优先级排序方向
2026-09-15 08:52:19 +08:00
Wesley Liddick e9169901d2 Merge pull request #7112 from ouchihao/codex/bug-monitor-refresh-interval
fix(monitor): honor the selected auto-refresh interval
2026-09-15 08:52:00 +08:00
xO-ox-ai 13c1d4cde2 test(openai): 覆盖 Lite 命名空间工具回传 2026-09-14 11:46:41 +08:00
xO-ox-ai 4bd7132ba9 fix(openai): 保留 Responses Lite namespace 调用 2026-09-14 11:35:03 +08:00
chihao.ou 28a4ea914d fix(proxy): allow explicitly clearing stored credentials 2026-09-13 17:23:24 +08:00
chihao.ou 5ecfaa4837 fix(batch-image): scope access cache to the current user 2026-09-13 17:22:56 +08:00
chihao.ou 06af9f871c fix(monitor): honor the selected auto-refresh interval 2026-09-13 17:20:56 +08:00
liuyuzhou666 52558381c6 fix(apicompat): merge leading system messages and downgrade mid-conversation system from Responses bridge
The Responses to Chat Completions bridge mapped both the instructions field and role developer items to system messages. Codex always sends both, and also injects developer notices mid-conversation when switching models, so the forwarded request contained consecutive leading system messages and mid-conversation system messages. Strict Qwen-family upstreams reject this with HTTP 400 "System message must be at the beginning."

Leading system/developer messages are now merged into a single leading system message, and later system/developer messages are downgraded to user so the content is preserved without violating upstream role ordering.
2026-09-13 14:08:57 +08:00
SavitarC e3cce574d7 fix: serialize subscription extensions 2026-09-13 12:33:35 +08:00
SavitarC 3d6c207723 feat: 支持订阅管理批量操作 2026-09-13 11:49:03 +08:00
alfadb a1d5968b24 feat(ollama): async rate-limit reset for Ollama Cloud usage windows
Real Ollama Cloud 429s are driven by ollama.com usage windows, not by
response headers that OpenAI/Anthropic/CN rate-limit parsing understands.
Handle them separately: apply a never-shortening temporary cooldown first,
then schedule an async probe that learns the true usage-window reset point
with a controlled, coalesced per-api_key refresh.

- ratelimit_service: route real Ollama Cloud usage accounts to a dedicated
  429 path before the CN-provider branch.
- ollama_cloud_usage: add a background probe coordinator (started in Start,
  waited by Stop) that fans out model-429 probes to one controlled refresh
  per api_key group, reusing the recent group snapshot for coalesced members.
- repository/account_repo: add SetRateLimitedIfUnchanged, a write-back CAS
  counterpart to ClearRateLimitIfObserved, so an async reset never overwrites
  a newer 429, an admin clear, a re-armed generation, or a key/state change
  observed by another writer between read and write.
- wire: inject OllamaCloudUsageService into RateLimitService.

Tests: unit tests for service and repository pass; targeted race tests pass;
build and vet clean; git diff --check clean. The CAS integration test compiles
but is not run (no Docker available).
2026-09-13 10:41:47 +08:00
r266-tech e5272c130c test(openai): check input type before metadata assertion 2026-09-13 07:13:31 +08:00
r266-tech 196c15b5cd fix(openai): strip internal metadata from OAuth input items 2026-09-13 07:00:50 +08:00
郭兰鑫 62635532a6 fix(antigravity): explicitly invalidate legacy project-based token cache 2026-09-13 03:45:12 +08:00
郭兰鑫 bc8da78150 fix(antigravity): isolate OAuth token cache key by account ID to prevent collision 2026-09-13 03:40:47 +08:00
Brisbanehuang d2c2b46c9e fix(batch-image): 修正批量生图账号优先级排序方向
按较小 priority 优先选择账号,保留同优先级 ID 排序及现有资格过滤。补充选择回归,并将定价夹具对齐实际选中的账号,不改变计费公式。
2026-09-12 09:06:33 -04:00
Brisbanehuang 7e152e4279 fix(scheduler): 修复选号耗时返回值和粘性命中率重复计数
使用命名返回值传回 defer 中记录的选号耗时;按每次选号累计任意粘性命中,保留两类独立计数。增加成功、失败及双粘性回归,不改变调度策略。
2026-09-12 09:03:52 -04:00
Wesley Liddick bdb42e22f8 Merge pull request #7064 from xlplbo/codex/openai-ws-v2-pool-capacity
fix(openai-ws): 对齐新版 ctx_pool 连接容量计算
2026-09-12 20:13:03 +08:00
Wesley Liddick 9383e0b154 Merge pull request #7057 from Sjeary/codex/issue-7042-max-context-window
fix(codex): 保留本地模型目录的最大上下文窗口
2026-09-12 20:11:40 +08:00
Wesley Liddick c7ed614240 Merge pull request #7001 from SavitarC/feat/ops-token-stats-all-platforms
feat(ops): 扩展 Token 请求统计至所有平台
2026-09-12 19:34:45 +08:00
Wesley Liddick a59f0c7fac Merge pull request #5833 from xuhaihan/fix/openai-fast-missing-tier
fix(openai): force priority when fast tier is omitted
2026-09-12 19:31:21 +08:00
Wesley Liddick 0a378f3432 Merge pull request #7026 from ouchihao/codex/bug-key-quota-reset-state
fix(keys): sync status after resetting quota
2026-09-12 19:30:40 +08:00
Wesley Liddick f2b51e3b24 Merge pull request #7023 from ouchihao/codex/bug-profile-api-errors
fix(profile): show normalized API error messages
2026-09-12 19:30:29 +08:00
Wesley Liddick 329641a86f Merge pull request #7025 from ouchihao/codex/bug-proxy-partial-import-refresh
fix(proxies): refresh the list after partial imports
2026-09-12 19:30:09 +08:00
Wesley Liddick f0dd497780 Merge pull request #7054 from ouchihao/codex/bug-usage-export-filter-snapshot
fix(usage): keep export filters consistent across pages
2026-09-12 19:29:57 +08:00
Wesley Liddick 99b93b2985 Merge pull request #7053 from ouchihao/codex/bug-proxy-filter-pagination
fix(proxies): reset pagination when changing filters
2026-09-12 19:29:41 +08:00
Wesley Liddick 67845665d6 Merge pull request #7050 from ouchihao/codex/bug-balance-email-verification-race
fix(profile): remove verified notification emails by identity
2026-09-12 19:29:24 +08:00
Wesley Liddick 3a070ec1bf Merge pull request #7010 from xpflying/fix/openai-privacy-client-cf-challenge
fix(openai): 隐私/账号检查客户端改用 Firefox 指纹,规避 chatgpt.com Cloudflare 质询
2026-09-12 19:28:09 +08:00
Wesley Liddick 749cd7c354 Merge pull request #7062 from wc4440222/fix/channel-monitor-minimax-provider
fix(channel-monitor): add missing minimax to provider allowlists
2026-09-12 19:27:46 +08:00
Wesley Liddick eaa4083e23 Merge pull request #6995 from alfadb/fix/claude-mid-conversation-output-config
fix(claude): 修复对话中 output_config 的 beta 兼容问题
2026-09-12 19:27:21 +08:00
Wesley Liddick 3873215908 Merge pull request #7049 from xlplbo/perf/openai-ws-pool-queue-wake
perf(openai): WS 连接池排队等待者按账号池变更重选连接
2026-09-12 19:26:56 +08:00
Wesley Liddick 2a4f3d1a71 Merge pull request #7043 from xlplbo/fix/openai-ws-max-conns-factor-default
fix(openai-ws): ctx_pool 每账号连接上限系数默认值改为 5.0
2026-09-12 19:26:26 +08:00
Wesley Liddick 4ff3e6dfb9 Merge pull request #7055 from ouchihao/codex/bug-redeem-profile-refresh
fix(redeem): preserve success when account refresh fails
2026-09-12 19:26:02 +08:00
Wesley Liddick f78c4b241e Merge pull request #7011 from ouchihao/codex/issue-7003-compact-default-model
fix(config): use GPT-5.5 as the default compact model
2026-09-12 19:25:43 +08:00
Wesley Liddick e67ffda7ae Merge pull request #7012 from ouchihao/codex/issue-6982-easypay-upstream-type-dots
fix(payment): allow periods in EasyPay upstream types
2026-09-12 19:24:48 +08:00
Wesley Liddick d2067668d3 Merge pull request #7024 from ouchihao/codex/fix-subscription-assignment-selection
fix(subscriptions): clear assignment user before search debounce
2026-09-12 19:24:00 +08:00
Wesley Liddick ba57ea914b Merge pull request #7052 from ouchihao/codex/issue-2912-transient-auth-failures
fix(auth): preserve sessions during temporary service failures
2026-09-12 19:23:12 +08:00
Wesley Liddick 5948988aae Merge pull request #7022 from ouchihao/codex/issue-3623-accept-encoding-header
fix(gateway): avoid duplicate Accept-Encoding headers
2026-09-12 19:22:44 +08:00
xlplbo 76b3f3c7cf docs(ui): 精简 OpenAI WS 模式提示 2026-09-12 17:38:55 +08:00
xlplbo db15ddf07f docs(ui): 区分 OpenAI WS 开关与连接模式说明 2026-09-12 16:54:22 +08:00
xlplbo 5d5b395d00 docs(ui): 完善 OpenAI WS 模式说明 2026-09-12 16:32:17 +08:00
xlplbo cf3aca6e3b fix(ui): 按 OpenAI WS 模式显示链路提示 2026-09-12 16:28:04 +08:00
xlplbo 956f4672e4 fix(openai-ws): 对齐 OpenAI 新版上下文池容量计算 2026-09-12 16:13:16 +08:00
Wesley Liddick 6206ce940e Merge pull request #6929 from feeeei/fix/gemini-inband-response-signals
fix(gemini): 原生路径登记 2xx 响应里的带内错误信号
2026-09-12 16:08:26 +08:00
Zichen Wang 5968fd0ed9 fix(channel-monitor): add missing minimax to provider allowlists
Fixes #7061

monitorProviders and probeCapableProviders were missing
MonitorProviderMiniMax even though the constant, adapter, ent schema and
error message all already included minimax, so provider=minimax was
rejected by validateProvider.
2026-09-12 15:30:43 +08:00
Shen-AnandClaude Sonnet 5 c6727ed4c9 fix(admin): surface partial Antigravity refresh warnings
Return the updated account alongside partial refresh warnings so the admin UI can keep its state current and tell operators when project ID retrieval must retry.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-12 15:19:19 +08:00
chihao.ou bb0b50d8bc fix(redeem): preserve success when account refresh fails 2026-09-12 13:50:12 +08:00
chihao.ou 429d6f0480 fix(usage): keep export filters consistent across pages 2026-09-12 13:44:33 +08:00
chihao.ou 5bafffed51 fix(proxies): reset pagination when changing filters 2026-09-12 13:44:05 +08:00
chihao.ou 781a02aea7 fix(auth): preserve sessions during temporary service failures 2026-09-12 13:41:16 +08:00
chihao.ou 3645ce5f43 fix(profile): remove verified notification emails by identity 2026-09-12 13:38:11 +08:00
xlplbo 7cf90c79d6 perf(openai): WS 连接池排队等待者按账号池变更重选连接 2026-09-12 13:01:43 +08:00
Sjeary 195a331322 fix(codex): preserve maximum context window in model catalogs 2026-09-11 21:56:25 -07:00
xlplbo 29371b0819 fix(openai-ws): ctx_pool 每账号连接上限系数默认值改为 5.0 2026-09-12 11:39:05 +08:00
xuhaihan (cc) 8e7954438d fix(openai): force priority when service tier is omitted 2026-09-11 21:16:12 +08:00
chihao.ou a65d476f25 fix(keys): sync status after resetting quota 2026-09-11 19:32:20 +08:00
chihao.ou 961d9e94f3 fix(proxies): refresh the list after partial imports 2026-09-11 19:29:36 +08:00
chihao.ou 51c5d03888 fix(subscriptions): clear assignment user before search debounce 2026-09-11 19:29:07 +08:00
chihao.ou 9b6a49b8e7 fix(profile): show normalized API error messages 2026-09-11 19:27:47 +08:00
chihao.ou 213de0797e fix(gateway): avoid duplicate Accept-Encoding headers 2026-09-11 19:19:40 +08:00
alfadb d8326fccfc fix(claude): preserve mid-conversation output config beta 2026-09-11 18:44:41 +08:00
xpflyingandClaude Fable 5.1 9eb120dd40 fix(openai): 隐私/账号检查客户端改用 Firefox 指纹,规避 chatgpt.com Cloudflare 质询
chatgpt.com 的 Cloudflare 会对 req 内置的 ImpersonateChrome(TLS/HTTP2 指纹与 UA 均为
Chrome/120)返回 403 cf-mitigated=challenge,导致 accounts/check、subscriptions、
account_user_setting 三个 backend-api 调用长期失败:订阅到期时间不再更新、隐私开关显示 Fail。
仅改 UA 版本号无效(指纹仍是 120),Firefox 伪装在同一出口下稳定通过,故切换。

同时:
- 用 cf-mitigated 响应头识别质询页,隐私设置失败时正确标记为 training_set_cf_blocked
- accounts/check / subscriptions 失败日志由 Debug 提升为 Warn 并附 cf_challenge 字段,不再静默

Closes #4825

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 18:10:15 +08:00
Wesley Liddick 4726bdd08b Merge pull request #6890 from slovx2/feat/openai-images-75be
feat(openai): route OAuth image requests through native Codex Images
2026-09-11 17:29:54 +08:00
feeeei 8ea4dc56f0 fix(gemini): 模型侧 finishReason 不再按上游失败登记
MALFORMED_FUNCTION_CALL / UNEXPECTED_TOOL_CALL 是模型生成结果,此前按生成异常
登记为上游 502:写带账号归因的上游错误事件、计入 SLA、严重度 P1,并落进渠道
监控的 upstream_5xx 桶。

- detectGeminiResponseSignal 的 finishReason 只登记内容过滤类枚举,其余一律不登记
- geminiSignalAbnormalStop 仅承载 EMPTY_STREAM / EMPTY_RESPONSE
- 新增原生流式 MALFORMED_FUNCTION_CALL 不留 ops 标记的回归用例
2026-09-11 17:06:01 +08:00
song 23eef9ecc3 test(openai): restore image model regression coverage 2026-09-11 16:44:56 +08:00
Wesley Liddick cdb5cfaf6c Merge pull request #6747 from DuskLin/feat/opencode-go-platform
feat(opencode): 增加 OpenCode 平台支持(Zen / GO)
2026-09-11 16:42:05 +08:00
Wesley Liddick dfa83fbe93 Merge pull request #6986 from feeeei/feat/site-billing-mode
feat(settings): 新增「站点类型」开关(充值 & 订阅 / 仅充值 / 仅订阅)
2026-09-11 16:34:46 +08:00
Wesley Liddick 2dff7af0f9 Merge pull request #6954 from feeeei/fix/quota
fix(quota): 修复仪表盘默认会给所有人分配显示所有平台卡片的问题
2026-09-11 16:34:18 +08:00
song 1067e89fab fix(openai): address image route review findings 2026-09-11 16:30:32 +08:00
song 04c45e9dc2 test: remove incompatible upstream image model assertions 2026-09-11 15:57:49 +08:00
song 5ee436d8a2 fix(pricing): set image cache fallback rate 2026-09-11 15:57:49 +08:00
song e5e04de137 fix: sync upstream config and model tests 2026-09-11 15:57:49 +08:00
song fa565eafb1 fix(pricing): add image cache pricing field 2026-09-11 15:57:38 +08:00
song eacff0a629 fix: retain upstream config and image pricing updates 2026-09-11 15:57:38 +08:00
song c0d5119375 feat(openai): route OAuth image requests through native Codex Images
Use shared model routing for image forwarding and account tests, retain Luna orchestration for legacy models, and add Image 2.5 pricing and usage accounting.
2026-09-11 15:57:09 +08:00
刘佳林 22dffa1bb9 fix(opencode): apply De Morgan for staticcheck QF1001
golangci-lint failed on the CN/OpenCode billing-candidate guard.
2026-09-11 15:47:23 +08:00
刘佳林 efcc2252e5 fix(opencode): address review on dispatch, billing, session, and 402
Skip OpenAI messages-dispatch gpt-5.x defaults for OpenCode groups and
composite OpenCode targets. Filter unpriced claude-* billing candidates,
estimate count_tokens locally, sanitize body session IDs before setting
X-OpenCode-Session, and treat Zen 402 as recoverable payg cooldown.

Stop rewriting the inbound body before protocol routing so account
model_mapping applies once and usage_logs.model stays the client name.
2026-09-11 15:39:13 +08:00
刘佳林 981279c996 fix(opencode): rewrite mapped model onto all three upstream protocols
Apply account model_mapping to the request body before OpenCode
protocol routing so /v1/responses, /v1/chat/completions, and
/v1/messages all send the mapped ID as the real upstream model.
2026-09-11 15:07:43 +08:00
chihao.ou c3b3072a6f fix(payment): allow periods in EasyPay upstream types 2026-09-11 14:51:34 +08:00
chihao.ou 489968fd7a fix(config): use GPT-5.5 as the default compact model 2026-09-11 14:50:39 +08:00
刘佳林 7c008bd8c1 fix(opencode): apply account model mapping in connection tests
Responses probes used the requested model as-is, so aliases like
opencode/muse-spark-* were sent upstream instead of the mapped ID.
2026-09-11 14:19:23 +08:00
Wesley Liddick 9c30951e4a Merge pull request #6973 from ouchihao/codex/issue-4210-renewal-modal-scroll
fix(payment): allow scrolling through renewal plans
2026-09-11 14:01:48 +08:00
Wesley Liddick 40cf3f50a6 Merge pull request #6968 from ouchihao/codex/issue-6903-deepseek-vision-modality
fix(codex): advertise DeepSeek vision image input
2026-09-11 14:01:36 +08:00
Wesley Liddick 75b7dd1e0b Merge pull request #6969 from ouchihao/codex/issue-5882-peak-rate-validation
fix(groups): return bad request for invalid peak-rate settings
2026-09-11 14:01:14 +08:00
Wesley Liddick 642d20b8ac Merge pull request #6988 from ZeroClover/fix/codex-manifest-display-name
fix(openai): keep Codex manifest display names in account model lists
2026-09-11 14:00:42 +08:00
Wesley Liddick 623c32e391 Merge pull request #6989 from ZeroClover/fix/openai-plan-type-labels
fix(openai): map new ChatGPT plan tiers to friendly labels
2026-09-11 14:00:07 +08:00
Wesley Liddick 310f8b7fa2 Merge pull request #6964 from xlplbo/fix/apicompat-agent-message-chat-bridge
fix(apicompat): chat 桥保留 Codex agent_message 任务正文
2026-09-11 13:59:41 +08:00
Wesley Liddick 6254aba5e9 Merge pull request #6575 from doublepi123/feat/antigravity-gemini-3.8-flash
feat(antigravity): support Gemini 3.7 and 3.8 Flash models
2026-09-11 13:58:54 +08:00
Wesley Liddick 7145484fde Merge pull request #6971 from ouchihao/codex/issue-1890-ttft-request-details
fix(ops): show first-token latency in TTFT request details
2026-09-11 13:58:37 +08:00
Wesley Liddick fd300ab6f3 Merge pull request #6974 from alfadb/fix/deepseek-v41-flash-pricing
fix(billing): 适配 DeepSeek V4.1-Flash 新价与 pro→Flash 计费切换
2026-09-11 13:58:23 +08:00
Wesley Liddick 88011a6a2e Merge pull request #6783 from asmoyou/fix/large-image-request-memory
perf(gateway): reduce memory amplification for large Responses image requests
2026-09-11 13:58:11 +08:00
Wesley Liddick 14029e50aa Merge pull request #6661 from whhphd/fix/grok-media-slot-release
fix(grok): release abandoned media slots and preserve video ownership
2026-09-11 13:57:07 +08:00
Wesley Liddick 3fb03cdac1 Merge pull request #6960 from xlplbo/fix/openai-ws-exec-scope-preemption
fix(openai-ws): ctx_pool 会话抢占与会话状态按 codex 线程隔离
2026-09-11 13:56:52 +08:00
Wesley Liddick 8f9a9a255b Merge pull request #6965 from xlplbo/fix/openai-ws-pool-reader-loop
fix(openai-ws): 连接池常驻读循环应答上游 ping,根治空闲连接 1011 keepalive ping timeout
2026-09-11 13:56:23 +08:00
Wesley Liddick bae37a00fb Merge pull request #7006 from Wei-Shaw/fix/setup-test-errcheck-close
fix(ci): 修复 main 分支 golangci-lint 红灯(setup_test.go errcheck)
2026-09-11 13:46:15 +08:00
xlplbo 4543ddc5c1 fix(openai): WS 执行作用域按 request_kind 分道 2026-09-11 13:37:59 +08:00
matt 5af7e7438a fix(ci): check deferred sql.DB.Close errors in setup tests
PR #6499 merged without CI ever running on its head commit (fork run stayed
action_required), landing three bare `defer db.Close()` calls in
internal/setup/setup_test.go. The repo's errcheck config sets
disable-default-exclusions: true, so those are reported and golangci-lint has
been red on main ever since, failing every subsequent PR run.

Switch them to the codebase's existing convention
`defer func() { _ = db.Close() }()`.
2026-09-11 11:45:59 +08:00
xlplbo 613722eee4 fix(openai): HTTP 转发的执行作用域改从原始请求计算 2026-09-11 11:36:29 +08:00
SavitarC 66a10d4939 fix(setup): handle mock database cleanup errors in tests 2026-09-11 10:31:03 +08:00
xlplbo d0ca057ca4 fix(openai): WS 会话被抢占时先发关闭帧再取消 2026-09-11 10:25:50 +08:00
xlplbo e4c369bd56 fix(openai): HTTP 经 WS 池转发按执行作用域取会话状态键 2026-09-11 10:25:50 +08:00
SavitarC 1035a4daf0 feat(ops): support token request stats across all platforms 2026-09-11 10:16:32 +08:00
刘佳林 242907854d feat(opencode): add OpenCode platform with Zen and GO account types
Keep the internal platform ID opencode_go. Product surface is OpenCode
with account_mode zen|go, model-aware routing across Chat Completions,
Responses, and Anthropic Messages, and X-OpenCode-Session derived from
prompt_cache_key / metadata.user_id so prompt cache can hit.

Unmatched models fall back to /v1/chat/completions. Related to #6742.
2026-09-11 10:05:24 +08:00
Wesley Liddick 0116c5a1e1 Merge pull request #6915 from ouchihao/codex/issue-2734-async-processed-label
fix(risk-control): clarify the async processed counter label
2026-09-11 09:35:50 +08:00
Wesley Liddick 67d3a896bd Merge pull request #6912 from ouchihao/codex/issue-2106-redeem-subscription-duration
fix(redeem): align subscription duration limit with the backend
2026-09-11 09:35:29 +08:00
Wesley Liddick 501cc19d0e Merge pull request #6902 from anguobao123/codex/apple-container-self-update
fix(deploy): support Web UI updates with Apple container
2026-09-11 09:35:11 +08:00
Wesley Liddick 8efe2fd8cf Merge pull request #6843 from ouchihao/codex/issue-6841-usage-cost-precision
fix(usage): show eight decimal places in cost details
2026-09-11 09:34:36 +08:00
Wesley Liddick 0aac71c6ee Merge pull request #6845 from ouchihao/codex/issue-6808-ops-error-summary-columns
fix(ops): prioritize time and response in error details
2026-09-11 09:34:23 +08:00
Wesley Liddick f4dd88b001 Merge pull request #6847 from ouchihao/codex/issue-4064-custom-page-button-visibility
feat(settings): allow hiding the custom page open button
2026-09-11 09:34:11 +08:00
Wesley Liddick 0be30886a5 Merge pull request #6916 from ouchihao/codex/issue-3640-subscription-user-search
fix(subscriptions): exclude deleted users from assignment search
2026-09-11 09:33:57 +08:00
Wesley Liddick 324a47e2b7 Merge pull request #6913 from ouchihao/codex/issue-6732-bulk-delete-users
feat(admin): support deleting selected users
2026-09-11 09:33:45 +08:00
Wesley Liddick 2493b0dc31 Merge pull request #6924 from smyyyyy2025/fix/codex-user-agent-validation
fix: validate Codex User-Agent before identity parsing
2026-09-11 09:33:19 +08:00
Wesley Liddick 264cbbec26 Merge pull request #6499 from soulxyz/fix/setup-database-bootstrap
fix(setup): avoid hardcoded postgres connection
2026-09-11 09:32:01 +08:00
Wesley Liddick 87e01596b7 Merge pull request #6850 from reallyfeishi/fix/channel-monitor-utc-buckets
fix: anchor channel monitor date_bin buckets to UTC
2026-09-11 09:31:22 +08:00
Wesley Liddick 43f9383d40 Merge pull request #6869 from Garfielk/codex/fix-heartbeat-envelope-fields
fix(openai): accept full heartbeat bootstrap envelopes
2026-09-11 09:30:45 +08:00
Wesley Liddick 685e97a3a6 Merge pull request #6904 from m11y/fix-scheduling-threshold-snapshot
fix(scheduler): preserve threshold metadata
2026-09-11 09:30:00 +08:00
Wesley Liddick e71d291b39 Merge pull request #6917 from ouchihao/codex/issue-2149-monitor-base-path
fix(monitor): support upstream base URL paths
2026-09-11 09:29:25 +08:00
Wesley Liddick 4adcef4ff4 Merge pull request #6943 from xiuai-labs/fix/keep-client-system-cache-control
fix(gateway): 不再剥离客户端 system 上的 cache_control / stop stripping client system cache_control
2026-09-11 09:28:55 +08:00
Wesley Liddick 799e9938fb Merge pull request #6945 from r266-tech/feat/model-retrieval-6931
feat(gateway): retrieve models from the visible catalogue
2026-09-11 09:26:41 +08:00
Zero Clover 8f2cba0c20 fix(openai): map new ChatGPT plan tiers to friendly labels
prolite -> Pro 5x (violet, same as Pro), pro/chatgptpro -> Pro 20x, self_serve_business_prolite -> Business Premium (indigo, same as Team), team -> Business Standard.

The tier naming stays OpenAI-only so Antigravity Pro and Grok pro keep their own labels; planTypeDisplayLabel now shares openAIPlanTypeLabel with PlatformTypeBadge instead of duplicating the mapping, and the manual plan_type dropdown gains the Pro 5x / Business Premium presets.
2026-09-11 03:25:44 +08:00
Zero Clover 6feefb7aec fix(openai): keep Codex manifest display names in account model lists 2026-09-11 03:18:00 +08:00
feeeei 9d475f9edb feat(settings): 新增「站点类型」三态开关,关闭订阅后用户端收起所有「订阅」入口与文案
后台功能开关页新增「站点类型」单选:充值 & 订阅(默认,即现状)/ 仅充值 / 仅订阅。
它由两个后端开关派生并在保存时同时写回:
- subscription_enabled(新增;默认 true,缺省/空值视为开启,仅显式 false 关闭),按既有
  开关模式贯通常量、公共设置、SSR 注入载荷、默认值、更新写入、DTO、审计 diff 与前端注册表;
- payment_balance_disabled(支付配置里既有的 BALANCE_PAYMENT_DISABLED,此前只能走接口/数据库
  改),补进公共设置与注入载荷,让用户端在任何鉴权请求之前就能推导站点类型。
两者都关闭是接口层可表达但 UI 不提供的组合,前端归为「仅充值」,后台保存时会把余额充值重新打开。

「仅充值」时的表现:
- 用户侧边栏隐藏「我的订阅」,「充值/订阅」入口收窄为「充值」;管理端侧边栏隐藏「订阅管理」
  入口(页面仍可通过地址访问);
- 购买页不再渲染「订阅」tab,只剩单个 tab 时顶部切换器随之隐藏;tab 列表变化时当前 tab
  自动收敛到可用项(关闭订阅回到充值、仅订阅站点重新打开订阅进入订阅),列表为空时展示
  「充值与订阅均暂未开放」而不是一个必定被后端拒绝的充值表单;
- /purchase 的页头标题/描述与 document.title 改走 resolveRouteMetaKeys 按站点类型切换;
- /subscriptions 路由新增 requiresSubscription 守卫,直达时重定向到仪表盘;
- 顶栏订阅进度徽章不再挂载,App.vue、PaymentView 与徽章组件都不再预加载/轮询订阅接口;
- 用量页隐藏「计费类型(余额/订阅)」筛选,Key 用量公开页「订阅类型」行改为「计费方式」。
「仅订阅」时侧边栏入口与页头显示为「订阅」,购买页只剩订阅 tab(余额下单由后端既有逻辑拒绝)。

购买订阅接口、已有订阅的计费、兑换码发放订阅与管理端订阅管理页面本身均不受影响。
featureFlags 抽出纯函数 resolveFeatureFlag,新增 utils/siteBillingMode 承载三态派生与反解,
PaymentView 测试改用响应式 store mock 覆盖开关双向切换。
2026-09-11 00:50:17 +08:00
alfadb ab9bd9e877 fix(billing): 适配 DeepSeek V4.1-Flash 新价与 pro→Flash 切换
- flash 低谷价更新为 $0.15/$0.60/$0.003 per MTok(2026-09-10 官方降价)
- deepseek-v4-pro 自 2026-09-14 04:00 UTC 起按 Flash 价计费(上游路由变更)
- 计费时点显式驱动 pro→Flash 切换,定价展示与计费两条路径同源
- deepseek-flash 保留 reasoning_effort=max;补 Codex 模型清单与前端白名单
- 价表 JSON 新增 deepseek-flash 条目并同步 flash 系价格
2026-09-10 22:10:29 +08:00
chihao.ou b112805ffa test(ops): align query matcher formatting with gofmt 2026-09-10 21:21:08 +08:00
chihao.ou 8c56eabcdc fix(payment): allow scrolling through renewal plans 2026-09-10 21:20:20 +08:00
chihao.ou d3bd614ba7 test(groups): initialize a valid rate multiplier in peak validation case 2026-09-10 21:17:42 +08:00
chihao.ou e9f5add97c fix(ops): show first-token latency in TTFT request details 2026-09-10 21:13:17 +08:00
chihao.ou ab3b398b5f fix(groups): return bad request for invalid peak-rate settings 2026-09-10 21:05:47 +08:00
chihao.ou 5032d06697 fix(codex): advertise DeepSeek vision image input 2026-09-10 21:04:56 +08:00
xlplbo bed1e3c362 fix(openai): 重试强制新建连接并放宽轮次预检超时 2026-09-10 20:35:53 +08:00
xlplbo a5b07b2960 feat(openai): WS 连接池常驻读循环应答上游 ping 2026-09-10 20:35:53 +08:00
xlplbo df8eceba0c fix(apicompat): chat 桥保留 Codex agent_message 任务正文 2026-09-10 20:25:38 +08:00
xlplbo 95a04c8dff test(apicompat): additional_tools 走 chat 桥的回归用例 2026-09-10 19:50:26 +08:00
xlplbo cd1ee1d1ad fix(openai): WS 接入会话状态改用执行作用域键 2026-09-10 17:50:24 +08:00
xlplbo 9e7c2d713c fix(openai): WS 会话抢占改用执行作用域键 2026-09-10 17:50:24 +08:00
xlplbo 06b61ba96f feat(openai): 解析 codex 线程标识派生 WS 执行作用域 2026-09-10 17:50:23 +08:00
feeeei 4a4b50d807 fix(quota): 平台限额表只保存已配置限额的记录,仪表盘按用量与限额展示平台
user_platform_quotas 只保存至少配置了一档限额的记录;不存在的行等价于不限额。

- 注册播种只为配置了限额的平台建行,一个都没有时不写入
- IncrementUsageWithReset 无行时不再 fail-open 建行
- BatchSnapshotUsage 改为只更新既有活跃行,不再插入无限额的新行
- 仓储 BulkInsertInitial/UpsertForUser 把三档全空的记录视为未配置;
  管理员全量替换接口同样剔除,清空限额即软删并放弃累计用量
- 迁移 238 删除三档全 NULL 的历史行
- 用户仪表盘平台卡片 = 有用量的平台 ∪ 配置了限额的平台,计数改为卡片数
- 管理员限额弹窗对未保存限额的平台禁用"重置窗口"
2026-09-10 16:41:58 +08:00
r266-tech 1c0932e173 feat(gateway): retrieve models from the visible catalogue 2026-09-10 15:57:17 +08:00
nekocode a4edda36d5 fix(gateway): 不再剥离客户端 system 上的 cache_control
OAuth mimic 路径无条件删除 system[*].cache_control,不打日志也不报错。
把稳定前缀锚在 system 上的客户端因此拿到 cache_creation 与 cache_read
双 0 —— 不是没命中,是连写都没写。

这个动作是 46e5ac9 引进时「system 必然被整个重写」的配套:客户端的
system 内容都搬进 messages 了,残留断点指着空气,删它是对的。后来
system 注入变成可配置,于是长出了原设计里不存在的组合 —— system 没被
重写,断点照删。剥离失去了赖以成立的前提,代码却还在。

三处调用点里 count_tokens 那处还是硬编码 true,与另两处的
!systemRewritten 语义不一致。

直接移除该选项而不是再加一个开关:注入开启时 system 已被整个替换、
留在上面的断点是我们自己拼进去的稳定锚点;注入关闭时断点就是客户端的
缓存意图。两种情形都没有删它的理由,而需要开关的是有取舍的行为。

顺带补上 count_tokens 的 4 块上限兜底:五条出口里只有它没在自己的转发
路径上调过 enforceCacheControlLimit,原先那句 strip 是这条路上唯一压制
断点数量的东西。拿掉之后「客户端 system + 客户端 messages + 刚注入的
tools[-1]」实测顶到 5 块,而上游对超限是 400。

messages 层的同类改写见 #2369,那次只落地了 messages 一半。

此前该行为零测试覆盖(stripSystemCacheControl 在 _test.go 里零命中),
本次补 4 个用例,其中上限兜底那条撤掉修复即红。

Claude-Session: https://claude.ai/code/session_01WnTW52pmELGy3g7KPEUXj2
2026-09-10 15:48:17 +08:00
reed 1ed36679bd fix(apicompat): recover Responses text from done and terminal events
The streaming Responses-to-Anthropic converter emitted assistant text only
from output_text.delta, so a stream carrying its answer on
response.output_text.done or in the terminal response.output produced a
protocol-valid message with no text.

Record emitted text per output_text part in a ledger that survives content
block closure, and recover only the suffix a finished payload adds, as #6629
does for function-call arguments. Where the part a payload finishes cannot be
established, decline to recover rather than repeat delivered text: terminal
recovery runs only when nothing was emitted, and a done payload indexed
differently from every delta is ignored.

Give output_text.done its own handler so the shared block-closing helper and
its tool-path caller stay unchanged, and so a payload arriving after
message_stop cannot open a content block past the end of the message.
2026-09-09 18:55:16 -05:00
smyyyyy 4e5632c3e3 fix: validate Codex User-Agent before identity parsing 2026-09-10 03:18:31 +08:00
feeeei 44bc47a3e3 fix(gemini): 原生路径登记 2xx 响应里的带内错误信号
Gemini API-key 原生转发对上游 2xx 只抽 usage 原样透传:finishReason 内容拦截、
promptFeedback.blockReason、事件里的 Google 错误信封、空响应都被当作成功,
ops 错误日志、用户错误页、渠道监控全部没有记录。

- 新增 gemini_response_signal:逐事件判定三类带内信号,流式按优先级登记最高者;
  没有 data 事件时对非 SSE 的兜底正文整段判定,空体登记 EMPTY_STREAM / EMPTY_RESPONSE
- 内容策略类按请求级结果登记(invalid_request_error / 400,business limited,
  不做上游归因、不计 SLA);错误信封、生成异常、空响应按上游失败登记并计 SLA
- OpsStreamError 新增 RequestScoped / NonStream:请求级带内行不受残留上游上下文与
  skip_monitoring 影响,落库状态取 IntendedStatus;中间件保留此前尝试的恢复行
- 客户端字节、wire 状态、用量与计费不变
2026-09-10 02:59:15 +08:00
chihao.ou 21323c31f8 fix(monitor): support upstream base URL paths 2026-09-09 20:02:25 +08:00
chihao.ou c76db386c0 fix(subscriptions): exclude deleted users from assignment search 2026-09-09 20:01:34 +08:00
chihao.ou 58e6e6b325 fix(risk-control): clarify the async processed counter label 2026-09-09 19:57:23 +08:00
chihao.ou 567ea21dc8 feat(admin): support deleting selected users 2026-09-09 19:50:05 +08:00
chihao.ou b9d0728687 fix(redeem): align subscription duration limit with the backend 2026-09-09 19:47:22 +08:00
wangjunwei de76baaa0d fix(scheduler): preserve Anthropic threshold metadata 2026-09-09 17:13:37 +08:00
anguobao123 26798773cb fix(deploy): support web updates with Apple container 2026-09-09 17:05:45 +08:00
github-actions[bot] 98d86915be chore: sync VERSION to 0.2.4 [skip ci] 2026-09-09 07:05:11 +00:00
Wesley Liddick 5de5e2bed0 Merge pull request #6874 from hansnow/fix/gpt-image-2-5-oauth
fix(openai): 支持 Image 2.5 并修复 OAuth 生图主控模型
2026-09-09 14:43:55 +08:00
刘世俊 a0babc93dd fix(openai): accept full heartbeat bootstrap envelopes 2026-09-09 12:48:57 +08:00
hansnow 7ccc8a6f50 fix(openai): 支持 Image 2.5 并修复 OAuth 生图主控模型 2026-09-09 11:04:59 +08:00
lcy 29a36a10be Merge upstream/main into feat/antigravity-gemini-3.8-flash
Resolve comment-wording conflict in normalizeGeminiThinkingTierAlias by
keeping upstream's phrasing. Function body and behavior are unchanged.
2026-09-09 01:49:32 +08:00
reallyfeishi f180fbb884 fix: anchor channel monitor buckets to UTC 2026-09-08 23:02:06 +08:00
chihao.ou 1d4e0436e4 feat(settings): allow hiding the custom page open button 2026-09-08 21:18:56 +08:00
shaw 270eac6973 chore: update sponsors 2026-09-08 21:18:24 +08:00
chihao.ou 266ac9c11b fix(ops): prioritize time and response in error details 2026-09-08 21:17:13 +08:00
chihao.ou fd880839f6 fix(usage): show eight decimal places in cost details 2026-09-08 21:15:55 +08:00
shaw 570106ac17 chore: update sponsors 2026-09-08 20:57:43 +08:00
Wesley Liddick 775348b402 Merge pull request #6706 from ouchihao/codex/issue-3679-draggable-custom-page-button
feat(custom-page): make the open-link button draggable
2026-09-08 20:47:42 +08:00
Wesley Liddick d9efbf8229 Merge pull request #6839 from mason0510/fix/grok-external-web-access
fix(grok): 移除 Chat Completions 中不支持的 external_web_access 参数
2026-09-08 20:47:25 +08:00
Wesley Liddick 233f3dffe6 Merge pull request #6281 from wuji-labs/feat/http2-long-stream-keepalive
feat(upstream): add HTTP/2 PING keepalive for long LLM generation streams
2026-09-08 20:47:07 +08:00
Wesley Liddick f9d7826711 Merge pull request #6513 from anguobao123/codex/optional-apple-container-network-subnet
feat(deploy): support optional Apple network subnet
2026-09-08 20:46:51 +08:00
Wesley Liddick c54897a59d Merge pull request #6815 from Pluviobyte/codex/fix-directed-proxy-backups
fix(proxies): make backup references directed and shareable
2026-09-08 20:46:32 +08:00
Wesley Liddick 29646ba6b4 Merge pull request #6488 from aofee/codex/feat-grok-media-eligibility-ui
feat: add Grok media eligibility controls
2026-09-08 20:46:20 +08:00
Wesley Liddick 6f0d0ababc Merge pull request #6320 from spongehah/fix/runtime-block-honor-persisted-cooldown
fix(schedule): 选号按持久化冷却条件清除本机账号级阻断
2026-09-08 20:46:07 +08:00
Wesley Liddick 52f7bcaedd Merge pull request #6759 from ssharkkky/feat/cmv2-user-ranking-toggle
feat(channel-monitor-v2): add user ranking toggle and keep missing TTFT neutral
2026-09-08 20:44:07 +08:00
Wesley Liddick 95acbf1f03 Merge pull request #6424 from cyhhao/fix/bound-ops-system-log-storage
fix(ops): bound database-backed system log growth
2026-09-08 20:43:54 +08:00
Wesley Liddick 43569bb44c Merge pull request #6434 from swjturay/codex/upstream-client-disconnect-drain
fix(openai-ws): drain upstream after client disconnect
2026-09-08 20:42:59 +08:00
Wesley Liddick 15557e7796 Merge pull request #6235 from StarryKira/codex/fix-6232-channel-cache-invalidation
fix: broadcast channel cache invalidations across instances
2026-09-08 20:42:45 +08:00
Wesley Liddick e2fd418a96 Merge pull request #6372 from clansty/fix/openai-oauth-429-fallback-disabled
fix: 修复关闭 429 默认回避后仍触发 OAuth 运行时冷却
2026-09-08 20:42:35 +08:00
Wesley Liddick 4e9b01fd59 Merge pull request #6838 from atogumo/fix/claude-code-probe-any-model
fix(gateway): accept Claude Code max_tokens=1 probes for any model
2026-09-08 20:42:24 +08:00
Wesley Liddick aa167a25a7 Merge pull request #6775 from xlplbo/fix/plugin-zip-handle-windows
fix(plugin): 提交插件包前先关闭 ZIP 读取器,修正 Windows 下两例单测
2026-09-08 20:12:42 +08:00
Wesley Liddick 5cbb9191a7 Merge pull request #6836 from r266-tech/fix-proxy-expiry-json-range
fix(proxies): reject malformed list responses
2026-09-08 20:12:30 +08:00
Wesley Liddick ae4cc14b28 Merge pull request #6819 from LeeeeeeM/codex/fix-registration-visibility
fix: respect registration visibility on login
2026-09-08 20:12:14 +08:00
Wesley Liddick fa556700c2 Merge pull request #6762 from ouchihao/codex/issue-2045-payment-help-markdown
feat(payment): render help text as sanitized Markdown
2026-09-08 20:11:56 +08:00
Wesley Liddick 68773aab98 Merge pull request #6764 from ouchihao/codex/issue-1499-select-failed-token-refresh
fix(accounts): keep failed token refresh accounts selected
2026-09-08 20:11:08 +08:00
Wesley Liddick 1e6114c270 Merge pull request #6812 from Pluviobyte/codex/fix-model-plaza-subscriptions
fix(model-plaza): include active subscription groups
2026-09-08 20:10:42 +08:00
Wesley Liddick 9e7039ebca Merge pull request #6811 from Pluviobyte/codex/fix-proxy-partial-update
fix(proxies): preserve omitted expiry and fallback settings
2026-09-08 20:10:20 +08:00
Wesley Liddick 1923d1c27d Merge pull request #6814 from feeeei/fix/go-redis-pool-nil-ctx-panic
fix(redis): 升级 go-redis 至 v9.22.0,修复连接池 nil-ctx panic 竞态
2026-09-08 20:09:59 +08:00
Wesley Liddick b638445234 Merge pull request #6754 from ccemoji/codex/minimal-upstream-fix
fix: cancel streaming upstream before closing body
2026-09-08 20:09:37 +08:00
Wesley Liddick 9bcf8a5b80 Merge pull request #6760 from wucm667/feat/issue-6752-weekly-cost-estimate
feat(accounts): show estimated weekly account cost
2026-09-08 20:09:25 +08:00
Wesley Liddick 979fe247f9 Merge pull request #6821 from 22zyfeng-collab/fix/antigravity-oauth-plan-type
fix(antigravity): preserve OAuth plan_type when saving accounts
2026-09-08 20:09:12 +08:00
Wesley Liddick 6378fb0cba Merge pull request #6798 from savvym/codex/fix-account-menu-overflow
fix(frontend): 修复账号操作菜单在视口底部被截断
2026-09-08 20:08:53 +08:00
Wesley Liddick 3ee92b40c5 Merge pull request #6659 from ouchihao/codex/issue-77-remove-inactive-groups
fix(accounts): allow removing inactive group assignments
2026-09-08 20:08:40 +08:00
Wesley Liddick 4892b8f17e Merge pull request #6810 from Pluviobyte/codex/fix-announcement-proxy-date-range
fix: validate announcement and proxy date ranges
2026-09-08 20:08:24 +08:00
Wesley Liddick 28b807a735 Merge pull request #6816 from Pluviobyte/codex/fix-repeated-proxy-fallback
fix(proxies): allow repeated expiry fallback and preserve origin
2026-09-08 20:07:08 +08:00
atogumo 4e5d67df35 fix(gateway): accept Claude Code max_tokens=1 probes for any model
The claude_code_only probe exemption only fired for models containing
"haiku". Claude Code also sends max_tokens=1 requests without a system
prompt to the model currently in use, e.g. when switching models or
refreshing the context usage, so on sonnet/opus groups those requests failed
the system-prompt check and were rejected as non-Claude-Code clients.

Recognise the probe from the request body itself (max_tokens == 1) once the
User-Agent check has passed, regardless of model, and carry max_tokens into
the ParsedRequest projection so the hot path sees it too. The haiku context
flag and the local haiku interception are unchanged; non-haiku probes reach
the pool as ordinary one-token requests. One output token offers nothing to
a client that already forges the CLI User-Agent, so this widens no attack
surface.

Fixes #6591
2026-09-08 19:12:37 +08:00
Wesley Liddick dbe92a1c24 Merge pull request #6758 from DuskLin/feat/minimax-platform
feat(minimax): 增加 MiniMax 平台支持
2026-09-08 18:50:06 +08:00
r266-tech 188e3a9f90 fix(proxies): reject malformed list responses 2026-09-08 18:29:17 +08:00
刘佳林 10940ecf39 fix(minimax): gofmt MiniMax allowlist comments
golangci-lint gofmt failed on aligned comments in the default upstream host list.
2026-09-08 17:49:11 +08:00
刘佳林 3495635a52 fix(minimax): 补齐阈值停调与额度监控接线
调度阈值评估和渠道监控 quota 取数补上 MiniMax 分支;GetCodingPlanProvider 改为只认官方域名,避免把中转 Key 发到厂商额度端点。composite /v1/models 与 Codex 目录纳入 MiniMax;迁移改名为 237;allowlist 去掉未使用的 www.* / api.minimax.cn,abab 前缀收窄到 5/6/7。
2026-09-08 17:41:29 +08:00
mason0510 c8deeb0b05 fix(grok): 移除 Chat Completions 中不支持的 external_web_access 参数
模型:Claude Sonnet 5
修复内容:在 Grok Chat Completions raw 转发流程中添加 external_web_access 参数过滤,修复 Grok API 返回 400 "Argument not supported: external_web_access" 错误
修改理由:Grok xAI API 不支持 external_web_access 参数,Sub2API Responses API 路径已有过滤,但 Chat Completions 路径缺失,导致 grok-4.5 等模型调用失败返回 502
验证结果:代码已通过本地 Demo 验证递归删除逻辑正常,待远端编译部署后验证真实 API 调用
影响范围:仅影响 Grok 平台 Chat Completions API 请求体处理,不影响 Responses API 和其他平台
回滚方式:git revert 本次提交,或手动移除 openai_gateway_chat_completions_raw.go line 159-163 的 sanitizeGrokUnsupportedFields 调用

[AI: 梁心语/Claude Sonnet 5 @ 2026-09-08T17:30]
2026-09-08 16:41:09 +08:00
22zyfeng-collab b6ee9f0a32 fix(antigravity): preserve OAuth subscription plan across account creation 2026-09-08 15:02:32 +08:00
G.Wood-Sun 6c8ad0bd4a fix: respect registration visibility on login 2026-09-08 14:44:41 +08:00
Rain 7137fae2a6 test(proxies): preserve incoming references when editing backups 2026-09-08 13:58:34 +08:00
Rain 859de4c8fd fix(proxies): allow repeated expiry fallback and preserve origin 2026-09-08 13:56:20 +08:00
Rain e835f79819 fix(proxies): make backup references directed and shareable 2026-09-08 13:56:19 +08:00
feeeei 99eba19ffc fix: 升级 go-redis 至 v9.22.0 修复连接池 nil-ctx panic
上游竞态:调用方超时后 wantConn.ctx 被置 nil,拨号 goroutine 用 nil ctx
创建超时上下文触发 panic(go-redis#3630,v9.18.0 起修复)。
生产环境在连接池饥饿 + 请求超时风暴下每秒触发上万次。

同步将 ZRangeByScore 迁移至 ZRangeArgs:v9.22.0 将其标记为 deprecated,
golangci-lint staticcheck 拦截(SA1019)。ZRangeArgs{ByScore: true} 与原
调用发出的 Redis 命令参数完全等价(LIMIT 编码逻辑相同)。
2026-09-08 13:46:59 +08:00
Rain 38cfd7e2d9 fix(proxies): preserve omitted expiry and fallback settings 2026-09-08 13:38:41 +08:00
Rain 3fc08745a6 fix(model-plaza): include active subscription groups 2026-09-08 13:37:53 +08:00
Rain efc6e4a816 fix: validate announcement and proxy date ranges 2026-09-08 13:37:52 +08:00
savvym df64b5f368 fix(frontend): keep account action menu within viewport 2026-09-08 10:46:56 +08:00
github-actions[bot] 772a0382f0 chore: sync VERSION to 0.2.3 [skip ci] 2026-09-08 02:35:11 +00:00
Wesley Liddick 8fa67d477d Merge pull request #6791 from Wei-Shaw/fix/group-model-allowlist-schema-repair
fix(db): 升级自愈 groups.model_allowlist 列,修复 API 密钥/订阅列表加载失败
2026-09-08 10:03:23 +08:00
tunsuyandCursor ef891643e9 merge(main): resolve antigravity toolConfig conflict for #6689
Keep always-on toolConfig from main (reasoning models without tools) while
retaining the PR behavior that drops mixed built-in tools when client
function tools are present, so includeServerSideToolInvocations stays unused.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-08 09:48:44 +08:00
shaw 4a7739acdf fix(db): converge groups.model_allowlist on upgrade
235 只在「models_list_config 存在且 model_allowlist 不存在」时重命名,而迁移一旦
写入 schema_migrations 就会按「文件名 + checksum」整份跳过,不再重跑。因此只要数据库
在 235 之后回到旧结构(为回滚旧版本镜像手工改回列名、或按旧结构做了部分恢复),
应用依旧能正常启动,但每个关联 groups 的查询都会失败:

    pq: column groups.model_allowlist does not exist

表现为「API 密钥」「我的订阅」「管理端订阅列表」等页面加载失败。

新增可重放的收敛迁移 236,升级即自愈:只有旧列则重命名(数据原样保留);两列并存
则在新列仍为默认空值时回填旧列配置;两列都没有则按默认值补建。收尾统一把
model_allowlist 归一成 NOT NULL DEFAULT '{}',与 ent schema 对齐。

判定列是否存在改用 attrelid = 'groups'::regclass(跟随 search_path),
不再写死 table_schema = 'public',避免与 ALTER TABLE 的解析路径分叉。

Fixes #6780
2026-09-08 09:15:23 +08:00
Wesley Liddick 14e0a49e17 Merge pull request #6767 from alfadb/fix/ollama-cloud-max-tokens-all-platforms
fix(ollama): 修复 DeepSeek 多协议输出上限并自动选择 Bearer 鉴权
2026-09-08 08:26:51 +08:00
Wesley Liddick b439dddfda Merge pull request #6772 from ZeroClover/fix/account-test-model-names
fix(accounts): populate display names for live test models
2026-09-08 08:25:54 +08:00
Qilin You 7e62731863 perf: reduce allocations for large Responses image payloads 2026-09-08 01:54:32 +08:00
xlplboandClaude Fable 5.1 b42157a725 test(service): 修正 Windows 下两例环境相关用例
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAJmqcVA9TLacTCD1bLh4i
2026-09-07 23:21:29 +08:00
xlplboandClaude Fable 5.1 335fcdc1d6 fix(plugin): 提交插件包前先关闭 ZIP 读取器
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SAJmqcVA9TLacTCD1bLh4i
2026-09-07 23:21:29 +08:00
Zero Clover e094a3f40b fix(accounts): populate display names for live test models 2026-09-07 21:59:35 +08:00
alfadb 57387445f3 fix(ollama): select Bearer auth for Anthropic endpoints automatically 2026-09-07 21:54:22 +08:00
alfadb 2fc24d8879 fix(ollama): normalize Messages base URLs before token clamping 2026-09-07 21:54:22 +08:00
alfadb cc91155fe4 fix(ollama): clamp DeepSeek output limits across API protocols 2026-09-07 21:54:21 +08:00
github-actions[bot] b7dba62678 chore: sync VERSION to 0.2.2 [skip ci] 2026-09-07 13:01:57 +00:00
tunsuyandCursor 140e172951 chore: retrigger CI after test fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 20:10:50 +08:00
chihao.ou 941a0487ab fix(accounts): allow removing inactive group assignments 2026-09-07 20:05:08 +08:00
tunsuyandCursor 1d4b9ead45 test(antigravity): align buildTools expectations with mixed-tool drop
Update the leftover buildTools unit test that still expected
googleSearch to coexist with client function declarations.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 20:04:33 +08:00
chihao.ou 7f0f579bb0 fix(accounts): keep failed token refresh accounts selected 2026-09-07 20:01:24 +08:00
chihao.ou dfb3978b9d feat(payment): render help text as sanitized Markdown 2026-09-07 19:51:03 +08:00
刘佳林and刘佳林 6f2295bfc7 test(minimax): 按平台注册表推导调度器 snapshot 期望数量
新增 MiniMax 后 canonical token / 账号查询数增加,硬编码期望值过期。
改为从 schedulerSnapshotPlatforms 派生 bucket 与 query 数量,避免下次加平台再踩坑。
账号查询计数辅助函数仅用于单测,放在 unit test 文件以免 unused。

Co-authored-by: 刘佳林 <DuskLin@users.noreply.github.com>
2026-09-07 11:40:11 +00:00
wucm667 35e69af41b feat(accounts): show estimated OpenAI weekly cost 2026-09-07 18:37:15 +08:00
TT 0d3dbae710 feat(channel-monitor-v2): add user ranking toggle and keep missing TTFT neutral
Give operators a V2 setting (channel_monitor_hide_user_ranking) to hide
the user ranking tab and the /users payload from non-admin channel
monitor v2 viewers. Default false (ranking stays visible). Admin
endpoints and views always keep the ranking.

Also stop painting first-token '-' as a red/empty failure when traffic
is sync-only and has no first-token samples: a missing TTFT is
"not applicable" (health-unknown), not a breached latency budget.

Changes:
- new setting key + fail-open parse (only literal "true" hides), wired
  through public/admin settings DTOs, handlers, and the V2 runtime
- ChannelMonitorV2Service.Users returns an empty list to non-admin
  viewers when the toggle is on
- frontend: feature flag + SettingsView toggle + user V2 view hides the
  ranking tab and skips the /users call
- monitorFormat: isTtftUnavailable/ttftDisplayState keep missing TTFT
  neutral

Tests:
- setting parse defaults to visible and only literal "true" hides
- V2 Users honors the toggle for non-admin viewers
- frontend MetricCell/monitorFormat keep missing TTFT neutral
2026-09-07 18:31:03 +08:00
刘佳林 19382f275e feat(minimax): 增加 MiniMax 平台支持
把 MiniMax 做成与 Kimi / 智谱 / DeepSeek 同级的国产供应商平台:账号创建/编辑、分组、Composite 路由、原生 Token Plan / Coding Plan 额度,以及 OpenAI 兼容模型列表。

额度走 GET api.minimaxi.com|io /v1/api/openplatform/coding_plan/remains(Bearer)。只解析 model_name=general 的 remaining percent;周窗口仅在 current_weekly_status=1 时计入。

Related to #6742
2026-09-07 18:22:56 +08:00
ccemoji be4ab92b2b fix: cancel streaming upstream before closing body 2026-09-07 16:47:51 +08:00
chihao.ou 76efc52fb6 feat(custom-page): make the open-link button draggable 2026-09-06 17:52:24 +08:00
tunsuyandCursor 6c2d2ed043 fix(antigravity): drop mixed web_search when client tools present
Antigravity v1internal rejects googleSearch/codeExecution mixed with
functionDeclarations even with includeServerSideToolInvocations (#6464).
Codex enables web_search by default alongside shell tools, which made
Sub2API return opaque upstream_error. Prefer client function tools so
agent sessions keep working; keep web_search-only fallback intact.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-06 13:23:53 +08:00
yan9651688 58f461b087 feat(keys): add bulk editing to API key management 2026-09-06 11:25:33 +08:00
whhphd b97a798ebc fix(grok): release abandoned media slots and preserve video ownership
Claim scheduler-acquired slots before media eligibility checks and task-owner
validation. Release abandoned attempts immediately and retain once-only
cleanup on cancellation and forwarding completion.

Select only the resolved video owner, reuse bounded account waiting without
sticky escape, and preserve the task binding and TTL. Keep generation
failover and owner/billing isolation intact.

Regression tests reproduce unreleased account slots on upstream ab99d56e9
and pass with this change. Validation: go test ./..., unit and integration
suites, focused race tests, backend build, and golangci-lint (0 issues).
2026-09-05 20:25:08 +08:00
chihao.ou 017cc62e41 feat(auth): require password confirmation during registration 2026-09-05 19:19:35 +08:00
doublepi123 8ed57b000c feat(antigravity): support Gemini 3.7 Flash models
Google released Gemini 3.7 Flash on 2026-08-13. Register it alongside 3.8
using the same shape as the existing 3.6 entries: bare id plus the
Antigravity -high/-low/-medium/-tiered thinking tiers, the model catalog,
the custom-mapping passthroughs, fallback pricing and the tier alias
normalizer.

Pricing matches 3.8 at $0.75 input / $3.75 output / $0.075 cached input
per MTok. Both are promotional through 2026-12-31 and double on
2027-01-01; the constants note this.

Also corrects the 3.8 catalog timestamps to the documented GA dates.
2026-09-04 16:04:47 +08:00
doublepi123 27bcec7645 feat(antigravity): support Gemini 3.8 Flash models
Google released Gemini 3.8 Flash on 2026-09-02. Register the model and its
Antigravity thinking tiers the same way Gemini 3.6 Flash is registered:

- DefaultAntigravityModelMapping: bare id plus -high/-low/-medium/-tiered
- geminiModels catalog exposed through /antigravity/v1/models
- ensureAntigravityDefaultPassthroughs so accounts with a custom
  model_mapping still resolve the new ids
- fallback pricing and the thinking-tier alias normalizer, so the tiers
  share the base model price card instead of failing pricing lookup

Fallback rates are Google's published prices: $0.75 input / $3.75 output /
$0.075 cached input per MTok. These are promotional through 2026-12-31 and
double on 2027-01-01; the constant carries a note to that effect.

Refs #6523
2026-09-04 16:04:47 +08:00
anguobao123 1859519573 feat(deploy): support optional Apple network subnet 2026-09-02 20:08:57 +08:00
Soulxyz 8ed48e27a6 fix(setup): avoid hardcoded postgres connection 2026-09-02 15:30:59 +08:00
WUJI-Labs f5e4da5e72 style(upstream): gofmt http_upstream_profile.go 2026-09-02 12:40:23 +08:00
WUJI-Labs 9dc4c40bff test(upstream): align HTTP/2 keepalive tests with long-stream profile API 2026-09-02 12:28:37 +08:00
WUJI-Labs 81fd853005 feat(upstream): add HTTP/2 PING keepalive for long LLM generation streams
Long-running LLM inferences (such as high-thinking Gemini models) may experience
silent TCP disconnections across intermediate proxies, NAT gateways, or firewalls
when no data frames are emitted for tens of seconds to minutes.

Go's default http2.Transport sets ReadIdleTimeout=0 (disabling active PING checks).
This commit introduces HTTPUpstreamProfileLongStream with:
- ReadIdleTimeout = 10s
- PingTimeout = 5s

When a pooled H2 connection stays idle for 10s during long streams, active PING frames
are dispatched. Dead connections failing to respond within 5s are proactively evicted,
preventing client generation requests from hanging on dead sockets or failing with EOF/502.

Signed-off-by: WUJI-Labs <zoujincool@gmail.com>
2026-09-02 12:11:17 +08:00
aofee 01bd9b71a2 feat: add Grok media eligibility controls 2026-09-02 11:50:33 +08:00
Ray a10ff1255c fix(openai-ws): classify canceled client streams 2026-08-31 21:04:29 +08:00
cyh 163eb7ffae fix(ops): bound database-backed system logs 2026-08-31 19:04:58 +08:00
Clansty 5fea83dcae fix: 修复429未耗尽额度仍触发账号退避
- 关闭429默认回避时不再创建OAuth运行时冷却

- 仅在x-codex窗口达到100%时使用上游重置时间

- 补充未耗尽额度和关闭开关的回归测试
2026-08-30 00:02:14 +08:00
spongehah 6d339ec93b fix: honor persisted cooldown when dropping in-process account blocks
- Treat TempUnschedulableUntil/RateLimitResetAt/OverloadUntil as source of truth during account selection
- Drop stale local account-level runtime blocks with generation+deadline CAS
- Leave model-scoped transient and Grok quota blocks unchanged
2026-08-28 14:38:15 +08:00
haruka 1ee929e4b9 fix: broadcast channel cache invalidations 2026-08-26 11:16:32 +08:00
574 changed files with 29341 additions and 2090 deletions
+3
View File
@@ -4,6 +4,9 @@ FRONTEND_CRITICAL_VITEST := \
src/i18n/__tests__/localeKeyCompleteness.spec.ts \
src/api/__tests__/client.spec.ts \
src/api/__tests__/tokenRefresh.spec.ts \
src/api/__tests__/keys.bulkUpdate.spec.ts \
src/components/keys/__tests__/BulkEditKeysModal.spec.ts \
src/views/user/__tests__/KeysView.spec.ts \
src/api/__tests__/channelMonitorV2.spec.ts \
src/views/auth/__tests__/LinuxDoCallbackView.spec.ts \
src/views/auth/__tests__/WechatCallbackView.spec.ts \
+10
View File
@@ -166,6 +166,16 @@ Please read the following carefully before using this project:
<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>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> is a source-level API gateway with its own massive account pool, purpose-built as an upstream relay for downstream transit stations and high-frequency developers — saving you the hassle of building and maintaining your own account pool. ✅ Direct from source: self-operated account pool with no middleman markup; 🧧 Cost crusher: combined rate multiplier as low as 0.03x, just 0.35% of official pricing; 🚀 Blazing fast: first-token latency < 1s, on par with the official native API. <a href="https://www.pp.dog/register?aff=SUB2API">Get started with PP.dog now</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy provides high-quality residential proxies built for web scraping, automation, and multi-account management. Get a free trial with non-expiring traffic, pricing starting as low as $0.3/GB, unlimited concurrent connections, and intelligent IP rotation for a smoother, more stable proxy experience. Use discount code COLA10 to get 10% off and start scaling your projects with reliable residential proxies today. <a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">Get started with ColaProxy</a></td>
</tr>
</table>
## Overview
+42
View File
@@ -169,6 +169,16 @@
<td>感谢 AxisNow 赞助了本项目!<a href="https://www.axisnow.io/">AxisNow</a> 保护并加速网站与 API,兼顾中国大陆及全球的访问体验,并通过客户端 SDK,将加速与安全能力延伸至原生/移动 App — <strong>自建私有部署 CDN</strong>|<strong>订阅式高防 CDN</strong>|<strong>自主可控、灵活组合的 CDN 网络</strong>。</td>
</tr>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> 是自建账号池的源头 API 网关,专注为下游中转站与高频开发者提供 API 网关中继服务,帮您省去自建号池的一切麻烦——✅ 源头直供:自持海量账号池,无中间商赚差价;🧧 成本屠夫:综合倍率低至 0.03x,成本仅为官方的千分之3.5;🚀 极速体验:首 Token 延迟 < 1s,流畅媲美官方原生 API。<a href="https://www.pp.dog/register?aff=SUB2API">立即接入PP.dog</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy 提供专为网页抓取、自动化和多账号管理打造的高质量住宅代理。免费试用,流量永不过期,价格低至 $0.3/GB,支持无限并发连接和智能 IP 轮换,带来更流畅、更稳定的代理体验。使用优惠码 COLA10 立享 9 折优惠,立即开始使用可靠的住宅代理扩展您的项目。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">立即开始使用 ColaProxy</a></td>
</tr>
</table>
## 项目概述
@@ -217,6 +227,30 @@ underscores_in_headers on;
Nginx 默认会丢弃名称中含下划线的请求头(如 `session_id`),这会导致多账号环境下的粘性会话功能失效。
## Codex Fast/Flex 策略说明
管理员后台的 `系统设置 -> 网关服务 -> OpenAI Fast/Flex 策略` 只负责处理请求体中的 `service_tier`,不会修改 Codex 客户端的模型目录,也不会让 Codex UI 自动出现 Speed 或 `/fast` 选项。
策略支持以下处理方式:
- `pass`:保留客户端传入的 `service_tier`;`fast` 会规范为上游使用的 `priority`。
- `filter`:移除 `service_tier`,按普通优先级请求。
- `block`:拒绝匹配的 Fast/Flex 请求。
- `force_priority`:将匹配请求强制设置为 `priority`,会按 Priority/Fast 价格计费。为避免升级后改变既有规则语义,`all` 只匹配显式存在的 tier;如需让省略 `service_tier` 的 OpenAI 请求也强制升级,必须新增 `service_tier=missing + force_priority` 规则。非 OpenAI 平台不会执行缺省 tier 注入。这种方式可以让请求实际使用 Fast,但 Codex UI 仍可能不显示 Fast 状态。
Codex 的 Fast 入口由客户端模型目录驱动。只有当前模型目录声明了 `additional_speed_tiers: ["fast"]` 和对应的 `service_tiers`,Codex 才会显示 `/fast`。通过 API Key 或自定义模型提供商连接 Sub2API 时,如果模型目录没有这些字段,即使后台配置了 `force_priority`,重启 Codex 后也不会出现 Speed 选项。
客户端可在 `~/.codex/config.toml` 中直接指定默认请求级别:
```toml
service_tier = "fast"
[features]
fast_mode = true
```
其中 `service_tier = "fast"` 会让请求携带 Fast 设置;`features.fast_mode` 只启用客户端 Fast 功能。模型目录没有声明 Fast 能力时,`/fast` 仍可能不显示。可通过 Sub2API 使用记录确认最终 `service_tier` 是否为 `priority`。
---
## 部署方式
@@ -696,6 +730,14 @@ go generate ./cmd/server
---
## OpenAI 图片模型
支持 `gpt-image-2.5-flare`、`gpt-image-2.5-sunburst` 及其 `2026-09-08` 日期快照,可通过 `/v1/images/generations`、`/v1/images/edits` 调用。`quality` 支持 `xhigh`、`max`、`auto`,合法自定义尺寸和图片 usage 明细保持透传。
OAuth / Setup Token 图片请求使用 Responses 主控模型调用 `image_generation` 工具,默认主控为 `gpt-5.6-luna`。可设置 `SUB2API_IMAGES_MAIN_MODEL` 切换为账号支持的文本模型;Docker Compose 用户修改 `.env` 后执行 `docker compose up -d` 重建容器。该配置不会替换所选图片模型,也不会覆盖 `/v1/responses` 请求中已经提供的文本主控模型。
升级后,无模型限制的账号自动支持新模型。已有显式账号映射或分组白名单需要加入两个 2.5 模型(日期快照按需加入);升级不会自动扩大管理员设置的模型权限。新模型内置价格包含官方文本输入、图片输入和图片输出 token 费率,远端价格表尚未更新时使用内置 2.5 价格;实际按次或按 token 计费仍由既有分组/渠道配置决定。
## 简易模式
简易模式适合个人开发者或内部团队快速使用,不依赖完整 SaaS 功能。
+10
View File
@@ -168,6 +168,16 @@
<td>AxisNow のご支援に感謝します!<a href="https://www.axisnow.io/">AxisNow</a> はウェブサイトと API を保護・高速化し、中国本土および世界中で快適なアクセス体験を実現します。クライアント SDK により、ネイティブ/モバイルアプリにも高速化・セキュリティ機能を拡張できます — <strong>自社構築・プライベートデプロイ CDN</strong>|<strong>サブスクリプション型高防御 CDN</strong>|<strong>自律制御で柔軟に組み合わせられる CDN ネットワーク</strong>。</td>
</tr>
<tr>
<td width="180"><a href="https://pp.dog/register?aff=SUB2API"><img src="assets/partners/logos/ppdog.png" alt="PP.dog" width="150"></a></td>
<td><a href="https://pp.dog/register?aff=SUB2API">PP.dog</a> は自社運用の大規模アカウントプールを持つソースレベルの API ゲートウェイで、下流の中継ステーションや高頻度開発者向けに API ゲートウェイリレーサービスを提供し、アカウントプールの自主構築にかかるすべての手間を省きます。✅ ソース直供:自社運用の大規模アカウントプール、中間マージンなし;🧧 コスト最強:総合レート倍率わずか 0.03x、コストは公式の 0.35% のみ;🚀 超高速体験:ファーストトークンレイテンシ 1 秒未満、公式ネイティブ API に匹敵するスムーズさ。<a href="https://www.pp.dog/register?aff=SUB2API">今すぐ PP.dog に接続</a></td>
</tr>
<tr>
<td width="180"><a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api"><img src="assets/partners/logos/cola-proxy.jpg" alt="ColaProxy" width="150"></a></td>
<td>ColaProxy は、Web スクレイピング、自動化、マルチアカウント管理向けに構築された高品質な住宅プロキシを提供します。有効期限なしのトラフィックで無料トライアルが利用でき、料金は $0.3/GB から、無制限の同時接続とインテリジェントな IP ローテーションにより、よりスムーズで安定したプロキシ体験を実現します。割引コード COLA10 で 10% オフ、信頼性の高い住宅プロキシでプロジェクトを今すぐスケールしましょう。<a href="https://colaproxy.com/?utm_source=sub2api&utm_medium=sub2api&ref=sub2api">ColaProxy を始める</a></td>
</tr>
</table>
## 概要
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB

+1 -1
View File
@@ -1 +1 @@
0.2.1
0.2.4
+6 -5
View File
@@ -117,10 +117,12 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
openAI403CounterCache := repository.NewOpenAI403CounterCache(redisClient)
geminiTokenCache := repository.NewGeminiTokenCache(redisClient)
compositeTokenCacheInvalidator := service.NewCompositeTokenCacheInvalidator(geminiTokenCache)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator)
httpUpstream := repository.NewHTTPUpstream(configConfig)
leaderLockCache := repository.NewLeaderLockCache(redisClient)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
rateLimitService := service.ProvideRateLimitService(accountRepository, usageLogRepository, configConfig, geminiQuotaService, tempUnschedCache, timeoutCounterCache, openAI403CounterCache, settingService, compositeTokenCacheInvalidator, ollamaCloudUsageService)
identityCache := repository.NewIdentityCache(redisClient)
identityService := service.NewIdentityService(identityCache)
httpUpstream := repository.NewHTTPUpstream(configConfig)
timingWheelService, err := service.ProvideTimingWheelService()
if err != nil {
return nil, err
@@ -137,7 +139,8 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
tlsFingerprintProfileCache := repository.NewTLSFingerprintProfileCache(redisClient)
tlsFingerprintProfileService := service.NewTLSFingerprintProfileService(tlsFingerprintProfileRepository, tlsFingerprintProfileCache)
channelRepository := repository.NewChannelRepository(db)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService)
channelCachePubSub := repository.NewChannelCache(redisClient)
channelService := service.NewChannelService(channelRepository, groupRepository, apiKeyAuthCacheInvalidator, pricingService, channelCachePubSub)
modelPricingResolver := service.NewModelPricingResolver(channelService, billingService)
compositeModelRouteRepository := repository.NewCompositeModelRouteRepository(client)
compositeRouteResolver := service.NewCompositeRouteResolver(compositeModelRouteRepository)
@@ -182,7 +185,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
dashboardAggregationRepository := repository.NewDashboardAggregationRepository(db)
dashboardStatsCache := repository.NewDashboardCache(redisClient, configConfig)
dashboardService := service.NewDashboardService(usageLogRepository, dashboardAggregationRepository, dashboardStatsCache, configConfig)
leaderLockCache := repository.NewLeaderLockCache(redisClient)
dashboardAggregationService := service.ProvideDashboardAggregationService(dashboardAggregationRepository, timingWheelService, leaderLockCache, db, configConfig)
dashboardHandler := admin.NewDashboardHandler(dashboardService, dashboardAggregationService)
adminGroupRepository := repository.NewAdminGroupRepository(client, db)
@@ -283,7 +285,6 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
auditLogService := service.ProvideAuditLogService(auditLogRepository, settingService)
auditLogHandler := admin.NewAuditLogHandler(auditLogService, totpService)
upstreamBillingProbeService := service.ProvideUpstreamBillingProbeService(accountRepository, accountTestService, settingService, leaderLockCache, db)
ollamaCloudUsageService := service.ProvideOllamaCloudUsageService(accountRepository, httpUpstream, settingService, secretEncryptor, configConfig, leaderLockCache, db)
adminHandlers := handler.ProvideAdminHandlers(dashboardHandler, adminUserHandler, groupHandler, accountHandler, adminAnnouncementHandler, dataManagementHandler, backupHandler, oAuthHandler, openAIOAuthHandler, geminiOAuthHandler, antigravityOAuthHandler, grokOAuthHandler, cnProviderHandler, proxyHandler, adminRedeemHandler, promoHandler, settingHandler, opsHandler, systemHandler, adminSubscriptionHandler, adminUsageHandler, userAttributeHandler, errorPassthroughHandler, tlsFingerprintProfileHandler, pluginHandler, adminAPIKeyHandler, scheduledTestHandler, channelHandler, channelMonitorHandler, channelMonitorRequestTemplateHandler, contentModerationHandler, promptAdminHandler, paymentHandler, affiliateHandler, complianceHandler, auditLogHandler, upstreamBillingProbeService, ollamaCloudUsageService)
usageRecordWorkerPool := service.NewUsageRecordWorkerPool(configConfig)
userMsgQueueCache := repository.NewUserMsgQueueCache(redisClient)
+3 -1
View File
@@ -182,6 +182,8 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
ProviderOpencodeGo Provider = "opencode_go"
)
func (pr Provider) String() string {
@@ -191,7 +193,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo:
return nil
default:
return fmt.Errorf("channelmonitor: invalid enum value for provider field: %q", pr)
@@ -108,6 +108,8 @@ const (
ProviderKimi Provider = "kimi"
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
ProviderOpencodeGo Provider = "opencode_go"
)
func (pr Provider) String() string {
@@ -117,7 +119,7 @@ func (pr Provider) String() string {
// ProviderValidator is a validator for the "provider" field enum values. It is called by the builders before save.
func ProviderValidator(pr Provider) error {
switch pr {
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek:
case ProviderOpenai, ProviderAnthropic, ProviderGemini, ProviderGrok, ProviderAntigravity, ProviderKimi, ProviderZhipu, ProviderDeepseek, ProviderMinimax, ProviderOpencodeGo:
return nil
default:
return fmt.Errorf("channelmonitorrequesttemplate: invalid enum value for provider field: %q", pr)
+17 -1
View File
@@ -4595,6 +4595,22 @@ func (c *ProxyClient) QueryAccounts(_m *Proxy) *AccountQuery {
return query
}
// QueryPrimaryProxies queries the primary_proxies edge of a Proxy.
func (c *ProxyClient) QueryPrimaryProxies(_m *Proxy) *ProxyQuery {
query := (&ProxyClient{config: c.config}).Query()
query.path = func(context.Context) (fromV *sql.Selector, _ error) {
id := _m.ID
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, id),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
}
return query
}
// QueryBackupProxy queries the backup_proxy edge of a Proxy.
func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
query := (&ProxyClient{config: c.config}).Query()
@@ -4603,7 +4619,7 @@ func (c *ProxyClient) QueryBackupProxy(_m *Proxy) *ProxyQuery {
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, id),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
)
fromV = sqlgraph.Neighbors(_m.driver.Dialect(), step)
return fromV, nil
+3 -3
View File
@@ -623,7 +623,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}},
{Name: "check_mode", Type: field.TypeString, Size: 32, Default: "probe"},
{Name: "account_id", Type: field.TypeInt64, Nullable: true},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
@@ -776,7 +776,7 @@ var (
{Name: "created_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "updated_at", Type: field.TypeTime, SchemaType: map[string]string{"postgres": "timestamptz"}},
{Name: "name", Type: field.TypeString, Size: 100},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek"}},
{Name: "provider", Type: field.TypeEnum, Enums: []string{"openai", "anthropic", "gemini", "grok", "antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"}},
{Name: "api_mode", Type: field.TypeString, Size: 32, Default: "chat_completions"},
{Name: "description", Type: field.TypeString, Nullable: true, Size: 500, Default: ""},
{Name: "extra_headers", Type: field.TypeJSON},
@@ -1413,7 +1413,7 @@ var (
{Name: "expires_at", Type: field.TypeTime, Nullable: true},
{Name: "fallback_mode", Type: field.TypeString, Size: 20, Default: "none"},
{Name: "expiry_warn_days", Type: field.TypeInt, Default: 7},
{Name: "backup_proxy_id", Type: field.TypeInt64, Unique: true, Nullable: true},
{Name: "backup_proxy_id", Type: field.TypeInt64, Nullable: true},
}
// ProxiesTable holds the schema information for the "proxies" table.
ProxiesTable = &schema.Table{
+113 -30
View File
@@ -37366,33 +37366,36 @@ func (m *PromoCodeUsageMutation) ResetEdge(name string) error {
// ProxyMutation represents an operation that mutates the Proxy nodes in the graph.
type ProxyMutation struct {
config
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
name *string
protocol *string
host *string
port *int
addport *int
username *string
password *string
status *string
expires_at *time.Time
fallback_mode *string
expiry_warn_days *int
addexpiry_warn_days *int
clearedFields map[string]struct{}
accounts map[int64]struct{}
removedaccounts map[int64]struct{}
clearedaccounts bool
backup_proxy *int64
clearedbackup_proxy bool
done bool
oldValue func(context.Context) (*Proxy, error)
predicates []predicate.Proxy
op Op
typ string
id *int64
created_at *time.Time
updated_at *time.Time
deleted_at *time.Time
name *string
protocol *string
host *string
port *int
addport *int
username *string
password *string
status *string
expires_at *time.Time
fallback_mode *string
expiry_warn_days *int
addexpiry_warn_days *int
clearedFields map[string]struct{}
accounts map[int64]struct{}
removedaccounts map[int64]struct{}
clearedaccounts bool
primary_proxies map[int64]struct{}
removedprimary_proxies map[int64]struct{}
clearedprimary_proxies bool
backup_proxy *int64
clearedbackup_proxy bool
done bool
oldValue func(context.Context) (*Proxy, error)
predicates []predicate.Proxy
}
var _ ent.Mutation = (*ProxyMutation)(nil)
@@ -38156,6 +38159,60 @@ func (m *ProxyMutation) ResetAccounts() {
m.removedaccounts = nil
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by ids.
func (m *ProxyMutation) AddPrimaryProxyIDs(ids ...int64) {
if m.primary_proxies == nil {
m.primary_proxies = make(map[int64]struct{})
}
for i := range ids {
m.primary_proxies[ids[i]] = struct{}{}
}
}
// ClearPrimaryProxies clears the "primary_proxies" edge to the Proxy entity.
func (m *ProxyMutation) ClearPrimaryProxies() {
m.clearedprimary_proxies = true
}
// PrimaryProxiesCleared reports if the "primary_proxies" edge to the Proxy entity was cleared.
func (m *ProxyMutation) PrimaryProxiesCleared() bool {
return m.clearedprimary_proxies
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to the Proxy entity by IDs.
func (m *ProxyMutation) RemovePrimaryProxyIDs(ids ...int64) {
if m.removedprimary_proxies == nil {
m.removedprimary_proxies = make(map[int64]struct{})
}
for i := range ids {
delete(m.primary_proxies, ids[i])
m.removedprimary_proxies[ids[i]] = struct{}{}
}
}
// RemovedPrimaryProxies returns the removed IDs of the "primary_proxies" edge to the Proxy entity.
func (m *ProxyMutation) RemovedPrimaryProxiesIDs() (ids []int64) {
for id := range m.removedprimary_proxies {
ids = append(ids, id)
}
return
}
// PrimaryProxiesIDs returns the "primary_proxies" edge IDs in the mutation.
func (m *ProxyMutation) PrimaryProxiesIDs() (ids []int64) {
for id := range m.primary_proxies {
ids = append(ids, id)
}
return
}
// ResetPrimaryProxies resets all changes to the "primary_proxies" edge.
func (m *ProxyMutation) ResetPrimaryProxies() {
m.primary_proxies = nil
m.clearedprimary_proxies = false
m.removedprimary_proxies = nil
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (m *ProxyMutation) ClearBackupProxy() {
m.clearedbackup_proxy = true
@@ -38597,10 +38654,13 @@ func (m *ProxyMutation) ResetField(name string) error {
// AddedEdges returns all edge names that were set/added in this mutation.
func (m *ProxyMutation) AddedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.accounts != nil {
edges = append(edges, proxy.EdgeAccounts)
}
if m.primary_proxies != nil {
edges = append(edges, proxy.EdgePrimaryProxies)
}
if m.backup_proxy != nil {
edges = append(edges, proxy.EdgeBackupProxy)
}
@@ -38617,6 +38677,12 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case proxy.EdgePrimaryProxies:
ids := make([]ent.Value, 0, len(m.primary_proxies))
for id := range m.primary_proxies {
ids = append(ids, id)
}
return ids
case proxy.EdgeBackupProxy:
if id := m.backup_proxy; id != nil {
return []ent.Value{*id}
@@ -38627,10 +38693,13 @@ func (m *ProxyMutation) AddedIDs(name string) []ent.Value {
// RemovedEdges returns all edge names that were removed in this mutation.
func (m *ProxyMutation) RemovedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.removedaccounts != nil {
edges = append(edges, proxy.EdgeAccounts)
}
if m.removedprimary_proxies != nil {
edges = append(edges, proxy.EdgePrimaryProxies)
}
return edges
}
@@ -38644,16 +38713,25 @@ func (m *ProxyMutation) RemovedIDs(name string) []ent.Value {
ids = append(ids, id)
}
return ids
case proxy.EdgePrimaryProxies:
ids := make([]ent.Value, 0, len(m.removedprimary_proxies))
for id := range m.removedprimary_proxies {
ids = append(ids, id)
}
return ids
}
return nil
}
// ClearedEdges returns all edge names that were cleared in this mutation.
func (m *ProxyMutation) ClearedEdges() []string {
edges := make([]string, 0, 2)
edges := make([]string, 0, 3)
if m.clearedaccounts {
edges = append(edges, proxy.EdgeAccounts)
}
if m.clearedprimary_proxies {
edges = append(edges, proxy.EdgePrimaryProxies)
}
if m.clearedbackup_proxy {
edges = append(edges, proxy.EdgeBackupProxy)
}
@@ -38666,6 +38744,8 @@ func (m *ProxyMutation) EdgeCleared(name string) bool {
switch name {
case proxy.EdgeAccounts:
return m.clearedaccounts
case proxy.EdgePrimaryProxies:
return m.clearedprimary_proxies
case proxy.EdgeBackupProxy:
return m.clearedbackup_proxy
}
@@ -38690,6 +38770,9 @@ func (m *ProxyMutation) ResetEdge(name string) error {
case proxy.EdgeAccounts:
m.ResetAccounts()
return nil
case proxy.EdgePrimaryProxies:
m.ResetPrimaryProxies()
return nil
case proxy.EdgeBackupProxy:
m.ResetBackupProxy()
return nil
+18 -2
View File
@@ -55,11 +55,13 @@ type Proxy struct {
type ProxyEdges struct {
// Accounts holds the value of the accounts edge.
Accounts []*Account `json:"accounts,omitempty"`
// PrimaryProxies holds the value of the primary_proxies edge.
PrimaryProxies []*Proxy `json:"primary_proxies,omitempty"`
// BackupProxy holds the value of the backup_proxy edge.
BackupProxy *Proxy `json:"backup_proxy,omitempty"`
// loadedTypes holds the information for reporting if a
// type was loaded (or requested) in eager-loading or not.
loadedTypes [2]bool
loadedTypes [3]bool
}
// AccountsOrErr returns the Accounts value or an error if the edge
@@ -71,12 +73,21 @@ func (e ProxyEdges) AccountsOrErr() ([]*Account, error) {
return nil, &NotLoadedError{edge: "accounts"}
}
// PrimaryProxiesOrErr returns the PrimaryProxies value or an error if the edge
// was not loaded in eager-loading.
func (e ProxyEdges) PrimaryProxiesOrErr() ([]*Proxy, error) {
if e.loadedTypes[1] {
return e.PrimaryProxies, nil
}
return nil, &NotLoadedError{edge: "primary_proxies"}
}
// BackupProxyOrErr returns the BackupProxy value or an error if the edge
// was not loaded in eager-loading, or loaded but was not found.
func (e ProxyEdges) BackupProxyOrErr() (*Proxy, error) {
if e.BackupProxy != nil {
return e.BackupProxy, nil
} else if e.loadedTypes[1] {
} else if e.loadedTypes[2] {
return nil, &NotFoundError{label: proxy.Label}
}
return nil, &NotLoadedError{edge: "backup_proxy"}
@@ -221,6 +232,11 @@ func (_m *Proxy) QueryAccounts() *AccountQuery {
return NewProxyClient(_m.config).QueryAccounts(_m)
}
// QueryPrimaryProxies queries the "primary_proxies" edge of the Proxy entity.
func (_m *Proxy) QueryPrimaryProxies() *ProxyQuery {
return NewProxyClient(_m.config).QueryPrimaryProxies(_m)
}
// QueryBackupProxy queries the "backup_proxy" edge of the Proxy entity.
func (_m *Proxy) QueryBackupProxy() *ProxyQuery {
return NewProxyClient(_m.config).QueryBackupProxy(_m)
+28 -1
View File
@@ -45,6 +45,8 @@ const (
FieldExpiryWarnDays = "expiry_warn_days"
// EdgeAccounts holds the string denoting the accounts edge name in mutations.
EdgeAccounts = "accounts"
// EdgePrimaryProxies holds the string denoting the primary_proxies edge name in mutations.
EdgePrimaryProxies = "primary_proxies"
// EdgeBackupProxy holds the string denoting the backup_proxy edge name in mutations.
EdgeBackupProxy = "backup_proxy"
// Table holds the table name of the proxy in the database.
@@ -56,6 +58,10 @@ const (
AccountsInverseTable = "accounts"
// AccountsColumn is the table column denoting the accounts relation/edge.
AccountsColumn = "proxy_id"
// PrimaryProxiesTable is the table that holds the primary_proxies relation/edge.
PrimaryProxiesTable = "proxies"
// PrimaryProxiesColumn is the table column denoting the primary_proxies relation/edge.
PrimaryProxiesColumn = "backup_proxy_id"
// BackupProxyTable is the table that holds the backup_proxy relation/edge.
BackupProxyTable = "proxies"
// BackupProxyColumn is the table column denoting the backup_proxy relation/edge.
@@ -219,6 +225,20 @@ func ByAccounts(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
}
}
// ByPrimaryProxiesCount orders the results by primary_proxies count.
func ByPrimaryProxiesCount(opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborsCount(s, newPrimaryProxiesStep(), opts...)
}
}
// ByPrimaryProxies orders the results by primary_proxies terms.
func ByPrimaryProxies(term sql.OrderTerm, terms ...sql.OrderTerm) OrderOption {
return func(s *sql.Selector) {
sqlgraph.OrderByNeighborTerms(s, newPrimaryProxiesStep(), append([]sql.OrderTerm{term}, terms...)...)
}
}
// ByBackupProxyField orders the results by backup_proxy field.
func ByBackupProxyField(field string, opts ...sql.OrderTermOption) OrderOption {
return func(s *sql.Selector) {
@@ -232,10 +252,17 @@ func newAccountsStep() *sqlgraph.Step {
sqlgraph.Edge(sqlgraph.O2M, true, AccountsTable, AccountsColumn),
)
}
func newPrimaryProxiesStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
)
}
func newBackupProxyStep() *sqlgraph.Step {
return sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.To(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
)
}
+24 -1
View File
@@ -913,12 +913,35 @@ func HasAccountsWith(preds ...predicate.Account) predicate.Proxy {
})
}
// HasPrimaryProxies applies the HasEdge predicate on the "primary_proxies" edge.
func HasPrimaryProxies() predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, PrimaryProxiesTable, PrimaryProxiesColumn),
)
sqlgraph.HasNeighbors(s, step)
})
}
// HasPrimaryProxiesWith applies the HasEdge predicate on the "primary_proxies" edge with a given conditions (other predicates).
func HasPrimaryProxiesWith(preds ...predicate.Proxy) predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := newPrimaryProxiesStep()
sqlgraph.HasNeighborsWith(s, step, func(s *sql.Selector) {
for _, p := range preds {
p(s)
}
})
})
}
// HasBackupProxy applies the HasEdge predicate on the "backup_proxy" edge.
func HasBackupProxy() predicate.Proxy {
return predicate.Proxy(func(s *sql.Selector) {
step := sqlgraph.NewStep(
sqlgraph.From(Table, FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, BackupProxyTable, BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, BackupProxyTable, BackupProxyColumn),
)
sqlgraph.HasNeighbors(s, step)
})
+33 -2
View File
@@ -202,6 +202,21 @@ func (_c *ProxyCreate) AddAccounts(v ...*Account) *ProxyCreate {
return _c.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_c *ProxyCreate) AddPrimaryProxyIDs(ids ...int64) *ProxyCreate {
_c.mutation.AddPrimaryProxyIDs(ids...)
return _c
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_c *ProxyCreate) AddPrimaryProxies(v ...*Proxy) *ProxyCreate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _c.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_c *ProxyCreate) SetBackupProxy(v *Proxy) *ProxyCreate {
return _c.SetBackupProxyID(v.ID)
@@ -432,13 +447,29 @@ func (_c *ProxyCreate) createSpec() (*Proxy, *sqlgraph.CreateSpec) {
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges = append(_spec.Edges, edge)
}
if nodes := _c.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
+92 -16
View File
@@ -21,13 +21,14 @@ import (
// ProxyQuery is the builder for querying Proxy entities.
type ProxyQuery struct {
config
ctx *QueryContext
order []proxy.OrderOption
inters []Interceptor
predicates []predicate.Proxy
withAccounts *AccountQuery
withBackupProxy *ProxyQuery
modifiers []func(*sql.Selector)
ctx *QueryContext
order []proxy.OrderOption
inters []Interceptor
predicates []predicate.Proxy
withAccounts *AccountQuery
withPrimaryProxies *ProxyQuery
withBackupProxy *ProxyQuery
modifiers []func(*sql.Selector)
// intermediate query (i.e. traversal path).
sql *sql.Selector
path func(context.Context) (*sql.Selector, error)
@@ -86,6 +87,28 @@ func (_q *ProxyQuery) QueryAccounts() *AccountQuery {
return query
}
// QueryPrimaryProxies chains the current query on the "primary_proxies" edge.
func (_q *ProxyQuery) QueryPrimaryProxies() *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
query.path = func(ctx context.Context) (fromU *sql.Selector, err error) {
if err := _q.prepareQuery(ctx); err != nil {
return nil, err
}
selector := _q.sqlQuery(ctx)
if err := selector.Err(); err != nil {
return nil, err
}
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2M, true, proxy.PrimaryProxiesTable, proxy.PrimaryProxiesColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
}
return query
}
// QueryBackupProxy chains the current query on the "backup_proxy" edge.
func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
@@ -100,7 +123,7 @@ func (_q *ProxyQuery) QueryBackupProxy() *ProxyQuery {
step := sqlgraph.NewStep(
sqlgraph.From(proxy.Table, proxy.FieldID, selector),
sqlgraph.To(proxy.Table, proxy.FieldID),
sqlgraph.Edge(sqlgraph.O2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
sqlgraph.Edge(sqlgraph.M2O, false, proxy.BackupProxyTable, proxy.BackupProxyColumn),
)
fromU = sqlgraph.SetNeighbors(_q.driver.Dialect(), step)
return fromU, nil
@@ -295,13 +318,14 @@ func (_q *ProxyQuery) Clone() *ProxyQuery {
return nil
}
return &ProxyQuery{
config: _q.config,
ctx: _q.ctx.Clone(),
order: append([]proxy.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.Proxy{}, _q.predicates...),
withAccounts: _q.withAccounts.Clone(),
withBackupProxy: _q.withBackupProxy.Clone(),
config: _q.config,
ctx: _q.ctx.Clone(),
order: append([]proxy.OrderOption{}, _q.order...),
inters: append([]Interceptor{}, _q.inters...),
predicates: append([]predicate.Proxy{}, _q.predicates...),
withAccounts: _q.withAccounts.Clone(),
withPrimaryProxies: _q.withPrimaryProxies.Clone(),
withBackupProxy: _q.withBackupProxy.Clone(),
// clone intermediate query.
sql: _q.sql.Clone(),
path: _q.path,
@@ -319,6 +343,17 @@ func (_q *ProxyQuery) WithAccounts(opts ...func(*AccountQuery)) *ProxyQuery {
return _q
}
// WithPrimaryProxies tells the query-builder to eager-load the nodes that are connected to
// the "primary_proxies" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *ProxyQuery) WithPrimaryProxies(opts ...func(*ProxyQuery)) *ProxyQuery {
query := (&ProxyClient{config: _q.config}).Query()
for _, opt := range opts {
opt(query)
}
_q.withPrimaryProxies = query
return _q
}
// WithBackupProxy tells the query-builder to eager-load the nodes that are connected to
// the "backup_proxy" edge. The optional arguments are used to configure the query builder of the edge.
func (_q *ProxyQuery) WithBackupProxy(opts ...func(*ProxyQuery)) *ProxyQuery {
@@ -408,8 +443,9 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
var (
nodes = []*Proxy{}
_spec = _q.querySpec()
loadedTypes = [2]bool{
loadedTypes = [3]bool{
_q.withAccounts != nil,
_q.withPrimaryProxies != nil,
_q.withBackupProxy != nil,
}
)
@@ -441,6 +477,13 @@ func (_q *ProxyQuery) sqlAll(ctx context.Context, hooks ...queryHook) ([]*Proxy,
return nil, err
}
}
if query := _q.withPrimaryProxies; query != nil {
if err := _q.loadPrimaryProxies(ctx, query, nodes,
func(n *Proxy) { n.Edges.PrimaryProxies = []*Proxy{} },
func(n *Proxy, e *Proxy) { n.Edges.PrimaryProxies = append(n.Edges.PrimaryProxies, e) }); err != nil {
return nil, err
}
}
if query := _q.withBackupProxy; query != nil {
if err := _q.loadBackupProxy(ctx, query, nodes, nil,
func(n *Proxy, e *Proxy) { n.Edges.BackupProxy = e }); err != nil {
@@ -483,6 +526,39 @@ func (_q *ProxyQuery) loadAccounts(ctx context.Context, query *AccountQuery, nod
}
return nil
}
func (_q *ProxyQuery) loadPrimaryProxies(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
fks := make([]driver.Value, 0, len(nodes))
nodeids := make(map[int64]*Proxy)
for i := range nodes {
fks = append(fks, nodes[i].ID)
nodeids[nodes[i].ID] = nodes[i]
if init != nil {
init(nodes[i])
}
}
if len(query.ctx.Fields) > 0 {
query.ctx.AppendFieldOnce(proxy.FieldBackupProxyID)
}
query.Where(predicate.Proxy(func(s *sql.Selector) {
s.Where(sql.InValues(s.C(proxy.PrimaryProxiesColumn), fks...))
}))
neighbors, err := query.All(ctx)
if err != nil {
return err
}
for _, n := range neighbors {
fk := n.BackupProxyID
if fk == nil {
return fmt.Errorf(`foreign-key "backup_proxy_id" is nil for node %v`, n.ID)
}
node, ok := nodeids[*fk]
if !ok {
return fmt.Errorf(`unexpected referenced foreign-key "backup_proxy_id" returned %v for node %v`, *fk, n.ID)
}
assign(node, n)
}
return nil
}
func (_q *ProxyQuery) loadBackupProxy(ctx context.Context, query *ProxyQuery, nodes []*Proxy, init func(*Proxy), assign func(*Proxy, *Proxy)) error {
ids := make([]int64, 0, len(nodes))
nodeids := make(map[int64][]*Proxy)
+170 -8
View File
@@ -262,6 +262,21 @@ func (_u *ProxyUpdate) AddAccounts(v ...*Account) *ProxyUpdate {
return _u.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_u *ProxyUpdate) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdate {
_u.mutation.AddPrimaryProxyIDs(ids...)
return _u
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdate) AddPrimaryProxies(v ...*Proxy) *ProxyUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdate) SetBackupProxy(v *Proxy) *ProxyUpdate {
return _u.SetBackupProxyID(v.ID)
@@ -293,6 +308,27 @@ func (_u *ProxyUpdate) RemoveAccounts(v ...*Account) *ProxyUpdate {
return _u.RemoveAccountIDs(ids...)
}
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdate) ClearPrimaryProxies() *ProxyUpdate {
_u.mutation.ClearPrimaryProxies()
return _u
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
func (_u *ProxyUpdate) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdate {
_u.mutation.RemovePrimaryProxyIDs(ids...)
return _u
}
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
func (_u *ProxyUpdate) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdate {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemovePrimaryProxyIDs(ids...)
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdate) ClearBackupProxy() *ProxyUpdate {
_u.mutation.ClearBackupProxy()
@@ -492,13 +528,58 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.BackupProxyCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -507,11 +588,11 @@ func (_u *ProxyUpdate) sqlSave(ctx context.Context) (_node int, err error) {
}
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -774,6 +855,21 @@ func (_u *ProxyUpdateOne) AddAccounts(v ...*Account) *ProxyUpdateOne {
return _u.AddAccountIDs(ids...)
}
// AddPrimaryProxyIDs adds the "primary_proxies" edge to the Proxy entity by IDs.
func (_u *ProxyUpdateOne) AddPrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
_u.mutation.AddPrimaryProxyIDs(ids...)
return _u
}
// AddPrimaryProxies adds the "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdateOne) AddPrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.AddPrimaryProxyIDs(ids...)
}
// SetBackupProxy sets the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdateOne) SetBackupProxy(v *Proxy) *ProxyUpdateOne {
return _u.SetBackupProxyID(v.ID)
@@ -805,6 +901,27 @@ func (_u *ProxyUpdateOne) RemoveAccounts(v ...*Account) *ProxyUpdateOne {
return _u.RemoveAccountIDs(ids...)
}
// ClearPrimaryProxies clears all "primary_proxies" edges to the Proxy entity.
func (_u *ProxyUpdateOne) ClearPrimaryProxies() *ProxyUpdateOne {
_u.mutation.ClearPrimaryProxies()
return _u
}
// RemovePrimaryProxyIDs removes the "primary_proxies" edge to Proxy entities by IDs.
func (_u *ProxyUpdateOne) RemovePrimaryProxyIDs(ids ...int64) *ProxyUpdateOne {
_u.mutation.RemovePrimaryProxyIDs(ids...)
return _u
}
// RemovePrimaryProxies removes "primary_proxies" edges to Proxy entities.
func (_u *ProxyUpdateOne) RemovePrimaryProxies(v ...*Proxy) *ProxyUpdateOne {
ids := make([]int64, len(v))
for i := range v {
ids[i] = v[i].ID
}
return _u.RemovePrimaryProxyIDs(ids...)
}
// ClearBackupProxy clears the "backup_proxy" edge to the Proxy entity.
func (_u *ProxyUpdateOne) ClearBackupProxy() *ProxyUpdateOne {
_u.mutation.ClearBackupProxy()
@@ -1034,13 +1151,58 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.RemovedPrimaryProxiesIDs(); len(nodes) > 0 && !_u.mutation.PrimaryProxiesCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Clear = append(_spec.Edges.Clear, edge)
}
if nodes := _u.mutation.PrimaryProxiesIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2M,
Inverse: true,
Table: proxy.PrimaryProxiesTable,
Columns: []string{proxy.PrimaryProxiesColumn},
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
}
for _, k := range nodes {
edge.Target.Nodes = append(edge.Target.Nodes, k)
}
_spec.Edges.Add = append(_spec.Edges.Add, edge)
}
if _u.mutation.BackupProxyCleared() {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
@@ -1049,11 +1211,11 @@ func (_u *ProxyUpdateOne) sqlSave(ctx context.Context) (_node *Proxy, err error)
}
if nodes := _u.mutation.BackupProxyIDs(); len(nodes) > 0 {
edge := &sqlgraph.EdgeSpec{
Rel: sqlgraph.O2O,
Rel: sqlgraph.M2O,
Inverse: false,
Table: proxy.BackupProxyTable,
Columns: []string{proxy.BackupProxyColumn},
Bidi: true,
Bidi: false,
Target: &sqlgraph.EdgeTarget{
IDSpec: sqlgraph.NewFieldSpec(proxy.FieldID, field.TypeInt64),
},
+1 -1
View File
@@ -36,7 +36,7 @@ func (ChannelMonitor) Fields() []ent.Field {
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
// check_mode: 'probe' | 'quota' | 'quota_probe'
// probe - LLM 探活(默认,原有行为)
// quota - 仅查关联账号的用量/余额(零 LLM 成本;endpoint/api_key 可空)
@@ -40,7 +40,7 @@ func (ChannelMonitorRequestTemplate) Fields() []ent.Field {
MaxLen(100),
field.Enum("provider").
Values("openai", "anthropic", "gemini", "grok",
"antigravity", "kimi", "zhipu", "deepseek"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+4
View File
@@ -73,6 +73,10 @@ func (Proxy) Edges() []ent.Edge {
// accounts: 使用此代理的账户(反向边)
edge.From("accounts", Account.Type).
Ref("proxy"),
// Directed many-to-one: a backup can serve multiple primary proxies.
// The inverse edge prevents Ent from treating this self-reference as symmetric.
edge.From("primary_proxies", Proxy.Type).
Ref("backup_proxy"),
edge.To("backup_proxy", Proxy.Type).
Field("backup_proxy_id").
Unique(),
+1 -1
View File
@@ -42,7 +42,7 @@ func (UserPlatformQuota) Fields() []ent.Field {
// 此处为 ent 构建期约束,需与 service.AllowedQuotaPlatforms 保持同步。
switch s {
case "anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek":
"kimi", "zhipu", "deepseek", "minimax", "opencode_go":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+3 -4
View File
@@ -31,7 +31,7 @@ require (
github.com/lib/pq v1.10.9
github.com/patrickmn/go-cache v2.1.0+incompatible
github.com/pquerna/otp v1.5.0
github.com/redis/go-redis/v9 v9.17.2
github.com/redis/go-redis/v9 v9.22.0
github.com/refraction-networking/utls v1.8.2
github.com/robfig/cron/v3 v3.0.1
github.com/shirou/gopsutil/v4 v4.25.6
@@ -101,7 +101,6 @@ require (
github.com/containerd/platforms v0.2.1 // indirect
github.com/cpuguy83/dockercfg v0.3.2 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/distribution/reference v0.6.0 // indirect
github.com/dlclark/regexp2/v2 v2.1.0 // indirect
github.com/docker/docker v28.5.1+incompatible // indirect
@@ -135,7 +134,7 @@ require (
github.com/hashicorp/yamux v0.1.2 // indirect
github.com/icholy/digest v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.4 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.2.4 // indirect
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.10 // indirect
@@ -197,7 +196,7 @@ require (
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.uber.org/atomic v1.10.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
go.uber.org/automaxprocs v1.6.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
golang.org/x/arch v0.3.0 // indirect
+16 -4
View File
@@ -159,8 +159,6 @@ github.com/dgraph-io/ristretto v0.2.0 h1:XAfl+7cmoUDWW/2Lx8TGZQjjxIQ2Ley9DSf52dr
github.com/dgraph-io/ristretto v0.2.0/go.mod h1:8uBHCU/PBV4Ag0CJrP47b9Ofby5dqWNh4FicAdoqFNU=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13 h1:fAjc9m62+UWV/WAFKLNi6ZS0675eEUC9y3AlwSbQu1Y=
github.com/dgryski/go-farm v0.0.0-20200201041132-a6ae2369ad13/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/distribution/reference v0.6.0 h1:0IXCQ5g4/QMHHkarYzh5l+u8T3t73zM5QvfrDyIgxBk=
github.com/distribution/reference v0.6.0/go.mod h1:BbU0aIcezP1/5jX/8MP0YiH4SdvB5Y4f/wlDRiLyi3E=
github.com/dlclark/regexp2/v2 v2.1.0 h1:jHXRmHRZGbuQzDZjMlCAXOvQb75iv3HyLDzXGj5H1AY=
@@ -255,6 +253,8 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -302,6 +302,8 @@ github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxh
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.4 h1:acbojRNwl3o09bUq+yDCtZFc1aiwaAAxtcn8YkZXnvk=
github.com/klauspost/cpuid/v2 v2.2.4/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
@@ -327,6 +329,8 @@ github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27k
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/mdelapenya/tlscert v0.2.0 h1:7H81W6Z/4weDvZBNOfQte5GpIMo0lGYEeWbkGp5LJHI=
@@ -365,6 +369,8 @@ github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/oklog/run v1.1.0 h1:GEenZ1cK0+q0+wsJew9qUg/DyD8k3JzYsZAi5gYi2mA=
github.com/oklog/run v1.1.0/go.mod h1:sVPdnTZT1zYwAJeCMu2Th4T21pA3FPOQRfWjQlk7DVU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
@@ -393,12 +399,14 @@ github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.60.0 h1:xcQioE8OM66UQLeUMHltK1CCcOu3JbVB4JAQdDQSB+0=
github.com/quic-go/quic-go v0.60.0/go.mod h1:wpKpjmPpftl30sL6pFh7REVpjbcCVy4zt2vDyK1TuJk=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
github.com/redis/go-redis/v9 v9.22.0 h1:laDvpYXTJtZLloinw1fA5Kqd6HAEH2XKxOkG/PDq2F0=
github.com/redis/go-redis/v9 v9.22.0/go.mod h1:y2g0Wj8rQvuK0ELM+oxSudcLtC09JScs98I/X9gRWY4=
github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEvV+S9iJ2IdQo=
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -434,6 +442,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@@ -535,6 +545,8 @@ go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeX
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
go.uber.org/atomic v1.10.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
go.uber.org/automaxprocs v1.6.0 h1:O3y2/QNTOdbF+e/dpXNNW7Rx2hZ4sTIPyybbxyNqTUs=
go.uber.org/automaxprocs v1.6.0/go.mod h1:ifeIMSnPZuznNm6jmdzmU3/bfk01Fe2fotchwEFJ8r8=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
+20 -10
View File
@@ -1274,11 +1274,15 @@ type GatewayOpenAIWSConfig struct {
MaxConnsPerAccount int `mapstructure:"max_conns_per_account"`
MinIdlePerAccount int `mapstructure:"min_idle_per_account"`
MaxIdlePerAccount int `mapstructure:"max_idle_per_account"`
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限
// DynamicMaxConnsByAccountConcurrencyEnabled: 是否按账号并发动态计算连接池上限。
// 旧版及 mode_router_v2 的 ctx_pool 共用此开关和类型系数;关闭后使用 max_conns_per_account。
// mode_router_v2 下并发数 <= 0 的账号仍不可调度。
DynamicMaxConnsByAccountConcurrencyEnabled bool `mapstructure:"dynamic_max_conns_by_account_concurrency_enabled"`
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor))
// OAuthMaxConnsFactor: OAuth 账号连接池系数(effective=ceil(concurrency*factor),再受 max_conns_per_account 封顶)。
// ctx_pool 接入下每个客户端会话在整个生命周期(含轮次之间)持有一条上游连接,此上限限制的是同时持有连接的会话数,
// 在飞请求数另由账号并发槽限制;系数 1.0 会让存活会话数一到并发数就返回 1013 busy,默认 5.0。
OAuthMaxConnsFactor float64 `mapstructure:"oauth_max_conns_factor"`
// APIKeyMaxConnsFactor: API Key 账号连接池系数(effective=ceil(concurrency*factor))
// APIKeyMaxConnsFactor: API Key 账号连接池系数,含义与 OAuthMaxConnsFactor 相同,默认 5.0。
APIKeyMaxConnsFactor float64 `mapstructure:"apikey_max_conns_factor"`
DialTimeoutSeconds int `mapstructure:"dial_timeout_seconds"`
ReadTimeoutSeconds int `mapstructure:"read_timeout_seconds"`
@@ -1613,10 +1617,10 @@ type OpsCleanupConfig struct {
Enabled bool `mapstructure:"enabled"`
Schedule string `mapstructure:"schedule"`
// Retention days (0 disables that cleanup target).
//
// vNext requirement: default 30 days across ops datasets.
// Retention days. Error and metrics targets accept 0 as an explicit truncate;
// system logs require a positive value because their runtime setting is bounded.
ErrorLogRetentionDays int `mapstructure:"error_log_retention_days"`
SystemLogRetentionDays int `mapstructure:"system_log_retention_days"`
MinuteMetricsRetentionDays int `mapstructure:"minute_metrics_retention_days"`
HourlyMetricsRetentionDays int `mapstructure:"hourly_metrics_retention_days"`
}
@@ -2046,7 +2050,9 @@ func setDefaults() {
"api.moonshot.ai",
"api.moonshot.cn",
"open.bigmodel.cn",
"api.minimaxi.com",
"api.minimaxi.com", // MiniMax CN quota + inference
"api.minimax.io", // MiniMax intl; frozen allowlists must add this host to use the intl site
"opencode.ai",
"generativelanguage.googleapis.com",
"cloudcode-pa.googleapis.com",
"*.openai.azure.com",
@@ -2250,6 +2256,7 @@ func setDefaults() {
viper.SetDefault("ops.cleanup.schedule", "0 2 * * *")
// Retention days: vNext defaults to 30 days across ops datasets.
viper.SetDefault("ops.cleanup.error_log_retention_days", 30)
viper.SetDefault("ops.cleanup.system_log_retention_days", 30)
viper.SetDefault("ops.cleanup.minute_metrics_retention_days", 30)
viper.SetDefault("ops.cleanup.hourly_metrics_retention_days", 30)
viper.SetDefault("ops.aggregation.enabled", true)
@@ -2373,7 +2380,7 @@ func setDefaults() {
viper.SetDefault("gateway.disable_codex_originator_normalization", false)
viper.SetDefault("gateway.codex_image_generation_bridge_enabled", false)
viper.SetDefault("gateway.openai_passthrough_allow_timeout_headers", false)
viper.SetDefault("gateway.openai_compact_model", "gpt-5.4")
viper.SetDefault("gateway.openai_compact_model", "gpt-5.5")
viper.SetDefault("gateway.live.max_session_duration_seconds", 3600)
// OpenAI Responses WebSocket(默认开启;可通过 force_http 紧急回滚)
viper.SetDefault("gateway.openai_ws.enabled", true)
@@ -2399,8 +2406,8 @@ func setDefaults() {
viper.SetDefault("gateway.openai_ws.min_idle_per_account", 4)
viper.SetDefault("gateway.openai_ws.max_idle_per_account", 12)
viper.SetDefault("gateway.openai_ws.dynamic_max_conns_by_account_concurrency_enabled", true)
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 1.0)
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 1.0)
viper.SetDefault("gateway.openai_ws.oauth_max_conns_factor", 5.0)
viper.SetDefault("gateway.openai_ws.apikey_max_conns_factor", 5.0)
viper.SetDefault("gateway.openai_ws.dial_timeout_seconds", 10)
viper.SetDefault("gateway.openai_ws.read_timeout_seconds", 900)
viper.SetDefault("gateway.openai_ws.write_timeout_seconds", 120)
@@ -3673,6 +3680,9 @@ func (c *Config) Validate() error {
if c.Ops.Cleanup.ErrorLogRetentionDays < 0 {
return fmt.Errorf("ops.cleanup.error_log_retention_days must be non-negative")
}
if c.Ops.Cleanup.Enabled && c.Ops.Cleanup.SystemLogRetentionDays <= 0 {
return fmt.Errorf("ops.cleanup.system_log_retention_days must be positive when ops cleanup is enabled")
}
if c.Ops.Cleanup.MinuteMetricsRetentionDays < 0 {
return fmt.Errorf("ops.cleanup.minute_metrics_retention_days must be non-negative")
}
+25 -5
View File
@@ -452,11 +452,11 @@ func TestLoadDefaultOpenAIWSConfig(t *testing.T) {
if !cfg.Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled {
t.Fatalf("Gateway.OpenAIWS.DynamicMaxConnsByAccountConcurrencyEnabled = false, want true")
}
if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 1.0 {
t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor)
if cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor != 5.0 {
t.Fatalf("Gateway.OpenAIWS.OAuthMaxConnsFactor = %v, want 5.0", cfg.Gateway.OpenAIWS.OAuthMaxConnsFactor)
}
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 1.0 {
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 1.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
if cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor != 5.0 {
t.Fatalf("Gateway.OpenAIWS.APIKeyMaxConnsFactor = %v, want 5.0", cfg.Gateway.OpenAIWS.APIKeyMaxConnsFactor)
}
if cfg.Gateway.OpenAIWS.StickySessionTTLSeconds != 3600 {
t.Fatalf("Gateway.OpenAIWS.StickySessionTTLSeconds = %d, want 3600", cfg.Gateway.OpenAIWS.StickySessionTTLSeconds)
@@ -574,7 +574,7 @@ func TestLoadDefaultOpenAICompactModel(t *testing.T) {
cfg, err := Load()
require.NoError(t, err)
require.Equal(t, "gpt-5.4", cfg.Gateway.OpenAICompactModel)
require.Equal(t, "gpt-5.5", cfg.Gateway.OpenAICompactModel)
}
func TestLoadOpenAICompactModelFromEnv(t *testing.T) {
@@ -1191,6 +1191,21 @@ func TestLoadDefaultUsageCleanupConfig(t *testing.T) {
}
}
func TestLoadDefaultOpsCleanupConfig(t *testing.T) {
resetViperWithJWTSecret(t)
cfg, err := Load()
if err != nil {
t.Fatalf("Load() error: %v", err)
}
if !cfg.Ops.Cleanup.Enabled {
t.Fatal("Ops.Cleanup.Enabled = false, want true")
}
if cfg.Ops.Cleanup.SystemLogRetentionDays != 30 {
t.Fatalf("Ops.Cleanup.SystemLogRetentionDays = %d, want 30", cfg.Ops.Cleanup.SystemLogRetentionDays)
}
}
func TestValidateUsageCleanupConfigEnabled(t *testing.T) {
resetViperWithJWTSecret(t)
@@ -2137,6 +2152,11 @@ func TestValidateConfigErrors(t *testing.T) {
mutate: func(c *Config) { c.Ops.Cleanup.ErrorLogRetentionDays = -1 },
wantErr: "ops.cleanup.error_log_retention_days",
},
{
name: "ops cleanup system log retention",
mutate: func(c *Config) { c.Ops.Cleanup.SystemLogRetentionDays = 0 },
wantErr: "ops.cleanup.system_log_retention_days",
},
{
name: "ops cleanup minute retention",
mutate: func(c *Config) { c.Ops.Cleanup.MinuteMetricsRetentionDays = -1 },
@@ -19,7 +19,8 @@ import "time"
// - "7d" 7 天窗口(Claude/Codex)
// - "7d-sonnet" Claude 7 天 Sonnet 独立额度
// - "7d-fable" Claude 7 天 Fable 独立额度
// - "weekly" 周窗口(Kimi/Zhipu coding plan)
// - "weekly" 周窗口(Kimi/Zhipu coding plan / OpenCode Go)
// - "monthly" 月窗口(OpenCode Go)
// - "daily" 日窗口(Gemini 日配额 / Grok 日请求)
// - "30d" 30 天窗口(Grok 月度)
// - "total" 无窗口语义的总量额度(Antigravity per-model 等)
+23 -5
View File
@@ -24,10 +24,14 @@ const (
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformComposite = "composite"
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
// PlatformOpenCodeGo 是 OpenCode 平台(账号类型 Zen 按量 / Go 订阅)。
// 值保持 opencode_go 以兼容已落库的分组、配额与 Composite 路由 CHECK。
PlatformOpenCodeGo = "opencode_go"
PlatformComposite = "composite"
)
// Account mode constants 区分国产供应商的「按量付费(余额)」与「Coding Plan」两种接入方式。
@@ -35,6 +39,8 @@ const (
const (
AccountModePayG = "payg" // 按量付费:消耗余额,做余额检测冷却
AccountModeCoding = "coding" // Coding Plan:滚动用量窗口冷却(5h / weekly)
AccountModeZen = "zen" // OpenCode Zen:按量付费,https://opencode.ai/zen/v1
AccountModeGo = "go" // OpenCode Go:订阅额度窗口,https://opencode.ai/zen/go/v1
)
// API protocol constants 国产供应商的上游 API 协议维度。存储于
@@ -43,7 +49,7 @@ const (
const (
APIProtocolChatCompletions = "chat_completions" // OpenAI Chat Completions(默认)
APIProtocolAnthropic = "anthropic" // 原生 Anthropic /v1/messages(适配 Claude Code)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi 原生端点,适配 Codex)
APIProtocolResponses = "responses" // OpenAI Responses(deepseek / kimi / minimax 原生端点,适配 Codex)
APIProtocolAdaptive = "adaptive" // 按入站协议优先选择供应商原生端点
)
@@ -145,6 +151,18 @@ var DefaultAntigravityModelMapping = map[string]string{
"gemini-3.6-flash-low": "gemini-3.6-flash-low",
"gemini-3.6-flash-medium": "gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered": "gemini-3.6-flash-tiered",
// Gemini 3.7 Flash tiered models
"gemini-3.7-flash": "gemini-3.7-flash",
"gemini-3.7-flash-high": "gemini-3.7-flash-high",
"gemini-3.7-flash-low": "gemini-3.7-flash-low",
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
"gemini-3.7-flash-tiered": "gemini-3.7-flash-tiered",
// Gemini 3.8 Flash tiered models
"gemini-3.8-flash": "gemini-3.8-flash",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
// Gemini 3 image 兼容映射(向 3.1 image 迁移)
"gemini-3-pro-image": "gemini-3.1-flash-image",
"gemini-3-pro-image-preview": "gemini-3.1-flash-image",
+16
View File
@@ -89,6 +89,22 @@ func TestDefaultAntigravityModelMapping_Gemini36FlashModels(t *testing.T) {
}
}
func TestDefaultAntigravityModelMapping_Gemini37FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.7-flash", "gemini-3.7-flash-high", "gemini-3.7-flash-low", "gemini-3.7-flash-medium", "gemini-3.7-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultAntigravityModelMapping_Gemini38FlashModels(t *testing.T) {
for _, model := range []string{"gemini-3.8-flash", "gemini-3.8-flash-high", "gemini-3.8-flash-low", "gemini-3.8-flash-medium", "gemini-3.8-flash-tiered"} {
if got := DefaultAntigravityModelMapping[model]; got != model {
t.Fatalf("expected %s to map to itself, got %q", model, got)
}
}
}
func TestDefaultBedrockModelMapping_ContainsNewClaudeModels(t *testing.T) {
t.Parallel()
+10 -6
View File
@@ -309,15 +309,17 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
_, _ = h.adminService.UpdateProxy(ctx, existingID, &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: existingExpiresAt,
ClearExpiresAt: existingExpiresAt == nil,
FallbackMode: existingFallbackMode,
BackupProxyID: existingBackupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: existingBackupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
Name: proxy.Name,
Protocol: proxy.Protocol,
Host: proxy.Host,
Port: proxy.Port,
Username: proxy.Username,
Password: proxy.Password,
Username: &proxy.Username,
Password: &proxy.Password,
})
}
}
@@ -383,15 +385,17 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
_, _ = h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: expiresAt,
ClearExpiresAt: expiresAt == nil,
FallbackMode: fallbackMode,
BackupProxyID: backupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: backupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
Name: created.Name,
Protocol: created.Protocol,
Host: created.Host,
Port: created.Port,
Username: created.Username,
Password: created.Password,
Username: &created.Username,
Password: &created.Password,
})
}
}
@@ -1545,6 +1545,7 @@ func (h *AccountHandler) Refresh(c *gin.Context) {
if warning == "missing_project_id_temporary" {
response.Success(c, gin.H{
"account": h.buildAccountResponseWithRuntime(c.Request.Context(), updatedAccount),
"message": "Token refreshed successfully, but project_id could not be retrieved (will retry automatically)",
"warning": "missing_project_id_temporary",
})
@@ -643,6 +643,8 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformKimi: "moonshot",
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
service.PlatformOpenCodeGo: "opencode-go",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -549,7 +549,7 @@ func TestSyncPricingModels_ValidPlatform_EmptyService(t *testing.T) {
svc := service.NewPricingService(nil, nil)
router := setupSyncPricingModelsRouter(svc)
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"anthropic", "openai", "gemini", "antigravity", "grok", "kimi", "zhipu", "deepseek", "minimax"} {
req := httptest.NewRequest(http.MethodGet, "/channels/pricing/sync-models?platform="+platform, nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
@@ -40,7 +40,7 @@ func NewChannelMonitorHandler(monitorService *service.ChannelMonitorService) *Ch
type channelMonitorCreateRequest struct {
Name string `json:"name" binding:"required,max=100"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider string `json:"provider" binding:"required,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
APIMode string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint string `json:"endpoint" binding:"omitempty,max=500"`
APIKey string `json:"api_key" binding:"omitempty,max=2000"`
@@ -64,7 +64,7 @@ type channelMonitorCreateRequest struct {
type channelMonitorUpdateRequest struct {
Name *string `json:"name" binding:"omitempty,max=100"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek"`
Provider *string `json:"provider" binding:"omitempty,oneof=openai anthropic gemini grok antigravity kimi zhipu deepseek minimax opencode_go"`
APIMode *string `json:"api_mode" binding:"omitempty,oneof=chat_completions responses"`
Endpoint *string `json:"endpoint" binding:"omitempty,max=500"`
APIKey *string `json:"api_key" binding:"omitempty,max=2000"`
@@ -0,0 +1,144 @@
package admin
import (
"strconv"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
const (
grokMediaEligibilityModeAuto = "auto"
grokMediaEligibilityModeEnabled = "enabled"
grokMediaEligibilityModeDisabled = "disabled"
)
type grokMediaEligibilityResponse struct {
AccountID int64 `json:"account_id"`
Mode string `json:"mode"`
Eligible bool `json:"eligible"`
Reason string `json:"reason"`
}
type updateGrokMediaEligibilityRequest struct {
Mode string `json:"mode" binding:"required"`
}
// GetGrokMediaEligibility returns the current manual override and evaluated
// media-routing decision for a Grok OAuth account.
// GET /api/v1/admin/accounts/:id/grok-media-eligibility
func (h *AccountHandler) GetGrokMediaEligibility(c *gin.Context) {
account, err := h.getGrokOAuthAccount(c)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, buildGrokMediaEligibilityResponse(account))
}
// UpdateGrokMediaEligibility updates only the manual media eligibility
// override. It deliberately uses UpdateAccountExtra so unrelated account
// extra fields are preserved.
// PUT /api/v1/admin/accounts/:id/grok-media-eligibility
func (h *AccountHandler) UpdateGrokMediaEligibility(c *gin.Context) {
account, err := h.getGrokOAuthAccount(c)
if err != nil {
response.ErrorFrom(c, err)
return
}
var req updateGrokMediaEligibilityRequest
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
var value any
switch req.Mode {
case grokMediaEligibilityModeAuto:
value = nil
case grokMediaEligibilityModeEnabled:
value = true
case grokMediaEligibilityModeDisabled:
value = false
default:
response.ErrorFrom(c, infraerrors.BadRequest(
"GROK_MEDIA_ELIGIBILITY_INVALID_MODE",
"mode must be one of auto, enabled, disabled",
))
return
}
updates := map[string]any{service.GrokMediaEligibleExtraKey: value}
if err := service.ValidateGrokMediaEligibilityExtra(account.Platform, updates); err != nil {
response.ErrorFrom(c, err)
return
}
if err := h.adminService.UpdateAccountExtra(c.Request.Context(), account.ID, updates); err != nil {
response.ErrorFrom(c, err)
return
}
updated, err := h.adminService.GetAccount(c.Request.Context(), account.ID)
if err != nil {
response.ErrorFrom(c, err)
return
}
if err := ensureGrokOAuthAccount(updated); err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, buildGrokMediaEligibilityResponse(updated))
}
func (h *AccountHandler) getGrokOAuthAccount(c *gin.Context) (*service.Account, error) {
accountID, err := strconv.ParseInt(c.Param("id"), 10, 64)
if err != nil {
return nil, infraerrors.BadRequest("INVALID_ACCOUNT_ID", "Invalid account ID")
}
account, err := h.adminService.GetAccount(c.Request.Context(), accountID)
if err != nil {
return nil, err
}
if err := ensureGrokOAuthAccount(account); err != nil {
return nil, err
}
return account, nil
}
func ensureGrokOAuthAccount(account *service.Account) error {
if account == nil {
return infraerrors.NotFound("ACCOUNT_NOT_FOUND", "account not found")
}
if account.Platform != service.PlatformGrok || account.Type != service.AccountTypeOAuth {
return infraerrors.BadRequest(
"GROK_MEDIA_ELIGIBILITY_UNSUPPORTED_ACCOUNT",
"media eligibility is only supported for Grok OAuth accounts",
)
}
return nil
}
func buildGrokMediaEligibilityResponse(account *service.Account) grokMediaEligibilityResponse {
eligible, reason := account.GrokMediaGenerationEligibility()
return grokMediaEligibilityResponse{
AccountID: account.ID,
Mode: grokMediaEligibilityModeFromExtra(account.Extra),
Eligible: eligible,
Reason: reason,
}
}
func grokMediaEligibilityModeFromExtra(extra map[string]any) string {
if extra != nil {
if enabled, ok := extra[service.GrokMediaEligibleExtraKey].(bool); ok {
if enabled {
return grokMediaEligibilityModeEnabled
}
return grokMediaEligibilityModeDisabled
}
}
return grokMediaEligibilityModeAuto
}
@@ -0,0 +1,146 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type grokMediaEligibilityAdminServiceStub struct {
*stubAdminService
account *service.Account
}
func (s *grokMediaEligibilityAdminServiceStub) GetAccount(_ context.Context, _ int64) (*service.Account, error) {
return s.account, nil
}
func (s *grokMediaEligibilityAdminServiceStub) UpdateAccountExtra(_ context.Context, _ int64, updates map[string]any) error {
if s.account.Extra == nil {
s.account.Extra = map[string]any{}
}
for key, value := range updates {
if value == nil {
delete(s.account.Extra, key)
} else {
s.account.Extra[key] = value
}
}
return nil
}
func newGrokMediaEligibilityRouter(account *service.Account) *gin.Engine {
stub := &grokMediaEligibilityAdminServiceStub{
stubAdminService: newStubAdminService(),
account: account,
}
h := NewAccountHandler(stub, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.GET("/accounts/:id/grok-media-eligibility", h.GetGrokMediaEligibility)
router.PUT("/accounts/:id/grok-media-eligibility", h.UpdateGrokMediaEligibility)
return router
}
func TestGrokMediaEligibilityHandlerGetReturnsModeAndReason(t *testing.T) {
gin.SetMode(gin.TestMode)
router := newGrokMediaEligibilityRouter(&service.Account{
ID: 12,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Extra: map[string]any{
"grok_billing_snapshot": map[string]any{"status_code": 200, "partial": true},
"unrelated_setting": "preserve",
},
})
req := httptest.NewRequest(http.MethodGet, "/accounts/12/grok-media-eligibility", nil)
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var envelope struct {
Data grokMediaEligibilityResponse `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
require.Equal(t, int64(12), envelope.Data.AccountID)
require.Equal(t, grokMediaEligibilityModeAuto, envelope.Data.Mode)
require.Equal(t, "billing_inconclusive", envelope.Data.Reason)
}
func TestGrokMediaEligibilityHandlerUpdateModesPreservesExtra(t *testing.T) {
gin.SetMode(gin.TestMode)
account := &service.Account{
ID: 12,
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Extra: map[string]any{"unrelated_setting": "preserve"},
}
router := newGrokMediaEligibilityRouter(account)
tests := []struct {
mode string
wantMode string
wantExtra any
wantEligible bool
wantReason string
}{
{mode: "enabled", wantMode: grokMediaEligibilityModeEnabled, wantExtra: true, wantEligible: true, wantReason: "override_enabled"},
{mode: "disabled", wantMode: grokMediaEligibilityModeDisabled, wantExtra: false, wantEligible: false, wantReason: "override_disabled"},
{mode: "auto", wantMode: grokMediaEligibilityModeAuto, wantExtra: nil, wantEligible: false, wantReason: "billing_unobserved"},
}
for _, tt := range tests {
t.Run(tt.mode, func(t *testing.T) {
body, err := json.Marshal(map[string]string{"mode": tt.mode})
require.NoError(t, err)
req := httptest.NewRequest(http.MethodPut, "/accounts/12/grok-media-eligibility", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusOK, rec.Code)
var envelope struct {
Data grokMediaEligibilityResponse `json:"data"`
}
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &envelope))
require.Equal(t, tt.wantMode, envelope.Data.Mode)
require.Equal(t, tt.wantEligible, envelope.Data.Eligible)
require.Equal(t, tt.wantReason, envelope.Data.Reason)
require.Equal(t, "preserve", account.Extra["unrelated_setting"])
if tt.wantExtra == nil {
_, exists := account.Extra[service.GrokMediaEligibleExtraKey]
require.False(t, exists)
} else {
require.Equal(t, tt.wantExtra, account.Extra[service.GrokMediaEligibleExtraKey])
}
})
}
}
func TestGrokMediaEligibilityHandlerRejectsUnsupportedAndInvalidModes(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tt := range []struct {
name string
account *service.Account
body string
}{
{name: "unsupported account", account: &service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth}, body: `{"mode":"enabled"}`},
{name: "invalid mode", account: &service.Account{ID: 1, Platform: service.PlatformGrok, Type: service.AccountTypeOAuth}, body: `{"mode":"force"}`},
} {
t.Run(tt.name, func(t *testing.T) {
router := newGrokMediaEligibilityRouter(tt.account)
req := httptest.NewRequest(http.MethodPut, "/accounts/1/grok-media-eligibility", bytes.NewBufferString(tt.body))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
require.Equal(t, http.StatusBadRequest, rec.Code)
})
}
}
@@ -184,7 +184,7 @@ func sanitizeUpdateGroupRequestForSimpleMode(req *UpdateGroupRequest) {
type CreateGroupRequest struct {
Name string `json:"name" binding:"required"`
Description string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
RateMultiplier float64 `json:"rate_multiplier"`
IsExclusive bool `json:"is_exclusive"`
SubscriptionType string `json:"subscription_type" binding:"omitempty,oneof=standard subscription"`
@@ -258,7 +258,7 @@ type CreateGroupRequest struct {
type UpdateGroupRequest struct {
Name string `json:"name"`
Description *string `json:"description"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek composite"`
Platform string `json:"platform" binding:"omitempty,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go composite"`
RateMultiplier *float64 `json:"rate_multiplier"`
IsExclusive *bool `json:"is_exclusive"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
@@ -332,7 +332,7 @@ type UpdateGroupRequest struct {
type CompositeRouteRequest struct {
PublicModel string `json:"public_model" binding:"required"`
MatchType string `json:"match_type" binding:"omitempty,oneof=exact prefix"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek"`
TargetPlatform string `json:"target_platform" binding:"required,oneof=anthropic openai gemini antigravity grok kimi zhipu deepseek minimax opencode_go"`
UpstreamModel string `json:"upstream_model"`
Endpoint string `json:"endpoint" binding:"omitempty,oneof=any messages count_tokens responses chat_completions embeddings images gemini"`
Priority int `json:"priority"`
@@ -12,9 +12,9 @@ import (
"github.com/stretchr/testify/require"
)
// 回归分组平台枚举:kimi/zhipu/deepseek 必须能通过 Create/Update 的 binding 校验
// (历史 bug:调度/路由链路已支持 CN 平台分组,但 oneof 白名单漏加三平台,导致
// 平台分组无法创建、CN 账号"无可用分组");非法值仍须被拒。
// 回归分组平台枚举:kimi/zhipu/deepseek/opencode_go 必须能通过 Create/Update 的
// binding 校验(历史 bug:调度/路由链路已支持这些平台,但 oneof 白名单漏加,
// 导致平台分组无法创建、账号"无可用分组");非法值仍须被拒。
func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
t.Helper()
gin.SetMode(gin.TestMode)
@@ -27,7 +27,7 @@ func bindGroupPlatformJSON(t *testing.T, target any, body string) error {
func TestGroupPlatformBinding_AllowedPlatforms(t *testing.T) {
allowed := []string{
"anthropic", "openai", "gemini", "antigravity", "grok",
"kimi", "zhipu", "deepseek", "composite",
"kimi", "zhipu", "deepseek", "minimax", "opencode_go", "composite",
}
for _, platform := range allowed {
t.Run("create_"+platform, func(t *testing.T) {
@@ -72,10 +72,15 @@ func TestGroupPlatformBinding_RejectsInvalidPlatforms(t *testing.T) {
}
func TestCompositeRouteTargetPlatform_AllowsCNProviders(t *testing.T) {
for _, platform := range []string{"kimi", "zhipu", "deepseek"} {
for _, platform := range []string{"kimi", "zhipu", "deepseek", "minimax", "opencode_go"} {
var req CompositeRouteRequest
body := fmt.Sprintf(`{"public_model":"m","target_platform":%q}`, platform)
require.NoError(t, bindGroupPlatformJSON(t, &req, body))
require.Equal(t, platform, req.TargetPlatform)
}
}
func TestCompositeRouteTargetPlatform_RejectsComposite(t *testing.T) {
var req CompositeRouteRequest
require.Error(t, bindGroupPlatformJSON(t, &req, `{"public_model":"m","target_platform":"composite"}`))
}
@@ -27,10 +27,27 @@ func executeAdminIdempotent(
payload any,
ttl time.Duration,
execute func(context.Context) (any, error),
) (*service.IdempotencyExecuteResult, error) {
return executeAdminIdempotentWithTimeout(c, scope, payload, ttl, 0, execute)
}
func executeAdminIdempotentWithTimeout(
c *gin.Context,
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
execute func(context.Context) (any, error),
) (*service.IdempotencyExecuteResult, error) {
coordinator := service.DefaultIdempotencyCoordinator()
if coordinator == nil {
data, err := execute(c.Request.Context())
ctx := c.Request.Context()
if executionTimeout > 0 {
var cancel context.CancelFunc
ctx, cancel = context.WithTimeout(context.WithoutCancel(ctx), executionTimeout)
defer cancel()
}
data, err := execute(ctx)
if err != nil {
return nil, err
}
@@ -38,14 +55,15 @@ func executeAdminIdempotent(
}
return coordinator.Execute(c.Request.Context(), service.IdempotencyExecuteOptions{
Scope: scope,
ActorScope: adminActorScope(c),
Method: c.Request.Method,
Route: c.FullPath(),
IdempotencyKey: c.GetHeader("Idempotency-Key"),
Payload: payload,
RequireKey: true,
TTL: ttl,
Scope: scope,
ActorScope: adminActorScope(c),
Method: c.Request.Method,
Route: c.FullPath(),
IdempotencyKey: c.GetHeader("Idempotency-Key"),
Payload: payload,
RequireKey: true,
TTL: ttl,
ExecutionTimeout: executionTimeout,
}, execute)
}
@@ -64,7 +82,18 @@ func executeAdminIdempotentJSON(
ttl time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailClose, execute)
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailClose, execute)
}
func executeAdminIdempotentJSONWithTimeout(
c *gin.Context,
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, executionTimeout, idempotencyStoreUnavailableFailClose, execute)
}
func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
@@ -74,7 +103,7 @@ func executeAdminIdempotentJSONFailOpenOnStoreUnavailable(
ttl time.Duration,
execute func(context.Context) (any, error),
) {
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, idempotencyStoreUnavailableFailOpen, execute)
executeAdminIdempotentJSONWithMode(c, scope, payload, ttl, 0, idempotencyStoreUnavailableFailOpen, execute)
}
func executeAdminIdempotentJSONWithMode(
@@ -82,10 +111,11 @@ func executeAdminIdempotentJSONWithMode(
scope string,
payload any,
ttl time.Duration,
executionTimeout time.Duration,
mode idempotencyStoreUnavailableMode,
execute func(context.Context) (any, error),
) {
result, err := executeAdminIdempotent(c, scope, payload, ttl, execute)
result, err := executeAdminIdempotentWithTimeout(c, scope, payload, ttl, executionTimeout, execute)
if err != nil {
if infraerrors.Code(err) == infraerrors.Code(service.ErrIdempotencyStoreUnavail) {
strategy := "fail_close"
@@ -219,7 +219,8 @@ func (h *OpsHandler) GetDashboardErrorDistribution(c *gin.Context) {
response.Success(c, data)
}
// GetDashboardOpenAITokenStats returns OpenAI token efficiency stats grouped by model.
// GetDashboardOpenAITokenStats returns token efficiency stats for all platforms, grouped by model.
// The endpoint name is retained for compatibility; platform and group_id optionally narrow the scope.
// GET /api/v1/admin/ops/dashboard/openai-token-stats
func (h *OpsHandler) GetDashboardOpenAITokenStats(c *gin.Context) {
if h.opsService == nil {
@@ -0,0 +1,41 @@
//go:build unit
package admin
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestProxyHandlerUpdateCredentialPresence(t *testing.T) {
gin.SetMode(gin.TestMode)
check := func(name, body string, username, password *string) {
t.Helper()
t.Run(name, func(t *testing.T) {
svc := &proxyPartialUpdateService{}
router := gin.New()
router.PUT("/proxies/:id", NewProxyHandler(svc).Update)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.NotNil(t, svc.input)
require.Equal(t, username, svc.input.Username)
require.Equal(t, password, svc.input.Password)
})
}
empty, username, password := "", "user", "pass"
check("omitted", `{"status":"inactive"}`, nil, nil)
check("null preserves", `{"username":null,"password":null}`, nil, nil)
check("clear both", `{"username":"","password":""}`, &empty, &empty)
check("clear username only", `{"username":""}`, &empty, nil)
check("clear password only", `{"password":""}`, nil, &empty)
check("trim values", `{"username":" user ","password":" pass "}`, &username, &password)
check("whitespace clears", `{"username":" ","password":" "}`, &empty, &empty)
}
+10 -6
View File
@@ -172,16 +172,18 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
updateInput := &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: existingExpiresAt,
ClearExpiresAt: existingExpiresAt == nil,
FallbackMode: existingFallbackMode,
BackupProxyID: existingBackupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: existingBackupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
// 保留已存在代理的网络配置字段
Name: existing.Name,
Protocol: existing.Protocol,
Host: existing.Host,
Port: existing.Port,
Username: existing.Username,
Password: existing.Password,
Username: &existing.Username,
Password: &existing.Password,
}
if _, err := h.adminService.UpdateProxy(ctx, existing.ID, updateInput); err != nil {
result.Errors = append(result.Errors, DataImportError{
@@ -255,15 +257,17 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
if _, err := h.adminService.UpdateProxy(ctx, created.ID, &service.UpdateProxyInput{
Status: normalizedStatus,
ExpiresAt: expiresAt,
ClearExpiresAt: expiresAt == nil,
FallbackMode: fallbackMode,
BackupProxyID: backupProxyID,
ExpiryWarnDays: item.ExpiryWarnDays,
ClearBackupID: backupProxyID == nil,
ExpiryWarnDays: &item.ExpiryWarnDays,
Name: created.Name,
Protocol: created.Protocol,
Host: created.Host,
Port: created.Port,
Username: created.Username,
Password: created.Password,
Username: &created.Username,
Password: &created.Password,
}); err != nil {
result.Errors = append(result.Errors, DataImportError{
Kind: "proxy",
+24 -16
View File
@@ -41,17 +41,17 @@ type CreateProxyRequest struct {
// UpdateProxyRequest represents update proxy request
type UpdateProxyRequest struct {
Name string `json:"name"`
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
Host string `json:"host"`
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
Username string `json:"username"`
Password string `json:"password"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
ExpiresAt *int64 `json:"expires_at"`
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
BackupProxyID *int64 `json:"backup_proxy_id"`
ExpiryWarnDays int `json:"expiry_warn_days" binding:"omitempty,min=0"`
Name string `json:"name"`
Protocol string `json:"protocol" binding:"omitempty,oneof=http https socks5 socks5h"`
Host string `json:"host"`
Port int `json:"port" binding:"omitempty,min=1,max=65535"`
Username *string `json:"username"`
Password *string `json:"password"`
Status string `json:"status" binding:"omitempty,oneof=active inactive"`
ExpiresAt dto.NullableInt64Field `json:"expires_at"`
FallbackMode string `json:"fallback_mode" binding:"omitempty,oneof=none proxy direct"`
BackupProxyID dto.NullableInt64Field `json:"backup_proxy_id"`
ExpiryWarnDays *int `json:"expiry_warn_days" binding:"omitempty,min=0"`
}
// List handles listing all proxies with pagination
@@ -183,21 +183,29 @@ func (h *ProxyHandler) Update(c *gin.Context) {
}
var expiresAt *time.Time
if req.ExpiresAt != nil && *req.ExpiresAt > 0 {
t := time.Unix(*req.ExpiresAt, 0).UTC()
if req.ExpiresAt.Value != nil && *req.ExpiresAt.Value > 0 {
t := time.Unix(*req.ExpiresAt.Value, 0).UTC()
expiresAt = &t
}
if req.Username != nil {
*req.Username = strings.TrimSpace(*req.Username)
}
if req.Password != nil {
*req.Password = strings.TrimSpace(*req.Password)
}
proxy, err := h.adminService.UpdateProxy(c.Request.Context(), proxyID, &service.UpdateProxyInput{
Name: strings.TrimSpace(req.Name),
Protocol: strings.TrimSpace(req.Protocol),
Host: strings.TrimSpace(req.Host),
Port: req.Port,
Username: strings.TrimSpace(req.Username),
Password: strings.TrimSpace(req.Password),
Username: req.Username,
Password: req.Password,
Status: strings.TrimSpace(req.Status),
ExpiresAt: expiresAt,
ClearExpiresAt: req.ExpiresAt.Set && expiresAt == nil,
FallbackMode: strings.TrimSpace(req.FallbackMode),
BackupProxyID: req.BackupProxyID,
BackupProxyID: req.BackupProxyID.Value,
ClearBackupID: req.BackupProxyID.Set && req.BackupProxyID.Value == nil,
ExpiryWarnDays: req.ExpiryWarnDays,
})
if err != nil {
@@ -0,0 +1,60 @@
//go:build unit
package admin
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type proxyPartialUpdateService struct {
service.AdminService
input *service.UpdateProxyInput
}
func (s *proxyPartialUpdateService) UpdateProxy(_ context.Context, _ int64, input *service.UpdateProxyInput) (*service.Proxy, error) {
s.input = input
return &service.Proxy{ID: 9}, nil
}
func TestProxyHandlerUpdatePreservesFieldPresence(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, tc := range []struct {
name, body string
set, clear, warn bool
}{
{name: "omitted", body: `{"status":"inactive"}`},
{name: "null clears", body: `{"expires_at":null,"backup_proxy_id":null,"fallback_mode":"none","expiry_warn_days":0}`, set: true, clear: true, warn: true},
{name: "zero clears expiry", body: `{"expires_at":0,"backup_proxy_id":null}`, set: true, clear: true},
{name: "values", body: `{"expires_at":1800000000,"backup_proxy_id":10,"fallback_mode":"proxy","expiry_warn_days":7}`, set: true, warn: true},
} {
t.Run(tc.name, func(t *testing.T) {
svc := &proxyPartialUpdateService{}
router := gin.New()
router.PUT("/proxies/:id", NewProxyHandler(svc).Update)
w := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodPut, "/proxies/9", strings.NewReader(tc.body))
req.Header.Set("Content-Type", "application/json")
router.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, w.Body.String())
require.NotNil(t, svc.input)
require.Equal(t, tc.set, svc.input.ExpiresAt != nil || svc.input.ClearExpiresAt)
require.Equal(t, tc.set, svc.input.BackupProxyID != nil || svc.input.ClearBackupID)
require.Equal(t, tc.warn, svc.input.ExpiryWarnDays != nil)
if !tc.set || tc.clear {
require.Nil(t, svc.input.ExpiresAt)
require.Nil(t, svc.input.BackupProxyID)
} else {
require.Equal(t, int64(1800000000), svc.input.ExpiresAt.Unix())
require.Equal(t, int64(10), *svc.input.BackupProxyID)
}
})
}
}
@@ -376,12 +376,14 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
GrokDefaultTextModel: settings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: settings.GrokCrossClientModelMapEnabled,
GrokDefaultBaseURLMode: settings.GrokDefaultBaseURLMode,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
SubscriptionEnabled: settings.SubscriptionEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
@@ -579,6 +579,9 @@ func diffSettings(before *service.SystemSettings, after *service.SystemSettings,
if before.AvailableChannelsEnabled != after.AvailableChannelsEnabled {
changed = append(changed, "available_channels_enabled")
}
if before.SubscriptionEnabled != after.SubscriptionEnabled {
changed = append(changed, "subscription_enabled")
}
if before.ModelPlazaEnabled != after.ModelPlazaEnabled {
changed = append(changed, "model_plaza_enabled")
}
@@ -186,3 +186,20 @@ func TestUpdateSettingsValidatesTencentCaptchaAppIDWhenEnabledFlagIsOmitted(t *t
require.Equal(t, http.StatusBadRequest, rec.Code)
require.Contains(t, rec.Body.String(), "positive integer")
}
// subscription_enabled is an opt-out switch: an explicit false is written as-is,
// and a later payload that omits the field keeps the stored value.
func TestUpdateSettingsSubscriptionEnabledIsWritableAndKeptWhenOmitted(t *testing.T) {
h, repo := newStepUpSwitchTestHandler(t, map[string]string{
service.SettingKeySubscriptionEnabled: "true",
})
rec := doUpdateSettings(t, h, map[string]any{"subscription_enabled": false}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled])
rec = doUpdateSettings(t, h, map[string]any{"site_name": "Example Gateway"}, nil)
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "false", repo.values[service.SettingKeySubscriptionEnabled],
"a payload without subscription_enabled must not flip the stored value back to true")
}
@@ -334,6 +334,7 @@ type UpdateSettingsRequest struct {
ChannelMonitorDefaultIntervalSeconds *int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput *bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota *bool `json:"channel_monitor_show_quota"`
ChannelMonitorHideUserRanking *bool `json:"channel_monitor_hide_user_ranking"`
// Grok model mapping policy
GrokDefaultTextModel *string `json:"grok_default_text_model"`
@@ -343,6 +344,9 @@ type UpdateSettingsRequest struct {
// Available Channels feature switch (user-facing)
AvailableChannelsEnabled *bool `json:"available_channels_enabled"`
// Subscription feature switch (user-facing subscription surface; see SettingKeySubscriptionEnabled)
SubscriptionEnabled *bool `json:"subscription_enabled"`
// Model Plaza feature switches + description
ModelPlazaEnabled *bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth *bool `json:"model_plaza_require_auth"`
@@ -1906,6 +1910,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.ChannelMonitorShowQuota
}(),
ChannelMonitorHideUserRanking: func() bool {
if req.ChannelMonitorHideUserRanking != nil {
return *req.ChannelMonitorHideUserRanking
}
return previousSettings.ChannelMonitorHideUserRanking
}(),
GrokDefaultTextModel: func() string {
if req.GrokDefaultTextModel != nil {
return *req.GrokDefaultTextModel
@@ -1930,6 +1940,12 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
}
return previousSettings.AvailableChannelsEnabled
}(),
SubscriptionEnabled: func() bool {
if req.SubscriptionEnabled != nil {
return *req.SubscriptionEnabled
}
return previousSettings.SubscriptionEnabled
}(),
ModelPlazaEnabled: func() bool {
if req.ModelPlazaEnabled != nil {
return *req.ModelPlazaEnabled
@@ -2366,12 +2382,14 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: updatedSettings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: updatedSettings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: updatedSettings.ChannelMonitorShowQuota,
ChannelMonitorHideUserRanking: updatedSettings.ChannelMonitorHideUserRanking,
GrokDefaultTextModel: updatedSettings.GrokDefaultTextModel,
GrokCrossClientModelMapEnabled: updatedSettings.GrokCrossClientModelMapEnabled,
GrokDefaultBaseURLMode: updatedSettings.GrokDefaultBaseURLMode,
AvailableChannelsEnabled: updatedSettings.AvailableChannelsEnabled,
SubscriptionEnabled: updatedSettings.SubscriptionEnabled,
ModelPlazaEnabled: updatedSettings.ModelPlazaEnabled,
ModelPlazaRequireAuth: updatedSettings.ModelPlazaRequireAuth,
@@ -0,0 +1,212 @@
package admin
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type bulkActionHandlerSubscriptionRepo struct {
service.UserSubscriptionRepository
sub *service.UserSubscription
extendCalls int
}
type cancellationAwareBulkActionRepo struct {
*bulkActionHandlerSubscriptionRepo
cancelRequest context.CancelFunc
}
func (r *cancellationAwareBulkActionRepo) GetByID(ctx context.Context, id int64) (*service.UserSubscription, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return r.bulkActionHandlerSubscriptionRepo.GetByID(ctx, id)
}
func (r *cancellationAwareBulkActionRepo) ExtendExpiry(ctx context.Context, id int64, expiry time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
err := r.bulkActionHandlerSubscriptionRepo.ExtendExpiry(ctx, id, expiry)
r.cancelRequest()
return err
}
type cancellationAwareAdminIdempotencyRepo struct {
*memoryIdempotencyRepoStub
saves int
}
func (r *cancellationAwareAdminIdempotencyRepo) MarkSucceeded(ctx context.Context, id int64, status int, body string, expiresAt time.Time) error {
if err := ctx.Err(); err != nil {
return err
}
r.saves++
return r.memoryIdempotencyRepoStub.MarkSucceeded(ctx, id, status, body, expiresAt)
}
func (r *bulkActionHandlerSubscriptionRepo) GetByID(_ context.Context, id int64) (*service.UserSubscription, error) {
if r.sub == nil || r.sub.ID != id {
return nil, service.ErrSubscriptionNotFound
}
copy := *r.sub
return &copy, nil
}
func (r *bulkActionHandlerSubscriptionRepo) GetByIDForUpdate(ctx context.Context, id int64) (*service.UserSubscription, error) {
return r.GetByID(ctx, id)
}
func (r *bulkActionHandlerSubscriptionRepo) ExtendExpiry(_ context.Context, _ int64, expiry time.Time) error {
r.extendCalls++
r.sub.ExpiresAt = expiry
return nil
}
func bulkActionHandlerRequest(router *gin.Engine, path, body, key string) *httptest.ResponseRecorder {
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body))
req.Header.Set("Content-Type", "application/json")
if key != "" {
req.Header.Set("Idempotency-Key", key)
}
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
return rec
}
func TestSubscriptionBulkAction_ReplaysPartialResultWithoutRepeatingExtension(t *testing.T) {
gin.SetMode(gin.TestMode)
cfg := service.DefaultIdempotencyConfig()
cfg.ObserveOnly = false
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(newMemoryIdempotencyRepoStub(), cfg))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
expiresAt := time.Now().AddDate(0, 0, 30)
repo := &bulkActionHandlerSubscriptionRepo{sub: &service.UserSubscription{ID: 1, UserID: 1, GroupID: 10, ExpiresAt: expiresAt}}
svc := service.NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
h := NewSubscriptionHandler(svc)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, h.BulkAction)
body := `{"subscription_ids":[1,404,1],"action":"extend","days":7}`
missingKey := bulkActionHandlerRequest(router, path, body, "")
require.Equal(t, http.StatusBadRequest, missingKey.Code)
require.Zero(t, repo.extendCalls)
first := bulkActionHandlerRequest(router, path, body, "bulk-extend-once")
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
var result struct {
Data service.BulkSubscriptionActionResult `json:"data"`
}
require.NoError(t, json.Unmarshal(first.Body.Bytes(), &result))
require.Equal(t, 1, result.Data.SuccessCount)
require.Equal(t, 1, result.Data.FailedCount)
require.Len(t, result.Data.Results, 2)
replayed := bulkActionHandlerRequest(router, path, body, "bulk-extend-once")
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
require.Equal(t, "true", replayed.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, first.Body.String(), replayed.Body.String())
require.Equal(t, 1, repo.extendCalls)
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.sub.ExpiresAt)
conflict := bulkActionHandlerRequest(router, path, `{"subscription_ids":[1,404,1],"action":"extend","days":14}`, "bulk-extend-once")
require.Equal(t, http.StatusConflict, conflict.Code)
require.Equal(t, 1, repo.extendCalls)
}
func TestSubscriptionBulkAction_ClientCancellationStillPersistsReplay(t *testing.T) {
gin.SetMode(gin.TestMode)
idempotencyRepo := &cancellationAwareAdminIdempotencyRepo{memoryIdempotencyRepoStub: newMemoryIdempotencyRepoStub()}
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(idempotencyRepo, service.DefaultIdempotencyConfig()))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
requestCtx, cancel := context.WithCancel(context.Background())
defer cancel()
expiresAt := time.Now().AddDate(0, 0, 30)
repo := &cancellationAwareBulkActionRepo{
bulkActionHandlerSubscriptionRepo: &bulkActionHandlerSubscriptionRepo{sub: &service.UserSubscription{ID: 1, UserID: 1, GroupID: 10, ExpiresAt: expiresAt}},
cancelRequest: cancel,
}
svc := service.NewSubscriptionService(nil, repo, nil, nil, nil)
t.Cleanup(svc.Stop)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, NewSubscriptionHandler(svc).BulkAction)
body := `{"subscription_ids":[1],"action":"extend","days":7}`
request := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(body)).WithContext(requestCtx)
request.Header.Set("Content-Type", "application/json")
request.Header.Set("Idempotency-Key", "canceled-request")
first := httptest.NewRecorder()
router.ServeHTTP(first, request)
require.ErrorIs(t, requestCtx.Err(), context.Canceled)
require.Equal(t, http.StatusOK, first.Code, first.Body.String())
require.Equal(t, 1, idempotencyRepo.saves)
replayed := bulkActionHandlerRequest(router, path, body, "canceled-request")
require.Equal(t, http.StatusOK, replayed.Code, replayed.Body.String())
require.Equal(t, "true", replayed.Header().Get("X-Idempotency-Replayed"))
require.JSONEq(t, first.Body.String(), replayed.Body.String())
require.Equal(t, 1, repo.extendCalls)
require.Equal(t, expiresAt.AddDate(0, 0, 7), repo.sub.ExpiresAt)
}
func TestSubscriptionBulkAction_ValidatesBeforeIdempotencyAndExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
service.SetDefaultIdempotencyCoordinator(service.NewIdempotencyCoordinator(storeUnavailableRepoStub{}, service.DefaultIdempotencyConfig()))
t.Cleanup(func() { service.SetDefaultIdempotencyCoordinator(nil) })
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-action"
router.POST(path, NewSubscriptionHandler(nil).BulkAction)
tooManyIDs := make([]int64, 101)
for i := range tooManyIDs {
tooManyIDs[i] = 1
}
tooManyBody, err := json.Marshal(service.BulkSubscriptionActionInput{SubscriptionIDs: tooManyIDs, Action: "revoke"})
require.NoError(t, err)
for _, body := range []string{
`{"subscription_ids":[1],"action":"delete"}`,
`{"subscription_ids":[1,0],"action":"revoke"}`,
`{"subscription_ids":[],"action":"restore"}`,
`{"subscription_ids":[1],"action":"extend","days":0}`,
`{"subscription_ids":[1],"action":"extend","days":36501}`,
`{"subscription_ids":[1],"action":"reset_quota"}`,
`{"subscription_ids":[1],"action":"reset_quota","daily":"yes"}`,
`{"subscription_ids":[1],"action":"revoke"`,
string(tooManyBody),
} {
t.Run(body, func(t *testing.T) {
response := bulkActionHandlerRequest(router, path, body, "bulk-invalid")
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
})
}
// A valid request must still fail closed when replay protection is unavailable.
response := bulkActionHandlerRequest(router, path, `{"subscription_ids":[1],"action":"revoke"}`, "bulk-valid")
require.Equal(t, http.StatusServiceUnavailable, response.Code)
}
func TestSubscriptionBulkAssign_RejectsInvalidUserIDsBeforeExecution(t *testing.T) {
gin.SetMode(gin.TestMode)
router := gin.New()
path := "/api/v1/admin/subscriptions/bulk-assign"
router.POST(path, NewSubscriptionHandler(nil).BulkAssign)
for _, ids := range [][]int64{{}, {1, 0}, {1, -1}, make([]int64, 101)} {
t.Run(fmt.Sprint(len(ids), ids), func(t *testing.T) {
body, err := json.Marshal(BulkAssignSubscriptionRequest{UserIDs: ids, GroupID: 1, ValidityDays: 30})
require.NoError(t, err)
response := bulkActionHandlerRequest(router, path, string(body), "")
require.Equal(t, http.StatusBadRequest, response.Code, response.Body.String())
})
}
}
@@ -3,6 +3,7 @@ package admin
import (
"context"
"strconv"
"time"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
@@ -48,7 +49,7 @@ type AssignSubscriptionRequest struct {
// BulkAssignSubscriptionRequest represents bulk assign subscription request
type BulkAssignSubscriptionRequest struct {
UserIDs []int64 `json:"user_ids" binding:"required,min=1"`
UserIDs []int64 `json:"user_ids" binding:"required,min=1,max=100,dive,gt=0"`
GroupID int64 `json:"group_id" binding:"required"`
ValidityDays int `json:"validity_days" binding:"omitempty,max=36500"` // max 100 years
Notes string `json:"notes"`
@@ -186,6 +187,23 @@ func (h *SubscriptionHandler) BulkAssign(c *gin.Context) {
response.Success(c, dto.BulkAssignResultFromService(result))
}
// BulkAction applies one operation to selected subscriptions, returning each outcome.
// POST /api/v1/admin/subscriptions/bulk-action
func (h *SubscriptionHandler) BulkAction(c *gin.Context) {
var req service.BulkSubscriptionActionInput
if err := c.ShouldBindJSON(&req); err != nil {
response.BadRequest(c, "Invalid request: "+err.Error())
return
}
if err := req.Validate(); err != nil {
response.ErrorFrom(c, err)
return
}
executeAdminIdempotentJSONWithTimeout(c, "admin.subscriptions.bulk-action", req, service.DefaultWriteIdempotencyTTL(), 2*time.Minute, func(ctx context.Context) (any, error) {
return h.subscriptionService.BulkSubscriptionAction(ctx, &req)
})
}
// Extend handles adjusting a subscription (extend or shorten)
// POST /api/v1/admin/subscriptions/:id/extend
func (h *SubscriptionHandler) Extend(c *gin.Context) {
@@ -790,15 +790,21 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
}
}
// 三档全空的输入不落库:user_platform_quotas 只保存至少配置了一档限额的记录,
// 不存在的行等价于不限额。未进入 records 的平台由 UpsertForUser 软删,审计里记为 removed。
records := make([]service.UserPlatformQuotaRecord, 0, len(req.Quotas))
for _, q := range req.Quotas {
records = append(records, service.UserPlatformQuotaRecord{
rec := service.UserPlatformQuotaRecord{
UserID: userID,
Platform: q.Platform,
DailyLimitUSD: q.DailyLimitUSD,
WeeklyLimitUSD: q.WeeklyLimitUSD,
MonthlyLimitUSD: q.MonthlyLimitUSD,
})
}
if !rec.HasAnyLimit() {
continue
}
records = append(records, rec)
}
ctx := c.Request.Context()
@@ -860,6 +866,7 @@ func (h *UserHandler) UpdateUserPlatformQuotas(c *gin.Context) {
slog.Info("admin.quota_updated",
"actor_admin_id", getAdminIDFromContext(c),
"target_user_id", userID,
"submitted_count", len(req.Quotas),
"platform_count", len(records),
"before_snapshot_available", beforeErr == nil,
"changes", changes)
@@ -112,16 +112,61 @@ func TestUpdateUserPlatformQuotas_Success(t *testing.T) {
if len(repo.upsertCalls) != 1 {
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
}
// upsert 记录数 = 请求体中给出的平台数(未给出的平台不落库)。
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 5 {
// upsert 记录数 = 请求体中至少配置了一档限额的平台数;三档全空的平台不落库,
// 由 UpsertForUser 的"不在列表里就软删"分支处理。
if repo.upsertCalls[0].userID != 42 || len(repo.upsertCalls[0].records) != 2 {
t.Errorf("unexpected upsert call: %+v", repo.upsertCalls[0])
}
for _, r := range repo.upsertCalls[0].records {
if r.Platform != "anthropic" && r.Platform != "openai" {
t.Errorf("platform %q has no configured limit and must not be upserted", r.Platform)
}
}
// 缓存失效:按全部允许平台统一失效(含 kimi/zhipu/deepseek)。
if len(cache.deleteCalls) != len(service.AllowedQuotaPlatforms) {
t.Errorf("expected %d cache delete calls, got %d: %+v", len(service.AllowedQuotaPlatforms), len(cache.deleteCalls), cache.deleteCalls)
}
}
// TestUpdateUserPlatformQuotas_AllUnlimitedClearsRows 锁定:全部平台三档全空等价于清空,
// UpsertForUser 收到空列表(软删该用户所有活跃行),且 0 = 显式禁用仍算已配置。
func TestUpdateUserPlatformQuotas_AllUnlimitedClearsRows(t *testing.T) {
repo := &upsertCapturingQuotaRepo{}
cache := &billingCacheStub{}
h := buildTestHandler(repo, cache)
body := `{"quotas":[
{"platform":"anthropic","daily_limit_usd":null,"weekly_limit_usd":null,"monthly_limit_usd":null},
{"platform":"openai"}
]}`
c, w := putReq(t, body)
h.UpdateUserPlatformQuotas(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if len(repo.upsertCalls) != 1 {
t.Fatalf("UpsertForUser should be called once, got %d", len(repo.upsertCalls))
}
if len(repo.upsertCalls[0].records) != 0 {
t.Errorf("all-unlimited input must upsert zero records, got %+v", repo.upsertCalls[0].records)
}
repo = &upsertCapturingQuotaRepo{}
h = buildTestHandler(repo, &billingCacheStub{})
c, w = putReq(t, `{"quotas":[{"platform":"gemini","daily_limit_usd":0}]}`)
h.UpdateUserPlatformQuotas(c)
if w.Code != http.StatusOK {
t.Fatalf("expected 200, got %d: %s", w.Code, w.Body.String())
}
if len(repo.upsertCalls) != 1 || len(repo.upsertCalls[0].records) != 1 {
t.Fatalf("zero limit is a configured limit and must be upserted: %+v", repo.upsertCalls)
}
if r := repo.upsertCalls[0].records[0]; r.Platform != "gemini" || r.DailyLimitUSD == nil || *r.DailyLimitUSD != 0 {
t.Errorf("unexpected record: %+v", r)
}
}
func TestUpdateUserPlatformQuotas_RejectsDuplicatePlatform(t *testing.T) {
h := buildTestHandler(&upsertCapturingQuotaRepo{}, &billingCacheStub{})
body := `{"quotas":[
@@ -35,6 +35,7 @@ func TestOpenAICompatibleTextTargetAllowsCompositeProviders(t *testing.T) {
{model: "k3", platform: service.PlatformKimi},
{model: "glm-5.2", platform: service.PlatformZhipu},
{model: "deepseek-v3.2", platform: service.PlatformDeepseek},
{model: "MiniMax-M3", platform: service.PlatformMiniMax},
}
for _, path := range []string{"/v1/messages", "/v1/chat/completions", "/v1/responses", "/v1/responses/input_tokens", "/v1/messages/count_tokens"} {
for _, provider := range providers {
@@ -56,7 +57,7 @@ func TestResponsesWebSocketCompositePlatformGuardKeepsOpenAIAndGrokOnly(t *testi
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformOpenAI))
require.True(t, isResponsesWebSocketCompositePlatform(service.PlatformGrok))
for _, platform := range []string{
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax,
service.PlatformAnthropic, service.PlatformGemini,
} {
require.False(t, isResponsesWebSocketCompositePlatform(platform), "platform=%s", platform)
+21 -8
View File
@@ -9,13 +9,14 @@ import (
// CustomMenuItem represents a user-configured custom menu entry.
type CustomMenuItem struct {
ID string `json:"id"`
Label string `json:"label"`
IconSVG string `json:"icon_svg"`
URL string `json:"url"`
PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"` // "user" or "admin"
SortOrder int `json:"sort_order"`
ID string `json:"id"`
Label string `json:"label"`
IconSVG string `json:"icon_svg"`
URL string `json:"url"`
PageSlug string `json:"page_slug,omitempty"`
Visibility string `json:"visibility"` // "user" or "admin"
SortOrder int `json:"sort_order"`
HideOpenButton bool `json:"hide_open_button,omitempty"`
}
// CustomEndpoint represents an admin-configured API endpoint for quick copy.
@@ -307,6 +308,7 @@ type SystemSettings struct {
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
// Grok model mapping policy (admin settings; empty account mapping falls back to these).
GrokDefaultTextModel string `json:"grok_default_text_model"`
@@ -316,6 +318,10 @@ type SystemSettings struct {
// Available Channels feature switch (user-facing aggregate view)
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
// Subscription feature switch: gates the whole user-facing subscription surface
// (sidebar entries, purchase-page subscription tab, header badge, /subscriptions route).
SubscriptionEnabled bool `json:"subscription_enabled"`
// Model Plaza feature (public group/model pricing showcase)
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
@@ -402,7 +408,11 @@ type PublicSettings struct {
GoogleOAuthEnabled bool `json:"google_oauth_enabled"`
BackendModeEnabled bool `json:"backend_mode_enabled"`
PaymentEnabled bool `json:"payment_enabled"`
Version string `json:"version"`
// PaymentBalanceDisabled mirrors the payment-config BALANCE_PAYMENT_DISABLED switch so the
// user shell can derive the site billing mode (recharge & subscription / recharge only /
// subscription only) before any authenticated checkout call.
PaymentBalanceDisabled bool `json:"payment_balance_disabled"`
Version string `json:"version"`
// 服务器全局时区(IANA 名称与当前 UTC 偏移,如 "Asia/Shanghai" / "+08:00")。
// 高峰时段等按服务器本地时间判定的窗口,前端展示时据此标注,避免用户按浏览器本地时间误读。
ServerTimezone string `json:"server_timezone"`
@@ -417,9 +427,12 @@ type PublicSettings struct {
ChannelMonitorDefaultIntervalSeconds int `json:"channel_monitor_default_interval_seconds"`
ChannelMonitorHideThroughput bool `json:"channel_monitor_hide_throughput"`
ChannelMonitorShowQuota bool `json:"channel_monitor_show_quota"`
ChannelMonitorHideUserRanking bool `json:"channel_monitor_hide_user_ranking"`
AvailableChannelsEnabled bool `json:"available_channels_enabled"`
SubscriptionEnabled bool `json:"subscription_enabled"`
ModelPlazaEnabled bool `json:"model_plaza_enabled"`
ModelPlazaRequireAuth bool `json:"model_plaza_require_auth"`
PluginManagementEnabled bool `json:"plugin_management_enabled"`
+1 -1
View File
@@ -315,7 +315,7 @@ func GetUpstreamEndpoint(c *gin.Context, platform string) string {
// OpenAI 转发服务维护独立的运行时端点上下文,覆盖普通入站推导。
// 这对 force_chat_completions 的错误路径尤为重要:此时可能没有
// ForwardResult,不能把入站 /v1/responses 误报成上游端点。
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsCNProvider(platform) {
if platform == service.PlatformOpenAI || platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform) {
if endpoint := service.GetActualOpenAIUpstreamEndpoint(c); endpoint != "" {
return endpoint
}
+12
View File
@@ -233,6 +233,18 @@ func TestResolveOpenAIUpstreamEndpointPrefersForwardResult(t *testing.T) {
result: &service.OpenAIForwardResult{},
want: EndpointResponses,
},
{
name: "opencode go conversion result reports responses",
account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey},
result: &service.OpenAIForwardResult{UpstreamEndpoint: EndpointResponses},
want: EndpointResponses,
},
{
name: "opencode go empty result without runtime stays inbound",
account: &service.Account{Platform: service.PlatformOpenCodeGo, Type: service.AccountTypeAPIKey},
result: &service.OpenAIForwardResult{},
want: EndpointChatCompletions,
},
}
for _, tt := range tests {
+16 -30
View File
@@ -1116,8 +1116,8 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
}
}
// Models handles listing available models
// GET /v1/models
// Models lists visible models, or retrieves the exact list entry for a model path parameter.
// GET /v1/models and /v1/models/:model (also exposed through root aliases)
// Returns models based on account configurations (model_mapping whitelist)
// Falls back to default models if no whitelist is configured
func (h *GatewayHandler) Models(c *gin.Context) {
@@ -1173,18 +1173,12 @@ func (h *GatewayHandler) Models(c *gin.Context) {
// Fallback to default models
if platform == service.PlatformOpenAI {
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": openai.DefaultModels,
})
writeModelsListResponse(c, openai.DefaultModels)
return
}
if platform == service.PlatformGemini {
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": geminicli.DefaultModels,
})
writeModelsListResponse(c, geminicli.DefaultModels)
return
}
if platform == service.PlatformGrok {
@@ -1192,10 +1186,7 @@ func (h *GatewayHandler) Models(c *gin.Context) {
return
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": claude.DefaultModels,
})
writeModelsListResponse(c, claude.DefaultModels)
}
// CodexModels returns the effective group model list using the manifest shape
@@ -1278,12 +1269,12 @@ func (h *GatewayHandler) compositeAvailableModels(ctx context.Context, groupID *
seen := make(map[string]struct{})
models := make([]string, 0)
schedulablePlatforms := h.gatewayService.GetSchedulablePlatforms(ctx, groupID)
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, platform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
platformModels := h.gatewayService.GetAvailableModels(ctx, groupID, platform)
if len(platformModels) == 0 {
// CN 供应商没有静态默认模型列表(defaultModelIDsForPlatform 的
// default 分支是 Claude 列表),composite 下只暴露账号映射键。
if _, ok := schedulablePlatforms[platform]; ok && !service.IsCNProvider(platform) {
if _, ok := schedulablePlatforms[platform]; ok && !service.IsMultiProtocolAPIKeyProvider(platform) {
platformModels = defaultModelIDsForPlatform(platform)
}
}
@@ -1320,10 +1311,7 @@ func writeModelsList(c *gin.Context, platform string, modelIDs []string) {
CreatedAt: "2024-01-01T00:00:00Z",
})
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": models,
})
writeModelsListResponse(c, models)
}
func writeAllowlistedModelsList(c *gin.Context, platform string, modelIDs []string) {
@@ -1382,10 +1370,7 @@ func writeGrokModelsList(c *gin.Context, modelIDs []string) {
models = append(models, item)
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": models,
})
writeModelsListResponse(c, models)
}
func grokModelSupportsConfigurableReasoning(modelID string) bool {
@@ -1418,10 +1403,7 @@ func writeOpenAIModelsList(c *gin.Context, modelIDs []string) {
DisplayName: modelID,
})
}
c.JSON(http.StatusOK, gin.H{
"object": "list",
"data": models,
})
writeModelsListResponse(c, models)
}
// modelListingSource 汇总模型列表过滤的候选来源:账号映射键(availableModels)
@@ -1440,7 +1422,9 @@ func modelListingSource(platform string, availableModels, fallbackModels []strin
func defaultCodexModelIDsForPlatform(platform string) []string {
switch platform {
case service.PlatformDeepseek:
return []string{"deepseek-v4-pro", "deepseek-v4-flash"}
return []string{"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"}
case service.PlatformMiniMax:
return []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}
default:
return defaultModelIDsForPlatform(platform)
}
@@ -1467,10 +1451,12 @@ func defaultModelIDsForPlatform(platform string) []string {
return claude.DefaultModelIDs()
case service.PlatformGrok:
return xai.DefaultModelIDs()
case service.PlatformOpenCodeGo:
return service.DefaultOpenCodeGoModelIDs()
case service.PlatformComposite:
ids := make([]string, 0)
seen := make(map[string]struct{})
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, concretePlatform := range []string{service.PlatformAnthropic, service.PlatformGemini, service.PlatformOpenAI, service.PlatformAntigravity, service.PlatformGrok, service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo} {
for _, id := range defaultModelIDsForPlatform(concretePlatform) {
if _, ok := seen[id]; ok {
continue
@@ -89,6 +89,10 @@ func claudeCodeBodyMapFromParsedRequest(parsedReq *service.ParsedRequest) map[st
bodyMap := map[string]any{
"model": parsedReq.Model,
}
// 探测识别(max_tokens=1)需要看到该字段,复用已解析请求时一并带上。
if parsedReq.MaxTokens > 0 {
bodyMap["max_tokens"] = parsedReq.MaxTokens
}
if parsedReq.HasSystem {
if system, ok := parsedReq.SystemValue(); ok {
bodyMap["system"] = system
@@ -500,3 +500,19 @@ type helperConcurrencyCacheStubWithError struct {
func (s *helperConcurrencyCacheStubWithError) AcquireAccountSlot(ctx context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
return false, s.err
}
func TestSetClaudeCodeClientContext_ParsedRequestProbeWithoutSystemPrompt(t *testing.T) {
c, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
c.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
// The hot path reuses ParsedRequest instead of re-parsing the body; the probe
// marker must survive that projection or the exemption only works cold.
parsed := &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 1}
SetClaudeCodeClientContext(c, nil, parsed)
require.True(t, service.IsClaudeCodeClient(c.Request.Context()))
c2, _ := newHelperTestContext(http.MethodPost, "/v1/messages")
c2.Request.Header.Set("User-Agent", "claude-cli/2.1.260 (external, cli)")
SetClaudeCodeClientContext(c2, nil, &service.ParsedRequest{Model: "claude-sonnet-4-5", MaxTokens: 64})
require.False(t, service.IsClaudeCodeClient(c2.Request.Context()))
}
@@ -0,0 +1,114 @@
package handler
import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func requestModelForTest(h *GatewayHandler, group *service.Group, modelID, etag string) *httptest.ResponseRecorder {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
c.Request.Header.Set("If-None-Match", etag)
if modelID != "" {
c.Params = gin.Params{{Key: "model", Value: modelID}}
}
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{GroupID: &group.ID, Group: group})
h.Models(c)
return rec
}
func TestRetrieveModelMatchesVisibleCatalogue(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, platform := range []string{service.PlatformOpenAI, service.PlatformAnthropic, service.PlatformGemini, service.PlatformGrok, service.PlatformComposite} {
for _, mapped := range []bool{false, true} {
name := platform + "/fallback"
if mapped {
name = platform + "/mapped"
}
t.Run(name, func(t *testing.T) {
accountPlatform := platform
if platform == service.PlatformComposite {
accountPlatform = service.PlatformOpenAI
}
credentials := map[string]any{}
if mapped {
credentials["model_mapping"] = map[string]any{"custom-model": "upstream-only-model"}
}
group := &service.Group{ID: 71, Platform: platform}
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
group.ID: {{ID: 1, Platform: accountPlatform, Type: service.AccountTypeAPIKey, Status: service.StatusActive, Schedulable: true, Credentials: credentials}},
}})
list := requestModelForTest(h, group, "", "")
require.Equal(t, http.StatusOK, list.Code, list.Body.String())
var catalog struct {
Data []json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(list.Body.Bytes(), &catalog))
require.NotEmpty(t, catalog.Data)
var model struct {
ID string `json:"id"`
}
require.NoError(t, json.Unmarshal(catalog.Data[0], &model))
retrieved := requestModelForTest(h, group, model.ID, "")
require.Equal(t, http.StatusOK, retrieved.Code, retrieved.Body.String())
require.JSONEq(t, string(catalog.Data[0]), retrieved.Body.String())
require.Equal(t, http.StatusNotFound, requestModelForTest(h, group, "unknown-model", "").Code)
group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: []string{"absent-from-source"}}
hidden := requestModelForTest(h, group, model.ID, "")
require.Equal(t, http.StatusNotFound, hidden.Code, hidden.Body.String())
require.Contains(t, hidden.Body.String(), `"code":"model_not_found"`)
})
}
}
}
func TestRetrievePinnedModelPreservesMetadataFilteringAndErrors(t *testing.T) {
for _, tc := range []struct {
name string
status int
selected []string
wantStatus int
}{
{name: "metadata", wantStatus: http.StatusOK},
{name: "hidden", selected: []string{"other-model"}, wantStatus: http.StatusNotFound},
{name: "upstream failure", status: http.StatusServiceUnavailable, wantStatus: http.StatusBadGateway},
} {
t.Run(tc.name, func(t *testing.T) {
upstream := &codexModelsPinnedHTTPUpstream{bodies: map[int64]string{
2: `{"data":[{"id":"special-model","ID":"not-a-model-id","owned_by":"source-owner","created":123,"extra":{"context":999}}]}`,
}, statuses: map[int64]int{}}
if tc.status != 0 {
upstream.statuses[2] = tc.status
}
codex := newPinnedCodexTestHandler([]service.Account{newPinnedCodexAccount(2, service.StatusActive, true, false)}, upstream, 3)
h := &GatewayHandler{openAIGatewayService: codex.gatewayService, maxAccountSwitches: 3}
group := &service.Group{ID: 72, Platform: service.PlatformOpenAI,
ModelAllowlist: service.GroupModelAllowlist{Enabled: len(tc.selected) > 0, Models: tc.selected},
CodexModelsManifestConfig: service.GroupCodexModelsManifestConfig{Enabled: true, AccountIDs: []int64{2}}}
got := requestModelForTest(h, group, "special-model", "collection-etag")
require.Equal(t, tc.wantStatus, got.Code, got.Body.String())
if tc.wantStatus == http.StatusOK {
list := requestModelForTest(h, group, "", "")
var catalog struct {
Data []json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(list.Body.Bytes(), &catalog))
require.JSONEq(t, string(catalog.Data[0]), got.Body.String())
require.Contains(t, got.Body.String(), `"owned_by":"source-owner"`)
require.Contains(t, got.Body.String(), `"created":123`)
again := requestModelForTest(h, group, "special-model", list.Header().Get("ETag"))
require.Equal(t, http.StatusOK, again.Code)
require.Empty(t, again.Header().Get("ETag"))
require.Equal(t, http.StatusNotFound, requestModelForTest(h, group, "not-a-model-id", "").Code)
}
})
}
}
@@ -791,6 +791,13 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
},
},
{
ID: 7,
Platform: service.PlatformMiniMax,
Credentials: map[string]any{
"model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"},
},
},
},
},
},
@@ -805,7 +812,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
Platform: service.PlatformComposite,
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"},
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"},
},
},
})
@@ -816,7 +823,7 @@ func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t
var got gatewayModelsResponseForTest
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom"}, modelIDsForTest(got.Data))
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data))
}
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
@@ -869,6 +876,7 @@ func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testin
{ID: 2, Platform: service.PlatformKimi},
{ID: 3, Platform: service.PlatformZhipu},
{ID: 4, Platform: service.PlatformDeepseek},
{ID: 5, Platform: service.PlatformMiniMax},
},
},
},
@@ -900,13 +908,14 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
for _, model := range claude.DefaultModels {
want = append(want, model.ID)
}
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek} {
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
}
}
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
}
+56 -20
View File
@@ -198,30 +198,57 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
}
routingStart := time.Now()
requiredCapability := grokMediaRequiredCapability(endpoint)
var accountReleaseFunc func()
releaseAccount := func() {
if accountReleaseFunc != nil {
accountReleaseFunc()
accountReleaseFunc = nil
}
}
defer releaseAccount()
for {
releaseAccount()
if failoverClientGone(c) {
return
}
selection, scheduleDecision, err := h.gatewayService.SelectAccountWithSchedulerForCapability(
requestCtx,
apiKey.GroupID,
"",
sessionHash,
routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
requiredCapability,
false,
false,
false,
service.PlatformGrok,
)
var selection *service.AccountSelectionResult
var scheduleDecision service.OpenAIAccountScheduleDecision
if boundLookupAccountID > 0 {
selection, scheduleDecision, err = h.gatewayService.SelectGrokMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel,
)
} else {
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability(
requestCtx,
apiKey.GroupID,
"",
sessionHash,
routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
requiredCapability,
false,
false,
false,
service.PlatformGrok,
)
}
// Own an eagerly acquired slot before any rejection or eligibility probe.
// Forwarding takes over the same once-only release after admission.
if selection != nil && selection.Acquired {
selection.ReleaseFunc = wrapReleaseOnDone(requestCtx, selection.ReleaseFunc)
accountReleaseFunc = selection.ReleaseFunc
}
if err != nil {
if failoverClientGone(c) {
reqLog.Info("grok_media.account_select_aborted_client_disconnected", zap.Error(err))
return
}
if boundLookupAccountID > 0 && errors.Is(err, service.ErrNoAvailableAccounts) {
h.errorResponse(c, http.StatusNotFound, "not_found_error", "Video request not found")
return
}
reqLog.Warn("grok_media.account_select_failed",
zap.Error(err),
zap.Int("excluded_account_count", len(failedAccountIDs)),
@@ -260,6 +287,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
h.errorResponse(c, cls.Status, cls.ErrType, cls.Message)
return
}
if failoverClientGone(c) {
return
}
if boundLookupAccountID > 0 && selection.Account.ID != boundLookupAccountID {
reqLog.Warn("grok_media.video_lookup_bound_account_unavailable",
zap.Int64("bound_account_id", boundLookupAccountID),
@@ -282,6 +312,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
if endpoint.IsGenerationRequest() {
eligible, eligibilityReason, eligibilityErr := h.ensureGrokMediaAccountEligibility(requestCtx, account)
if !eligible {
releaseAccount()
mediaEligibilityRejected = true
failedAccountIDs[account.ID] = struct{}{}
reqLog.Warn("grok_media.account_eligibility_rejected",
@@ -298,10 +329,19 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
continue
}
}
if failoverClientGone(c) {
return
}
sessionHash = ensureOpenAIPoolModeSessionHash(sessionHash, account)
setOpsSelectedAccount(c, account.ID, account.Platform)
accountReleaseFunc, slotResult := h.acquireResponsesAccountSlot(c, apiKey.GroupID, sessionHash, selection, false, &streamStarted, reqLog)
admissionSessionHash := sessionHash
if boundLookupAccountID > 0 {
// Waiting must not replace the video ownership TTL with text sticky TTL.
admissionSessionHash = ""
}
var slotResult openAISlotAcquireResult
accountReleaseFunc, slotResult = h.acquireResponsesAccountSlot(c, apiKey.GroupID, admissionSessionHash, selection, false, &streamStarted, reqLog)
if slotResult == openAISlotAcquireProfitVetoed {
// 媒体路径已显式豁免利润门(suppress 标记),此分支仅防御性兜底,
// 同样受否决上限约束。
@@ -319,11 +359,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
forwardStart := time.Now()
writerSizeBeforeForward := c.Writer.Size()
result, err := func() (*service.OpenAIForwardResult, error) {
defer func() {
if accountReleaseFunc != nil {
accountReleaseFunc()
}
}()
defer releaseAccount()
return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType)
}()
@@ -0,0 +1,405 @@
//go:build unit
package handler
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/Wei-Shaw/sub2api/internal/testutil"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
)
type grokMediaSlotsCache struct {
testutil.StubConcurrencyCache
mu sync.Mutex
accounts map[string]int64
users map[string]int64
acquired, released, userAcquired, userReleased int
waiting, denied int
full bool
queueFull bool
onWait func()
duplicateRelease bool
peak int
}
func (s *grokMediaSlotsCache) AcquireAccountSlot(_ context.Context, id int64, _ int, request string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
if s.full || s.denied > 0 {
if s.denied > 0 {
s.denied--
}
return false, nil
}
s.accounts[request] = id
s.acquired++
if len(s.accounts) > s.peak {
s.peak = len(s.accounts)
}
return true, nil
}
func (s *grokMediaSlotsCache) ReleaseAccountSlot(_ context.Context, _ int64, request string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.accounts[request]; !ok {
s.duplicateRelease = true
}
delete(s.accounts, request)
s.released++
return nil
}
func (s *grokMediaSlotsCache) AcquireUserSlot(_ context.Context, id int64, _ int, request string) (bool, error) {
s.mu.Lock()
defer s.mu.Unlock()
s.users[request] = id
s.userAcquired++
return true, nil
}
func (s *grokMediaSlotsCache) ReleaseUserSlot(_ context.Context, _ int64, request string) error {
s.mu.Lock()
defer s.mu.Unlock()
if _, ok := s.users[request]; !ok {
s.duplicateRelease = true
}
delete(s.users, request)
s.userReleased++
return nil
}
func (s *grokMediaSlotsCache) IncrementAccountWaitCount(context.Context, int64, int) (bool, error) {
s.mu.Lock()
if s.queueFull {
s.mu.Unlock()
return false, nil
}
s.waiting++
onWait := s.onWait
s.mu.Unlock()
if onWait != nil {
onWait()
}
return true, nil
}
func (s *grokMediaSlotsCache) DecrementAccountWaitCount(context.Context, int64) error {
s.mu.Lock()
defer s.mu.Unlock()
s.waiting--
return nil
}
func (s *grokMediaSlotsCache) assertReleased(t *testing.T) {
t.Helper()
s.mu.Lock()
defer s.mu.Unlock()
require.Empty(t, s.accounts)
require.Empty(t, s.users)
require.Equal(t, s.acquired, s.released)
require.Equal(t, s.userAcquired, s.userReleased)
require.False(t, s.duplicateRelease)
require.Zero(t, s.waiting)
}
type grokMediaSlotBindings struct {
testutil.StubGatewayCache
owner int64
writes int
key string
billed map[string]bool
}
func (s *grokMediaSlotBindings) GetSessionAccountID(_ context.Context, groupID int64, key string) (int64, error) {
if groupID != 24 || key != s.key {
return 0, service.ErrStickySessionNotFound
}
return s.owner, nil
}
func (s *grokMediaSlotBindings) SetSessionAccountID(_ context.Context, _ int64, key string, owner int64, _ time.Duration) error {
s.key, s.owner = key, owner
s.writes++
return nil
}
func (s *grokMediaSlotBindings) RefreshSessionTTL(context.Context, int64, string, time.Duration) error {
s.writes++
return nil
}
func (s *grokMediaSlotBindings) DeleteSessionAccountID(context.Context, int64, string) error {
s.writes++
return nil
}
func (s *grokMediaSlotBindings) ClaimGrokVideoBilled(_ context.Context, key string, _ time.Duration) (bool, error) {
if s.billed == nil {
s.billed = make(map[string]bool)
}
if s.billed[key] {
return false, nil
}
s.billed[key] = true
return true, nil
}
type grokMediaSlotRepo struct {
openAIImagesFailoverAccountRepo
mismatch bool
}
func (r grokMediaSlotRepo) GetByID(ctx context.Context, id int64) (*service.Account, error) {
if r.mismatch {
return r.openAIImagesFailoverAccountRepo.GetByID(ctx, 2)
}
return r.openAIImagesFailoverAccountRepo.GetByID(ctx, id)
}
type grokMediaSlotUpstream struct {
service.HTTPUpstream
call func(*http.Request, int64) (*http.Response, error)
calls int
}
func (u *grokMediaSlotUpstream) Do(req *http.Request, _ string, id int64, _ int) (*http.Response, error) {
u.calls++
return u.call(req, id)
}
type grokMediaSlotProber func(context.Context, int64) (bool, string, error)
func (p grokMediaSlotProber) ProbeMediaEligibility(ctx context.Context, id int64) (bool, string, error) {
return p(ctx, id)
}
func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) {
t.Helper()
gin.SetMode(gin.TestMode)
accounts := make([]service.Account, 3)
for i := range accounts {
accounts[i] = service.Account{ID: int64(i + 1), Platform: service.PlatformGrok, Type: service.AccountTypeAPIKey,
Status: service.StatusActive, Schedulable: true, Concurrency: 50, Priority: i,
GroupIDs: []int64{24}, Credentials: map[string]any{"api_key": "test-key", "access_token": "test-token"}}
if oauth {
accounts[i].Type = service.AccountTypeOAuth
accounts[i].Credentials["refresh_token"] = "test-refresh"
accounts[i].Credentials["expires_at"] = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
}
}
slots := &grokMediaSlotsCache{accounts: map[string]int64{}, users: map[string]int64{}}
concurrency := service.NewConcurrencyService(slots)
bindings := &grokMediaSlotBindings{owner: 1}
upstream := &grokMediaSlotUpstream{call: func(*http.Request, int64) (*http.Response, error) {
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(`{"request_id":"task","status":"pending"}`))}, nil
}}
cfg := &config.Config{RunMode: config.RunModeSimple}
cfg.Gateway.Scheduling.StickySessionWaitTimeout = 20 * time.Millisecond
cfg.Gateway.Scheduling.StickySessionMaxWaiting = 3
cfg.Gateway.OpenAIScheduler.StickyEscapeEnabled = true
repo := grokMediaSlotRepo{openAIImagesFailoverAccountRepo: openAIImagesFailoverAccountRepo{accounts: accounts}, mismatch: mismatch}
provider := service.NewGrokTokenProvider(repo, nil)
if oauth {
_, err := provider.GetAccessToken(context.Background(), &accounts[1])
require.NoError(t, err)
}
gateway := service.NewOpenAIGatewayService(repo, nil, nil, nil, nil, nil, bindings, cfg, nil, concurrency, nil, nil, nil, upstream, nil, nil, provider, nil, nil, nil, nil, nil)
groupID := int64(24)
require.NoError(t, gateway.BindGrokMediaVideoRequestAccount(context.Background(), &groupID, "task", 10, 20, 1))
bindings.writes = 0
billing := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
t.Cleanup(billing.Stop)
handler := NewOpenAIGatewayHandler(gateway, concurrency, billing, service.NewAPIKeyService(nil, nil, nil, nil, nil, nil, cfg), nil, nil, nil, nil, cfg)
return handler, slots, bindings, upstream
}
func grokMediaSlotContext(ctx context.Context, generation bool) (*gin.Context, *httptest.ResponseRecorder) {
groupID := int64(24)
method, path, body := http.MethodGet, "/v1/videos/task", ""
if generation {
method, path, body = http.MethodPost, "/v1/videos/generations", `{"model":"grok-imagine-video","prompt":"test","duration":6}`
}
req := httptest.NewRequest(method, path, strings.NewReader(body)).WithContext(ctx)
req.Header.Set("Content-Type", "application/json")
if !generation {
req.Header.Set("session-id", "unrelated-client-session")
}
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = req
c.Params = gin.Params{{Key: "request_id", Value: "task"}}
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{ID: 20, UserID: 10, GroupID: &groupID,
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok, AllowImageGeneration: true}, User: &service.User{ID: 10}})
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 10, Concurrency: 5})
return c, w
}
func TestGrokMediaLookupSlotLifecycle(t *testing.T) {
for _, scenario := range []string{"normal", "mismatch acquired", "mismatch wait", "full", "queue full", "cancel while waiting", "wait then acquired", "upstream error", "cancel", "panic"} {
t.Run(scenario, func(t *testing.T) {
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, strings.HasPrefix(scenario, "mismatch"))
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
if scenario == "full" || scenario == "mismatch wait" || scenario == "queue full" || scenario == "cancel while waiting" {
slots.full = true
}
if scenario == "queue full" {
slots.queueFull = true
}
if scenario == "cancel while waiting" {
slots.onWait = cancel
}
if scenario == "wait then acquired" {
slots.denied = 1
}
original := upstream.call
upstream.call = func(req *http.Request, id int64) (*http.Response, error) {
require.Equal(t, int64(1), id)
switch scenario {
case "upstream error":
return nil, errors.New("upstream unavailable")
case "cancel":
cancel()
return nil, context.Canceled
case "panic":
panic("test upstream panic")
}
return original(req, id)
}
for range 20 {
c, w := grokMediaSlotContext(ctx, false)
h.GrokVideoStatus(c)
slots.assertReleased(t)
if strings.HasPrefix(scenario, "mismatch") {
require.Equal(t, 404, w.Code)
}
if scenario == "normal" || scenario == "wait then acquired" {
require.Equal(t, 200, w.Code)
}
if scenario == "full" || scenario == "queue full" {
require.Equal(t, http.StatusTooManyRequests, w.Code)
}
}
require.Zero(t, bindings.writes, "lookups must preserve owner and TTL")
if scenario == "mismatch acquired" {
require.Equal(t, 20, slots.released)
}
if slots.full {
require.Zero(t, slots.released)
require.Zero(t, upstream.calls)
}
if scenario == "full" || strings.HasPrefix(scenario, "mismatch") {
require.Zero(t, upstream.calls)
}
switch scenario {
case "normal", "wait then acquired", "upstream error", "panic":
require.Equal(t, 20, upstream.calls)
require.Equal(t, 20, slots.released)
case "cancel":
require.Equal(t, 1, upstream.calls)
require.Equal(t, 1, slots.released)
}
})
}
}
func TestGrokMediaEligibilityReleasesBeforeSwitch(t *testing.T) {
for _, scenario := range []string{"switch", "exhausted", "cancel during probe"} {
t.Run(scenario, func(t *testing.T) {
h, slots, _, upstream := newGrokMediaSlotHandler(t, true, false)
h.maxAccountSwitches = 1
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
probes := 0
h.grokMediaEligibilityProber = grokMediaSlotProber(func(context.Context, int64) (bool, string, error) {
probes++
if scenario == "cancel during probe" {
cancel()
require.Eventually(t, func() bool { slots.mu.Lock(); defer slots.mu.Unlock(); return len(slots.accounts) == 0 }, time.Second, time.Millisecond)
}
if scenario == "switch" && probes == 2 {
return true, "eligible", nil
}
return false, "billing_unobserved", errors.New("probe failed")
})
c, w := grokMediaSlotContext(ctx, true)
h.GrokVideoGeneration(c)
slots.assertReleased(t)
require.Equal(t, 1, slots.peak, "rejected attempts must release before the next selection")
if scenario == "switch" {
events, _ := c.Get(service.OpsUpstreamErrorsKey)
detail, err := json.Marshal(events)
require.NoError(t, err)
require.Equal(t, 200, w.Code, "body=%s probes=%d upstream=%d events=%s", w.Body.String(), probes, upstream.calls, detail)
require.Equal(t, 1, upstream.calls)
}
if scenario == "exhausted" {
require.Equal(t, 503, w.Code)
require.Equal(t, 2, slots.released)
require.Zero(t, upstream.calls)
}
})
}
}
func TestGrokMediaVideoLookupOwnerIsolation(t *testing.T) {
for _, other := range []string{"user", "api key", "group", "task"} {
t.Run(other, func(t *testing.T) {
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, false)
c, w := grokMediaSlotContext(context.Background(), false)
key, ok := middleware2.GetAPIKeyFromContext(c)
require.True(t, ok)
switch other {
case "user":
c.Set(string(middleware2.ContextKeyUser), middleware2.AuthSubject{UserID: 11, Concurrency: 5})
case "api key":
key.ID = 21
case "group":
groupID := int64(25)
key.GroupID = &groupID
case "task":
c.Params = gin.Params{{Key: "request_id", Value: "other-task"}}
}
h.GrokVideoStatus(c)
require.Equal(t, http.StatusNotFound, w.Code)
slots.assertReleased(t)
require.Zero(t, slots.acquired)
require.Zero(t, upstream.calls)
require.Zero(t, bindings.writes)
})
}
}
func TestGrokMediaVideoCompletionStillClaimsBillingOnce(t *testing.T) {
h, _, bindings, _ := newGrokMediaSlotHandler(t, false, false)
c, _ := grokMediaSlotContext(context.Background(), false)
key, ok := middleware2.GetAPIKeyFromContext(c)
require.True(t, ok)
subject := middleware2.AuthSubject{UserID: 10, Concurrency: 5}
result := &service.OpenAIForwardResult{ResponseID: "task", Model: "grok-imagine-video",
VideoCount: 1, VideoDurationSeconds: 6}
for i := range 20 {
bill := prepareGrokVideoCompletionBilling(c.Request.Context(), h, zap.NewNop(), key, subject, "task", result)
if i == 0 {
require.NotNil(t, bill)
require.Equal(t, service.StableGrokVideoBillingRequestID("task"), bill.RequestID)
} else {
require.Nil(t, bill)
}
}
require.Len(t, bindings.billed, 1)
}
@@ -15,7 +15,7 @@ import (
// 广场路由挂 OptionalJWT 中间件:匿名可访问(除非 require_auth 开启),带 token 则
// 识别用户。可见性规则(橱窗语义,与「可用渠道」的可绑定语义不同):
// - 匿名:仅非专属分组(订阅型照常展示);
// - 登录:非专属分组 + user_allowed_groups 授权的专属分组(不检查订阅有效性);
// - 登录:非专属分组 + user_allowed_groups 授权或持有有效订阅的专属分组;
// 若该用户开启了公开分组限制,则公开分组同样需要落在授权集合内。
type ModelPlazaHandler struct {
plazaService *service.ModelPlazaService
@@ -160,7 +160,7 @@ func (h *ModelPlazaHandler) Get(c *gin.Context) {
}
// filterPlazaVisibleGroups 按登录态裁剪分组可见性。
// allowedGroups == nil 表示匿名(仅非专属);非 nil 表示登录(非专属 + 授权专属)。
// allowedGroups == nil 表示匿名(仅非专属);非 nil 包含普通授权及有效订阅分组。
// restrictPublicGroups 为 true 时,公开分组也必须落在 allowedGroups 内,否则用户会
// 在广场看到自己实际绑定不了的分组。
func filterPlazaVisibleGroups(
@@ -244,3 +244,17 @@ func TestToModelPlazaGroupDTO_TimePricing(t *testing.T) {
weekdaysTP := weekdaysModel["time_pricing"].(map[string]any)
require.Equal(t, true, weekdaysTP["weekdays_only"])
}
func TestFilterPlazaVisibleGroups_SubscribedExclusiveGroup(t *testing.T) {
groups := []service.PlazaGroup{
{ID: 42, IsExclusive: true, SubscriptionType: "subscription"},
{ID: 43, IsExclusive: true, SubscriptionType: "subscription"},
{ID: 44, IsExclusive: true, SubscriptionType: "standard"},
}
require.Empty(t, filterPlazaVisibleGroups(groups, nil, false))
for _, restricted := range []bool{false, true} {
visible := filterPlazaVisibleGroups(groups, map[int64]struct{}{42: {}}, restricted)
require.Len(t, visible, 1)
require.Equal(t, int64(42), visible[0].ID)
}
}
@@ -166,3 +166,44 @@ func TestNormalizeCodexAutomationBootstrapPreservesOrderAndIsIdempotent(t *testi
require.False(t, changedAgain)
require.Equal(t, got, again)
}
func TestNormalizeCodexAutomationBootstrapFullHeartbeat(t *testing.T) {
const output = `<heartbeat>
<automation_id>flutter</automation_id>
<current_time_iso>2026-09-09T00:33:34.775Z</current_time_iso>
<instructions>
Read the reference index and report changes. Preserve A &amp; B and &lt;tags&gt;.
</instructions>
</heartbeat>`
body := codexAutomationBootstrapBody(t, output, "")
got, changed := normalizeCodexAutomationBootstrap(body)
require.True(t, changed)
require.Equal(t, "user", gjson.GetBytes(got, "input.0.role").String())
require.Equal(t, output, gjson.GetBytes(got, "input.0.content.0.text").String())
again, changedAgain := normalizeCodexAutomationBootstrap(got)
require.False(t, changedAgain)
require.Equal(t, got, again)
for _, field := range []string{"automation_id", "current_time_iso", "instructions"} {
duplicate := strings.Replace(output, "</heartbeat>", "<"+field+">duplicate</"+field+"></heartbeat>", 1)
require.False(t, validCodexAutomationHeartbeat(duplicate), field)
}
for _, invalid := range []string{
strings.Replace(output, "2026-09-09T00:33:34.775Z", "yesterday", 1),
strings.Replace(output, "<current_time_iso>2026-09-09T00:33:34.775Z</current_time_iso>", "", 1),
strings.Replace(output, "Read the reference index and report changes. Preserve A &amp; B and &lt;tags&gt;.", " ", 1),
strings.Replace(output, "<instructions>", `<instructions source="other">`, 1),
strings.Replace(output, "<instructions>", "<instructions><nested/>", 1),
} {
require.False(t, validCodexAutomationHeartbeat(invalid))
}
for _, guarded := range [][]byte{
codexAutomationBootstrapBody(t, output, `,"call_id":"call-1"`),
[]byte(strings.Replace(string(body), `"model":"gpt-5"`, `"model":"gpt-5","previous_response_id":"resp-1"`, 1)),
[]byte(strings.Replace(string(body), `"namespace":"codex_app"`, `"namespace":"other"`, 1)),
} {
unchanged, normalized := normalizeCodexAutomationBootstrap(guarded)
require.False(t, normalized)
require.Equal(t, guarded, unchanged)
}
}
@@ -0,0 +1,25 @@
package handler
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestOpenAIEnsureForwardErrorResponse_SkipsCanceledClient(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
ctx, cancel := context.WithCancel(context.Background())
c.Request = httptest.NewRequest(http.MethodPost, EndpointResponses, nil).WithContext(ctx)
cancel()
h := &OpenAIGatewayHandler{}
require.False(t, h.ensureForwardErrorResponse(c, true))
require.Equal(t, statusClientClosedRequest, c.Writer.Status())
require.Empty(t, recorder.Body.String())
}
@@ -18,7 +18,7 @@ import (
func TestAllowOpenAICompatibleMessagesDispatch_CNProvidersExempt(t *testing.T) {
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, nil), "无 key 保持放行")
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformGrok} {
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax, service.PlatformOpenCodeGo, service.PlatformGrok} {
apiKey := &service.APIKey{Group: &service.Group{Platform: platform, AllowMessagesDispatch: false}}
require.True(t, allowOpenAICompatibleMessagesDispatch(nil, apiKey),
"%s 分组必须豁免 allow_messages_dispatch 闸门", platform)
@@ -74,4 +74,11 @@ func TestResolveOpenAIMessagesDispatchMappedModel_CompositeCNTargetsSkipGroupMap
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"), "model=%s", model)
}
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest("POST", "/v1/messages", nil)
c.Request = c.Request.WithContext(service.WithResolvedTargetPlatform(c.Request.Context(), service.PlatformOpenCodeGo))
apiKey := &service.APIKey{Group: &service.Group{Platform: service.PlatformComposite}}
require.Empty(t, resolveOpenAIMessagesDispatchMappedModel(c, apiKey, "claude-sonnet-4-5-20250929"),
"composite → opencode_go 不得注入 openai 默认 gpt-5.x 映射")
}
@@ -198,11 +198,11 @@ func resolveOpenAIMessagesDispatchMappedModel(c *gin.Context, apiKey *service.AP
if apiKey == nil || apiKey.Group == nil {
return ""
}
// composite 解析到 grok/CN 目标时调度级映射不适用(Group 级映射的 gpt-5.x
// 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
// composite 解析到 grok/CN/OpenCode 目标时调度级映射不适用(Group 级映射的
// gpt-5.x 默认值是 openai 专属,发给这些上游必错),模型改写交给账号级 model_mapping。
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
return ""
}
}
@@ -305,14 +305,14 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
// 协议账号原生直通 Claude Code),无需 allow_messages_dispatch 开关授权——
// 该开关对非 openai/composite 平台恒被 sanitizeGroupMessagesDispatchFields 置 false,
// 若不豁免,CN 分组将永远 403。
if service.IsCNProvider(apiKey.Group.Platform) {
if service.IsMultiProtocolAPIKeyProvider(apiKey.Group.Platform) {
return true
}
// composite 分组解析到 grok/CN 目标时与对应独立分组同语义豁免;
// composite 分组解析到 grok/CN/OpenCode Go 目标时与对应独立分组同语义豁免;
// 解析到 openai 目标则受 composite 分组自身的可配置开关控制。
if apiKey.Group.Platform == service.PlatformComposite && c != nil && c.Request != nil {
if platform, ok := service.ResolvedTargetPlatformFromContext(c.Request.Context()); ok &&
(platform == service.PlatformGrok || service.IsCNProvider(platform)) {
(platform == service.PlatformGrok || service.IsMultiProtocolAPIKeyProvider(platform)) {
return true
}
}
@@ -322,7 +322,8 @@ func allowOpenAICompatibleMessagesDispatch(c *gin.Context, apiKey *service.APIKe
func openAICompatibleTextTargetAllowed(c *gin.Context, apiKey *service.APIKey, model string) bool {
return compositeTargetPlatformAllowed(c, apiKey, model,
service.PlatformOpenAI, service.PlatformGrok,
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek)
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformMiniMax, service.PlatformOpenCodeGo)
}
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
@@ -835,6 +836,22 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
})
}
if err != nil {
if result != nil && result.ClientDisconnect {
reqLog.Info("openai.client_disconnected",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
submitResponsesUsage(result)
return
}
if failoverClientGone(c) {
reqLog.Info("openai.client_disconnected",
zap.Int64("account_id", account.ID),
zap.Error(err),
)
submitResponsesUsage(result)
return
}
if result != nil && result.ImageCount > 0 {
reqLog.Warn("openai.forward_partial_error_with_image_result",
zap.Int64("account_id", account.ID),
@@ -1873,12 +1890,24 @@ func validCodexAutomationLastRun(value string) bool {
func validCodexAutomationHeartbeat(value string) bool {
decoder := xml.NewDecoder(strings.NewReader(value))
var rootSeen, automationIDSeen bool
var automationID bytes.Buffer
var childName string
var childText bytes.Buffer
fields := make(map[string]string)
depth := 0
for {
token, err := decoder.Token()
if err == io.EOF {
id := automationID.String()
id := fields["automation_id"]
timestamp, hasTime := fields["current_time_iso"]
instructions, hasInstructions := fields["instructions"]
if hasTime != hasInstructions {
return false
}
if hasTime {
if _, err := time.Parse(time.RFC3339Nano, timestamp); err != nil || strings.TrimSpace(instructions) == "" {
return false
}
}
return rootSeen && automationIDSeen && depth == 0 &&
strings.TrimSpace(id) == id && validCodexAutomationID(id)
}
@@ -1896,21 +1925,33 @@ func validCodexAutomationHeartbeat(value string) bool {
return false
}
rootSeen = true
} else if automationIDSeen || current.Name.Local != "automation_id" {
return false
} else {
childName = current.Name.Local
switch childName {
case "automation_id", "current_time_iso", "instructions":
default:
return false
}
if _, duplicate := fields[childName]; duplicate {
return false
}
childText.Reset()
}
automationIDSeen = depth == 2
case xml.EndElement:
if current.Name.Space != "" {
return false
}
if depth == 2 {
fields[childName] = childText.String()
automationIDSeen = automationIDSeen || childName == "automation_id"
}
depth--
if depth < 0 {
return false
}
case xml.CharData:
if depth == 2 {
_, _ = automationID.Write(current)
_, _ = childText.Write(current)
} else if len(bytes.TrimSpace(current)) != 0 {
return false
}
@@ -2976,7 +3017,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
// WebSocket 首包可能很大,hash 必须在 hooks 外算成字符串,避免 AfterTurn 闭包保活请求体。
requestPayloadHash = service.HashUsageRequestPayload(wsFirstMessage)
if preemptCtx, cleanupPreempt, armed := h.gatewayService.BeginOpenAIWSIngressSessionPreemption(ctx, c, account, wsFirstMessage); armed {
if preemptCtx, cleanupPreempt, armed := h.gatewayService.BeginOpenAIWSIngressSessionPreemptionWithClient(ctx, c, account, wsFirstMessage, wsConn); armed {
ctx = preemptCtx
defer cleanupPreempt()
}
@@ -2988,6 +3029,8 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
return
}
if service.IsOpenAIWSSessionPreemptedError(err) {
// 关闭帧已由抢占登记在取消前发给本连接,这里只记录并释放。
reqLog.Info("openai.websocket_ingress_preempted", zap.Int64("account_id", account.ID))
return
}
var failoverErr *service.UpstreamFailoverError
@@ -3528,6 +3571,10 @@ func (h *OpenAIGatewayHandler) ensureForwardErrorResponse(c *gin.Context, stream
if c == nil || c.Writer == nil {
return false
}
if c.Request != nil && errors.Is(c.Request.Context().Err(), context.Canceled) {
failoverClientGone(c)
return false
}
// 先停 compact 心跳再读 Writer 状态,避免与心跳 goroutine 竞争。
compactKeepaliveCommitted := service.StopOpenAICompactSSEKeepaliveCommitted(c)
if compactKeepaliveCommitted {
@@ -2960,7 +2960,7 @@ func runOpenAIResponsesWebSocketUsageLogCase(t *testing.T, tc openAIResponsesWSU
BillingModelSource: tc.billingModelSource,
}},
groupPlatforms: map[int64]string{groupID: service.PlatformOpenAI},
}, nil, nil, nil)
}, nil, nil, nil, nil)
}
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil, cfg, nil)
@@ -157,7 +157,7 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
)
handler.maxAccountSwitches = 10
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
body := []byte(`{"model":"gpt-image-1","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
core, observedLogs := observer.New(zap.DebugLevel)
requestCtx := logger.IntoContext(context.Background(), zap.New(core))
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)).WithContext(requestCtx)
@@ -1,7 +1,9 @@
package handler
import (
"encoding/json"
"errors"
"fmt"
"net/http"
infraerrors "github.com/Wei-Shaw/sub2api/internal/pkg/errors"
@@ -17,8 +19,12 @@ func (h *GatewayHandler) pinnedOpenAIModels(c *gin.Context, group *service.Group
writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "OpenAI model discovery is not configured")
return
}
etag := c.GetHeader("If-None-Match")
if c.Param("model") != "" {
etag = "" // A collection ETag cannot validate a single-model representation.
}
response, account, err := h.openAIGatewayService.FetchPinnedOpenAIModelsList(
c.Request.Context(), group, h.maxAccountSwitches, c.GetHeader("If-None-Match"),
c.Request.Context(), group, h.maxAccountSwitches, etag,
)
if c.Request.Context().Err() != nil {
return
@@ -40,6 +46,10 @@ func writeOpenAIModelsError(c *gin.Context, status int, errorType, message strin
}
func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsResponse) {
if c.Param("model") != "" {
writeRetrievedModel(c, manifest.Body)
return
}
if manifest.ETag != "" {
c.Header("ETag", manifest.ETag)
}
@@ -50,3 +60,50 @@ func writeOpenAIModelsResponse(c *gin.Context, manifest *service.OpenAIModelsRes
}
c.Data(http.StatusOK, "application/json", manifest.Body)
}
// Both discovery endpoints consume the same final catalogue, after group/platform
// selection and allowlist filtering. Preserve every field on the selected entry.
func writeModelsListResponse(c *gin.Context, models any) {
response := gin.H{"object": "list", "data": models}
if c.Param("model") == "" {
c.JSON(http.StatusOK, response)
return
}
body, err := json.Marshal(response)
if err != nil {
writeOpenAIModelsError(c, http.StatusInternalServerError, "api_error", "Failed to encode model catalogue")
return
}
writeRetrievedModel(c, body)
}
func writeRetrievedModel(c *gin.Context, body []byte) {
var catalog struct {
Data []json.RawMessage `json:"data"`
}
if err := json.Unmarshal(body, &catalog); err != nil {
writeOpenAIModelsError(c, http.StatusBadGateway, "upstream_error", "Invalid model catalogue")
return
}
modelID := c.Param("model")
for _, raw := range catalog.Data {
var model map[string]json.RawMessage
if err := json.Unmarshal(raw, &model); err != nil {
writeOpenAIModelsError(c, http.StatusBadGateway, "upstream_error", "Invalid model catalogue entry")
return
}
var id string
if err := json.Unmarshal(model["id"], &id); err != nil {
writeOpenAIModelsError(c, http.StatusBadGateway, "upstream_error", "Invalid model catalogue ID")
return
}
if id == modelID {
c.Data(http.StatusOK, "application/json", raw)
return
}
}
c.JSON(http.StatusNotFound, gin.H{"error": gin.H{
"type": "invalid_request_error", "code": "model_not_found", "param": "model",
"message": fmt.Sprintf("Model %q does not exist or is not available for this group", modelID),
}})
}
+40 -15
View File
@@ -1124,8 +1124,12 @@ func OpsErrorLoggerMiddleware(ops *service.OpsService) gin.HandlerFunc {
// A marked in-band error is a visible request failure even though its
// wire status is already 200. Otherwise retain recovered attempts as a
// provider-health row whose 2xx status keeps it outside request SLA.
if len(service.GetOpsStreamErrors(c)) > 0 {
if streamErrs := service.GetOpsStreamErrors(c); len(streamErrs) > 0 {
logOpsStreamError(c, ops, status)
// 请求级带内结果不承载上游归因,此前尝试的上游错误仍按恢复行记录。
if opsStreamErrorsAllRequestScoped(streamErrs) {
logOpsRecoveredUpstream(c, ops, status)
}
} else {
logOpsRecoveredUpstream(c, ops, status)
}
@@ -1405,22 +1409,31 @@ func opsRequestTypeFromContext(c *gin.Context) *int16 {
return nil
}
// logOpsStreamError 记录一次挂在已固化 HTTP 200 SSE 流上的就地错误。
// 由于 wire 状态码停留在 200,常规的 status>=400 捕获路径永远不会触发;
// handleStreamingAwareError 通过 service.MarkOpsStreamError 标记这类错误,
// 此函数据此补记一条错误日志,让并发限流/流内失败在错误看板里可见。
//
// 仅在 status<400 且不存在上游错误上下文时调用:上游透传错误已由中间件的
// upstream-context 分支落库,无需在此重复记录。
// logOpsStreamError 记录挂在 2xx 响应上的带内错误(就地 SSE error 帧、非流式正文里的
// 请求级结果等)。由于 wire 状态码停留在 2xx,常规的 status>=400 捕获路径不会触发;
// 标记方通过 service.MarkOpsStreamError / MarkOpsStreamErrorValue 登记,此函数据此补记
// 错误日志。上游错误上下文(若有)是否参与分类与归因由标记的 RequestScoped 决定。
func logOpsStreamError(c *gin.Context, ops *service.OpsService, wireStatus int) {
for _, streamErr := range service.GetOpsStreamErrors(c) {
logOpsStreamErrorValue(c, ops, wireStatus, streamErr)
}
}
func opsStreamErrorsAllRequestScoped(streamErrs []service.OpsStreamError) bool {
if len(streamErrs) == 0 {
return false
}
for _, streamErr := range streamErrs {
if !streamErr.RequestScoped {
return false
}
}
return true
}
func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus int, streamErr service.OpsStreamError) {
// 命中 skip_monitoring=true 透传规则的请求跳过落库,与其它分支一致。
if streamErr.SkipMonitoring || (streamErr.Turn == 0 && shouldSkipFinalOpsFailure(c)) {
if streamErr.SkipMonitoring || (streamErr.Turn == 0 && !streamErr.RequestScoped && shouldSkipFinalOpsFailure(c)) {
return
}
@@ -1435,9 +1448,19 @@ func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus
classifyStatus = wireStatus
}
normalizedType := normalizeOpsErrorType(streamErr.ErrType, streamErr.Code)
phase, isBusinessLimited, errorOwner, errorSource := classifyOpsErrorLog(c, normalizedType, streamErr.Message, streamErr.Code, classifyStatus)
var phase, errorOwner, errorSource string
var isBusinessLimited bool
if streamErr.RequestScoped {
// 请求级带内结果只按错误类型分类,此前尝试残留的上游错误上下文不参与判定。
phase = classifyOpsPhase(normalizedType, streamErr.Message, streamErr.Code)
isBusinessLimited = true
errorOwner = classifyOpsErrorOwner(phase, streamErr.Message)
errorSource = classifyOpsErrorSource(phase, streamErr.Message)
} else {
phase, isBusinessLimited, errorOwner, errorSource = classifyOpsErrorLog(c, normalizedType, streamErr.Message, streamErr.Code, classifyStatus)
}
recordedStatus := wireStatus
if streamErr.CountTowardsSLA && streamErr.IntendedStatus >= 400 {
if streamErr.IntendedStatus >= 400 && (streamErr.CountTowardsSLA || streamErr.RequestScoped) {
recordedStatus = streamErr.IntendedStatus
}
errorBody := ""
@@ -1488,8 +1511,8 @@ func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus
}
return ""
}(),
// 就地 SSE 错误只出现在流式请求上。
Stream: true,
// 带内错误默认挂在 SSE 流上;NonStream 标记的来自非流式 2xx 响应体。
Stream: !streamErr.NonStream,
InboundEndpoint: GetInboundEndpoint(c),
UpstreamEndpoint: GetUpstreamEndpoint(c, platform),
RequestedModel: modelName,
@@ -1530,8 +1553,10 @@ func logOpsStreamErrorValue(c *gin.Context, ops *service.OpsService, wireStatus
CreatedAt: time.Now(),
}
applyOpsLatencyFieldsFromContext(c, entry)
applyOpsUpstreamFieldsFromContext(c, entry)
if streamErr.Turn > 0 {
if !streamErr.RequestScoped {
applyOpsUpstreamFieldsFromContext(c, entry)
}
if streamErr.Turn > 0 && !streamErr.RequestScoped {
applyOpsStreamErrorSnapshot(entry, streamErr)
}
@@ -1996,3 +1996,175 @@ func TestClassifyOpsLocalModelConfigurationWithoutIngressMarkStaysRouting(t *tes
require.True(t, isBusinessLimited)
require.Equal(t, "platform", errorOwner)
}
// 带内错误也可能出现在非流式 2xx 响应体里(如 Gemini generateContent 的 finishReason):
// NonStream 标记落库 stream=false,未标记的沿用流式。
func TestLogOpsStreamError_NonStreamInBandContentPolicy(t *testing.T) {
setupOpsErrorLogTestQueue(t, 4)
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:generateContent", nil)
setOpsRequestContext(c, "gemini-3.7-flash", false)
service.MarkOpsStreamErrorValue(c, service.OpsStreamError{
ErrType: "invalid_request_error",
Code: "PROHIBITED_CONTENT",
Message: "Gemini content policy stop (finishReason=PROHIBITED_CONTENT): Response stopped due to prohibited content.",
IntendedStatus: http.StatusBadRequest,
RequestScoped: true,
NonStream: true,
})
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
logOpsStreamError(c, ops, http.StatusOK)
job := <-opsErrorLogQueue
require.NotNil(t, job.entry)
require.False(t, job.entry.Stream)
require.Equal(t, "invalid_request_error", job.entry.ErrorType)
require.Equal(t, "request", job.entry.ErrorPhase)
require.Equal(t, "client", job.entry.ErrorOwner)
require.True(t, job.entry.IsBusinessLimited)
require.Equal(t, http.StatusBadRequest, job.entry.StatusCode, "请求级带内结果按 IntendedStatus 落库,才能进入错误列表")
require.Equal(t, "P3", job.entry.Severity)
require.Equal(t, "gemini-3.7-flash", job.entry.Model)
require.Contains(t, job.entry.ErrorBody, "PROHIBITED_CONTENT")
}
// 请求级带内结果(RequestScoped):此前尝试残留的上游错误上下文不参与分类、不做上游归因,
// 按业务限制计;透传规则的 skip_monitoring 残留也不影响它落库。
func TestLogOpsStreamError_RequestScopedIgnoresResidualUpstreamContext(t *testing.T) {
setupOpsErrorLogTestQueue(t, 4)
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:streamGenerateContent", nil)
setOpsRequestContext(c, "gemini-3.7-flash", true)
c.Set(service.OpsUpstreamStatusCodeKey, http.StatusTooManyRequests)
c.Set(service.OpsUpstreamErrorMessageKey, "earlier attempt was rate limited")
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
UpstreamStatusCode: http.StatusTooManyRequests,
Message: "earlier attempt was rate limited",
SkipMonitoring: true,
}})
service.MarkOpsStreamErrorValue(c, service.OpsStreamError{
ErrType: "invalid_request_error",
Code: "PROHIBITED_CONTENT",
Message: "Gemini content policy stop (finishReason=PROHIBITED_CONTENT): Response stopped due to prohibited content.",
IntendedStatus: http.StatusBadRequest,
RequestScoped: true,
})
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
logOpsStreamError(c, ops, http.StatusOK)
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.NotNil(t, job.entry)
require.Equal(t, "invalid_request_error", job.entry.ErrorType)
require.Equal(t, "request", job.entry.ErrorPhase)
require.Equal(t, "client", job.entry.ErrorOwner)
require.Equal(t, "client_request", job.entry.ErrorSource)
require.True(t, job.entry.IsBusinessLimited)
require.True(t, job.entry.Stream)
require.Equal(t, http.StatusBadRequest, job.entry.StatusCode)
require.Nil(t, job.entry.UpstreamStatusCode, "残留的 429 不得贴到内容策略行上")
require.Nil(t, job.entry.UpstreamErrorMessage)
require.Nil(t, job.entry.UpstreamErrorsJSON)
require.Contains(t, job.entry.ErrorBody, "PROHIBITED_CONTENT")
}
// 请求级带内结果与此前尝试的上游错误并存时,中间件既落带内错误行,也保留恢复行的 provider 遥测。
func TestOpsErrorLoggerMiddleware_RequestScopedInBandErrorKeepsRecoveredTelemetry(t *testing.T) {
setupOpsErrorLogTestQueue(t, 4)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1beta/models/gemini-3.7-flash:generateContent", func(c *gin.Context) {
setOpsRequestContext(c, "gemini-3.7-flash", false)
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
UpstreamStatusCode: http.StatusTooManyRequests,
Message: "earlier attempt was rate limited",
}})
service.MarkOpsStreamErrorValue(c, service.OpsStreamError{
ErrType: "invalid_request_error",
Code: "SAFETY",
Message: "Gemini content policy stop (finishReason=SAFETY): Response stopped due to safety reasons.",
IntendedStatus: http.StatusBadRequest,
RequestScoped: true,
NonStream: true,
})
c.JSON(http.StatusOK, gin.H{"candidates": []any{}})
})
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:generateContent", nil))
require.Equal(t, http.StatusOK, recorder.Code)
require.Equal(t, int64(2), OpsErrorLogQueueLength())
inBand := <-opsErrorLogQueue
require.Equal(t, "invalid_request_error", inBand.entry.ErrorType)
require.Equal(t, "request", inBand.entry.ErrorPhase)
require.Equal(t, "client", inBand.entry.ErrorOwner)
require.True(t, inBand.entry.IsBusinessLimited)
require.False(t, inBand.entry.Stream)
require.Equal(t, http.StatusBadRequest, inBand.entry.StatusCode)
require.Nil(t, inBand.entry.UpstreamStatusCode)
require.Nil(t, inBand.entry.UpstreamErrorsJSON)
recovered := <-opsErrorLogQueue
require.Equal(t, "upstream", recovered.entry.ErrorPhase)
require.Equal(t, "provider", recovered.entry.ErrorOwner)
require.Equal(t, http.StatusOK, recovered.entry.StatusCode)
require.Equal(t, "Recovered upstream error 429: earlier attempt was rate limited", recovered.entry.ErrorMessage)
}
// 非请求级带内失败(如上游错误信封)仍与既有语义一致:上游上下文参与分类并做归因,只落一行。
func TestOpsErrorLoggerMiddleware_UpstreamInBandFailureStillSingleRow(t *testing.T) {
setupOpsErrorLogTestQueue(t, 4)
gin.SetMode(gin.TestMode)
ops := service.NewOpsService(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
router := gin.New()
router.Use(OpsErrorLoggerMiddleware(ops))
router.POST("/v1beta/models/gemini-3.7-flash:streamGenerateContent", func(c *gin.Context) {
setOpsRequestContext(c, "gemini-3.7-flash", true)
service.SetOpsUpstreamError(c, http.StatusTooManyRequests, "quota", "")
c.Set(service.OpsUpstreamErrorsKey, []*service.OpsUpstreamErrorEvent{{
UpstreamStatusCode: http.StatusTooManyRequests,
Message: "quota",
Kind: "stream_failed",
}})
service.MarkOpsStreamFailure(c, "rate_limit_error", "RESOURCE_EXHAUSTED", "quota", http.StatusTooManyRequests)
c.Data(http.StatusOK, "text/event-stream", []byte("data: {\"error\":{\"code\":429}}\n\n"))
})
recorder := httptest.NewRecorder()
router.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-3.7-flash:streamGenerateContent", nil))
require.Equal(t, int64(1), OpsErrorLogQueueLength())
job := <-opsErrorLogQueue
require.Equal(t, "rate_limit_error", job.entry.ErrorType)
require.Equal(t, "upstream", job.entry.ErrorPhase)
require.Equal(t, "provider", job.entry.ErrorOwner)
require.Equal(t, http.StatusTooManyRequests, job.entry.StatusCode)
require.True(t, job.entry.Stream)
require.NotNil(t, job.entry.UpstreamStatusCode)
require.Equal(t, http.StatusTooManyRequests, *job.entry.UpstreamStatusCode)
}
// Gemini 带内信号登记使用的错误类型必须都在 ops 白名单里,否则会被归一化成 api_error。
func TestNormalizeOpsErrorType_KeepsGeminiInBandSignalTypes(t *testing.T) {
for _, errType := range []string{
"invalid_request_error", "authentication_error", "permission_error",
"not_found_error", "rate_limit_error", "upstream_error",
} {
require.Equal(t, errType, normalizeOpsErrorType(errType, "PROHIBITED_CONTENT"), errType)
}
}
@@ -95,6 +95,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
GoogleOAuthEnabled: settings.GoogleOAuthEnabled,
BackendModeEnabled: settings.BackendModeEnabled,
PaymentEnabled: settings.PaymentEnabled,
PaymentBalanceDisabled: settings.PaymentBalanceDisabled,
Version: h.version,
ServerTimezone: timezone.Name(),
ServerUTCOffset: timezone.UTCOffset(),
@@ -108,8 +109,10 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
ChannelMonitorDefaultIntervalSeconds: settings.ChannelMonitorDefaultIntervalSeconds,
ChannelMonitorHideThroughput: settings.ChannelMonitorHideThroughput,
ChannelMonitorShowQuota: settings.ChannelMonitorShowQuota,
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
SubscriptionEnabled: settings.SubscriptionEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
@@ -44,6 +44,8 @@ const (
PlatformKimi = domain.PlatformKimi
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
)
// AllPlatforms 返回所有支持的平台列表
@@ -57,6 +59,8 @@ func AllPlatforms() []string {
PlatformKimi,
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
PlatformOpenCodeGo,
}
}
@@ -16,5 +16,7 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"kimi",
"zhipu",
"deepseek",
"minimax",
"opencode_go",
}, AllPlatforms())
}
@@ -202,7 +202,7 @@ func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
"notifyUrl": "https://example.com/notify",
"returnUrl": "https://example.com/return",
"paymentMode": paymentModePopup,
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt","displayName":"USDT-TRC20"}]`,
"customMethods": `[{"type":"ldc","upstreamType":"epay","displayName":"LDC"},{"type":"usdt_trc20","upstreamType":"usdt.trc20","displayName":"USDT-TRC20"}]`,
})
if err != nil {
t.Fatalf("NewEasyPay: %v", err)
@@ -221,8 +221,8 @@ func TestEasyPayCustomMethodsUseConfiguredUpstreamType(t *testing.T) {
if err != nil {
t.Fatalf("parse pay url: %v", err)
}
if got := payURL.Query().Get("type"); got != "usdt" {
t.Fatalf("pay url type = %q, want usdt (%s)", got, resp.PayURL)
if got := payURL.Query().Get("type"); got != "usdt.trc20" {
t.Fatalf("pay url type = %q, want usdt.trc20 (%s)", got, resp.PayURL)
}
}
@@ -184,6 +184,16 @@ var geminiModels = []modelDef{
{ID: "gemini-3.6-flash-low", DisplayName: "Gemini 3.6 Flash Low", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.6-flash-medium", DisplayName: "Gemini 3.6 Flash Medium", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.6-flash-tiered", DisplayName: "Gemini 3.6 Flash", CreatedAt: "2026-07-21T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.7-flash", DisplayName: "Gemini 3.7 Flash", CreatedAt: "2026-08-13T00:00:00Z"},
{ID: "gemini-3.7-flash-high", DisplayName: "Gemini 3.7 Flash High", CreatedAt: "2026-08-13T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.7-flash-low", DisplayName: "Gemini 3.7 Flash Low", CreatedAt: "2026-08-13T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.7-flash-medium", DisplayName: "Gemini 3.7 Flash Medium", CreatedAt: "2026-08-13T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.7-flash-tiered", DisplayName: "Gemini 3.7 Flash", CreatedAt: "2026-08-13T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.8-flash", DisplayName: "Gemini 3.8 Flash", CreatedAt: "2026-09-02T00:00:00Z"},
{ID: "gemini-3.8-flash-high", DisplayName: "Gemini 3.8 Flash High", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.8-flash-low", DisplayName: "Gemini 3.8 Flash Low", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.8-flash-medium", DisplayName: "Gemini 3.8 Flash Medium", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
{ID: "gemini-3.8-flash-tiered", DisplayName: "Gemini 3.8 Flash", CreatedAt: "2026-09-02T00:00:00Z", IsReasoning: true},
{ID: "gemini-3-pro-preview", DisplayName: "Gemini 3 Pro Preview", CreatedAt: "2025-06-01T00:00:00Z", IsReasoning: true},
{ID: "gemini-3-pro-image", DisplayName: "Gemini 3 Pro Image", CreatedAt: "2025-06-01T00:00:00Z"},
}
@@ -26,6 +26,16 @@ func TestDefaultModels_ContainsNewAndLegacyImageModels(t *testing.T) {
"gemini-3.6-flash-low",
"gemini-3.6-flash-medium",
"gemini-3.6-flash-tiered",
"gemini-3.7-flash",
"gemini-3.7-flash-high",
"gemini-3.7-flash-low",
"gemini-3.7-flash-medium",
"gemini-3.7-flash-tiered",
"gemini-3.8-flash",
"gemini-3.8-flash-high",
"gemini-3.8-flash-low",
"gemini-3.8-flash-medium",
"gemini-3.8-flash-tiered",
}
for _, id := range requiredIDs {
@@ -88,11 +88,14 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
// 用于存储 tool_use id -> name 映射
toolIDToName := make(map[string]string)
// 检测是否有 web_search 工具
hasWebSearchTool := hasWebSearchTool(claudeReq.Tools)
// 仅在「只有内置 web_search、没有客户端 function tools」时走 web_search 降级模型。
// Antigravity v1internal 不支持内置工具与 functionDeclarations 混用(即使设置
// includeServerSideToolInvocations 仍会 400,见 issue #6464),混用时会丢弃内置搜索,
// 因此不能再强制切到 gemini-2.5-flash,否则 Codex 等带 shell 工具的请求会整单失败。
useWebSearchRequest := hasWebSearchTool(claudeReq.Tools) && !hasClientFunctionTools(claudeReq.Tools)
requestType := "agent"
targetModel := mappedModel
if hasWebSearchTool {
if useWebSearchRequest {
requestType = "web_search"
if targetModel != webSearchFallbackModel {
targetModel = webSearchFallbackModel
@@ -147,22 +150,16 @@ func TransformClaudeToGeminiWithOptions(claudeReq *ClaudeRequest, projectID, map
SessionID: generateStableSessionID(contents),
}
// 针对 Gemini Reasoning 模型(如 gemini-3.1-pro-high等)过滤强制空 ToolConfig
// toolConfig must always be present: upstream rejects requests without it,
// including reasoning models called without any tools.
// 总是设置 toolConfig,与官方客户端一致
// 总是设置 toolConfig,与官方客户端一致。
// 注意:buildTools 会在客户端 function tools 存在时丢弃 googleSearch/codeExecution
// (issue #6464),因此这里不再注入 includeServerSideToolInvocations。
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 {
innerRequest.SystemInstruction = systemInstruction
@@ -696,6 +693,20 @@ func hasWebSearchTool(tools []ClaudeTool) bool {
return false
}
// hasClientFunctionTools 判断是否存在可转发的客户端 function/custom 工具。
// 内置 web_search / code_execution 不算客户端工具。
func hasClientFunctionTools(tools []ClaudeTool) bool {
for _, tool := range tools {
if isWebSearchTool(tool) || isCodeExecutionTool(tool) {
continue
}
if strings.TrimSpace(tool.Name) != "" {
return true
}
}
return false
}
func isWebSearchTool(tool ClaudeTool) bool {
if strings.HasPrefix(tool.Type, "web_search") || tool.Type == "google_search" {
return true
@@ -714,21 +725,6 @@ func isCodeExecutionTool(tool ClaudeTool) bool {
return strings.TrimSpace(tool.Type) == "code_execution"
}
// hasMixedToolInvocations 判断构建后的工具声明是否同时包含函数声明与内置工具
// (googleSearch)。仅在两者并存时需要开启 includeServerSideToolInvocations。
func hasMixedToolInvocations(declarations []GeminiToolDeclaration) bool {
hasFunc, hasBuiltin := false, false
for _, d := range declarations {
if len(d.FunctionDeclarations) > 0 {
hasFunc = true
}
if d.GoogleSearch != nil || d.CodeExecution != nil {
hasBuiltin = true
}
}
return hasFunc && hasBuiltin
}
// buildTools 构建 tools
func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
if len(tools) == 0 {
@@ -794,6 +790,18 @@ func buildTools(tools []ClaudeTool) []GeminiToolDeclaration {
})
}
// Antigravity v1internal 协议不支持内置工具与 functionDeclarations 混用:
// 即便带上 includeServerSideToolInvocations 仍返回 400(issue #6464)。
// Codex 默认同时带 web_search 与 shell 等客户端工具,优先保留客户端工具,
// 使代理会话可继续,而不是整单 upstream_error。
if len(funcDecls) > 0 {
if hasWebSearch || hasCodeExecution {
log.Printf("[antigravity] dropping built-in tools (web_search/code_execution) because client function tools are present; Antigravity v1internal rejects the mix")
}
hasWebSearch = false
hasCodeExecution = false
}
var declarations []GeminiToolDeclaration
if len(funcDecls) > 0 {
declarations = append(declarations, GeminiToolDeclaration{
@@ -263,7 +263,7 @@ func TestBuildTools_CustomTypeTools(t *testing.T) {
}
}
func TestBuildTools_PreservesWebSearchAlongsideFunctions(t *testing.T) {
func TestBuildTools_DropsWebSearchWhenClientFunctionsPresent(t *testing.T) {
tools := []ClaudeTool{
{
Name: "get_weather",
@@ -277,13 +277,27 @@ func TestBuildTools_PreservesWebSearchAlongsideFunctions(t *testing.T) {
}
result := buildTools(tools)
require.Len(t, result, 2)
// Antigravity v1internal rejects mixed built-in + functionDeclarations (#6464).
require.Len(t, result, 1)
require.Len(t, result[0].FunctionDeclarations, 1)
require.Equal(t, "get_weather", result[0].FunctionDeclarations[0].Name)
require.NotNil(t, result[1].GoogleSearch)
require.NotNil(t, result[1].GoogleSearch.EnhancedContent)
require.NotNil(t, result[1].GoogleSearch.EnhancedContent.ImageSearch)
require.Equal(t, 5, result[1].GoogleSearch.EnhancedContent.ImageSearch.MaxResultCount)
require.Nil(t, result[0].GoogleSearch)
}
func TestBuildTools_KeepsWebSearchWhenNoClientFunctions(t *testing.T) {
tools := []ClaudeTool{
{
Type: "web_search_20250305",
Name: "web_search",
},
}
result := buildTools(tools)
require.Len(t, result, 1)
require.NotNil(t, result[0].GoogleSearch)
require.NotNil(t, result[0].GoogleSearch.EnhancedContent)
require.NotNil(t, result[0].GoogleSearch.EnhancedContent.ImageSearch)
require.Equal(t, 5, result[0].GoogleSearch.EnhancedContent.ImageSearch.MaxResultCount)
}
func TestBuildGenerationConfig_ThinkingDynamicBudget(t *testing.T) {
@@ -555,18 +569,22 @@ func TestTransformClaudeToGeminiWithOptions_PreservesWebSearchAlongsideFunctions
},
}
body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "gemini-2.5-flash", DefaultTransformOptions())
body, err := TransformClaudeToGeminiWithOptions(claudeReq, "project-1", "claude-sonnet-4-6", DefaultTransformOptions())
require.NoError(t, err)
var req V1InternalRequest
require.NoError(t, json.Unmarshal(body, &req))
require.Len(t, req.Request.Tools, 2)
// Antigravity v1internal cannot mix googleSearch with functionDeclarations
// (#6464). Prefer client tools so Codex-style sessions keep working.
require.Equal(t, "agent", req.RequestType)
require.Equal(t, "claude-sonnet-4-6", req.Model)
require.Len(t, req.Request.Tools, 1)
require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1)
require.Equal(t, "get_weather", req.Request.Tools[0].FunctionDeclarations[0].Name)
require.NotNil(t, req.Request.Tools[1].GoogleSearch)
require.Nil(t, req.Request.Tools[0].GoogleSearch)
}
func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
func TestGeminiToolConfig_DropsBuiltinsWhenClientFunctionsPresent(t *testing.T) {
functionTool := ClaudeTool{
Name: "get_weather",
Description: "Get weather information",
@@ -577,7 +595,7 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
Name: "web_search",
}
transform := func(t *testing.T, tools []ClaudeTool) (V1InternalRequest, string) {
transform := func(t *testing.T, tools []ClaudeTool, mappedModel string) (V1InternalRequest, string) {
t.Helper()
body, err := TransformClaudeToGeminiWithOptions(&ClaudeRequest{
Model: "claude-3-5-sonnet-latest",
@@ -588,7 +606,7 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
},
},
Tools: tools,
}, "project-1", "gemini-2.5-flash", DefaultTransformOptions())
}, "project-1", mappedModel, DefaultTransformOptions())
require.NoError(t, err)
var req V1InternalRequest
@@ -596,27 +614,33 @@ func TestGeminiToolConfig_IncludeServerSideToolInvocations(t *testing.T) {
return req, string(body)
}
t.Run("mixed builtin and function tools enable server-side tool invocations", func(t *testing.T) {
req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool})
t.Run("mixed builtin and function tools keep only client functions", func(t *testing.T) {
req, raw := transform(t, []ClaudeTool{functionTool, webSearchTool}, "claude-sonnet-4-6")
require.NotNil(t, req.Request.ToolConfig)
require.NotNil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.True(t, *req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.Contains(t, raw, `"includeServerSideToolInvocations":true`)
require.Equal(t, "agent", req.RequestType)
require.Equal(t, "claude-sonnet-4-6", req.Model)
require.Len(t, req.Request.Tools, 1)
require.Len(t, req.Request.Tools[0].FunctionDeclarations, 1)
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.NotContains(t, raw, "googleSearch")
require.NotContains(t, raw, "includeServerSideToolInvocations")
})
t.Run("function tools only leave the flag unset", func(t *testing.T) {
req, raw := transform(t, []ClaudeTool{functionTool})
req, raw := transform(t, []ClaudeTool{functionTool}, "gemini-2.5-flash")
require.NotNil(t, req.Request.ToolConfig)
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.NotContains(t, raw, "includeServerSideToolInvocations")
})
t.Run("web search only leaves the flag unset", func(t *testing.T) {
req, raw := transform(t, []ClaudeTool{webSearchTool})
t.Run("web search only keeps googleSearch and fallback model", func(t *testing.T) {
req, raw := transform(t, []ClaudeTool{webSearchTool}, "claude-sonnet-4-6")
require.NotNil(t, req.Request.ToolConfig)
require.Equal(t, "web_search", req.RequestType)
require.Equal(t, "gemini-2.5-flash", req.Model)
require.Len(t, req.Request.Tools, 1)
require.NotNil(t, req.Request.Tools[0].GoogleSearch)
require.Nil(t, req.Request.ToolConfig.IncludeServerSideToolInvocations)
require.NotContains(t, raw, "includeServerSideToolInvocations")
})
@@ -339,7 +339,63 @@ func responsesInputToChatMessagesWithOptions(instructions string, inputRaw json.
if err != nil {
return nil, err
}
return normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID), nil
normalized := normalizeChatMessagesWithToolOutputMedia(built, mediaByCallID)
return normalizeResponsesDerivedChatMessageRoles(normalized), nil
}
// normalizeResponsesDerivedChatMessageRoles rewrites the Chat Completions
// message list produced by the Responses bridge so that strict upstreams which
// only accept system content at the very start of the conversation accept it.
//
// The bridge turns the Responses `instructions` field and every role:"developer"
// item into a system message. Codex always sends both instructions and a leading
// developer item, and it also injects developer notices into the middle of the
// message history (for example when the user switches models). Forwarded as-is
// that produces two leading system messages and mid-conversation system
// messages, which Qwen-family upstreams reject with
// "System message must be at the beginning." (HTTP 400).
//
// Leading system/developer messages are therefore merged into a single leading
// system message, while later ones keep their position but are downgraded to
// user messages so the same text still reaches the model.
func normalizeResponsesDerivedChatMessageRoles(messages []ChatMessage) []ChatMessage {
isInstructionRole := func(role string) bool {
return role == "system" || role == "developer"
}
leading := 0
for leading < len(messages) && isInstructionRole(messages[leading].Role) {
leading++
}
out := make([]ChatMessage, 0, len(messages))
switch leading {
case 0:
// No leading instructions, nothing to merge.
case 1:
// A single leading prompt is already valid; keep its content byte for
// byte instead of round-tripping it through the text merge below.
out = append(out, messages[0])
default:
merged := make([]string, 0, leading)
for _, m := range messages[:leading] {
if text := strings.TrimSpace(chatMessageContentText(m.Content)); text != "" {
merged = append(merged, text)
}
}
if len(merged) > 0 {
content, _ := json.Marshal(strings.Join(merged, "\n\n"))
out = append(out, ChatMessage{Role: "system", Content: content})
}
}
for _, m := range messages[leading:] {
if isInstructionRole(m.Role) {
m.Role = "user"
}
out = append(out, m)
}
return out
}
// buildChatMessagesFromItems walks the Responses input items and appends the
@@ -523,6 +579,21 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
})
pendingReasoning = ""
continue
case "agent_message":
// Codex multi_agent_v2 用 agent_message 在父线程与子智能体之间传递任务和回复:
// input_text 是信封(消息类型、任务名、发送者),正文放在 encrypted_content 片段里
// (自定义 provider 下为明文)。chat 上游没有对应条目,按原顺序拼成一条 user 消息,
// 否则子智能体收不到任务却仍返回 200。
text := agentMessageText(item["content"])
if text == "" {
pendingReasoning = ""
continue
}
content, _ := json.Marshal(text)
messages = append(messages, ChatMessage{Role: "user", Content: content})
pendingReasoning = ""
lastTurnReasoning = ""
continue
case "input_text", "text":
content, _ := json.Marshal(rawString(item["text"]))
messages = append(messages, ChatMessage{Role: "user", Content: content})
@@ -582,6 +653,32 @@ func buildChatMessagesFromItems(messages []ChatMessage, rawItems []json.RawMessa
return messages, mediaByCallID, nil
}
// agentMessageText 按原顺序拼接 agent_message 里 input_text 与 encrypted_content 片段的文本。
func agentMessageText(raw json.RawMessage) string {
raw = bytesTrimSpace(raw)
if len(raw) == 0 || string(raw) == "null" {
return ""
}
var text string
if err := json.Unmarshal(raw, &text); err == nil {
return text
}
var parts []map[string]json.RawMessage
if err := json.Unmarshal(raw, &parts); err != nil {
return ""
}
var b strings.Builder
for _, part := range parts {
switch rawString(part["type"]) {
case "input_text", "text":
_, _ = b.WriteString(rawString(part["text"]))
case "encrypted_content":
_, _ = b.WriteString(rawString(part["encrypted_content"]))
}
}
return b.String()
}
// extractToolOutputMedia rewrites only recognized image nodes. Media-free
// outputs return rewritten=false so the caller can preserve their original
// bytes and prompt-cache prefix.
@@ -0,0 +1,52 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// #6653:Codex 把运行时工具放在 input[].additional_tools 里,force_chat_completions
// 下必须仍降级成标准 chat 工具随 /v1/chat/completions 发出,而不是因无法转换退回 Responses。
func TestResponsesToChatCompletionsRequest_LowersAdditionalToolsForChatOnlyUpstream(t *testing.T) {
body := `{
"model":"gpt-5.6-luna","stream":true,"store":false,
"reasoning":{"effort":"medium","context":"all_turns"},
"tool_choice":"auto","parallel_tool_calls":false,
"include":["reasoning.encrypted_content"],
"input":[
{"type":"additional_tools","role":"developer","tools":[
{"type":"custom","name":"exec","description":"run a shell command","format":{"type":"grammar","syntax":"lark","definition":"start: /.*/"}},
{"type":"function","name":"wait","strict":false,"parameters":{"type":"object"}},
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","parameters":{"type":"object"}}]}
]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"reply ok"}]}
]
}`
var req ResponsesRequest
require.NoError(t, json.Unmarshal([]byte(body), &req))
tools, err := EffectiveResponsesTools(&req)
require.NoError(t, err)
require.Len(t, tools, 3, "additional_tools 里的 custom / function / namespace 都要被提升")
require.True(t, CustomToolNames(tools)["exec"])
require.Equal(t, "collaboration", NamespaceToolNames(tools)["collaboration__spawn_agent"].Namespace)
chat, err := ResponsesToChatCompletionsRequest(&req)
require.NoError(t, err)
require.Len(t, chat.Messages, 1, "additional_tools 不能变成 chat 消息")
require.Equal(t, "user", chat.Messages[0].Role)
names := make([]string, 0, len(chat.Tools))
for _, tool := range chat.Tools {
require.Equal(t, "function", tool.Type, "chat 上游只认 function 工具")
require.NotNil(t, tool.Function)
names = append(names, tool.Function.Name)
}
require.Equal(t, []string{"exec", "wait", "collaboration__spawn_agent"}, names)
require.JSONEq(t, customToolInputSchema, string(chat.Tools[0].Function.Parameters), "custom 工具降级为单一 input 字符串参数")
require.NotNil(t, chat.ParallelToolCalls)
require.False(t, *chat.ParallelToolCalls)
require.Equal(t, "medium", chat.ReasoningEffort)
}
@@ -0,0 +1,58 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
// #6887:Codex multi_agent_v2 用 agent_message 条目在父线程与子智能体之间传递任务和回复,
// 任务正文放在 encrypted_content 片段里(自定义 provider 下为明文)。chat 上游没有对应条目,
// 桥必须把它按原顺序拼成一条 user 消息,否则子智能体收不到任务。
func TestResponsesToChatCompletionsRequest_AgentMessageBecomesUserMessage(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.3",
Input: json.RawMessage(`[
{"type":"message","role":"user","content":[{"type":"input_text","text":"<environment_context>cwd</environment_context>"}]},
{"type":"agent_message","id":"amsg_1","author":"/root","recipient":"/root/alpha_task","content":[
{"type":"input_text","text":"Message Type: NEW_TASK\nTask name: /root/alpha_task\nSender: /root\nPayload:\n"},
{"type":"encrypted_content","encrypted_content":"Reply with the single word: ALPHA"}
]},
{"type":"reasoning","summary":[{"type":"summary_text","text":"just answer"}]},
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"ALPHA"}]},
{"type":"agent_message","id":"amsg_2","author":"/root/alpha_task","recipient":"/root","content":[
{"type":"input_text","text":"Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/alpha_task\nPayload:\nALPHA"}
]}
]`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Messages, 4)
require.Equal(t, "user", out.Messages[0].Role)
require.Equal(t, "user", out.Messages[1].Role, "父线程发来的任务要成为 user 消息")
require.JSONEq(t, `"Message Type: NEW_TASK\nTask name: /root/alpha_task\nSender: /root\nPayload:\nReply with the single word: ALPHA"`, string(out.Messages[1].Content), "信封与 encrypted_content 里的正文要按原顺序拼接")
require.Equal(t, "assistant", out.Messages[2].Role)
require.JSONEq(t, `"ALPHA"`, string(out.Messages[2].Content))
require.Equal(t, "user", out.Messages[3].Role, "子智能体回给父线程的消息同样是 user 消息")
require.JSONEq(t, `"Message Type: FINAL_ANSWER\nTask name: /root\nSender: /root/alpha_task\nPayload:\nALPHA"`, string(out.Messages[3].Content))
}
func TestResponsesToChatCompletionsRequest_AgentMessageWithoutTextIsSkipped(t *testing.T) {
req := &ResponsesRequest{
Model: "glm-5.3",
Input: json.RawMessage(`[
{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]},
{"type":"agent_message","author":"/root","recipient":"/root/a","content":[]},
{"type":"agent_message","author":"/root","recipient":"/root/a"},
{"type":"agent_message","author":"/root","recipient":"/root/a","content":[{"type":"input_image","image_url":"data:image/png;base64,AA=="}]}
]`),
}
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Messages, 1, "没有文本正文的 agent_message 不产生消息")
require.Equal(t, "user", out.Messages[0].Role)
}
@@ -96,7 +96,7 @@ func TestResponsesInputToChatMessages_EmptyRoleFallsBackToUser(t *testing.T) {
assert.Equal(t, "user", messages[0].Role)
}
func TestResponsesInputToChatMessages_DeveloperRoleTrimAndCaseInsensitive(t *testing.T) {
func TestResponsesInputToChatMessages_LeadingDeveloperRolesMergeIntoOneSystem(t *testing.T) {
input := json.RawMessage(`[
{"role":" Developer ","content":"one"},
{"role":"\tDEVELOPER\n","content":"two"}
@@ -104,9 +104,10 @@ func TestResponsesInputToChatMessages_DeveloperRoleTrimAndCaseInsensitive(t *tes
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 2)
require.Len(t, messages, 1)
assert.Equal(t, []string{"system", "system"}, chatMessageRoles(messages))
assert.Equal(t, []string{"system"}, chatMessageRoles(messages))
assert.JSONEq(t, `"one\n\ntwo"`, string(messages[0].Content))
}
func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t *testing.T) {
@@ -121,12 +122,29 @@ func TestResponsesToChatCompletionsRequest_InstructionsAndInputDeveloperRole(t *
out, err := ResponsesToChatCompletionsRequest(req)
require.NoError(t, err)
require.Len(t, out.Messages, 3)
require.Len(t, out.Messages, 2)
assert.Equal(t, []string{"system", "system", "user"}, chatMessageRoles(out.Messages))
assert.JSONEq(t, `"Use concise answers."`, string(out.Messages[0].Content))
assert.JSONEq(t, `"Prefer JSON."`, string(out.Messages[1].Content))
assert.JSONEq(t, `"Hello"`, string(out.Messages[2].Content))
assert.Equal(t, []string{"system", "user"}, chatMessageRoles(out.Messages))
assert.JSONEq(t, `"Use concise answers.\n\nPrefer JSON."`, string(out.Messages[0].Content))
assert.JSONEq(t, `"Hello"`, string(out.Messages[1].Content))
}
func TestResponsesInputToChatMessages_MidConversationDeveloperBecomesUser(t *testing.T) {
input := json.RawMessage(`[
{"type":"message","role":"user","content":[{"type":"input_text","text":"hello"}]},
{"type":"message","role":"assistant","content":[{"type":"output_text","text":"hi"}]},
{"type":"message","role":"developer","content":[{"type":"input_text","text":"<model_switch> switched model"}]},
{"type":"message","role":"system","content":[{"type":"input_text","text":"be terse"}]},
{"type":"message","role":"user","content":[{"type":"input_text","text":"continue"}]}
]`)
messages, err := responsesInputToChatMessages("", input)
require.NoError(t, err)
require.Len(t, messages, 5)
assert.Equal(t, []string{"user", "assistant", "user", "user", "user"}, chatMessageRoles(messages))
assert.JSONEq(t, `"<model_switch> switched model"`, string(messages[2].Content))
assert.JSONEq(t, `"be terse"`, string(messages[3].Content))
}
func TestResponsesToChatCompletionsRequest_TextFormatJsonObject(t *testing.T) {
@@ -154,7 +154,7 @@ func TestResponsesToolOutputMedia_InterleavedMessagesFollowMediaBatch(t *testing
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
]`)
require.Equal(t, []string{"assistant", "tool", "user", "system", "user"}, chatMessageRoles(messages))
require.Equal(t, []string{"assistant", "tool", "user", "user", "user"}, chatMessageRoles(messages))
require.Equal(t, "[Tool output media for call call_A]", chatContentParts(t, messages[2])[0].Text)
require.JSONEq(t, `"approval saved"`, string(messages[3].Content))
require.JSONEq(t, `"continue"`, string(messages[4].Content))
@@ -170,6 +170,12 @@ func sanitizeAnthropicToolUseInput(name string, raw string) json.RawMessage {
// Streaming: ResponsesStreamEvent → []AnthropicStreamEvent (stateful converter)
// ---------------------------------------------------------------------------
// responsesTextPart identifies one output_text part of a streamed response.
type responsesTextPart struct {
OutputIndex int
ContentIndex int
}
// ResponsesEventToAnthropicState tracks state for converting a sequence of
// Responses SSE events directly into Anthropic SSE events.
type ResponsesEventToAnthropicState struct {
@@ -190,6 +196,13 @@ type ResponsesEventToAnthropicState struct {
// OutputIndexToBlockIdx maps Responses output_index → Anthropic content block index.
OutputIndexToBlockIdx map[int]int
// textByPart records the text already delivered for each output_text part
// so that a done payload can be reconciled against it. It outlives the
// content block; closeCurrentBlock must not reset it.
textByPart map[responsesTextPart]*strings.Builder
// textDelivered records whether any assistant text reached the client.
textDelivered bool
InputTokens int
OutputTokens int
CacheReadInputTokens int
@@ -204,6 +217,7 @@ type ResponsesEventToAnthropicState struct {
func NewResponsesEventToAnthropicState() *ResponsesEventToAnthropicState {
return &ResponsesEventToAnthropicState{
OutputIndexToBlockIdx: make(map[int]int),
textByPart: make(map[responsesTextPart]*strings.Builder),
Created: time.Now().Unix(),
}
}
@@ -222,7 +236,7 @@ func ResponsesEventToAnthropicEvents(
case "response.output_text.delta":
return resToAnthHandleTextDelta(evt, state)
case "response.output_text.done":
return resToAnthHandleBlockDone(state)
return resToAnthHandleTextDone(evt, state)
case "response.function_call_arguments.delta",
// custom/freeform 工具的输入增量与 function_call 参数增量同形。
"response.custom_tool_call_input.delta":
@@ -390,7 +404,18 @@ func resToAnthHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesE
}
func resToAnthHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if evt.Delta == "" {
return resToAnthEmitText(evt.Delta, resToAnthTextPartOf(evt), state)
}
func resToAnthTextPartOf(evt *ResponsesStreamEvent) responsesTextPart {
return responsesTextPart{OutputIndex: evt.OutputIndex, ContentIndex: evt.ContentIndex}
}
// resToAnthEmitText opens a text block when needed, emits text, and records it
// against its part so that a later payload for the same part is reconciled
// against what the client already received.
func resToAnthEmitText(text string, part responsesTextPart, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if text == "" {
return nil
}
@@ -413,18 +438,63 @@ func resToAnthHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventTo
})
}
delivered, ok := state.textByPart[part]
if !ok {
delivered = &strings.Builder{}
state.textByPart[part] = delivered
}
_, _ = delivered.WriteString(text)
state.textDelivered = true
idx := state.ContentBlockIndex
events = append(events, AnthropicStreamEvent{
Type: "content_block_delta",
Index: &idx,
Delta: &AnthropicDelta{
Type: "text_delta",
Text: evt.Delta,
Text: text,
},
})
return events
}
// resToAnthRecoverText emits the tail of a finished text payload that never
// reached the client. Streamed text cannot be recalled, so a payload that does
// not extend what was already delivered is left alone.
func resToAnthRecoverText(text string, part responsesTextPart, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
builder, known := state.textByPart[part]
if !known && state.textDelivered {
// The payload is indexed differently from every delta seen so far, so
// which part it finishes cannot be established. Recovering it could
// repeat an answer the client already has, which is worse than leaving
// a partially delivered one alone.
return nil
}
var delivered string
if known {
delivered = builder.String()
}
if text == delivered || !strings.HasPrefix(text, delivered) {
return nil
}
return resToAnthEmitText(text[len(delivered):], part, state)
}
// resToAnthHandleTextDone recovers text that upstream carried only on the done
// event before closing the block, which some streams use instead of sending
// output_text.delta at all.
func resToAnthHandleTextDone(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if state.MessageStopSent {
// The message is already terminated; a late payload cannot be delivered
// without emitting a content block after message_stop.
return resToAnthHandleBlockDone(state)
}
events := resToAnthRecoverText(evt.Text, resToAnthTextPartOf(evt), state)
return append(events, resToAnthHandleBlockDone(state)...)
}
func resToAnthHandleFuncArgsDelta(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if evt.Delta == "" {
return nil
@@ -622,6 +692,38 @@ func resToAnthHandleWebSearchDone(evt *ResponsesStreamEvent, state *ResponsesEve
return events
}
// resToAnthRecoverTerminalText emits assistant text that only ever appeared in
// the terminal response payload, which some streams populate without sending
// any output_text event.
//
// It only runs when no text at all reached the client. Streamed events and the
// terminal output array carry no guaranteed common identity, so reconciling
// them part by part risks repeating an answer the client already has, which is
// worse than leaving a partially streamed response as it is.
func resToAnthRecoverTerminalText(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if state.textDelivered || evt.Response == nil {
return nil
}
var events []AnthropicStreamEvent
for outputIndex, item := range evt.Response.Output {
if item.Type != "message" {
continue
}
for contentIndex, content := range item.Content {
if content.Type != "output_text" {
continue
}
part := responsesTextPart{OutputIndex: outputIndex, ContentIndex: contentIndex}
events = append(events, resToAnthEmitText(content.Text, part, state)...)
}
}
if len(events) > 0 {
events = append(events, closeCurrentBlock(state)...)
}
return events
}
func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventToAnthropicState) []AnthropicStreamEvent {
if state.MessageStopSent {
return nil
@@ -629,6 +731,7 @@ func resToAnthHandleCompleted(evt *ResponsesStreamEvent, state *ResponsesEventTo
var events []AnthropicStreamEvent
events = append(events, closeCurrentBlock(state)...)
events = append(events, resToAnthRecoverTerminalText(evt, state)...)
stopReason := "end_turn"
if evt.Usage != nil {
@@ -0,0 +1,532 @@
package apicompat
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// feedResponsesEvents converts a whole event sequence with a single state.
func feedResponsesEvents(events ...*ResponsesStreamEvent) []AnthropicStreamEvent {
state := NewResponsesEventToAnthropicState()
var out []AnthropicStreamEvent
for _, evt := range events {
out = append(out, ResponsesEventToAnthropicEvents(evt, state)...)
}
return out
}
// collectAnthropicText accumulates the visible assistant text a Claude client
// reconstructs from a converted event stream.
func collectAnthropicText(events []AnthropicStreamEvent) string {
text := ""
for _, event := range events {
if event.Type == "content_block_delta" && event.Delta != nil && event.Delta.Type == "text_delta" {
text += event.Delta.Text
}
}
return text
}
// requireAnthropicBlockLifecycle checks the block framing a strict client
// relies on: indices start at zero and increase by one, every block is closed,
// and no delta arrives outside an open block.
func requireAnthropicBlockLifecycle(t *testing.T, events []AnthropicStreamEvent) {
t.Helper()
open := false
next := 0
for _, event := range events {
switch event.Type {
case "content_block_start":
require.False(t, open, "content_block_start while a block is open")
require.NotNil(t, event.Index)
require.Equal(t, next, *event.Index, "content block indices must increase by one")
open = true
case "content_block_delta":
require.True(t, open, "content_block_delta outside an open block")
require.NotNil(t, event.Index)
require.Equal(t, next, *event.Index)
case "content_block_stop":
require.True(t, open, "content_block_stop without an open block")
require.NotNil(t, event.Index)
require.Equal(t, next, *event.Index)
open = false
next++
}
}
require.False(t, open, "stream ended with an unclosed content block")
}
func responsesCreated() *ResponsesStreamEvent {
return &ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_text_recovery", Model: "gpt-5.2"},
}
}
func responsesMessageOutput(texts ...string) []ResponsesOutput {
content := make([]ResponsesContentPart, 0, len(texts))
for _, text := range texts {
content = append(content, ResponsesContentPart{Type: "output_text", Text: text})
}
return []ResponsesOutput{{Type: "message", Role: "assistant", Content: content}}
}
// Some streams carry the finished text only on response.output_text.done and
// send no output_text.delta at all. Dropping it leaves a protocol-valid message
// with no text, which clients report as an empty response.
func TestResponsesEventToAnthropicEvents_RecoversTextFromDoneEvent(t *testing.T) {
const answer = "recovered from done"
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_item.added", Item: &ResponsesOutput{Type: "message"}},
&ResponsesStreamEvent{Type: "response.output_text.done", Text: answer},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Equal(t, answer, collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
require.NotEmpty(t, events)
assert.Equal(t, "message_stop", events[len(events)-1].Type)
}
// When nothing streamed at all, the terminal event still carries the finished
// output. Dropping it produces the same empty turn.
func TestResponsesEventToAnthropicEvents_RecoversTextFromTerminalOutput(t *testing.T) {
const answer = "recovered from terminal"
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: responsesMessageOutput(answer),
}},
)
assert.Equal(t, answer, collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
require.NotEmpty(t, events)
assert.Equal(t, "message_stop", events[len(events)-1].Type)
}
// The normal path delivers text through deltas. Neither the done payload nor
// the terminal payload may repeat it.
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateTextDeliveredByDeltas(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hel"},
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "lo"},
&ResponsesStreamEvent{Type: "response.output_text.done", Text: "Hello"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: responsesMessageOutput("Hello"),
}},
)
assert.Equal(t, "Hello", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Text recovered from a done event must not be repeated by the terminal
// payload that also carries it.
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateTextRecoveredFromDoneEvent(t *testing.T) {
const answer = "recovered once"
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.done", Text: answer},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: responsesMessageOutput(answer),
}},
)
assert.Equal(t, answer, collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Deduplication is per output_text part, not per content block: an event that
// closes the block between the deltas and the matching done must not make the
// done payload look undelivered.
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateTextWhenBlockClosedBeforeDone(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"},
&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 1,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "lookup"},
},
&ResponsesStreamEvent{Type: "response.output_text.done", Text: "Hello"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Equal(t, "Hello", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// When only part of the text streamed, the done payload is authoritative and
// the missing suffix must still reach the client.
func TestResponsesEventToAnthropicEvents_AppendsOnlySuffixMissingFromDeltas(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hel"},
&ResponsesStreamEvent{Type: "response.output_text.done", Text: "Hello world"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: responsesMessageOutput("Hello world"),
}},
)
assert.Equal(t, "Hello world", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Streamed text cannot be recalled, so a payload that contradicts what was
// already delivered is left alone rather than appended.
func TestResponsesEventToAnthropicEvents_IgnoresDonePayloadDivergingFromDeltas(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello"},
&ResponsesStreamEvent{Type: "response.output_text.done", Text: "Goodbye"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Equal(t, "Hello", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Streamed events and the terminal output array share no guaranteed identity,
// so once any text has been delivered the terminal payload is left alone. An
// upstream that numbers its streamed items differently from its terminal array
// must not make a delivered answer look undelivered.
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateWhenTerminalOutputIsIndexedDifferently(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "reasoning", ID: "rs_1"},
},
&ResponsesStreamEvent{Type: "response.output_text.delta", OutputIndex: 1, Delta: "Hello"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
// The reasoning item is absent here, so the message sits at array
// position 0 while it streamed at output_index 1.
Output: responsesMessageOutput("Hello"),
}},
)
assert.Equal(t, "Hello", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Partially streamed responses keep exactly what streamed. Recovering the rest
// would mean matching two index spaces that upstreams do not keep aligned.
func TestResponsesEventToAnthropicEvents_LeavesTerminalOutputAloneOnceTextStreamed(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.delta", ContentIndex: 0, Delta: "first"},
&ResponsesStreamEvent{Type: "response.output_text.done", ContentIndex: 0, Text: "first"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: responsesMessageOutput("first", "second"),
}},
)
assert.Equal(t, "first", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// When nothing streamed, every output_text part of the terminal payload is
// recovered, merged into one block the way contiguous streamed text is.
func TestResponsesEventToAnthropicEvents_RecoversEveryTerminalPartWhenNothingStreamed(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: responsesMessageOutput("first", "second"),
}},
)
assert.Equal(t, "firstsecond", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
starts := 0
for _, event := range events {
if event.Type == "content_block_start" {
starts++
}
}
assert.Equal(t, 1, starts, "contiguous recovered text belongs in one block")
}
// A turn that produced only a tool call must stay text-free: the terminal
// payload carries no message item and nothing may be synthesised.
func TestResponsesEventToAnthropicEvents_LeavesToolOnlyTurnTextFree(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "lookup"},
},
&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"id":1}`,
},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: []ResponsesOutput{{Type: "function_call", CallID: "call_1", Name: "lookup", Arguments: `{"id":1}`}},
}},
)
assert.Empty(t, collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Recovery must behave the same on every terminal alias the converter accepts,
// not only on response.completed.
func TestResponsesEventToAnthropicEvents_RecoversTextOnEveryTerminalAlias(t *testing.T) {
for _, terminal := range []string{
"response.completed",
"response.done",
"response.incomplete",
"response.failed",
} {
t.Run(terminal, func(t *testing.T) {
const answer = "recovered on a terminal alias"
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: terminal, Response: &ResponsesResponse{
Status: "completed",
Output: responsesMessageOutput(answer),
}},
)
assert.Equal(t, answer, collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
require.NotEmpty(t, events)
assert.Equal(t, "message_stop", events[len(events)-1].Type)
})
}
}
// A done payload indexed differently from every delta seen so far cannot be
// matched to a part. Emitting it anyway would repeat an answer the client
// already has, which is the failure the terminal guard also exists to prevent.
func TestResponsesEventToAnthropicEvents_DoesNotDuplicateWhenDoneIsIndexedDifferently(t *testing.T) {
for _, tc := range []struct {
name string
done *ResponsesStreamEvent
}{
{"content index diverges", &ResponsesStreamEvent{Type: "response.output_text.done", ContentIndex: 1, Text: "Hello world"}},
{"output index diverges", &ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 1, Text: "Hello world"}},
} {
t.Run(tc.name, func(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.delta", Delta: "Hello world"},
tc.done,
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Equal(t, "Hello world", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
})
}
}
// The same rule holds when only part of the text streamed: an unmatchable
// payload is left alone rather than appended on top of what was delivered.
func TestResponsesEventToAnthropicEvents_LeavesUnmatchableDoneAloneAfterPartialDeltas(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.delta", ContentIndex: 0, Delta: "Hello"},
&ResponsesStreamEvent{Type: "response.output_text.done", ContentIndex: 1, Text: "Hello world"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Equal(t, "Hello", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Delivered text is keyed by output index and content index together. Keying on
// either alone would make one of these parts look already delivered.
func TestResponsesEventToAnthropicEvents_KeysDeliveredTextByBothIndices(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 0, Delta: "first"},
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 0, ContentIndex: 0, Text: "first"},
&ResponsesStreamEvent{Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 1, Delta: "second"},
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 0, ContentIndex: 1, Text: "second-tail"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Equal(t, "firstsecond-tail", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// A stray done event after the terminal one must not resurrect a stopped
// message. The baseline dropped it outright; recovery has to keep that, or a
// client sees a content block after message_stop.
func TestResponsesEventToAnthropicEvents_IgnoresDoneEventAfterMessageStop(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: responsesMessageOutput("the answer"),
}},
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 7, Text: "late text"},
)
assert.Equal(t, "the answer", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
require.NotEmpty(t, events)
assert.Equal(t, "message_stop", events[len(events)-1].Type, "no content may follow message_stop")
}
// The tool path reads arguments, never text. A tool done event carrying a
// stray text field must not produce a text block.
func TestResponsesEventToAnthropicEvents_LeavesToolArgumentsDoneTextFree(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "lookup"},
},
&ResponsesStreamEvent{
Type: "response.function_call_arguments.done",
OutputIndex: 0,
Arguments: `{"id":1}`,
Text: "must not be emitted",
},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Empty(t, collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Recovery closes whatever block is open through the shared helper, which owns
// signature emission. A thinking block must still get its signature_delta
// before its stop.
func TestResponsesEventToAnthropicEvents_PreservesThinkingSignatureWhenRecovering(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "reasoning", EncryptedContent: "sig-abc"},
},
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 1, Text: "recovered"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Equal(t, "recovered", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
var signatures, stopsBefore int
for _, event := range events {
if event.Type == "content_block_delta" && event.Delta != nil && event.Delta.Type == "signature_delta" {
assert.Equal(t, "sig-abc", event.Delta.Signature)
signatures++
assert.Equal(t, 0, stopsBefore, "signature_delta must precede the thinking block stop")
}
if event.Type == "content_block_stop" {
stopsBefore++
}
}
assert.Equal(t, 1, signatures, "the thinking signature must survive recovery")
}
// A stream that ends without any terminal event exits through the synthetic
// finalizer, which must still close a block recovery opened.
func TestResponsesEventToAnthropicEvents_RecoveredTextSurvivesTheSyntheticFinalizer(t *testing.T) {
state := NewResponsesEventToAnthropicState()
var events []AnthropicStreamEvent
for _, evt := range []*ResponsesStreamEvent{
responsesCreated(),
{Type: "response.output_text.done", Text: "recovered before the stream died"},
} {
events = append(events, ResponsesEventToAnthropicEvents(evt, state)...)
}
events = append(events, FinalizeResponsesAnthropicStream(state)...)
assert.Equal(t, "recovered before the stream died", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
require.NotEmpty(t, events)
assert.Equal(t, "message_stop", events[len(events)-1].Type)
}
// The terminal walk only takes output_text parts of message items.
func TestResponsesEventToAnthropicEvents_DeclinesTerminalRecoveryWithoutMessageText(t *testing.T) {
for _, tc := range []struct {
name string
response *ResponsesResponse
}{
{"no response payload", nil},
{"no message item", &ResponsesResponse{Status: "completed", Output: []ResponsesOutput{{Type: "reasoning"}}}},
{"message without output_text", &ResponsesResponse{Status: "completed", Output: []ResponsesOutput{{
Type: "message",
Role: "assistant",
Content: []ResponsesContentPart{{Type: "refusal", Text: "refused"}},
}}}},
{"empty output_text", &ResponsesResponse{Status: "completed", Output: responsesMessageOutput("")}},
} {
t.Run(tc.name, func(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.completed", Response: tc.response},
)
assert.Empty(t, collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
})
}
}
// Every message item of the terminal payload is recovered, and non-message
// items between them are skipped without breaking the merged block.
func TestResponsesEventToAnthropicEvents_RecoversEveryTerminalMessageItem(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{
Status: "completed",
Output: []ResponsesOutput{
{Type: "reasoning"},
{Type: "message", Role: "assistant", Content: []ResponsesContentPart{{Type: "output_text", Text: "first"}}},
{Type: "function_call", CallID: "call_1", Name: "lookup"},
{Type: "message", Role: "assistant", Content: []ResponsesContentPart{{Type: "output_text", Text: "second"}}},
},
}},
)
assert.Equal(t, "firstsecond", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
// Text that upstream delivers only on a done event still reaches the client
// when a tool block is open, which the baseline dropped. The tool block is
// closed first, so later block indices shift by one.
func TestResponsesEventToAnthropicEvents_EmitsDoneTextWhileAToolBlockIsOpen(t *testing.T) {
events := feedResponsesEvents(
responsesCreated(),
&ResponsesStreamEvent{
Type: "response.output_item.added",
OutputIndex: 0,
Item: &ResponsesOutput{Type: "function_call", CallID: "call_1", Name: "lookup"},
},
&ResponsesStreamEvent{Type: "response.output_text.done", OutputIndex: 1, Text: "spoken after the call"},
&ResponsesStreamEvent{Type: "response.completed", Response: &ResponsesResponse{Status: "completed"}},
)
assert.Equal(t, "spoken after the call", collectAnthropicText(events))
requireAnthropicBlockLifecycle(t, events)
}
+8 -6
View File
@@ -20,12 +20,13 @@ 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"
BetaThinkingBindingControls = "thinking-binding-controls-2026-08-01"
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"
BetaMidConversationOutputConfig = "mid-conversation-output-config-2026-07-01"
BetaExtendedCacheTTL = "extended-cache-ttl-2025-04-11"
// server-side refusal fallback beta 字段族(beta Messages API 专有)。
// 客户端(Claude Code / SDK / OpenCode 等)会默认透传 body.fallbacks /
@@ -101,6 +102,7 @@ func FullClaudeCodeMimicryBetas() []string {
BetaEffort,
BetaContextManagement,
BetaThinkingBindingControls,
BetaMidConversationOutputConfig,
BetaExtendedCacheTTL,
}
}
+52 -3
View File
@@ -83,11 +83,10 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) {
}
}
buf := bytes.NewBuffer(make([]byte, 0, capHint))
if _, err := io.Copy(buf, req.Body); err != nil {
raw, err := readRequestBodyChunks(req.Body, capHint, req.ContentLength)
if err != nil {
return nil, err
}
raw := buf.Bytes()
enc := strings.ToLower(strings.TrimSpace(req.Header.Get("Content-Encoding")))
if enc == "" || enc == "identity" {
@@ -106,6 +105,56 @@ func ReadRequestBodyWithPrealloc(req *http.Request) ([]byte, error) {
return decoded, nil
}
// Read bounded chunks as bytes arrive, then assemble the exact-size result.
// This avoids doubling large buffers or eagerly allocating an untrusted
// Content-Length before the corresponding bytes have arrived.
func readRequestBodyChunks(reader io.Reader, initialCapacity int, contentLength int64) ([]byte, error) {
capacity := initialCapacity
var chunks [][]byte
total := 0
for {
chunkCapacity := capacity
if remaining := contentLength - int64(total); remaining >= 0 && remaining < int64(chunkCapacity) {
chunkCapacity = int(remaining) + 1
}
chunk := make([]byte, chunkCapacity)
n := 0
var err error
for n < len(chunk) && err == nil {
var read int
read, err = reader.Read(chunk[n:])
n += read
}
if err != nil && err != io.EOF {
return nil, err
}
if n > 0 {
chunks = append(chunks, chunk[:n])
total += n
}
if err != nil {
if len(chunks) == 0 {
return chunk[:0], nil
}
if len(chunks) == 1 {
return chunks[0], nil
}
body := make([]byte, total)
offset := 0
for _, part := range chunks {
offset += copy(body[offset:], part)
}
return body, nil
}
if capacity < requestBodyReadMaxInitCap {
capacity *= 2
if capacity > requestBodyReadMaxInitCap {
capacity = requestBodyReadMaxInitCap
}
}
}
}
// ReadLenientJSONRequestBodyWithPrealloc reads a request body and normalizes
// JSON string control bytes before strict validation.
func ReadLenientJSONRequestBodyWithPrealloc(req *http.Request, maxNormalizedBytes int64) ([]byte, error) {
@@ -0,0 +1,67 @@
package httputil
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"testing"
"testing/iotest"
)
type bodyReaderOnly struct{ io.Reader }
func TestReadRequestBodyChunksPreservesContent(t *testing.T) {
for _, size := range []int{0, 1, 512, 513, 1 << 20, (2 << 20) + 17} {
body := bytes.Repeat([]byte{'x'}, size)
for _, declared := range []int64{-1, 0, 1, int64(size), 1 << 40} {
t.Run(fmt.Sprintf("size_%d_length_%d", size, declared), func(t *testing.T) {
req := &http.Request{Body: io.NopCloser(bodyReaderOnly{bytes.NewReader(body)}), ContentLength: declared, Header: make(http.Header)}
got, err := ReadRequestBodyWithPrealloc(req)
if err != nil || !bytes.Equal(got, body) {
t.Fatalf("body was lost or changed: %v", err)
}
})
}
}
}
func TestReadRequestBodyChunksPreservesReadErrors(t *testing.T) {
for _, readErr := range []error{io.ErrUnexpectedEOF, errors.New("connection reset")} {
reader := io.MultiReader(bytes.NewReader([]byte("partial")), iotest.ErrReader(readErr))
req := &http.Request{Body: io.NopCloser(reader), ContentLength: 100, Header: make(http.Header)}
got, err := ReadRequestBodyWithPrealloc(req)
if !errors.Is(err, readErr) || got != nil {
t.Fatalf("truncated body accepted: body=%q err=%v", got, err)
}
}
}
func TestReadRequestBodyChunksPreservesMaxBytesReader(t *testing.T) {
body := bytes.Repeat([]byte{'x'}, (2<<20)+1)
req := &http.Request{Body: http.MaxBytesReader(nil, io.NopCloser(bytes.NewReader(body)), 2<<20), ContentLength: int64(len(body)), Header: make(http.Header)}
got, err := ReadRequestBodyWithPrealloc(req)
var limitErr *http.MaxBytesError
if !errors.As(err, &limitErr) || limitErr.Limit != 2<<20 || got != nil {
t.Fatalf("body limit changed: len=%d err=%v", len(got), err)
}
}
func BenchmarkReadLargeBody(b *testing.B) {
for _, size := range []int{512 << 10, 69 << 20} {
b.Run(fmt.Sprintf("bytes_%d", size), func(b *testing.B) {
body := bytes.Repeat([]byte{'x'}, size)
b.ReportAllocs()
b.SetBytes(int64(size))
b.ResetTimer()
for i := 0; i < b.N; i++ {
req := &http.Request{Body: io.NopCloser(bodyReaderOnly{bytes.NewReader(body)}), ContentLength: int64(size), Header: make(http.Header)}
got, err := ReadRequestBodyWithPrealloc(req)
if err != nil || !bytes.Equal(got, body) {
b.Fatalf("body changed: %v", err)
}
}
})
}
}
+2
View File
@@ -33,6 +33,8 @@ var DefaultModels = []Model{
{ID: "gpt-image-1", Object: "model", Created: 1733875200, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1"},
{ID: "gpt-image-1.5", Object: "model", Created: 1735689600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 1.5"},
{ID: "gpt-image-2", Object: "model", Created: 1738368000, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2"},
{ID: "gpt-image-2.5-flare", Object: "model", Created: 1788825600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2.5 Flare"},
{ID: "gpt-image-2.5-sunburst", Object: "model", Created: 1788825600, OwnedBy: "openai", Type: "model", DisplayName: "GPT Image 2.5 Sunburst"},
}
// DefaultModelIDs returns the default model ID list
@@ -27,3 +27,8 @@ func TestDefaultModelsPreferConcreteGPT56SolForAccountTests(t *testing.T) {
require.NotEmpty(t, DefaultModels)
require.Equal(t, "gpt-5.6-sol", DefaultModels[0].ID)
}
func TestDefaultModelsIncludeGPTImage25(t *testing.T) {
require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-flare")
require.Contains(t, DefaultModelIDs(), "gpt-image-2.5-sunburst")
}
+15
View File
@@ -3,6 +3,8 @@ package openai
import (
"regexp"
"strings"
"golang.org/x/net/http/httpguts"
)
// CodexCLIUserAgentPrefixes matches Codex CLI User-Agent patterns
@@ -198,6 +200,10 @@ func matchCodexClientHeaderStrictPrefixes(value string, prefixes []string) bool
// UA 首段后配对,保留真实版本/OS/终端指纹;
// 3. 均不命中 → ok=false,调用方应整体回退为默认官方身份。
func PairCodexClientIdentity(userAgent string) (originator string, pairedUA string, ok bool) {
// Validate before trimming so control bytes cannot become a valid identity.
if !validCodexUserAgentValue(userAgent) {
return "", "", false
}
ua := strings.TrimSpace(userAgent)
slash := strings.IndexByte(ua, '/')
if slash <= 0 {
@@ -217,6 +223,15 @@ func PairCodexClientIdentity(userAgent string) (originator string, pairedUA stri
return "", "", false
}
func validCodexUserAgentValue(value string) bool {
if !httpguts.ValidHeaderFieldValue(value) {
return false
}
// httpguts follows the legacy field-value grammar and permits obs-fold.
// User-Agent is not an obs-folded header; reject CR/LF before forwarding.
return !strings.ContainsAny(value, "\r\n")
}
// codexOriginatorMaxLen 官方 clientInfo.name 均为短 ASCII 标识,远低于此上限。
const codexOriginatorMaxLen = 64
@@ -0,0 +1,37 @@
package openai
import (
"testing"
"github.com/stretchr/testify/require"
)
func TestPairCodexClientIdentityRejectsInvalidHeaderBytes(t *testing.T) {
for name, ua := range map[string]string{
"crlf_suffix": "codex-tui/0.146.0 (Linux)\r\nX-Injected: value",
"nul_suffix": "codex-tui/0.146.0 (Linux\x00)",
"del_suffix": "codex-tui/0.146.0 terminal\x7f",
"newline_prefix": "\ncodex-tui/0.146.0",
"newline_suffix": "codex-tui/0.146.0\n",
"control_in_trailer": "custom/0.146.0 bad\x01 (codex-tui; 0.146.0)",
} {
t.Run(name, func(t *testing.T) {
originator, paired, ok := PairCodexClientIdentity(ua)
require.False(t, ok)
require.Empty(t, originator)
require.Empty(t, paired)
})
}
}
func TestPairCodexClientIdentityPreservesValidSuffix(t *testing.T) {
for _, ua := range []string{
"codex-tui/0.146.0 (Mac OS X 14.0; arm64) iTerm",
"codex-tui/0.146.0 (Windows NT 10.0; x86_64) terminal",
"codex-tui/0.146.0 (Linux)\tterminal",
} {
_, paired, ok := PairCodexClientIdentity(ua)
require.True(t, ok)
require.Equal(t, ua, paired)
}
}
@@ -2257,6 +2257,61 @@ func (r *accountRepository) ClearRateLimitIfObserved(ctx context.Context, id int
return true, nil
}
// SetRateLimitedIfUnchanged atomically applies a rate-limit reset only while the
// account still carries exactly the generation the caller observed: its
// UpdatedAt row version, its RateLimitedAt and its RateLimitResetAt (nil means
// that field is currently unset). It is the write-back CAS counterpart to
// ClearRateLimitIfObserved: an async rate-limit reset (e.g. an Ollama Cloud
// usage probe) must not overwrite a newer 429, an admin clear, a re-armed
// generation, or a key/state change observed by another writer between the
// caller's read and this write. The whole update is a single statement, so the
// write itself is race-free. updated reports whether the write happened, and the
// caller must ONLY send its scheduling notification when updated == true (this
// method already performed the DB update; no further SetRateLimited call is
// allowed, as a second unconditional write would reintroduce the race). No new
// migration is required.
func (r *accountRepository) SetRateLimitedIfUnchanged(
ctx context.Context,
id int64,
expectedUpdatedAt time.Time,
expectedLimitedAt, expectedResetAt *time.Time,
newResetAt time.Time,
) (bool, error) {
preds := []dbpredicate.Account{dbaccount.IDEQ(id), dbaccount.UpdatedAtEQ(expectedUpdatedAt)}
if expectedLimitedAt == nil {
preds = append(preds, dbaccount.RateLimitedAtIsNil())
} else {
preds = append(preds, dbaccount.RateLimitedAtEQ(*expectedLimitedAt))
}
if expectedResetAt == nil {
preds = append(preds, dbaccount.RateLimitResetAtIsNil())
} else {
preds = append(preds, dbaccount.RateLimitResetAtEQ(*expectedResetAt))
}
now := time.Now()
updated, err := r.client.Account.Update().
Where(preds...).
SetRateLimitedAt(now).
SetRateLimitResetAt(newResetAt).
Save(ctx)
if err != nil {
return false, err
}
if updated == 0 {
// The generation changed concurrently (cleared, re-armed, or the account
// was otherwise updated elsewhere): do not announce anything, just
// refresh the local scheduler snapshot.
r.syncSchedulerAccountSnapshot(ctx, id)
return false, nil
}
if err := enqueueSchedulerOutbox(ctx, r.sql, service.SchedulerOutboxEventAccountChanged, &id, nil, nil); err != nil {
logger.LegacyPrintf("repository.account", "[SchedulerOutbox] enqueue rate limit failed: account=%d err=%v", id, err)
}
r.syncSchedulerAccountSnapshot(ctx, id)
return true, nil
}
func (r *accountRepository) SetModelRateLimit(ctx context.Context, id int64, scope string, resetAt time.Time, reason ...string) error {
if scope == "" {
return nil
@@ -1540,6 +1540,41 @@ func (s *AccountRepoSuite) TestUpdateExtra_SchedulerNeutralSkipsOutboxAndSyncsFr
s.Require().Equal("2026-03-11T10:00:00Z", cacheRecorder.accounts[account.ID].Extra["codex_usage_updated_at"])
}
// Exercise the complete UpdateExtra -> PostgreSQL -> Redis metadata -> admission
// path. A recorder-only cache would miss fields discarded by the slim projection.
func (s *AccountRepoSuite) TestUpdateExtra_AnthropicThresholdRefreshesCandidateSnapshot() {
now := time.Now().UTC().Truncate(time.Second)
end := now.Add(time.Hour)
account := mustCreateAccount(s.T(), s.client, &service.Account{
Name: "threshold-refresh", Platform: service.PlatformAnthropic, Type: service.AccountTypeOAuth,
Credentials: map[string]any{"account_scheduling_threshold": 60},
Extra: map[string]any{"passive_usage_7d_utilization": .59, "passive_usage_7d_reset": end.Unix()},
})
cache := NewSchedulerCache(testRedis(s.T()))
s.repo.schedulerCache = cache
bucket := service.SchedulerBucket{GroupID: account.ID, Platform: service.PlatformAnthropic, Mode: service.SchedulerModeSingle}
token, err := cache.CaptureBucketWriteToken(s.ctx, bucket)
s.Require().NoError(err)
s.Require().NoError(cache.SetSnapshot(s.ctx, bucket, token, []service.Account{*account}))
for _, step := range []struct {
used float64
reset time.Time
paused bool
}{
{.59, end, false}, {.66, end, true}, {.66, now.Add(-time.Hour), false}, {.10, end, false},
} {
s.Require().NoError(s.repo.UpdateExtra(s.ctx, account.ID, map[string]any{
"passive_usage_7d_utilization": step.used, "passive_usage_7d_reset": step.reset.Unix(),
}))
candidates, hit, err := cache.GetSnapshot(s.ctx, bucket)
s.Require().NoError(err)
s.Require().True(hit)
s.Require().Len(candidates, 1)
decision := service.EvaluateAccountSchedulingThreshold(candidates[0], map[string]int{service.PlatformAnthropic: 100}, now)
s.Require().Equal(step.paused, decision.ShouldPause)
}
}
func (s *AccountRepoSuite) TestUpdateExtra_ExhaustedCodexSnapshotSyncsSchedulerCache() {
account := mustCreateAccount(s.T(), s.client, &service.Account{
Name: "acc-extra-codex-exhausted",
@@ -18,7 +18,7 @@ const (
// ollamaCloudUsagePlatformsSQL 是 service.isOllamaCloudUsagePlatform 的 SQL
// 镜像:Ollama Cloud key 允许挂在 openai/anthropic 与国产 OpenAI 兼容平台
// 下复用。所有平台白名单 SQL 只允许引用本常量,不得各处重写字面量,防止漂移。
ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek'"
ollamaCloudUsagePlatformsSQL = "'openai', 'anthropic', 'kimi', 'zhipu', 'deepseek', 'minimax'"
ollamaCloudUsageEligibleSQL = `
platform IN (` + ollamaCloudUsagePlatformsSQL + `)
AND type = 'apikey'

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