Compare commits

...
Author SHA1 Message Date
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
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
Brisbanehuang db76cc4e4e fix(scheduler): 按规范窗口读取 Codex 配额与重置时间
优先读取 codex_5h/codex_7d 规范字段,旧快照复用 Normalize 的窗口分类;用量与重置时间按同源窗口配对。重置评分与管理端快照复用规范 5h 重置时间,保留会话列回退,不改变权重与暂停阈值。
2026-09-12 09:06:33 -04:00
Lawrence c0c4ce1ce6 Merge upstream main and retain blank OAuth promo code regression test 2026-09-07 14:27:55 +08:00
Lawrence e1eff57c25 fix(auth): preserve promo codes through OAuth registration 2026-08-26 21:12:52 +08:00
154 changed files with 8003 additions and 454 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
+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!
+2 -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>注册体验!
+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.5
+3 -1
View File
@@ -204,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)
+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=
@@ -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 {
+3
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):
@@ -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")
}
@@ -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
+34 -11
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) == "" {
@@ -215,8 +228,8 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
var selection *service.AccountSelectionResult
var scheduleDecision service.OpenAIAccountScheduleDecision
if boundLookupAccountID > 0 {
selection, scheduleDecision, err = h.gatewayService.SelectGrokMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel,
selection, scheduleDecision, err = h.gatewayService.SelectMediaVideoRequestAccount(
requestCtx, apiKey.GroupID, sessionHash, boundLookupAccountID, routingModel, platform,
)
} else {
selection, scheduleDecision, err = h.gatewayService.SelectAccountWithSchedulerForCapability(
@@ -231,7 +244,7 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
false,
false,
false,
service.PlatformGrok,
platform,
)
}
// Own an eagerly acquired slot before any rejection or eligibility probe.
@@ -256,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)
}
@@ -277,10 +290,10 @@ 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)
}
@@ -309,7 +322,7 @@ 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()
@@ -360,6 +373,9 @@ func (h *OpenAIGatewayHandler) handleGrokMedia(c *gin.Context, endpoint service.
writerSizeBeforeForward := c.Writer.Size()
result, err := func() (*service.OpenAIForwardResult, error) {
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)
}()
@@ -490,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)
@@ -521,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
}
@@ -539,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
@@ -712,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
@@ -120,10 +120,22 @@ func (s *grokMediaSlotsCache) assertReleased(t *testing.T) {
type grokMediaSlotBindings struct {
testutil.StubGatewayCache
owner int64
writes int
key string
billed map[string]bool
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) {
@@ -186,7 +198,7 @@ func (p grokMediaSlotProber) ProbeMediaEligibility(ctx context.Context, id int64
return p(ctx, id)
}
func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGatewayHandler, *grokMediaSlotsCache, *grokMediaSlotBindings, *grokMediaSlotUpstream) {
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)
@@ -194,6 +206,11 @@ func newGrokMediaSlotHandler(t *testing.T, oauth, mismatch bool) (*OpenAIGateway
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"
@@ -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)
}
@@ -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)
@@ -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="))
}
@@ -293,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
@@ -305,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
@@ -320,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
@@ -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(`{
@@ -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,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"`
}
// ---------------------------------------------------------------------------
+10 -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 {
@@ -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)
}
@@ -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
@@ -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
@@ -3,8 +3,10 @@
package repository
import (
"fmt"
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
"github.com/Wei-Shaw/sub2api/internal/service"
"time"
)
func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
@@ -22,3 +24,50 @@ func (s *RedeemCodeRepoSuite) TestListWithFilters_SortByValueAsc() {
s.Require().Equal("VALUE-10", codes[0].Code)
s.Require().Equal("VALUE-20", codes[1].Code)
}
func (s *RedeemCodeRepoSuite) TestHistoryPaginationIsolationAndOrdering() {
user := s.createUser("history@example.com")
other := s.createUser("other-history@example.com")
now := time.Now().UTC().Truncate(time.Second)
var ids []int64
types := []string{service.RedeemTypeBalance, service.RedeemTypeConcurrency, service.RedeemTypeSubscription, "admin_balance", "admin_concurrency"}
for i := 0; i < 105; i++ {
usedAt := now
if i == 0 {
usedAt = now.Add(time.Hour)
}
code, err := s.client.RedeemCode.Create().SetCode(fmt.Sprintf("HISTORY-%d", i)).SetType(types[i%len(types)]).SetStatus(service.StatusUsed).SetUsedBy(user.ID).SetUsedAt(usedAt).Save(s.ctx)
s.Require().NoError(err)
ids = append(ids, code.ID)
}
_, err := s.client.RedeemCode.Create().SetCode("OTHER-HISTORY").SetType(service.RedeemTypeSubscription).SetStatus(service.StatusUsed).SetUsedBy(other.ID).SetUsedAt(now.Add(2 * time.Hour)).Save(s.ctx)
s.Require().NoError(err)
expected := []int64{ids[0]}
for i := len(ids) - 1; i > 0; i-- {
expected = append(expected, ids[i])
}
for _, size := range []int{20, 50, 100} {
var got []int64
for page := 1; page <= (105+size-1)/size+1; page++ {
codes, result, err := s.repo.ListByUserPaginated(s.ctx, user.ID, pagination.PaginationParams{Page: page, PageSize: size}, "")
s.Require().NoError(err)
s.Equal(int64(105), result.Total)
s.LessOrEqual(len(codes), size)
for _, code := range codes {
s.Equal(user.ID, *code.UsedBy)
got = append(got, code.ID)
}
}
s.Equal(expected, got)
}
codes, result, err := s.repo.ListByUserPaginated(s.ctx, other.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "")
s.Require().NoError(err)
s.Equal(int64(1), result.Total)
s.Require().Len(codes, 1)
s.Equal("OTHER-HISTORY", codes[0].Code)
empty := s.createUser("empty-history@example.com")
codes, result, err = s.repo.ListByUserPaginated(s.ctx, empty.ID, pagination.PaginationParams{Page: 1, PageSize: 20}, "")
s.Require().NoError(err)
s.Empty(codes)
s.Zero(result.Total)
}
@@ -18,8 +18,23 @@ func TestUsageLogRepositoryGetAllGroupUsageSummaryUsesRollupTail(t *testing.T) {
todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC)
yesterdayStart := time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC)
mock.ExpectQuery(`(?s)usage_group_rollup_state.*usage_group_daily_rollups.*created_at >= state\.tail_start`).
WithArgs(todayStart, yesterdayStart, "America/New_York", "2026-03-09", "2026-03-08").
// 水位有效:closed_before = 2026-03-07(≤ today),时区与配置一致。
mock.ExpectQuery(`(?s)COUNT\(\*\).*usage_group_rollup_state.*WHERE id = 1`).
WillReturnRows(sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}).
AddRow(1, "2026-03-07", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "America/New_York"))
// 尾段起点必须以参数形式进 WHERE —— 这正是把 usage_logs 全表扫换回
// idx_usage_logs_created_at 索引扫的那一步。
mock.ExpectQuery(`(?s)usage_group_daily_rollups.*FROM usage_logs ul\s+WHERE ul\.created_at >= \$7`).
WithArgs(
todayStart,
yesterdayStart,
"2026-03-08", // yesterdayDate
true, // 水位有效
"2026-02-01", // retained_from 在配置时区内的日期
"2026-03-07", // closed_before
time.Date(2026, 3, 7, 5, 0, 0, 0, time.UTC), // tail_start = closed_before 在配置时区的零点
).
WillReturnRows(sqlmock.NewRows([]string{"group_id", "total_cost", "today_cost", "yesterday_cost"}).
AddRow(int64(7), 12.5, 1.25, 2.5))
@@ -31,3 +46,51 @@ func TestUsageLogRepositoryGetAllGroupUsageSummaryUsesRollupTail(t *testing.T) {
require.InDelta(t, 2.5, result[0].YesterdayCost, 0.0000001)
require.NoError(t, mock.ExpectationsWereMet())
}
// 水位无效(时区变了 / 水位在未来 / 行缺失)时必须退化成"从 epoch 起全量重算",
// 与改动前 SQL 里 CASE WHEN valid 的 ELSE 分支逐字对应:
// 历史日桶整段不参与,尾段起点回到 1970-01-01。
func TestUsageLogRepositoryGetAllGroupUsageSummaryFallsBackWhenWatermarkInvalid(t *testing.T) {
epoch := time.Unix(0, 0).UTC()
todayStart := time.Date(2026, 3, 9, 4, 0, 0, 0, time.UTC)
yesterdayStart := time.Date(2026, 3, 8, 5, 0, 0, 0, time.UTC)
cases := []struct {
name string
rows *sqlmock.Rows
}{
{
name: "水位行缺失",
rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}).
AddRow(0, nil, nil, nil),
},
{
name: "时区与当前配置不一致",
rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}).
AddRow(1, "2026-03-07", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "Asia/Shanghai"),
},
{
name: "水位位于未来",
rows: sqlmock.NewRows([]string{"count", "closed_before", "retained_from", "timezone_name"}).
AddRow(1, "2026-03-10", time.Date(2026, 2, 1, 5, 0, 0, 0, time.UTC), "America/New_York"),
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
db, mock := newSQLMock(t)
repo := newUsageLogRepositoryWithSQL(nil, db)
useGroupUsageRepositoryTestTimezone(t, "America/New_York")
mock.ExpectQuery(`(?s)COUNT\(\*\).*usage_group_rollup_state.*WHERE id = 1`).
WillReturnRows(tc.rows)
mock.ExpectQuery(`(?s)usage_group_daily_rollups.*FROM usage_logs ul\s+WHERE ul\.created_at >= \$7`).
WithArgs(todayStart, yesterdayStart, "2026-03-08", false, "1970-01-01", "1970-01-01", epoch).
WillReturnRows(sqlmock.NewRows([]string{"group_id", "total_cost", "today_cost", "yesterday_cost"}))
_, err := repo.GetAllGroupUsageSummary(context.Background(), todayStart)
require.NoError(t, err)
require.NoError(t, mock.ExpectationsWereMet())
})
}
}
+1
View File
@@ -97,6 +97,7 @@ var ProviderSet = wire.NewSet(
NewErrorPassthroughRepository,
NewTLSFingerprintProfileRepository,
NewPluginRepository,
NewPluginKVStore,
NewChannelRepository,
NewChannelMonitorRepository,
NewChannelMonitorV2Repository,
+1
View File
@@ -759,6 +759,7 @@ func registerPluginRoutes(admin *gin.RouterGroup, h *handler.Handlers, stepUpAut
plugins.POST("/:id/disable", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Disable)
plugins.DELETE("/:id", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Delete)
plugins.GET("/:id/config", h.Admin.Plugin.GetConfig)
plugins.GET("/:id/status", h.Admin.Plugin.Status)
plugins.PUT("/:id/config", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.SaveConfig)
plugins.POST("/:id/test", gin.HandlerFunc(stepUpAuth), h.Admin.Plugin.Test)
plugins.POST("/:id/ui-session", h.Admin.Plugin.CreateUISession)
@@ -368,6 +368,11 @@ func RegisterGatewayRoutes(
rootRoute := func(method, path string, limit gin.HandlerFunc, handler gin.HandlerFunc) {
r.Handle(method, path, limit, clientRequestID, opsErrorLogger, endpointNorm, gin.HandlerFunc(apiKeyAuth), groupModelAllowlist, compositeTarget, requireGroupAnthropic, handler)
}
for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} {
rootRoute(http.MethodPost, prefix+"/contents/generations/tasks", bodyLimit, h.OpenAIGateway.SeedanceTasks)
rootRoute(http.MethodGet, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks)
rootRoute(http.MethodDelete, prefix+"/contents/generations/tasks/:task_id", bodyLimit, h.OpenAIGateway.SeedanceTasks)
}
rootRoute(http.MethodPost, "/responses", bodyLimit, responsesHandler)
rootRoute(http.MethodPost, "/responses/*subpath", bodyLimit, guardResponsesSubpath(responsesHandler))
rootRoute(http.MethodPost, "/alpha/search", textBodyLimit, h.OpenAIGateway.AlphaSearch)
@@ -157,6 +157,10 @@ func TestGatewayRoutesGroupModelAllowlistCoversRootAliasRoutes(t *testing.T) {
{http.MethodPost, "/v1/embeddings", `{"model":"gpt-4.1","input":"hi"}`},
{http.MethodPost, "/v1/images/generations", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/v1/videos/generations", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/api/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
{http.MethodPost, "/v3/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
{http.MethodPost, "/v1/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
{http.MethodPost, "/contents/generations/tasks", `{"model":"gpt-4.1","content":[{"type":"text","text":"waves"}]}`},
{http.MethodPost, "/v1/live", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`},
{http.MethodPost, "/backend-api/codex/responses", `{"model":"gpt-4.1"}`},
{http.MethodPost, "/backend-api/codex/realtime/calls", `{"session":{"model":"gpt-4.1"},"sdp":"v=0"}`},
@@ -0,0 +1,34 @@
package routes
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func TestSeedanceNativeRoutes(t *testing.T) {
router := newGatewayRoutesTestRouter()
for _, prefix := range []string{"/api/v3", "/v3", "/v1", ""} {
for _, method := range []string{http.MethodPost, http.MethodGet, http.MethodDelete} {
path := prefix + "/contents/generations/tasks"
if method != http.MethodPost {
path += "/task-1"
}
w := httptest.NewRecorder()
router.ServeHTTP(w, httptest.NewRequest(method, path, strings.NewReader(`{"model":"seedance","content":[{"type":"text","text":"waves"}]}`)))
require.NotEqual(t, http.StatusNotFound, w.Code, method+" "+path)
}
}
}
func TestSeedanceRejectsOtherPlatforms(t *testing.T) {
for _, platform := range []string{service.PlatformGrok, service.PlatformAnthropic, service.PlatformGemini} {
w := httptest.NewRecorder()
newGatewayRoutesTestRouter(platform).ServeHTTP(w, httptest.NewRequest(http.MethodPost, "/api/v3/contents/generations/tasks", strings.NewReader(`{"model":"seedance","content":[{}]}`)))
require.Equal(t, http.StatusForbidden, w.Code)
}
}
+12
View File
@@ -848,6 +848,10 @@ func resolveRequestedModelInMapping(mapping map[string]string, requestedModel st
// 会把未知模型原样透传,Codex 上游对这类模型必然返回不可重试的 400,导致
// 请求卡死在该账号上、无法 failover 到真正支持该模型的 API Key 账号(#3662)。
// 未知/自定义别名仍保持允许(兼容渠道级映射),见 isOpenAIOAuthServableModel。
//
// 例外:DeepSeek 平台的空映射不再是「允许所有」,改按官方模型白名单判定
// (isDeepseekServableModel)——未知模型名透传上游只会得到 404/400,并误触发
// per-(账号,模型) 30 分钟冷却;带 [1m] 上下文后缀的写法先归一化再比对。
func (a *Account) IsModelSupported(requestedModel string) bool {
// 透传模式仅替换认证、模型语义完全交由上游决定,因此放行所有模型。
// 该短路必须在 model_mapping 判定之前:账号从"白名单模式"切换到透传后,
@@ -861,6 +865,9 @@ func (a *Account) IsModelSupported(requestedModel string) bool {
if a.IsOpenAIOAuth() {
return isOpenAIOAuthServableModel(requestedModel)
}
if a.Platform == PlatformDeepseek {
return isDeepseekServableModel(requestedModel)
}
return true // 无映射 = 允许所有
}
if mappingSupportsRequestedModel(mapping, requestedModel) {
@@ -1821,6 +1828,11 @@ func (a *Account) SupportsOpenAIEndpointCapability(capability OpenAIEndpointCapa
if a == nil {
return false
}
if capability == OpenAIEndpointCapabilitySeedance {
configured, _ := a.openAIEndpointCapabilitySet()
return configured["seedance"] && a.Platform == PlatformOpenAI && a.Type == AccountTypeAPIKey &&
strings.TrimSpace(a.GetCredential("base_url")) != ""
}
if capability == "" {
return true
}
@@ -231,6 +231,82 @@ func TestAccountIsModelSupported(t *testing.T) {
requestedModel: "gemini-3-flash",
expected: false,
},
// DeepSeek 空映射:官方模型白名单(不再是「允许所有」)
{
name: "deepseek empty mapping allows official flash",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-flash",
expected: true,
},
{
name: "deepseek empty mapping normalizes claude code long context suffix",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-flash[1m]",
expected: true,
},
{
name: "deepseek empty mapping matches case-insensitively",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-FLASH",
expected: true,
},
{
name: "deepseek empty mapping allows versioned pro name",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-v4-pro-0813",
expected: true,
},
{
name: "deepseek empty mapping rejects retired chat model",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-chat",
expected: false,
},
{
name: "deepseek empty mapping rejects foreign claude model",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "claude-sonnet-4-6",
expected: false,
},
{
name: "deepseek empty mapping rejects unknown gpt model",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "gpt-future-model",
expected: false,
},
{
name: "deepseek empty mapping rejects typo",
platform: PlatformDeepseek,
credentials: map[string]any{},
requestedModel: "deepseek-flas",
expected: false,
},
{
name: "deepseek explicit mapping wins over whitelist",
platform: PlatformDeepseek,
credentials: map[string]any{
"model_mapping": map[string]any{
"foo-bar": "deepseek-flash",
},
},
requestedModel: "foo-bar",
expected: true,
},
{
name: "non deepseek empty mapping still allows all",
platform: PlatformAnthropic,
credentials: map[string]any{},
requestedModel: "any-model",
expected: true,
},
}
for _, tt := range tests {
@@ -86,7 +86,14 @@ func (s *AntigravityGatewayService) ForwardGemini(ctx context.Context, c *gin.Co
return nil, s.writeGoogleError(c, http.StatusNotFound, "Unsupported action: "+action)
}
mappedModel := s.getMappedModel(account, originalModel)
// 裸模型名(gemini-3.8-flash)+ thinkingConfig → 账号映射表里对应的 -low/-medium/-high 变体;
// 裸名有显式映射时保持原行为。
mappedModel, variantResolved := resolveGeminiThinkingVariant(account, originalModel, body)
if !variantResolved {
mappedModel = s.getMappedModel(account, originalModel)
} else {
logger.LegacyPrintf("service.antigravity_gateway", "%s resolved bare Gemini model %s to thinking variant %s", prefix, originalModel, mappedModel)
}
if mappedModel == "" {
MarkOpsClientBusinessLimited(c, OpsClientBusinessLimitedReasonLocalFeatureGate)
return nil, s.writeGoogleError(c, http.StatusForbidden, fmt.Sprintf("model %s not in whitelist", originalModel))
@@ -1387,6 +1387,49 @@ func TestHandleClaudeStreamingResponse_NormalComplete(t *testing.T) {
// TestHandleGeminiStreamingResponse_ThoughtsTokenCount
// 验证:Gemini 流式转发时 thoughtsTokenCount 被计入 OutputTokens
// 回归:上游事件之间的空分隔行不能再透传,否则下游看到的是 "data: ...\n\n\n"。
// google-genai Go SDK 按 "\n\n" 切事件,多出的 "\n" 会让第二个事件的前缀变成
// "\ndata" 而报 invalid stream chunk(Antigravity CLI 每条流都在第二个事件中断)。
func TestHandleGeminiStreamingResponse_EventSeparatorIsExactlyOneBlankLine(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize},
})
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
pr, pw := io.Pipe()
resp := &http.Response{StatusCode: http.StatusOK, Body: pr, Header: http.Header{}}
first := `{"candidates":[{"content":{"role":"model","parts":[{"text":"Hello"}]}}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3}}`
second := `{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig","text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":3,"thoughtsTokenCount":5}}`
go func() {
defer func() { _ = pw.Close() }()
// 上游原样:每个 data 行后跟一个空分隔行(第二个事件用 CRLF,覆盖两种换行)
fmt.Fprintf(pw, "data: %s\n\n", first)
fmt.Fprintf(pw, "data: %s\r\n\r\n", second)
}()
result, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
_ = pr.Close()
require.NoError(t, err)
require.NotNil(t, result)
body := rec.Body.String()
require.Equal(t, "data: "+first+"\n\ndata: "+second+"\n\n", body)
require.NotContains(t, body, "\n\n\n", "events must be separated by exactly one blank line")
// 模拟 google-genai 的切帧方式:按 "\n\n" 切,每个非空 token 都必须以 "data" 为前缀
for _, token := range strings.Split(strings.TrimSuffix(body, "\n\n"), "\n\n") {
prefix, _, _ := strings.Cut(token, ":")
require.Equal(t, "data", prefix, "token %q would be rejected by a \\n\\n-delimited SSE parser", token)
}
}
func TestHandleGeminiStreamingResponse_ThoughtsTokenCount(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := newAntigravityTestService(&config.Config{
@@ -197,6 +197,11 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
if s.settingService.cfg != nil && s.settingService.cfg.Gateway.StreamKeepaliveInterval > 0 {
keepaliveInterval = time.Duration(s.settingService.cfg.Gateway.StreamKeepaliveInterval) * time.Second
}
// go-genai / python-genai 不会忽略 SSE 注释行,收到 ":\n\n" 会直接把整个流判成
// invalid stream chunk 而中断(Antigravity CLI 在用 go-genai)。对这类客户端宁可不发心跳。
if keepaliveInterval > 0 && downstreamRejectsSSEComments(c) {
keepaliveInterval = 0
}
var keepaliveTicker *time.Ticker
if keepaliveInterval > 0 {
keepaliveTicker = time.NewTicker(keepaliveInterval)
@@ -288,6 +293,14 @@ func (s *AntigravityGatewayService) handleGeminiStreamingResponse(c *gin.Context
continue
}
// 上游每个 data 事件后面跟一个空行作为事件分隔。上面已经把 data 行写成
// "data: ...\n\n",若再把这个空行透传出去,事件之间就会变成 "\n\n\n"。
// google-genai 的 Go SDK(Antigravity CLI 在用)按 "\n\n" 切事件,多出的
// "\n" 会粘到下一个事件开头,前缀变成 "\ndata" 而被判成 invalid stream chunk。
if trimmed == "" {
continue
}
cw.Fprintf("%s\n", line)
case <-intervalCh:
@@ -0,0 +1,137 @@
package service
import (
"encoding/json"
"strings"
)
// Gemini 原生请求(/v1beta/models/{model}:generateContent 等)经 Antigravity 账号转发时,
// 客户端(如 Antigravity CLI / go-genai)习惯发"裸"模型名(gemini-3.8-flash)并用
// generationConfig.thinkingConfig 表达思考深度;而 Antigravity 上游的模型目录只有
// gemini-3.8-flash-low / -medium / -high / -tiered 这类带后缀的变体,裸名直接转发会被上游
// 以 404 "Requested entity was not found." 拒绝。
//
// 这里在账号 model_mapping 没有为裸名配置显式条目、但配置了它的后缀变体时,
// 按 thinkingConfig 自动挑一个变体:
// - thinkingLevel: "low" / "medium" / "high" → 同名后缀
// - thinkingBudget: -1(动态)→ high;0 → low;1..1024 → low;1025..8192 → medium;>8192 → high
// - 未携带 thinkingConfig → high(与 Gemini 3 系列默认开启动态思考一致)
// 选中的后缀在映射表里不存在时按 high → medium → low → tiered 的顺序降级到存在的变体。
// 显式映射(裸名本身在 model_mapping 里)始终优先,行为与现在完全一致。
var geminiThinkingVariantSuffixes = []string{"-low", "-medium", "-high", "-tiered"}
const (
geminiThinkingBudgetLowMax = 1024
geminiThinkingBudgetMediumMax = 8192
)
type geminiThinkingConfigProbe struct {
GenerationConfig struct {
ThinkingConfig *struct {
ThinkingBudget *json.Number `json:"thinkingBudget"`
ThinkingLevel string `json:"thinkingLevel"`
} `json:"thinkingConfig"`
} `json:"generationConfig"`
}
// hasGeminiThinkingVariantSuffix 判断模型名是否已经带了思考深度后缀。
func hasGeminiThinkingVariantSuffix(model string) bool {
for _, suffix := range geminiThinkingVariantSuffixes {
if strings.HasSuffix(model, suffix) {
return true
}
}
return false
}
// geminiThinkingLevelFromBody 从 Gemini 原生请求体推导期望的思考档位(low/medium/high)。
// 解析失败或未携带 thinkingConfig 时返回 "high"。
func geminiThinkingLevelFromBody(body []byte) string {
if len(body) == 0 {
return "high"
}
var probe geminiThinkingConfigProbe
if err := json.Unmarshal(body, &probe); err != nil || probe.GenerationConfig.ThinkingConfig == nil {
return "high"
}
tc := probe.GenerationConfig.ThinkingConfig
switch strings.ToLower(strings.TrimSpace(tc.ThinkingLevel)) {
case "low":
return "low"
case "medium":
return "medium"
case "high":
return "high"
}
if tc.ThinkingBudget == nil {
return "high"
}
budget, err := tc.ThinkingBudget.Float64()
if err != nil {
return "high"
}
switch {
case budget < 0:
return "high" // -1 = 动态思考
case budget <= geminiThinkingBudgetLowMax:
return "low" // 含 0(关闭思考):上游没有"无思考"变体,取最浅档,budget 本身仍原样透传
case budget <= geminiThinkingBudgetMediumMax:
return "medium"
default:
return "high"
}
}
// accountRawModelMappingHasKey 判断 key 是否由用户写在账号 credentials.model_mapping 里
// (而不是 resolveModelMapping 运行时补进来的默认透传条目)。
func accountRawModelMappingHasKey(account *Account, key string) bool {
if account == nil || account.Credentials == nil {
return false
}
raw, _ := account.Credentials["model_mapping"].(map[string]any)
_, ok := raw[key]
return ok
}
// resolveGeminiThinkingVariant 为裸 Gemini 模型名挑选账号映射表里存在的思考深度变体。
// 返回 (映射后的上游模型名, 是否命中)。未命中时调用方应回退到常规 getMappedModel 流程。
func resolveGeminiThinkingVariant(account *Account, requestedModel string, body []byte) (string, bool) {
if account == nil {
return "", false
}
model := strings.TrimSpace(strings.TrimPrefix(requestedModel, "models/"))
if !strings.HasPrefix(model, "gemini-") || hasGeminiThinkingVariantSuffix(model) {
return "", false
}
mapping := account.GetModelMapping()
if len(mapping) == 0 {
return "", false
}
// 裸名本身已有"真正的"映射 → 尊重现有配置,不做推导。判定分两层:
// 1. 映射目标不是自己(如 gemini-3.8-flash → gemini-3.8-flash-tiered):用户明确指定了目标;
// 2. 映射目标是自己(原样透传):仅当这条是用户亲手写进 credentials.model_mapping 的才尊重。
// resolveModelMapping 会给每个 Antigravity 账号自动补 gemini-3.x-flash 裸名自映射
// (ensureAntigravityDefaultPassthroughs),而上游目录里并没有裸名模型,那条自映射
// 正是导致 404 的来源,不能算作用户意图。
if mapped, matched := resolveRequestedModelInMapping(mapping, model); matched {
if strings.TrimSpace(mapped) != model || accountRawModelMappingHasKey(account, model) {
return "", false
}
}
preferred := geminiThinkingLevelFromBody(body)
order := []string{preferred}
for _, level := range []string{"high", "medium", "low", "tiered"} {
if level != preferred {
order = append(order, level)
}
}
for _, level := range order {
candidate := model + "-" + level
if mapped, matched := resolveRequestedModelInMapping(mapping, candidate); matched && strings.TrimSpace(mapped) != "" {
return mapped, true
}
}
return "", false
}
@@ -0,0 +1,118 @@
package service
import (
"testing"
"github.com/stretchr/testify/require"
)
func newAntigravityAccountWithMapping(mapping map[string]string) *Account {
mappingAny := make(map[string]any, len(mapping))
for k, v := range mapping {
mappingAny[k] = v
}
return &Account{
ID: 1,
Name: "ag",
Platform: PlatformAntigravity,
Credentials: map[string]any{
"model_mapping": mappingAny,
},
}
}
func TestGeminiThinkingLevelFromBody(t *testing.T) {
tests := []struct {
name string
body string
want string
}{
{"empty body", ``, "high"},
{"no thinkingConfig", `{"contents":[]}`, "high"},
{"budget -1 dynamic", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`, "high"},
{"budget 0 off", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":0}}}`, "low"},
{"budget 1000 (agy low)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":1000}}}`, "low"},
{"budget 4000 (agy medium)", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":4000}}}`, "medium"},
{"budget 24576", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576}}}`, "high"},
{"thinkingLevel wins over budget", `{"generationConfig":{"thinkingConfig":{"thinkingBudget":24576,"thinkingLevel":"low"}}}`, "low"},
{"thinkingLevel medium", `{"generationConfig":{"thinkingConfig":{"thinkingLevel":"MEDIUM"}}}`, "medium"},
{"invalid json", `{"generationConfig":`, "high"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, geminiThinkingLevelFromBody([]byte(tt.body)))
})
}
}
func TestResolveGeminiThinkingVariant(t *testing.T) {
// 与生产 Antigravity 账号从上游同步来的目录一致:只有带后缀的变体,没有裸名。
catalog := map[string]string{
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
"gemini-3.8-flash-medium": "gemini-3.8-flash-medium",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
"gemini-3.8-flash-tiered": "gemini-3.8-flash-tiered",
"gemini-3.6-flash-high": "gemini-3.6-flash-high",
"gemini-3.1-pro-high": "gemini-3.1-pro-high",
"gemini-2.5-flash": "gemini-2.5-flash",
}
budget := func(v string) []byte {
return []byte(`{"contents":[],"generationConfig":{"thinkingConfig":{"thinkingBudget":` + v + `}}}`)
}
tests := []struct {
name string
mapping map[string]string
model string
body []byte
want string
wantMatch bool
}{
{"agy high (-1) -> -high", catalog, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
{"agy medium (4000) -> -medium", catalog, "gemini-3.8-flash", budget("4000"), "gemini-3.8-flash-medium", true},
{"agy low (1000) -> -low", catalog, "gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
{"models/ prefix stripped", catalog, "models/gemini-3.8-flash", budget("1000"), "gemini-3.8-flash-low", true},
{"no thinkingConfig -> -high", catalog, "gemini-3.8-flash", []byte(`{"contents":[]}`), "gemini-3.8-flash-high", true},
// gemini-3.6/3.7/3.8-flash 的四个变体会被 resolveModelMapping 自动补齐,所以降级用一个不在默认表里的型号
{"only -high exists: low request degrades to high", map[string]string{
"gemini-3.5-flash-high": "gemini-3.5-flash-high",
}, "gemini-3.5-flash", budget("1000"), "gemini-3.5-flash-high", true},
{"injected default variants are usable", catalog, "gemini-3.6-flash", budget("1000"), "gemini-3.6-flash-low", true},
{"already suffixed: untouched", catalog, "gemini-3.8-flash-low", budget("-1"), "", false},
{"bare name explicitly mapped: untouched", catalog, "gemini-2.5-flash", budget("-1"), "", false},
{"no variants in mapping: untouched", catalog, "gemini-9.9-flash", budget("-1"), "", false},
{"non-gemini model: untouched", catalog, "claude-sonnet-4-6", budget("-1"), "", false},
{"explicit bare alias wins over variants", map[string]string{
"gemini-3.8-flash": "gemini-3.8-flash-tiered",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
}, "gemini-3.8-flash", budget("-1"), "", false},
{"variant value is itself a mapping target", map[string]string{
"gemini-3.8-flash-high": "gemini-3.8-flash-tiered",
}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-tiered", true},
{"user-written bare identity passthrough: respected", map[string]string{
"gemini-3.8-flash": "gemini-3.8-flash",
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
}, "gemini-3.8-flash", budget("1000"), "", false},
{"runtime-injected bare passthrough (not in credentials): still resolved", map[string]string{
// resolveModelMapping 会补 gemini-3.7-flash → gemini-3.7-flash 的默认透传,
// 但 credentials 里没有这条,应当继续推导变体。
"gemini-3.7-flash-medium": "gemini-3.7-flash-medium",
}, "gemini-3.7-flash", budget("4000"), "gemini-3.7-flash-medium", true},
// 空 credentials 映射 → 走 DefaultAntigravityModelMapping,其中同样只有带后缀的 3.8 flash
{"empty mapping falls back to default catalog", map[string]string{}, "gemini-3.8-flash", budget("-1"), "gemini-3.8-flash-high", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
account := newAntigravityAccountWithMapping(tt.mapping)
got, matched := resolveGeminiThinkingVariant(account, tt.model, tt.body)
require.Equal(t, tt.wantMatch, matched)
require.Equal(t, tt.want, got)
})
}
t.Run("nil account", func(t *testing.T) {
got, matched := resolveGeminiThinkingVariant(nil, "gemini-3.8-flash", budget("-1"))
require.False(t, matched)
require.Empty(t, got)
})
}
@@ -834,6 +834,16 @@ func TestNormalizeDeepSeekResponsesRequestBody(t *testing.T) {
require.False(t, gjson.GetBytes(adaptiveNormalized, "store").Bool())
require.False(t, gjson.GetBytes(adaptiveNormalized, "previous_response_id").Exists())
mediaBody := []byte(`{"model":"deepseek-v4.1-flash","store":true,"input":[{"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"}]}]}`)
mediaNormalized := normalizeDeepSeekResponsesRequestBody(deepseekResponses, mediaBody)
require.False(t, gjson.GetBytes(mediaNormalized, "store").Bool())
require.Equal(t, gjson.String, gjson.GetBytes(mediaNormalized, "input.1.output").Type)
require.NotContains(t, gjson.GetBytes(mediaNormalized, "input.1.output").String(), "data:image/png")
require.Equal(t, "message", gjson.GetBytes(mediaNormalized, "input.2.type").String())
require.Equal(t, "user", gjson.GetBytes(mediaNormalized, "input.2.role").String())
require.Equal(t, "input_image", gjson.GetBytes(mediaNormalized, "input.2.content.1.type").String())
require.Equal(t, "data:image/png;base64,AQID", gjson.GetBytes(mediaNormalized, "input.2.content.1.image_url").String())
// 非 responses 协议(deepseek CC 账号)原样返回
deepseekCC := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
require.Equal(t, string(body), string(normalizeDeepSeekResponsesRequestBody(deepseekCC, body)))
@@ -74,6 +74,53 @@ func TestAdaptResponsesClientToolsForAnthropic_LiftsAdditionalTools(t *testing.T
require.Equal(t, "message", input[0].(map[string]any)["type"])
}
// Codex 的 codex_app 工具(如 automation_update)把 parameters 根节点声明成对象
// 联合;Responses→Anthropic 转换后 input_schema 顶部不能再出现联合关键字。
func TestAdaptResponsesClientToolsForAnthropic_FlattensRootUnionSchema(t *testing.T) {
t.Parallel()
body := []byte(`{
"model":"claude-opus-5",
"input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"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"]}
]}
}]}]
}`)
adapted, _, err := adaptResponsesClientToolsForAnthropic(body)
require.NoError(t, err)
var responsesReq apicompat.ResponsesRequest
require.NoError(t, json.Unmarshal(adapted, &responsesReq))
claudeReq, err := apicompat.ResponsesToAnthropicRequest(&responsesReq)
require.NoError(t, err)
require.Len(t, claudeReq.Tools, 1)
tool := claudeReq.Tools[0]
require.Equal(t, "codex_app__automation_update", tool.Name)
var schema map[string]json.RawMessage
require.NoError(t, json.Unmarshal(tool.InputSchema, &schema))
require.JSONEq(t, `"object"`, string(schema["type"]))
require.NotContains(t, schema, "oneOf")
require.NotContains(t, schema, "anyOf")
require.NotContains(t, schema, "allOf")
require.JSONEq(t, `["mode","id"]`, string(schema["required"]))
wire, err := json.Marshal(tool)
require.NoError(t, err)
require.NotContains(t, string(wire), `"input_schema":{"oneOf"`)
require.NotContains(t, string(wire), `"input_schema":{"anyOf"`)
require.NotContains(t, string(wire), `"input_schema":{"allOf"`)
}
func namespaceToolAnthropicStream() string {
return strings.Join([]string{
`event: message_start`,
@@ -16,6 +16,7 @@ import (
mathrand "math/rand"
"net/http"
"regexp"
"sort"
"strings"
"time"
@@ -483,6 +484,32 @@ func (s *GeminiMessagesCompatService) validateUpstreamBaseURL(raw string) (strin
return normalized, nil
}
// AntigravityGeminiModelIDs returns the effective Gemini mappings of schedulable
// Antigravity accounts. Mixed routes must additionally respect the account opt-in.
func (s *GeminiMessagesCompatService) AntigravityGeminiModelIDs(ctx context.Context, groupID *int64, requireMixed bool) ([]string, error) {
accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false)
if err != nil {
return nil, err
}
seen := make(map[string]struct{})
for _, account := range accounts {
if account.Platform != PlatformAntigravity || (requireMixed && !account.IsMixedSchedulingEnabled()) {
continue
}
for model := range account.GetModelMapping() {
if isAntigravityGeminiModel(model) {
seen[model] = struct{}{}
}
}
}
models := make([]string, 0, len(seen))
for model := range seen {
models = append(models, model)
}
sort.Strings(models)
return models, nil
}
// HasAntigravityAccounts 检查是否有可用的 antigravity 账户
func (s *GeminiMessagesCompatService) HasAntigravityAccounts(ctx context.Context, groupID *int64) (bool, error) {
accounts, err := s.listSchedulableAccountsOnce(ctx, groupID, PlatformAntigravity, false)
@@ -0,0 +1,35 @@
package service
import (
"strings"
"github.com/gin-gonic/gin"
)
// geminiClientRejectsSSEComments 判断下游客户端是否是「无法容忍 SSE 注释行」的 Google GenAI SDK。
//
// Gemini 原生流里我们用 ":\n\n" 作为空闲心跳(防止代理/Cloudflare Tunnel 断开),但官方 SDK 里
// 有两个实现不按 SSE 规范忽略注释行:
// - go-genai(Antigravity CLI 在用):api_client.go iterateResponseStream 对任何非 "data:" 前缀的
// 事件直接报 "invalid stream chunk: :",整个流中断;
// - python-genai:_api_client.py 把非 "data:" 行当作错误 JSON 拼接后 json.loads,同样抛错。
//
// js-genai 用的是规范的 event-stream 解析器,能正确忽略注释。Google 官方 API 从不下发注释行,
// 所以这两个 SDK 从未处理过这种输入。识别依据是 SDK 固定携带的 User-Agent / X-Goog-Api-Client,
// 形如 "google-genai-sdk/1.71.0 gl-go/go1.28 ..." 或 "google-genai-sdk/1.x gl-python/3.12"。
func geminiClientRejectsSSEComments(clientHint string) bool {
hint := strings.ToLower(strings.TrimSpace(clientHint))
if !strings.Contains(hint, "google-genai-sdk/") {
return false
}
return strings.Contains(hint, "gl-go/") || strings.Contains(hint, "gl-python/")
}
// downstreamRejectsSSEComments 从请求头识别下游客户端(同时看 User-Agent 与 X-Goog-Api-Client)。
func downstreamRejectsSSEComments(c *gin.Context) bool {
if c == nil || c.Request == nil {
return false
}
return geminiClientRejectsSSEComments(c.GetHeader("User-Agent")) ||
geminiClientRejectsSSEComments(c.GetHeader("X-Goog-Api-Client"))
}
@@ -0,0 +1,95 @@
package service
import (
"io"
"net/http"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func TestGeminiClientRejectsSSEComments(t *testing.T) {
cases := []struct {
name string
hint string
want bool
}{
{"go-genai (Antigravity CLI)", "google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03 cl/951519500 +3ebc191975 X:fieldtrack,boringcrypto", true},
{"python-genai", "google-genai-sdk/1.20.0 gl-python/3.12.4", true},
{"js-genai tolerates comments", "google-genai-sdk/1.9.0 gl-node/22.3.0", false},
{"gemini-cli", "GeminiCLI/0.60.0 (darwin; arm64)", false},
{"curl", "curl/8.7.1", false},
{"empty", "", false},
{"case-insensitive", "Google-GenAI-SDK/1.0.0 GL-Go/go1.27", true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
require.Equal(t, tc.want, geminiClientRejectsSSEComments(tc.hint))
})
}
}
func TestDownstreamRejectsSSECommentsReadsBothHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := newAntigravityCompatContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:streamGenerateContent", nil)
require.False(t, downstreamRejectsSSEComments(c))
c.Request.Header.Set("X-Goog-Api-Client", "google-genai-sdk/1.71.0 gl-go/go1.28")
require.True(t, downstreamRejectsSSEComments(c))
c.Request.Header.Del("X-Goog-Api-Client")
c.Request.Header.Set("User-Agent", "google-genai-sdk/1.71.0 gl-go/go1.28")
require.True(t, downstreamRejectsSSEComments(c))
require.False(t, downstreamRejectsSSEComments(nil))
}
// runAntigravityGeminiStreamWithIdle 起一条上游流:先发一个 data 事件,然后空闲 idle 时长再关闭,
// 返回写给下游的全部字节。用来观察空闲期间网关是否发了 ":\n\n" 心跳。
func runAntigravityGeminiStreamWithIdle(t *testing.T, userAgent string, idle time.Duration) string {
t.Helper()
gin.SetMode(gin.TestMode)
svc := newAntigravityCompatService(
config.GatewayConfig{MaxLineSize: defaultMaxLineSize, StreamKeepaliveInterval: 1},
nil,
)
c, recorder := newAntigravityCompatContext(http.MethodPost, "/v1beta/models/gemini-3.8-flash:streamGenerateContent", nil)
if userAgent != "" {
c.Request.Header.Set("User-Agent", userAgent)
}
reader, writer := io.Pipe()
resp := &http.Response{StatusCode: http.StatusOK, Header: http.Header{}, Body: reader}
done := make(chan error, 1)
go func() {
_, err := svc.handleGeminiStreamingResponse(c, resp, time.Now())
done <- err
}()
_, err := io.WriteString(
writer,
`data: {"response":{"responseId":"resp_1","candidates":[{"content":{"parts":[{"text":"partial"}]}}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":1}}}`+"\n\n",
)
require.NoError(t, err)
time.Sleep(idle)
require.NoError(t, writer.Close())
require.NoError(t, <-done)
require.NoError(t, reader.Close())
return recorder.Body.String()
}
func TestAntigravityGeminiStreamKeepsCommentKeepaliveForOrdinaryClients(t *testing.T) {
out := runAntigravityGeminiStreamWithIdle(t, "curl/8.7.1", 1200*time.Millisecond)
require.Contains(t, out, ":\n\n", "ordinary clients should still get the idle keepalive")
require.Contains(t, out, `"text":"partial"`)
}
func TestAntigravityGeminiStreamSkipsCommentKeepaliveForGoGenai(t *testing.T) {
out := runAntigravityGeminiStreamWithIdle(t, "google-genai-sdk/1.71.0 gl-go/go1.28-20260721-RC03", 1200*time.Millisecond)
require.Contains(t, out, `"text":"partial"`)
for _, event := range strings.Split(out, "\n\n") {
require.False(t, strings.HasPrefix(event, ":"), "go-genai must never receive an SSE comment event, got %q", event)
}
}
+11 -2
View File
@@ -41,10 +41,13 @@ func (e GrokMediaEndpoint) RequiresRequestBody() bool {
}
func (e GrokMediaEndpoint) IsVideoLookupRequest() bool {
return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent
return e == GrokMediaEndpointVideoStatus || e == GrokMediaEndpointVideoContent || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete
}
func (e GrokMediaEndpoint) IsGenerationRequest() bool {
if e == SeedanceEndpointCreate {
return true
}
switch e {
case GrokMediaEndpointImagesGenerations, GrokMediaEndpointImagesEdits, GrokMediaEndpointVideosGenerations, GrokMediaEndpointVideosEdits, GrokMediaEndpointVideosExtensions:
return true
@@ -337,6 +340,12 @@ func (s *OpenAIGatewayService) ResolveGrokMediaVideoRequestAccount(
// the ownership key; video lookups must neither escape nor refresh that key.
func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount(
ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel string,
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
return s.SelectMediaVideoRequestAccount(ctx, groupID, sessionHash, accountID, requestedModel, PlatformGrok)
}
func (s *OpenAIGatewayService) SelectMediaVideoRequestAccount(
ctx context.Context, groupID *int64, sessionHash string, accountID int64, requestedModel, platform string,
) (*AccountSelectionResult, OpenAIAccountScheduleDecision, error) {
decision := OpenAIAccountScheduleDecision{Layer: openAIAccountScheduleLayerSessionSticky}
if accountID <= 0 || strings.TrimSpace(sessionHash) == "" {
@@ -345,7 +354,7 @@ func (s *OpenAIGatewayService) SelectGrokMediaVideoRequestAccount(
ctx = s.withOpenAIGroupPrivacyRequirement(WithOpenAIProfitControlSuppressed(ctx), groupID)
scheduler := &defaultOpenAIAccountScheduler{service: s}
selection, _, err := scheduler.selectBySessionHash(ctx, OpenAIAccountScheduleRequest{
GroupID: groupID, Platform: PlatformGrok, SessionHash: sessionHash,
GroupID: groupID, Platform: platform, SessionHash: sessionHash,
StickyAccountID: accountID, PreserveStickyBinding: true, DisableStickyEscape: true,
RequestedModel: requestedModel, RequiredTransport: OpenAIUpstreamTransportHTTPSSE,
RequirePrivacySet: s.openAIGroupRequiresPrivacySet(ctx, groupID),
@@ -961,15 +961,15 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan(
}
}
// Reset 因子(use-it-or-lose-it):在拥有「未来会话窗口结束时间」的账号中,
// Reset 因子(use-it-or-lose-it):优先读取 Codex 5h 重置时间,回退到会话窗口。
// 剩余时间越短 → 因子越接近 1(越早重置越优先用尽)。无活跃窗口的账号因子为 0。
// 仅在 weights.Reset > 0 时计算,默认关闭不影响原有行为。
minResetRemaining, maxResetRemaining := 0.0, 0.0
hasResetSample := false
if weights.Reset > 0 {
for _, candidate := range candidates {
end := candidate.account.SessionWindowEnd
if end == nil || !now.Before(*end) {
end, ok := openAISchedulingResetWindowEnd(candidate.account, now)
if !ok {
continue
}
remaining := end.Sub(now).Seconds()
@@ -1002,7 +1002,7 @@ func (s *defaultOpenAIAccountScheduler) buildOpenAIAccountLoadPlan(
}
resetFactor := 0.0
if weights.Reset > 0 && hasResetSample {
if end := item.account.SessionWindowEnd; end != nil && now.Before(*end) {
if end, ok := openAISchedulingResetWindowEnd(item.account, now); ok {
if maxResetRemaining > minResetRemaining {
resetFactor = 1 - clamp01((end.Sub(now).Seconds()-minResetRemaining)/(maxResetRemaining-minResetRemaining))
} else {
@@ -2732,8 +2732,8 @@ func buildOpenAIAccountSchedulerScoreSnapshot(
}
if weights.Reset > 0 {
for _, candidate := range candidates {
end := candidate.account.SessionWindowEnd
if end == nil || !now.Before(*end) {
end, ok := openAISchedulingResetWindowEnd(candidate.account, now)
if !ok {
continue
}
remaining := end.Sub(now).Seconds()
@@ -2763,7 +2763,7 @@ func buildOpenAIAccountSchedulerScoreSnapshot(
ttftFactor := 0.5
resetFactor := 0.0
if weights.Reset > 0 && hasResetSample {
if end := candidate.account.SessionWindowEnd; end != nil && now.Before(*end) {
if end, ok := openAISchedulingResetWindowEnd(candidate.account, now); ok {
if maxResetRemaining > minResetRemaining {
resetFactor = 1 - clamp01((end.Sub(now).Seconds()-minResetRemaining)/(maxResetRemaining-minResetRemaining))
} else {
@@ -2952,22 +2952,89 @@ func openAIQuotaHeadroomFactor(account *Account, now time.Time) float64 {
if account == nil || len(account.Extra) == 0 || openAIQuotaHeadroomSnapshotStale(account.Extra, now) {
return openAIQuotaHeadroomNeutralFactor
}
primaryUsedPercent, ok := resolveAccountExtraNumber(account.Extra, "codex_primary_used_percent", "codex_7d_used_percent")
if !ok || openAIQuotaWindowResetAny(account.Extra, now, "primary", "7d") {
window5h, window7d := openAICanonicalQuotaWindows(account.Extra, now)
if !window7d.hasUsed || window7d.reset {
return openAIQuotaHeadroomNeutralFactor
}
factor := 1 - clamp01(primaryUsedPercent/100)
if secondaryUsedPercent, ok := resolveAccountExtraNumber(account.Extra, "codex_secondary_used_percent", "codex_5h_used_percent"); ok &&
!openAIQuotaWindowResetAny(account.Extra, now, "secondary", "5h") {
secondaryRemaining := 1 - clamp01(secondaryUsedPercent/100)
if secondaryRemaining < openAIQuotaHeadroomSecondaryLowRemain {
factor := 1 - clamp01(window7d.usedPercent/100)
if window5h.hasUsed && !window5h.reset {
remaining := 1 - clamp01(window5h.usedPercent/100)
if remaining < openAIQuotaHeadroomSecondaryLowRemain {
factor *= openAIQuotaHeadroomNeutralFactor
}
}
return factor
}
type openAICanonicalQuotaWindow struct {
usedPercent float64
hasUsed bool
reset bool
}
// 规范字段优先;历史原始字段复用写入端 Normalize 的窗口分类,不能固定把 primary 当作 7d。
func openAICanonicalQuotaWindows(extra map[string]any, now time.Time) (window5h, window7d openAICanonicalQuotaWindow) {
if used, ok := resolveAccountExtraNumber(extra, "codex_5h_used_percent"); ok {
window5h = openAICanonicalQuotaWindow{usedPercent: used, hasUsed: true, reset: openAIQuotaWindowReset(extra, "5h", now)}
}
if used, ok := resolveAccountExtraNumber(extra, "codex_7d_used_percent"); ok {
window7d = openAICanonicalQuotaWindow{usedPercent: used, hasUsed: true, reset: openAIQuotaWindowReset(extra, "7d", now)}
}
if window5h.hasUsed && window7d.hasUsed {
return window5h, window7d
}
snapshot := &OpenAICodexUsageSnapshot{}
if used, ok := resolveAccountExtraNumber(extra, "codex_primary_used_percent"); ok {
snapshot.PrimaryUsedPercent = &used
}
if used, ok := resolveAccountExtraNumber(extra, "codex_secondary_used_percent"); ok {
snapshot.SecondaryUsedPercent = &used
}
if minutes := parseExtraInt(extra["codex_primary_window_minutes"]); minutes > 0 {
snapshot.PrimaryWindowMinutes = &minutes
}
if minutes := parseExtraInt(extra["codex_secondary_window_minutes"]); minutes > 0 {
snapshot.SecondaryWindowMinutes = &minutes
}
normalized := snapshot.Normalize()
if normalized == nil {
return window5h, window7d
}
fromRaw := func(used *float64) openAICanonicalQuotaWindow {
if used == nil {
return openAICanonicalQuotaWindow{}
}
// Normalize 保留原始字段指针,据此配对同一个窗口的用量和重置时间。
window := "secondary"
if used == snapshot.PrimaryUsedPercent {
window = "primary"
}
return openAICanonicalQuotaWindow{usedPercent: *used, hasUsed: true, reset: openAIQuotaWindowReset(extra, window, now)}
}
if !window5h.hasUsed {
window5h = fromRaw(normalized.Used5hPercent)
}
if !window7d.hasUsed {
window7d = fromRaw(normalized.Used7dPercent)
}
return window5h, window7d
}
func openAISchedulingResetWindowEnd(account *Account, now time.Time) (time.Time, bool) {
if account == nil {
return time.Time{}, false
}
if end, ok := openAICodexWindowResetAt(account.Extra, "5h"); ok && now.Before(end) {
return end, true
}
if end := account.SessionWindowEnd; end != nil && now.Before(*end) {
return *end, true
}
return time.Time{}, false
}
func openAIQuotaHeadroomSnapshotStale(extra map[string]any, now time.Time) bool {
updatedRaw, ok := extra["codex_usage_updated_at"]
if !ok {
@@ -2980,15 +3047,6 @@ func openAIQuotaHeadroomSnapshotStale(extra map[string]any, now time.Time) bool
return now.Sub(updatedAt) >= openAIQuotaHeadroomSnapshotStaleAfter
}
func openAIQuotaWindowResetAny(extra map[string]any, now time.Time, windows ...string) bool {
for _, window := range windows {
if openAIQuotaWindowReset(extra, window, now) {
return true
}
}
return false
}
func clamp01(value float64) float64 {
switch {
case value < 0:
@@ -0,0 +1,141 @@
package service
import (
"context"
"strings"
"testing"
"time"
"github.com/stretchr/testify/require"
)
func TestOpenAIQuotaHeadroomFactorUsesCanonicalWindows(t *testing.T) {
now := time.Now()
shortMins, longMins := 300, 10080
shortReset, longReset := 3600, 86400
shortUsed, longUsed := 96.0, 25.0
for _, primaryIs5h := range []bool{true, false} {
name := "primary_is_7d"
snapshot := &OpenAICodexUsageSnapshot{
PrimaryUsedPercent: &longUsed, PrimaryWindowMinutes: &longMins, PrimaryResetAfterSeconds: &longReset,
SecondaryUsedPercent: &shortUsed, SecondaryWindowMinutes: &shortMins, SecondaryResetAfterSeconds: &shortReset,
}
if primaryIs5h {
name = "primary_is_5h"
snapshot = &OpenAICodexUsageSnapshot{
PrimaryUsedPercent: &shortUsed, PrimaryWindowMinutes: &shortMins, PrimaryResetAfterSeconds: &shortReset,
SecondaryUsedPercent: &longUsed, SecondaryWindowMinutes: &longMins, SecondaryResetAfterSeconds: &longReset,
}
}
t.Run(name, func(t *testing.T) {
for _, rawOnly := range []bool{false, true} {
fields := "canonical_and_raw"
if rawOnly {
fields = "raw_only"
}
t.Run(fields, func(t *testing.T) {
extra := buildCodexUsageExtraUpdates(snapshot, now)
if rawOnly {
for key := range extra {
if strings.HasPrefix(key, "codex_5h_") || strings.HasPrefix(key, "codex_7d_") {
delete(extra, key)
}
}
}
// 周余量 75%,5h 余量低于 10% 时沿用现有的减半规则。
require.InDelta(t, 0.375, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
})
}
})
}
}
func TestOpenAIQuotaHeadroomFactorCanonicalPrecedence(t *testing.T) {
now := time.Now()
extra := map[string]any{
"codex_primary_used_percent": 40.0,
"codex_secondary_used_percent": 20.0,
"codex_usage_updated_at": now.Format(time.RFC3339),
}
// 没有窗口长度的历史数据仍沿用 primary=7d、secondary=5h。
require.InDelta(t, 0.6, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
extra["codex_7d_used_percent"] = 80.0
extra["codex_5h_used_percent"] = 10.0
require.InDelta(t, 0.2, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
// 规范窗口有效时,不应被另一个原始窗口的过期时间误判为已重置。
extra["codex_7d_reset_at"] = now.Add(time.Hour).Format(time.RFC3339)
extra["codex_primary_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
require.InDelta(t, 0.2, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
extra["codex_7d_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now))
}
func TestOpenAIQuotaHeadroomFactorRawWindowReset(t *testing.T) {
now := time.Now()
extra := map[string]any{
"codex_primary_used_percent": 96.0,
"codex_primary_window_minutes": 300,
"codex_primary_reset_at": now.Add(-time.Minute).Format(time.RFC3339),
"codex_secondary_used_percent": 25.0,
"codex_secondary_reset_at": now.Add(time.Hour).Format(time.RFC3339),
"codex_usage_updated_at": now.Add(-time.Hour).Format(time.RFC3339),
}
require.InDelta(t, 0.75, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now), 1e-9)
extra["codex_secondary_reset_at"] = now.Add(-time.Minute).Format(time.RFC3339)
require.Equal(t, openAIQuotaHeadroomNeutralFactor, openAIQuotaHeadroomFactor(&Account{Extra: extra}, now))
}
func TestOpenAISchedulerCanonicalResetScoresMatchSnapshot(t *testing.T) {
now := time.Now()
scheduler := openAIResetTestScheduler(1)
accounts := []*Account{
{ID: 1, Extra: map[string]any{"codex_5h_reset_at": now.Add(10 * time.Minute).Format(time.RFC3339)}},
{ID: 2, Extra: map[string]any{"codex_5h_reset_at": now.Add(4 * time.Hour).Format(time.RFC3339)}},
}
plan := scheduler.buildOpenAIAccountLoadPlan(context.Background(), OpenAIAccountScheduleRequest{}, accounts, nil)
scores := openAIPlanScores(plan)
require.InDelta(t, 1, scores[1]-scores[2], 1e-9)
snapshots := buildOpenAIAccountSchedulerScoreSnapshot(accounts, nil, scheduler.service.openAIWSSchedulerWeights(), false, defaultOpenAIOAuthSchedulingRateMultiplier)
for _, account := range accounts {
require.InDelta(t, scores[account.ID], snapshots[account.ID].BaseScore, 1e-9)
}
}
func TestOpenAISchedulingResetWindowEnd(t *testing.T) {
now := time.Date(2026, 9, 1, 12, 0, 0, 0, time.UTC)
updated := now.Add(-30 * time.Minute)
account := &Account{Extra: map[string]any{
"codex_usage_updated_at": updated.Format(time.RFC3339),
"codex_5h_reset_after_seconds": 3600,
}}
end, ok := openAISchedulingResetWindowEnd(account, now)
require.True(t, ok)
require.Equal(t, updated.Add(time.Hour), end)
account.Extra["codex_5h_reset_at"] = now.Add(10 * time.Minute).Format(time.RFC3339)
end, ok = openAISchedulingResetWindowEnd(account, now)
require.True(t, ok)
require.Equal(t, now.Add(10*time.Minute), end)
delete(account.Extra, "codex_5h_reset_at")
account.Extra["codex_5h_reset_after_seconds"] = 60
_, ok = openAISchedulingResetWindowEnd(account, now)
require.False(t, ok)
account.Extra["codex_5h_reset_after_seconds"] = 3600
delete(account.Extra, "codex_usage_updated_at")
_, ok = openAISchedulingResetWindowEnd(account, now)
require.False(t, ok, "缺少采样时间时不能把相对倒计时锚定到当前时间")
account.Extra["codex_usage_updated_at"] = "invalid"
_, ok = openAISchedulingResetWindowEnd(account, now)
require.False(t, ok)
sessionEnd := now.Add(2 * time.Hour)
account.SessionWindowEnd = &sessionEnd
end, ok = openAISchedulingResetWindowEnd(account, now)
require.True(t, ok)
require.Equal(t, sessionEnd, end)
_, ok = openAISchedulingResetWindowEnd(nil, now)
require.False(t, ok)
}
@@ -0,0 +1,82 @@
package service
import (
"encoding/json"
"errors"
"net/url"
"strings"
)
func requiresSystemChatRole(account *Account, targetURL string) bool {
if account == nil || account.Type != AccountTypeAPIKey {
return false
}
switch account.Platform {
case PlatformDeepseek, PlatformKimi, PlatformZhipu:
return true
}
// OpenAI-compatible accounts can point at the same strict providers. Match
// the selected destination's exact hostname, never the requested model ID.
u, err := url.Parse(targetURL)
if err != nil {
return false
}
switch strings.ToLower(u.Hostname()) {
case "api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai":
return true
}
return false
}
// normalizeStrictChatDeveloperRoles adapts native Chat requests only after an
// account and destination have been selected. Unlike the Responses bridge, it
// does not reorder/merge messages or downgrade instructions to user messages.
// RawMessage preserves unknown fields and numeric precision. The input remains
// untouched so a retry on another account sees the original developer roles.
func normalizeStrictChatDeveloperRoles(account *Account, targetURL string, body []byte) ([]byte, error) {
if !requiresSystemChatRole(account, targetURL) {
return body, nil
}
var root map[string]json.RawMessage
if json.Unmarshal(body, &root) != nil || root == nil {
return nil, errors.New("chat role normalization: invalid request object")
}
raw, exists := root["messages"]
if !exists {
return body, nil
}
var messages []json.RawMessage
if json.Unmarshal(raw, &messages) != nil {
return nil, errors.New("chat role normalization: invalid messages array")
}
changed := false
for i, raw := range messages {
var message map[string]json.RawMessage
if json.Unmarshal(raw, &message) != nil || message == nil {
return nil, errors.New("chat role normalization: invalid message object")
}
var role string
if json.Unmarshal(message["role"], &role) != nil {
return nil, errors.New("chat role normalization: invalid message role")
}
if role != "developer" {
continue
}
message["role"] = json.RawMessage(`"system"`)
updated, err := json.Marshal(message)
if err != nil {
return nil, errors.New("chat role normalization: cannot encode message")
}
messages[i] = updated
changed = true
}
if !changed {
return body, nil
}
updated, err := json.Marshal(messages)
if err != nil {
return nil, errors.New("chat role normalization: cannot encode messages")
}
root["messages"] = updated
return json.Marshal(root)
}
@@ -0,0 +1,185 @@
//go:build unit
package service
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
// Only the upstream transport is stubbed. Requests go through the actual Chat
// dispatcher, model mapping, request construction and response/usage handling.
type chatRoleContractUpstream struct {
httpUpstreamRecorder
strict bool
}
func (u *chatRoleContractUpstream) Do(req *http.Request, proxyURL string, accountID int64, concurrency int) (*http.Response, error) {
body, err := io.ReadAll(req.Body)
if err != nil {
return nil, err
}
req.Body = io.NopCloser(bytes.NewReader(body))
status, contentType := http.StatusOK, "application/json"
result := `{"id":"chatcmpl_roles","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"pong"},"finish_reason":"stop"}],"usage":{"prompt_tokens":9,"completion_tokens":1,"total_tokens":10}}`
if u.strict {
for _, message := range gjson.GetBytes(body, "messages").Array() {
if message.Get("role").String() == "developer" {
status = http.StatusBadRequest
result = "{\"error\":{\"message\":\"unknown variant `developer`\",\"type\":\"invalid_request_error\"}}"
}
}
}
if status == http.StatusOK && gjson.GetBytes(body, "stream").Bool() {
contentType = "text/event-stream"
result = "data: {\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"pong\"},\"finish_reason\":\"stop\"}]}\n\ndata: {\"choices\":[],\"usage\":{\"prompt_tokens\":9,\"completion_tokens\":1,\"total_tokens\":10}}\n\ndata: [DONE]\n\n"
}
u.resp = &http.Response{StatusCode: status, Header: http.Header{"Content-Type": {contentType}}, Body: io.NopCloser(strings.NewReader(result))}
return u.httpUpstreamRecorder.Do(req, proxyURL, accountID, concurrency)
}
func TestForwardAsChatCompletions_StrictDeveloperRole(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, stream := range []bool{false, true} {
for _, target := range []struct{ platform, model, base string }{
{PlatformDeepseek, "deepseek-flash", "https://api.deepseek.com"},
{PlatformKimi, "k3", "https://api.kimi.com/coding"},
{PlatformZhipu, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"},
{PlatformOpenAI, "deepseek-flash", "https://api.deepseek.com"},
{PlatformOpenAI, "k3", "https://api.kimi.com/coding"},
{PlatformOpenAI, "kimi-k3", "https://api.moonshot.ai"},
{PlatformOpenAI, "glm-5.3", "https://open.bigmodel.cn/api/paas/v4"},
} {
t.Run(fmt.Sprintf("%s/%s/%s/stream=%t", target.platform, target.model, target.base, stream), func(t *testing.T) {
body := []byte(fmt.Sprintf(`{"model":"example-alias","messages":[{"role":"developer","content":"Keep these instructions."},{"role":"user","content":"ping"}],"stream":%t,"max_tokens":10}`, stream))
original := bytes.Clone(body)
upstream := &chatRoleContractUpstream{strict: true}
svc := &OpenAIGatewayService{cfg: rawChatCompletionsTestConfig(), httpUpstream: upstream}
account := rawChatCompletionsTestAccount()
account.Platform = target.platform
account.Credentials["base_url"] = target.base
account.Credentials["api_protocol"] = APIProtocolChatCompletions
account.Credentials["model_mapping"] = map[string]any{"example-alias": target.model}
account.Extra = map[string]any{openai_compat.ExtraKeyResponsesSupported: false}
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
result, err := svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 9, result.Usage.InputTokens)
require.Equal(t, 1, result.Usage.OutputTokens)
require.Equal(t, target.model, gjson.GetBytes(upstream.lastBody, "model").String())
require.Equal(t, "system", gjson.GetBytes(upstream.lastBody, "messages.0.role").String())
require.Equal(t, "Keep these instructions.", gjson.GetBytes(upstream.lastBody, "messages.0.content").String())
require.Equal(t, original, body, "per-account adaptation must not mutate the retry body")
require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), "pong")
if stream {
require.Contains(t, recorder.Body.String(), "data: [DONE]")
}
// A subsequent attempt with the same body on a compatible account
// must still see developer, not the previous account's system role.
upstream.strict = false
account.Platform = PlatformOpenAI
account.Credentials["base_url"] = "https://compatible.example.test/v1"
recorder = httptest.NewRecorder()
c, _ = gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", bytes.NewReader(body))
_, err = svc.ForwardAsChatCompletions(context.Background(), c, account, body, "", "")
require.NoError(t, err)
require.Equal(t, "developer", gjson.GetBytes(upstream.lastBody, "messages.0.role").String())
})
}
}
}
func TestNormalizeStrictChatDeveloperRoles_PreservesConversation(t *testing.T) {
body := []byte(`{"model":"alias","messages":[{"role":"system","content":"first"},{"role":"developer","content":[{"type":"text","text":"developer is just text","cache_control":{"type":"ephemeral"}}],"name":"rules"},{"role":"user","content":"ping"},{"role":"assistant","content":null,"tool_calls":[{"id":"call_1","type":"function","function":{"name":"echo","arguments":"{\"role\":\"developer\"}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"},{"role":"developer","content":"last"}],"metadata":{"large":9007199254740993,"role":"developer"},"stream":true,"reasoning_effort":"high"}`)
original := bytes.Clone(body)
want := strings.ReplaceAll(string(body), `"role":"developer","content"`, `"role":"system","content"`)
for _, platform := range []string{PlatformDeepseek, PlatformKimi, PlatformZhipu} {
t.Run(platform, func(t *testing.T) {
account := &Account{Platform: platform, Type: AccountTypeAPIKey}
got, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", body)
require.NoError(t, err)
require.JSONEq(t, want, string(got))
require.Contains(t, string(got), "9007199254740993")
require.Equal(t, original, body)
again, err := normalizeStrictChatDeveloperRoles(account, "https://relay.example.test/v1/chat/completions", got)
require.NoError(t, err)
require.Equal(t, got, again)
})
}
}
func TestNormalizeStrictChatDeveloperRoles_ExactOfficialHosts(t *testing.T) {
body := []byte(`{"messages":[{"role":"developer","content":"rules"}]}`)
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
for _, host := range []string{"api.deepseek.com", "api.kimi.com", "api.moonshot.cn", "api.moonshot.ai", "open.bigmodel.cn", "api.z.ai", "API.DEEPSEEK.COM:443"} {
t.Run(host, func(t *testing.T) {
got, err := normalizeStrictChatDeveloperRoles(account, "https://"+host+"/v1/chat/completions", body)
require.NoError(t, err)
require.JSONEq(t, `{"messages":[{"role":"system","content":"rules"}]}`, string(got))
})
}
}
func TestNormalizeStrictChatDeveloperRoles_OtherTargetsUntouched(t *testing.T) {
// Neither model names nor host-like strings in the path/userinfo may enable
// normalization on an otherwise compatible provider.
body := []byte("{ \"model\": \"deepseek-flash\", \"messages\": [{\"role\":\"developer\",\"content\":\"rules\"}] }")
account := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
for _, target := range []string{
"https://api.openai.com/v1/chat/completions",
"https://relay.example.test/v1/chat/completions",
"https://api.deepseek.com.example.test/v1/chat/completions",
"https://example.test/api.kimi.com/chat/completions",
"https://api.deepseek.com@example.test/v1/chat/completions",
"://invalid-url",
} {
got, err := normalizeStrictChatDeveloperRoles(account, target, body)
require.NoError(t, err)
require.Equal(t, body, got)
}
for _, account := range []*Account{nil, {Platform: PlatformKimi, Type: AccountTypeOAuth}, {Platform: PlatformOpenAI, Type: AccountTypeOAuth}} {
got, err := normalizeStrictChatDeveloperRoles(account, "https://api.kimi.com/coding/v1/chat/completions", body)
require.NoError(t, err)
require.Equal(t, body, got)
}
}
func TestNormalizeStrictChatDeveloperRoles_CompatibleBodyUntouched(t *testing.T) {
account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
for _, body := range []string{
"{ \"messages\": [{\"role\":\"system\",\"content\":\"rules\"}] }",
`{"messages":[]}`,
`{"input":[{"role":"developer","content":"Responses is not Chat"}]}`,
} {
got, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body))
require.NoError(t, err)
require.Equal(t, body, string(got))
}
}
func TestNormalizeStrictChatDeveloperRoles_MalformedBodyDoesNotLeak(t *testing.T) {
account := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
for _, body := range []string{`{"secret":`, `null`, `[]`, `{"messages":"secret"}`, `{"messages":["secret"]}`, `{"messages":[null]}`, `{"messages":[{"content":"secret"}]}`, `{"messages":[{"role":12,"content":"secret"}]}`} {
_, err := normalizeStrictChatDeveloperRoles(account, "https://api.deepseek.com/v1/chat/completions", []byte(body))
require.Error(t, err)
require.NotContains(t, err.Error(), "secret")
}
}
@@ -2473,13 +2473,9 @@ func validateCodexModelsManifestEnvelope(body []byte) error {
return errors.New("missing top-level models array")
}
models = bytes.TrimSpace(models)
var entries []json.RawMessage
if len(models) == 0 || models[0] != '[' {
return errors.New("top-level models field is not an array")
}
if err := json.Unmarshal(models, &entries); err != nil {
return fmt.Errorf("decode top-level models array: %w", err)
}
return nil
}
@@ -2562,6 +2562,11 @@ func TestFetchCodexModelsManifestRejectsInvalidEnvelope(t *testing.T) {
}
}
func TestValidateCodexModelsManifestEnvelopeRequiresExactModelsKey(t *testing.T) {
require.NoError(t, validateCodexModelsManifestEnvelope([]byte(`{"models":[]}`)))
require.Error(t, validateCodexModelsManifestEnvelope([]byte(`{"Models":[]}`)))
}
func TestFetchCodexModelsManifestAPIKeyDoesNotCacheInvalidEnvelope(t *testing.T) {
var calls atomic.Int32
upstream := &codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) {
@@ -1228,7 +1228,15 @@ func normalizeOpenAIModelForUpstream(account *Account, model string) string {
if account == nil || account.UsesOpenAICodexProtocol() {
return normalizeCodexModel(model)
}
return strings.TrimSpace(model)
model = strings.TrimSpace(model)
if account.Platform == PlatformDeepseek {
// DeepSeek 走 OpenAI 兼容入口,不经 Anthropic 入站的 [1m] 后缀归一
// (parseGatewayRequestCurrentBody 仅处理 PlatformAnthropic 协议)。
// 官方 Claude Code 接入要求 ANTHROPIC_MODEL=deepseek-flash[1m] 写法,
// 出站前剥离泄漏的客户端上下文后缀,转发规范名给上游。
return normalizeClaudeCodeLongContextModel(model)
}
return model
}
func SupportsVerbosity(model string) bool {
@@ -114,7 +114,8 @@ func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType
}
MarkOpsStreamError(c, errType, message, statusCode)
payload, err := json.Marshal(map[string]any{
"type": "response.failed",
"type": "response.failed",
"sequence_number": 0,
"response": map[string]any{
"id": "resp_" + strings.ReplaceAll(uuid.NewString(), "-", ""),
"object": "response",
@@ -143,7 +144,8 @@ func writeOpenAICompactSSEFailureMessage(c *gin.Context, statusCode int, errType
// 携带完整 response 对象。Codex 的 SSE 解析只从 output_item.done 收集 item,
// 并要求 response.completed 的 response.id 必填、usage(若存在)必须携带
// input_tokens/output_tokens/total_tokens 整数字段,否则整条 completed 事件
// 解析失败,故此处做兜底修补。
// 解析失败,故此处做兜底修补。每帧还要带单调的 sequence_number:grok-build
// 把它当必填,缺了整轮反序列化失败。
func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
if len(finalResponse) == 0 || !gjson.ValidBytes(finalResponse) {
return nil, false
@@ -176,12 +178,19 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
var buf bytes.Buffer
outputIndex := 0
appendEvent := func(eventType string, data []byte) {
sequenceNumber := 0
appendEvent := func(eventType string, data []byte) bool {
numbered, err := sjson.SetBytes(data, "sequence_number", sequenceNumber)
if err != nil {
return false
}
sequenceNumber++
_, _ = buf.WriteString("event: ")
_, _ = buf.WriteString(eventType)
_, _ = buf.WriteString("\ndata: ")
_, _ = buf.Write(data)
_, _ = buf.Write(numbered)
_, _ = buf.WriteString("\n\n")
return true
}
for _, item := range gjson.GetBytes(response, "output").Array() {
if !item.IsObject() {
@@ -195,7 +204,9 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
if err != nil {
return nil, false
}
appendEvent("response.output_item.done", event)
if !appendEvent("response.output_item.done", event) {
return nil, false
}
outputIndex++
}
@@ -203,7 +214,9 @@ func buildOpenAICompactSSEPayload(finalResponse []byte) ([]byte, bool) {
if err != nil {
return nil, false
}
appendEvent("response.completed", completed)
if !appendEvent("response.completed", completed) {
return nil, false
}
return buf.Bytes(), true
}
@@ -73,6 +73,8 @@ func TestBuildOpenAICompactSSEPayload_EmitsItemsAndCompleted(t *testing.T) {
require.Equal(t, "response.output_item.done", events[0][0])
first := events[0][1]
require.Equal(t, "response.output_item.done", gjson.Get(first, "type").String())
require.True(t, gjson.Get(first, "sequence_number").Exists(), "compact 合成帧必须带 sequence_number")
require.Equal(t, int64(0), gjson.Get(first, "sequence_number").Int())
require.Equal(t, int64(0), gjson.Get(first, "output_index").Int())
require.Equal(t, "compaction", gjson.Get(first, "item.type").String())
require.Equal(t, "cmp_1", gjson.Get(first, "item.id").String())
@@ -81,12 +83,14 @@ func TestBuildOpenAICompactSSEPayload_EmitsItemsAndCompleted(t *testing.T) {
require.True(t, gjson.Get(first, "item.opaque.kept").Bool(), "item 原始字段必须逐字节保留")
require.Equal(t, "response.output_item.done", events[1][0])
require.Equal(t, int64(1), gjson.Get(events[1][1], "sequence_number").Int())
require.Equal(t, int64(1), gjson.Get(events[1][1], "output_index").Int())
require.Equal(t, "message", gjson.Get(events[1][1], "item.type").String())
require.Equal(t, "response.completed", events[2][0])
completed := events[2][1]
require.Equal(t, "response.completed", gjson.Get(completed, "type").String())
require.Equal(t, int64(2), gjson.Get(completed, "sequence_number").Int())
require.Equal(t, "resp_compact_1", gjson.Get(completed, "response.id").String())
require.Equal(t, int64(13), gjson.Get(completed, "response.usage.total_tokens").Int())
require.Len(t, gjson.Get(completed, "response.output").Array(), 2)
@@ -31,8 +31,9 @@ func TestWriteOpenAICompactSSEFailureMessage_CarriesCreatedAt(t *testing.T) {
require.True(t, found, "SSE 帧必须带 data 行: %q", body)
var event struct {
Type string `json:"type"`
Response struct {
Type string `json:"type"`
SequenceNumber *int `json:"sequence_number"`
Response struct {
ID string `json:"id"`
Object string `json:"object"`
CreatedAt int64 `json:"created_at"`
@@ -42,6 +43,8 @@ func TestWriteOpenAICompactSSEFailureMessage_CarriesCreatedAt(t *testing.T) {
require.NoError(t, json.Unmarshal([]byte(strings.TrimSpace(payload)), &event))
require.Equal(t, "response.failed", event.Type)
require.NotNil(t, event.SequenceNumber, "response.failed 必须带 sequence_number,否则 grok-build 读不出这帧")
require.GreaterOrEqual(t, *event.SequenceNumber, 0)
require.Equal(t, "response", event.Response.Object)
require.Equal(t, "failed", event.Response.Status)
require.Greater(t, event.Response.CreatedAt, int64(0),
@@ -182,6 +182,11 @@ func (s *OpenAIGatewayService) sendCCUpstreamRequest(
userAgent string,
grokCacheIdentity string,
) (*http.Response, error) {
// DeepSeek thinking mode 要求历史 assistant 回传 reasoning_content。
// Responses→CC 回退在加密-only / 缺 reasoning item 且缓存未命中时会漏掉该
// 字段,上游 400 "The `reasoning_content` in the thinking mode must be
// passed back to the API"。在共用出站点补空格占位,真实明文不覆盖。
body = ensureDeepSeekChatReasoningPlaceholders(account, body)
upstreamCtx, releaseUpstreamCtx := detachUpstreamContext(ctx)
upstreamReq, err := http.NewRequestWithContext(upstreamCtx, http.MethodPost, targetURL, bytes.NewReader(body))
releaseUpstreamCtx()
@@ -178,6 +178,11 @@ func (s *OpenAIGatewayService) forwardAsRawChatCompletions(
if err != nil {
return nil, err
}
upstreamBody, err = normalizeStrictChatDeveloperRoles(account, targetURL, upstreamBody)
if err != nil {
writeChatCompletionsError(c, http.StatusBadRequest, "invalid_request_error", err.Error())
return nil, err
}
SetActualOpenAIUpstreamEndpoint(c, grokChatRawEndpoint)
customUA := account.GetOpenAIUserAgent()
if customUA == "" && account.IsGrokOAuth() {
@@ -0,0 +1,193 @@
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/openai_compat"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func openaiPlatformDeepSeekAccount() *Account {
return &Account{
ID: 18,
Name: "openai-deepseek-apikey",
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
},
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": DefaultDeepseekBaseURL,
},
Status: StatusActive,
Schedulable: true,
}
}
func deepSeekChatFallbackTestConfig() *config.Config {
return &config.Config{
Security: config.SecurityConfig{
URLAllowlist: config.URLAllowlistConfig{
Enabled: false,
AllowInsecureHTTP: true,
},
},
}
}
func TestEnsureDeepSeekChatReasoningPlaceholders(t *testing.T) {
deepSeekAccount := openaiPlatformDeepSeekAccount()
nativeDeepSeek := &Account{Platform: PlatformDeepseek, Type: AccountTypeAPIKey}
otherOpenAI := &Account{
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-test", "base_url": "http://upstream.example"},
}
missing := []byte(`{"model":"deepseek-chat","messages":[{"role":"user","content":"hi"},{"role":"assistant","content":"","tool_calls":[{"id":"call_1","type":"function","function":{"name":"exec","arguments":"{}"}}]},{"role":"tool","tool_call_id":"call_1","content":"ok"}]}`)
withPlain := []byte(`{"model":"deepseek-chat","messages":[{"role":"assistant","reasoning_content":"real thinking","content":"ok"}]}`)
t.Run("openai_platform_deepseek_fills_empty_assistant", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(deepSeekAccount, missing)
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
require.False(t, gjson.GetBytes(got, "messages.0.reasoning_content").Exists())
require.False(t, gjson.GetBytes(got, "messages.2.reasoning_content").Exists())
})
t.Run("native_deepseek_platform_fills_empty_assistant", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(nativeDeepSeek, missing)
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(got, "messages.1.reasoning_content").String())
})
t.Run("does_not_overwrite_plaintext", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(deepSeekAccount, withPlain)
require.Equal(t, "real thinking", gjson.GetBytes(got, "messages.0.reasoning_content").String())
require.Equal(t, string(withPlain), string(got), "已有明文时必须原样返回")
})
t.Run("non_deepseek_upstream_unchanged", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(otherOpenAI, missing)
require.Equal(t, string(missing), string(got))
require.False(t, gjson.GetBytes(got, "messages.1.reasoning_content").Exists())
})
t.Run("nil_account_unchanged", func(t *testing.T) {
got := ensureDeepSeekChatReasoningPlaceholders(nil, missing)
require.Equal(t, string(missing), string(got))
})
}
type reasoningHitCache struct {
stubGatewayCache
values map[string]string
}
func (c *reasoningHitCache) GetReasoningContent(_ context.Context, itemID string) (string, error) {
if v, ok := c.values[itemID]; ok {
return v, nil
}
return "", ErrReasoningContentNotFound
}
func deepSeekChatHistoryWithEncryptedReasoning() []byte {
return []byte(`{
"model":"gpt-5.6-sol",
"stream":false,
"input":[
{"type":"reasoning","id":"item_enc1","summary":[],"encrypted_content":"opaque"},
{"type":"function_call","call_id":"call_1","name":"exec","arguments":"{}"},
{"type":"function_call_output","call_id":"call_1","output":"ok"},
{"type":"message","role":"user","content":[{"type":"input_text","text":"go on"}]}
]
}`)
}
func newDeepSeekChatFallbackContext(t *testing.T, body []byte) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/responses", bytes.NewReader(body))
c.Request.Header.Set("Content-Type", "application/json")
return c
}
func newOKChatCompletionsUpstream(requestID, body string) *httpUpstreamRecorder {
return &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}, "x-request-id": []string{requestID}},
Body: io.NopCloser(strings.NewReader(body)),
}}
}
const deepSeekChatFallbackOKBody = `{"id":"chatcmpl_ph","object":"chat.completion","model":"deepseek-chat","choices":[{"index":0,"message":{"role":"assistant","content":"ok"},"finish_reason":"stop"}],"usage":{"prompt_tokens":3,"completion_tokens":2,"total_tokens":5}}`
func TestForwardResponses_DeepSeekChatFallbackInjectsReasoningPlaceholderOnCacheMiss(t *testing.T) {
body := deepSeekChatHistoryWithEncryptedReasoning()
c := newDeepSeekChatFallbackContext(t, body)
upstream := newOKChatCompletionsUpstream("rid_ds_rc_placeholder", deepSeekChatFallbackOKBody)
svc := &OpenAIGatewayService{
cfg: deepSeekChatFallbackTestConfig(),
httpUpstream: upstream,
}
result, err := svc.Forward(context.Background(), c, openaiPlatformDeepSeekAccount(), body)
require.NoError(t, err)
require.NotNil(t, result)
require.Contains(t, upstream.lastReq.URL.String(), "/chat/completions")
require.Equal(t, deepSeekChatReasoningPlaceholderText, gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
require.Equal(t, "call_1", gjson.GetBytes(upstream.lastBody, "messages.0.tool_calls.0.id").String())
}
func TestForwardResponses_DeepSeekChatFallbackKeepsCachedReasoningContent(t *testing.T) {
body := deepSeekChatHistoryWithEncryptedReasoning()
c := newDeepSeekChatFallbackContext(t, body)
upstream := newOKChatCompletionsUpstream("rid_ds_rc_cached", deepSeekChatFallbackOKBody)
svc := &OpenAIGatewayService{
cfg: deepSeekChatFallbackTestConfig(),
httpUpstream: upstream,
cache: &reasoningHitCache{values: map[string]string{"item_enc1": "cached thinking"}},
}
result, err := svc.Forward(context.Background(), c, openaiPlatformDeepSeekAccount(), body)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, "cached thinking", gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").String())
}
func TestForwardResponses_NonDeepSeekChatFallbackDoesNotInjectReasoningPlaceholder(t *testing.T) {
body := deepSeekChatHistoryWithEncryptedReasoning()
c := newDeepSeekChatFallbackContext(t, body)
upstream := newOKChatCompletionsUpstream("rid_other_rc", deepSeekChatFallbackOKBody)
account := &Account{
ID: 99,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Extra: map[string]any{
openai_compat.ExtraKeyResponsesMode: string(openai_compat.ResponsesSupportModeForceChatCompletions),
},
Credentials: map[string]any{
"api_key": "sk-test",
"base_url": "http://upstream.example",
},
}
svc := &OpenAIGatewayService{
cfg: deepSeekChatFallbackTestConfig(),
httpUpstream: upstream,
}
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.False(t, gjson.GetBytes(upstream.lastBody, "messages.0.reasoning_content").Exists())
}
@@ -10,6 +10,7 @@ import (
"strings"
"unicode/utf8"
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/util/urlvalidator"
"github.com/gin-gonic/gin"
@@ -141,7 +142,25 @@ func normalizeDeepSeekResponsesRequestBody(account *Account, body []byte) []byte
if stripped, err := sjson.DeleteBytes(normalized, "previous_response_id"); err == nil {
normalized = stripped
}
return normalized
var requestBody map[string]any
if err := decodeOpenAIJSONUseNumber(normalized, &requestBody); err != nil {
return normalized
}
input, exists := requestBody["input"]
if !exists {
return normalized
}
liftedInput, changed := apicompat.LiftResponsesToolOutputMedia(input)
if !changed {
return normalized
}
requestBody["input"] = liftedInput
rebuilt, err := marshalOpenAIUpstreamJSON(requestBody)
if err != nil {
return normalized
}
return rebuilt
}
func trimOpenAIEncryptedReasoningItems(reqBody map[string]any) bool {
@@ -1464,12 +1464,23 @@ func (s *OpenAIGatewayService) bindHTTPResponseAccount(ctx context.Context, c *g
if store == nil {
return
}
// The client may close the stream immediately after receiving its terminal
// event, canceling the request context before these durable affinity writes
// run. Preserve request values, but give all Redis writes one bounded budget
// independent of the downstream connection lifecycle.
bindBaseCtx := context.Background()
if ctx != nil {
bindBaseCtx = context.WithoutCancel(ctx)
}
bindCtx, cancel := context.WithTimeout(bindBaseCtx, openAIWSStateStoreRedisTimeout)
defer cancel()
groupID := getOpenAIGroupIDFromContext(c)
ttl := s.openAIWSResponseStickyTTL()
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(ctx, groupID, responseID, account.ID, ttl))
logOpenAIWSBindResponseAccountWarn(groupID, account.ID, responseID, store.BindResponseAccount(bindCtx, groupID, responseID, account.ID, ttl))
if rawOwner, ok := c.Get(openAIHTTPResponseOwnerContextKey); ok {
if owner, ok := rawOwner.(openAIHTTPResponseOwner); ok && owner.userID > 0 && owner.apiKeyID > 0 {
if err := s.BindOpenAIHTTPResponseOwner(ctx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil {
if err := s.BindOpenAIHTTPResponseOwner(bindCtx, groupID, responseID, owner.userID, owner.apiKeyID); err != nil {
logger.L().Warn(
"openai.http_bind_response_owner_failed",
zap.Int64("group_id", groupID),
@@ -7,6 +7,8 @@ import (
"errors"
"fmt"
"net/http"
"net/url"
"strconv"
"strings"
"time"
@@ -14,6 +16,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
"github.com/Wei-Shaw/sub2api/internal/util/responseheaders"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
"go.uber.org/zap"
)
@@ -399,3 +403,67 @@ func (s *OpenAIGatewayService) setReasoningContent(itemID, content string) {
)
}
}
// deepSeekChatReasoningPlaceholderText 是 Chat Completions 侧 thinking-mode
// 占位明文。必须是单个空格:DeepSeek 拒绝空串,非空即可通过;LiteLLM 同样注入
// 单个空格。
const deepSeekChatReasoningPlaceholderText = " "
// targetsDeepSeekAPIHost 报告该账号实际上游是否是 DeepSeek 官方 API。
// platform=deepseek 直接命中;platform=openai 但 base_url 指向
// api.deepseek.com 的映射账号同样命中(Codex 把 GPT 模型名映射到 DeepSeek
// 时的典型接入方式)。
func targetsDeepSeekAPIHost(account *Account) bool {
if account == nil {
return false
}
if account.Platform == PlatformDeepseek {
return true
}
u, err := url.Parse(strings.TrimSpace(account.GetOpenAIBaseURL()))
if err != nil {
return false
}
ds, err := url.Parse(DefaultDeepseekBaseURL)
if err != nil {
return false
}
return strings.EqualFold(u.Hostname(), ds.Hostname())
}
// ensureDeepSeekChatReasoningPlaceholders 给缺 reasoning_content 的 assistant
// 消息补单个空格占位。DeepSeek thinking mode 要求历史里每条产生过思维的
// assistant 消息都回传该字段,否则 400
// "The `reasoning_content` in the thinking mode must be passed back to the API"。
//
// 桥接会从 summary / 缓存回注真实明文;这里只填仍为空的缺口,不覆盖已有内容。
// 非 DeepSeek 上游原样返回(字节不变)。
func ensureDeepSeekChatReasoningPlaceholders(account *Account, body []byte) []byte {
if !targetsDeepSeekAPIHost(account) {
return body
}
messages := gjson.GetBytes(body, "messages")
if !messages.IsArray() {
return body
}
updated := body
changed := false
for i, msg := range messages.Array() {
if strings.TrimSpace(msg.Get("role").String()) != "assistant" {
continue
}
if msg.Get("reasoning_content").String() != "" {
continue
}
next, err := sjson.SetBytes(updated, "messages."+strconv.Itoa(i)+".reasoning_content", deepSeekChatReasoningPlaceholderText)
if err != nil {
return body
}
updated = next
changed = true
}
if !changed {
return body
}
return updated
}
@@ -710,26 +710,29 @@ func openAICodexSnapshotStaleForPause(extra map[string]any, now time.Time) bool
// timestamp and falls back to codex_<window>_reset_after_seconds anchored at
// codex_usage_updated_at, mirroring AccountUsageService's window-progress logic.
func openAIQuotaWindowReset(extra map[string]any, window string, now time.Time) bool {
resetAt, ok := openAICodexWindowResetAt(extra, window)
return ok && !now.Before(resetAt)
}
// 绝对时间优先;相对倒计时必须锚定快照采样时间,不能随每次评分向后滑动。
func openAICodexWindowResetAt(extra map[string]any, window string) (time.Time, bool) {
if len(extra) == 0 {
return false
return time.Time{}, false
}
if resetAtRaw, ok := extra["codex_"+window+"_reset_at"]; ok {
if resetAt, err := parseTime(fmt.Sprint(resetAtRaw)); err == nil {
return !now.Before(resetAt)
return resetAt, true
}
}
resetAfter := parseExtraInt(extra["codex_"+window+"_reset_after_seconds"])
if resetAfter <= 0 {
return false
return time.Time{}, false
}
base := now
if updatedRaw, ok := extra["codex_usage_updated_at"]; ok {
if updatedAt, err := parseTime(fmt.Sprint(updatedRaw)); err == nil {
base = updatedAt
}
updatedAt, err := parseTime(fmt.Sprint(extra["codex_usage_updated_at"]))
if err != nil {
return time.Time{}, false
}
resetAt := base.Add(time.Duration(resetAfter) * time.Second)
return !now.Before(resetAt)
return updatedAt.Add(time.Duration(resetAfter) * time.Second), true
}
func readOpenAIQuotaUsedPercent(extra map[string]any, window string) float64 {
@@ -589,6 +589,37 @@ func TestOpenAIGatewayService_BindHTTPResponseAccount(t *testing.T) {
require.False(t, owned)
}
func TestOpenAIGatewayService_BindHTTPResponseAccount_DetachesCanceledRequestContext(t *testing.T) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
groupID := int64(4201)
c.Set("api_key", &APIKey{ID: 501, GroupID: &groupID})
SetOpenAIHTTPResponseOwner(c, 601, 501)
cache := &responseBindContextProbeCache{}
svc := &OpenAIGatewayService{cache: cache}
account := &Account{ID: 37001, Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
requestCtx, cancelRequest := context.WithCancel(context.Background())
cancelRequest()
startedAt := time.Now()
svc.bindHTTPResponseAccount(requestCtx, c, account, "resp_http_canceled_001")
require.Len(t, cache.setContextErrors, 3)
for _, contextErr := range cache.setContextErrors {
require.NoError(t, contextErr, "response affinity writes must survive downstream cancellation")
}
require.Len(t, cache.setDeadlines, 3)
for _, deadline := range cache.setDeadlines {
require.True(t, deadline.After(startedAt))
require.LessOrEqual(t, deadline.Sub(startedAt), openAIWSStateStoreRedisTimeout+100*time.Millisecond)
}
require.Len(t, cache.sessionBindings, 3)
require.Contains(t, cache.sessionBindings, openAIWSResponseAccountCacheKey("resp_http_canceled_001"))
}
func TestOpenAIGatewayService_GenerateExplicitSessionHash_SkipsContentFallback(t *testing.T) {
gin.SetMode(gin.TestMode)
svc := &OpenAIGatewayService{}
@@ -710,6 +741,23 @@ type stubGatewayCache struct {
deletedSessions map[string]int
}
type responseBindContextProbeCache struct {
stubGatewayCache
setContextErrors []error
setDeadlines []time.Time
}
func (c *responseBindContextProbeCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
c.setContextErrors = append(c.setContextErrors, ctx.Err())
if deadline, ok := ctx.Deadline(); ok {
c.setDeadlines = append(c.setDeadlines, deadline)
}
if err := ctx.Err(); err != nil {
return err
}
return c.stubGatewayCache.SetSessionAccountID(ctx, groupID, sessionHash, accountID, ttl)
}
func (c *stubGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
if id, ok := c.sessionBindings[sessionHash]; ok {
return id, nil
@@ -82,6 +82,43 @@ func isOpenAIOAuthServableModel(requestedModel string) bool {
return true
}
// deepseekServableModels 列出 DeepSeek 平台账号在「未配置 model_mapping」时
// 可服务的官方模型名(精确匹配,小写比较)。deepseek-flash 与 deepseek-v4-pro
// 为官方现行名;deepseek-v4-flash / deepseek-v4-flash-vision-exp 为处于兼容
// 路由期的旧名(上游仍接受并按 flash 价计费);deepseek-v4-pro-0813 为与
// 计费 pro 档口径一致的版本化名。
var deepseekServableModels = []string{
"deepseek-flash",
"deepseek-v4-pro",
"deepseek-v4-flash",
"deepseek-v4-flash-vision-exp",
"deepseek-v4-pro-0813",
}
// isDeepseekServableModel 报告 DeepSeek 平台账号在「未配置 model_mapping」时
// 可服务的模型名。官方现行模型为 deepseek-flash 与 deepseek-v4-pro;旧名
// deepseek-v4-flash / deepseek-v4-flash-vision-exp 处于兼容路由期(上游仍接受并
// 按 flash 价计费),deepseek-v4-pro-0813 为版本化名。其余一律拒绝:既避免把
// 未知模型透传给上游(上游 404/400 会触发 per-model 冷却并掩盖真实配置错误),
// 也避免上游把不认识的模型名静默兜底成 deepseek-flash。
// Source: https://api-docs.deepseek.com/quick_start/pricing
//
// https://api-docs.deepseek.com/guides/anthropic_api
func isDeepseekServableModel(requestedModel string) bool {
// 官方 Claude Code 接入文档要求 ANTHROPIC_MODEL=deepseek-flash[1m]:
// [1m] 是客户端上下文选择器,先按同一规则归一化再比对白名单。
model := strings.ToLower(normalizeClaudeCodeLongContextModel(strings.TrimSpace(requestedModel)))
if model == "" {
return true // 空模型交由上层必填校验处理
}
for _, servable := range deepseekServableModels {
if model == servable {
return true
}
}
return false
}
// resolveOpenAICompactForwardModel determines the compact-only upstream model
// for /responses/compact requests. It never affects normal /responses traffic.
// When no compact-specific mapping matches, the input model is returned as-is.
@@ -429,6 +429,30 @@ func TestNormalizeOpenAIModelForUpstream(t *testing.T) {
model: "gpt-4.1",
want: "gpt-4.1",
},
{
name: "deepseek strips claude code long context suffix",
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
model: "deepseek-flash[1m]",
want: "deepseek-flash",
},
{
name: "deepseek strips duplicated long context suffix",
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
model: "deepseek-flash[1M][1m]",
want: "deepseek-flash",
},
{
name: "deepseek preserves plain model",
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformDeepseek},
model: "deepseek-flash",
want: "deepseek-flash",
},
{
name: "non deepseek preserves long context suffix",
account: &Account{Type: AccountTypeAPIKey, Platform: PlatformOpenAI},
model: "deepseek-flash[1m]",
want: "deepseek-flash[1m]",
},
}
for _, tt := range tests {
@@ -0,0 +1,79 @@
package service
import (
"context"
"net/http"
"strings"
)
// OpenAIGatewayService implements service.PluginAccountDirectory for the OpenAI
// OAuth outbound transport capability. The directory is intentionally scoped to
// OpenAI OAuth-like, non-shadow accounts regardless of the requested filter, so a
// plugin can never enumerate or resolve credentials outside that set. The host
// additionally only wires this directory into plugins whose manifest declares the
// matching capability (see PluginManager.buildHostServices).
// ListPluginAccounts returns the ids of active OpenAI OAuth-like accounts.
func (s *OpenAIGatewayService) ListPluginAccounts(ctx context.Context, platform, accountType string) ([]int64, error) {
if s == nil || s.accountRepo == nil {
return nil, nil
}
if p := strings.TrimSpace(platform); p != "" && p != PlatformOpenAI {
return nil, nil
}
if at := strings.TrimSpace(accountType); at != "" && at != AccountTypeOAuth {
// Setup-token style accounts are OAuth-like but not AccountTypeOAuth; the
// current binding only routes AccountTypeOAuth, so honour that filter.
return nil, nil
}
accounts, err := s.accountRepo.ListByPlatform(ctx, PlatformOpenAI)
if err != nil {
return nil, err
}
ids := make([]int64, 0, len(accounts))
for i := range accounts {
account := accounts[i]
if account.Status == StatusActive && account.IsOpenAIOAuthLike() && !account.IsShadow() && account.Type == AccountTypeOAuth {
ids = append(ids, account.ID)
}
}
return ids, nil
}
// ResolvePluginOutboundIdentity resolves the access token plus the outbound
// identity headers and proxy the host would attach to a live request for the
// account. It returns (nil, nil) for out-of-scope accounts or when no token can
// be resolved.
func (s *OpenAIGatewayService) ResolvePluginOutboundIdentity(ctx context.Context, accountID int64) (*PluginOutboundIdentity, error) {
if s == nil || s.accountRepo == nil || accountID <= 0 {
return nil, nil
}
account, err := s.accountRepo.GetByID(ctx, accountID)
if err != nil {
return nil, err
}
if account == nil || account.Type != AccountTypeOAuth || !account.IsOpenAIOAuthLike() || account.IsShadow() {
return nil, nil
}
token, _, err := s.GetAccessToken(ctx, account)
if err != nil {
return nil, err
}
if strings.TrimSpace(token) == "" {
return nil, nil
}
headers := http.Header{}
if err := resolveAndSetOpenAIChatGPTAccountHeaders(ctx, s.accountRepo, headers, account); err != nil {
return nil, err
}
ensureCodexIdentityHeaders(headers)
enforceCodexIdentityHeaders(headers)
return &PluginOutboundIdentity{
AccountID: account.ID,
Platform: account.Platform,
AccountType: account.Type,
ProxyURL: resolveAccountProxyURL(account),
Token: token,
Headers: headers,
}, nil
}
@@ -354,8 +354,9 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte {
message = "upstream request failed"
}
event := map[string]any{
"type": "error",
"status": statusCode,
"type": "error",
"sequence_number": 0,
"status": statusCode,
"error": map[string]any{
"type": "upstream_error",
"message": message,
@@ -363,7 +364,7 @@ func buildOpenAIWSHTTPBridgeErrorEvent(statusCode int, message string) []byte {
}
body, err := json.Marshal(event)
if err != nil {
return []byte(`{"type":"error","error":{"type":"upstream_error","message":"upstream request failed"}}`)
return []byte(`{"type":"error","sequence_number":0,"error":{"type":"upstream_error","message":"upstream request failed"}}`)
}
return body
}
@@ -398,9 +399,9 @@ func buildOpenAIWSHTTPBridgeFailedEvent(responseID, model string, source []byte,
if model = strings.TrimSpace(model); model != "" {
response["model"] = model
}
body, err := json.Marshal(map[string]any{"type": "response.failed", "response": response})
body, err := json.Marshal(map[string]any{"type": "response.failed", "sequence_number": 0, "response": response})
if err != nil {
return []byte(`{"type":"response.failed","response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
return []byte(`{"type":"response.failed","sequence_number":0,"response":{"status":"failed","output":[],"error":{"code":"upstream_error","message":"Upstream response failed"}}}`)
}
return body
}
@@ -0,0 +1,265 @@
package service
import (
"context"
"net/http"
"time"
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// 通用宿主服务(HostService)的资源上限。这些限制与任何具体插件能力无关,
// 只用于约束单个插件对共享存储的占用,属于防御性护栏而非业务策略。
const (
// pluginKVMaxPluginKeyLen 与清单 id 的 maxLength 一致(manifest.schema.json),
// 确保任何可安装插件的 pluginKey 都能通过校验,不会被误判为不可用。
pluginKVMaxPluginKeyLen = 160
pluginKVMaxNamespaceLen = 128
pluginKVMaxKeyLen = 256
pluginKVMaxValueBytes = 256 * 1024
pluginKVMaxListLimit = 1000
pluginKVDefaultListLimit = 100
pluginKVMaxTTL = 90 * 24 * time.Hour
)
// PluginKVStore 是宿主向插件提供的通用命名空间键值存储端口。它对存储介质保持中立
// (由 repository 层用 Redis 等实现),供任何插件持久化跨请求 / 跨副本 / 跨重启的
// 状态。pluginKey 由宿主根据服务该连接的运行时注入,插件无法伪造,从而保证不同
// 插件之间命名空间严格隔离。
type PluginKVStore interface {
Get(ctx context.Context, pluginKey, namespace, key string) ([]byte, bool, error)
Set(ctx context.Context, pluginKey, namespace, key string, value []byte, ttl time.Duration) error
Delete(ctx context.Context, pluginKey, namespace, key string) error
List(ctx context.Context, pluginKey, namespace, keyPrefix string, limit int) ([]string, error)
}
// PluginOutboundIdentity 是宿主为某账号解析出的、可直接用于出站请求的身份材料:
// 访问令牌、宿主会附加的出站请求头,以及账号代理。
type PluginOutboundIdentity struct {
AccountID int64
Platform string
AccountType string
ProxyURL string
Token string
Headers http.Header
}
// PluginAccountDirectory 让插件枚举其能力所覆盖的账号,并按需解析这些账号的出站身份,
// 无需等待一条真实请求流经插件。这是一项敏感能力(会把账号凭据交给插件进程),因此
// 宿主只对「其声明能力确实覆盖这些账号」的插件开放(见 PluginManager.buildHostServices)。
// 实现方自身也必须把返回范围收敛到该能力对应的账号集合。
type PluginAccountDirectory interface {
ListPluginAccounts(ctx context.Context, platform, accountType string) ([]int64, error)
ResolvePluginOutboundIdentity(ctx context.Context, accountID int64) (*PluginOutboundIdentity, error)
}
// pluginHostServiceServer 实现 pluginv1.HostServiceServer,是宿主经 go-plugin broker
// 反向暴露给单个插件进程的服务端点。它绑定到具体插件的 pluginKey,因此每个运行时都有
// 自己的实例;所有键值操作都被强制限定在该插件的命名空间内。
type pluginHostServiceServer struct {
pluginv1.UnimplementedHostServiceServer
pluginKey string
store PluginKVStore
directory PluginAccountDirectory
}
func newPluginHostServiceServer(pluginKey string, store PluginKVStore, directory PluginAccountDirectory) *pluginHostServiceServer {
return &pluginHostServiceServer{pluginKey: pluginKey, store: store, directory: directory}
}
func (s *pluginHostServiceServer) ready() bool {
return s != nil && s.store != nil && isValidPluginKVSegment(s.pluginKey, pluginKVMaxPluginKeyLen)
}
func (s *pluginHostServiceServer) KVGet(ctx context.Context, req *pluginv1.KVGetRequest) (*pluginv1.KVGetResponse, error) {
if !s.ready() {
return nil, status.Error(codes.Unavailable, "宿主键值存储不可用")
}
if req == nil {
return nil, status.Error(codes.InvalidArgument, "请求为空")
}
if err := validatePluginKVNamespace(req.Namespace); err != nil {
return nil, err
}
if err := validatePluginKVKey(req.Key); err != nil {
return nil, err
}
value, found, err := s.store.Get(ctx, s.pluginKey, req.Namespace, req.Key)
if err != nil {
return nil, status.Errorf(codes.Internal, "读取键值失败: %v", err)
}
if !found {
return &pluginv1.KVGetResponse{Found: false}, nil
}
return &pluginv1.KVGetResponse{Found: true, Value: value}, nil
}
func (s *pluginHostServiceServer) KVSet(ctx context.Context, req *pluginv1.KVSetRequest) (*pluginv1.KVSetResponse, error) {
if !s.ready() {
return nil, status.Error(codes.Unavailable, "宿主键值存储不可用")
}
if req == nil {
return nil, status.Error(codes.InvalidArgument, "请求为空")
}
if err := validatePluginKVNamespace(req.Namespace); err != nil {
return nil, err
}
if err := validatePluginKVKey(req.Key); err != nil {
return nil, err
}
if len(req.Value) > pluginKVMaxValueBytes {
return nil, status.Errorf(codes.InvalidArgument, "值超过 %d 字节上限", pluginKVMaxValueBytes)
}
ttl, err := pluginKVTTL(req.TtlSeconds)
if err != nil {
return nil, err
}
if err := s.store.Set(ctx, s.pluginKey, req.Namespace, req.Key, req.Value, ttl); err != nil {
return nil, status.Errorf(codes.Internal, "写入键值失败: %v", err)
}
return &pluginv1.KVSetResponse{}, nil
}
func (s *pluginHostServiceServer) KVDelete(ctx context.Context, req *pluginv1.KVDeleteRequest) (*pluginv1.KVDeleteResponse, error) {
if !s.ready() {
return nil, status.Error(codes.Unavailable, "宿主键值存储不可用")
}
if req == nil {
return nil, status.Error(codes.InvalidArgument, "请求为空")
}
if err := validatePluginKVNamespace(req.Namespace); err != nil {
return nil, err
}
if err := validatePluginKVKey(req.Key); err != nil {
return nil, err
}
if err := s.store.Delete(ctx, s.pluginKey, req.Namespace, req.Key); err != nil {
return nil, status.Errorf(codes.Internal, "删除键值失败: %v", err)
}
return &pluginv1.KVDeleteResponse{}, nil
}
func (s *pluginHostServiceServer) KVList(ctx context.Context, req *pluginv1.KVListRequest) (*pluginv1.KVListResponse, error) {
if !s.ready() {
return nil, status.Error(codes.Unavailable, "宿主键值存储不可用")
}
if req == nil {
return nil, status.Error(codes.InvalidArgument, "请求为空")
}
if err := validatePluginKVNamespace(req.Namespace); err != nil {
return nil, err
}
if req.KeyPrefix != "" {
if err := validatePluginKVKey(req.KeyPrefix); err != nil {
return nil, err
}
}
limit := int(req.Limit)
if limit <= 0 {
limit = pluginKVDefaultListLimit
}
if limit > pluginKVMaxListLimit {
limit = pluginKVMaxListLimit
}
keys, err := s.store.List(ctx, s.pluginKey, req.Namespace, req.KeyPrefix, limit)
if err != nil {
return nil, status.Errorf(codes.Internal, "列举键值失败: %v", err)
}
return &pluginv1.KVListResponse{Keys: keys}, nil
}
func (s *pluginHostServiceServer) ListAccounts(ctx context.Context, req *pluginv1.ListAccountsRequest) (*pluginv1.ListAccountsResponse, error) {
if s == nil || s.directory == nil {
return nil, status.Error(codes.Unavailable, "账号目录不可用")
}
if req == nil {
return nil, status.Error(codes.InvalidArgument, "请求为空")
}
ids, err := s.directory.ListPluginAccounts(ctx, req.Platform, req.AccountType)
if err != nil {
return nil, status.Errorf(codes.Internal, "列举账号失败: %v", err)
}
return &pluginv1.ListAccountsResponse{AccountIds: ids}, nil
}
func (s *pluginHostServiceServer) ResolveOutboundIdentity(ctx context.Context, req *pluginv1.ResolveOutboundIdentityRequest) (*pluginv1.ResolveOutboundIdentityResponse, error) {
if s == nil || s.directory == nil {
return nil, status.Error(codes.Unavailable, "账号目录不可用")
}
if req == nil || req.AccountId <= 0 {
return nil, status.Error(codes.InvalidArgument, "account_id 无效")
}
identity, err := s.directory.ResolvePluginOutboundIdentity(ctx, req.AccountId)
if err != nil {
return nil, status.Errorf(codes.Internal, "解析账号出站身份失败: %v", err)
}
if identity == nil {
return &pluginv1.ResolveOutboundIdentityResponse{Found: false}, nil
}
return &pluginv1.ResolveOutboundIdentityResponse{
Found: true,
AccountId: identity.AccountID,
Platform: identity.Platform,
AccountType: identity.AccountType,
ProxyUrl: identity.ProxyURL,
Token: identity.Token,
Headers: headersToPlugin(identity.Headers),
}, nil
}
func pluginKVTTL(seconds int64) (time.Duration, error) {
if seconds < 0 {
return 0, status.Error(codes.InvalidArgument, "ttl_seconds 不能为负")
}
if seconds == 0 {
return 0, nil
}
// 先在整数秒上比较上限,避免 seconds*time.Second 溢出 int64 后回绕成负值、
// 从而绕过上限检查把负 TTL 传给 Redis。
maxSeconds := int64(pluginKVMaxTTL / time.Second)
if seconds > maxSeconds {
return 0, status.Errorf(codes.InvalidArgument, "ttl_seconds 超过上限 %d", maxSeconds)
}
return time.Duration(seconds) * time.Second, nil
}
func validatePluginKVNamespace(namespace string) error {
if namespace == "" {
return status.Error(codes.InvalidArgument, "namespace 不能为空")
}
if !isValidPluginKVSegment(namespace, pluginKVMaxNamespaceLen) {
return status.Error(codes.InvalidArgument, "namespace 仅允许字母、数字、'.'、'_'、'-' 且长度受限")
}
return nil
}
func validatePluginKVKey(key string) error {
if key == "" {
return status.Error(codes.InvalidArgument, "key 不能为空")
}
if !isValidPluginKVSegment(key, pluginKVMaxKeyLen) {
return status.Error(codes.InvalidArgument, "key 仅允许字母、数字、'.'、'_'、'-' 且长度受限")
}
return nil
}
// isValidPluginKVSegment 限定命名段字符集为 [A-Za-z0-9._-]。这既避免 Redis glob
// 元字符(*?[])污染 SCAN,也避免 ':' 破坏内部键结构,从而保证命名空间隔离可靠。
func isValidPluginKVSegment(value string, maxLen int) bool {
if value == "" || len(value) > maxLen {
return false
}
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '.' || r == '_' || r == '-':
default:
return false
}
}
return true
}
@@ -0,0 +1,130 @@
package service
import (
"context"
"sync"
"testing"
"time"
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
hcplugin "github.com/hashicorp/go-plugin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// brokerProbePlugin 是仅用于测试的插件传输实现:它实现 HostBrokerReceiver 以拿到
// broker,并在 InitHostServices 中用宿主下发的 broker id 拨号回宿主的 HostService,
// 走一次真实 gRPC 的 KVSet/KVGet 往返。
type brokerProbePlugin struct {
pluginv1.UnimplementedTransportPluginServer
broker *hcplugin.GRPCBroker
mu sync.Mutex
ready bool
getFound bool
getValue []byte
failure string
}
func (p *brokerProbePlugin) SetHostBroker(broker *hcplugin.GRPCBroker) { p.broker = broker }
func (p *brokerProbePlugin) InitHostServices(ctx context.Context, req *pluginv1.InitHostServicesRequest) (*pluginv1.InitHostServicesResponse, error) {
if p.broker == nil {
return &pluginv1.InitHostServicesResponse{Ready: false, Message: "broker 未注入"}, nil
}
conn, err := p.broker.Dial(req.HostServiceId)
if err != nil {
p.record("dial: " + err.Error())
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
}
defer func() { _ = conn.Close() }()
client := pluginv1.NewHostServiceClient(conn)
if _, err := client.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "probe", Value: []byte("hello"), TtlSeconds: 60}); err != nil {
p.record("set: " + err.Error())
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
}
got, err := client.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "probe"})
if err != nil {
p.record("get: " + err.Error())
return &pluginv1.InitHostServicesResponse{Ready: false, Message: err.Error()}, nil
}
p.mu.Lock()
p.ready = true
p.getFound = got.Found
p.getValue = got.Value
p.mu.Unlock()
return &pluginv1.InitHostServicesResponse{Ready: true}, nil
}
func (p *brokerProbePlugin) record(failure string) {
p.mu.Lock()
p.failure = failure
p.mu.Unlock()
}
// noHostServicesPlugin 模拟老插件:不实现 InitHostServices(返回 Unimplemented),
// 也不接收 broker。
type noHostServicesPlugin struct {
pluginv1.UnimplementedTransportPluginServer
}
func dispenseTransportClient(t *testing.T, impl pluginv1.TransportPluginServer) *pluginv1.TransportClient {
t.Helper()
client, _ := hcplugin.TestPluginGRPCConn(t, false, map[string]hcplugin.Plugin{
pluginv1.TransportPluginName: &pluginv1.GRPCPlugin{Impl: impl},
})
t.Cleanup(func() { _ = client.Close() })
dispensed, err := client.Dispense(pluginv1.TransportPluginName)
require.NoError(t, err)
tc, ok := dispensed.(*pluginv1.TransportClient)
require.True(t, ok)
require.NotNil(t, tc.Broker)
return tc
}
// 端到端验证 broker 反向通道:宿主服务 AcceptAndServe + 插件 Dial 回来,经真实 gRPC
// 完成 KV 往返,且写入落到宿主注入的 pluginKey 命名空间下。
func TestOfferPluginHostServices_BrokerRoundtrip(t *testing.T) {
probe := &brokerProbePlugin{}
tc := dispenseTransportClient(t, probe)
store := newFakePluginKVStore()
hostServer := newPluginHostServiceServer("test.plugin", store, nil)
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
probe.mu.Lock()
defer probe.mu.Unlock()
require.Empty(t, probe.failure)
require.True(t, probe.ready)
assert.True(t, probe.getFound)
assert.Equal(t, []byte("hello"), probe.getValue)
stored, found, err := store.Get(context.Background(), "test.plugin", "state", "probe")
require.NoError(t, err)
require.True(t, found)
assert.Equal(t, []byte("hello"), stored)
}
// 老插件不实现 InitHostServices 时,offerPluginHostServices 必须优雅降级、不 panic、
// 不写入任何状态。
func TestOfferPluginHostServices_UnimplementedIsGraceful(t *testing.T) {
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
store := newFakePluginKVStore()
hostServer := newPluginHostServiceServer("test.plugin", store, nil)
require.NotPanics(t, func() {
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, hostServer, 5*time.Second)
})
_, found, err := store.Get(context.Background(), "test.plugin", "state", "probe")
require.NoError(t, err)
assert.False(t, found)
}
// hostServices 为 nil(未配置键值存储)时不得触发任何 broker 交互。
func TestOfferPluginHostServices_NilHostServicesNoop(t *testing.T) {
tc := dispenseTransportClient(t, &noHostServicesPlugin{})
require.NotPanics(t, func() {
offerPluginHostServices(context.Background(), &PluginInstallation{PluginKey: "test.plugin"}, tc.TransportPluginClient, tc.Broker, nil, 5*time.Second)
})
}
@@ -0,0 +1,331 @@
package service
import (
"context"
"math"
"net/http"
"sort"
"strings"
"sync"
"testing"
"time"
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
// fakePluginKVStore 是内存版 PluginKVStore,按 (pluginKey, namespace, key) 三元组隔离,
// 用于确定性地验证宿主服务端的校验与命名空间隔离逻辑。
type fakePluginKVStore struct {
mu sync.Mutex
values map[string][]byte
ttls map[string]time.Duration
}
func newFakePluginKVStore() *fakePluginKVStore {
return &fakePluginKVStore{values: map[string][]byte{}, ttls: map[string]time.Duration{}}
}
func (f *fakePluginKVStore) compose(pluginKey, namespace, key string) string {
return pluginKey + "\x00" + namespace + "\x00" + key
}
func (f *fakePluginKVStore) Get(_ context.Context, pluginKey, namespace, key string) ([]byte, bool, error) {
f.mu.Lock()
defer f.mu.Unlock()
value, ok := f.values[f.compose(pluginKey, namespace, key)]
if !ok {
return nil, false, nil
}
return append([]byte(nil), value...), true, nil
}
func (f *fakePluginKVStore) Set(_ context.Context, pluginKey, namespace, key string, value []byte, ttl time.Duration) error {
f.mu.Lock()
defer f.mu.Unlock()
composed := f.compose(pluginKey, namespace, key)
f.values[composed] = append([]byte(nil), value...)
f.ttls[composed] = ttl
return nil
}
func (f *fakePluginKVStore) Delete(_ context.Context, pluginKey, namespace, key string) error {
f.mu.Lock()
defer f.mu.Unlock()
composed := f.compose(pluginKey, namespace, key)
delete(f.values, composed)
delete(f.ttls, composed)
return nil
}
func (f *fakePluginKVStore) List(_ context.Context, pluginKey, namespace, keyPrefix string, limit int) ([]string, error) {
f.mu.Lock()
defer f.mu.Unlock()
prefix := f.compose(pluginKey, namespace, keyPrefix)
scope := f.compose(pluginKey, namespace, "")
var keys []string
for composed := range f.values {
if strings.HasPrefix(composed, prefix) {
keys = append(keys, strings.TrimPrefix(composed, scope))
}
}
sort.Strings(keys)
if limit > 0 && len(keys) > limit {
keys = keys[:limit]
}
return keys, nil
}
func TestPluginHostServiceServer_SetGetDeleteRoundtrip(t *testing.T) {
store := newFakePluginKVStore()
server := newPluginHostServiceServer("local.example.plugin", store, nil)
ctx := context.Background()
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "alpha", Value: []byte("v1"), TtlSeconds: 60})
require.NoError(t, err)
got, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "alpha"})
require.NoError(t, err)
assert.True(t, got.Found)
assert.Equal(t, []byte("v1"), got.Value)
assert.Equal(t, time.Minute, store.ttls[store.compose("local.example.plugin", "state", "alpha")])
_, err = server.KVDelete(ctx, &pluginv1.KVDeleteRequest{Namespace: "state", Key: "alpha"})
require.NoError(t, err)
got, err = server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "alpha"})
require.NoError(t, err)
assert.False(t, got.Found)
}
func TestPluginHostServiceServer_GetNotFound(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
got, err := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "missing"})
require.NoError(t, err)
assert.False(t, got.Found)
assert.Nil(t, got.Value)
}
func TestPluginHostServiceServer_NamespaceIsolationAcrossPlugins(t *testing.T) {
store := newFakePluginKVStore()
first := newPluginHostServiceServer("local.plugin.one", store, nil)
second := newPluginHostServiceServer("local.plugin.two", store, nil)
ctx := context.Background()
_, err := first.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "shared", Value: []byte("first")})
require.NoError(t, err)
// 另一个插件即便使用完全相同的 namespace/key 也读不到,因为 pluginKey 由宿主注入。
got, err := second.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "shared"})
require.NoError(t, err)
assert.False(t, got.Found)
got, err = first.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "shared"})
require.NoError(t, err)
assert.True(t, got.Found)
assert.Equal(t, []byte("first"), got.Value)
}
func TestPluginHostServiceServer_ListPrefixAndLimit(t *testing.T) {
store := newFakePluginKVStore()
server := newPluginHostServiceServer("local.example.plugin", store, nil)
ctx := context.Background()
for _, key := range []string{"account-1", "account-2", "account-3", "other-1"} {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: key, Value: []byte("x")})
require.NoError(t, err)
}
resp, err := server.KVList(ctx, &pluginv1.KVListRequest{Namespace: "state", KeyPrefix: "account-"})
require.NoError(t, err)
assert.ElementsMatch(t, []string{"account-1", "account-2", "account-3"}, resp.Keys)
resp, err = server.KVList(ctx, &pluginv1.KVListRequest{Namespace: "state", KeyPrefix: "account-", Limit: 2})
require.NoError(t, err)
assert.Len(t, resp.Keys, 2)
}
func TestPluginHostServiceServer_Validation(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
ctx := context.Background()
oversized := make([]byte, pluginKVMaxValueBytes+1)
cases := []struct {
name string
call func() error
}{
{"empty namespace", func() error {
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "", Key: "k"})
return err
}},
{"namespace with colon", func() error {
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "a:b", Key: "k"})
return err
}},
{"key with glob", func() error {
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: "a*"})
return err
}},
{"empty key", func() error {
_, err := server.KVGet(ctx, &pluginv1.KVGetRequest{Namespace: "state", Key: ""})
return err
}},
{"oversized value", func() error {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: oversized})
return err
}},
{"negative ttl", func() error {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: -1})
return err
}},
{"ttl over max", func() error {
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: int64(pluginKVMaxTTL/time.Second) + 1})
return err
}},
{"ttl overflow", func() error {
// 溢出用例:seconds*time.Second 会回绕成负值,必须仍判为超限而非放行。
_, err := server.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v"), TtlSeconds: math.MaxInt64})
return err
}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
err := tc.call()
require.Error(t, err)
assert.Equal(t, codes.InvalidArgument, status.Code(err))
})
}
}
func TestPluginHostServiceServer_UnavailableWhenNoStore(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", nil, nil)
_, err := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
// pluginKey 为空同样视为不可用,避免键前缀塌缩导致的跨插件泄漏。
blank := newPluginHostServiceServer("", newFakePluginKVStore(), nil)
_, err = blank.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
// pluginKey 含 ':' 会破坏内部键结构,必须判定为不可用而非放行。
tainted := newPluginHostServiceServer("bad:key", newFakePluginKVStore(), nil)
_, err = tainted.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
}
// pluginKey 的长度上限必须覆盖清单 id 的 maxLength(160),否则长 id 的合法插件会被
// 静默剥夺宿主服务能力。
func TestPluginHostServiceServer_PluginKeyLengthBoundary(t *testing.T) {
ctx := context.Background()
maxLen := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen), newFakePluginKVStore(), nil)
_, err := maxLen.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v")})
require.NoError(t, err)
tooLong := newPluginHostServiceServer(strings.Repeat("a", pluginKVMaxPluginKeyLen+1), newFakePluginKVStore(), nil)
_, err = tooLong.KVSet(ctx, &pluginv1.KVSetRequest{Namespace: "state", Key: "k", Value: []byte("v")})
require.Error(t, err)
assert.Equal(t, codes.Unavailable, status.Code(err))
}
type fakeAccountDirectory struct {
ids []int64
identity *PluginOutboundIdentity
lastReq int64
}
func (f *fakeAccountDirectory) ListPluginAccounts(_ context.Context, _, _ string) ([]int64, error) {
return f.ids, nil
}
func (f *fakeAccountDirectory) ResolvePluginOutboundIdentity(_ context.Context, accountID int64) (*PluginOutboundIdentity, error) {
f.lastReq = accountID
if f.identity == nil || f.identity.AccountID != accountID {
return nil, nil
}
return f.identity, nil
}
func TestPluginHostServiceServer_AccountDirectory(t *testing.T) {
ctx := context.Background()
dir := &fakeAccountDirectory{
ids: []int64{3, 7},
identity: &PluginOutboundIdentity{
AccountID: 7, Platform: "openai", AccountType: "oauth", ProxyURL: "http://p:1",
Token: "tok", Headers: http.Header{"Originator": {"codex-tui"}},
},
}
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), dir)
list, err := server.ListAccounts(ctx, &pluginv1.ListAccountsRequest{Platform: "openai", AccountType: "oauth"})
require.NoError(t, err)
assert.Equal(t, []int64{3, 7}, list.AccountIds)
resolved, err := server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 7})
require.NoError(t, err)
require.True(t, resolved.Found)
assert.Equal(t, "tok", resolved.Token)
assert.Equal(t, "http://p:1", resolved.ProxyUrl)
assert.Equal(t, "codex-tui", resolved.Headers["Originator"].Values[0])
// unknown account → found=false, no error
miss, err := server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 99})
require.NoError(t, err)
assert.False(t, miss.Found)
// invalid account id → InvalidArgument
_, err = server.ResolveOutboundIdentity(ctx, &pluginv1.ResolveOutboundIdentityRequest{AccountId: 0})
assert.Equal(t, codes.InvalidArgument, status.Code(err))
}
// 无目录时账号目录 RPC 必须返回 Unavailable(KV 仍可用),保证未授权插件拿不到凭据。
func TestPluginHostServiceServer_DirectoryUnavailableWithoutDirectory(t *testing.T) {
server := newPluginHostServiceServer("local.example.plugin", newFakePluginKVStore(), nil)
_, err := server.ListAccounts(context.Background(), &pluginv1.ListAccountsRequest{})
assert.Equal(t, codes.Unavailable, status.Code(err))
_, err = server.ResolveOutboundIdentity(context.Background(), &pluginv1.ResolveOutboundIdentityRequest{AccountId: 1})
assert.Equal(t, codes.Unavailable, status.Code(err))
// KV 仍可用
_, kvErr := server.KVGet(context.Background(), &pluginv1.KVGetRequest{Namespace: "state", Key: "k"})
require.NoError(t, kvErr)
}
func TestPluginDeclaresOpenAIOAuthCapability(t *testing.T) {
match := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}}}
if !pluginDeclaresOpenAIOAuthCapability(match) {
t.Fatal("matching capability must be recognized")
}
wrongType := PluginManifest{Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: "apikey"}}}
if pluginDeclaresOpenAIOAuthCapability(wrongType) {
t.Fatal("wrong account_type must not match")
}
empty := PluginManifest{}
if pluginDeclaresOpenAIOAuthCapability(empty) {
t.Fatal("no capability must not match")
}
}
// buildHostServices 只对声明了 OpenAI OAuth 能力的插件注入账号目录。
func TestBuildHostServicesGatesDirectoryByCapability(t *testing.T) {
dir := &fakeAccountDirectory{}
m := &PluginManager{kvStore: newFakePluginKVStore(), accountDirectory: dir}
authorized := &PluginInstallation{PluginKey: "p.authorized", Manifest: PluginManifest{
Capabilities: []PluginCapability{{ID: PluginCapabilityOpenAIOAuthOutbound, Platform: PlatformOpenAI, AccountType: AccountTypeOAuth}},
}}
srv, ok := m.buildHostServices(authorized).(*pluginHostServiceServer)
require.True(t, ok)
require.NotNil(t, srv.directory)
unauthorized := &PluginInstallation{PluginKey: "p.other", Manifest: PluginManifest{
Capabilities: []PluginCapability{{ID: "some.other.capability", Platform: "x", AccountType: "y"}},
}}
srv2, ok := m.buildHostServices(unauthorized).(*pluginHostServiceServer)
require.True(t, ok)
require.Nil(t, srv2.directory, "unauthorized plugin must not receive the account directory")
require.NotNil(t, srv2.store, "but KV remains available")
}
+65 -2
View File
@@ -46,6 +46,11 @@ type PluginManager struct {
cfg *config.Config
hostInfo PluginHostInfo
installer *PluginPackageInstaller
// kvStore 为运行中的插件提供通用宿主键值存储;为 nil 时不向插件暴露宿主服务。
kvStore PluginKVStore
// accountDirectory 为声明了对应能力的插件提供账号目录与出站身份解析(敏感能力);
// 通过 SetAccountDirectory 在启动装配阶段注入,为 nil 时插件拿不到该能力。
accountDirectory PluginAccountDirectory
operationMu sync.Mutex
mu sync.Mutex
@@ -57,13 +62,14 @@ type PluginManager struct {
route atomic.Pointer[pluginRoute]
}
func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo) *PluginManager {
func NewPluginManager(repo PluginRepository, encryptor SecretEncryptor, cfg *config.Config, hostInfo PluginHostInfo, kvStore PluginKVStore) *PluginManager {
return &PluginManager{
repo: repo,
encryptor: encryptor,
cfg: cfg,
hostInfo: hostInfo,
installer: NewPluginPackageInstaller(cfg, hostInfo),
kvStore: kvStore,
runtimes: make(map[int64]*pluginRuntime),
localInstallations: make(map[int64]*PluginInstallation),
}
@@ -813,6 +819,26 @@ func (m *PluginManager) Test(ctx context.Context, id int64) (*pluginv1.TestConfi
return runtime.api.TestConfig(testCtx, &pluginv1.TestConfigRequest{ConfigJson: configJSON})
}
// Status returns the plugin's passive runtime status (its Health response, including
// any status_json blob) for the config UI. Unlike Test it never applies config,
// starts a temporary runtime, or reaches upstream — a plugin that is not currently
// running simply reports "not running" with no status blob. This makes it safe to
// serve from a lightweight, ungated, read-only endpoint used for status polling.
func (m *PluginManager) Status(ctx context.Context, id int64) (*pluginv1.HealthResponse, error) {
if _, err := m.repo.GetByID(ctx, id); err != nil {
return nil, err
}
m.mu.Lock()
runtime := m.runtimes[id]
m.mu.Unlock()
if runtime == nil {
return &pluginv1.HealthResponse{Healthy: false, Message: "插件未运行"}, nil
}
statusCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
return runtime.status(statusCtx)
}
type pluginUIAssetClaims struct {
Version int `json:"version"`
PluginID int64 `json:"plugin_id"`
@@ -1018,7 +1044,44 @@ func (m *PluginManager) newRuntime(ctx context.Context, installation *PluginInst
return nil, err
}
timeout := time.Duration(m.cfg.Plugins.StartTimeoutSeconds) * time.Second
return startPluginRuntime(ctx, installation, timeout, socketDir)
return startPluginRuntime(ctx, installation, timeout, socketDir, m.buildHostServices(installation))
}
// SetAccountDirectory 注入账号目录实现(敏感能力)。仅在启动装配阶段调用一次,
// 早于 Start,因此运行期读取无需额外同步。
func (m *PluginManager) SetAccountDirectory(directory PluginAccountDirectory) {
m.mu.Lock()
m.accountDirectory = directory
m.mu.Unlock()
}
// buildHostServices 为单个插件构造绑定其 pluginKey 的宿主服务端点。返回 nil(未配置
// 键值存储或缺少 pluginKey)时,startPluginRuntime 不会向插件暴露任何宿主服务。
// 账号目录(会向插件交付账号凭据)只对「清单声明了 OpenAI OAuth 出站能力」的插件开放,
// 从而把凭据暴露面收敛到本就要处理这些账号的插件。
func (m *PluginManager) buildHostServices(installation *PluginInstallation) pluginv1.HostServiceServer {
if m.kvStore == nil || installation == nil || strings.TrimSpace(installation.PluginKey) == "" {
return nil
}
var directory PluginAccountDirectory
if pluginDeclaresOpenAIOAuthCapability(installation.Manifest) {
m.mu.Lock()
directory = m.accountDirectory
m.mu.Unlock()
}
return newPluginHostServiceServer(installation.PluginKey, m.kvStore, directory)
}
// pluginDeclaresOpenAIOAuthCapability reports whether the (install-validated)
// manifest declares the OpenAI OAuth outbound transport capability.
func pluginDeclaresOpenAIOAuthCapability(manifest PluginManifest) bool {
for _, capability := range manifest.Capabilities {
if capability.ID == PluginCapabilityOpenAIOAuthOutbound &&
capability.Platform == PlatformOpenAI && capability.AccountType == AccountTypeOAuth {
return true
}
}
return false
}
func (m *PluginManager) removeRuntimeLocked(id int64) *pluginRuntime {
@@ -112,3 +112,24 @@ func TestStablePluginBucketIsDeterministicAndBounded(t *testing.T) {
assert.Less(t, first, uint64(100))
}
}
type statusStubRepository struct {
PluginRepository
}
func (r *statusStubRepository) GetByID(context.Context, int64) (*PluginInstallation, error) {
return &PluginInstallation{ID: 7}, nil
}
// Status is the read-only, ungated runtime-status channel: when the plugin is not
// running it must report "not running" with no status blob and never error or start
// a runtime (that side-effecting behaviour belongs to Test, not Status).
func TestPluginManagerStatusReportsNotRunningWithoutRuntime(t *testing.T) {
manager := &PluginManager{repo: &statusStubRepository{}}
resp, err := manager.Status(context.Background(), 7)
require.NoError(t, err)
require.NotNil(t, resp)
assert.False(t, resp.Healthy)
assert.Equal(t, "插件未运行", resp.Message)
assert.Empty(t, resp.StatusJson)
}
+72 -3
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"net/http"
"os/exec"
"strconv"
@@ -19,6 +20,9 @@ import (
pluginv1 "github.com/Wei-Shaw/sub2api/pkg/pluginapi/v1"
hclog "github.com/hashicorp/go-hclog"
hcplugin "github.com/hashicorp/go-plugin"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/status"
)
type pluginRuntime struct {
@@ -31,7 +35,7 @@ type pluginRuntime struct {
doneOnce sync.Once
}
func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string) (*pluginRuntime, error) {
func startPluginRuntime(ctx context.Context, installation *PluginInstallation, startTimeout time.Duration, socketDir string, hostServices pluginv1.HostServiceServer) (*pluginRuntime, error) {
if installation == nil {
return nil, errors.New("插件安装记录为空")
}
@@ -66,11 +70,12 @@ func startPluginRuntime(ctx context.Context, installation *PluginInstallation, s
client.Kill()
return nil, fmt.Errorf("获取插件传输能力: %w", err)
}
api, ok := dispensed.(pluginv1.TransportPluginClient)
if !ok {
transportClient, ok := dispensed.(*pluginv1.TransportClient)
if !ok || transportClient.TransportPluginClient == nil {
client.Kill()
return nil, errors.New("插件未实现传输 gRPC 客户端")
}
api := transportClient.TransportPluginClient
runtime := &pluginRuntime{
installation: installation,
client: client,
@@ -97,9 +102,56 @@ func startPluginRuntime(ctx context.Context, installation *PluginInstallation, s
}
return nil, fmt.Errorf("插件不健康: %s", health.Message)
}
// 可选地把宿主服务(HostService)反向暴露给插件。这是叠加在传输契约之上的能力:
// 老插件不实现 InitHostServices(返回 Unimplemented),此处静默跳过,绝不阻断启动。
offerPluginHostServices(ctx, installation, api, transportClient.Broker, hostServices, startTimeout)
return runtime, nil
}
// offerPluginHostServices 在 go-plugin broker 上启动一个宿主服务实例,并通过
// InitHostServices 把 broker 流 id 交给插件。服务生命周期与插件进程绑定:client.Kill()
// 会关闭 broker,AcceptAndServe 随之 GracefulStop,无需手动清理。整个过程尽力而为,
// 任何失败都只记录日志、不影响插件转发能力。
func offerPluginHostServices(
ctx context.Context,
installation *PluginInstallation,
api pluginv1.TransportPluginClient,
broker *hcplugin.GRPCBroker,
hostServices pluginv1.HostServiceServer,
startTimeout time.Duration,
) {
if broker == nil || hostServices == nil || api == nil {
return
}
brokerID := broker.NextId()
go broker.AcceptAndServe(brokerID, func(opts []grpc.ServerOption) *grpc.Server {
server := grpc.NewServer(opts...)
pluginv1.RegisterHostServiceServer(server, hostServices)
return server
})
initCtx, cancel := context.WithTimeout(ctx, startTimeout)
defer cancel()
resp, err := api.InitHostServices(initCtx, &pluginv1.InitHostServicesRequest{
HostServiceId: brokerID,
HostServiceApiVersion: pluginv1.HostServiceAPIVersion,
})
pluginKey := ""
if installation != nil {
pluginKey = installation.PluginKey
}
if err != nil {
if status.Code(err) == codes.Unimplemented {
slog.Debug("plugin_host_services_unimplemented", "plugin", pluginKey)
} else {
slog.Warn("plugin_host_services_init_failed", "plugin", pluginKey, "error", err)
}
return
}
if resp != nil && !resp.Ready {
slog.Debug("plugin_host_services_declined", "plugin", pluginKey, "message", resp.Message)
}
}
func (r *pluginRuntime) validateAndApplyConfig(ctx context.Context, configJSON []byte) error {
_, err := r.validateAndApplyNormalizedConfig(ctx, configJSON)
return err
@@ -161,6 +213,23 @@ func (r *pluginRuntime) checkHealth(ctx context.Context) error {
return nil
}
// status returns the plugin's passive Health response, including any status_json
// blob it exposes for the config UI. It performs no config apply and no upstream
// call, so it is safe to serve from a lightweight, ungated status endpoint.
func (r *pluginRuntime) status(ctx context.Context) (*pluginv1.HealthResponse, error) {
if r == nil || r.api == nil || r.client == nil || r.client.Exited() {
return nil, errors.New("插件进程已退出")
}
health, err := r.api.Health(ctx, &pluginv1.HealthRequest{})
if err != nil {
return nil, fmt.Errorf("插件状态查询失败: %w", err)
}
if health == nil {
return nil, errors.New("插件未返回状态")
}
return health, nil
}
func (r *pluginRuntime) beginRequest() bool {
if r == nil || r.draining.Load() {
return false
@@ -47,7 +47,7 @@ func TestPluginRuntimeIntegration(t *testing.T) {
assert.FileExists(t, filepath.Join(installation.InstallPath, filepath.FromSlash(relative)))
}
runtime, err := startPluginRuntime(context.Background(), installation, 10*time.Second, filepath.Join(root, "runtime"))
runtime, err := startPluginRuntime(context.Background(), installation, 10*time.Second, filepath.Join(root, "runtime"), nil)
require.NoError(t, err)
defer runtime.kill()
require.NoError(t, runtime.validateAndApplyConfig(context.Background(), []byte(`{
@@ -6,6 +6,8 @@ import (
"net/http"
"strings"
"time"
"github.com/tidwall/gjson"
)
// 国产供应商(kimi/zhipu/deepseek)的响应式冷却辅助。
@@ -35,22 +37,65 @@ func isCNProviderConcurrencyLimit403(account *Account, upstreamMsg string) bool
strings.TrimSpace(upstreamMsg) == kimiConcurrentRequestLimitMessage
}
// cnQuotaExhausted403ErrorType 是 Kimi Coding Plan 配额窗口耗尽的 403 错误类型。
// 与并发限制 403(见 isCNProviderConcurrencyLimit403)不同,这是 5h/weekly
// 窗口耗尽的限流信号:窗口到期后自动恢复,绝不能落入通用 403 升级计数
// (连续 3 次永久 SetError),必须按 429 口径冷却到真实窗口重置点。
const cnQuotaExhausted403ErrorType = "access_terminated_error"
// cnQuotaExhaustedReasonPrefix 是配额耗尽 403 临时停调 reason 的稳定前缀。
const cnQuotaExhaustedReasonPrefix = "cn_quota_exhausted"
// isCNProviderQuotaExhausted403 识别 CN 供应商配额窗口耗尽的 403。
// 先做廉价的文案子串匹配(usage limit / quota will reset),未命中再解析
// 响应体匹配结构化错误类型(Kimi 的 access_terminated_error),以兼容同族
// 供应商的文案变体。与并发限制文案互斥,两类信号不会误判。
// 仅限 Coding Plan 账号:滚动窗口快照(含重置时间)只有 Coding Plan 账号才有,
// 非 Coding Plan 账号的同类 403 语义不明(可能是需要升级套餐的硬上限),
// 应回退到通用 403 升级逻辑而非无限临时冷却。
func isCNProviderQuotaExhausted403(account *Account, responseBody []byte, upstreamMsg string) bool {
if account == nil || !account.IsCNProvider() || !account.IsCodingPlan() {
return false
}
msg := strings.ToLower(strings.TrimSpace(upstreamMsg))
if strings.Contains(msg, "usage limit") || strings.Contains(msg, "quota will reset") {
return true
}
return strings.EqualFold(strings.TrimSpace(gjson.GetBytes(responseBody, "error.type").String()), cnQuotaExhausted403ErrorType)
}
// handleCNProviderQuotaExhausted403 把 CN 供应商配额窗口耗尽的 403 按 429
// 同口径处理:有配额快照时冷却到最早的未来窗口重置点(SetRateLimited,
// 窗口过期后调度自动恢复);快照缺失(额度探测尚未刷新)时兜底默认 403
// 冷却时长的临时停调,避免过度停调到数天后的窗口。无论哪条路径都不写
// 账号 error 状态。
func (s *RateLimitService) handleCNProviderQuotaExhausted403(
ctx context.Context,
account *Account,
upstreamMsg string,
) {
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, cnQuotaExhaustedReasonPrefix, "cn_quota_exhausted_rate_limited") != nil {
return
}
// upstreamMsg 由调用方(HandleUpstreamError)从同一响应体提取,此处直接复用;
// 为空时仅存前缀,保证 reason 稳定可检索。
reason := cnQuotaExhaustedReasonPrefix
if msg := strings.TrimSpace(upstreamMsg); msg != "" {
reason += ": " + msg
}
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
s.setCNProviderTempUnschedulable(ctx, account, until, cnQuotaExhaustedReasonPrefix, reason, "cn_quota_exhausted_temp_unschedulable")
}
func (s *RateLimitService) handleCNProviderConcurrencyLimit403(
ctx context.Context,
account *Account,
) {
until := time.Now().Add(time.Duration(openAI403CooldownMinutesDefault) * time.Minute)
reason := cnConcurrencyLimitReasonPrefix + ": " + kimiConcurrentRequestLimitMessage
s.notifyAccountSchedulingBlocked(account, until, cnConcurrencyLimitReasonPrefix)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, reason); err != nil {
slog.Warn("cn_concurrency_limit_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
return
}
slog.Info("cn_provider_concurrency_limited",
"account_id", account.ID,
"platform", account.Platform,
"until", until.UTC(),
)
s.setCNProviderTempUnschedulable(ctx, account, until,
cnConcurrencyLimitReasonPrefix, cnConcurrencyLimitReasonPrefix+": "+kimiConcurrentRequestLimitMessage,
"cn_provider_concurrency_limited")
}
// cnBalanceLowReason 构造余额不足临时停调的 reason(带稳定前缀)。
@@ -151,6 +196,45 @@ func cnProviderQuotaSnapshotReset(account *Account, now time.Time) *time.Time {
return earliest
}
// cooldownCNProviderToQuotaSnapshotReset 把账号冷却到配额快照中最早的未来窗口
// 重置点(SetRateLimited,窗口过期后调度自动恢复),供 429(OpenCodeGo /
// Coding Plan)与配额耗尽 403 共用。仅在冷却成功持久化后返回非 nil;
// 无快照或写库失败时返回 nil,由调用方落入各自的兜底逻辑(避免写库失败
// 时账号完全失去持久冷却)。
func (s *RateLimitService) cooldownCNProviderToQuotaSnapshotReset(ctx context.Context, account *Account, reason, logEvent string) *time.Time {
until := cnProviderQuotaSnapshotReset(account, time.Now())
if until == nil {
return nil
}
s.notifyAccountSchedulingBlocked(account, *until, reason)
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return nil
}
slog.Info(logEvent,
"account_id", account.ID,
"platform", account.Platform,
"reset_at", until.UTC(),
)
return until
}
// setCNProviderTempUnschedulable 写临时停调(含调度层通知与日志),供 CN 403
// 各分支(并发限制、配额耗尽兜底)共用。告警 key 由 notifyReason 派生,保持
// 各分支既有日志 key 稳定(<prefix>_set_temp_unschedulable_failed)。
func (s *RateLimitService) setCNProviderTempUnschedulable(ctx context.Context, account *Account, until time.Time, notifyReason, storeReason, logEvent string) {
s.notifyAccountSchedulingBlocked(account, until, notifyReason)
if err := s.accountRepo.SetTempUnschedulable(ctx, account.ID, until, storeReason); err != nil {
slog.Warn(notifyReason+"_set_temp_unschedulable_failed", "account_id", account.ID, "error", err)
return
}
slog.Info(logEvent,
"account_id", account.ID,
"platform", account.Platform,
"until", until.UTC(),
)
}
// applyCNProviderReactive429 处理国产供应商的 429 响应。
// 返回 true 表示已处理(调用方应 return),false 表示未命中、继续走默认 429 逻辑。
func (s *RateLimitService) applyCNProviderReactive429(
@@ -160,17 +244,7 @@ func (s *RateLimitService) applyCNProviderReactive429(
responseBody []byte,
) bool {
if account.IsOpenCodeGo() {
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
s.notifyAccountSchedulingBlocked(account, *until, "429")
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return true
}
slog.Info("opencode_go_rate_limited",
"account_id", account.ID,
"platform", account.Platform,
"reset_at", *until,
)
if s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "opencode_go_rate_limited") != nil {
return true
}
if resetAt := parseOpenAIRateLimitResetTime(responseBody); resetAt != nil {
@@ -199,20 +273,9 @@ func (s *RateLimitService) applyCNProviderReactive429(
}
// 2) Coding Plan 窗口耗尽:冷却到快照中最早的窗口重置点(见
// cnProviderQuotaSnapshotReset:429 多由 5h 窗口触发,取较早点避免过度停调)。
if account.IsCodingPlan() {
if until := cnProviderQuotaSnapshotReset(account, time.Now()); until != nil {
s.notifyAccountSchedulingBlocked(account, *until, "429")
if err := s.accountRepo.SetRateLimited(ctx, account.ID, *until); err != nil {
slog.Warn("rate_limit_set_failed", "account_id", account.ID, "error", err)
return true
}
slog.Info("cn_coding_plan_rate_limited",
"account_id", account.ID,
"platform", account.Platform,
"reset_at", *until,
)
return true
}
if account.IsCodingPlan() &&
s.cooldownCNProviderToQuotaSnapshotReset(ctx, account, "429", "cn_coding_plan_rate_limited") != nil {
return true
}
return false
}
@@ -992,6 +992,14 @@ func (s *RateLimitService) handle403(ctx context.Context, account *Account, upst
s.handleCNProviderConcurrencyLimit403(ctx, account)
return true
}
// Kimi 等 CN 供应商把 Coding Plan 配额窗口耗尽打成 403
// (error.type=access_terminated_error),这是窗口到期后自动恢复的限流
// 信号而非封禁:按 429 口径冷却到真实窗口重置点,避免落入下方通用 403
// 升级计数后被永久 SetError。
if isCNProviderQuotaExhausted403(account, responseBody, upstreamMsg) {
s.handleCNProviderQuotaExhausted403(ctx, account, upstreamMsg)
return true
}
// 国产供应商与 openai 同口径:HTML 403(CDN/代理拦截页)不构成账号失效证据,
// 且 403 在 failover 状态集里会被逐账号重放——直接 SetError 会让一个坏请求/
// 一层坏代理连环永久禁用整组账号。走 HTML 豁免 + N 次累计 + 临时冷却。
@@ -17,6 +17,7 @@ type rateLimitAccountRepoStub struct {
mockAccountRepoForGemini
setErrorCalls int
tempCalls int
rateLimitedCalls int
updateCredentialsCalls int
updateExtraCalls int
lastCredentials map[string]any
@@ -25,6 +26,8 @@ type rateLimitAccountRepoStub struct {
lastTempReason string
lastErrorID int64
lastTempID int64
lastRateLimitedID int64
lastRateLimitedAt time.Time
tempErr error
}
@@ -42,6 +45,13 @@ func (r *rateLimitAccountRepoStub) SetTempUnschedulable(ctx context.Context, id
return r.tempErr
}
func (r *rateLimitAccountRepoStub) SetRateLimited(ctx context.Context, id int64, resetAt time.Time) error {
r.rateLimitedCalls++
r.lastRateLimitedID = id
r.lastRateLimitedAt = resetAt
return nil
}
func (r *rateLimitAccountRepoStub) UpdateCredentials(ctx context.Context, id int64, credentials map[string]any) error {
r.updateCredentialsCalls++
r.lastCredentials = shallowCopyMap(credentials)
@@ -0,0 +1,171 @@
//go:build unit
package service
import (
"context"
"fmt"
"net/http"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
// newCNQuotaExhaustedTestAccount 构造一个 Kimi Coding Plan 账号:
// account_mode=coding 使 IsCodingPlan() 为真,Extra 带 5h/weekly 窗口快照。
func newCNQuotaExhaustedTestAccount(resetIn time.Duration) *Account {
extra := map[string]any{}
if resetIn > 0 {
extra[cnExtraKey(PlatformKimi, cnExtraSuffix5hUsed)] = 100
extra[cnExtraKey(PlatformKimi, cnExtraSuffix5hReset)] = time.Now().Add(resetIn).UTC().Format(time.RFC3339)
extra[cnExtraKey(PlatformKimi, cnExtraSuffixWeeklyReset)] = time.Now().Add(7 * 24 * time.Hour).UTC().Format(time.RFC3339)
}
return &Account{
ID: 401,
Platform: PlatformKimi,
Type: AccountTypeAPIKey,
Credentials: map[string]any{"account_mode": AccountModeCoding},
Extra: extra,
}
}
const kimiWeeklyQuotaExhaustedBody = `{"error":{"message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends. To continue now, purchase extra usage or upgrade your plan: https://www.kimi.com/membership/subscription?tab=quota","type":"access_terminated_error"}}`
// runKimi403 以 403 + 给定响应体驱动 HandleUpstreamError,返回 shouldDisable。
func runKimi403(service *RateLimitService, account *Account, body string) bool {
return service.HandleUpstreamError(
context.Background(),
account,
http.StatusForbidden,
http.Header{},
[]byte(body),
)
}
func TestHandleUpstreamError_KimiQuotaExhausted403RateLimitedToWindowReset(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
blocker := &runtimeBlockRecorder{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetAccountRuntimeBlocker(blocker)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls, "quota exhaustion must never SetError")
require.Equal(t, 0, repo.tempCalls)
require.Equal(t, 1, repo.rateLimitedCalls)
require.Equal(t, account.ID, repo.lastRateLimitedID)
require.True(t, repo.lastRateLimitedAt.After(time.Now()), "cooldown should target the future window reset")
require.True(t, repo.lastRateLimitedAt.Before(time.Now().Add(3*time.Hour)), "should cool down to the nearer 5h reset, not weekly")
require.Len(t, blocker.accounts, 1)
require.Equal(t, cnQuotaExhaustedReasonPrefix, blocker.reasons[0])
}
func TestHandleUpstreamError_KimiQuotaExhausted403WithoutSnapshotFallsBackToTemp(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := newCNQuotaExhaustedTestAccount(0)
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 0, repo.rateLimitedCalls)
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, cnQuotaExhaustedReasonPrefix)
require.Greater(t, len(repo.lastTempReason), len(cnQuotaExhaustedReasonPrefix))
}
func TestHandleUpstreamError_KimiQuotaExhausted403MatchedByMessageOnly(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
// 无 error.type 字段,仅靠文案兜底匹配。
body := `{"error":{"message":"You've reached your weekly (7-day) usage limit. Your quota will reset when the current 7-day window ends."}}`
shouldDisable := runKimi403(service, account, body)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 1, repo.rateLimitedCalls)
}
func TestHandleUpstreamError_KimiConcurrencyLimit403StillUsesConcurrencyPath(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
body := fmt.Sprintf(`{"error":{"message":%q,"type":"access_terminated_error"}}`, kimiConcurrentRequestLimitMessage)
shouldDisable := runKimi403(service, account, body)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 0, repo.rateLimitedCalls, "concurrency-limit message must keep its dedicated path")
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, cnConcurrencyLimitReasonPrefix)
}
func TestHandleUpstreamError_KimiGeneric403StillEscalates(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{3}}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
shouldDisable := runKimi403(service, account, `{"error":{"message":"policy rejected this request"}}`)
require.True(t, shouldDisable)
require.Equal(t, 1, repo.setErrorCalls, "non-quota 403 keeps the generic escalation path")
require.Equal(t, 0, repo.rateLimitedCalls)
}
func TestHandleUpstreamError_KimiNonCodingPlanQuotaMessageUsesGeneric403(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{3}}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
account := newCNQuotaExhaustedTestAccount(2 * time.Hour)
account.Credentials = map[string]any{"account_mode": AccountModePayG}
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
require.True(t, shouldDisable)
require.Equal(t, 1, repo.setErrorCalls, "non-coding-plan account must keep the generic 403 escalation")
require.Equal(t, 0, repo.rateLimitedCalls)
require.Equal(t, 0, repo.tempCalls)
}
func TestHandleUpstreamError_NonCNAccessTerminated403KeepsGenericPath(t *testing.T) {
repo := &rateLimitAccountRepoStub{}
counter := &openAI403CounterCacheStub{counts: []int64{1}}
service := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
service.SetOpenAI403CounterCache(counter)
account := &Account{
ID: 402,
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
}
shouldDisable := runKimi403(service, account, kimiWeeklyQuotaExhaustedBody)
require.True(t, shouldDisable)
require.Equal(t, 0, repo.rateLimitedCalls, "non-CN platform must not enter the CN quota path")
require.Equal(t, 0, repo.setErrorCalls)
require.Equal(t, 1, repo.tempCalls)
require.Contains(t, repo.lastTempReason, "(1/3)")
}
func TestIsCNProviderQuotaExhausted403_Classification(t *testing.T) {
kimi := newCNQuotaExhaustedTestAccount(0)
require.True(t, isCNProviderQuotaExhausted403(kimi, []byte(kimiWeeklyQuotaExhaustedBody), ""))
require.True(t, isCNProviderQuotaExhausted403(kimi, []byte(`{"error":{"message":"You've reached your weekly (7-day) usage limit."}}`), "You've reached your weekly (7-day) usage limit."))
require.False(t, isCNProviderQuotaExhausted403(kimi, []byte(`{"error":{"message":"policy rejected"}}`), "policy rejected"))
require.False(t, isCNProviderQuotaExhausted403(kimi, nil, ""))
openai := &Account{ID: 1, Platform: PlatformOpenAI, Type: AccountTypeOAuth}
require.False(t, isCNProviderQuotaExhausted403(openai, []byte(kimiWeeklyQuotaExhaustedBody), ""))
require.False(t, isCNProviderQuotaExhausted403(nil, []byte(kimiWeeklyQuotaExhaustedBody), ""))
}
@@ -742,3 +742,8 @@ func (s *RedeemService) reduceOrCancelSubscription(ctx context.Context, userID,
return nil
}
// GetUserHistoryPaginated returns all redemption types for the authenticated user.
func (s *RedeemService) GetUserHistoryPaginated(ctx context.Context, userID int64, params pagination.PaginationParams) ([]RedeemCode, *pagination.PaginationResult, error) {
return s.redeemRepo.ListByUserPaginated(ctx, userID, params, "")
}
+155
View File
@@ -0,0 +1,155 @@
package service
import (
"bytes"
"context"
"fmt"
"net/http"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const (
SeedanceEndpointCreate GrokMediaEndpoint = "seedance_create"
SeedanceEndpointStatus GrokMediaEndpoint = "seedance_status"
SeedanceEndpointDelete GrokMediaEndpoint = "seedance_delete"
OpenAIEndpointCapabilitySeedance OpenAIEndpointCapability = "seedance"
)
func (e GrokMediaEndpoint) IsSeedance() bool {
return e == SeedanceEndpointCreate || e == SeedanceEndpointStatus || e == SeedanceEndpointDelete
}
// SeedanceTaskKey isolates ownership and billing keys from other video providers.
func SeedanceTaskKey(id string) string { return "seedance:" + strings.TrimSpace(id) }
func ParseSeedanceRequest(body []byte) (GrokMediaRequestInfo, error) {
var info GrokMediaRequestInfo
if !gjson.ValidBytes(body) || !gjson.ParseBytes(body).IsObject() {
return info, fmt.Errorf("request body must be a JSON object")
}
model := gjson.GetBytes(body, "model")
if model.Type != gjson.String || strings.TrimSpace(model.String()) == "" {
return info, fmt.Errorf("model is required")
}
content := gjson.GetBytes(body, "content")
if !content.IsArray() || len(content.Array()) == 0 {
return info, fmt.Errorf("content must be a non-empty array")
}
info.Model = strings.TrimSpace(model.String())
var texts []string
for _, item := range content.Array() {
switch item.Get("type").String() {
case "text":
texts = append(texts, item.Get("text").String())
case "image_url":
info.InputImageURLs = append(info.InputImageURLs, item.Get("image_url.url").String())
}
}
info.Prompt = strings.Join(texts, "\n")
return info, nil
}
func buildSeedanceURL(base string, endpoint GrokMediaEndpoint, taskID string) (string, error) {
base = strings.TrimRight(base, "/")
// Accept an origin, a proxy prefix, or the full Ark API base.
if !strings.HasSuffix(base, "/api/v3") && !strings.HasSuffix(base, "/v3") {
base += "/api/v3"
}
base += "/contents/generations/tasks"
if endpoint != SeedanceEndpointCreate {
if err := validateUpstreamPathSegment("Seedance task ID", taskID); err != nil || strings.TrimSpace(taskID) == "" {
return "", fmt.Errorf("invalid Seedance task ID")
}
base += "/" + taskID
}
return base, nil
}
// ForwardSeedance preserves the Ark protocol, including multimodal content and
// future fields. Only model is rewritten using the account's configured mapping.
func (s *OpenAIGatewayService) ForwardSeedance(ctx context.Context, c *gin.Context, account *Account, endpoint GrokMediaEndpoint, taskID string, body []byte) (*OpenAIForwardResult, error) {
if !account.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance) || !endpoint.IsSeedance() {
return nil, fmt.Errorf("seedance requires an OpenAI API key account with a custom base URL")
}
base, err := s.validateUpstreamBaseURL(account.GetCredential("base_url"))
if err != nil {
return nil, err
}
target, err := buildSeedanceURL(base, endpoint, strings.TrimPrefix(taskID, "seedance:"))
if err != nil {
return nil, err
}
model, upstreamModel := "", ""
method := http.MethodGet
switch endpoint {
case SeedanceEndpointCreate:
info, parseErr := ParseSeedanceRequest(body)
if parseErr != nil {
return nil, parseErr
}
model = info.Model
upstreamModel = account.GetMappedModel(model)
body, err = sjson.SetBytes(body, "model", upstreamModel)
if err != nil {
return nil, err
}
method = http.MethodPost
case SeedanceEndpointDelete:
method = http.MethodDelete
}
token := strings.TrimSpace(account.GetCredential("api_key"))
if token == "" {
return nil, fmt.Errorf("seedance account missing api_key")
}
started := time.Now()
req, err := http.NewRequestWithContext(ctx, method, target, bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
account.ApplyHeaderOverrides(req.Header)
proxy := ""
if account.ProxyID != nil && account.Proxy != nil {
proxy = account.Proxy.URL()
}
resp, err := s.httpUpstream.Do(req, proxy, account.ID, account.Concurrency)
SetOpsLatencyMs(c, OpsUpstreamLatencyMsKey, time.Since(started).Milliseconds())
if err != nil {
return nil, err
}
defer func() { _ = resp.Body.Close() }()
responseBody, err := ReadUpstreamResponseBody(resp.Body, s.cfg, c, openAITooLargeError)
if err != nil {
return nil, err
}
// Do not retry ambiguous asynchronous creates: the upstream may already have
// accepted a billable job. Preserve native error codes and response bodies.
if resp.StatusCode >= 300 {
writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter)
return nil, fmt.Errorf("seedance upstream status %d", resp.StatusCode)
}
result := &OpenAIForwardResult{Model: model, BillingModel: model, UpstreamModel: upstreamModel, Duration: time.Since(started), ResponseHeaders: resp.Header.Clone()}
if endpoint == SeedanceEndpointCreate {
id := strings.TrimSpace(gjson.GetBytes(responseBody, "id").String())
if id == "" {
return nil, fmt.Errorf("seedance create response missing task ID")
}
result.ResponseID = SeedanceTaskKey(id)
}
if endpoint == SeedanceEndpointStatus {
result.ResponseID = taskID
result.UpstreamModel = gjson.GetBytes(responseBody, "model").String()
if gjson.GetBytes(responseBody, "status").String() == "succeeded" {
result.Usage.OutputTokens = max(0, int(gjson.GetBytes(responseBody, "usage.completion_tokens").Int()))
}
}
writeGrokMediaResponse(c, resp, responseBody, s.responseHeaderFilter)
return result, nil
}
+112
View File
@@ -0,0 +1,112 @@
//go:build unit
package service
import (
"context"
"io"
"net/http"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func seedanceTestAccount() *Account {
return &Account{ID: 9, Platform: PlatformOpenAI, Type: AccountTypeAPIKey, Credentials: map[string]any{
"api_key": "ark-secret", "base_url": "https://ark.cn-beijing.volces.com/api/v3",
"openai_capabilities": []string{"seedance"},
"model_mapping": map[string]any{"video": "ep-seedance"},
}}
}
func TestSeedanceNativeForwarding(t *testing.T) {
body := []byte(`{"model":"video","content":[{"type":"text","text":"waves"},{"type":"image_url","image_url":{"url":"https://example.com/first.png"},"role":"first_frame"},{"type":"audio_url","audio_url":{"url":"https://example.com/audio.mp3"}}],"duration":-1,"generate_audio":true,"future_field":{"keep":true}}`)
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"id":"task-1"}`)}
svc := &OpenAIGatewayService{httpUpstream: upstream}
c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil)
result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", body)
require.NoError(t, err)
require.JSONEq(t, `{"id":"task-1"}`, w.Body.String())
require.Equal(t, "seedance:task-1", result.ResponseID)
require.Zero(t, result.Usage.OutputTokens)
require.Equal(t, "video", result.BillingModel)
require.Equal(t, "ep-seedance", result.UpstreamModel)
require.Equal(t, "https://ark.cn-beijing.volces.com/api/v3/contents/generations/tasks", upstream.request.URL.String())
require.Equal(t, "Bearer ark-secret", upstream.request.Header.Get("Authorization"))
forwarded, err := io.ReadAll(upstream.request.Body)
require.NoError(t, err)
require.Equal(t, "ep-seedance", gjson.GetBytes(forwarded, "model").String())
for _, field := range []string{"content", "duration", "generate_audio", "future_field"} {
require.Equal(t, gjson.GetBytes(body, field).Raw, gjson.GetBytes(forwarded, field).Raw)
}
}
func TestSeedanceStatusAndDelete(t *testing.T) {
for _, status := range []string{"queued", "running", "failed", "cancelled", "expired", "succeeded"} {
t.Run(status, func(t *testing.T) {
body := `{"id":"task-1","status":"` + status + `","model":"ep-seedance","content":{"video_url":"https://cdn.example/video.mp4"},"usage":{"completion_tokens":12345}}`
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(body)}
svc := &OpenAIGatewayService{httpUpstream: upstream}
c, w := grokMediaContentTestContext(http.MethodGet, "/api/v3/contents/generations/tasks/task-1", nil)
result, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointStatus, "seedance:task-1", nil)
require.NoError(t, err)
require.JSONEq(t, body, w.Body.String())
require.Equal(t, "/api/v3/contents/generations/tasks/task-1", upstream.request.URL.Path)
if status == "succeeded" {
require.Equal(t, 12345, result.Usage.OutputTokens)
} else {
require.Zero(t, result.Usage.OutputTokens)
}
require.Zero(t, result.VideoCount, "must use token billing, not Grok seconds")
})
}
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse("")}
upstream.response.StatusCode = http.StatusNoContent
svc := &OpenAIGatewayService{httpUpstream: upstream}
c, w := grokMediaContentTestContext(http.MethodDelete, "/api/v3/contents/generations/tasks/task-1", nil)
_, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointDelete, "seedance:task-1", nil)
require.NoError(t, err)
require.Equal(t, http.MethodDelete, upstream.request.Method)
require.Equal(t, http.StatusNoContent, w.Code)
}
func TestSeedanceValidationAndCapability(t *testing.T) {
for _, body := range []string{`{`, `[]`, `{}`, `{"model":12,"content":[{}]}`, `{"model":"x","content":[]}`} {
_, err := ParseSeedanceRequest([]byte(body))
require.Error(t, err)
}
info, err := ParseSeedanceRequest([]byte(`{"model":"x","content":[{"type":"text","text":"first"},{"type":"text","text":"second"},{"type":"image_url","image_url":{"url":"https://example.com/a.png"}}]}`))
require.NoError(t, err)
require.Contains(t, string(info.ModerationBody()), "first")
require.Contains(t, string(info.ModerationBody()), "second")
require.Contains(t, string(info.ModerationBody()), "https://example.com/a.png")
for _, id := range []string{"", "..", "a/b", "a?b", "a#b", "%2e%2e"} {
_, err := buildSeedanceURL("https://example.com", SeedanceEndpointStatus, id)
require.Error(t, err, id)
}
for _, base := range []string{"https://example.com", "https://example.com/api/v3/", "https://example.com/v3"} {
url, err := buildSeedanceURL(base, SeedanceEndpointCreate, "")
require.NoError(t, err)
require.NotContains(t, url, "/v3/api/v3")
}
a := seedanceTestAccount()
require.True(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
a.Type = AccountTypeOAuth
require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
a.Type = AccountTypeAPIKey
delete(a.Credentials, "openai_capabilities")
require.False(t, a.SupportsOpenAIEndpointCapability(OpenAIEndpointCapabilitySeedance))
}
func TestSeedancePreservesUpstreamErrorsWithoutRetry(t *testing.T) {
upstream := &grokMediaContentUpstreamStub{response: grokMediaContentStatusResponse(`{"error":{"code":"QuotaExceeded","message":"quota exhausted"}}`)}
upstream.response.StatusCode = 429
svc := &OpenAIGatewayService{httpUpstream: upstream}
c, w := grokMediaContentTestContext(http.MethodPost, "/api/v3/contents/generations/tasks", nil)
_, err := svc.ForwardSeedance(context.Background(), c, seedanceTestAccount(), SeedanceEndpointCreate, "", []byte(`{"model":"video","content":[{"type":"text","text":"waves"}]}`))
require.Error(t, err)
require.Equal(t, 429, w.Code)
require.Contains(t, w.Body.String(), "QuotaExceeded")
require.Len(t, upstream.requests, 1)
}
@@ -51,7 +51,6 @@ func (r *tokenRefreshCandidateRepo) ListOAuthRefreshCandidatePage(_ context.Cont
}
}
if options.ActiveOnly && account.Status != StatusActive ||
!account.Schedulable ||
account.Type != AccountTypeOAuth ||
!platformAllowed ||
options.RequireRefreshToken && strings.TrimSpace(refreshToken) == "" ||
@@ -176,7 +175,7 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing
Type: AccountTypeOAuth,
Status: StatusActive,
Schedulable: false,
Credentials: map[string]any{"refresh_token": "permanently-rejected-token"},
Credentials: map[string]any{"refresh_token": "paused-account-token"},
},
},
}
@@ -195,7 +194,9 @@ func TestTokenRefreshService_ProcessRefreshUsesOAuthRefreshCandidates(t *testing
svc.processRefresh()
require.Zero(t, repo.listActiveCalls, "TokenRefreshService should not use the broad active-account query")
require.ElementsMatch(t, []int64{1, 6}, repo.updatedCredentialIDs)
// Account 7 is paused (schedulable=false) but active: it must still be
// refreshed so its stored access_token does not silently expire.
require.ElementsMatch(t, []int64{1, 6, 7}, repo.updatedCredentialIDs)
require.Equal(t, 1, repo.clearTempCalls, "successful refresh should clear the OAuth 401 temp-unschedulable state")
}
+28 -7
View File
@@ -30,6 +30,21 @@
- Sub2API 继续负责响应状态处理、SSE 解析、错误映射、用量统计、计费和下游输出。
- 灰度比例以账号 ID 稳定分桶,未命中的 OAuth 账号继续使用原有内置路径。
## 宿主服务(HostService)
`HostService` 是宿主经 go-plugin broker 反向暴露给插件的通用能力层,独立于具体插件类型,供有状态插件使用。它通过 `TransportPlugin.InitHostServices` 在运行时协商:宿主启动后把一个 broker 流 id 交给插件,插件用它拨号回宿主并获得 `HostServiceClient`。
- **可选且向后兼容**:未实现 `InitHostServices` 的旧插件返回 `Unimplemented`,宿主静默跳过,转发能力不受影响。宿主服务有独立的 `HostServiceAPIVersion`,新增能力不会改变传输契约版本,也不会使既有插件失效。
- **随进程回收**:宿主服务实例与插件进程生命周期绑定,插件退出时 broker 关闭并自动 `GracefulStop`,无需插件手动清理。
当前提供的通用设施:
- **命名空间键值存储(KV)**:`KVGet` / `KVSet` / `KVDelete` / `KVList`,为插件持久化跨请求、跨副本、跨重启的状态。存储由 Redis 支撑,因此多实例部署天然共享同一份状态。
- **命名空间隔离**:宿主根据服务该连接的运行时注入插件身份(`pluginKey`),插件无法伪造,也无法读写其它插件的命名空间。
- **护栏**:`namespace` / `key` 仅允许 `[A-Za-z0-9._-]`;单值上限 256 KiB;`ttl_seconds` 为 0 表示不过期、正值有上限;`KVList` 返回条数有上限。
新增宿主设施时,在 `HostService` 上追加 RPC 即可,无需改动传输契约或清单格式。
## 包结构
```text
@@ -61,15 +76,21 @@ ui/assets/...
插件 UI 由包内静态文件实现,宿主使用只有 `allow-scripts` 权限的 sandbox iframe 加载。iframe 没有管理员 Token,也不能直接访问管理 API。宿主为每次打开配置页生成短时资源 URL 和独立 Bridge Token,并且同时校验消息来源窗口与 Token。
UI 可以发送以下消息:
UI 可以发送以下消息。消息按语义分层,鉴权与副作用一致对应(读操作免二次验证,写/主动测试需要二次验证),任何插件都可复用,不针对具体插件定制:
- `config.load`
- `config.save`
- `config.test`
- `ui.resize`
- `ui.notify`
| 消息 | 语义 | 二次验证 | 映射的插件 RPC |
| --- | --- | --- | --- |
| `config.load` | 读取已保存配置 | 否 | 宿主数据库 |
| `config.save` | 写入配置 | 是 | `ValidateConfig` + `ApplyConfig` |
| `config.test` | 主动测试配置/连通性(可产生副作用) | 是 | `TestConfig` |
| `plugin.status` | 读取运行时状态(无副作用) | 否 | `Health`(`status_json`) |
| `ui.resize` / `ui.notify` | 仅 UI 交互 | — | — |
每个请求消息带 `request_id`,宿主以 `<type>.result` 返回结果。配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。
每个请求消息带 `request_id`,宿主以 `<type>.result` 返回结果。
- 配置整体使用 Sub2API 的密钥加密后存入数据库;运行中插件会先验证并应用新配置,数据库写入失败时恢复旧配置。
- `config.test` 的结果由插件 UI 自行展示(内联或经 `ui.notify`),宿主不再对成功结果强制弹出提示,避免插件把它当作轻量状态轮询时刷屏。
- `plugin.status` 是通用的**只读**状态通道:宿主经 `GET /admin/plugins/:id/status` 调用运行中插件的 `Health`,返回 `{healthy, message, status_json}`。`status_json` 是插件自定义的**不透明** JSON 快照(宿主不解析、不参与健康判定),插件必须以无副作用方式生成(不得应用配置、访问上游或触发探测),因此该端点只读、免二次验证。插件未运行时返回 `healthy=false` 且不含 `status_json`。这样带状态面板的插件无需滥用 `config.test` 即可展示实时状态。
## 协议源码
+4 -1
View File
@@ -44,10 +44,13 @@ UI 到宿主:
| `config.load` | 无 | `config` |
| `config.save` | `config` 对象 | 规范化后的 `config` |
| `config.test` | 无 | `result` |
| `plugin.status` | 无 | `result`(`Health`:`{healthy, message, status_json}`) |
| `ui.resize` | `height` | 无响应 |
| `ui.notify` | `level`、`message` | 无响应 |
`config.test` 在 v1 中测试已保存配置。UI 若要测试当前表单,应先调用 `config.save`。
`config.test` 在 v1 中测试已保存配置(需二次验证,可产生副作用)。UI 若要测试当前表单,应先调用 `config.save`。
`plugin.status` 是只读运行时状态通道:无副作用、免二次验证、不弹宿主提示,供状态面板轮询。它映射到插件 `Health`,`result.status_json` 是插件自定义的不透明 JSON 快照。带状态展示的插件应使用它,而不是把 `config.test` 当作状态轮询。
## 必须执行的校验
File diff suppressed because it is too large Load Diff
+107
View File
@@ -11,6 +11,28 @@ service TransportPlugin {
rpc ApplyConfig(ApplyConfigRequest) returns (ApplyConfigResponse);
rpc TestConfig(TestConfigRequest) returns (TestConfigResponse);
rpc Forward(stream ForwardRequest) returns (stream ForwardResponse);
// InitHostServices hands the plugin a go-plugin broker stream id it can dial
// back to reach the host-provided HostService. It is an optional, generic
// lifecycle hook: plugins built against an older contract leave it
// Unimplemented and the host simply runs without offering host services.
rpc InitHostServices(InitHostServicesRequest) returns (InitHostServicesResponse);
}
// HostService is implemented by the host and dialed by the plugin over the
// go-plugin broker id delivered via InitHostServices. It exposes generic,
// capability-neutral facilities that any plugin may use; new facilities are
// added here as new RPCs without changing the transport contract version.
service HostService {
rpc KVGet(KVGetRequest) returns (KVGetResponse);
rpc KVSet(KVSetRequest) returns (KVSetResponse);
rpc KVDelete(KVDeleteRequest) returns (KVDeleteResponse);
rpc KVList(KVListRequest) returns (KVListResponse);
// Account directory: enumerate the accounts the plugin is bound to act on and
// resolve the outbound identity (credentials/headers/proxy) the host would
// attach to a live request. The host restricts both calls to accounts the
// requesting plugin's capability already covers.
rpc ListAccounts(ListAccountsRequest) returns (ListAccountsResponse);
rpc ResolveOutboundIdentity(ResolveOutboundIdentityRequest) returns (ResolveOutboundIdentityResponse);
}
message GetInfoRequest {}
@@ -28,6 +50,12 @@ message HealthRequest {}
message HealthResponse {
bool healthy = 1;
string message = 2;
// status_json is an optional plugin-defined JSON blob describing current runtime
// state (e.g. a status summary for the config UI). It MUST be produced passively
// — reading it must not apply config, reach upstream, or cause side effects — so
// the host can expose it via a lightweight, ungated status endpoint. Ignored by
// the host's health gating.
string status_json = 3;
}
message ValidateConfigRequest {
@@ -57,6 +85,9 @@ message TestConfigResponse {
bool success = 1;
string message = 2;
int64 latency_ms = 3;
// status_json is an optional plugin-defined JSON blob surfaced to the config UI
// alongside the test result (e.g. a runtime status summary). Ignored by the host.
string status_json = 4;
}
message HeaderValues {
@@ -115,3 +146,79 @@ message ForwardResponse {
ForwardResponseError error = 4;
}
}
message InitHostServicesRequest {
// host_service_id is the broker stream id the plugin dials to reach HostService.
uint32 host_service_id = 1;
// host_service_api_version lets the plugin reject a host it cannot talk to.
uint32 host_service_api_version = 2;
}
message InitHostServicesResponse {
// ready is true once the plugin has successfully dialed back the host service.
bool ready = 1;
string message = 2;
}
// KV messages back a namespaced, per-plugin persistent key-value store. The
// host derives the owning plugin from the runtime that serves the connection,
// so a plugin can never read or write another plugin's namespace.
message KVGetRequest {
string namespace = 1;
string key = 2;
}
message KVGetResponse {
bool found = 1;
bytes value = 2;
}
message KVSetRequest {
string namespace = 1;
string key = 2;
bytes value = 3;
// ttl_seconds > 0 sets an expiry; 0 stores the entry without expiry.
int64 ttl_seconds = 4;
}
message KVSetResponse {}
message KVDeleteRequest {
string namespace = 1;
string key = 2;
}
message KVDeleteResponse {}
message KVListRequest {
string namespace = 1;
string key_prefix = 2;
int32 limit = 3;
}
message KVListResponse {
repeated string keys = 1;
}
message ListAccountsRequest {
string platform = 1;
string account_type = 2;
}
message ListAccountsResponse {
repeated int64 account_ids = 1;
}
message ResolveOutboundIdentityRequest {
int64 account_id = 1;
}
message ResolveOutboundIdentityResponse {
bool found = 1;
int64 account_id = 2;
string platform = 3;
string account_type = 4;
string proxy_url = 5;
string token = 6;
map<string, HeaderValues> headers = 7;
}
+362 -6
View File
@@ -19,12 +19,13 @@ import (
const _ = grpc.SupportPackageIsVersion9
const (
TransportPlugin_GetInfo_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/GetInfo"
TransportPlugin_Health_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Health"
TransportPlugin_ValidateConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ValidateConfig"
TransportPlugin_ApplyConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ApplyConfig"
TransportPlugin_TestConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/TestConfig"
TransportPlugin_Forward_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Forward"
TransportPlugin_GetInfo_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/GetInfo"
TransportPlugin_Health_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Health"
TransportPlugin_ValidateConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ValidateConfig"
TransportPlugin_ApplyConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/ApplyConfig"
TransportPlugin_TestConfig_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/TestConfig"
TransportPlugin_Forward_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/Forward"
TransportPlugin_InitHostServices_FullMethodName = "/sub2api.plugin.v1.TransportPlugin/InitHostServices"
)
// TransportPluginClient is the client API for TransportPlugin service.
@@ -37,6 +38,11 @@ type TransportPluginClient interface {
ApplyConfig(ctx context.Context, in *ApplyConfigRequest, opts ...grpc.CallOption) (*ApplyConfigResponse, error)
TestConfig(ctx context.Context, in *TestConfigRequest, opts ...grpc.CallOption) (*TestConfigResponse, error)
Forward(ctx context.Context, opts ...grpc.CallOption) (grpc.BidiStreamingClient[ForwardRequest, ForwardResponse], error)
// InitHostServices hands the plugin a go-plugin broker stream id it can dial
// back to reach the host-provided HostService. It is an optional, generic
// lifecycle hook: plugins built against an older contract leave it
// Unimplemented and the host simply runs without offering host services.
InitHostServices(ctx context.Context, in *InitHostServicesRequest, opts ...grpc.CallOption) (*InitHostServicesResponse, error)
}
type transportPluginClient struct {
@@ -110,6 +116,16 @@ func (c *transportPluginClient) Forward(ctx context.Context, opts ...grpc.CallOp
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type TransportPlugin_ForwardClient = grpc.BidiStreamingClient[ForwardRequest, ForwardResponse]
func (c *transportPluginClient) InitHostServices(ctx context.Context, in *InitHostServicesRequest, opts ...grpc.CallOption) (*InitHostServicesResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(InitHostServicesResponse)
err := c.cc.Invoke(ctx, TransportPlugin_InitHostServices_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// TransportPluginServer is the server API for TransportPlugin service.
// All implementations must embed UnimplementedTransportPluginServer
// for forward compatibility.
@@ -120,6 +136,11 @@ type TransportPluginServer interface {
ApplyConfig(context.Context, *ApplyConfigRequest) (*ApplyConfigResponse, error)
TestConfig(context.Context, *TestConfigRequest) (*TestConfigResponse, error)
Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error
// InitHostServices hands the plugin a go-plugin broker stream id it can dial
// back to reach the host-provided HostService. It is an optional, generic
// lifecycle hook: plugins built against an older contract leave it
// Unimplemented and the host simply runs without offering host services.
InitHostServices(context.Context, *InitHostServicesRequest) (*InitHostServicesResponse, error)
mustEmbedUnimplementedTransportPluginServer()
}
@@ -148,6 +169,9 @@ func (UnimplementedTransportPluginServer) TestConfig(context.Context, *TestConfi
func (UnimplementedTransportPluginServer) Forward(grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]) error {
return status.Error(codes.Unimplemented, "method Forward not implemented")
}
func (UnimplementedTransportPluginServer) InitHostServices(context.Context, *InitHostServicesRequest) (*InitHostServicesResponse, error) {
return nil, status.Error(codes.Unimplemented, "method InitHostServices not implemented")
}
func (UnimplementedTransportPluginServer) mustEmbedUnimplementedTransportPluginServer() {}
func (UnimplementedTransportPluginServer) testEmbeddedByValue() {}
@@ -266,6 +290,24 @@ func _TransportPlugin_Forward_Handler(srv interface{}, stream grpc.ServerStream)
// This type alias is provided for backwards compatibility with existing code that references the prior non-generic stream type by name.
type TransportPlugin_ForwardServer = grpc.BidiStreamingServer[ForwardRequest, ForwardResponse]
func _TransportPlugin_InitHostServices_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(InitHostServicesRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(TransportPluginServer).InitHostServices(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: TransportPlugin_InitHostServices_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(TransportPluginServer).InitHostServices(ctx, req.(*InitHostServicesRequest))
}
return interceptor(ctx, in, info, handler)
}
// TransportPlugin_ServiceDesc is the grpc.ServiceDesc for TransportPlugin service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
@@ -293,6 +335,10 @@ var TransportPlugin_ServiceDesc = grpc.ServiceDesc{
MethodName: "TestConfig",
Handler: _TransportPlugin_TestConfig_Handler,
},
{
MethodName: "InitHostServices",
Handler: _TransportPlugin_InitHostServices_Handler,
},
},
Streams: []grpc.StreamDesc{
{
@@ -304,3 +350,313 @@ var TransportPlugin_ServiceDesc = grpc.ServiceDesc{
},
Metadata: "plugin.proto",
}
const (
HostService_KVGet_FullMethodName = "/sub2api.plugin.v1.HostService/KVGet"
HostService_KVSet_FullMethodName = "/sub2api.plugin.v1.HostService/KVSet"
HostService_KVDelete_FullMethodName = "/sub2api.plugin.v1.HostService/KVDelete"
HostService_KVList_FullMethodName = "/sub2api.plugin.v1.HostService/KVList"
HostService_ListAccounts_FullMethodName = "/sub2api.plugin.v1.HostService/ListAccounts"
HostService_ResolveOutboundIdentity_FullMethodName = "/sub2api.plugin.v1.HostService/ResolveOutboundIdentity"
)
// HostServiceClient is the client API for HostService service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
//
// HostService is implemented by the host and dialed by the plugin over the
// go-plugin broker id delivered via InitHostServices. It exposes generic,
// capability-neutral facilities that any plugin may use; new facilities are
// added here as new RPCs without changing the transport contract version.
type HostServiceClient interface {
KVGet(ctx context.Context, in *KVGetRequest, opts ...grpc.CallOption) (*KVGetResponse, error)
KVSet(ctx context.Context, in *KVSetRequest, opts ...grpc.CallOption) (*KVSetResponse, error)
KVDelete(ctx context.Context, in *KVDeleteRequest, opts ...grpc.CallOption) (*KVDeleteResponse, error)
KVList(ctx context.Context, in *KVListRequest, opts ...grpc.CallOption) (*KVListResponse, error)
// Account directory: enumerate the accounts the plugin is bound to act on and
// resolve the outbound identity (credentials/headers/proxy) the host would
// attach to a live request. The host restricts both calls to accounts the
// requesting plugin's capability already covers.
ListAccounts(ctx context.Context, in *ListAccountsRequest, opts ...grpc.CallOption) (*ListAccountsResponse, error)
ResolveOutboundIdentity(ctx context.Context, in *ResolveOutboundIdentityRequest, opts ...grpc.CallOption) (*ResolveOutboundIdentityResponse, error)
}
type hostServiceClient struct {
cc grpc.ClientConnInterface
}
func NewHostServiceClient(cc grpc.ClientConnInterface) HostServiceClient {
return &hostServiceClient{cc}
}
func (c *hostServiceClient) KVGet(ctx context.Context, in *KVGetRequest, opts ...grpc.CallOption) (*KVGetResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(KVGetResponse)
err := c.cc.Invoke(ctx, HostService_KVGet_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostServiceClient) KVSet(ctx context.Context, in *KVSetRequest, opts ...grpc.CallOption) (*KVSetResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(KVSetResponse)
err := c.cc.Invoke(ctx, HostService_KVSet_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostServiceClient) KVDelete(ctx context.Context, in *KVDeleteRequest, opts ...grpc.CallOption) (*KVDeleteResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(KVDeleteResponse)
err := c.cc.Invoke(ctx, HostService_KVDelete_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostServiceClient) KVList(ctx context.Context, in *KVListRequest, opts ...grpc.CallOption) (*KVListResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(KVListResponse)
err := c.cc.Invoke(ctx, HostService_KVList_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostServiceClient) ListAccounts(ctx context.Context, in *ListAccountsRequest, opts ...grpc.CallOption) (*ListAccountsResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ListAccountsResponse)
err := c.cc.Invoke(ctx, HostService_ListAccounts_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
func (c *hostServiceClient) ResolveOutboundIdentity(ctx context.Context, in *ResolveOutboundIdentityRequest, opts ...grpc.CallOption) (*ResolveOutboundIdentityResponse, error) {
cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...)
out := new(ResolveOutboundIdentityResponse)
err := c.cc.Invoke(ctx, HostService_ResolveOutboundIdentity_FullMethodName, in, out, cOpts...)
if err != nil {
return nil, err
}
return out, nil
}
// HostServiceServer is the server API for HostService service.
// All implementations must embed UnimplementedHostServiceServer
// for forward compatibility.
//
// HostService is implemented by the host and dialed by the plugin over the
// go-plugin broker id delivered via InitHostServices. It exposes generic,
// capability-neutral facilities that any plugin may use; new facilities are
// added here as new RPCs without changing the transport contract version.
type HostServiceServer interface {
KVGet(context.Context, *KVGetRequest) (*KVGetResponse, error)
KVSet(context.Context, *KVSetRequest) (*KVSetResponse, error)
KVDelete(context.Context, *KVDeleteRequest) (*KVDeleteResponse, error)
KVList(context.Context, *KVListRequest) (*KVListResponse, error)
// Account directory: enumerate the accounts the plugin is bound to act on and
// resolve the outbound identity (credentials/headers/proxy) the host would
// attach to a live request. The host restricts both calls to accounts the
// requesting plugin's capability already covers.
ListAccounts(context.Context, *ListAccountsRequest) (*ListAccountsResponse, error)
ResolveOutboundIdentity(context.Context, *ResolveOutboundIdentityRequest) (*ResolveOutboundIdentityResponse, error)
mustEmbedUnimplementedHostServiceServer()
}
// UnimplementedHostServiceServer must be embedded to have
// forward compatible implementations.
//
// NOTE: this should be embedded by value instead of pointer to avoid a nil
// pointer dereference when methods are called.
type UnimplementedHostServiceServer struct{}
func (UnimplementedHostServiceServer) KVGet(context.Context, *KVGetRequest) (*KVGetResponse, error) {
return nil, status.Error(codes.Unimplemented, "method KVGet not implemented")
}
func (UnimplementedHostServiceServer) KVSet(context.Context, *KVSetRequest) (*KVSetResponse, error) {
return nil, status.Error(codes.Unimplemented, "method KVSet not implemented")
}
func (UnimplementedHostServiceServer) KVDelete(context.Context, *KVDeleteRequest) (*KVDeleteResponse, error) {
return nil, status.Error(codes.Unimplemented, "method KVDelete not implemented")
}
func (UnimplementedHostServiceServer) KVList(context.Context, *KVListRequest) (*KVListResponse, error) {
return nil, status.Error(codes.Unimplemented, "method KVList not implemented")
}
func (UnimplementedHostServiceServer) ListAccounts(context.Context, *ListAccountsRequest) (*ListAccountsResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ListAccounts not implemented")
}
func (UnimplementedHostServiceServer) ResolveOutboundIdentity(context.Context, *ResolveOutboundIdentityRequest) (*ResolveOutboundIdentityResponse, error) {
return nil, status.Error(codes.Unimplemented, "method ResolveOutboundIdentity not implemented")
}
func (UnimplementedHostServiceServer) mustEmbedUnimplementedHostServiceServer() {}
func (UnimplementedHostServiceServer) testEmbeddedByValue() {}
// UnsafeHostServiceServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to HostServiceServer will
// result in compilation errors.
type UnsafeHostServiceServer interface {
mustEmbedUnimplementedHostServiceServer()
}
func RegisterHostServiceServer(s grpc.ServiceRegistrar, srv HostServiceServer) {
// If the following call panics, it indicates UnimplementedHostServiceServer was
// embedded by pointer and is nil. This will cause panics if an
// unimplemented method is ever invoked, so we test this at initialization
// time to prevent it from happening at runtime later due to I/O.
if t, ok := srv.(interface{ testEmbeddedByValue() }); ok {
t.testEmbeddedByValue()
}
s.RegisterService(&HostService_ServiceDesc, srv)
}
func _HostService_KVGet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(KVGetRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServiceServer).KVGet(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HostService_KVGet_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServiceServer).KVGet(ctx, req.(*KVGetRequest))
}
return interceptor(ctx, in, info, handler)
}
func _HostService_KVSet_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(KVSetRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServiceServer).KVSet(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HostService_KVSet_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServiceServer).KVSet(ctx, req.(*KVSetRequest))
}
return interceptor(ctx, in, info, handler)
}
func _HostService_KVDelete_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(KVDeleteRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServiceServer).KVDelete(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HostService_KVDelete_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServiceServer).KVDelete(ctx, req.(*KVDeleteRequest))
}
return interceptor(ctx, in, info, handler)
}
func _HostService_KVList_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(KVListRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServiceServer).KVList(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HostService_KVList_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServiceServer).KVList(ctx, req.(*KVListRequest))
}
return interceptor(ctx, in, info, handler)
}
func _HostService_ListAccounts_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ListAccountsRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServiceServer).ListAccounts(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HostService_ListAccounts_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServiceServer).ListAccounts(ctx, req.(*ListAccountsRequest))
}
return interceptor(ctx, in, info, handler)
}
func _HostService_ResolveOutboundIdentity_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(ResolveOutboundIdentityRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(HostServiceServer).ResolveOutboundIdentity(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: HostService_ResolveOutboundIdentity_FullMethodName,
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(HostServiceServer).ResolveOutboundIdentity(ctx, req.(*ResolveOutboundIdentityRequest))
}
return interceptor(ctx, in, info, handler)
}
// HostService_ServiceDesc is the grpc.ServiceDesc for HostService service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var HostService_ServiceDesc = grpc.ServiceDesc{
ServiceName: "sub2api.plugin.v1.HostService",
HandlerType: (*HostServiceServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "KVGet",
Handler: _HostService_KVGet_Handler,
},
{
MethodName: "KVSet",
Handler: _HostService_KVSet_Handler,
},
{
MethodName: "KVDelete",
Handler: _HostService_KVDelete_Handler,
},
{
MethodName: "KVList",
Handler: _HostService_KVList_Handler,
},
{
MethodName: "ListAccounts",
Handler: _HostService_ListAccounts_Handler,
},
{
MethodName: "ResolveOutboundIdentity",
Handler: _HostService_ResolveOutboundIdentity_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "plugin.proto",
}

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