Compare commits

...
Author SHA1 Message Date
shaw 29eead917f feat(pluginapi): HostService 账号目录返回结构化只读元数据
纯宿主侧改动,向后兼容。

- 契约(pkg/pluginapi/v1/plugin.proto,已重新生成):ListAccounts 增补
  repeated AccountInfo accounts(保留 account_ids 向后兼容),新增 metadata_json
  只读快照字段。HostServiceAPIVersion 1→2(InitHostServices 运行时协商,加法式,
  旧调用方忽略新字段即可)。
- 权限边界:可见范围由清单能力经 pluginCapabilityAccountScopeGrants 推导,
  ListAccounts 与 ResolveOutboundIdentity 均以该范围为权威边界。
- metadata_json 采用 denylist:剥离 Credentials 与含反向引用的关联对象
  Groups/AccountGroups(避免 encoding/json 循环引用),其余字段自动透传,
  数据模型新增非机密字段无需改宿主。

测试:范围/元数据/无凭据泄漏用例、denylist 反射护栏 + 循环引用安全断言、纵深过滤用例;
build/vet/gofmt、-tags unit 与 -race 全绿。
2026-09-20 11:26:19 +08:00
github-actions[bot] 1a9d49e16f chore: sync VERSION to 0.2.7 [skip ci] 2026-09-19 04:29:03 +00:00
Wesley Liddick aea725f2ea Merge pull request #7247 from Qiaogun/codex/seedance-native-api
feat: 支持 Seedance / Ark 原生视频任务 API
2026-09-19 12:11:17 +08:00
Wesley Liddick 74431ffad8 Merge pull request #7258 from HuntercodeT/fix/antigravity-gemini-bare-model-thinking-variant
fix(antigravity): Gemini 原生请求的裸模型名按 thinkingConfig 解析到 -low/-medium/-high 变体
2026-09-19 12:01:22 +08:00
Wesley Liddick 0c9e83b59c Merge pull request #7278 from HuntercodeT/fix/antigravity-gemini-sse-comment-keepalive
fix(antigravity): Gemini 原生流对 go-genai / python-genai 客户端不发 SSE 注释心跳
2026-09-19 12:00:18 +08:00
Wesley Liddick 09f88865e0 Merge pull request #7294 from feeeei/feat/model-plaza-icon
ui(header): 手机竖屏顶栏保留模型广场图标入口
2026-09-19 11:59:46 +08:00
Wesley Liddick 1b45d21328 Merge pull request #7340 from ye4241/fix/kimi-cn-quota-403-rate-limit-pause
fix(ratelimit): CN coding-plan 账号配额耗尽的 403 按限流暂停调度,而非永久禁用
2026-09-19 11:59:21 +08:00
Wesley Liddick d5cea617a5 Merge pull request #7345 from laoxiechuzheng/fix/anthropic-root-union-schema
fix(apicompat): 摊平 Responses→Anthropic 工具 schema 顶层联合,修复 Codex 内置工具导致的 400
2026-09-19 11:59:00 +08:00
Wesley Liddick 4275047d16 Merge pull request #7313 from baryon/contrib/deepseek-chat-reasoning-content
fix(openai): pass DeepSeek thinking-mode reasoning_content on chat fallback
2026-09-19 11:57:55 +08:00
Wesley Liddick 4dbcdce43c Merge pull request #7351 from Wei-Shaw/feat/plugin-host-services-status-bridge
feat(plugin): generic host services + read-only status bridge channel
2026-09-19 11:57:41 +08:00
shaw 6477143533 test(plugin): check type assertions to satisfy errcheck lint 2026-09-19 11:47:14 +08:00
shaw c63bd14a02 feat(plugin): generic host services + read-only status bridge channel
Extend the local plugin system's host<->plugin contract with generic,
capability-neutral facilities, so transport plugins can be built on top
without embedding plugin-specific logic in the host.

Host services (plugin dials a broker-provided HostService gRPC):
- per-plugin namespaced KV store (key/value size and TTL bounded)
- account directory: ListAccounts + ResolveOutboundIdentity, restricted to
  the accounts a plugin's declared capability already covers
- InitHostServices lifecycle hook; plugins that don't implement it run
  unchanged and are simply offered no host services

Read-only runtime status channel:
- HealthResponse gains an opaque, plugin-defined status_json produced
  passively (no config apply, no upstream call, no side effects)
- GET /admin/plugins/:id/status (ungated) and the plugin.status UI-bridge
  verb map to Health, so a status UI can poll live state without a step-up
  prompt or a "test" side effect
- config.test no longer force-shows a host success toast; the plugin UI owns
  result presentation, so plugins that read status do not spam toasts

Document the UI-bridge verb taxonomy (read vs mutate/test gating) and the
status contract under pkg/pluginapi.
2026-09-19 11:25:56 +08:00
laoxiechuzheng a8a1a3a958 fix(apicompat): flatten root union tool schemas for Anthropic
Codex 会把 codex_app 等内置工具的 parameters 根节点声明成对象分支的 oneOf/anyOf,Anthropic 拒绝工具 input_schema 顶层的 oneOf/anyOf/allOf,导致 /v1/responses -> /v1/messages 直接 400。

Responses->Anthropic 转换时摊平顶层联合:属性取各分支并集,同名属性用嵌套联合保留两侧约束,required 在 oneOf/anyOf 下取分支交集、allOf 下取并集;嵌套联合保持不变。
2026-09-19 07:34:21 +08:00
ye4241andClaude Opus 4.8 db8692d679 fix(ratelimit): pause CN coding-plan accounts on quota-exhausted 403
Kimi reports Coding Plan window exhaustion (5h/weekly) as a 403 with
error.type=access_terminated_error instead of 429. It fell into the
generic handleOpenAI403 escalation, so three 403s within 180 minutes
permanently disabled accounts that recover by themselves at the window
reset.

Detect the quota-exhausted 403 for CN coding-plan accounts and cool it
down exactly like the 429 path: SetRateLimited until the earliest future
window reset from the quota snapshot, falling back to a short
temp-unschedulable window when the snapshot is missing. Non-coding-plan
accounts keep the generic 403 escalation since the rolling-window
recovery mechanism does not apply to them.

Also extract cooldownCNProviderToQuotaSnapshotReset and
setCNProviderTempUnschedulable so the 429 (OpenCodeGo / Coding Plan)
and 403 branches stop carrying three copies of the same sequence.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-19 00:00:57 +08:00
Long LiandCursor bcc73f8d40 fix(openai): pass DeepSeek thinking-mode reasoning_content on chat fallback
Codex histories often replay encrypted-only reasoning items. After a
Responses→Chat Completions fallback, a cache miss left assistant
messages without reasoning_content and DeepSeek rejected the turn
with 400. Fill empty assistant fields with a single-space placeholder
for DeepSeek hosts, including openai-platform accounts whose base_url
points at api.deepseek.com. Existing plaintext and cache hits are
left unchanged.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-18 15:42:22 +09:00
feeeei a9c7c6e8bf ui(header): 手机竖屏顶栏保留模型广场图标入口
模型广场入口所有宽度都渲染,<640px 只保留图标,补 title/aria-label
2026-09-18 09:52:40 +08:00
HuntercodeT 0f4d8acaa8 fix(antigravity): Gemini 原生请求的裸模型名按 thinkingConfig 解析到 -low/-medium/-high 变体
Antigravity CLI / go-genai 发裸 Gemini 模型名(gemini-3.8-flash)并用
generationConfig.thinkingConfig 表达思考深度,而 Antigravity 上游目录只有
-low/-medium/-high/-tiered 变体。v0.2.5 起 resolveModelMapping 会给 antigravity
账号运行时补 gemini-3.x-flash 裸名自映射,裸名因此被当成显式透传直发上游,
上游回 404 "Requested entity was not found."。

在 ForwardGemini 取映射之前加 resolveGeminiThinkingVariant:
- 仅对 gemini- 前缀且不带后缀的裸名生效;
- 用户在 credentials.model_mapping 亲写的裸名条目一律不介入,运行时注入的
  默认自映射不算用户意图;
- thinkingLevel 优先;否则 thinkingBudget <0→high,≤1024→low,≤8192→medium,
  其余 high;未携带 thinkingConfig→high;所选变体不存在时按
  high→medium→low→tiered 降级;一个变体都没有则回退原逻辑;
- thinkingConfig 仍原样透传,上游按 budget 决定实际思考量。
2026-09-18 09:03:40 +08:00
HuntercodeT f79b8bf96c fix(antigravity): Gemini 原生流对 go-genai / python-genai 客户端不发 SSE 注释心跳
Antigravity 账号的 Gemini 原生流式路径在上游空闲超过 gateway.stream_keepalive_interval
(默认 10s)时会向下游写 ":\n\n" 作为心跳。SSE 规范允许注释行,但 Google 官方 SDK 里
go-genai 与 python-genai 都不会忽略它:

- go-genai(Antigravity CLI 在用)api_client.go iterateResponseStream 对任何非 data: 前缀的
  事件直接返回 "iterateResponseStream: invalid stream chunk: :",整个流中断;
- python-genai _api_client.py 把非 data: 行当作错误 JSON 拼接后 json.loads,同样抛错。

Google 官方 API 从不下发注释行,这两个 SDK 从未处理过这种输入。js-genai 用规范的
event-stream 解析器,不受影响。

现在按下游 User-Agent / X-Goog-Api-Client 识别这两类 SDK(形如
google-genai-sdk/1.71.0 gl-go/go1.28 …、google-genai-sdk/x gl-python/3.12),命中时该路径
不再发注释心跳;其他客户端(curl、js-genai、Gemini CLI)行为不变。

生产复现:Antigravity CLI 1.2.5 经网关(sub2api 0.2.5)跑长思考请求,上游首个 chunk 超过 10s
时客户端报 "agent executor error: … iterateResponseStream: invalid stream chunk: :"。
2026-09-17 19:41:55 +08:00
Wesley Liddick efe9aab1e4 Merge pull request #7246 from laoxiechuzheng/fix/deepseek-native-responses-tool-output-media
fix(deepseek): lift Responses tool output images
2026-09-17 17:02:43 +08:00
Cai d099c06945 fix: resolve Seedance lint and gRPC security checks 2026-09-17 17:51:03 +09:00
Wesley Liddick aeab57b9b2 Merge pull request #7256 from zhudaguaneren/fix/antigravity-attribution-429
fix(antigravity): 过滤 Claude attribution 元数据,修复特定请求的 429
2026-09-17 16:44:09 +08:00
Wesley Liddick 18328aed05 Merge pull request #7212 from wucm667/feat/issue-7203-redemption-pagination
feat(redeem): paginate user redemption history
2026-09-17 16:43:06 +08:00
Wesley Liddick 48a86ec926 Merge pull request #7224 from HypoxanthineOvO/codex/upstream-chat-role-compat
fix(gateway): normalize developer roles for strict native Chat upstreams
2026-09-17 16:42:31 +08:00
Wesley Liddick b4e560824a Merge pull request #7177 from xO-ox-ai/codex/fix-openai-response-binding-context
fix(openai): 客户端断开后仍持久化 Responses 续聊绑定
2026-09-17 16:42:12 +08:00
Wesley Liddick 9efc167482 Merge pull request #7195 from bitnom/fix/refresh-paused-oauth-accounts
fix(account): keep refreshing OAuth tokens for paused accounts
2026-09-17 16:41:54 +08:00
Wesley Liddick 5050471822 Merge pull request #7239 from Liu8Can/fix/register-promo-code-flicker
fix: prevent promo code field flash on registration
2026-09-17 16:41:35 +08:00
Wesley Liddick c84a3d50a3 Merge pull request #7215 from xz-dev/fix/manifest-double-parse
fix(openai): parse Codex models manifest envelope once
2026-09-17 16:41:12 +08:00
Wesley Liddick cd1834e0a9 Merge pull request #7262 from ouchihao/codex/bug-refund-balance-warning
fix(payment): base refund balance warnings on the requested amount
2026-09-17 16:41:00 +08:00
Wesley Liddick 0932e8ce0a Merge pull request #7266 from ouchihao/codex/bug-subscription-loading-reset
fix(subscriptions): reset loading when clearing state
2026-09-17 16:40:44 +08:00
Wesley Liddick ffa43607e5 Merge pull request #7236 from ouchihao/codex/bug-payment-amount-input
fix(payment): restore accepted text after invalid amount input
2026-09-17 16:40:33 +08:00
Wesley Liddick 57e9f6a4b8 Merge pull request #7238 from ouchihao/codex/bug-model-tag-tab-navigation
fix(channels): allow tabbing out of an empty model input
2026-09-17 16:40:23 +08:00
Wesley Liddick 71c0eb5126 Merge pull request #7184 from ouchihao/codex/bug-totp-error-messages
fix(totp): display normalized API error messages
2026-09-17 16:40:07 +08:00
Wesley Liddick 10fd1ada28 Merge pull request #7182 from ouchihao/codex/bug-orders-filter-page
fix(payment): reset the order page when changing status
2026-09-17 16:39:53 +08:00
Wesley Liddick 53fd5f8351 Merge pull request #7183 from ouchihao/codex/bug-proxy-test-dedup
fix(proxies): reuse in-flight test guards during batch testing
2026-09-17 16:39:34 +08:00
Wesley Liddick 55bde0857f Merge pull request #7185 from ouchihao/codex/bug-announcement-read-results
fix(announcements): preserve successful bulk read results
2026-09-17 16:39:16 +08:00
Wesley Liddick 5f75e0d2a7 Merge pull request #7186 from ouchihao/codex/bug-clipboard-fallback-error
fix(clipboard): report errors thrown by the copy fallback
2026-09-17 16:38:55 +08:00
Wesley Liddick efd86c63e6 Merge pull request #7235 from ouchihao/codex/bug-totp-setup-input-sync
fix(auth): keep TOTP setup digits in sync with state
2026-09-17 16:38:37 +08:00
Wesley Liddick ea68c0e82c Merge pull request #7263 from ouchihao/codex/bug-dialog-title-ids
fix(ui): keep dialog title IDs unique across instances
2026-09-17 16:38:16 +08:00
Wesley Liddick 40e7ce7fbf Merge pull request #7265 from ouchihao/codex/bug-payment-config-await
fix(payment): await in-flight configuration requests
2026-09-17 16:37:54 +08:00
Wesley Liddick d52ab27def Merge pull request #7173 from HuntercodeT/fix/gemini-native-mixed-model-listing
fix(gemini): discover Antigravity mappings in native model lists
2026-09-17 16:37:38 +08:00
Wesley Liddick 7e4164c1cf Merge pull request #7207 from HuntercodeT/fix/group-usage-summary-seq-scan
fix: 分组用量汇总不再全表扫 usage_logs(48.7s → 34ms)
2026-09-17 16:37:21 +08:00
Wesley Liddick 44831bb854 Merge pull request #7261 from HuntercodeT/chore/bump-grpc-1.83.2
chore(deps): 升级 google.golang.org/grpc 到 v1.83.2,修复 govulncheck 报告的 GO-2026-6443 / GO-2026-6348
2026-09-17 16:36:57 +08:00
Wesley Liddick 131893e449 Merge pull request #7237 from ouchihao/codex/bug-platform-quota-negative
fix(admin): reject negative platform quota limits before saving
2026-09-17 16:36:34 +08:00
Wesley Liddick 4daabc3d84 Merge pull request #7234 from ouchihao/codex/bug-pagination-jump-number
fix(pagination): handle numeric jump input
2026-09-17 16:36:23 +08:00
laoxiechuzheng acc05620cb fix(deepseek): keep parallel Responses tool outputs contiguous 2026-09-17 15:45:56 +08:00
chihao.ou 406be7c518 fix(subscriptions): reset loading when clearing state 2026-09-17 14:59:43 +08:00
chihao.ou b51f0759d4 fix(payment): await in-flight configuration requests 2026-09-17 14:58:46 +08:00
chihao.ou be313735dc fix(ui): keep dialog title IDs unique across instances 2026-09-17 14:56:58 +08:00
chihao.ou 017e9e98d1 fix(payment): base refund balance warnings on the requested amount 2026-09-17 14:56:33 +08:00
HuntercodeT 611c30f04f chore(deps): 升级 google.golang.org/grpc 到 v1.83.2 修复 govulncheck 报告的两条公告
Security Scan / backend-security 自 2026-09-16 起在所有 PR 上失败,govulncheck 对
google.golang.org/grpc v1.82.1 报:
- GO-2026-6443 Server panic via missing authority or Host headers(修复于 v1.83.2)
- GO-2026-6348 Heap Memory Exhaustion via HTTP/2 DATA Frame Fragmentation(修复于 v1.83.1)
调用链在 pkg/pluginapi/v1/runtime.go 与 internal/service/plugin_runtime.go。

go get google.golang.org/grpc@v1.83.2 && go mod tidy。最小版本选择连带抬升了
golang.org/x/{crypto,mod,net,sync,term,sys,text,tools}、genproto/googleapis/rpc 与
go.opentelemetry.io/otel* 的间接依赖,均为补丁级别。
2026-09-17 14:33:48 +08:00
zhudaguaneren be4a4990ff fix(antigravity): strip Claude attribution metadata from system prompts 2026-09-17 11:57:53 +08:00
Cai aba34524f1 feat: support native Seedance Ark video task API 2026-09-17 04:14:14 +09:00
laoxiechuzheng 881ab1b0c6 fix(deepseek): lift Responses tool output images
DeepSeek native Responses rejects Codex view_image results when function_call_output.output contains input_image parts. Move media into a following user message and keep the tool output as a string so call_id pairing remains valid.
2026-09-17 01:22:22 +08:00
DanielSong fe36f4a914 fix: prevent promo code field flash on registration 2026-09-16 18:34:52 +08:00
chihao.ou f8e5a0d94f fix(channels): allow tabbing out of an empty model input 2026-09-16 18:19:41 +08:00
chihao.ou 0838e0e610 fix(admin): reject negative platform quota limits before saving 2026-09-16 18:17:29 +08:00
chihao.ou 98321a054e fix(payment): restore accepted text after invalid amount input 2026-09-16 18:17:14 +08:00
chihao.ou ee9ac3e45f fix(auth): keep TOTP setup digits in sync with state 2026-09-16 18:15:28 +08:00
chihao.ou 1a32b91eb2 fix(pagination): handle numeric jump input 2026-09-16 18:15:12 +08:00
HypoxanthineOvO 18bfa4bf20 fix(gateway): normalize developer roles for strict Chat upstreams 2026-09-16 16:11:43 +08:00
Xiangzhe 31f3003ff5 fix(openai): preserve manifest key validation 2026-09-16 06:15:31 +00:00
Xiangzhe 2f16e0984e fix(openai): avoid duplicate manifest parsing 2026-09-16 06:15:25 +00:00
wucm667 a9ed218989 feat(redeem): paginate user redemption history 2026-09-16 13:19:15 +08:00
HuntercodeT 18d483c2a1 fix: 分组用量汇总不再全表扫 usage_logs
GET /api/v1/admin/groups/usage-summary 在有一定数据量的库上会退化成
usage_logs 全表扫。根因是 getAllGroupUsageSummaryFromRollups 里,tail CTE
的 created_at 下界 state.tail_start 由同一条 SQL 的 state CTE 算出、再用
CROSS JOIN 交给 tail:这个值对 planner 来说要到运行期才知道,既进不了
index cond,也估不准选择性,于是只能顺序扫全表再逐行过滤。

生产实测(1721 万行 usage_logs / PostgreSQL 17):

    Nested Loop  (actual time=28161..48334 rows=34788)
      Join Filter: (ul.created_at >= state_1.tail_start)
      Rows Removed by Join Filter: 17178082
      ->  Seq Scan on usage_logs  (actual time=0.030..47567 rows=17212870)
          Buffers: shared hit=12863 read=951875
    Execution Time: 48720 ms

改成先读一次水位行、在 Go 侧算出 tail_start / closed_before / retained
日期,再把它们当查询参数传进来。同一套数据上:

    Index Scan using idx_usage_logs_created_at on usage_logs
      Index Cond: (created_at >= $7)
    Execution Time: 34 ms

水位有效性的判定(恰好一行、时区名与配置一致、水位不在未来)和无效时
退化成从 epoch 全量重算的行为,与原先 SQL 里 state_values / CASE WHEN
valid 的分支逐条对应,结果口径不变。

另外读取水位的语句不加锁,不参与 #6976 那条 FOR UPDATE / FOR KEY SHARE
的阻塞链。
2026-09-16 10:11:18 +08:00
bitnom 8e34ca5e31 fix(account): keep refreshing OAuth tokens for paused accounts
Paused (schedulable=false) active accounts were excluded from the
background token refresh candidates, so their stored access_token
silently expired. The admin usage-window quota probe uses the stored
token verbatim (no token provider / no refresh), so the probe then gets
HTTP 401 UNAUTHENTICATED and the dashboard reports a false "needs
re-auth" for a perfectly healthy account, while Test Connection and live
traffic (which refresh on demand via the token provider) keep working.

Pause is not permanent rejection: error accounts are already excluded by
the status = 'active' filter, and accounts whose refresh genuinely fails
are rate-limited by the retry-cooldown exclusion (10-minute retry-
exhausted cooldown). Refresh success only persists credentials and never
touches schedulable, so a paused account cannot be silently un-paused.

Drop the schedulable filter and update the SQL-filter test, the
integration pagination test, and the service-level candidate fake.
2026-09-15 10:00:26 -04:00
chihao.ou 130ba634ad fix(clipboard): report errors thrown by the copy fallback 2026-09-15 20:08:45 +08:00
chihao.ou 6a4938bdfb fix(announcements): preserve successful bulk read results 2026-09-15 20:06:33 +08:00
chihao.ou 14636d2f0e fix(totp): display normalized API error messages 2026-09-15 20:06:18 +08:00
chihao.ou 0ed735d3ba fix(proxies): reuse in-flight test guards during batch testing 2026-09-15 20:04:49 +08:00
chihao.ou 21532add46 fix(payment): reset the order page when changing status 2026-09-15 20:04:35 +08:00
xO-ox-ai d7ee1ab6be fix(openai): persist response affinity after client cancel 2026-09-15 18:44:50 +08:00
github-actions[bot] 881f320269 chore: sync VERSION to 0.2.5 [skip ci] 2026-09-15 09:02:51 +00:00
HuntercodeT da74bf13a4 fix(gemini): discover antigravity mappings in native model lists 2026-09-15 16:50:46 +08:00
Wesley Liddick 86f93c28ee Merge pull request #6977 from alfadb/fix/deepseek-model-name-validation
fix(deepseek): 未配置 model_mapping 时按官方模型名单校验,不再透传未知模型名
2026-09-15 16:20:31 +08:00
Wesley Liddick 30ed40a56a Merge pull request #7074 from Brisbanehuang/codex/fix-codex-quota-windows
fix(scheduler): 按规范 5h/7d 窗口读取 Codex 配额与重置时间
2026-09-15 16:17:37 +08:00
Wesley Liddick b8275209e8 Merge pull request #7162 from HuntercodeT/fix/antigravity-gemini-sse-separator
fix(antigravity): gemini 透传 SSE 事件之间不再多写一个空行(Antigravity CLI invalid stream chunk)
2026-09-15 16:17:25 +08:00
Wesley Liddick 25c21aedda Merge pull request #7129 from kk-418/fix/grok-build-responses-sequence-number
fix(responses): 始终在 Responses SSE 中写出 sequence_number 避免反序列化失败
2026-09-15 15:28:33 +08:00
Wesley Liddick 2d9af8592f Merge pull request #7153 from hezhaoqian1/codex/upstream-api-key-provider-filter
feat: filter API key groups by provider
2026-09-15 15:26:29 +08:00
HuntercodeT 8447bdd368 fix(antigravity): gemini 透传 SSE 事件之间不再多写一个空行
handleGeminiStreamingResponse 把上游每条 data 行改写成 "data: ...\n\n",
紧接着又把上游的空分隔行原样透传成第三个 "\n",下游看到的事件之间是
"\n\n\n"。google-genai 的 Go SDK(Antigravity CLI 在用)按 "\n\n" 切事件,
多出的 "\n" 粘到下一个事件开头,前缀变成 "\ndata" 而报
"iterateResponseStream: invalid stream chunk",每条流都在第二个事件中断。

空分隔行不再透传(data 分支已经写过 "\n\n"),附回归测试:两个事件
(LF 与 CRLF 各一)经过后输出必须恰好是一个空行分隔,并按 "\n\n" 切帧
逐个校验前缀。

Fixes #7161
2026-09-15 15:10:45 +08:00
shaw 32682a4f84 chore: update sponsors 2026-09-15 14:01:28 +08:00
hezhaoqian1 55a95d4c67 feat: filter API key groups by provider 2026-09-15 11:16:17 +08:00
alfadb a939553c87 fix(deepseek): 模型名校验不再默认兜底,未配置映射时按官方名单校验
- 未配置 model_mapping 的 DeepSeek 账号不再放行任意模型名,改为官方名单精确匹配
  (deepseek-flash / deepseek-v4-pro / deepseek-v4-flash /
   deepseek-v4-flash-vision-exp / deepseek-v4-pro-0813)
- 校验前先归一化:剥离 Claude Code 客户端上下文后缀 [1m](含重复形式)并统一小写,
  使官方 Claude Code 接入写法 deepseek-flash[1m] 继续可用
- 未知模型名在调度阶段即拒绝,返回 404 model_not_found,避免透传上游后触发
  per-(账号,模型) 冷却、并避免上游把不认识的模型名静默兜底成 deepseek-flash
- 出站前剥离泄漏的 [1m] 后缀,仅作用于 DeepSeek 平台
- 配置了 model_mapping 的账号语义不变(映射优先)
2026-09-15 10:42:29 +08:00
Wesley Liddick badfad8b72 Merge pull request #6257 from 88766lawrence/fix/oauth-promo-code-6216
fix(auth): preserve promo codes through OAuth registration
2026-09-15 09:01:39 +08:00
Wesley Liddick 1be482c313 Merge pull request #7148 from Wei-Shaw/revert-7110-codex/bug-batch-image-access-session
Revert "fix(batch-image): scope access cache to the current user"
2026-09-15 09:00:23 +08:00
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
cikenerdandCursor 087987070b fix(responses): 为 grok-build 始终写出 sequence_number
网关合成/重编的 Responses SSE 因 omitempty 或缺字段丢掉 sequence_number,
grok-build 会整轮反序列化失败。合成失败帧、compact 桥接和转换 wire JSON
一律写出该字段,0 也保留。

Fixes #7128

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-14 11:45:53 +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 db76cc4e4e fix(scheduler): 按规范窗口读取 Codex 配额与重置时间
优先读取 codex_5h/codex_7d 规范字段,旧快照复用 Normalize 的窗口分类;用量与重置时间按同源窗口配对。重置评分与管理端快照复用规范 5h 重置时间,保留会话列回退,不改变权重与暂停阈值。
2026-09-12 09:06:33 -04: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
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
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
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
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
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
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
Qilin You 7e62731863 perf: reduce allocations for large Responses image payloads 2026-09-08 01:54:32 +08:00
tunsuyandCursor 140e172951 chore: retrigger CI after test fix
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-07 20:10:50 +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
Lawrence c0c4ce1ce6 Merge upstream main and retain blank OAuth promo code regression test 2026-09-07 14:27:55 +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
Lawrence e1eff57c25 fix(auth): preserve promo codes through OAuth registration 2026-08-26 21:12:52 +08:00
504 changed files with 29297 additions and 1715 deletions
+1
View File
@@ -133,6 +133,7 @@ deploy/docker-compose.override.yml
.gocache/
vite.config.js
docs/*
!docs/ANTIGRAVITY_ATTRIBUTION_429.md
!docs/PAYMENT.md
!docs/PAYMENT_CN.md
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
+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 \
+2 -10
View File
@@ -49,8 +49,8 @@ Please read the following carefully before using this project:
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>Thanks to APIKEY.FUN for sponsoring this project! <a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> is one of the core contributors to the sub2api open-source project, dedicated to providing open, stable, and cost-effective AI API access. The platform supports API relay services for Claude, OpenAI, Gemini, and other popular models, with pricing starting from as low as 7% of the original rate. Register via the exclusive link: <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> to enjoy up to 5% off on all recharges.</td>
</tr>
<tr>
@@ -102,14 +102,6 @@ Please read the following carefully before using this project:
</td>
</tr>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 Thanks to FastAIToken for sponsoring this project! <a href="http://www.fastaitoken.com/register">FastAIToken</a> is an AI API aggregation platform for developers, supporting mainstream large models such as OpenAI, Claude, and Gemini. Top-up at 1:1 — 1 CNY = 1 USD of API credit — letting developers use the world's leading large model services at lower cost and with greater convenience.<br>
🚀 The platform offers a variety of channels to choose from: an ultra-low-price 0.02x OpenAI promotional group (limited time), groups as low as 0.25x OpenAI, 0.7x Claude with 95% fixed cache, and a 1.2x Claude Max channel. It also provides a public status page showing real-time availability, latency, and operating status of each group for transparent and reliable service, plus 7×24 human technical support (not bots) with fast responses to developer needs.
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>Thanks to Aimzoon for sponsoring this project! <a href="http://aimzoon.com">Aimzoon</a> provides stable, cost-effective AI API access services, enabling developers to quickly connect popular AI services to coding tools such as Codex, Claude Code, and Gemini CLI. No complex configuration — faster onboarding, more stable calls, and lower costs. Ongoing promotions including discounted Codex rates and special pricing, with free trial credits upon registration, bringing AI coding into your daily workflow. <a href="http://aimzoon.com">Click here</a> to register and try it out!
+26 -10
View File
@@ -50,8 +50,8 @@
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>感谢 APIKEY.FUN 赞助了本项目!<a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> 是 sub2api 开源项目的核心贡献者之一,致力于提供开放、稳定、高性价比的 AI API 接入服务。平台支持 Claude、OpenAI、Gemini 等热门模型的 API 中转服务,价格低至官方原价的 7%。通过专属链接 <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> 注册,可享受充值最高 95 折优惠。</td>
</tr>
<tr>
@@ -105,14 +105,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 感谢 FastAIToken 对本项目的赞助! <a href="http://www.fastaitoken.com/register">FastAIToken</a> 是面向开发者的 AI API 聚合平台,支持 OpenAI、Claude、Gemini 等主流大模型,充值 1:1,1 元 = 1 美元 API 额度,让开发者以更低成本、更便捷地使用全球领先的大模型服务。<br>
🚀 平台提供多种渠道自由选择:超级低价的0.02x OpenAI 福利分组(限时)、低至 0.25x OpenAI 分组、0.7x Claude 95%固定缓存、1.2x Claude Max 渠道;同时提供公开状态页,实时展示各分组的可用率、延迟及运行状态,服务透明可靠,并提供 7×24 小时真人技术支持(非机器人),快速响应开发者需求。
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>感谢 Aimzoon 对本项目的赞助! <a href="http://aimzoon.com">Aimzoon</a> 提供稳定、高性价比的 AI API 接入服务,支持开发者将常用 AI 服务快速接入 Codex、Claude Code、Gemini CLI 等编程工具。无需复杂配置,更快接入,更稳调用,更省成本。codex倍率优惠,特价倍率等促销不断,注册即送免费体验额度,让 AI 编程真正进入日常工作流。<a href="http://aimzoon.com">点击这里</a>注册体验!
@@ -227,6 +219,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`。
---
## 部署方式
+2 -10
View File
@@ -49,8 +49,8 @@
</tr>
<tr>
<td width="180"><a href="https://apikey.fun/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>APIKEY.FUN のご支援に感謝します!<a href="https://apikey.fun/register?aff=SUB2API">APIKEY.FUN</a> は sub2api オープンソースプロジェクトのコアコントリビューターの一つであり、オープンで安定した、コストパフォーマンスに優れた AI API アクセスサービスの提供に取り組んでいます。プラットフォームは Claude、OpenAI、Gemini など人気モデルの API 中継サービスをサポートし、価格は公式料金のわずか 7% から。専用リンク <a href="https://apikey.fun/register?aff=SUB2API">APIKEY</a> から登録すると、すべてのチャージで永久 5% 割引をご利用いただけます。</td>
<td width="180"><a href="https://apikey.fan/register?aff=SUB2API"><img src="assets/partners/logos/apikey-fun.png" alt="APIKEY.FUN" width="150"></a></td>
<td>APIKEY.FUN のご支援に感謝します!<a href="https://apikey.fan/register?aff=SUB2API">APIKEY.FUN</a> は sub2api オープンソースプロジェクトのコアコントリビューターの一つであり、オープンで安定した、コストパフォーマンスに優れた AI API アクセスサービスの提供に取り組んでいます。プラットフォームは Claude、OpenAI、Gemini など人気モデルの API 中継サービスをサポートし、価格は公式料金のわずか 7% から。専用リンク <a href="https://apikey.fan/register?aff=SUB2API">APIKEY</a> から登録すると、すべてのチャージで永久 5% 割引をご利用いただけます。</td>
</tr>
<tr>
@@ -104,14 +104,6 @@
</td>
</tr>
<tr>
<td width="180"><a href="http://www.fastaitoken.com/register"><img src="assets/partners/logos/fastaitoken.jpg" alt="fastaitoken" width="150"></a></td>
<td>🎉 FastAIToken のご支援に感謝します!<a href="http://www.fastaitoken.com/register">FastAIToken</a> は開発者向けの AI API アグリゲーションプラットフォームで、OpenAI、Claude、Gemini などの主要な大規模モデルに対応しています。チャージは 1:1(1 元 = 1 米ドル分の API クレジット)で、開発者がより低コスト・より手軽に世界トップクラスの大規模モデルサービスを利用できます。<br>
🚀 プラットフォームでは多彩なチャネルを自由に選択できます:超低価格の 0.02x OpenAI 特典グループ(期間限定)、最低 0.25x の OpenAI グループ、0.7x Claude(95% 固定キャッシュ)、1.2x Claude Max チャネル。さらに、各グループの可用率・レイテンシ・稼働状況をリアルタイムで表示する公開ステータスページを提供し、透明で信頼できるサービスを実現。7×24 時間の有人テクニカルサポート(ボットではありません)により、開発者のニーズに迅速に対応します。
</td>
</tr>
<tr>
<td width="180"><a href="http://aimzoon.com"><img src="assets/partners/logos/aimzoon.jpg" alt="aimzoon" width="150"></a></td>
<td>Aimzoon のご支援に感謝します!<a href="http://aimzoon.com">Aimzoon</a> は安定してコストパフォーマンスに優れた AI API 接続サービスを提供し、開発者が主要な AI サービスを Codex、Claude Code、Gemini CLI などのコーディングツールへ素早く接続できるようにします。複雑な設定は不要で、より速い接続、より安定した呼び出し、より低いコストを実現。Codex レート割引や特価レートなどのキャンペーンも随時開催中、登録するだけで無料お試しクレジットをプレゼント。AI コーディングを日常のワークフローへ。<a href="http://aimzoon.com">こちら</a>から登録してお試しください!
+1 -1
View File
@@ -1 +1 @@
0.2.4
0.2.7
+7 -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
@@ -183,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)
@@ -203,7 +204,9 @@ func initializeApplication(buildInfo handler.BuildInfo) (*Application, error) {
accountUsageService := service.ProvideAccountUsageService(accountRepository, usageLogRepository, claudeUsageFetcher, geminiQuotaService, antigravityQuotaFetcher, grokQuotaFetcher, grokQuotaService, openAIQuotaService, usageCache, identityCache, tlsFingerprintProfileService, openAIGatewayService)
pluginRepository := repository.NewPluginRepository(db)
pluginHostInfo := providePluginHostInfo(buildInfo)
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo)
pluginKVStore := repository.NewPluginKVStore(redisClient)
pluginManager := service.NewPluginManager(pluginRepository, secretEncryptor, configConfig, pluginHostInfo, pluginKVStore)
pluginManager.SetAccountDirectory(openAIGatewayService)
accountTestService := service.ProvideAccountTestService(accountRepository, geminiTokenProvider, claudeTokenProvider, grokTokenProvider, antigravityGatewayService, httpUpstream, configConfig, tlsFingerprintProfileService, openAIGatewayService, settingService, pluginManager)
crsSyncService := service.NewCRSSyncService(accountRepository, proxyRepository, oAuthService, openAIOAuthService, geminiOAuthService, configConfig)
accountHandler := admin.ProvideAccountHandler(configConfig, adminService, oAuthService, openAIOAuthService, geminiOAuthService, antigravityOAuthService, grokOAuthService, rateLimitService, accountUsageService, accountTestService, concurrencyService, crsSyncService, sessionLimitCache, rpmCache, compositeTokenCacheInvalidator, grokQuotaService)
@@ -284,7 +287,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)
+2 -1
View File
@@ -183,6 +183,7 @@ const (
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
ProviderOpencodeGo Provider = "opencode_go"
)
func (pr Provider) String() string {
@@ -192,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, ProviderMinimax:
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)
@@ -109,6 +109,7 @@ const (
ProviderZhipu Provider = "zhipu"
ProviderDeepseek Provider = "deepseek"
ProviderMinimax Provider = "minimax"
ProviderOpencodeGo Provider = "opencode_go"
)
func (pr Provider) String() string {
@@ -118,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, ProviderMinimax:
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)
+2 -2
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", "minimax"}},
{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", "minimax"}},
{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},
+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", "minimax"),
"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", "minimax"),
"antigravity", "kimi", "zhipu", "deepseek", "minimax", "opencode_go"),
field.String("api_mode").
Default("chat_completions").
MaxLen(32).
+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", "minimax":
"kimi", "zhipu", "deepseek", "minimax", "opencode_go":
return nil
default:
return fmt.Errorf("platform %q is not allowed", s)
+14 -14
View File
@@ -50,13 +50,13 @@ require (
github.com/wechatpay-apiv3/wechatpay-go v0.2.21
github.com/zeromicro/go-zero v1.9.4
go.uber.org/zap v1.24.0
golang.org/x/crypto v0.53.0
golang.org/x/crypto v0.55.0
golang.org/x/image v0.41.0
golang.org/x/mod v0.37.0
golang.org/x/net v0.56.0
golang.org/x/sync v0.21.0
golang.org/x/term v0.44.0
google.golang.org/grpc v1.82.1
golang.org/x/mod v0.38.0
golang.org/x/net v0.58.0
golang.org/x/sync v0.22.0
golang.org/x/term v0.45.0
google.golang.org/grpc v1.83.2
google.golang.org/protobuf v1.36.11
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
@@ -192,20 +192,20 @@ require (
github.com/zclconf/go-cty v1.14.4 // indirect
github.com/zclconf/go-cty-yaml v1.1.0 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect
go.opentelemetry.io/otel v1.43.0 // indirect
go.opentelemetry.io/otel/metric v1.43.0 // indirect
go.opentelemetry.io/otel/trace v1.43.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 // indirect
go.opentelemetry.io/otel v1.44.0 // indirect
go.opentelemetry.io/otel/metric v1.44.0 // indirect
go.opentelemetry.io/otel/trace v1.44.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
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 // indirect
golang.org/x/sys v0.46.0 // indirect
golang.org/x/text v0.39.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.41.0 // indirect
golang.org/x/time v0.12.0 // indirect
golang.org/x/tools v0.47.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 // indirect
golang.org/x/tools v0.48.0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa // indirect
gopkg.in/ini.v1 v1.67.0 // indirect
modernc.org/libc v1.67.6 // indirect
modernc.org/mathutil v1.7.1 // indirect
+36 -49
View File
@@ -253,8 +253,6 @@ github.com/google/go-tpm-tools v0.3.13-0.20230620182252-4639ecce2aba/go.mod h1:E
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/subcommands v1.2.0 h1:vWQspBTo2nEqTUFita5/KeEWlUL8kQObDFbub/EN9oE=
github.com/google/subcommands v1.2.0/go.mod h1:ZjhPrFU+Olkh9WazFPsl27BQ4UPiG37m3yTrtFlrHVk=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/wire v0.7.0 h1:JxUKI6+CVBgCO2WToKy/nQk0sS+amI9z9EjVmdaocj4=
@@ -300,8 +298,6 @@ github.com/kisielk/sqlstruct v0.0.0-20201105191214-5f3e10d3ab46/go.mod h1:yyMNCy
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
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=
@@ -329,8 +325,6 @@ 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=
@@ -369,8 +363,6 @@ 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=
@@ -405,8 +397,6 @@ github.com/refraction-networking/utls v1.8.2 h1:j4Q1gJj0xngdeH+Ox/qND11aEfhpgoEv
github.com/refraction-networking/utls v1.8.2/go.mod h1:jkSOEkLqn+S/jtpEHPOsVv/4V4EVnelwbMQl4vCWXAM=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs=
github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -442,8 +432,6 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.18.2 h1:LUXCnvUvSM6FXAsj6nnfc8Q2tp1dIgUfY9Kc8GsSOiQ=
@@ -521,30 +509,30 @@ github.com/zclconf/go-cty v1.14.4 h1:uXXczd9QDGsgu0i/QFR/hzI5NYCHLf6NQw/atrbnhq8
github.com/zclconf/go-cty v1.14.4/go.mod h1:VvMs5i0vgZdhYawQNq5kePSpLAoz8u1xvZgrPIxfnZE=
github.com/zclconf/go-cty-yaml v1.1.0 h1:nP+jp0qPHv2IhUVqmQSzjvqAWcObN0KBkUl2rWBdig0=
github.com/zclconf/go-cty-yaml v1.1.0/go.mod h1:9YLUH4g7lOhVWqUbctnVlZ5KLpg7JAprQNgxSZ1Gyxs=
github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs=
github.com/zeebo/xxh3 v1.1.0/go.mod h1:IisAie1LELR4xhVinxWS5+zf1lA4p0MW4T+w+W07F5s=
github.com/zeromicro/go-zero v1.9.4 h1:aRLFoISqAYijABtkbliQC5SsI5TbizJpQvoHc9xup8k=
github.com/zeromicro/go-zero v1.9.4/go.mod h1:a17JOTch25SWxBcUgJZYps60hygK3pIYdw7nGwlcS38=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 h1:jq9TW8u3so/bN+JPT166wjOI6/vQPF6Xe7nMNIltagk=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0/go.mod h1:p8pYQP+m5XfbZm9fxtSKAbM6oIllS7s2AfxrChvc7iw=
go.opentelemetry.io/otel v1.43.0 h1:mYIM03dnh5zfN7HautFE4ieIig9amkNANT+xcVxAj9I=
go.opentelemetry.io/otel v1.43.0/go.mod h1:JuG+u74mvjvcm8vj8pI5XiHy1zDeoCS2LB1spIq7Ay0=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0 h1:F7Jx+6hwnZ41NSFTO5q4LYDtJRXBf2PD0rNBkeB/lus=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.61.0/go.mod h1:UHB22Z8QsdRDrnAtX4PntOl36ajSxcdUMt1sF7Y6E7Q=
go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU=
go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0 h1:t6wl9SPayj+c7lEIFgm4ooDBZVb01IhLB4InpomhRw8=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.24.0/go.mod h1:iSDOcsnSA5INXzZtwaBPrKp/lWu/V14Dd+llD0oI2EA=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0 h1:Xw8U6u2f8DK2XAkGRFV7BBLENgnTGX9i4rQRxJf+/vs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.24.0/go.mod h1:6KW1Fm6R/s6Z3PGXwSJN2K4eT6wQB3vXX6CVnYX9NmM=
go.opentelemetry.io/otel/metric v1.43.0 h1:d7638QeInOnuwOONPp4JAOGfbCEpYb+K6DVWvdxGzgM=
go.opentelemetry.io/otel/metric v1.43.0/go.mod h1:RDnPtIxvqlgO8GRW18W6Z/4P462ldprJtfxHxyKd2PY=
go.opentelemetry.io/otel/sdk v1.43.0 h1:pi5mE86i5rTeLXqoF/hhiBtUNcrAGHLKQdhg4h4V9Dg=
go.opentelemetry.io/otel/sdk v1.43.0/go.mod h1:P+IkVU3iWukmiit/Yf9AWvpyRDlUeBaRg6Y+C58QHzg=
go.opentelemetry.io/otel/sdk/metric v1.43.0 h1:S88dyqXjJkuBNLeMcVPRFXpRw2fuwdvfCGLEo89fDkw=
go.opentelemetry.io/otel/sdk/metric v1.43.0/go.mod h1:C/RJtwSEJ5hzTiUz5pXF1kILHStzb9zFlIEe85bhj6A=
go.opentelemetry.io/otel/trace v1.43.0 h1:BkNrHpup+4k4w+ZZ86CZoHHEkohws8AY+WTX09nk+3A=
go.opentelemetry.io/otel/trace v1.43.0/go.mod h1:/QJhyVBUUswCphDVxq+8mld+AvhXZLhe+8WVFxiFff0=
go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc=
go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo=
go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58=
go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0=
go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI=
go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA=
go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk=
go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE=
go.opentelemetry.io/proto/otlp v1.3.1 h1:TrMUixzpM0yuc/znrFTP9MMRh8trP93mkCiDVeXrui0=
go.opentelemetry.io/proto/otlp v1.3.1/go.mod h1:0X1WI4de4ZsLrrJNLAQbFeLCm3T7yBkR0XqQ7niQU+8=
go.uber.org/atomic v1.10.0 h1:9qC72Qh0+3MqyJbAn8YU5xVq1frD8bn3JtD2oXtafVQ=
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=
@@ -574,8 +562,8 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs=
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto=
golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio=
golang.org/x/crypto v0.55.0 h1:+KWHjbgOaAQ66dh/YlkZKHlz9ZUlq61AFirAR9ntP8M=
golang.org/x/crypto v0.55.0/go.mod h1:uq0V9dE/fzQuJtbnL+2EhWOE63vo164FY8xqEnV9xis=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546 h1:mgKeJMpvi0yx/sU5GsxQ7p6s2wtOnGAHZWCHUM4KGzY=
golang.org/x/exp v0.0.0-20251023183803-a4bb9ffd2546/go.mod h1:j/pmGrbnkbPtQfxEe5D0VQhZC6qKbfKifgD0oM7sR70=
@@ -590,8 +578,8 @@ golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ=
golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0=
golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk=
golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -613,8 +601,8 @@ golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
golang.org/x/net v0.58.0 h1:ynWG7rqYi4ccpTEuPZ2QGWHktVEM9DMCj9yzDE0Q7To=
golang.org/x/net v0.58.0/go.mod h1:YwCddHnFlT7eLQqVprV19OnhLGtc5xOKgE0RyqgfWAU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -626,8 +614,8 @@ golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM=
golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -645,7 +633,6 @@ golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBc
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220503163025-988cb79eb6c6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220704084225-05e143d24a9e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -660,8 +647,8 @@ golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw=
golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@@ -674,8 +661,8 @@ golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58=
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc=
golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y=
golang.org/x/term v0.45.0 h1:NwWyBmoJCbfTHpxrWoZ9C6/VxOf7ic219I8xZZFdrf0=
golang.org/x/term v0.45.0/go.mod h1:9aqxs0blBcrm/n0L9QW0aRVD+ktan8ssZromtqJC43w=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
@@ -686,8 +673,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.39.0 h1:UbZz4pLOvn600D6Oh6GGEI6VAmndrEBLv8/6BEXzyus=
golang.org/x/text v0.39.0/go.mod h1:3UwRclnC2g0TU9x8PZiyfOajCd1zaUNHF9cvqcQZ+ZM=
golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8=
golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -702,8 +689,8 @@ golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q=
golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA=
golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE=
golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -714,16 +701,16 @@ google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20231106174013-bbf56f31fb17 h1:wpZ8pe2x1Q3f2KyT5f8oP/fa9rHAKgFPr/HZdNuS+PQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478 h1:yQugLulqltosq0B/f8l4w9VryjV+N/5gcW0jQ3N8Qec=
google.golang.org/genproto/googleapis/api v0.0.0-20260414002931-afd174a4e478/go.mod h1:C6ADNqOxbgdUUeRTU+LCHDPB9ttAMCTff6auwCVa4uc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478 h1:RmoJA1ujG+/lRGNfUnOMfhCy5EipVMyvUE+KNbPbTlw=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260414002931-afd174a4e478/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa h1:Kjn0N0tCrDgiAFW+lGO4JZ3ck44CehvJQMAwj9QF0G8=
google.golang.org/genproto/googleapis/api v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:q4lMZS6kskjT5HvCPrnnypcDPVJqT/f4nfxmkE7gryY=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa h1:mZHHdPZl0dbGHCflZgAq/Q468DWVFcU2whhB2KAo8fk=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260526163538-3dc84a4a5aaa/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.82.1 h1:NnAxzGRA0677vCa4BUkOAnO5+FfQqVl9iUXeD0IqcGE=
google.golang.org/grpc v1.82.1/go.mod h1:yzTZ1TB1Z3SG+LIYaI+WiE8D5+PZ3ArnrSp8zF3+/ZA=
google.golang.org/grpc v1.83.2 h1:EManeRomTObA0BU7I8vXgg/78uE5MJ9M8B39EX2WscU=
google.golang.org/grpc v1.83.2/go.mod h1:YPI1hK3kDked6iHvgX3tR0y+nX/qpMFKhPgFsokw1S8=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
+11 -6
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"`
@@ -2048,6 +2052,7 @@ func setDefaults() {
"open.bigmodel.cn",
"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",
@@ -2375,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)
@@ -2401,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)
+5 -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) {
@@ -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 等)
+22 -5
View File
@@ -24,11 +24,14 @@ const (
PlatformAntigravity = "antigravity"
PlatformGrok = "grok"
// 国产 OpenAI 兼容供应商(经 OpenAI 网关转发,按 Chat Completions 协议)。
PlatformKimi = "kimi" // Kimi (月之暗面 / Moonshot)
PlatformZhipu = "zhipu" // 智谱 GLM (bigmodel)
PlatformDeepseek = "deepseek" // DeepSeek
PlatformMiniMax = "minimax" // MiniMax (M 系列)
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」两种接入方式。
@@ -36,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 协议维度。存储于
@@ -146,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()
@@ -318,8 +318,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
Protocol: proxy.Protocol,
Host: proxy.Host,
Port: proxy.Port,
Username: proxy.Username,
Password: proxy.Password,
Username: &proxy.Username,
Password: &proxy.Password,
})
}
}
@@ -394,8 +394,8 @@ func (h *AccountHandler) importData(ctx context.Context, req DataImportRequest)
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",
})
@@ -644,6 +644,7 @@ var platformToLiteLLMProvider = map[string]string{
service.PlatformZhipu: "zhipu",
service.PlatformDeepseek: "deepseek",
service.PlatformMiniMax: "minimax",
service.PlatformOpenCodeGo: "opencode-go",
}
// SyncPricingModels 返回 LiteLLM 定价目录中指定平台的最新模型列表
@@ -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 minimax"`
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 minimax"`
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"`
@@ -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 minimax 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 minimax 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 minimax"`
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", "minimax", "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", "minimax"} {
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 {
@@ -183,6 +183,22 @@ func (h *PluginHandler) Test(c *gin.Context) {
response.Success(c, result)
}
// Status returns the plugin's passive runtime status for the config UI. It is
// read-only (no config apply, no upstream call) and therefore not step-up gated,
// so a status UI can poll it without a 2FA prompt or a "test" side effect.
func (h *PluginHandler) Status(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
return
}
result, err := h.manager.Status(c.Request.Context(), id)
if err != nil {
response.ErrorFrom(c, err)
return
}
response.Success(c, result)
}
func (h *PluginHandler) CreateUISession(c *gin.Context) {
id, ok := pluginIDParam(c)
if !ok {
@@ -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)
}
+4 -4
View File
@@ -182,8 +182,8 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
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{
@@ -266,8 +266,8 @@ func (h *ProxyHandler) ImportData(c *gin.Context) {
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",
@@ -45,8 +45,8 @@ type UpdateProxyRequest struct {
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"`
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"`
@@ -187,13 +187,19 @@ func (h *ProxyHandler) Update(c *gin.Context) {
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,
@@ -383,6 +383,7 @@ func (h *SettingHandler) GetSettings(c *gin.Context) {
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")
}
@@ -344,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"`
@@ -1937,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
@@ -2380,6 +2389,7 @@ func (h *SettingHandler) UpdateSettings(c *gin.Context) {
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":[
+11 -1
View File
@@ -318,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"`
@@ -404,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"`
@@ -423,6 +431,8 @@ type PublicSettings struct {
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"`
+4 -1
View File
@@ -29,6 +29,7 @@ const (
EndpointVideosEdits = "/v1/videos/edits"
EndpointVideosExtensions = "/v1/videos/extensions"
EndpointVideos = "/v1/videos"
EndpointSeedanceTasks = "/api/v3/contents/generations/tasks"
EndpointGeminiModels = "/v1beta/models"
)
@@ -81,6 +82,8 @@ const (
func NormalizeInboundEndpoint(path string) string {
path = strings.TrimSpace(path)
switch {
case strings.Contains(path, "/contents/generations/tasks"):
return EndpointSeedanceTasks
case strings.Contains(path, EndpointResponsesInputTokens) || isResponsesInputTokensAliasPath(path):
return EndpointResponsesInputTokens
case strings.Contains(path, EndpointEmbeddings):
@@ -315,7 +318,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 {
+6 -4
View File
@@ -1269,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, service.PlatformMiniMax} {
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)
}
}
@@ -1422,7 +1422,7 @@ 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:
@@ -1451,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, service.PlatformMiniMax} {
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
@@ -91,7 +91,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{
byGroup: map[int64][]service.Account{
// 只有 antigravity 账号:Gemini 选号失败后回落静态模型列表。
41: {{ID: 2, Platform: service.PlatformAntigravity, Status: service.StatusActive, Schedulable: true}},
41: {{ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Status: service.StatusActive, Schedulable: true}},
},
}}
h := &GatewayHandler{
@@ -109,7 +109,7 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
Platform: service.PlatformGemini,
ModelAllowlist: service.GroupModelAllowlist{
Enabled: true,
Models: []string{"gemini-2.5-pro", "gemini-3-*"},
Models: []string{"gemini-3.8-flash-high", "gemini-3-*"},
},
},
})
@@ -129,8 +129,8 @@ func TestGeminiV1BetaListModels_FiltersFallbackByAllowlist(t *testing.T) {
names = append(names, model.Name)
}
// models/ 前缀的候选形式也应命中条目。
require.Contains(t, names, "models/gemini-2.5-pro")
require.Contains(t, names, "models/gemini-3-pro-preview")
require.Contains(t, names, "models/gemini-3.8-flash-high")
require.Contains(t, names, "models/gemini-3-flash")
require.NotContains(t, names, "models/gemini-2.5-flash")
require.NotContains(t, names, "models/gemini-2.0-flash")
}
@@ -914,7 +914,7 @@ func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T)
}
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))
}
@@ -0,0 +1,136 @@
package handler
import (
"encoding/json"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/gemini"
"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"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func TestGeminiNativeModelsUsesAccountMappings(t *testing.T) {
for _, tt := range []struct {
name string
forced, mixed, allowlist bool
status int
}{
{"mixed", false, true, false, 200},
{"mixed allowlist", false, true, true, 200},
{"disabled mixed", false, false, false, 503},
{"forced without mixed opt-in", true, false, false, 200},
{"forced allowlist", true, false, true, 200},
} {
t.Run(tt.name, func(t *testing.T) {
groupID := int64(45)
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
groupID: {{ID: 1, Platform: service.PlatformAntigravity,
Extra: map[string]any{"mixed_scheduling": tt.mixed},
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high", "claude-custom": "claude-sonnet-4-6"}}}},
}}}
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &groupID, Group: &service.Group{ID: groupID, Platform: service.PlatformGemini,
ModelAllowlist: service.GroupModelAllowlist{Enabled: tt.allowlist, Models: []string{"gemini-synced-custom"}},
}})
if tt.forced {
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
}
h.GeminiV1BetaListModels(c)
require.Equal(t, tt.status, rec.Code, rec.Body.String())
if tt.status != 200 {
return
}
var got gemini.ModelsListResponse
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
names := []string{}
for _, model := range got.Models {
names = append(names, model.Name)
require.Contains(t, model.SupportedGenerationMethods, "generateContent")
}
require.Contains(t, names, "models/gemini-synced-custom")
require.NotContains(t, names, "models/claude-custom")
require.NotContains(t, names, "models/gemini-2.0-flash")
if tt.allowlist {
require.Equal(t, []string{"models/gemini-synced-custom"}, names)
}
})
}
}
func TestAppendUpstreamGeminiModelsPreservesMetadata(t *testing.T) {
body := []byte(`{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}}],"nextPageToken":"next","other":42}`)
extra := []gemini.Model{gemini.FallbackModel("gemini-native"), gemini.FallbackModel("gemini-synced-custom"), gemini.FallbackModel("gemini-synced-custom")}
merged, ok := appendUpstreamGeminiModels(body, extra)
require.True(t, ok)
require.JSONEq(t, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123,"custom":{"a":true}},{"name":"models/gemini-synced-custom","supportedGenerationMethods":["generateContent","streamGenerateContent"]}],"nextPageToken":"next","other":42}`, string(merged))
filtered, dropped, ok := filterUpstreamGeminiModelsBody(merged, service.GroupModelAllowlist{Enabled: true, Models: []string{"gemini-synced-*"}})
require.True(t, ok)
require.True(t, dropped)
require.NotContains(t, string(filtered), "gemini-native")
require.Contains(t, string(filtered), "gemini-synced-custom")
for _, invalid := range []string{`null`, `not-json`, `{"error":"bad"}`, `{"models":{}}`} {
got, ok := appendUpstreamGeminiModels([]byte(invalid), extra)
require.False(t, ok)
require.Equal(t, invalid, string(got))
}
}
// Exercise the real handler's native-upstream branch, including scope fallback.
type geminiMixedModelsUpstream struct {
service.HTTPUpstream
status int
body string
}
func (u *geminiMixedModelsUpstream) Do(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
return &http.Response{StatusCode: u.status, Header: http.Header{"Content-Type": {"application/json"}, "X-Request-Id": {"native-id"}}, Body: io.NopCloser(strings.NewReader(u.body))}, nil
}
func TestGeminiNativeModelsMergesNativeUpstream(t *testing.T) {
for _, tt := range []struct {
name string
status int
body string
expectedNative string
}{
{"native", 200, `{"models":[{"name":"models/gemini-native","inputTokenLimit":123}],"nextPageToken":"next"}`, "models/gemini-native"},
{"scope fallback", 403, `{"error":"insufficient authentication scopes"}`, "models/gemini-2.5-pro"},
{"upstream error", 429, `{"error":"rate limited"}`, ""},
} {
t.Run(tt.name, func(t *testing.T) {
id := int64(46)
repo := &geminiAllowlistAccountRepoStub{gatewayModelsAccountRepoStub: gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{id: {
{ID: 1, Platform: service.PlatformGemini, Type: service.AccountTypeAPIKey, Credentials: map[string]any{"api_key": "test"}},
{ID: 2, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{"model_mapping": map[string]any{"gemini-synced-custom": "gemini-3.8-flash-high"}}},
}}}}
upstream := &geminiMixedModelsUpstream{status: tt.status, body: tt.body}
h := &GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, upstream, nil, &config.Config{})}
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodGet, "/v1beta/models", nil)
c.Set(string(middleware.ContextKeyAPIKey), &service.APIKey{GroupID: &id, Group: &service.Group{ID: id, Platform: service.PlatformGemini}})
h.GeminiV1BetaListModels(c)
if tt.status == 429 {
require.Equal(t, 429, rec.Code)
require.JSONEq(t, tt.body, rec.Body.String())
return
}
require.Equal(t, 200, rec.Code, rec.Body.String())
require.Contains(t, rec.Body.String(), tt.expectedNative)
require.Contains(t, rec.Body.String(), "models/gemini-synced-custom")
if tt.status == 200 {
require.Contains(t, rec.Body.String(), `"inputTokenLimit":123`)
require.Contains(t, rec.Body.String(), `"nextPageToken":"next"`)
require.Equal(t, "native-id", rec.Header().Get("X-Request-Id"))
}
})
}
}
@@ -60,30 +60,24 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return filtered
}
// 强制 antigravity 模式:返回 antigravity 支持的模型列表
agModelIDs, err := h.geminiCompatService.AntigravityGeminiModelIDs(c.Request.Context(), apiKey.GroupID, forcePlatform != service.PlatformAntigravity)
if err != nil {
googleError(c, http.StatusServiceUnavailable, "Unable to list Antigravity models")
return
}
agModels := make([]gemini.Model, 0, len(agModelIDs))
for _, id := range agModelIDs {
agModels = append(agModels, gemini.FallbackModel(id))
}
if forcePlatform == service.PlatformAntigravity {
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
agModels := antigravity.DefaultGeminiModels()
filtered := make([]antigravity.GeminiModel, 0, len(agModels))
for _, model := range agModels {
if apiKey.Group.ModelAllowlist.Allows(model.Name) {
filtered = append(filtered, model)
}
}
c.JSON(http.StatusOK, antigravity.GeminiModelsListResponse{Models: filtered})
return
}
c.JSON(http.StatusOK, antigravity.FallbackGeminiModelsList())
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
return
}
account, err := h.geminiCompatService.SelectAccountForAIStudioEndpoints(c.Request.Context(), apiKey.GroupID)
if err != nil {
// 没有 gemini 账户,检查是否有 antigravity 账户可用
hasAntigravity, _ := h.geminiCompatService.HasAntigravityAccounts(c.Request.Context(), apiKey.GroupID)
if hasAntigravity {
// antigravity 账户使用静态模型列表
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
if len(agModels) > 0 {
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(agModels)})
return
}
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
@@ -97,9 +91,15 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
return
}
if shouldFallbackGeminiModels(res) {
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(gemini.DefaultModels())})
c.JSON(http.StatusOK, gemini.ModelsListResponse{Models: filterGeminiModels(mergeGeminiModelLists(gemini.DefaultModels(), agModels))})
return
}
if res.StatusCode == http.StatusOK && len(agModels) > 0 {
if merged, ok := appendUpstreamGeminiModels(res.Body, agModels); ok {
res.Body = merged
}
}
if apiKey.Group != nil && apiKey.Group.ModelAllowlistEnabled() {
if filtered, dropped, ok := filterUpstreamGeminiModelsBody(res.Body, apiKey.Group.ModelAllowlist); ok && dropped {
// 只在确有条目被过滤时替换响应体;全命中或解析失败时保持原始响应,
@@ -110,6 +110,64 @@ func (h *GatewayHandler) GeminiV1BetaListModels(c *gin.Context) {
writeUpstreamResponse(c, res)
}
// mergeGeminiModelLists keeps native metadata when both sources advertise a model.
func mergeGeminiModelLists(native, extra []gemini.Model) []gemini.Model {
result := append([]gemini.Model{}, native...)
seen := make(map[string]bool, len(native))
for _, model := range native {
seen[model.Name] = true
}
for _, model := range extra {
if !seen[model.Name] {
result = append(result, model)
seen[model.Name] = true
}
}
return result
}
// appendUpstreamGeminiModels preserves unknown model metadata and envelope fields.
func appendUpstreamGeminiModels(body []byte, extra []gemini.Model) ([]byte, bool) {
var envelope map[string]json.RawMessage
if json.Unmarshal(body, &envelope) != nil || envelope == nil {
return body, false
}
var models []json.RawMessage
raw, exists := envelope["models"]
if !exists || json.Unmarshal(raw, &models) != nil {
return body, false
}
seen := make(map[string]bool, len(models))
for _, raw := range models {
var model struct {
Name string `json:"name"`
}
if json.Unmarshal(raw, &model) != nil {
return body, false
}
seen[model.Name] = true
}
changed := false
for _, model := range extra {
if seen[model.Name] {
continue
}
raw, err := json.Marshal(model)
if err != nil {
return body, false
}
models = append(models, raw)
seen[model.Name] = true
changed = true
}
if !changed {
return body, true
}
envelope["models"], _ = json.Marshal(models)
merged, err := json.Marshal(envelope)
return merged, err == nil
}
// filterUpstreamGeminiModelsBody 按白名单过滤上游 /v1beta/models 响应中的
// models[].name,其余信封字段(如 nextPageToken)原样保留。
// 返回值:filtered 为过滤后的响应体;dropped 表示是否有条目被移除(全命中时
@@ -39,7 +39,11 @@ func TestGeminiV1BetaListModels_ForcedAntigravityAppliesAllowlist(t *testing.T)
})
c.Set(string(middleware.ContextKeyForcePlatform), service.PlatformAntigravity)
(&GatewayHandler{}).GeminiV1BetaListModels(c)
groupID := int64(42)
key, _ := middleware.GetAPIKeyFromContext(c)
key.GroupID = &groupID
repo := &geminiAllowlistAccountRepoStub{}
(&GatewayHandler{geminiCompatService: service.NewGeminiMessagesCompatService(repo, nil, nil, nil, nil, nil, nil, nil, nil)}).GeminiV1BetaListModels(c)
require.Equal(t, http.StatusOK, rec.Code)
var got antigravity.GeminiModelsListResponse
+87 -28
View File
@@ -52,6 +52,12 @@ func (h *OpenAIGatewayHandler) GrokVideoContent(c *gin.Context) {
}
func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.GrokMediaEndpoint, requestID string) {
platform := service.PlatformGrok
noAccountCode, noAccountMessage := "grok_media_no_eligible_account", "No eligible Grok media accounts"
if endpoint.IsSeedance() {
platform = service.PlatformOpenAI
noAccountCode, noAccountMessage = "seedance_no_eligible_account", "No eligible Seedance accounts"
}
streamStarted := false
defer h.recoverResponsesPanic(c, &streamStarted)
@@ -99,6 +105,13 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
contentType := c.GetHeader("Content-Type")
requestInfo := service.ParseGrokMediaRequest(contentType, body)
if endpoint == service.SeedanceEndpointCreate {
requestInfo, err = service.ParseSeedanceRequest(body)
if err != nil {
h.errorResponse(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return
}
}
requestModel := requestInfo.Model
routingModel := service.NormalizeGrokMediaModelForEndpoint(endpoint, requestModel, requestInfo.HasInputImage())
if endpoint.IsGenerationRequest() && strings.TrimSpace(requestModel) == "" {
@@ -198,30 +211,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.SelectMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, platform,
)
} else {
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability(
requestCtx,
apiKey.GroupID,
"",
sessionHash,
routingModel,
failedAccountIDs,
service.OpenAIUpstreamTransportHTTPSSE,
requiredCapability,
false,
false,
false,
platform,
)
}
// 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)),
@@ -229,11 +269,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
if endpoint.IsGenerationRequest() && errors.Is(err, service.ErrNoAvailableAccounts) &&
(len(failedAccountIDs) == 0 || (mediaEligibilityRejected && lastFailoverErr == nil)) {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage)
return
}
if len(failedAccountIDs) == 0 {
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok)
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimitedIfNoAvailable(c, err)
}
@@ -250,16 +290,19 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
if selection == nil || selection.Account == nil {
if endpoint.IsGenerationRequest() {
markOpsRoutingCapacityLimited(c)
h.errorResponse(c, http.StatusServiceUnavailable, "grok_media_no_eligible_account", "No eligible Grok media accounts")
h.errorResponse(c, http.StatusServiceUnavailable, noAccountCode, noAccountMessage)
return
}
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, service.PlatformGrok)
cls := classifyNoAccountErrorFromGin(c, h.gatewayService, apiKey, requestModel, routingModel, platform)
if !cls.ModelNotFound {
markOpsRoutingCapacityLimited(c)
}
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),
@@ -279,9 +322,10 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
)
account := selection.Account
if endpoint.IsGenerationRequest() {
if endpoint.IsGenerationRequest() && !endpoint.IsSeedance() {
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 +342,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 +372,10 @@ 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()
if endpoint.IsSeedance() {
return h.gatewayService.ForwardSeedance(requestCtx, c, account, endpoint, requestID, body)
}
return h.gatewayService.ForwardGrokMedia(requestCtx, c, account, endpoint, requestID, body, contentType)
}()
@@ -454,7 +506,11 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
}
// Status poll OR content download can observe official done+video.url.
// Both paths share the same claim key so the customer is charged once.
if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent {
if endpoint == service.SeedanceEndpointStatus {
if billResult := prepareSeedanceCompletionBilling(requestCtx, h, apiKey, subject, requestID, result); billResult != nil {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, requestID)
}
} else if endpoint == service.GrokMediaEndpointVideoStatus || endpoint == service.GrokMediaEndpointVideoContent {
taskID := strings.TrimSpace(requestID)
if billResult := prepareGrokVideoCompletionBilling(requestCtx, h, reqLog, apiKey, subject, taskID, result); billResult != nil {
recordGrokMediaUsage(c, h, reqLog, apiKey, subject, subscription, account, billResult, billResult.Model, body, taskID)
@@ -485,6 +541,9 @@ func (h *OpenAIGatewayHandler) ensureGrokMediaAccountEligibility(ctx context.Con
}
func grokMediaRequiredCapability(endpoint service.GrokMediaEndpoint) service.OpenAIEndpointCapability {
if endpoint.IsSeedance() {
return service.OpenAIEndpointCapabilitySeedance
}
if endpoint.IsGenerationRequest() {
return service.OpenAIEndpointCapabilityGrokMediaGeneration
}
@@ -503,7 +562,7 @@ func grokMediaScheduleModel(account *service.Account, routingModel string, resul
func isGrokVideoCreateEndpoint(endpoint service.GrokMediaEndpoint) bool {
switch endpoint {
case service.GrokMediaEndpointVideosGenerations,
case service.SeedanceEndpointCreate, service.GrokMediaEndpointVideosGenerations,
service.GrokMediaEndpointVideosEdits,
service.GrokMediaEndpointVideosExtensions:
return true
@@ -676,7 +735,7 @@ func recordGrokMediaUsage(
}
// Async video: force durable task request id and release claim if billing fails.
videoTaskID := ""
if result != nil && result.VideoCount > 0 {
if result != nil && (result.VideoCount > 0 || strings.HasPrefix(result.ResponseID, "seedance:")) {
videoTaskID = strings.TrimSpace(firstNonEmptyString(requestID, result.ResponseID))
if stable := service.StableGrokVideoBillingRequestID(firstNonEmptyString(result.ResponseID, requestID)); stable != "" {
result.RequestID = stable
@@ -0,0 +1,422 @@
//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
pending map[string][]byte
}
func (s *grokMediaSlotBindings) SetGrokVideoPendingBilling(_ context.Context, key string, body []byte, _ time.Duration) error {
if s.pending == nil {
s.pending = make(map[string][]byte)
}
s.pending[key] = append([]byte(nil), body...)
return nil
}
func (s *grokMediaSlotBindings) GetGrokVideoPendingBilling(_ context.Context, key string) ([]byte, error) {
return s.pending[key], nil
}
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, platforms ...string) (*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 len(platforms) > 0 {
accounts[i].Platform = platforms[0]
accounts[i].Credentials["base_url"] = "https://ark.cn-beijing.volces.com/api/v3"
accounts[i].Credentials["openai_capabilities"] = []string{"seedance"}
}
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)
}
@@ -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.PlatformMiniMax, 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.PlatformMiniMax)
service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek,
service.PlatformMiniMax, service.PlatformOpenCodeGo)
}
// isResponsesWebSocketCompositePlatform 限定 composite 分组在 Responses WebSocket
@@ -3016,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()
}
@@ -3028,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
@@ -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)
+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)
}
}
@@ -1,7 +1,10 @@
package handler
import (
"strconv"
"github.com/Wei-Shaw/sub2api/internal/handler/dto"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/pkg/response"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
@@ -68,6 +71,39 @@ func (h *RedeemHandler) GetHistory(c *gin.Context) {
return
}
// Keep the legacy array response for clients that do not request pagination.
if c.Request.URL.Query().Has("page") || c.Request.URL.Query().Has("page_size") {
page, pageSize := 1, 20
for name, target := range map[string]*int{"page": &page, "page_size": &pageSize} {
if raw, exists := c.GetQuery(name); exists {
value, err := strconv.Atoi(raw)
if err != nil || value < 1 {
response.BadRequest(c, "Invalid "+name)
return
}
*target = value
}
}
if pageSize > 100 {
pageSize = 100
}
if page-1 > int(^uint(0)>>1)/pageSize {
response.BadRequest(c, "Invalid page")
return
}
codes, result, err := h.redeemService.GetUserHistoryPaginated(c.Request.Context(), subject.UserID, pagination.PaginationParams{Page: page, PageSize: pageSize})
if err != nil {
response.ErrorFrom(c, err)
return
}
out := make([]dto.RedeemCode, 0, len(codes))
for i := range codes {
out = append(out, *dto.RedeemCodeFromService(&codes[i]))
}
response.Paginated(c, out, result.Total, page, pageSize)
return
}
// Default limit is 25
limit := 25
@@ -0,0 +1,89 @@
package handler
import (
"context"
"encoding/json"
"net/http/httptest"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
middleware "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"
)
type historyRepo struct {
service.RedeemCodeRepository
userID int64
params pagination.PaginationParams
legacyLimit int
}
func (r *historyRepo) ListByUser(_ context.Context, userID int64, limit int) ([]service.RedeemCode, error) {
r.userID, r.legacyLimit = userID, limit
return []service.RedeemCode{}, nil
}
func (r *historyRepo) ListByUserPaginated(_ context.Context, userID int64, params pagination.PaginationParams, codeType string) ([]service.RedeemCode, *pagination.PaginationResult, error) {
r.userID, r.params = userID, params
return []service.RedeemCode{}, &pagination.PaginationResult{Total: 101}, nil
}
func TestRedeemHistory(t *testing.T) {
for _, tt := range []struct {
name, query string
userID int64
status, page, size int
}{
{"legacy", "", 7, 200, 0, 0},
{"default", "?page=1", 7, 200, 1, 20},
{"size only", "?page_size=50", 7, 200, 1, 50},
{"second user", "?page=2&page_size=100&user_id=7", 8, 200, 2, 100},
{"cap", "?page_size=101", 7, 200, 1, 100},
{"beyond last", "?page=100&page_size=20", 7, 200, 100, 20},
{"zero", "?page=0", 7, 400, 0, 0},
{"negative", "?page_size=-1", 7, 400, 0, 0},
{"invalid", "?page=abc", 7, 400, 0, 0},
{"overflow", "?page=9223372036854775807", 7, 400, 0, 0},
{"unauthenticated", "?page=1", 0, 401, 0, 0},
} {
t.Run(tt.name, func(t *testing.T) {
repo := &historyRepo{}
h := NewRedeemHandler(service.NewRedeemService(repo, nil, nil, nil, nil, nil, nil, nil))
w := httptest.NewRecorder()
c, _ := gin.CreateTestContext(w)
c.Request = httptest.NewRequest("GET", "/api/v1/redeem/history"+tt.query, nil)
if tt.userID != 0 {
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: tt.userID})
}
h.GetHistory(c)
require.Equal(t, tt.status, w.Code)
if tt.status != 200 {
require.Zero(t, repo.userID)
return
}
require.Equal(t, tt.userID, repo.userID)
var body struct {
Data json.RawMessage `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &body))
if tt.query == "" {
require.Equal(t, 25, repo.legacyLimit)
require.JSONEq(t, "[]", string(body.Data))
return
}
require.Equal(t, tt.page, repo.params.Page)
require.Equal(t, tt.size, repo.params.PageSize)
var data struct {
Items []service.RedeemCode `json:"items"`
Total int `json:"total"`
Page int `json:"page"`
Size int `json:"page_size"`
}
require.NoError(t, json.Unmarshal(body.Data, &data))
require.NotNil(t, data.Items)
require.Equal(t, 101, data.Total)
require.Equal(t, tt.page, data.Page)
require.Equal(t, tt.size, data.Size)
})
}
}
+63
View File
@@ -0,0 +1,63 @@
package handler
import (
"context"
"mime"
"net/http"
"time"
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
)
// SeedanceTasks exposes Ark's native asynchronous video task protocol.
func (h *OpenAIGatewayHandler) SeedanceTasks(c *gin.Context) {
if c.Request.Method == http.MethodPost && c.GetHeader("Content-Type") != "" {
mediaType, _, err := mime.ParseMediaType(c.GetHeader("Content-Type"))
if err != nil || mediaType != "application/json" {
h.errorResponse(c, http.StatusUnsupportedMediaType, "invalid_request_error", "Seedance requires application/json")
return
}
}
key, ok := middleware.GetAPIKeyFromContext(c)
if !ok || key.Group == nil || (key.Group.Platform != service.PlatformOpenAI && key.Group.Platform != service.PlatformComposite) {
h.errorResponse(c, http.StatusForbidden, "permission_error", "Seedance requires an OpenAI or composite group")
return
}
endpoint := service.SeedanceEndpointCreate
setActualUpstreamEndpoint(c, EndpointSeedanceTasks)
taskID := ""
if c.Request.Method != http.MethodPost {
taskID = service.SeedanceTaskKey(c.Param("task_id"))
endpoint = service.SeedanceEndpointStatus
if c.Request.Method == http.MethodDelete {
endpoint = service.SeedanceEndpointDelete
}
}
h.handleGrokMedia(c, endpoint, taskID)
}
// Ark reports actual completion tokens. Never infer tokens from duration or use
// Grok's per-second video tariff. Repeated polls share the durable task dedup key.
func prepareSeedanceCompletionBilling(ctx context.Context, h *OpenAIGatewayHandler, key *service.APIKey, subject middleware.AuthSubject, taskID string, result *service.OpenAIForwardResult) *service.OpenAIForwardResult {
if result == nil || result.Usage.OutputTokens <= 0 {
return nil
}
pending, err := h.gatewayService.LoadGrokVideoPendingBilling(ctx, taskID, subject.UserID, key.ID)
if err != nil || pending == nil {
return nil
}
claimed, err := h.gatewayService.ClaimGrokVideoBilling(ctx, taskID, subject.UserID, key.ID)
if err != nil || !claimed {
return nil
}
merged := *result
merged.Model = pending.Model
merged.BillingModel = firstNonEmptyString(pending.BillingModel, pending.Model)
merged.UpstreamModel = firstNonEmptyString(pending.UpstreamModel, result.UpstreamModel)
merged.RequestID = service.StableGrokVideoBillingRequestID(taskID)
merged.ResponseID = taskID
merged.Duration = service.GrokVideoE2EDuration(pending.CreatedAt, time.Now())
return &merged
}
+97
View File
@@ -0,0 +1,97 @@
//go:build unit
package handler
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
middleware "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 TestSeedanceHandlerLifecycleAndOwnership(t *testing.T) {
h, slots, bindings, upstream := newGrokMediaSlotHandler(t, false, false, service.PlatformOpenAI)
var owner int64
upstream.call = func(req *http.Request, id int64) (*http.Response, error) {
body := `{"id":"task-ark","status":"queued"}`
if req.Method == http.MethodPost {
owner = id
} else {
require.Equal(t, owner, id)
}
return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(body))}, nil
}
newContext := func(method string) (*gin.Context, *httptest.ResponseRecorder) {
c, w := grokMediaSlotContext(context.Background(), method == http.MethodPost)
key, _ := middleware.GetAPIKeyFromContext(c)
key.Group.Platform = service.PlatformOpenAI
body := ""
if method == http.MethodPost {
body = `{"model":"doubao-seedance","content":[{"type":"text","text":"waves"}]}`
}
c.Request = httptest.NewRequest(method, "/api/v3/contents/generations/tasks", strings.NewReader(body))
c.Params = gin.Params{{Key: "task_id", Value: "task-ark"}}
return c, w
}
c, w := newContext(http.MethodPost)
h.SeedanceTasks(c)
require.Equal(t, 200, w.Code, w.Body.String())
require.Positive(t, owner)
require.Len(t, bindings.pending, 1)
slots.assertReleased(t)
for _, method := range []string{http.MethodGet, http.MethodDelete} {
c, w = newContext(method)
h.SeedanceTasks(c)
require.Equal(t, 200, w.Code, w.Body.String())
slots.assertReleased(t)
}
for _, other := range []string{"user", "key", "group", "task", "provider"} {
c, w = newContext(http.MethodGet)
key, _ := middleware.GetAPIKeyFromContext(c)
switch other {
case "user":
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: 11, Concurrency: 5})
case "key":
key.ID = 21
case "group":
group := int64(25)
key.GroupID = &group
case "task":
c.Params = gin.Params{{Key: "task_id", Value: "other"}}
case "provider":
c.Params = gin.Params{{Key: "request_id", Value: "task-ark"}}
}
before := upstream.calls
if other == "provider" {
h.GrokVideoStatus(c)
} else {
h.SeedanceTasks(c)
}
require.Equal(t, 404, w.Code, other+": "+w.Body.String())
require.Equal(t, before, upstream.calls)
slots.assertReleased(t)
}
c, _ = newContext(http.MethodGet)
key, _ := middleware.GetAPIKeyFromContext(c)
subject, _ := middleware.GetAuthSubjectFromContext(c)
result := &service.OpenAIForwardResult{Usage: service.OpenAIUsage{OutputTokens: 12345}, ResponseID: "seedance:task-ark"}
for i := range 20 {
billed := prepareSeedanceCompletionBilling(context.Background(), h, key, subject, result.ResponseID, result)
if i == 0 {
require.NotNil(t, billed)
require.Equal(t, "doubao-seedance", billed.BillingModel)
require.Equal(t, 12345, billed.Usage.OutputTokens)
require.Zero(t, billed.VideoCount)
} else {
require.Nil(t, billed)
}
}
require.Len(t, bindings.billed, 1)
}
@@ -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(),
@@ -111,6 +112,7 @@ func (h *SettingHandler) GetPublicSettings(c *gin.Context) {
ChannelMonitorHideUserRanking: settings.ChannelMonitorHideUserRanking,
AvailableChannelsEnabled: settings.AvailableChannelsEnabled,
SubscriptionEnabled: settings.SubscriptionEnabled,
ModelPlazaEnabled: settings.ModelPlazaEnabled,
ModelPlazaRequireAuth: settings.ModelPlazaRequireAuth,
@@ -33,10 +33,13 @@ type responsesFailedBody struct {
}
// responsesFailedEvent 是写入 SSE data 行的顶层结构。
// 故意不带 sequence_number:spec 标记可选,且本函数被调用时无法可靠拿到 last seq。
// SequenceNumber 不带 omitempty:grok-build 等 Rust serde 客户端把它当必填字段,
// 缺失即 `missing field 'sequence_number'`。拿不到上一帧序号时写 0——这是终止事件,
// 后面不会再续流。
type responsesFailedEvent struct {
Type string `json:"type"`
Response responsesFailedBody `json:"response"`
Type string `json:"type"`
SequenceNumber int `json:"sequence_number"`
Response responsesFailedBody `json:"response"`
}
// writeResponsesFailedSSE emits a `response.failed` SSE event in the OpenAI
@@ -49,8 +52,7 @@ type responsesFailedEvent struct {
// 而抛出 "stream closed before response.completed"。
//
// 字段集对齐 apicompat.makeResponsesCompletedEvent:id/object/model/status/output/error。
// 故意不写 sequence_number:本函数被调用时无法可靠拿到当前流的 last sequence,
// 而 OpenAI spec 将 sequence_number 设为可选;省略避免破坏单调性约束。
// sequence_number 始终写出(未知时为 0):OpenAI spec 标可选,但 grok-build 当必填。
//
// 返回 true 表示已尝试 SSE 写出(不论 Write 是否成功,caller 都应直接 return)。
// 返回 false 表示 writer 不支持 Flusher,无法以 SSE 形式回报错误;
@@ -48,9 +48,12 @@ func parseResponsesFailedSSE(t *testing.T, body string) (map[string]any, map[str
require.NoError(t, json.Unmarshal([]byte(jsonStr), &parsed), "data must be valid JSON: %s", jsonStr)
assert.Equal(t, "response.failed", parsed["type"])
// 故意不发 sequence_number,避免与后续真实事件的序号冲突。
_, hasSeq := parsed["sequence_number"]
assert.False(t, hasSeq, "synthetic event must not emit sequence_number")
// grok-build 把 sequence_number 当必填;合成终止事件未知上一帧时写 0。
rawSeq, hasSeq := parsed["sequence_number"]
assert.True(t, hasSeq, "synthetic event must emit sequence_number")
seq, ok := rawSeq.(float64)
assert.True(t, ok, "sequence_number must be a number, got %T", rawSeq)
assert.GreaterOrEqual(t, seq, float64(0))
resp, ok := parsed["response"].(map[string]any)
require.True(t, ok, "response object missing")
@@ -316,7 +319,20 @@ func TestOpenAIHandleStreamingAwareError_BareResponsesRouteEmitsResponseFailed(t
assert.Equal(t, "rate_limit_exceeded", errObj["code"])
}
// Synthesized response.failed id falls back to uuid when no request_id is present.
// issue #7128:grok-build 把顶层 sequence_number 当必填,合成的 response.failed
// 必须写出该字段,否则整轮反序列化失败。
func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesSequenceNumber(t *testing.T) {
c, w := newGinContextForEndpoint(t, EndpointResponses)
h := &OpenAIGatewayHandler{}
h.handleStreamingAwareError(c, http.StatusBadGateway, "upstream_error", "boom", true)
require.True(t, strings.HasPrefix(w.Body.String(), "event: response.failed\n"))
_, payload, found := strings.Cut(w.Body.String(), "data: ")
require.True(t, found)
require.True(t, gjson.Get(payload, "sequence_number").Exists(), "response.failed 必须带 sequence_number")
require.GreaterOrEqual(t, gjson.Get(payload, "sequence_number").Int(), int64(0))
}
// issue #5601:严格的 Responses 客户端把 created_at 当必填字段,缺失即
// `missing field 'created_at'`。合成的终止事件若解析不了,本文件存在的意义
// (给客户端一个可识别的终止事件而不是盲重连)就落空了。
@@ -333,6 +349,7 @@ func TestOpenAIHandleStreamingAwareError_ResponsesStreamingCarriesCreatedAt(t *t
assert.Greater(t, int64(createdAt), int64(0), "created_at 必须是有效的 unix 时间戳")
}
// Synthesized response.failed id falls back to uuid when no request_id is present.
func TestSynthesizeResponseID_FallbackUUID(t *testing.T) {
c, _ := newGinContextForEndpoint(t, EndpointResponses)
id := synthesizeResponseID(c)
@@ -45,6 +45,7 @@ const (
PlatformZhipu = domain.PlatformZhipu
PlatformDeepseek = domain.PlatformDeepseek
PlatformMiniMax = domain.PlatformMiniMax
PlatformOpenCodeGo = domain.PlatformOpenCodeGo
)
// AllPlatforms 返回所有支持的平台列表
@@ -59,6 +60,7 @@ func AllPlatforms() []string {
PlatformZhipu,
PlatformDeepseek,
PlatformMiniMax,
PlatformOpenCodeGo,
}
}
@@ -17,5 +17,6 @@ func TestAllPlatformsIncludesEveryConcretePlatform(t *testing.T) {
"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)
}
}
@@ -0,0 +1,88 @@
package antigravity
import (
"encoding/json"
"strings"
"testing"
"github.com/stretchr/testify/require"
)
func TestTransformClaudeToGemini_AttributionSystemText(t *testing.T) {
const attribution = "x-anthropic-billing-header: cc_version=2.1.271.4bf; cc_entrypoint=claude-desktop-3p;"
tests := []struct {
name string
text string
want string
}{
{"metadata only", attribution, ""},
{"metadata with newline", attribution + "\n", ""},
{"leading whitespace", " \t\n" + attribution, ""},
{"following instructions", attribution + "\nKeep these instructions.", "Keep these instructions."},
{"CRLF", attribution + "\r\n Keep indentation.\n", " Keep indentation.\n"},
{"CR", attribution + "\rKeep these instructions.", "Keep these instructions."},
{"blank lines preserved", attribution + "\n\nKeep these instructions.", "\nKeep these instructions."},
{"ordinary text", " Keep these instructions.\n", " Keep these instructions.\n"},
{"no colon", "x-anthropic-billing-header keep", "x-anthropic-billing-header keep"},
{"different field", "x-anthropic-billing-header-extra: keep", "x-anthropic-billing-header-extra: keep"},
{"quoted within instructions", "Explain this metadata: " + attribution, "Explain this metadata: " + attribution},
{"later line", "Example:\n" + attribution, "Example:\n" + attribution},
}
for _, tt := range tests {
for _, arrayForm := range []bool{false, true} {
name := tt.name + "/string"
var system any = tt.text
if arrayForm {
name = tt.name + "/array"
system = []SystemBlock{{Type: "text", Text: tt.text}, {Type: "text", Text: "Keep the next block."}}
}
t.Run(name, func(t *testing.T) {
systemJSON, err := json.Marshal(system)
require.NoError(t, err)
userJSON, err := json.Marshal(attribution)
require.NoError(t, err)
input := &ClaudeRequest{
Model: "gemini-3.8-flash-high", System: systemJSON,
Messages: []ClaudeMessage{{Role: "user", Content: userJSON}},
Tools: []ClaudeTool{{Name: "example", Description: attribution,
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"query": map[string]any{"type": "string"}}}}},
}
before, err := json.Marshal(input)
require.NoError(t, err)
body, err := TransformClaudeToGeminiWithOptions(input, "test-project", input.Model, TransformOptions{})
require.NoError(t, err)
var got V1InternalRequest
require.NoError(t, json.Unmarshal(body, &got))
var texts []string
for _, part := range got.Request.SystemInstruction.Parts {
if part.Text != "\n--- [SYSTEM_PROMPT_END] ---" {
texts = append(texts, part.Text)
}
}
var want []string
if tt.want != "" {
want = append(want, tt.want)
}
if arrayForm {
want = append(want, "Keep the next block.")
}
require.Equal(t, want, texts)
// The same literal text in user content and tool descriptions is not metadata.
require.Equal(t, attribution, got.Request.Contents[0].Parts[0].Text)
require.Equal(t, attribution, got.Request.Tools[0].FunctionDeclarations[0].Description)
after, err := json.Marshal(input)
require.NoError(t, err)
require.JSONEq(t, string(before), string(after), "do not mutate the incoming request")
})
}
}
}
func TestTransformClaudeToGemini_AttributionDoesNotHideIdentity(t *testing.T) {
system, err := json.Marshal("x-anthropic-billing-header: cc_version=example;\nYou are Antigravity. Keep this identity.")
require.NoError(t, err)
got := buildSystemInstruction(system, "gemini-3.8-flash-high", DefaultTransformOptions(), nil)
require.Len(t, got.Parts, 1)
require.Equal(t, "You are Antigravity. Keep this identity.", got.Parts[0].Text)
require.False(t, strings.Contains(got.Parts[0].Text, "cc_version="))
}
@@ -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
@@ -296,6 +293,26 @@ func filterOpenCodePrompt(text string) string {
return ""
}
// stripClaudeAttribution removes the leading Claude Code attribution metadata line
// from Antigravity system text. This is prompt metadata, not an HTTP header; it
// can trigger RESOURCE_EXHAUSTED on the Google upstream. Keep this scoped to the
// Antigravity transformer: native Anthropic OAuth may require the attribution.
func stripClaudeAttribution(text string) string {
trimmed := strings.TrimLeft(text, " \t\r\n")
if !strings.HasPrefix(trimmed, "x-anthropic-billing-header:") {
return text
}
end := strings.IndexAny(trimmed, "\r\n")
if end < 0 {
return ""
}
rest := trimmed[end+1:]
if trimmed[end] == '\r' {
rest = strings.TrimPrefix(rest, "\n")
}
return rest
}
// buildSystemInstruction 构建 systemInstruction(与 Antigravity-Manager 保持一致)
func buildSystemInstruction(system json.RawMessage, modelName string, opts TransformOptions, tools []ClaudeTool) *GeminiContent {
var parts []GeminiPart
@@ -308,6 +325,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
// 尝试解析为字符串
var sysStr string
if err := json.Unmarshal(system, &sysStr); err == nil {
sysStr = stripClaudeAttribution(sysStr)
if strings.TrimSpace(sysStr) != "" {
if strings.Contains(sysStr, "You are Antigravity") {
userHasAntigravityIdentity = true
@@ -323,6 +341,7 @@ func buildSystemInstruction(system json.RawMessage, modelName string, opts Trans
var sysBlocks []SystemBlock
if err := json.Unmarshal(system, &sysBlocks); err == nil {
for _, block := range sysBlocks {
block.Text = stripClaudeAttribution(block.Text)
if block.Type == "text" && strings.TrimSpace(block.Text) != "" {
if strings.Contains(block.Text, "You are Antigravity") {
userHasAntigravityIdentity = true
@@ -696,6 +715,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 +747,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 +812,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))
@@ -132,6 +132,31 @@ func TestWire_UnknownEventFallsBackToDefault(t *testing.T) {
require.Contains(t, m, "response")
}
// grok-build 把 sequence_number 当必填。response.created 从 0 起号,
// omitempty 会把 0 整段丢掉,第一帧就反序列化失败。
func TestWire_SequenceNumberPresentAtZero(t *testing.T) {
created := marshalEvent(t, ResponsesStreamEvent{
Type: "response.created",
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "in_progress"},
})
require.Contains(t, created, "sequence_number")
require.EqualValues(t, 0, created["sequence_number"])
completed := marshalEvent(t, ResponsesStreamEvent{
Type: "response.completed",
SequenceNumber: 0,
Response: &ResponsesResponse{ID: "resp_1", Object: "response", Status: "completed"},
})
require.Contains(t, completed, "sequence_number")
require.EqualValues(t, 0, completed["sequence_number"])
delta := marshalEvent(t, ResponsesStreamEvent{
Type: "response.output_text.delta", OutputIndex: 0, ContentIndex: 0, ItemID: "msg_1", Delta: "hi",
})
require.Contains(t, delta, "sequence_number")
require.EqualValues(t, 0, delta["sequence_number"])
}
func TestResponsesOutputUnmarshal_ToolSearchObjectArguments(t *testing.T) {
var item ResponsesOutput
require.NoError(t, json.Unmarshal([]byte(`{
@@ -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 {
@@ -646,6 +646,9 @@ func convertResponsesToAnthropicTools(tools []ResponsesTool) []AnthropicTool {
}
// normalizeAnthropicInputSchema ensures input_schema is a valid object schema.
// Codex 会把部分内置工具(例如 codex_app 的 automation_update)的 parameters
// 根节点声明成对象分支的 oneOf/anyOf,Anthropic 只接受 object 根节点,这里把
// 顶层联合摊平成单个 object schema。
func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
const emptyObjectSchema = `{"type":"object","properties":{}}`
@@ -659,6 +662,8 @@ func normalizeAnthropicInputSchema(schema json.RawMessage) json.RawMessage {
return json.RawMessage(`{"type":"object","properties":{}}`)
}
flattenAnthropicRootUnions(m)
typeRaw, ok := m["type"]
if !ok || strings.TrimSpace(string(typeRaw)) == "" || string(typeRaw) == "null" {
m["type"] = json.RawMessage(`"object"`)
@@ -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)
}
@@ -0,0 +1,213 @@
package apicompat
import (
"bytes"
"encoding/json"
)
// anthropicInputSchemaUnionKeywords 是 Anthropic 在工具 input_schema 顶层拒绝的
// JSON Schema 联合关键字;嵌套层不受限制,因此只摊平根节点。
var anthropicInputSchemaUnionKeywords = []string{"oneOf", "anyOf", "allOf"}
// flattenAnthropicRootUnions 用单一 object schema 替换顶层的 oneOf/anyOf/allOf:
// 属性取各分支并集,同名属性用嵌套联合保留两侧约束;required 在 oneOf/anyOf
// 下取分支交集、在 allOf 下取分支并集。非对象分支无法用 Anthropic 的 object
// schema 表达,视为不贡献属性与必填字段。
func flattenAnthropicRootUnions(schema map[string]json.RawMessage) {
present := make([]string, 0, len(anthropicInputSchemaUnionKeywords))
for _, keyword := range anthropicInputSchemaUnionKeywords {
if _, ok := schema[keyword]; ok {
present = append(present, keyword)
}
}
if len(present) == 0 {
return
}
properties := anthropicSchemaProperties(schema)
rootRequired := anthropicBranchRequired(schema)
var (
intersections [][]string
allOfRequired []string
)
for _, keyword := range present {
raw := schema[keyword]
delete(schema, keyword)
branches, ok := decodeAnthropicUnionBranches(raw)
if !ok {
continue
}
mergeAnthropicObjectBranchProperties(properties, branches)
if keyword == "allOf" {
for _, branch := range branches {
allOfRequired = appendAnthropicRequired(allOfRequired, anthropicBranchRequired(branch))
}
continue
}
intersections = append(intersections, intersectAnthropicRequired(branches))
}
if len(properties) > 0 {
if encoded, err := json.Marshal(properties); err == nil {
schema["properties"] = encoded
}
}
required := intersectAnthropicRequiredLists(intersections)
required = appendAnthropicRequired(required, allOfRequired)
required = appendAnthropicRequired(required, rootRequired)
if len(required) > 0 {
if encoded, err := json.Marshal(required); err == nil {
schema["required"] = encoded
}
}
}
func anthropicSchemaProperties(schema map[string]json.RawMessage) map[string]json.RawMessage {
properties := make(map[string]json.RawMessage)
raw, ok := schema["properties"]
if !ok {
return properties
}
if err := json.Unmarshal(raw, &properties); err != nil || properties == nil {
return make(map[string]json.RawMessage)
}
return properties
}
// decodeAnthropicUnionBranches 解析联合分支并递归摊平分支自身的顶层联合。
// 非对象 JSON 分支以 nil 占位:它不贡献属性,也不要求任何字段。
func decodeAnthropicUnionBranches(raw json.RawMessage) ([]map[string]json.RawMessage, bool) {
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil || len(items) == 0 {
return nil, false
}
branches := make([]map[string]json.RawMessage, 0, len(items))
for _, item := range items {
var branch map[string]json.RawMessage
if err := json.Unmarshal(item, &branch); err != nil || branch == nil {
branches = append(branches, nil)
continue
}
flattenAnthropicRootUnions(branch)
branches = append(branches, branch)
}
return branches, true
}
func mergeAnthropicObjectBranchProperties(into map[string]json.RawMessage, branches []map[string]json.RawMessage) {
for _, branch := range branches {
if !anthropicSchemaBranchIsObject(branch) {
continue
}
raw, ok := branch["properties"]
if !ok {
continue
}
var properties map[string]json.RawMessage
if err := json.Unmarshal(raw, &properties); err != nil {
continue
}
for name, property := range properties {
existing, seen := into[name]
if !seen {
into[name] = property
continue
}
if anthropicSchemaJSONEqual(existing, property) {
continue
}
merged, err := json.Marshal(map[string][]json.RawMessage{"anyOf": {existing, property}})
if err != nil {
continue
}
into[name] = merged
}
}
}
// anthropicSchemaBranchIsObject 判断分支是否可能是对象:显式 type=object,或省略
// type 但声明了 properties(缺失 type 等价于不约束,不能据此排除对象分支)。
func anthropicSchemaBranchIsObject(branch map[string]json.RawMessage) bool {
if branch == nil {
return false
}
if raw, ok := branch["type"]; ok {
var typ string
return json.Unmarshal(raw, &typ) == nil && typ == "object"
}
_, ok := branch["properties"]
return ok
}
func anthropicBranchRequired(branch map[string]json.RawMessage) []string {
if branch == nil {
return nil
}
raw, ok := branch["required"]
if !ok {
return nil
}
var required []string
if err := json.Unmarshal(raw, &required); err != nil {
return nil
}
return required
}
// intersectAnthropicRequired 返回所有分支共同要求的字段,顺序沿用第一个分支。
func intersectAnthropicRequired(branches []map[string]json.RawMessage) []string {
lists := make([][]string, 0, len(branches))
for _, branch := range branches {
lists = append(lists, anthropicBranchRequired(branch))
}
return intersectAnthropicRequiredLists(lists)
}
func intersectAnthropicRequiredLists(lists [][]string) []string {
if len(lists) == 0 {
return nil
}
result := lists[0]
for _, list := range lists[1:] {
if len(result) == 0 {
return nil
}
next := make([]string, 0, len(result))
for _, name := range result {
if containsAnthropicName(list, name) {
next = append(next, name)
}
}
result = next
}
return result
}
func appendAnthropicRequired(required []string, names []string) []string {
merged := make([]string, 0, len(required))
merged = append(merged, required...)
for _, name := range names {
if !containsAnthropicName(merged, name) {
merged = append(merged, name)
}
}
return merged
}
func containsAnthropicName(names []string, target string) bool {
for _, name := range names {
if name == target {
return true
}
}
return false
}
func anthropicSchemaJSONEqual(left, right json.RawMessage) bool {
var leftBuf, rightBuf bytes.Buffer
if json.Compact(&leftBuf, left) != nil || json.Compact(&rightBuf, right) != nil {
return false
}
return leftBuf.String() == rightBuf.String()
}
@@ -138,3 +138,180 @@ func TestResponsesToAnthropic_DefaultToolNormalizesInputSchema(t *testing.T) {
assert.Equal(t, "shell", tools[0].Name)
assert.JSONEq(t, `{"type":"object","properties":{}}`, string(tools[0].InputSchema))
}
// Codex 的 codex_app 命名空间工具(如 automation_update)把 parameters 根节点声明为
// 对象分支的 oneOf/anyOf;Anthropic 拒绝 input_schema 顶层的 oneOf/anyOf/allOf,
// 转换时必须摊平成单个 object schema。
func TestResponsesToAnthropic_ObjectUnionRootFlattened(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "codex_app__automation_update",
Parameters: json.RawMessage(`{
"oneOf": [
{"type":"object","properties":{"id":{"type":"string"}}},
{"anyOf":[{"type":"object"},{"type":"object","properties":{}}]}
]
}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "oneOf")
assert.NotContains(t, schema, "anyOf")
assert.NotContains(t, schema, "allOf")
assert.JSONEq(t, `{"id":{"type":"string"}}`, string(schema["properties"]))
assert.NotContains(t, schema, "required")
}
func TestResponsesToAnthropic_ObjectUnionRootMergesBranches(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "codex_app__automation_update",
Parameters: json.RawMessage(`{
"anyOf": [
{"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]},
{"type":"object","properties":{"mode":{"enum":["update"]},"prompt":{"type":"string"}},"required":["mode","prompt"]}
]
}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "anyOf")
// 属性取各分支并集;同名属性保留两侧约束(嵌套联合本身是允许的)。
var properties map[string]json.RawMessage
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
require.Contains(t, properties, "id")
require.Contains(t, properties, "prompt")
var mode map[string]json.RawMessage
require.NoError(t, json.Unmarshal(properties["mode"], &mode))
assert.Contains(t, mode, "anyOf")
// required 取各分支交集,只保留所有分支都要求的字段。
assert.JSONEq(t, `["mode"]`, string(schema["required"]))
}
func TestResponsesToAnthropic_AllOfRootKeepsUnionRequired(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "strict_tool",
Parameters: json.RawMessage(`{
"allOf": [
{"type":"object","properties":{"path":{"type":"string"}},"required":["path"]},
{"type":"object","properties":{"encoding":{"type":"string"}},"required":["encoding"]}
]
}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "allOf")
assert.JSONEq(t, `["path","encoding"]`, string(schema["required"]))
}
// 非对象分支无法用 Anthropic 的 object schema 表达,直接丢弃分支但保留属性词汇。
func TestResponsesToAnthropic_RootUnionDropsNonObjectBranches(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "mixed_tool",
Parameters: json.RawMessage(`{"oneOf":[{"type":"object","properties":{"path":{"type":"string"}}},{"type":"string"}]}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "oneOf")
var properties map[string]json.RawMessage
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
assert.Contains(t, properties, "path")
}
func TestResponsesToAnthropic_NestedUnionPreserved(t *testing.T) {
parameters := json.RawMessage(`{"type":"object","properties":{"value":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}]}}}`)
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "get_value",
Parameters: parameters,
}})
require.Len(t, tools, 1)
assert.JSONEq(t, string(parameters), string(tools[0].InputSchema))
}
// 根节点自带 properties/required 时,与分支约束合并而不是被覆盖。
func TestResponsesToAnthropic_RootUnionKeepsRootPropertiesAndRequired(t *testing.T) {
tools := convertResponsesToAnthropicTools([]ResponsesTool{{
Type: "function",
Name: "merge_root_tool",
Parameters: json.RawMessage(`{
"type":"object",
"properties":{"shared":{"type":"string"}},
"required":["shared"],
"oneOf":[{"type":"object","properties":{"extra":{"type":"integer"}}}]
}`),
}})
require.Len(t, tools, 1)
schema := requireObjectInputSchema(t, tools[0].InputSchema)
assert.NotContains(t, schema, "oneOf")
var properties map[string]json.RawMessage
require.NoError(t, json.Unmarshal(schema["properties"], &properties))
assert.Contains(t, properties, "shared")
assert.Contains(t, properties, "extra")
assert.JSONEq(t, `["shared"]`, string(schema["required"]))
}
// 端到端:Codex 命名空间工具经降级/摊平后,最终 Anthropic 工具 schema 顶部不再有联合关键字。
func TestResponsesToAnthropic_CodexNamespaceRootUnionTool(t *testing.T) {
body := []byte(`{
"model": "claude-opus-5",
"input": "create an automation",
"tools": [{
"type": "namespace",
"name": "codex_app",
"tools": [{
"type": "function",
"name": "automation_update",
"description": "Create, update, view, or delete recurring automations",
"parameters": {
"oneOf": [
{"type":"object","properties":{"mode":{"enum":["view"]},"id":{"type":"string"}},"required":["mode","id"]},
{"type":"object","properties":{"mode":{"enum":["update"]},"id":{"type":"string"}},"required":["mode","id"]}
]
}
}]
}]
}`)
var requestBody map[string]any
require.NoError(t, json.Unmarshal(body, &requestBody))
_, changed, err := AdaptResponsesClientTools(requestBody)
require.NoError(t, err)
require.True(t, changed)
adapted, err := json.Marshal(requestBody)
require.NoError(t, err)
var responsesReq ResponsesRequest
require.NoError(t, json.Unmarshal(adapted, &responsesReq))
anthropicReq, err := ResponsesToAnthropicRequest(&responsesReq)
require.NoError(t, err)
require.Len(t, anthropicReq.Tools, 1)
tool := anthropicReq.Tools[0]
assert.Equal(t, "codex_app__automation_update", tool.Name)
schema := requireObjectInputSchema(t, tool.InputSchema)
assert.NotContains(t, schema, "oneOf")
assert.NotContains(t, schema, "anyOf")
assert.NotContains(t, schema, "allOf")
assert.JSONEq(t, `["mode","id"]`, string(schema["required"]))
wire, err := json.Marshal(tool)
require.NoError(t, err)
assert.NotContains(t, string(wire), `"oneOf"`)
assert.NotContains(t, string(wire), `"allOf"`)
// 同名属性两侧的约束用嵌套联合保留,只允许出现在 properties 内部。
assert.Contains(t, string(wire), `"mode":{"anyOf":[{"enum":["view"]},{"enum":["update"]}]}`)
}
@@ -0,0 +1,167 @@
package apicompat
import (
"encoding/json"
"fmt"
"strings"
)
type responsesToolOutputMedia struct {
callID string
imageURL string
}
// LiftResponsesToolOutputMedia moves image parts out of Responses tool outputs
// and into a following user message. Native Responses endpoints such as
// DeepSeek accept function_call_output.output as a string, but Codex view_image
// returns an array containing input_image. Keeping the image inside the tool
// output makes the upstream report "No tool output found for tool call ...".
func LiftResponsesToolOutputMedia(input any) (any, bool) {
items, ok := input.([]any)
if !ok {
return input, false
}
rewritten := make([]any, 0, len(items)+1)
changed := false
// DeepSeek validates parallel tool outputs as one contiguous run. Codex can
// inject developer notices between image outputs, so keep those notices
// pending until the full batch and its lifted media have been emitted.
for index := 0; index < len(items); {
item, ok := items[index].(map[string]any)
if !ok || !isResponsesToolOutputItem(item) {
rewritten = append(rewritten, items[index])
index++
continue
}
batchStart := index
outputs := make([]any, 0)
trailing := make([]any, 0)
pending := make([]responsesToolOutputMedia, 0)
batchChanged := false
for index < len(items) {
rawItem := items[index]
item, ok := rawItem.(map[string]any)
if !ok {
break
}
if isResponsesToolOutputItem(item) {
rewrittenItem, media, didRewrite := liftResponsesToolOutputMediaItem(item)
if didRewrite {
batchChanged = true
pending = append(pending, media...)
}
outputs = append(outputs, rewrittenItem)
index++
continue
}
if isResponsesToolBatchInstruction(item) {
trailing = append(trailing, rawItem)
index++
continue
}
break
}
if !batchChanged {
rewritten = append(rewritten, items[batchStart:index]...)
continue
}
rewritten = append(rewritten, outputs...)
if len(pending) > 0 {
rewritten = append(rewritten, buildResponsesToolOutputMediaMessage(pending))
}
rewritten = append(rewritten, trailing...)
changed = true
}
if !changed {
return input, false
}
return rewritten, true
}
func liftResponsesToolOutputMediaItem(item map[string]any) (any, []responsesToolOutputMedia, bool) {
output, exists := item["output"]
if !exists {
return item, nil, false
}
outputRaw, err := json.Marshal(output)
if err != nil {
return item, nil, false
}
outputText, media, didRewrite := extractToolOutputMedia(outputRaw)
if !didRewrite {
return item, nil, false
}
item["output"] = outputText
callID := strings.TrimSpace(stringValue(item["call_id"]))
lifted := make([]responsesToolOutputMedia, 0, len(media))
for _, part := range media {
if part.ImageURL == nil {
continue
}
imageURL := strings.TrimSpace(part.ImageURL.URL)
if imageURL == "" {
continue
}
lifted = append(lifted, responsesToolOutputMedia{
callID: callID,
imageURL: imageURL,
})
}
return item, lifted, true
}
func buildResponsesToolOutputMediaMessage(pending []responsesToolOutputMedia) map[string]any {
content := make([]map[string]any, 0, len(pending)*2)
lastCallID := ""
for _, media := range pending {
if media.callID != lastCallID {
text := "Tool output media"
if media.callID != "" {
text = fmt.Sprintf(toolOutputMediaAttribution, media.callID)
}
content = append(content, map[string]any{
"type": "input_text",
"text": text,
})
lastCallID = media.callID
}
content = append(content, map[string]any{
"type": "input_image",
"image_url": media.imageURL,
})
}
return map[string]any{
"type": "message",
"role": "user",
"content": content,
}
}
func isResponsesToolBatchInstruction(item map[string]any) bool {
itemType := strings.TrimSpace(stringValue(item["type"]))
if itemType != "" && itemType != "message" {
return false
}
role := strings.TrimSpace(stringValue(item["role"]))
return role == "developer" || role == "system"
}
func isResponsesToolOutputItem(item map[string]any) bool {
switch strings.TrimSpace(stringValue(item["type"])) {
case "function_call_output", "custom_tool_call_output",
"tool_search_output", "tool_search_call_output", "mcp_tool_call_output":
return true
default:
return false
}
}
@@ -0,0 +1,116 @@
package apicompat
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/require"
)
func TestLiftResponsesToolOutputMedia(t *testing.T) {
var input any
require.NoError(t, json.Unmarshal([]byte(`[
{"type":"function_call","call_id":"call_image","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_image","output":[{"type":"input_image","image_url":"data:image/png;base64,AQID"}]}
]`), &input))
lifted, changed := LiftResponsesToolOutputMedia(input)
require.True(t, changed)
items, ok := lifted.([]any)
require.True(t, ok)
require.Len(t, items, 3)
outputItem, ok := items[1].(map[string]any)
require.True(t, ok)
outputText, ok := outputItem["output"].(string)
require.True(t, ok)
require.Contains(t, outputText, toolOutputMediaMarker)
require.NotContains(t, outputText, "data:image/png")
mediaMessage, ok := items[2].(map[string]any)
require.True(t, ok)
require.Equal(t, "message", mediaMessage["type"])
require.Equal(t, "user", mediaMessage["role"])
parts, ok := mediaMessage["content"].([]map[string]any)
require.True(t, ok)
require.Len(t, parts, 2)
require.Equal(t, "input_text", parts[0]["type"])
require.Equal(t, "[Tool output media for call call_image]", parts[0]["text"])
require.Equal(t, "input_image", parts[1]["type"])
require.Equal(t, "data:image/png;base64,AQID", parts[1]["image_url"])
}
func TestLiftResponsesToolOutputMediaLeavesPlainOutputUntouched(t *testing.T) {
input := []any{
map[string]any{
"type": "function_call_output",
"call_id": "call_text",
"output": "plain output",
},
}
lifted, changed := LiftResponsesToolOutputMedia(input)
require.False(t, changed)
require.Equal(t, input, lifted)
}
func TestLiftResponsesToolOutputMediaKeepsParallelBatchContiguous(t *testing.T) {
var input any
require.NoError(t, json.Unmarshal([]byte(`[
{"type":"function_call","call_id":"call_A","name":"view_image","arguments":"{}"},
{"type":"function_call","call_id":"call_B","name":"view_image","arguments":"{}"},
{"type":"function_call","call_id":"call_C","name":"view_image","arguments":"{}"},
{"type":"function_call_output","call_id":"call_A","output":[{"type":"input_image","image_url":"data:image/png;base64,QQ=="}]},
{"type":"message","role":"developer","content":[{"type":"input_text","text":"resized A"}]},
{"type":"function_call_output","call_id":"call_B","output":[{"type":"input_image","image_url":"data:image/png;base64,Qg=="}]},
{"type":"message","role":"developer","content":[{"type":"input_text","text":"resized B"}]},
{"type":"function_call_output","call_id":"call_C","output":[{"type":"input_image","image_url":"data:image/png;base64,Qw=="}]},
{"type":"message","role":"developer","content":[{"type":"input_text","text":"resized C"}]}
]`), &input))
lifted, changed := LiftResponsesToolOutputMedia(input)
require.True(t, changed)
items, ok := lifted.([]any)
require.True(t, ok)
require.Len(t, items, 10)
for index, callID := range []string{"call_A", "call_B", "call_C"} {
item, ok := items[index+3].(map[string]any)
require.True(t, ok)
require.Equal(t, "function_call_output", item["type"])
require.Equal(t, callID, item["call_id"])
outputText, ok := item["output"].(string)
require.True(t, ok)
require.Contains(t, outputText, toolOutputMediaMarker)
require.NotContains(t, outputText, "data:image/png")
}
mediaMessage, ok := items[6].(map[string]any)
require.True(t, ok)
require.Equal(t, "message", mediaMessage["type"])
require.Equal(t, "user", mediaMessage["role"])
parts, ok := mediaMessage["content"].([]map[string]any)
require.True(t, ok)
require.Len(t, parts, 6)
require.Equal(t, "[Tool output media for call call_A]", parts[0]["text"])
require.Equal(t, "data:image/png;base64,QQ==", parts[1]["image_url"])
require.Equal(t, "[Tool output media for call call_B]", parts[2]["text"])
require.Equal(t, "data:image/png;base64,Qg==", parts[3]["image_url"])
require.Equal(t, "[Tool output media for call call_C]", parts[4]["text"])
require.Equal(t, "data:image/png;base64,Qw==", parts[5]["image_url"])
for index, text := range []string{"resized A", "resized B", "resized C"} {
notice, ok := items[index+7].(map[string]any)
require.True(t, ok)
require.Equal(t, "developer", notice["role"])
content, ok := notice["content"].([]any)
require.True(t, ok)
require.Len(t, content, 1)
contentPart, ok := content[0].(map[string]any)
require.True(t, ok)
require.Equal(t, text, contentPart["text"])
}
}
+5 -2
View File
@@ -642,8 +642,11 @@ type ResponsesStreamEvent struct {
Code string `json:"code,omitempty"`
Param string `json:"param,omitempty"`
// Sequence number for ordering events
SequenceNumber int `json:"sequence_number,omitempty"`
// SequenceNumber orders streamed events. Strict Responses clients (Grok Build,
// Codex CLI) declare it required and abort with `missing field 'sequence_number'`
// when it is absent, so it is always emitted — no omitempty. Same rule as
// ResponsesResponse.CreatedAt. Zero is a valid first-event value.
SequenceNumber int `json:"sequence_number"`
}
// ---------------------------------------------------------------------------
+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)
}
}
})
}
}
+65 -1
View File
@@ -1211,11 +1211,20 @@ func (r *accountRepository) ListOAuthRefreshCandidatePage(ctx context.Context, o
// NOT (a AND b) 在 PG 三值逻辑下会把 a 或 b 为 NULL 的行(即绝大多数
// 健康账号:temp_unschedulable_until=NULL)也排除,导致后台 token
// 刷新工作器漏掉所有正常账号 → access_token 到期后请求开始 401。
//
// Deliberately NO `schedulable = TRUE` filter here: paused accounts
// (schedulable=false, status=active) still hold valid refresh tokens and
// their stored access_token must keep working for the admin usage-window
// probe. Excluding them lets the token silently expire, after which the
// dashboard reports a false "needs re-auth" even though Test Connection
// (which refreshes on demand) succeeds. Permanent rejection is already
// covered by the status = 'active' filter (error accounts drop out), and
// accounts whose refresh actually fails are rate-limited by the
// ExcludeRetryCooldown clause below.
query := `
SELECT id
FROM accounts
WHERE deleted_at IS NULL
AND schedulable = TRUE
AND platform = ANY($1)
AND id > $2`
if options.ActiveOnly {
@@ -2257,6 +2266,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
@@ -327,14 +327,16 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
"expires_at": now.Add(30 * time.Minute).Format(time.RFC3339),
},
})
unschedulable := mustCreateAccount(s.T(), s.client, &service.Account{
Name: "grok-oauth-unschedulable-excluded",
// Paused but active OAuth accounts (schedulable=false) must remain refresh
// candidates so their stored access_token does not silently expire.
paused := mustCreateAccount(s.T(), s.client, &service.Account{
Name: "grok-oauth-paused-included",
Platform: service.PlatformGrok,
Type: service.AccountTypeOAuth,
Status: service.StatusActive,
Credentials: map[string]any{"refresh_token": "refresh-unschedulable"},
Credentials: map[string]any{"refresh_token": "refresh-paused"},
})
s.Require().NoError(s.client.Account.UpdateOneID(unschedulable.ID).SetSchedulable(false).Exec(s.ctx))
s.Require().NoError(s.client.Account.UpdateOneID(paused.ID).SetSchedulable(false).Exec(s.ctx))
mustCreateAccount(s.T(), s.client, &service.Account{
Name: "grok-api-key-excluded",
Platform: service.PlatformGrok,
@@ -393,15 +395,14 @@ func (s *AccountRepoSuite) TestListOAuthRefreshCandidatePage_GrokCursorAndExclus
s.Require().NoError(err)
first := firstPage.Accounts
s.Require().Len(first, 2)
s.Require().Equal([]int64{valid1.ID, valid2.ID}, []int64{first[0].ID, first[1].ID})
s.Require().NotContains([]int64{first[0].ID, first[1].ID}, unschedulable.ID)
s.Require().Equal([]int64{valid1.ID, paused.ID}, []int64{first[0].ID, first[1].ID})
options.AfterID = first[len(first)-1].ID
secondPage, err := s.repo.ListOAuthRefreshCandidatePage(s.ctx, options)
s.Require().NoError(err)
second := secondPage.Accounts
s.Require().Len(second, 1)
s.Require().Equal(valid3.ID, second[0].ID)
s.Require().Len(second, 2)
s.Require().Equal([]int64{valid2.ID, valid3.ID}, []int64{second[0].ID, second[1].ID})
s.Require().NotContains([]int64{first[0].ID, first[1].ID}, second[0].ID)
}
@@ -0,0 +1,96 @@
//go:build integration
package repository
import (
"context"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// TestAccountRepositorySetRateLimitedIfUnchanged exercises the atomic generation
// CAS backing the Ollama Cloud usage probe write-back (SetRateLimitedIfUnchanged).
// It must only write when the row still carries the exact UpdatedAt /
// RateLimitedAt / RateLimitResetAt the caller observed, so a stale async reset can
// never overwrite a newer 429, an admin clear, or a re-armed generation.
func TestAccountRepositorySetRateLimitedIfUnchanged(t *testing.T) {
ctx := context.Background()
tx := testEntTx(t)
repo := newAccountRepositoryWithSQL(tx.Client(), tx, nil)
makeAcct := func(name string) *service.Account {
return mustCreateAccount(t, tx.Client(), &service.Account{
Name: name, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"base_url": "https://www.ollama.com", "api_key": name},
})
}
// --- Matching generation applies the new reset. ---
acct := makeAcct("cas-match")
base := time.Now().Add(5 * time.Second)
require.NoError(t, repo.SetRateLimited(ctx, acct.ID, base))
cur, err := repo.GetByID(ctx, acct.ID)
require.NoError(t, err)
require.NotNil(t, cur)
require.NotNil(t, cur.RateLimitedAt)
require.NotNil(t, cur.RateLimitResetAt)
expUpdated, expLimited, expReset := cur.UpdatedAt, cur.RateLimitedAt, cur.RateLimitResetAt
newReset := time.Now().Add(2 * time.Hour)
updated, err := repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, expLimited, expReset, newReset)
require.NoError(t, err)
require.True(t, updated, "a write matching the observed generation must apply")
after, err := repo.GetByID(ctx, acct.ID)
require.NoError(t, err)
require.NotNil(t, after.RateLimitResetAt)
require.WithinDuration(t, newReset, *after.RateLimitResetAt, 2*time.Second)
// --- Stale UpdatedAt (row version moved by the write above) must NOT apply. ---
stale := time.Now().Add(3 * time.Hour)
updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, expLimited, expReset, stale)
require.NoError(t, err)
require.False(t, updated, "a write whose row version moved on must not apply")
// --- Current UpdatedAt but OLD limited/reset must NOT apply: the rate-limit
// generation was re-armed by the successful write, so the CAS must reject
// purely on the limited/reset predicates (isolated from UpdatedAt). ---
updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, after.UpdatedAt, expLimited, expReset, stale)
require.NoError(t, err)
require.False(t, updated, "re-armed limited/reset generation must not accept the old one even with the current row version")
// --- Current UpdatedAt but nil limited/reset must NOT apply against a set
// generation (admin-clear expectation only matches an unset state). ---
clearedReset := time.Now().Add(30 * time.Minute)
updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, after.UpdatedAt, nil, nil, clearedReset)
require.NoError(t, err)
require.False(t, updated, "nil-limited expectation must not match a set generation")
// --- Stale UpdatedAt alone (with nil/nil) must also fail on the version. ---
updated, err = repo.SetRateLimitedIfUnchanged(ctx, acct.ID, expUpdated, nil, nil, clearedReset)
require.NoError(t, err)
require.False(t, updated, "stale row version must not apply even with nil limited/reset")
// --- Never-limited account: nil generation CAS applies. ---
fresh := makeAcct("cas-fresh")
freshCur, err := repo.GetByID(ctx, fresh.ID)
require.NoError(t, err)
require.Nil(t, freshCur.RateLimitedAt)
require.Nil(t, freshCur.RateLimitResetAt)
firstReset := time.Now().Add(time.Hour)
updated, err = repo.SetRateLimitedIfUnchanged(ctx, fresh.ID, freshCur.UpdatedAt, nil, nil, firstReset)
require.NoError(t, err)
require.True(t, updated, "nil generation CAS must apply to a never-limited account")
// Admin-clear style: clear, then the old (set) generation must no longer match.
require.NoError(t, repo.ClearRateLimit(ctx, fresh.ID))
afterClear, err := repo.GetByID(ctx, fresh.ID)
require.NoError(t, err)
updated, err = repo.SetRateLimitedIfUnchanged(ctx, fresh.ID, afterClear.UpdatedAt, nil, nil, time.Now().Add(time.Hour))
require.NoError(t, err)
require.True(t, updated, "cleared account with nil generation accepts a fresh write")
}
@@ -288,8 +288,8 @@ func TestAccountRepository_ListOAuthRefreshCandidatePage_SQLFilter(t *testing.T)
normalized := normalizeSQLWhitespace(capturedSQL)
require.Contains(t, normalized, "deleted_at IS NULL")
require.Contains(t, normalized, "schedulable = TRUE",
"permanently unschedulable accounts must not remain OAuth refresh candidates")
require.NotContains(t, normalized, "schedulable = TRUE",
"paused (schedulable=false, status=active) accounts must remain OAuth refresh candidates: excluding them lets their stored access_token expire and the usage-window probe then reports a false 'needs re-auth'; permanent rejection is already covered by status = 'active' and refresh failures are bounded by the retry-cooldown exclusion")
require.Contains(t, normalized, "status = 'active'")
// setup-token 的 access_token 同为 8h 短期令牌,必须与 oauth 一起纳入后台刷新候选
require.Contains(t, normalized, "type IN ('oauth', 'setup-token')")
@@ -10,6 +10,62 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
// groupUsageRollupSnapshot 是一次汇总水位的快照。
// closedBefore / retainedFrom 在水位无效时退化为 1970-01-01,
// 与此前 SQL 里 CASE WHEN valid 的语义一一对应。
type groupUsageRollupSnapshot struct {
valid bool
closedBefore string // date,历史日桶的右开边界
retainedDate string // retained_from 在服务端时区内的日期,历史日桶的左闭边界
tailStart time.Time // 尾段(还没进日桶的那部分 usage_logs)的起点
}
// readGroupUsageRollupSnapshot 读一次汇总水位并在 Go 侧判定有效性。
//
// 判定条件与原先 SQL 里的 state_values CTE 完全一致:
// 恰好一行、时区名与当前服务端配置相同、且水位不在未来。
func (r *usageLogRepository) readGroupUsageRollupSnapshot(ctx context.Context, timezoneName, todayDate string) (groupUsageRollupSnapshot, error) {
epoch := time.Unix(0, 0).UTC()
invalid := groupUsageRollupSnapshot{
closedBefore: "1970-01-01",
retainedDate: "1970-01-01",
tailStart: epoch,
}
var rowCount int
var closedBefore sql.NullString
var retainedFrom sql.NullTime
var stateTimezone sql.NullString
if err := scanSingleRow(ctx, r.sql, `
SELECT
COUNT(*),
MAX(closed_before)::text,
MAX(retained_from),
MAX(timezone_name)
FROM usage_group_rollup_state
WHERE id = 1
`, nil, &rowCount, &closedBefore, &retainedFrom, &stateTimezone); err != nil {
return groupUsageRollupSnapshot{}, fmt.Errorf("读取分组用量汇总水位: %w", err)
}
if rowCount != 1 || !closedBefore.Valid || !retainedFrom.Valid ||
!stateTimezone.Valid || stateTimezone.String != timezoneName ||
closedBefore.String > todayDate {
return invalid, nil
}
tailStart, err := service.ParseGroupUsageDate(closedBefore.String)
if err != nil {
// 水位值解析不出来,按无效处理:宁可多扫一次,也不要算错钱。
return invalid, nil //nolint:nilerr // 与上面的 valid 判定同语义,均降级为全量重算
}
return groupUsageRollupSnapshot{
valid: true,
closedBefore: closedBefore.String,
retainedDate: service.GroupUsageDate(retainedFrom.Time),
tailStart: tailStart.UTC(),
}, nil
}
func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Context, todayStart time.Time) (results []usagestats.GroupUsageSummary, err error) {
todayStart = service.GroupUsageTodayStart(todayStart)
yesterdayStart := service.GroupUsageYesterdayStart(todayStart)
@@ -17,40 +73,34 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont
todayDate := service.GroupUsageDate(todayStart)
yesterdayDate := service.GroupUsageDate(yesterdayStart)
state, err := r.readGroupUsageRollupSnapshot(ctx, timezoneName, todayDate)
if err != nil {
return nil, err
}
// 尾段起点必须以**查询参数**的形式给进来。
//
// 此前它是在同一条 SQL 里由 state CTE 算出、再 CROSS JOIN 给 tail 用的,
// 于是 created_at 的下界对 planner 来说是个运行期才知道的值:既进不了
// index cond,也估不准选择性,只能退化成 usage_logs 全表扫。
// 生产实测(1721 万行 / PostgreSQL 17):
// Seq Scan on usage_logs … rows=17212870, Rows Removed by Join Filter: 17178082
// Execution Time: 48720 ms
// 换成参数之后走 idx_usage_logs_created_at:
// Index Scan … Index Cond: (created_at >= $7)
// Execution Time: 34 ms
const query = `
WITH state_values AS (
SELECT
COUNT(*) = 1
AND MAX(timezone_name) = $3
AND MAX(closed_before) <= $4::date AS valid,
MAX(closed_before) AS closed_before,
MAX(retained_from) AS retained_from
FROM usage_group_rollup_state
WHERE id = 1
),
state AS (
SELECT
CASE WHEN valid THEN closed_before ELSE DATE '1970-01-01' END AS closed_before,
CASE WHEN valid THEN retained_from ELSE TIMESTAMPTZ '1970-01-01 00:00:00+00' END AS retained_from,
CASE
WHEN valid THEN closed_before::timestamp AT TIME ZONE $3::text
ELSE TIMESTAMPTZ '1970-01-01 00:00:00+00'
END AS tail_start,
valid
FROM state_values
),
historical AS (
WITH historical AS (
SELECT
rollup.group_id,
COALESCE(SUM(rollup.actual_cost), 0) AS actual_cost,
COALESCE(SUM(rollup.actual_cost) FILTER (
WHERE rollup.bucket_date = $5::date
WHERE rollup.bucket_date = $3::date
), 0) AS yesterday_cost
FROM usage_group_daily_rollups rollup
CROSS JOIN state
WHERE state.valid
AND rollup.bucket_date >= (state.retained_from AT TIME ZONE $3::text)::date
AND rollup.bucket_date < state.closed_before
WHERE $4::boolean
AND rollup.bucket_date >= $5::date
AND rollup.bucket_date < $6::date
GROUP BY rollup.group_id
),
tail AS (
@@ -63,8 +113,7 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont
AND ul.created_at < $1
), 0) AS yesterday_cost
FROM usage_logs ul
CROSS JOIN state
WHERE ul.created_at >= state.tail_start
WHERE ul.created_at >= $7
GROUP BY ul.group_id
)
SELECT
@@ -83,9 +132,11 @@ func (r *usageLogRepository) getAllGroupUsageSummaryFromRollups(ctx context.Cont
query,
todayStart,
yesterdayStart,
timezoneName,
todayDate,
yesterdayDate,
state.valid,
state.retainedDate,
state.closedBefore,
state.tailStart,
)
if err != nil {
return nil, err
@@ -32,7 +32,6 @@ func (r *opsRepository) GetOpenAITokenStats(ctx context.Context, filter *service
}
join, where, baseArgs, next := buildUsageWhere(dashboardFilter, dashboardFilter.StartTime, dashboardFilter.EndTime, 1)
where += " AND ul.model LIKE 'gpt%'"
baseCTE := `
WITH stats AS (
@@ -2,6 +2,8 @@ package repository
import (
"context"
"database/sql/driver"
"regexp"
"testing"
"time"
@@ -10,6 +12,81 @@ import (
"github.com/stretchr/testify/require"
)
func TestOpsRepositoryGetOpenAITokenStats_PlatformScope(t *testing.T) {
start := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
end := start.Add(time.Hour)
groupID := int64(9)
tests := []struct {
name string
platform string
groupID *int64
models []string
}{
{name: "all platforms", models: []string{"claude-sonnet-4", "deepseek-chat", "gemini-2.5-pro", "gpt-4o", "o3"}},
{name: "anthropic group", platform: "anthropic", groupID: &groupID, models: []string{"claude-sonnet-4"}},
{name: "gemini", platform: "gemini", models: []string{"gemini-2.5-pro"}},
{name: "openai reasoning model", platform: "openai", models: []string{"o3"}},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
db, mock := newSQLMock(t)
repo := &opsRepository{db: db}
filter := &service.OpsOpenAITokenStatsFilter{
TimeRange: "1h",
StartTime: start,
EndTime: end,
Platform: tt.platform,
GroupID: tt.groupID,
TopN: 20,
}
// Match the complete filtering clause in both queries so an implicit
// model restriction cannot silently narrow the selected platform scope.
where := "WHERE ul.created_at >= $1 AND ul.created_at < $2"
args := []driver.Value{start, end}
if tt.groupID != nil {
where += " AND ul.group_id = $3"
args = append(args, *tt.groupID)
}
if tt.platform != "" {
placeholder := "$3"
if tt.groupID != nil {
placeholder = "$4"
}
where += " AND COALESCE(NULLIF(g.platform,''), a.platform) = " + placeholder
args = append(args, tt.platform)
}
queryPrefix := regexp.QuoteMeta(where) + `\s+GROUP BY ul.model\s+\)`
mock.ExpectQuery(queryPrefix + `\s+SELECT COUNT\(\*\) FROM stats`).
WithArgs(args...).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(len(tt.models)))
rows := sqlmock.NewRows([]string{
"model", "request_count", "avg_tokens_per_sec", "avg_first_token_ms",
"total_output_tokens", "avg_duration_ms", "requests_with_first_token",
})
for _, model := range tt.models {
rows.AddRow(model, int64(2), 10.0, 100.0, int64(20), int64(1000), int64(2))
}
mock.ExpectQuery(queryPrefix + `\s+SELECT model,`).
WithArgs(append(args, 20)...).
WillReturnRows(rows)
resp, err := repo.GetOpenAITokenStats(context.Background(), filter)
require.NoError(t, err)
require.Equal(t, tt.platform, resp.Platform)
require.Equal(t, tt.groupID, resp.GroupID)
require.Equal(t, int64(len(tt.models)), resp.Total)
require.Len(t, resp.Items, len(tt.models))
for i, model := range tt.models {
require.Equal(t, model, resp.Items[i].Model)
}
require.NoError(t, mock.ExpectationsWereMet())
})
}
}
func TestOpsRepositoryGetOpenAITokenStats_PaginationMode(t *testing.T) {
db, mock := newSQLMock(t)
repo := &opsRepository{db: db}
@@ -93,6 +93,7 @@ WITH combined AS (
COALESCE(NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform,
ul.model AS model,
ul.duration_ms AS duration_ms,
ul.first_token_ms AS first_token_ms,
NULL::INT AS status_code,
NULL::BIGINT AS error_id,
NULL::TEXT AS phase,
@@ -117,6 +118,7 @@ WITH combined AS (
COALESCE(NULLIF(o.platform, ''), NULLIF(g.platform, ''), NULLIF(a.platform, ''), '') AS platform,
o.model AS model,
o.duration_ms AS duration_ms,
o.time_to_first_token_ms AS first_token_ms,
o.status_code AS status_code,
o.id AS error_id,
o.error_phase AS phase,
@@ -152,6 +154,8 @@ WITH combined AS (
// default
case "duration_desc":
sort = "ORDER BY duration_ms DESC NULLS LAST, created_at DESC"
case "ttft_desc":
sort = "ORDER BY first_token_ms DESC NULLS LAST, created_at DESC"
default:
return nil, 0, fmt.Errorf("invalid sort")
}
@@ -166,6 +170,7 @@ SELECT
platform,
model,
duration_ms,
first_token_ms,
status_code,
error_id,
phase,
@@ -213,9 +218,10 @@ LIMIT $%d OFFSET $%d
platform sql.NullString
model sql.NullString
durationMs sql.NullInt64
statusCode sql.NullInt64
errorID sql.NullInt64
durationMs sql.NullInt64
firstTokenMs sql.NullInt64
statusCode sql.NullInt64
errorID sql.NullInt64
phase sql.NullString
severity sql.NullString
@@ -236,6 +242,7 @@ LIMIT $%d OFFSET $%d
&platform,
&model,
&durationMs,
&firstTokenMs,
&statusCode,
&errorID,
&phase,
@@ -257,12 +264,13 @@ LIMIT $%d OFFSET $%d
Platform: strings.TrimSpace(platform.String),
Model: strings.TrimSpace(model.String),
DurationMs: toIntPtr(durationMs),
StatusCode: toIntPtr(statusCode),
ErrorID: toInt64Ptr(errorID),
Phase: phase.String,
Severity: severity.String,
Message: message.String,
DurationMs: toIntPtr(durationMs),
FirstTokenMs: toIntPtr(firstTokenMs),
StatusCode: toIntPtr(statusCode),
ErrorID: toInt64Ptr(errorID),
Phase: phase.String,
Severity: severity.String,
Message: message.String,
UserID: toInt64Ptr(userID),
APIKeyID: toInt64Ptr(apiKeyID),
@@ -0,0 +1,63 @@
package repository
import (
"context"
"testing"
"time"
"github.com/DATA-DOG/go-sqlmock"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestOpsRepositoryListRequestDetails_LatencySort(t *testing.T) {
for _, tc := range []struct {
name string
sort string
order string
}{
{name: "TTFT", sort: "ttft_desc", order: "first_token_ms DESC NULLS LAST, created_at DESC"},
{name: "duration", sort: "duration_desc", order: "duration_ms DESC NULLS LAST, created_at DESC"},
{name: "default", order: "created_at DESC"},
} {
t.Run(tc.name, func(t *testing.T) {
db, mock := newSQLMock(t)
repo := &opsRepository{db: db}
start := time.Date(2026, 9, 10, 0, 0, 0, 0, time.UTC)
end := start.Add(time.Hour)
filter := &service.OpsRequestDetailFilter{
StartTime: &start,
EndTime: &end,
Sort: tc.sort,
Page: 2,
PageSize: 10,
}
mock.ExpectQuery(`SELECT COUNT\(1\) FROM combined`).
WithArgs(start, end).
WillReturnRows(sqlmock.NewRows([]string{"count"}).AddRow(13))
rows := sqlmock.NewRows([]string{
"kind", "created_at", "request_id", "platform", "model", "duration_ms", "first_token_ms",
"status_code", "error_id", "phase", "severity", "message", "user_id", "api_key_id", "account_id", "group_id", "stream",
}).
AddRow("success", start, "req-slow", "openai", "gpt-5.5", 12000, 800, nil, nil, nil, nil, nil, 1, 2, 3, 4, true).
AddRow("error", start, "req-zero", "openai", "gpt-5.5", 9000, 0, 502, 5, "upstream", "error", "failed", 1, 2, 3, 4, true).
AddRow("success", start, "req-missing", "openai", "gpt-5.5", 5000, nil, nil, nil, nil, nil, nil, 1, 2, 3, 4, false)
mock.ExpectQuery(`(?s)ul\.first_token_ms AS first_token_ms.*o\.time_to_first_token_ms AS first_token_ms.*SELECT.*duration_ms,\s+first_token_ms,.*ORDER BY `+tc.order+`\s+LIMIT \$3 OFFSET \$4`).
WithArgs(start, end, 10, 10).
WillReturnRows(rows)
items, total, err := repo.ListRequestDetails(context.Background(), filter)
require.NoError(t, err)
require.EqualValues(t, 13, total)
require.Len(t, items, 3)
require.NotNil(t, items[0].FirstTokenMs)
require.Equal(t, 800, *items[0].FirstTokenMs)
require.Equal(t, 12000, *items[0].DurationMs)
require.NotNil(t, items[1].FirstTokenMs)
require.Zero(t, *items[1].FirstTokenMs)
require.Nil(t, items[2].FirstTokenMs)
require.NoError(t, mock.ExpectationsWereMet())
})
}
}
@@ -0,0 +1,99 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/redis/go-redis/v9"
)
// pluginKVKeyPrefix 是所有插件通用键值存储在 Redis 中的根前缀。版本段 v1 便于未来
// 变更内部键结构时平滑迁移。
const pluginKVKeyPrefix = "plugin:kv:v1:"
// pluginKVScanCount 是 SCAN 每轮的 COUNT 提示,兼顾单轮开销与往返次数。
const pluginKVScanCount = 200
type pluginKVStore struct {
rdb *redis.Client
}
// NewPluginKVStore 返回 Redis 支撑的通用插件键值存储。使用 Redis 而非进程内存,使得
// 插件状态可跨副本、跨重启共享,这正是有状态插件所需的通用能力。
func NewPluginKVStore(rdb *redis.Client) service.PluginKVStore {
return &pluginKVStore{rdb: rdb}
}
// namespacePrefix 拼出某插件某命名空间的内部键前缀。pluginKey / namespace 均已由宿主
// 服务端按 [A-Za-z0-9._-] 校验,不含 ':' 或 glob 元字符,键结构因此无歧义。
func (s *pluginKVStore) namespacePrefix(pluginKey, namespace string) string {
return pluginKVKeyPrefix + pluginKey + ":" + namespace + ":"
}
func (s *pluginKVStore) fullKey(pluginKey, namespace, key string) string {
return s.namespacePrefix(pluginKey, namespace) + key
}
func (s *pluginKVStore) Get(ctx context.Context, pluginKey, namespace, key string) ([]byte, bool, error) {
value, err := s.rdb.Get(ctx, s.fullKey(pluginKey, namespace, key)).Bytes()
if errors.Is(err, redis.Nil) {
return nil, false, nil
}
if err != nil {
return nil, false, fmt.Errorf("get plugin kv: %w", err)
}
return value, true, nil
}
func (s *pluginKVStore) Set(ctx context.Context, pluginKey, namespace, key string, value []byte, ttl time.Duration) error {
// go-redis 将 ttl==0 视为不过期,正好对应“持久保存”语义。
if err := s.rdb.Set(ctx, s.fullKey(pluginKey, namespace, key), value, ttl).Err(); err != nil {
return fmt.Errorf("set plugin kv: %w", err)
}
return nil
}
func (s *pluginKVStore) Delete(ctx context.Context, pluginKey, namespace, key string) error {
if err := s.rdb.Del(ctx, s.fullKey(pluginKey, namespace, key)).Err(); err != nil {
return fmt.Errorf("delete plugin kv: %w", err)
}
return nil
}
func (s *pluginKVStore) List(ctx context.Context, pluginKey, namespace, keyPrefix string, limit int) ([]string, error) {
if limit <= 0 {
return nil, nil
}
internalPrefix := s.namespacePrefix(pluginKey, namespace)
// keyPrefix 已按 [A-Za-z0-9._-] 校验,不含 glob 元字符,可安全拼接进 MATCH。
pattern := internalPrefix + keyPrefix + "*"
seen := make(map[string]struct{}, limit)
keys := make([]string, 0, limit)
var cursor uint64
for {
batch, next, err := s.rdb.Scan(ctx, cursor, pattern, pluginKVScanCount).Result()
if err != nil {
return nil, fmt.Errorf("scan plugin kv: %w", err)
}
for _, full := range batch {
// SCAN 可能返回重复键,去重后再裁剪前缀返回面向插件的键名。
if _, ok := seen[full]; ok {
continue
}
seen[full] = struct{}{}
keys = append(keys, strings.TrimPrefix(full, internalPrefix))
if len(keys) >= limit {
return keys, nil
}
}
cursor = next
if cursor == 0 {
break
}
}
return keys, nil
}
@@ -0,0 +1,148 @@
package repository
import (
"context"
"testing"
"time"
"github.com/alicebob/miniredis/v2"
"github.com/redis/go-redis/v9"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func newTestPluginKVStore(t *testing.T) (*pluginKVStore, *miniredis.Miniredis) {
t.Helper()
mr := miniredis.RunT(t)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
store, ok := NewPluginKVStore(rdb).(*pluginKVStore)
require.True(t, ok)
return store, mr
}
func TestPluginKVStore_SetGetDelete(t *testing.T) {
store, _ := newTestPluginKVStore(t)
ctx := context.Background()
value, found, err := store.Get(ctx, "p1", "state", "k")
require.NoError(t, err)
assert.False(t, found)
assert.Nil(t, value)
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte("hello"), 0))
value, found, err = store.Get(ctx, "p1", "state", "k")
require.NoError(t, err)
assert.True(t, found)
assert.Equal(t, []byte("hello"), value)
require.NoError(t, store.Delete(ctx, "p1", "state", "k"))
_, found, err = store.Get(ctx, "p1", "state", "k")
require.NoError(t, err)
assert.False(t, found)
}
func TestPluginKVStore_TTLExpires(t *testing.T) {
store, mr := newTestPluginKVStore(t)
ctx := context.Background()
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte("v"), 30*time.Second))
full := store.fullKey("p1", "state", "k")
ttl := mr.TTL(full)
assert.Equal(t, 30*time.Second, ttl)
mr.FastForward(31 * time.Second)
_, found, err := store.Get(ctx, "p1", "state", "k")
require.NoError(t, err)
assert.False(t, found)
}
func TestPluginKVStore_PluginIsolation(t *testing.T) {
store, _ := newTestPluginKVStore(t)
ctx := context.Background()
require.NoError(t, store.Set(ctx, "p1", "state", "shared", []byte("one"), 0))
require.NoError(t, store.Set(ctx, "p2", "state", "shared", []byte("two"), 0))
v1, found, err := store.Get(ctx, "p1", "state", "shared")
require.NoError(t, err)
require.True(t, found)
assert.Equal(t, []byte("one"), v1)
v2, found, err := store.Get(ctx, "p2", "state", "shared")
require.NoError(t, err)
require.True(t, found)
assert.Equal(t, []byte("two"), v2)
}
func TestPluginKVStore_ListStripsPrefixAndHonorsLimit(t *testing.T) {
store, _ := newTestPluginKVStore(t)
ctx := context.Background()
for _, key := range []string{"account-1", "account-2", "account-3", "other"} {
require.NoError(t, store.Set(ctx, "p1", "state", key, []byte("x"), 0))
}
// 另一命名空间/插件的键不应混入结果。
require.NoError(t, store.Set(ctx, "p1", "other-ns", "account-9", []byte("x"), 0))
require.NoError(t, store.Set(ctx, "p2", "state", "account-9", []byte("x"), 0))
keys, err := store.List(ctx, "p1", "state", "account-", 100)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"account-1", "account-2", "account-3"}, keys)
keys, err = store.List(ctx, "p1", "state", "", 100)
require.NoError(t, err)
assert.ElementsMatch(t, []string{"account-1", "account-2", "account-3", "other"}, keys)
keys, err = store.List(ctx, "p1", "state", "account-", 2)
require.NoError(t, err)
assert.Len(t, keys, 2)
keys, err = store.List(ctx, "p1", "state", "account-", 0)
require.NoError(t, err)
assert.Empty(t, keys)
}
// namespace 是另一 namespace 的前缀时(state vs state2),List 不能串味——内部键的 ':'
// 分隔符 + namespace 字符集排除 ':' 共同保证消歧。
func TestPluginKVStore_ListNamespacePrefixDisambiguation(t *testing.T) {
store, _ := newTestPluginKVStore(t)
ctx := context.Background()
require.NoError(t, store.Set(ctx, "p1", "state", "k1", []byte("a"), 0))
require.NoError(t, store.Set(ctx, "p1", "state2", "k2", []byte("b"), 0))
keys, err := store.List(ctx, "p1", "state", "", 100)
require.NoError(t, err)
assert.Equal(t, []string{"k1"}, keys)
keys, err = store.List(ctx, "p1", "state2", "", 100)
require.NoError(t, err)
assert.Equal(t, []string{"k2"}, keys)
}
func TestPluginKVStore_OverwriteResetsValueAndTTL(t *testing.T) {
store, mr := newTestPluginKVStore(t)
ctx := context.Background()
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte("v1"), 10*time.Second))
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte("v2"), 0))
value, found, err := store.Get(ctx, "p1", "state", "k")
require.NoError(t, err)
require.True(t, found)
assert.Equal(t, []byte("v2"), value)
// ttl=0 覆盖后应变为不过期。
assert.Equal(t, time.Duration(0), mr.TTL(store.fullKey("p1", "state", "k")))
}
func TestPluginKVStore_EmptyValueIsFound(t *testing.T) {
store, _ := newTestPluginKVStore(t)
ctx := context.Background()
require.NoError(t, store.Set(ctx, "p1", "state", "k", []byte{}, 0))
value, found, err := store.Get(ctx, "p1", "state", "k")
require.NoError(t, err)
assert.True(t, found)
assert.Empty(t, value)
}
func TestPluginKVStore_DeleteMissingIsNoError(t *testing.T) {
store, _ := newTestPluginKVStore(t)
require.NoError(t, store.Delete(context.Background(), "p1", "state", "missing"))
}
@@ -347,7 +347,7 @@ func (r *redeemCodeRepository) ListByUser(ctx context.Context, userID int64, lim
codes, err := r.client.RedeemCode.Query().
Where(redeemcode.UsedByEQ(userID)).
WithGroup().
Order(dbent.Desc(redeemcode.FieldUsedAt)).
Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)).
Limit(limit).
All(ctx)
if err != nil {
@@ -377,7 +377,7 @@ func (r *redeemCodeRepository) ListByUserPaginated(ctx context.Context, userID i
WithGroup().
Offset(params.Offset()).
Limit(params.Limit()).
Order(dbent.Desc(redeemcode.FieldUsedAt)).
Order(dbent.Desc(redeemcode.FieldUsedAt), dbent.Desc(redeemcode.FieldID)).
All(ctx)
if err != nil {
return nil, nil, err

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