mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-07 16:48:45 +08:00
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:
@@ -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
@@ -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
|
||||
|
||||
Vendored
+55
-43
@@ -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。
|
||||
Reference in New Issue
Block a user