test(platform-seam): Phase-3 前置特征化 T1-T5——平台分发行为锁定

为 gatewayplatform 接缝(:444/:794 分发改 registry)建立等价性 gate:
- T1 :794 路由矩阵(antigravity×{非APIKey,APIKey},锁定 Type!=APIKey 真实条件)
- T2 :444 gemini→ForwardGemini 参数逐一断言 + action 契约(service 层)
- T3 粘性 session 选项端到端透传(DeleteSessionAccountID 参数 + force_cache_billing)
- T4 BetaBlockedError/PromptTooLong 错误链(adapter 包裹即红)
- T5 antigravity forward 基准 ×2 纳入基线(旧基准零漂移后重采)

含 Phase-3 设计双视角评审归档(修正摸底三处事实错误、接口砍至 2 方法、OpenAI 剔出本期)。
This commit is contained in:
shaw
2026-06-12 00:07:02 +08:00
parent 0df861f450
commit 57a2e7184b
8 changed files with 1049 additions and 44 deletions
@@ -0,0 +1,741 @@
//go:build unit
// Phase-3 TASK-001 前置特征化测试:/v1/messages 两处平台 Forward 分发点
// (gateway_handler.go :444 与 :794)的路由与参数透传锁定,作为 TASK-002
// Provider 接缝改造(registry 替换分发点)的等价性 gate。
//
// 固化内容(对应 SEAM-DESIGN.md 裁决记录的 T1-T4):
// - T1 :794 路由矩阵:antigravity 平台分组下,
// {OAuth/Upstream(Type != APIKey)账号 → AntigravityGatewayService.Forward,
// APIKey 账号 → GatewayService.Forward};通过两个独立的上游记录桩区分命中路径;
// - T2 :444 路由:gemini 平台分组 + antigravity 账号 → ForwardGemini,
// 参数透传可观测面:model(v1internal 包裹体 model 字段=映射后模型)、
// 上游 action 恒为 streamGenerateContent、stream(客户端响应形态
// SSE/收集 JSON)、body 原文进入包裹体;
// - T3 session 选项透传:WithForwardGeminiSession(groupID, "gemini:"+sessionHash)
// 在模型限流切换路径上经 clearStickySession 落到
// GatewayCache.DeleteSessionAccountID(groupID, sessionKey),参数逐一断言;
// 粘性绑定账号被优先选中(sessionBoundAccountID → 调度命中);
// - T4 错误链:GatewayService.Forward 返回 *BetaBlockedError → handler errors.As
// 命中 → 400 + invalid_request_error + 策略消息;AntigravityGatewayService.Forward
// 返回 *PromptTooLongError(无兜底分组)→ errors.As 命中 → WriteMappedClaudeError
// 输出。改造后 adapter 若包裹/吞掉这两类错误,本组测试必红。
//
// mock 方式取舍说明:GatewayHandler 的 service 字段均为具体类型
// (*service.GatewayService / *service.AntigravityGatewayService),无法以接口桩
// 替换记录调用参数;故采用真实 service + 注入 HTTPUpstream/GatewayCache 记录桩,
// 通过上游收到的请求特征(URL/包裹体)与缓存调用参数区分路径并断言透传。
// isStickySession 实参本身在 handler 层无外部可观测面(其效果 ForceCacheBilling
// 已由 service 层既有测试 TestAntigravityGatewayService_Forward_StickySessionForceCacheBilling /
// _ForwardGemini_StickySessionForceCacheBilling 锁定);handler 层锁定其数据来源
// hasBoundSession 的判定链:粘性绑定命中 → 绑定账号被优先调度(见 T3 用例)。
//
// 复用同包既有夹具:fakeSchedulerCache / fakeGroupRepo
// (gateway_handler_warmup_intercept_unit_test.go)、schedInvNewCountingCache
// (scheduling_invariants_failover_test.go)、passCharSettingRepo
// (gateway_intercept_characterization_test.go)。
// 本文件新增的包级辅助类型/函数一律带 p3Char 前缀。
package handler
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
"github.com/Wei-Shaw/sub2api/internal/pkg/tlsfingerprint"
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"
"github.com/tidwall/gjson"
)
// ---------------------------------------------------------------------------
// p3Char 夹具
// ---------------------------------------------------------------------------
// p3CharUpstreamCall 记录一次上游调用的可观测特征。
type p3CharUpstreamCall struct {
AccountID int64
Method string
URL string
Header http.Header
Body []byte
}
// p3CharUpstream 是可编程的 HTTPUpstream 记录桩:按 accountID 返回脚本化响应,
// 并记录每次调用的完整请求特征(URL/Header/Body)。
type p3CharUpstream struct {
mu sync.Mutex
respond func(accountID int64) (*http.Response, error)
calls []p3CharUpstreamCall
}
var _ service.HTTPUpstream = (*p3CharUpstream)(nil)
func (u *p3CharUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
call := p3CharUpstreamCall{AccountID: accountID}
if req != nil {
call.Method = req.Method
call.URL = req.URL.String()
call.Header = req.Header.Clone()
if req.Body != nil {
body, _ := io.ReadAll(req.Body)
_ = req.Body.Close()
call.Body = body
}
}
u.mu.Lock()
u.calls = append(u.calls, call)
respond := u.respond
u.mu.Unlock()
if respond == nil {
return nil, fmt.Errorf("p3CharUpstream: unexpected upstream call (account=%d url=%s)", accountID, call.URL)
}
return respond(accountID)
}
func (u *p3CharUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
return u.Do(req, proxyURL, accountID, accountConcurrency)
}
func (u *p3CharUpstream) recordedCalls() []p3CharUpstreamCall {
u.mu.Lock()
defer u.mu.Unlock()
return append([]p3CharUpstreamCall(nil), u.calls...)
}
// p3CharSessionCall 记录一次粘性会话缓存调用的参数。
type p3CharSessionCall struct {
GroupID int64
SessionHash string
AccountID int64
}
// p3CharStickyCache 是 service.GatewayCache 的内存实现,记录所有调用参数。
// 同一实例同时注入 GatewayService(粘性查询/绑定)与 AntigravityGatewayService
// (模型限流时清除粘性绑定),与生产共用同一 Redis 缓存的接线一致。
type p3CharStickyCache struct {
mu sync.Mutex
bound map[string]int64
getCalls []p3CharSessionCall
setCalls []p3CharSessionCall
deleteCalls []p3CharSessionCall
}
var _ service.GatewayCache = (*p3CharStickyCache)(nil)
func p3CharNewStickyCache() *p3CharStickyCache {
return &p3CharStickyCache{bound: make(map[string]int64)}
}
func p3CharSessionKey(groupID int64, sessionHash string) string {
return fmt.Sprintf("%d|%s", groupID, sessionHash)
}
func (c *p3CharStickyCache) bind(groupID int64, sessionHash string, accountID int64) {
c.mu.Lock()
defer c.mu.Unlock()
c.bound[p3CharSessionKey(groupID, sessionHash)] = accountID
}
func (c *p3CharStickyCache) GetSessionAccountID(_ context.Context, groupID int64, sessionHash string) (int64, error) {
c.mu.Lock()
defer c.mu.Unlock()
c.getCalls = append(c.getCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash})
return c.bound[p3CharSessionKey(groupID, sessionHash)], nil
}
func (c *p3CharStickyCache) SetSessionAccountID(_ context.Context, groupID int64, sessionHash string, accountID int64, _ time.Duration) error {
c.mu.Lock()
defer c.mu.Unlock()
c.setCalls = append(c.setCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash, AccountID: accountID})
c.bound[p3CharSessionKey(groupID, sessionHash)] = accountID
return nil
}
func (c *p3CharStickyCache) RefreshSessionTTL(_ context.Context, _ int64, _ string, _ time.Duration) error {
return nil
}
func (c *p3CharStickyCache) DeleteSessionAccountID(_ context.Context, groupID int64, sessionHash string) error {
c.mu.Lock()
defer c.mu.Unlock()
c.deleteCalls = append(c.deleteCalls, p3CharSessionCall{GroupID: groupID, SessionHash: sessionHash})
delete(c.bound, p3CharSessionKey(groupID, sessionHash))
return nil
}
func (c *p3CharStickyCache) recordedDeleteCalls() []p3CharSessionCall {
c.mu.Lock()
defer c.mu.Unlock()
return append([]p3CharSessionCall(nil), c.deleteCalls...)
}
func (c *p3CharStickyCache) recordedGetCalls() []p3CharSessionCall {
c.mu.Lock()
defer c.mu.Unlock()
return append([]p3CharSessionCall(nil), c.getCalls...)
}
// p3CharGroup 构造指定平台的测试分组。
func p3CharGroup(groupID int64, platform string) *service.Group {
return &service.Group{
ID: groupID,
Hydrated: true,
Platform: platform,
Status: service.StatusActive,
}
}
// p3CharAntigravityAccount 构造 antigravity 账号夹具。
func p3CharAntigravityAccount(id, groupID int64, accountType string, creds, extra map[string]any) *service.Account {
credentials := map[string]any{"access_token": fmt.Sprintf("ag-token-%d", id)}
for k, v := range creds {
credentials[k] = v
}
return &service.Account{
ID: id,
Name: fmt.Sprintf("p3-char-ag-%d", id),
Platform: service.PlatformAntigravity,
Type: accountType,
Credentials: credentials,
Extra: extra,
Concurrency: 5,
Priority: 1,
Status: service.StatusActive,
Schedulable: true,
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}},
}
}
// p3CharHandlerEnv 聚合一套完整 GatewayHandler 测试环境。
type p3CharHandlerEnv struct {
handler *GatewayHandler
gwUpstream *p3CharUpstream // GatewayService.Forward 路径的上游
agUpstream *p3CharUpstream // AntigravityGatewayService.Forward/ForwardGemini 路径的上游
stickyCache *p3CharStickyCache
cleanup func()
}
// p3CharNewHandler 构造完整 GatewayHandler:真实 GatewayService 与
// AntigravityGatewayService,各自注入独立上游记录桩;共享粘性会话缓存桩。
// settingValues 写入两个 service 共享的 SettingService 内存仓库(beta policy 等)。
func p3CharNewHandler(t *testing.T, group *service.Group, accounts []*service.Account, settingValues map[string]string) *p3CharHandlerEnv {
t.Helper()
t.Setenv("SUB2API_DEBUG_GATEWAY_BODY", "")
gwUpstream := &p3CharUpstream{}
agUpstream := &p3CharUpstream{}
stickyCache := p3CharNewStickyCache()
schedulerSnapshot := service.NewSchedulerSnapshotService(&fakeSchedulerCache{accounts: accounts}, nil, nil, nil, nil)
concurrencySvc := service.NewConcurrencyService(schedInvNewCountingCache())
settingSvc := service.NewSettingService(&passCharSettingRepo{values: settingValues}, &config.Config{})
gwSvc := service.NewGatewayService(
nil, // accountRepo
&fakeGroupRepo{group: group},
nil, nil, nil, nil, nil, // usageLogRepo / usageBillingRepo / userRepo / userSubRepo / userGroupRateRepo
stickyCache,
&config.Config{},
schedulerSnapshot,
concurrencySvc,
nil, // billingService
&service.RateLimitService{}, // 零值:错误码仅记录日志
nil, // billingCacheService
nil, // identityService
gwUpstream,
&service.DeferredService{},
nil, nil, nil, nil, // claudeTokenProvider / sessionLimitCache / rpmCache / digestStore
settingSvc,
nil, nil, nil, nil, nil, // tlsFPProfileService / channelService / resolver / balanceNotifyService / userPlatformQuotaRepo
)
agSvc := service.NewAntigravityGatewayService(
nil, // accountRepo(模型限流写库不在锁定面,nil → 仅走 handleError 兜底分支)
stickyCache,
schedulerSnapshot,
&service.AntigravityTokenProvider{},
nil, // rateLimitService
agUpstream,
settingSvc,
nil, // internal500Cache
)
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil,
&config.Config{RunMode: config.RunModeSimple}, nil)
h := NewGatewayHandler(
gwSvc,
nil, // geminiCompatService(:444 非 antigravity 分支误入会 panic → 用例显式失败)
agSvc,
nil, // userService
concurrencySvc,
billingCacheSvc,
nil, nil, nil, nil, nil, nil, // usageService / apiKeyService / workerPool / errorPassthrough / preFlightHooks / userMsgQueue
nil, // cfg
nil, // settingService(handler 级版本检查等不在锁定面)
)
return &p3CharHandlerEnv{
handler: h,
gwUpstream: gwUpstream,
agUpstream: agUpstream,
stickyCache: stickyCache,
cleanup: func() { billingCacheSvc.Stop() },
}
}
// p3CharMessagesContext 构造带认证上下文的 /v1/messages 请求(可附加 header)。
func p3CharMessagesContext(t *testing.T, group *service.Group, body []byte, headers map[string]string) (*gin.Context, *httptest.ResponseRecorder, context.CancelFunc) {
t.Helper()
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
req := httptest.NewRequest(http.MethodPost, "/v1/messages", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
for k, v := range headers {
req.Header.Set(k, v)
}
ctx := context.WithValue(req.Context(), ctxkey.Group, group)
ctx, cancel := context.WithCancel(ctx)
c.Request = req.WithContext(ctx)
apiKey := &service.APIKey{
ID: 8501,
UserID: 8601,
GroupID: &group.ID,
Status: service.StatusActive,
User: &service.User{
ID: 8601,
Concurrency: 10,
Balance: 100,
},
Group: group,
}
c.Set(string(middleware.ContextKeyAPIKey), apiKey)
c.Set(string(middleware.ContextKeyUser), middleware.AuthSubject{UserID: apiKey.UserID, Concurrency: 10})
return c, rec, cancel
}
// p3CharSessionID 是 metadata.user_id 中的 session 段(36 字符 UUID),
// 用于产生确定性的 sessionHash(GenerateSessionHash 最高优先级来源)。
const p3CharSessionID = "12345678-1234-1234-1234-123456789abc"
// p3CharMetadataUserID 构造 legacy 格式 metadata.user_id。
func p3CharMetadataUserID() string {
return "user_" + strings.Repeat("a", 64) + "_account__session_" + p3CharSessionID
}
// p3CharClaudeBody 构造 Claude 协议请求体(:794 路径)。
func p3CharClaudeBody(stream bool) []byte {
return []byte(fmt.Sprintf(`{
"model": "claude-sonnet-4-5",
"max_tokens": 64,
"stream": %t,
"metadata": {"user_id": %q},
"messages": [{"role":"user","content":"p3 dispatch probe"}]
}`, stream, p3CharMetadataUserID()))
}
// p3CharGeminiLoopBody 构造同时满足 /v1/messages 解析(model/stream/metadata)
// 与 ForwardGemini 原文透传(contents)的混合请求体(:444 路径将原始 body
// 原样传给 ForwardGemini)。
func p3CharGeminiLoopBody(stream bool) []byte {
return []byte(fmt.Sprintf(`{
"model": "gemini-2.5-flash",
"stream": %t,
"metadata": {"user_id": %q},
"contents": [{"role":"user","parts":[{"text":"p3 gemini dispatch probe"}]}]
}`, stream, p3CharMetadataUserID()))
}
// p3CharAntigravitySSEResponse 构造 v1internal 包裹的上游流式成功响应。
func p3CharAntigravitySSEResponse() *http.Response {
sse := "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":8,\"candidatesTokenCount\":3}}}\n\n"
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"text/event-stream"},
"X-Request-Id": []string{"rid-p3-ag"},
},
Body: io.NopCloser(strings.NewReader(sse)),
}
}
// p3CharRateLimit429Response 构造触发"模型限流 + 切换账号"的 429 响应
// (RATE_LIMIT_EXCEEDED + retryDelay 60s ≥ 7s 阈值,无智能重试等待)。
func p3CharRateLimit429Response() *http.Response {
body := `{
"error": {
"code": 429,
"message": "rate limited",
"status": "RESOURCE_EXHAUSTED",
"details": [
{"@type": "type.googleapis.com/google.rpc.ErrorInfo", "reason": "RATE_LIMIT_EXCEEDED", "metadata": {"model": "gemini-3-pro-high"}},
{"@type": "type.googleapis.com/google.rpc.RetryInfo", "retryDelay": "60s"}
]
}
}`
return &http.Response{
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(body)),
}
}
// ---------------------------------------------------------------------------
// T1 :794 路由矩阵
// ---------------------------------------------------------------------------
// TestPlatformDispatchCharacterization_AntigravityNonAPIKeyRoutesToAntigravityForward
// 固化 :794 分支条件 `Platform == Antigravity && Type != APIKey` 的"命中"侧:
// OAuth 与 Upstream 两种非 APIKey 账号均走 AntigravityGatewayService.Forward
// (上游收到 antigravity v1internal 请求 / 上游透传请求),GatewayService 上游零调用。
func TestPlatformDispatchCharacterization_AntigravityNonAPIKeyRoutesToAntigravityForward(t *testing.T) {
t.Run("OAuth账号走antigravity转发", func(t *testing.T) {
group := p3CharGroup(9501, service.PlatformAntigravity)
account := p3CharAntigravityAccount(9511, group.ID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"claude-sonnet-4-5": "gemini-3-pro-high"},
}, nil)
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
defer env.cleanup()
env.agUpstream.respond = func(int64) (*http.Response, error) {
return p3CharAntigravitySSEResponse(), nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
defer cancel()
env.handler.Messages(c)
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次")
require.Empty(t, env.gwUpstream.recordedCalls(), "GatewayService 上游不应被调用")
// Claude→Gemini 转换 + v1internal 包裹:URL 恒为 streamGenerateContent,
// 包裹体 model = 账号映射后的最终模型
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
require.Equal(t, "gemini-3-pro-high", gjson.GetBytes(agCalls[0].Body, "model").String())
require.Equal(t, http.StatusOK, rec.Code)
})
t.Run("Upstream账号走antigravity转发", func(t *testing.T) {
group := p3CharGroup(9502, service.PlatformAntigravity)
account := p3CharAntigravityAccount(9512, group.ID, service.AccountTypeUpstream, map[string]any{
"api_key": "ag-upstream-key",
"base_url": "https://ag-upstream.example.com",
"model_mapping": map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"},
}, nil)
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
defer env.cleanup()
env.agUpstream.respond = func(int64) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"msg_p3_up","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":5,"output_tokens":2}}`)),
}, nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
defer cancel()
env.handler.Messages(c)
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次(ForwardUpstream 透传)")
require.Empty(t, env.gwUpstream.recordedCalls(), "GatewayService 上游不应被调用")
require.Equal(t, "https://ag-upstream.example.com/v1/messages", agCalls[0].URL)
require.Equal(t, http.StatusOK, rec.Code)
})
}
// TestPlatformDispatchCharacterization_AntigravityAPIKeyRoutesToGatewayForward
// 固化 :794 分支条件的"未命中"侧:antigravity 平台的 APIKey 账号走
// GatewayService.Forward(Claude 协议直连上游),antigravity 上游零调用。
func TestPlatformDispatchCharacterization_AntigravityAPIKeyRoutesToGatewayForward(t *testing.T) {
group := p3CharGroup(9503, service.PlatformAntigravity)
account := p3CharAntigravityAccount(9513, group.ID, service.AccountTypeAPIKey, map[string]any{
"api_key": "ag-apikey-cred",
"model_mapping": map[string]any{"claude-sonnet-4-5": "claude-sonnet-4-5"},
}, nil)
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
defer env.cleanup()
env.gwUpstream.respond = func(int64) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"msg_p3_gw","type":"message","role":"assistant","content":[{"type":"text","text":"ok"}],"usage":{"input_tokens":5,"output_tokens":2}}`)),
}, nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
defer cancel()
env.handler.Messages(c)
gwCalls := env.gwUpstream.recordedCalls()
require.Len(t, gwCalls, 1, "GatewayService 上游应被调用恰好一次")
require.Empty(t, env.agUpstream.recordedCalls(), "antigravity 上游不应被调用")
// APIKey 账号未配置 base_url 时直连 Anthropic 默认地址,认证走 x-api-key
// (setHeaderRaw 以小写原样写入 header map,须用原始 key 读取)
require.Equal(t, "https://api.anthropic.com/v1/messages?beta=true", gwCalls[0].URL)
require.Equal(t, []string{"ag-apikey-cred"}, gwCalls[0].Header["x-api-key"])
require.Equal(t, http.StatusOK, rec.Code)
}
// ---------------------------------------------------------------------------
// T2 :444 路由 + 参数透传
// ---------------------------------------------------------------------------
// TestPlatformDispatchCharacterization_GeminiLoopAntigravityRoutesToForwardGemini
// 固化 :444 分支:gemini 平台分组下 antigravity 账号 → ForwardGemini。
// 参数可观测面逐一断言:
// - model:v1internal 包裹体 model = 账号映射后的模型(请求体 model 经映射进入上游);
// - 上游 action 恒为 streamGenerateContent(handler 传入的 action="generateContent"
// 仅决定客户端响应形态合法性,上游不变量见 service 层 action 契约测试);
// - stream=false → 网关收集上游流式响应,客户端收到单个 JSON(application/json);
// - stream=true → SSE 解包透传(text/event-stream);
// - body 原文(contents 文本)进入包裹体 request 字段;
// - sessionKey 格式 "gemini:"+sessionHash 进入粘性查询。
func TestPlatformDispatchCharacterization_GeminiLoopAntigravityRoutesToForwardGemini(t *testing.T) {
newEnv := func(t *testing.T, groupID int64) (*p3CharHandlerEnv, *service.Group) {
group := p3CharGroup(groupID, service.PlatformGemini)
account := p3CharAntigravityAccount(groupID+10, groupID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
}, map[string]any{"mixed_scheduling": true})
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
env.agUpstream.respond = func(int64) (*http.Response, error) {
return p3CharAntigravitySSEResponse(), nil
}
return env, group
}
t.Run("非流式收集为单个JSON", func(t *testing.T) {
env, group := newEnv(t, 9521)
defer env.cleanup()
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(false), nil)
defer cancel()
env.handler.Messages(c)
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 1, "antigravity 上游应被调用恰好一次")
require.Empty(t, env.gwUpstream.recordedCalls())
// model 参数:包裹体 model = 映射后模型
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
require.Equal(t, "gemini-3-pro-high", gjson.GetBytes(agCalls[0].Body, "model").String())
// body 透传:原始 contents 文本进入包裹体 request 字段
require.Contains(t, gjson.GetBytes(agCalls[0].Body, "request").Raw, "p3 gemini dispatch probe")
// stream=false:收集上游流式响应为单个 JSON
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
require.Equal(t, "ok", gjson.Get(rec.Body.String(), "candidates.0.content.parts.0.text").String())
// sessionKey 格式:gemini 平台为 "gemini:"+sessionHash
getCalls := env.stickyCache.recordedGetCalls()
require.NotEmpty(t, getCalls, "粘性会话查询应发生")
for _, call := range getCalls {
require.Equal(t, group.ID, call.GroupID)
require.Equal(t, "gemini:"+p3CharSessionID, call.SessionHash)
}
})
t.Run("流式SSE解包透传", func(t *testing.T) {
env, group := newEnv(t, 9522)
defer env.cleanup()
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(true), nil)
defer cancel()
env.handler.Messages(c)
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 1)
require.Contains(t, agCalls[0].URL, "/v1internal:streamGenerateContent?alt=sse")
// stream=true:SSE 解包透传
require.Equal(t, http.StatusOK, rec.Code)
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
require.Contains(t, rec.Body.String(), `data: {"candidates"`)
})
}
// ---------------------------------------------------------------------------
// T3 session 选项透传 + 粘性绑定语义
// ---------------------------------------------------------------------------
// TestPlatformDispatchCharacterization_GeminiLoopSessionOptionsReachStickyClear
// 固化 WithForwardGeminiSession(groupID, sessionKey) 的端到端透传:
// 1. 粘性绑定账号被优先调度(首次上游调用命中绑定账号);
// 2. 绑定账号 429(RATE_LIMIT_EXCEEDED + 长 retryDelay)触发模型限流切换时,
// ForwardGemini 内部以 handler 传入的 (groupID, sessionKey) 调用
// GatewayCache.DeleteSessionAccountID 清除粘性绑定——参数逐一断言;
// 3. failover 切换第二账号成功,客户端最终 200。
//
// 此场景同时锁定 isStickySession=hasBoundSession 的端到端透传:绑定命中 →
// ForwardGemini(isSticky=true) → 切换信号携带 IsStickySession → handler
// fs.ForceCacheBilling → 第二账号成功后 RecordUsage 应用 force_cache_billing
// (经结构化日志 sink 断言;isSticky 在 service 内的语义另由 service 层既有
// 测试 TestAntigravityGatewayService_ForwardGemini_StickySessionForceCacheBilling 锁定)。
func TestPlatformDispatchCharacterization_GeminiLoopSessionOptionsReachStickyClear(t *testing.T) {
sink, restoreSink := captureHandlerStructuredLog(t)
defer restoreSink()
group := p3CharGroup(9531, service.PlatformGemini)
boundAccount := p3CharAntigravityAccount(9541, group.ID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
}, map[string]any{"mixed_scheduling": true})
healthyAccount := p3CharAntigravityAccount(9542, group.ID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"},
}, map[string]any{"mixed_scheduling": true})
env := p3CharNewHandler(t, group, []*service.Account{boundAccount, healthyAccount}, nil)
defer env.cleanup()
sessionKey := "gemini:" + p3CharSessionID
env.stickyCache.bind(group.ID, sessionKey, boundAccount.ID)
env.agUpstream.respond = func(accountID int64) (*http.Response, error) {
if accountID == boundAccount.ID {
return p3CharRateLimit429Response(), nil
}
return p3CharAntigravitySSEResponse(), nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharGeminiLoopBody(false), nil)
defer cancel()
env.handler.Messages(c)
// 1. 粘性绑定账号被优先调度,429 后切换到第二账号
agCalls := env.agUpstream.recordedCalls()
require.Len(t, agCalls, 2, "绑定账号 1 次 + failover 后第二账号 1 次")
require.Equal(t, boundAccount.ID, agCalls[0].AccountID, "首次尝试必须命中粘性绑定账号")
require.Equal(t, healthyAccount.ID, agCalls[1].AccountID, "failover 切换到第二账号")
// 2. WithForwardGeminiSession 的 (groupID, sessionKey) 透传到粘性清除
deleteCalls := env.stickyCache.recordedDeleteCalls()
require.NotEmpty(t, deleteCalls, "模型限流切换必须清除粘性绑定")
for _, call := range deleteCalls {
require.Equal(t, group.ID, call.GroupID, "清除粘性绑定的 groupID 必须等于 handler 传入的分组 ID")
require.Equal(t, sessionKey, call.SessionHash, "清除粘性绑定的 sessionHash 必须等于 handler 传入的 sessionKey")
}
// 3. failover 成功,客户端 200
require.Equal(t, http.StatusOK, rec.Code)
require.Empty(t, env.gwUpstream.recordedCalls())
// 4. isStickySession 透传链:hasBoundSession=true → ForwardGemini 切换信号
// IsStickySession=true → fs.ForceCacheBilling → RecordUsage 应用
// force_cache_billing(input_tokens 转 cache_read)。
require.True(t,
sink.ContainsMessageAtLevel("force_cache_billing", "info"),
"粘性会话切换后第二账号成功,RecordUsage 必须应用 force_cache_billing")
}
// ---------------------------------------------------------------------------
// T4 错误链现状锁定
// ---------------------------------------------------------------------------
// TestErrorChainCharacterization_BetaBlockedErrorReturns400 固化:
// GatewayService.Forward 返回 *service.BetaBlockedError 时,handler 的
// errors.As 命中 → 400 + invalid_request_error + 策略消息原文;不触发 failover、
// 不触上游。改造后 adapter 若包裹该错误且破坏 errors.As 链,本用例必红。
func TestErrorChainCharacterization_BetaBlockedErrorReturns400(t *testing.T) {
group := p3CharGroup(9551, service.PlatformAnthropic)
// anthropic APIKey 账号(未启用透传)→ GatewayService.Forward 主路径会评估 beta policy
account := &service.Account{
ID: 9561,
Name: "p3-char-anthropic-apikey",
Platform: service.PlatformAnthropic,
Type: service.AccountTypeAPIKey,
Credentials: map[string]any{"api_key": "sk-p3-beta"},
Concurrency: 5,
Priority: 1,
Status: service.StatusActive,
Schedulable: true,
AccountGroups: []service.AccountGroup{{AccountID: 9561, GroupID: group.ID}},
}
betaPolicy := `{"rules":[{"beta_token":"p3-blocked-beta","action":"block","scope":"all","error_message":"beta blocked by p3 policy"}]}`
env := p3CharNewHandler(t, group, []*service.Account{account}, map[string]string{
"beta_policy_settings": betaPolicy,
})
defer env.cleanup()
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), map[string]string{
"anthropic-beta": "p3-blocked-beta",
})
defer cancel()
env.handler.Messages(c)
require.Equal(t, http.StatusBadRequest, rec.Code)
require.JSONEq(t,
`{"type":"error","error":{"type":"invalid_request_error","message":"beta blocked by p3 policy"}}`,
rec.Body.String())
require.Empty(t, env.gwUpstream.recordedCalls(), "beta policy 拦截发生在触上游之前")
require.Empty(t, env.agUpstream.recordedCalls())
}
// TestErrorChainCharacterization_PromptTooLongWritesMappedClaudeError 固化:
// AntigravityGatewayService.Forward 返回 *service.PromptTooLongError 且无兜底
// 分组时,handler 的 errors.As 命中 → WriteMappedClaudeError 写回上游状态码 +
// Claude 风格错误体;不触发 failover(单次上游调用)。
func TestErrorChainCharacterization_PromptTooLongWritesMappedClaudeError(t *testing.T) {
group := p3CharGroup(9552, service.PlatformAntigravity)
account := p3CharAntigravityAccount(9562, group.ID, service.AccountTypeOAuth, map[string]any{
"model_mapping": map[string]any{"claude-sonnet-4-5": "gemini-3-pro-high"},
}, nil)
env := p3CharNewHandler(t, group, []*service.Account{account}, nil)
defer env.cleanup()
env.agUpstream.respond = func(int64) (*http.Response, error) {
return &http.Response{
StatusCode: http.StatusBadRequest,
Header: http.Header{
"Content-Type": []string{"application/json"},
"X-Request-Id": []string{"rid-p3-ptl"},
},
Body: io.NopCloser(strings.NewReader(`{"error":{"message":"Prompt is too long"}}`)),
}, nil
}
c, rec, cancel := p3CharMessagesContext(t, group, p3CharClaudeBody(false), nil)
defer cancel()
env.handler.Messages(c)
require.Len(t, env.agUpstream.recordedCalls(), 1, "prompt too long 不触发 failover")
require.Equal(t, http.StatusBadRequest, rec.Code, "WriteMappedClaudeError 透传上游状态码")
var errBody map[string]any
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &errBody))
require.Equal(t, "error", errBody["type"])
errObj, ok := errBody["error"].(map[string]any)
require.True(t, ok, "错误体必须是 Claude 风格 {type, error:{...}}")
require.Contains(t, fmt.Sprintf("%v", errObj["message"]), "Prompt is too long")
}
@@ -0,0 +1,107 @@
//go:build unit
// Phase-3 TASK-001 前置特征化测试(T2 补充):ForwardGemini 的 action 参数契约。
//
// handler :444 调用点(以及未来 gemini adapter)以硬编码 "generateContent"
// 传入 action;本文件锁定该参数的全部行为面,使 adapter 改写 action 字面量
// 时测试必红:
// - "generateContent" / "streamGenerateContent" 被接受,且上游 action 恒为
// streamGenerateContent(客户端流式与否由独立的 stream 参数决定);
// - "countTokens" 短路返回 {"totalTokens":0},不触上游;
// - 其他 action → 404 "Unsupported action",不触上游。
//
// 夹具复用本包既有 p3 系列/passChar 系列(antigravitySettingRepoStub、
// queuedHTTPUpstreamStub、passCharAntigravityService/Account)。
package service
import (
"bytes"
"context"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
// p3CharGeminiNativeBody 返回 Gemini 原生请求体。
func p3CharGeminiNativeBody(t *testing.T) []byte {
t.Helper()
return []byte(`{"contents":[{"role":"user","parts":[{"text":"action contract probe"}]}]}`)
}
// p3CharGeminiSSESuccess 返回 v1internal 包裹的上游流式成功响应。
func p3CharGeminiSSESuccess() *http.Response {
sse := "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"ok\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":4,\"candidatesTokenCount\":2}}}\n\n"
return &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"text/event-stream"}},
Body: io.NopCloser(bytes.NewReader([]byte(sse))),
}
}
// TestGatewayCharacterization_ForwardGeminiActionContract 锁定 action 参数契约。
func TestGatewayCharacterization_ForwardGeminiActionContract(t *testing.T) {
gin.SetMode(gin.TestMode)
newCtx := func(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(p3CharGeminiNativeBody(t)))
return c, rec
}
t.Run("generateContent被接受_上游action恒为streamGenerateContent", func(t *testing.T) {
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{p3CharGeminiSSESuccess()}}
svc := passCharAntigravityService(upstream)
c, _ := newCtx(t)
account := passCharAntigravityAccount(9701, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "generateContent", false, p3CharGeminiNativeBody(t), false)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, upstream.callCount)
})
t.Run("streamGenerateContent同样被接受_与generateContent上游行为一致", func(t *testing.T) {
upstream := &queuedHTTPUpstreamStub{responses: []*http.Response{p3CharGeminiSSESuccess()}}
svc := passCharAntigravityService(upstream)
c, _ := newCtx(t)
account := passCharAntigravityAccount(9702, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", false, p3CharGeminiNativeBody(t), false)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 1, upstream.callCount)
})
t.Run("countTokens短路返回零值不触上游", func(t *testing.T) {
upstream := &queuedHTTPUpstreamStub{} // 任何上游调用都会失败
svc := passCharAntigravityService(upstream)
c, rec := newCtx(t)
account := passCharAntigravityAccount(9703, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "countTokens", false, p3CharGeminiNativeBody(t), false)
require.NoError(t, err)
require.NotNil(t, result)
require.Equal(t, 0, upstream.callCount, "countTokens 不触上游")
require.Equal(t, http.StatusOK, rec.Code)
require.JSONEq(t, `{"totalTokens":0}`, rec.Body.String())
})
t.Run("未知action返回404不触上游", func(t *testing.T) {
upstream := &queuedHTTPUpstreamStub{}
svc := passCharAntigravityService(upstream)
c, rec := newCtx(t)
account := passCharAntigravityAccount(9704, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "bogusAction", false, p3CharGeminiNativeBody(t), false)
require.Error(t, err)
require.Nil(t, result)
require.Equal(t, 0, upstream.callCount)
require.Equal(t, http.StatusNotFound, rec.Code)
require.Contains(t, rec.Body.String(), "Unsupported action")
})
}
@@ -0,0 +1,68 @@
//go:build unit
// Phase-3 TASK-001 T5:antigravity Forward(Claude→Gemini 转换路径)热路径基准,
// 纳入 scripts/bench-baseline.sh 的回归 gate(基准名匹配 BenchmarkGatewayForward 模式)。
// 覆盖 :794 分发点背后的真实转发成本:请求解析 + Claude→Gemini 转换 +
// v1internal 包裹 + 上游 SSE 解包 +(流式透传转换 / 非流式收集合并)。
// 与 gateway_forward_benchmark_test.go 同策略:mock 上游、静音 stdlib log、
// allocs/op 严格、ns/op 容忍 15% 抖动。
package service
import (
"context"
"io"
"log"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func benchAntigravityFwdRun(b *testing.B, reqBody string) {
b.Helper()
gin.SetMode(gin.TestMode)
// antigravity 路径每请求有数条 LegacyPrintf(基准模式下全局 logger 未初始化,
// 回退 stdlib log),静音以降低基准 I/O 噪声(baseline 与 compare 两侧同等消除)。
prevLogOut := log.Writer()
log.SetOutput(io.Discard)
b.Cleanup(func() { log.SetOutput(prevLogOut) })
upstreamSSE := "data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"Hel\"}]}}]}}\n\n" +
"data: {\"response\":{\"candidates\":[{\"content\":{\"role\":\"model\",\"parts\":[{\"text\":\"lo\"}]},\"finishReason\":\"STOP\"}],\"usageMetadata\":{\"promptTokenCount\":12,\"candidatesTokenCount\":5,\"cachedContentTokenCount\":3}}}\n\n"
account := passCharAntigravityAccount(9800, map[string]any{"claude-sonnet-4-5": "gemini-3-pro-high"})
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
upstream := &httpUpstreamStub{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{
"Content-Type": []string{"text/event-stream"},
"X-Request-Id": []string{"rid-bench-ag"},
},
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
}}
svc := passCharAntigravityService(upstream)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", strings.NewReader(reqBody))
if _, err := svc.Forward(context.Background(), c, account, []byte(reqBody), false); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkGatewayForward_AntigravityClaudeNonStream(b *testing.B) {
benchAntigravityFwdRun(b,
`{"model":"claude-sonnet-4-5","max_tokens":64,"messages":[{"role":"user","content":"hi"}]}`,
)
}
func BenchmarkGatewayForward_AntigravityClaudeStream(b *testing.B) {
benchAntigravityFwdRun(b,
`{"model":"claude-sonnet-4-5","max_tokens":64,"stream":true,"messages":[{"role":"user","content":"hi"}]}`,
)
}
+1 -1
View File
@@ -1,4 +1,4 @@
go: go version go1.26.4 linux/amd64
cpu: 13th Gen Intel(R) Core(TM) i5-13600KF
date: 2026-06-11T06:26:18Z
date: 2026-06-11T15:55:37Z
count: 6
+55 -43
View File
@@ -2,47 +2,59 @@ goos: linux
goarch: amd64
pkg: github.com/Wei-Shaw/sub2api/internal/service
cpu: 13th Gen Intel(R) Core(TM) i5-13600KF
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 403580 2613 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 467905 2583 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 462894 2551 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 443625 2538 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 473317 2544 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 465820 2481 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1746464 692.2 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1731408 677.6 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1786676 673.6 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1765081 675.2 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1762540 687.4 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1737454 679.1 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 470481 2281 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 512163 2243 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 540775 2273 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 509505 2292 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 510970 2300 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 509253 2325 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1560142 769.5 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1574744 765.5 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1569315 767.5 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1561520 769.2 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1551661 801.5 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1555836 769.8 ns/op 0 B/op 0 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1430790 850.6 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1431716 846.4 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1427368 842.8 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1414472 842.4 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1410212 838.6 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1424356 840.8 ns/op 320 B/op 2 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 120924 9941 ns/op 14667 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 117312 10316 ns/op 14667 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 113154 10088 ns/op 14666 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 116223 10061 ns/op 14667 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 119020 10349 ns/op 14667 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 112460 10227 ns/op 14667 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 39451 29660 ns/op 19156 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 41238 29418 ns/op 19189 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 40820 29667 ns/op 19218 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 41295 29620 ns/op 19186 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 40642 29393 ns/op 19171 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 40014 29780 ns/op 19176 B/op 134 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 467865 2403 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 463100 2455 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 472974 2421 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 468956 2379 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 508983 2372 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageStart-20 494251 2378 ns/op 2224 B/op 40 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1846669 643.4 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1850743 655.2 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1842531 650.7 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1842110 649.5 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1824736 647.4 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageStart-20 1847221 649.1 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 515887 2186 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 519673 2255 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 555856 2197 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 547356 2194 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 520796 2224 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsage_MessageDelta-20 531657 2177 ns/op 1872 B/op 37 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1596841 755.8 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1580008 751.3 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1594468 756.5 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1579392 750.5 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1595959 751.4 ns/op 0 B/op 0 allocs/op
BenchmarkGatewayService_ParseSSEUsagePassthrough_MessageDelta-20 1604078 753.9 ns/op 0 B/op 0 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1474922 817.2 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1468426 820.2 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1469355 815.5 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1473657 819.9 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1353183 863.3 ns/op 320 B/op 2 allocs/op
BenchmarkParseClaudeUsageFromResponseBody-20 1439142 825.9 ns/op 320 B/op 2 allocs/op
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 28329 39620 ns/op 30780 B/op 266 allocs/op
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 30484 39647 ns/op 30791 B/op 266 allocs/op
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 29310 39630 ns/op 30832 B/op 266 allocs/op
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 31288 38798 ns/op 30769 B/op 266 allocs/op
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 31226 40313 ns/op 30848 B/op 266 allocs/op
BenchmarkGatewayForward_AntigravityClaudeNonStream-20 30166 39549 ns/op 30793 B/op 266 allocs/op
BenchmarkGatewayForward_AntigravityClaudeStream-20 27830 42915 ns/op 39901 B/op 329 allocs/op
BenchmarkGatewayForward_AntigravityClaudeStream-20 28850 43027 ns/op 39900 B/op 329 allocs/op
BenchmarkGatewayForward_AntigravityClaudeStream-20 26864 41222 ns/op 39906 B/op 329 allocs/op
BenchmarkGatewayForward_AntigravityClaudeStream-20 28328 43579 ns/op 39959 B/op 329 allocs/op
BenchmarkGatewayForward_AntigravityClaudeStream-20 28596 43420 ns/op 39852 B/op 329 allocs/op
BenchmarkGatewayForward_AntigravityClaudeStream-20 28867 42027 ns/op 39861 B/op 329 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 132291 9375 ns/op 14666 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 129505 9491 ns/op 14666 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 122263 9278 ns/op 14666 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 132528 9455 ns/op 14666 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 130437 9289 ns/op 14666 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicNonStreamPassthrough-20 131500 9505 ns/op 14666 B/op 93 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 42405 27982 ns/op 19168 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 43042 28211 ns/op 19174 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 41194 28241 ns/op 19221 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 42230 28071 ns/op 19172 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 43098 27915 ns/op 19186 B/op 134 allocs/op
BenchmarkGatewayForward_AnthropicStreamPassthrough-20 42762 27136 ns/op 19161 B/op 134 allocs/op
PASS
ok github.com/Wei-Shaw/sub2api/internal/service 66.644s
ok github.com/Wei-Shaw/sub2api/internal/service 85.616s
@@ -0,0 +1,17 @@
# Phase-3 接缝设计评审 — 架构合理性/演进性视角(2026-06-11)
> 评审对象:SEAM-DESIGN v1。裁决采纳情况见 SEAM-DESIGN v2【裁决记录】。
## 裁决表
| # | 裁决 |
|---|---|
| Q1 | D-A 采纳、D-C 否决(Host 需暴露 upstream/token/billing/setting/ratelimit ≥5 项重型能力,耦合倒置);TokenRefreshService 不收编(分发粒度是"账号"非"平台",CanRefresh 多态已完善,GetTokenRefresher 进 Provider 违背单一职责) |
| Q2 | v1 接口仅 Platform()+Forward()。FallbackModel 全仓单消费点(antigravity_gateway_service.go:2258,且是 service 调自己依赖);DefaultModels 消费点需要完整结构体且不经 registry 路径(gemini_v1beta_handler 4 处直调包函数);InvalidateToken 的消费者是账号管理/限速路径,admin handler 不应依赖 gateway 层 registry——三者均不进接口,switch 用数据驱动 map 另行消灭 |
| Q3 | ForwardRequest 必须补 SessionGroupID/SessionKey(WithForwardGeminiSession 两字段);action="generateContent" hardcode+测试锁定;**OpenAI 不经过 :444/:794(独立 handler),OpenAIForwardResult 适配是 Batch-2 阶段的虚假问题**——OpenAI 留原路径,Phase-4 再统一 |
| Q4 | 永久留核心:粘性 key 格式(Redis 键策略,影响存量会话 TTL 接续)、antigravity 退避(调度决策,Phase-0 不变量覆盖区)、beta policy(Forward 内部 Anthropic 专属)、endpoint 映射(多 handler 共享纯函数,OpenAI subpath 依赖 rawRequestPath 无法平台常量化)、BetaBlockedError/PromptTooLong 类型断言链(adapter 不得包裹) |
| Q5 | 从 ROADMAP 摘除"Phase-3 单独提案"表述,但**不宣布伪需求**——改为可观测触发条件:"gateway.hook.* 首个真实模块进入 modules/standard 插装清单时重新提案"(两次推迟原因不同:Phase-2 是零成员命名空间、Phase-3 是依赖未收敛,均正确) |
## 附加意见
ForwardRequest 分层建议:Parsed(Anthropic/Antigravity 路径)与 session 字段并存;GeminiAction 不进字段(adapter 内 hardcode);adapter 按 Platform() 决定底层调用与字段提取,不泄漏到接口定义。
@@ -0,0 +1,31 @@
# Phase-3 接缝设计评审 — 回归风险/等价性/YAGNI 视角(2026-06-11)
> 评审对象:SEAM-DESIGN v1。本报告与架构评审共同纠正三处事实硬伤;裁决见 SEAM-DESIGN v2。
## 三处事实硬伤(必须改)
1. OpenAI 不流经 :444/:794(独立 OpenAIGatewayHandler + OpenAIRecordUsage 管线)——设计把"OpenAI 结果适配"挂错位置;
2. :794 真实条件 `antigravity && Type != APIKey`,非"OAuth vs APIKey";
3. Batch-1 六点多已集中化:InvalidateToken 单处 composite(8 消费点统一)、GetFallbackModel 单消费点且各平台默认值不同、DefaultModels 异构结构体([]string 丢字段改变 /v1/models 与 admin 响应)。
## 关键风险(节选)
- **R-2.1(高)统一返回类型必丢载荷计费字段**:BillingModel 直接决定计费模型(openai_gateway_service.go:5789)、ServiceTier 驱动倍率(:2681)、ResponseID 会话绑定(openai_gateway_messages.go:371);Usage 子类型也不同——**禁止跨平台 result 收敛**;
- R-2.2(高)registry 只按 platform 分发会把 antigravity-APIKey 错误路由(须保留 Type 条件);
- R-2.3(中)ForwardRequest 两字段不足以覆盖 ForwardGemini(model/action/stream/session 选项);
- R-2.4(高)bench gate 盲区:现仅 anthropic forward 基准,须补 antigravity;
- R-2.5(低)variadic 仅 1 个 option(WithForwardGeminiSession 两字段、生产 2 调用点),折叠风险低。
## 裁决表
| # | 裁决 |
|---|---|
| Q1 | D-A 骨架接受;TokenRefresh 独立 |
| Q2 | 6 方法砍到 Platform()+Forward() |
| Q3 | 返回类型不得统一;OpenAI 剔出本期;前置特征化 T1-T5(OpenAI 不碰则 ≈1.75d) |
| Q4 | 永久留核心点比设计多(含全部原 Batch-1);**19 点中真正值得建缝 ≤2**——Provider 抽象 ROI 需在 PHASE_PLAN 复核(裁决:保留收窄版,为 Phase-4 搬家建立 Forward 承重接缝) |
| Q5 | 推迟成立;API 从 ROADMAP 待建清单摘除(裁决采纳架构评审的"可观测触发条件"折中表述) |
## 前置特征化清单(已转化为 PHASE_PLAN TASK-001)
T1 :794 路由矩阵(antigravity×{OAuth,APIKey})/ T2 :444 ForwardGemini 参数逐一断言 / T3 session 选项不丢参 / T4 错误链(BetaBlockedError/PromptTooLong 经 errors.As)/ T5 antigravity forward bench。
@@ -0,0 +1,29 @@
# 完成报告: [TASK-001] Phase-3 前置特征化测试 T1-T5
- **完成状态**: Success
- **关联设计**: [SEAM-DESIGN.md v2 裁决记录](../../../.claude/plugin-refactor/phases/phase-3_platform/SEAM-DESIGN.md)
- **完成日期**: 2026-06-12
## 1. 任务完成简报
7 个特征化测试 + 2 个 antigravity forward 基准落地,全部在当前行为下绿(行为锁定,零产线改动)。这是 TASK-002(gatewayplatform 接缝)的等价性 gate。
## 2. 锁定的行为(T1-T5)
- **T1 :794 路由矩阵**:antigravity×{非 APIKey→AntigravityGatewayService.Forward(v1internal 包裹体上游), APIKey→GatewayService.Forward(直连 anthropic + x-api-key)}——锁定真实条件 `Type != APIKey`(含 Upstream 类型子用例),每用例双向断言(命中侧 1 次/另一侧 0 次);
- **T2 :444 + 参数**:gemini→ForwardGemini 的 model/body/stream(false=收集单 JSON、true=SSE 解包)/sessionKey `"gemini:"+hash`;action 契约在 service 层锁定(generateContent/streamGenerateContent 接受、countTokens 短路零上游、未知 404);
- **T3 session 透传**:粘性绑定→429 限流→DeleteSessionAccountID(groupID, key) 参数断言→failover 成功 + RecordUsage force_cache_billing 日志断言(isStickySession 端到端可观测面);
- **T4 错误链**:BetaBlockedError→400 invalid_request_error(零上游)、PromptTooLongError→WriteMappedClaudeError 透传(单次上游无 failover)——adapter 若包裹错误必红;
- **T5 基准**:AntigravityClaude NonStream ~39.6µs/266 allocs、Stream ~42.7µs/329 allocs 入基线(旧 7 基准先 compare 零漂移再 collect,新基线自比 exit 0)。
## 3. 实现取舍
handler 的 service 字段为具体类型不可接口替换 → 真实 service + 注入式记录桩(双 HTTPUpstream 按 accountID 脚本化区分路径 + GatewayCache 记录桩断言 session 参数 + 结构化日志 sink)。
## 4. 文件变更
新增 `internal/handler/gateway_platform_dispatch_characterization_test.go`、`internal/service/antigravity_forward_gemini_action_characterization_test.go`、`internal/service/gateway_forward_antigravity_benchmark_test.go`;基线文件重采集。
## 5. 验证(主控复跑确认)
7 测试全绿、`make test-invariants` 44 包、全量 unit 零失败、vet 干净、bench 自比 exit 0。