mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-20 00:18:55 +00:00
test(safety-net): Phase-0 回归安全网——特征化/不变量测试 + 基准基线 + CI 门禁
插件化改造前置:把'绝不回归'变成机器可验证的硬约束。 - 透传特征化:anthropic 逐字节/SSE 逐事件、openai、gemini 路径与错误透传 - 计费不变量:5m/1h 双档缓存价、倍率叠加顺序、overages、端到端金额与配额增量 - 调度不变量:粘性会话、failover 10/3/3 完整循环、并发槽配平、等待队列 - 热路径基准基线 + scripts/bench-baseline.sh(allocs 严格/ns 15%/基准缺失 FAIL) - Makefile test-invariants 目标 + CI invariants job;修复失效的 test-e2e 脚本引用 - .gitignore:放行 backend/scripts 与 docs/plugin-architecture(既有白名单模式) 注:本分支按整体门禁验证(部分测试夹具引用后续提交的签名,中间提交不保证独立编译)。
This commit is contained in:
@@ -28,6 +28,23 @@ jobs:
|
||||
working-directory: backend
|
||||
run: make test-integration
|
||||
|
||||
# 插件化改造回归安全网:特征化/不变量测试作为独立命名的合并门禁
|
||||
# (这些测试也包含在 test-unit 中,独立 job 是为了回归信号清晰可辨)
|
||||
# 详见 .claude/plugin-refactor/INVARIANTS.md
|
||||
invariants:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-go@v6
|
||||
with:
|
||||
go-version-file: backend/go.mod
|
||||
check-latest: false
|
||||
cache: true
|
||||
cache-dependency-path: backend/go.sum
|
||||
- name: Regression invariants (plugin-refactor safety net)
|
||||
working-directory: backend
|
||||
run: make test-invariants
|
||||
|
||||
frontend:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
|
||||
@@ -133,6 +133,10 @@ docs/*
|
||||
!docs/ADMIN_PAYMENT_INTEGRATION_API.md
|
||||
!docs/legal/
|
||||
!docs/legal/*.md
|
||||
!docs/plugin-architecture/
|
||||
!docs/plugin-architecture/*.md
|
||||
!backend/scripts/
|
||||
!backend/scripts/*.sh
|
||||
.serena/
|
||||
.codex/
|
||||
frontend/coverage/
|
||||
|
||||
+15
-2
@@ -1,4 +1,4 @@
|
||||
.PHONY: build generate test test-unit test-integration test-e2e
|
||||
.PHONY: build generate test test-unit test-integration test-e2e test-invariants new-module
|
||||
|
||||
VERSION ?= $(shell tr -d '\r\n' < ./cmd/server/VERSION)
|
||||
LDFLAGS ?= -s -w -X main.Version=$(VERSION)
|
||||
@@ -20,8 +20,21 @@ test-unit:
|
||||
test-integration:
|
||||
go test -tags=integration ./...
|
||||
|
||||
# 插件化改造回归安全网(特征化/不变量测试,详见 .claude/plugin-refactor/INVARIANTS.md)
|
||||
test-invariants:
|
||||
go test -tags=unit -count=1 -run 'Characterization|Invariant' ./internal/...
|
||||
|
||||
# 生成插件模块骨架:make new-module ID=job.foo
|
||||
# (生成后按提示在 internal/modules/standard/imports.go 手工加 import 行)
|
||||
new-module:
|
||||
ifndef ID
|
||||
$(error ID is required: make new-module ID=job.foo)
|
||||
endif
|
||||
go run ./tools/newmodule -id "$(ID)"
|
||||
|
||||
# scripts/e2e-test.sh 已不存在,test-e2e 与 test-e2e-local 等价(env 驱动的真实服务 e2e)
|
||||
test-e2e:
|
||||
./scripts/e2e-test.sh
|
||||
go test -tags=e2e -v -timeout=300s ./internal/integration/...
|
||||
|
||||
test-e2e-local:
|
||||
go test -tags=e2e -v -timeout=300s ./internal/integration/...
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-002 特征化测试:网关拦截链路(INVARIANTS I-7.1 / I-7.2)。
|
||||
// 通过完整的 GatewayHandler.Messages / CountTokens 入口驱动,断言客户端最终
|
||||
// 收到的状态码与错误体:
|
||||
// - I-7.1 内容审核拦截:403 + content_policy_violation 错误体;审核服务失败 = 放行(fail-open);
|
||||
// - I-7.2 Claude Code 版本检查:低版本 400 + 升级提示;/count_tokens 路径豁免。
|
||||
//
|
||||
// 复用 gateway_handler_warmup_intercept_unit_test.go 的 newTestGatewayHandler 夹具:
|
||||
// antigravity 账号 intercept_warmup_requests=true 时,Warmup 请求在转发上游前被
|
||||
// mock 拦截返回 200,使"请求被放行"成为可观测结果(无需真实上游)。
|
||||
//
|
||||
// 注意:Claude Code 版本上下限走 service 包内全局 60s TTL 缓存
|
||||
// (setting_service.go versionBoundsCache)。本文件所有版本测试固定使用同一组
|
||||
// 上下限(min=1.5.0,无 max),避免同进程内缓存交叉污染。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/pagination"
|
||||
middleware "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// passCharSettingRepo 是 service.SettingRepository 的内存实现。
|
||||
type passCharSettingRepo struct {
|
||||
values map[string]string
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) Get(_ context.Context, key string) (*service.Setting, error) {
|
||||
if v, ok := r.values[key]; ok {
|
||||
return &service.Setting{Key: key, Value: v}, nil
|
||||
}
|
||||
return nil, service.ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) GetValue(_ context.Context, key string) (string, error) {
|
||||
if v, ok := r.values[key]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return "", service.ErrSettingNotFound
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) Set(_ context.Context, key, value string) error {
|
||||
if r.values == nil {
|
||||
r.values = map[string]string{}
|
||||
}
|
||||
r.values[key] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) GetMultiple(_ context.Context, keys []string) (map[string]string, error) {
|
||||
out := map[string]string{}
|
||||
for _, key := range keys {
|
||||
if v, ok := r.values[key]; ok {
|
||||
out[key] = v
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) SetMultiple(_ context.Context, settings map[string]string) error {
|
||||
for k, v := range settings {
|
||||
_ = r.Set(context.Background(), k, v)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) GetAll(_ context.Context) (map[string]string, error) {
|
||||
out := make(map[string]string, len(r.values))
|
||||
for k, v := range r.values {
|
||||
out[k] = v
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (r *passCharSettingRepo) Delete(_ context.Context, key string) error {
|
||||
delete(r.values, key)
|
||||
return nil
|
||||
}
|
||||
|
||||
// passCharModerationRepo 是 service.ContentModerationRepository 的空操作实现。
|
||||
type passCharModerationRepo struct{}
|
||||
|
||||
func (r *passCharModerationRepo) CreateLog(context.Context, *service.ContentModerationLog) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *passCharModerationRepo) ListLogs(context.Context, service.ContentModerationLogFilter) ([]service.ContentModerationLog, *pagination.PaginationResult, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
func (r *passCharModerationRepo) CountFlaggedByUserSince(context.Context, int64, time.Time) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (r *passCharModerationRepo) CleanupExpiredLogs(context.Context, time.Time, time.Time) (*service.ContentModerationCleanupResult, error) {
|
||||
return &service.ContentModerationCleanupResult{}, nil
|
||||
}
|
||||
|
||||
// passCharInterceptFixture 构造完整的 Messages 链路夹具(拦截预热账号 + 上下文注入)。
|
||||
func passCharInterceptFixture(t *testing.T) (*GatewayHandler, func()) {
|
||||
t.Helper()
|
||||
|
||||
groupID := int64(7001)
|
||||
accountID := int64(7101)
|
||||
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
account := &service.Account{
|
||||
ID: accountID,
|
||||
Name: "pass-char-intercept",
|
||||
Platform: service.PlatformAntigravity,
|
||||
Type: service.AccountTypeOAuth,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "tok_char",
|
||||
"intercept_warmup_requests": true,
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"mixed_scheduling": true,
|
||||
},
|
||||
Concurrency: 1,
|
||||
Priority: 1,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
AccountGroups: []service.AccountGroup{{AccountID: accountID, GroupID: groupID}},
|
||||
}
|
||||
return newTestGatewayHandler(t, group, []*service.Account{account})
|
||||
}
|
||||
|
||||
// passCharNewMessagesContext 构造带认证上下文的 /v1/messages 请求。
|
||||
func passCharNewMessagesContext(t *testing.T, path string, body []byte) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
groupID := int64(7001)
|
||||
group := &service.Group{
|
||||
ID: groupID,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
req := httptest.NewRequest(http.MethodPost, path, bytes.NewReader(body))
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req = req.WithContext(context.WithValue(req.Context(), ctxkey.Group, group))
|
||||
c.Request = req
|
||||
|
||||
apiKey := &service.APIKey{
|
||||
ID: 7301,
|
||||
UserID: 7401,
|
||||
GroupID: &groupID,
|
||||
Status: service.StatusActive,
|
||||
User: &service.User{
|
||||
ID: 7401,
|
||||
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
|
||||
}
|
||||
|
||||
// passCharWarmupBody 返回触发预热拦截的最小请求体。
|
||||
func passCharWarmupBody() []byte {
|
||||
return []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 256,
|
||||
"messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
|
||||
}`)
|
||||
}
|
||||
|
||||
// passCharClaudeCodeWarmupBody 返回能通过 Claude Code 客户端校验且触发预热拦截的请求体。
|
||||
func passCharClaudeCodeWarmupBody() []byte {
|
||||
deviceID := strings.Repeat("a", 64)
|
||||
return []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 256,
|
||||
"system": [{"type":"text","text":"You are Claude Code, Anthropic's official CLI for Claude."}],
|
||||
"metadata": {"user_id": "user_` + deviceID + `_account__session_01234567-89ab-cdef-0123-456789abcdef"},
|
||||
"messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
|
||||
}`)
|
||||
}
|
||||
|
||||
// passCharSetClaudeCodeHeaders 设置 Claude Code 客户端校验所需的请求头。
|
||||
func passCharSetClaudeCodeHeaders(c *gin.Context, ua string) {
|
||||
c.Request.Header.Set("User-Agent", ua)
|
||||
c.Request.Header.Set("X-App", "cli")
|
||||
c.Request.Header.Set("anthropic-beta", "claude-code-20250219")
|
||||
c.Request.Header.Set("anthropic-version", "2023-06-01")
|
||||
}
|
||||
|
||||
// passCharModerationService 构造指向 mock 审核 API 的内容审核服务。
|
||||
func passCharModerationService(t *testing.T, moderationBaseURL string) *service.ContentModerationService {
|
||||
t.Helper()
|
||||
cfgJSON, err := json.Marshal(map[string]any{
|
||||
"enabled": true,
|
||||
"mode": "pre_block",
|
||||
"base_url": moderationBaseURL,
|
||||
"api_keys": []string{"sk-audit"},
|
||||
"sample_rate": 100,
|
||||
"all_groups": true,
|
||||
"auto_ban_enabled": false,
|
||||
"email_on_hit": false,
|
||||
"retry_count": 0,
|
||||
"timeout_ms": 2000,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
settingRepo := &passCharSettingRepo{values: map[string]string{
|
||||
service.SettingKeyRiskControlEnabled: "true",
|
||||
service.SettingKeyContentModerationConfig: string(cfgJSON),
|
||||
}}
|
||||
return service.NewContentModerationService(settingRepo, &passCharModerationRepo{}, nil, nil, nil, nil, nil)
|
||||
}
|
||||
|
||||
// passCharRequireWarmupMock 断言响应为预热拦截 mock(请求被放行并完成)。
|
||||
func passCharRequireWarmupMock(t *testing.T, rec *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
var resp map[string]any
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, "msg_mock_warmup", resp["id"])
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ContentModerationBlock 固化 I-7.1 拦截侧:
|
||||
// 审核 API 判定命中(pre_block 模式)时,客户端收到 403 +
|
||||
// {"type":"error","error":{"type":"content_policy_violation","message":<配置的拦截文案>}}。
|
||||
func TestGatewayCharacterization_ContentModerationBlock(t *testing.T) {
|
||||
moderationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"results":[{"flagged":true,"category_scores":{"sexual":0.99}}]}`))
|
||||
}))
|
||||
defer moderationSrv.Close()
|
||||
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
|
||||
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharWarmupBody())
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusForbidden, rec.Code, "内容审核命中应返回 403")
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"content_policy_violation","message":"内容审计命中风险规则,请调整输入后重试"}}`,
|
||||
rec.Body.String())
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ContentModerationFailOpen 固化 I-7.1 fail-open 侧:
|
||||
// 审核 API 自身失败(HTTP 500)时请求必须放行——客户端收到正常业务响应
|
||||
// (此处为预热拦截 mock 200),而不是 403。
|
||||
func TestGatewayCharacterization_ContentModerationFailOpen(t *testing.T) {
|
||||
moderationSrv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, "moderation backend exploded", http.StatusInternalServerError)
|
||||
}))
|
||||
defer moderationSrv.Close()
|
||||
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.preFlightHooks = ProvideGatewayHookChain(passCharModerationService(t, moderationSrv.URL))
|
||||
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharWarmupBody())
|
||||
h.Messages(c)
|
||||
|
||||
passCharRequireWarmupMock(t, rec)
|
||||
}
|
||||
|
||||
// passCharVersionBoundSettingService 返回固定 min=1.5.0(无 max)的 SettingService。
|
||||
// 注意:版本上下限有进程内全局 60s 缓存,本文件所有版本测试统一使用该配置。
|
||||
func passCharVersionBoundSettingService() *service.SettingService {
|
||||
repo := &passCharSettingRepo{values: map[string]string{
|
||||
service.SettingKeyMinClaudeCodeVersion: "1.5.0",
|
||||
}}
|
||||
return service.NewSettingService(repo, &config.Config{})
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ClaudeCodeVersionCheck 固化 I-7.2 主路径:
|
||||
// Claude Code 客户端(UA claude-cli/x.y.z + 完整客户端特征)在 /v1/messages 上:
|
||||
// - CLI 版本低于最低要求 → 400 invalid_request_error + 升级提示;
|
||||
// - CLI 版本满足要求 → 请求放行(预热拦截 mock 200)。
|
||||
func TestGatewayCharacterization_ClaudeCodeVersionCheck(t *testing.T) {
|
||||
t.Run("低版本_400升级提示", func(t *testing.T) {
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.settingService = passCharVersionBoundSettingService()
|
||||
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharClaudeCodeWarmupBody())
|
||||
passCharSetClaudeCodeHeaders(c, "claude-cli/1.0.0 (external)")
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
var resp struct {
|
||||
Type string `json:"type"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.Equal(t, "error", resp.Type)
|
||||
require.Equal(t, "invalid_request_error", resp.Error.Type)
|
||||
require.Contains(t, resp.Error.Message, "Your Claude Code version (1.0.0) is below the minimum required version (1.5.0)")
|
||||
require.Contains(t, resp.Error.Message, "npm update -g @anthropic-ai/claude-code", "错误信息应包含升级指引")
|
||||
})
|
||||
|
||||
t.Run("版本达标_请求放行", func(t *testing.T) {
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.settingService = passCharVersionBoundSettingService()
|
||||
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages", passCharClaudeCodeWarmupBody())
|
||||
passCharSetClaudeCodeHeaders(c, "claude-cli/2.0.0 (external)")
|
||||
h.Messages(c)
|
||||
|
||||
passCharRequireWarmupMock(t, rec)
|
||||
})
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ClaudeCodeVersionCheck_CountTokensExempt 固化 I-7.2 豁免侧:
|
||||
// /v1/messages/count_tokens 不做版本检查——低版本 Claude Code CLI 不会收到版本 400,
|
||||
// 请求继续走 count_tokens 业务(antigravity 账号当前返回 404 not_found_error)。
|
||||
func TestGatewayCharacterization_ClaudeCodeVersionCheck_CountTokensExempt(t *testing.T) {
|
||||
h, cleanup := passCharInterceptFixture(t)
|
||||
defer cleanup()
|
||||
h.settingService = passCharVersionBoundSettingService()
|
||||
|
||||
body := []byte(`{"model":"claude-sonnet-4-5","messages":[{"role":"user","content":[{"type":"text","text":"hello"}]}]}`)
|
||||
c, rec := passCharNewMessagesContext(t, "/v1/messages/count_tokens", body)
|
||||
passCharSetClaudeCodeHeaders(c, "claude-cli/1.0.0 (external)")
|
||||
h.CountTokens(c)
|
||||
|
||||
var resp struct {
|
||||
Type string `json:"type"`
|
||||
Error struct {
|
||||
Type string `json:"type"`
|
||||
Message string `json:"message"`
|
||||
} `json:"error"`
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp))
|
||||
require.NotContains(t, resp.Error.Message, "below the minimum required version",
|
||||
"count_tokens 必须豁免版本检查")
|
||||
|
||||
// 固化当前实际行为:antigravity 账号不支持 count_tokens,返回 404 not_found_error。
|
||||
require.Equal(t, http.StatusNotFound, rec.Code)
|
||||
require.Equal(t, "not_found_error", resp.Error.Type)
|
||||
}
|
||||
@@ -0,0 +1,614 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-004 failover 不变量测试(INVARIANTS I-5.1 / I-5.2 / I-5.3、I-6.1 部分)。
|
||||
//
|
||||
// 固化内容:
|
||||
// - I-5.1 三平台换号上限默认值(anthropic=10 / gemini=3 / openai=3,双断言:
|
||||
// 构造函数默认值 + 硬编码当前值);anthropic 平台通过完整 Messages 入口驱动
|
||||
// 真实 failover 循环:连续上游 500 时恰好尝试 maxAccountSwitches+1 个账号后
|
||||
// 返回 502 + 映射错误体;
|
||||
// - I-5.2 失败账号进入排除集合不被重选(同一账号不会被尝试两次);
|
||||
// - I-5.3 可重试错误(池模式)先同账号重试 maxSameAccountRetries 次再换号(完整链);
|
||||
// - I-6.1 转发失败路径下账号/用户槽位获取-释放严格配平(配平计数器);
|
||||
// - chat-completions 兼容路径耗尽错误体("All available accounts exhausted")。
|
||||
//
|
||||
// 复用同包既有夹具:fakeSchedulerCache / fakeGroupRepo
|
||||
// (gateway_handler_warmup_intercept_unit_test.go)、mockTempUnscheduler /
|
||||
// newTestFailoverErr(failover_loop_test.go)。
|
||||
// 本文件新增的包级辅助类型/函数一律带 schedInv 前缀。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schedInv 夹具
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// schedInvUpstream 是固定响应的 HTTPUpstream 桩,记录每次调用的 accountID。
|
||||
type schedInvUpstream struct {
|
||||
mu sync.Mutex
|
||||
status int
|
||||
body string
|
||||
panicOn bool
|
||||
attempts []int64
|
||||
}
|
||||
|
||||
var _ service.HTTPUpstream = (*schedInvUpstream)(nil)
|
||||
|
||||
func (u *schedInvUpstream) Do(req *http.Request, _ string, accountID int64, _ int) (*http.Response, error) {
|
||||
u.mu.Lock()
|
||||
u.attempts = append(u.attempts, accountID)
|
||||
shouldPanic := u.panicOn
|
||||
status := u.status
|
||||
body := u.body
|
||||
u.mu.Unlock()
|
||||
|
||||
if req != nil && req.Body != nil {
|
||||
_, _ = io.Copy(io.Discard, req.Body)
|
||||
_ = req.Body.Close()
|
||||
}
|
||||
if shouldPanic {
|
||||
panic("schedInv: simulated upstream panic")
|
||||
}
|
||||
return &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(body)),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (u *schedInvUpstream) DoWithTLS(req *http.Request, proxyURL string, accountID int64, accountConcurrency int, _ *tlsfingerprint.Profile) (*http.Response, error) {
|
||||
return u.Do(req, proxyURL, accountID, accountConcurrency)
|
||||
}
|
||||
|
||||
func (u *schedInvUpstream) attemptedAccounts() []int64 {
|
||||
u.mu.Lock()
|
||||
defer u.mu.Unlock()
|
||||
return append([]int64(nil), u.attempts...)
|
||||
}
|
||||
|
||||
// schedInvCountingCache 是带真实容量语义的 ConcurrencyCache,
|
||||
// 记录账号/用户槽位与等待计数的获取-释放配平。
|
||||
type schedInvCountingCache struct {
|
||||
mu sync.Mutex
|
||||
|
||||
accountHeld map[int64]map[string]struct{}
|
||||
userHeld map[int64]map[string]struct{}
|
||||
|
||||
accountAcquired int
|
||||
accountReleased int
|
||||
// accountReleasedUnknown 记录对未持有 requestID 的重复/无效释放(幂等释放语义)
|
||||
accountReleasedUnknown int
|
||||
userAcquired int
|
||||
userReleased int
|
||||
userReleasedUnknown int
|
||||
|
||||
userWaitInc int
|
||||
userWaitDec int
|
||||
accountWaitInc int
|
||||
accountWaitDec int
|
||||
}
|
||||
|
||||
var _ service.ConcurrencyCache = (*schedInvCountingCache)(nil)
|
||||
|
||||
func schedInvNewCountingCache() *schedInvCountingCache {
|
||||
return &schedInvCountingCache{
|
||||
accountHeld: make(map[int64]map[string]struct{}),
|
||||
userHeld: make(map[int64]map[string]struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) AcquireAccountSlot(_ context.Context, accountID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
held := c.accountHeld[accountID]
|
||||
if maxConcurrency > 0 && len(held) >= maxConcurrency {
|
||||
return false, nil
|
||||
}
|
||||
if held == nil {
|
||||
held = make(map[string]struct{})
|
||||
c.accountHeld[accountID] = held
|
||||
}
|
||||
held[requestID] = struct{}{}
|
||||
c.accountAcquired++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) ReleaseAccountSlot(_ context.Context, accountID int64, requestID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if held := c.accountHeld[accountID]; held != nil {
|
||||
if _, ok := held[requestID]; ok {
|
||||
delete(held, requestID)
|
||||
c.accountReleased++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.accountReleasedUnknown++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetAccountConcurrency(_ context.Context, accountID int64) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.accountHeld[accountID]), nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetAccountConcurrencyBatch(_ context.Context, accountIDs []int64) (map[int64]int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
result := make(map[int64]int, len(accountIDs))
|
||||
for _, id := range accountIDs {
|
||||
result[id] = len(c.accountHeld[id])
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) IncrementAccountWaitCount(_ context.Context, _ int64, _ int) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.accountWaitInc++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) DecrementAccountWaitCount(_ context.Context, _ int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.accountWaitDec++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetAccountWaitingCount(_ context.Context, _ int64) (int, error) {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) AcquireUserSlot(_ context.Context, userID int64, maxConcurrency int, requestID string) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
held := c.userHeld[userID]
|
||||
if maxConcurrency > 0 && len(held) >= maxConcurrency {
|
||||
return false, nil
|
||||
}
|
||||
if held == nil {
|
||||
held = make(map[string]struct{})
|
||||
c.userHeld[userID] = held
|
||||
}
|
||||
held[requestID] = struct{}{}
|
||||
c.userAcquired++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) ReleaseUserSlot(_ context.Context, userID int64, requestID string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if held := c.userHeld[userID]; held != nil {
|
||||
if _, ok := held[requestID]; ok {
|
||||
delete(held, requestID)
|
||||
c.userReleased++
|
||||
return nil
|
||||
}
|
||||
}
|
||||
c.userReleasedUnknown++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetUserConcurrency(_ context.Context, userID int64) (int, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return len(c.userHeld[userID]), nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) IncrementWaitCount(_ context.Context, _ int64, _ int) (bool, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.userWaitInc++
|
||||
return true, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) DecrementWaitCount(_ context.Context, _ int64) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.userWaitDec++
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetAccountsLoadBatch(_ context.Context, accounts []service.AccountWithConcurrency) (map[int64]*service.AccountLoadInfo, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
result := make(map[int64]*service.AccountLoadInfo, len(accounts))
|
||||
for _, acc := range accounts {
|
||||
held := len(c.accountHeld[acc.ID])
|
||||
loadRate := 0
|
||||
if acc.MaxConcurrency > 0 {
|
||||
loadRate = held * 100 / acc.MaxConcurrency
|
||||
}
|
||||
result[acc.ID] = &service.AccountLoadInfo{
|
||||
AccountID: acc.ID,
|
||||
CurrentConcurrency: held,
|
||||
LoadRate: loadRate,
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) GetUsersLoadBatch(_ context.Context, users []service.UserWithConcurrency) (map[int64]*service.UserLoadInfo, error) {
|
||||
result := make(map[int64]*service.UserLoadInfo, len(users))
|
||||
for _, u := range users {
|
||||
result[u.ID] = &service.UserLoadInfo{UserID: u.ID}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (c *schedInvCountingCache) CleanupExpiredAccountSlots(_ context.Context, _ int64) error { return nil }
|
||||
func (c *schedInvCountingCache) CleanupStaleProcessSlots(_ context.Context, _ string) error { return nil }
|
||||
|
||||
// schedInvRequireBalanced 断言所有槽位与等待计数获取-释放严格配平。
|
||||
func schedInvRequireBalanced(t *testing.T, cc *schedInvCountingCache) {
|
||||
t.Helper()
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
for accountID, held := range cc.accountHeld {
|
||||
require.Empty(t, held, "账号 %d 仍有未释放的槽位", accountID)
|
||||
}
|
||||
for userID, held := range cc.userHeld {
|
||||
require.Empty(t, held, "用户 %d 仍有未释放的槽位", userID)
|
||||
}
|
||||
require.Equal(t, cc.accountAcquired, cc.accountReleased, "账号槽位获取/释放必须配平")
|
||||
require.Equal(t, cc.userAcquired, cc.userReleased, "用户槽位获取/释放必须配平")
|
||||
require.Equal(t, cc.userWaitInc, cc.userWaitDec, "用户等待计数增减必须配平")
|
||||
require.Equal(t, cc.accountWaitInc, cc.accountWaitDec, "账号等待计数增减必须配平")
|
||||
}
|
||||
|
||||
// schedInvGroup 构造测试分组。
|
||||
func schedInvGroup(groupID int64) *service.Group {
|
||||
return &service.Group{
|
||||
ID: groupID,
|
||||
Hydrated: true,
|
||||
Platform: service.PlatformAnthropic,
|
||||
Status: service.StatusActive,
|
||||
}
|
||||
}
|
||||
|
||||
// schedInvPassthroughAccount 构造 anthropic API Key 透传账号(Forward 路径不依赖真实上游域名)。
|
||||
func schedInvPassthroughAccount(id, groupID int64, extraCreds map[string]any) *service.Account {
|
||||
creds := map[string]any{"api_key": fmt.Sprintf("sk-sched-inv-%d", id)}
|
||||
for k, v := range extraCreds {
|
||||
creds[k] = v
|
||||
}
|
||||
return &service.Account{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("sched-inv-acc-%d", id),
|
||||
Platform: service.PlatformAnthropic,
|
||||
Type: service.AccountTypeAPIKey,
|
||||
Credentials: creds,
|
||||
Extra: map[string]any{"anthropic_passthrough": true},
|
||||
Concurrency: 5,
|
||||
Priority: 1,
|
||||
Status: service.StatusActive,
|
||||
Schedulable: true,
|
||||
AccountGroups: []service.AccountGroup{{AccountID: id, GroupID: groupID}},
|
||||
}
|
||||
}
|
||||
|
||||
// schedInvNewHandler 构造完整 GatewayHandler:真实 failover 循环 + 计数并发缓存 + 上游桩。
|
||||
// cfg 传 nil 给 NewGatewayHandler,以固化默认换号上限(anthropic=10 / gemini=3)。
|
||||
func schedInvNewHandler(t *testing.T, group *service.Group, accounts []*service.Account, upstream service.HTTPUpstream, cc *schedInvCountingCache) (*GatewayHandler, func()) {
|
||||
t.Helper()
|
||||
|
||||
// 隔离环境变量:避免本机 SUB2API_DEBUG_GATEWAY_BODY 在测试期间写调试文件。
|
||||
t.Setenv("SUB2API_DEBUG_GATEWAY_BODY", "")
|
||||
|
||||
schedulerSnapshot := service.NewSchedulerSnapshotService(&fakeSchedulerCache{accounts: accounts}, nil, nil, nil, nil)
|
||||
concurrencySvc := service.NewConcurrencyService(cc)
|
||||
|
||||
gwSvc := service.NewGatewayService(
|
||||
nil, // accountRepo(scheduler snapshot 命中,不需要)
|
||||
&fakeGroupRepo{group: group},
|
||||
nil, nil, nil, nil, nil, // usageLogRepo / usageBillingRepo / userRepo / userSubRepo / userGroupRateRepo
|
||||
nil, // cache(粘性会话关闭)
|
||||
// 空 cfg(非 nil):API Key 透传分支会校验默认 base_url,
|
||||
// validateUpstreamBaseURL 在 cfg=nil 时会空指针。
|
||||
&config.Config{},
|
||||
schedulerSnapshot,
|
||||
concurrencySvc,
|
||||
nil, // billingService
|
||||
&service.RateLimitService{}, // rateLimitService(零值:500 仅记录日志,无副作用)
|
||||
nil, // billingCacheService
|
||||
nil, // identityService
|
||||
upstream,
|
||||
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
||||
)
|
||||
|
||||
// RunModeSimple 跳过计费检查,避免引入 repo/cache 依赖。
|
||||
billingCacheSvc := service.NewBillingCacheService(nil, nil, nil, nil, nil, nil,
|
||||
&config.Config{RunMode: config.RunModeSimple}, nil)
|
||||
|
||||
h := NewGatewayHandler(
|
||||
gwSvc,
|
||||
nil, // geminiCompatService
|
||||
nil, // antigravityGatewayService
|
||||
nil, // userService
|
||||
concurrencySvc,
|
||||
billingCacheSvc,
|
||||
nil, // usageService
|
||||
nil, // apiKeyService
|
||||
nil, // usageRecordWorkerPool
|
||||
nil, // errorPassthroughService
|
||||
nil, // preFlightHooks
|
||||
nil, // userMsgQueueService
|
||||
nil, // cfg → 默认换号上限
|
||||
nil, // settingService
|
||||
)
|
||||
return h, func() { billingCacheSvc.Stop() }
|
||||
}
|
||||
|
||||
// schedInvNewMessagesContext 构造带认证上下文的 /v1/messages 请求。
|
||||
// 返回的 cancel 用于模拟请求结束时的 context 取消(生产环境由 net/http 完成)。
|
||||
func schedInvNewMessagesContext(t *testing.T, group *service.Group, body []byte) (*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")
|
||||
ctx := context.WithValue(req.Context(), ctxkey.Group, group)
|
||||
ctx, cancel := context.WithCancel(ctx)
|
||||
c.Request = req.WithContext(ctx)
|
||||
|
||||
apiKey := &service.APIKey{
|
||||
ID: 8301,
|
||||
UserID: 8401,
|
||||
GroupID: &group.ID,
|
||||
Status: service.StatusActive,
|
||||
User: &service.User{
|
||||
ID: 8401,
|
||||
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
|
||||
}
|
||||
|
||||
func schedInvMessagesBody() []byte {
|
||||
return []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 256,
|
||||
"messages": [{"role":"user","content":[{"type":"text","text":"scheduling invariants probe"}]}]
|
||||
}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-5.1 三平台换号上限默认值(双断言)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_FailoverSwitchLimit_DefaultValues(t *testing.T) {
|
||||
t.Run("anthropic与gemini默认上限", func(t *testing.T) {
|
||||
h := NewGatewayHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.Equal(t, 10, h.maxAccountSwitches, "anthropic 默认换号上限必须为 10")
|
||||
require.Equal(t, 3, h.maxAccountSwitchesGemini, "gemini 默认换号上限必须为 3")
|
||||
})
|
||||
|
||||
t.Run("openai默认上限", func(t *testing.T) {
|
||||
oh := NewOpenAIGatewayHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.Equal(t, 3, oh.maxAccountSwitches, "openai 默认换号上限必须为 3")
|
||||
})
|
||||
|
||||
t.Run("配置可覆盖默认上限", func(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
cfg.Gateway.MaxAccountSwitches = 5
|
||||
cfg.Gateway.MaxAccountSwitchesGemini = 2
|
||||
h := NewGatewayHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, cfg, nil)
|
||||
require.Equal(t, 5, h.maxAccountSwitches)
|
||||
require.Equal(t, 2, h.maxAccountSwitchesGemini)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-5.1 + I-5.2 + I-6.1:anthropic 完整 failover 循环
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_FailoverAnthropic_FullLoopExhaustion 通过完整 Messages
|
||||
// 入口驱动真实 failover 循环:12 个账号 + 恒定上游 500。
|
||||
// 固化语义:
|
||||
// 1. 恰好尝试 maxAccountSwitches+1 = 11 个账号(初次 + 10 次换号);
|
||||
// 2. 失败账号进入排除集合,同一账号不被尝试两次;
|
||||
// 3. 耗尽后客户端收到 502 + {"type":"error","error":{"type":"upstream_error",...}}
|
||||
// (/v1/messages 路径按 mapUpstreamError 映射 500→502;
|
||||
// "server_error/All available accounts exhausted" 错误体属于
|
||||
// chat-completions/responses 兼容路径,见下方独立用例);
|
||||
// 4. 转发失败路径下账号/用户槽位获取-释放严格配平。
|
||||
func TestSchedulingInvariant_FailoverAnthropic_FullLoopExhaustion(t *testing.T) {
|
||||
groupID := int64(9001)
|
||||
group := schedInvGroup(groupID)
|
||||
|
||||
accounts := make([]*service.Account, 0, 12)
|
||||
for i := int64(1); i <= 12; i++ {
|
||||
accounts = append(accounts, schedInvPassthroughAccount(9100+i, groupID, nil))
|
||||
}
|
||||
|
||||
upstream := &schedInvUpstream{
|
||||
status: http.StatusInternalServerError,
|
||||
body: `{"type":"error","error":{"type":"api_error","message":"schedInv upstream boom"}}`,
|
||||
}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, accounts, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
c, rec, cancel := schedInvNewMessagesContext(t, group, schedInvMessagesBody())
|
||||
defer cancel()
|
||||
|
||||
h.Messages(c)
|
||||
|
||||
// 1. 换号次数恰为上限:尝试账号数 = maxAccountSwitches + 1(常量引用 + 硬编码双断言)
|
||||
attempts := upstream.attemptedAccounts()
|
||||
require.Len(t, attempts, h.maxAccountSwitches+1, "尝试账号数必须等于 maxAccountSwitches+1")
|
||||
require.Len(t, attempts, 11, "anthropic 平台:1 次初始尝试 + 10 次换号")
|
||||
|
||||
// 2. 排除集合语义:同一账号不被重选
|
||||
seen := make(map[int64]struct{}, len(attempts))
|
||||
for _, accountID := range attempts {
|
||||
_, dup := seen[accountID]
|
||||
require.False(t, dup, "账号 %d 被尝试了两次:失败账号必须进入排除集合", accountID)
|
||||
seen[accountID] = struct{}{}
|
||||
}
|
||||
|
||||
// 3. 耗尽后错误语义:502 + upstream_error 映射错误体(固化当前实际行为)
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"upstream_error","message":"Upstream service temporarily unavailable"}}`,
|
||||
rec.Body.String())
|
||||
|
||||
// 4. I-6.1 转发失败路径:槽位严格配平
|
||||
require.Equal(t, 11, cc.accountAcquired, "每次尝试获取一次账号槽位")
|
||||
require.Equal(t, 1, cc.userAcquired, "整个请求只获取一次用户槽位")
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-5.3 池模式可重试错误:同账号重试 3 次后换号(完整链)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_FailoverSameAccountRetry_FullChain 使用池模式账号
|
||||
// (pool_mode_retry_status_codes=[500])驱动完整链路:
|
||||
// 上游恒定 500 → 同账号共尝试 1+maxSameAccountRetries=4 次 → 加入排除集合换号 →
|
||||
// 无其他账号 → 502。同时验证每次重试均独立获取/释放账号槽位(配平)。
|
||||
func TestSchedulingInvariant_FailoverSameAccountRetry_FullChain(t *testing.T) {
|
||||
groupID := int64(9002)
|
||||
group := schedInvGroup(groupID)
|
||||
|
||||
poolAccount := schedInvPassthroughAccount(9201, groupID, map[string]any{
|
||||
"pool_mode": true,
|
||||
"pool_mode_retry_status_codes": []any{float64(500)},
|
||||
})
|
||||
|
||||
upstream := &schedInvUpstream{
|
||||
status: http.StatusInternalServerError,
|
||||
body: `{"type":"error","error":{"type":"api_error","message":"schedInv pool boom"}}`,
|
||||
}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, []*service.Account{poolAccount}, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
c, rec, cancel := schedInvNewMessagesContext(t, group, schedInvMessagesBody())
|
||||
defer cancel()
|
||||
|
||||
h.Messages(c)
|
||||
|
||||
// 同账号共尝试 1 + maxSameAccountRetries 次(常量引用 + 硬编码双断言)
|
||||
attempts := upstream.attemptedAccounts()
|
||||
require.Len(t, attempts, 1+maxSameAccountRetries, "可重试错误:1 次初始 + maxSameAccountRetries 次同账号重试")
|
||||
require.Len(t, attempts, 4)
|
||||
for _, accountID := range attempts {
|
||||
require.Equal(t, poolAccount.ID, accountID, "重试必须发生在同一账号上")
|
||||
}
|
||||
|
||||
// 重试耗尽 + 换号后无可用账号 → 502
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"upstream_error","message":"Upstream service temporarily unavailable"}}`,
|
||||
rec.Body.String())
|
||||
|
||||
// 每轮重试独立获取/释放账号槽位
|
||||
require.Equal(t, 4, cc.accountAcquired)
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-5.1 gemini 换号上限:FailoverState 循环契约
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_FailoverGemini_SwitchLimitLoopContract 按 Messages
|
||||
// gemini 分支的真实接线(NewFailoverState(h.maxAccountSwitchesGemini, ...))
|
||||
// 驱动 FailoverState:连续上游失败时恰好允许 3+1=4 个账号尝试后耗尽。
|
||||
// (gemini 平台 Forward 的服务层内部 500 重试带秒级退避,完整 e2e 不可在
|
||||
// 单测时间预算内执行,故此处固化 handler 循环契约层语义。)
|
||||
func TestSchedulingInvariant_FailoverGemini_SwitchLimitLoopContract(t *testing.T) {
|
||||
h := NewGatewayHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||
require.Equal(t, 3, h.maxAccountSwitchesGemini)
|
||||
|
||||
mock := &mockTempUnscheduler{}
|
||||
fs := NewFailoverState(h.maxAccountSwitchesGemini, false)
|
||||
|
||||
attempts := 0
|
||||
for i := 1; ; i++ {
|
||||
require.LessOrEqual(t, i, 10, "防御:循环不应超过 10 次")
|
||||
attempts++
|
||||
action := fs.HandleFailoverError(context.Background(), mock, int64(i), service.PlatformGemini, newTestFailoverErr(500, false, false))
|
||||
if action == FailoverExhausted {
|
||||
break
|
||||
}
|
||||
require.Equal(t, FailoverContinue, action)
|
||||
}
|
||||
|
||||
require.Equal(t, h.maxAccountSwitchesGemini+1, attempts, "gemini:尝试账号数 = 上限 + 1")
|
||||
require.Equal(t, 4, attempts)
|
||||
require.Len(t, fs.FailedAccountIDs, 4, "所有失败账号都进入排除集合")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// chat-completions 兼容路径耗尽错误体
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_FailoverExhausted_ChatCompletionsErrorBody 固化
|
||||
// chat-completions 兼容路径的耗尽错误体(INVARIANTS 记录的
|
||||
// "All available accounts exhausted" 属于此路径而非 /v1/messages 路径)。
|
||||
// 注意当前实际行为:lastErr 非空时状态码透传上游状态码(500→500),
|
||||
// lastErr 为空时才回退 502。
|
||||
func TestSchedulingInvariant_FailoverExhausted_ChatCompletionsErrorBody(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
t.Run("lastErr存在_状态码透传上游", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
h := &GatewayHandler{}
|
||||
h.handleCCFailoverExhausted(c, &service.UpstreamFailoverError{
|
||||
StatusCode: http.StatusInternalServerError,
|
||||
ResponseBody: []byte(`{"error":{"message":"boom"}}`),
|
||||
}, false)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, rec.Code,
|
||||
"当前行为:lastErr 存在时透传上游状态码(非固定 502)")
|
||||
require.JSONEq(t,
|
||||
`{"error":{"type":"server_error","message":"All available accounts exhausted"}}`,
|
||||
rec.Body.String())
|
||||
})
|
||||
|
||||
t.Run("lastErr为空_回退502", func(t *testing.T) {
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
|
||||
|
||||
h := &GatewayHandler{}
|
||||
h.handleCCFailoverExhausted(c, nil, false)
|
||||
|
||||
require.Equal(t, http.StatusBadGateway, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"error":{"type":"server_error","message":"All available accounts exhausted"}}`,
|
||||
rec.Body.String())
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-004 并发槽不变量测试(INVARIANTS I-6.1 / I-6.2)。
|
||||
//
|
||||
// 固化内容:
|
||||
// - I-6.1 用户槽/账号槽获取-释放严格配平:
|
||||
// * 正常路径(上游 200,完整 Messages 链路);
|
||||
// * early-return 路径(预热拦截:选号后转发前直接返回);
|
||||
// * panic 路径(defer 释放用户槽 + context 取消兜底回收账号槽);
|
||||
// * wrapReleaseOnDone 恰好一次语义(显式调用 / context 取消两种触发);
|
||||
// * service 层 AcquireResult.ReleaseFunc 重复调用的当前行为特征化;
|
||||
// - I-6.2 AcquireUserSlotWithWait 等待队列:满 → 等待 → 释放 → 唤醒;
|
||||
// 等待超时返回 ConcurrencyError{IsTimeout}。
|
||||
//
|
||||
// 转发失败路径的配平已由 scheduling_invariants_failover_test.go 的完整循环用例覆盖。
|
||||
// 复用本包 schedInv* 夹具(scheduling_invariants_failover_test.go)。
|
||||
package handler
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/service"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// schedInvNewGinContext 构造仅含基础请求的 gin 上下文(供 ConcurrencyHelper 使用)。
|
||||
func schedInvNewGinContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-6.2 用户槽等待队列:满 → 等待 → 释放 → 唤醒
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_UserSlotWaitQueue_FullWaitReleaseWakeup(t *testing.T) {
|
||||
cc := schedInvNewCountingCache()
|
||||
helper := NewConcurrencyHelper(service.NewConcurrencyService(cc), SSEPingFormatNone, time.Hour)
|
||||
|
||||
const userID = int64(701)
|
||||
|
||||
// 1. 占满唯一槽位
|
||||
c1, _ := schedInvNewGinContext(t)
|
||||
var ss1 bool
|
||||
release1, err := helper.AcquireUserSlotWithWait(c1, userID, 1, false, &ss1)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, release1)
|
||||
|
||||
// 2. 第二个请求进入等待(槽位已满)
|
||||
type waitResult struct {
|
||||
release func()
|
||||
err error
|
||||
}
|
||||
done := make(chan waitResult, 1)
|
||||
go func() {
|
||||
c2, _ := schedInvNewGinContext(t)
|
||||
var ss2 bool
|
||||
release2, err2 := helper.AcquireUserSlotWithWait(c2, userID, 1, false, &ss2)
|
||||
done <- waitResult{release: release2, err: err2}
|
||||
}()
|
||||
|
||||
// 槽位未释放期间,等待者不应返回
|
||||
select {
|
||||
case r := <-done:
|
||||
t.Fatalf("槽位未释放时等待者不应获取成功: err=%v", r.err)
|
||||
case <-time.After(400 * time.Millisecond):
|
||||
}
|
||||
|
||||
// 3. 释放 → 4. 等待者被唤醒并成功获取
|
||||
release1()
|
||||
select {
|
||||
case r := <-done:
|
||||
require.NoError(t, r.err, "释放后等待者必须能获取槽位")
|
||||
require.NotNil(t, r.release)
|
||||
r.release()
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("释放槽位后等待者在 10s 内未被唤醒")
|
||||
}
|
||||
|
||||
require.Equal(t, 2, cc.userAcquired, "两次成功获取")
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// TestSchedulingInvariant_AccountSlotWait_TimeoutReturnsConcurrencyError 固化:
|
||||
// 账号槽等待超时后返回 ConcurrencyError{IsTimeout:true},且不产生未配平的获取。
|
||||
func TestSchedulingInvariant_AccountSlotWait_TimeoutReturnsConcurrencyError(t *testing.T) {
|
||||
cc := schedInvNewCountingCache()
|
||||
svc := service.NewConcurrencyService(cc)
|
||||
helper := NewConcurrencyHelper(svc, SSEPingFormatNone, time.Hour)
|
||||
|
||||
const accountID = int64(801)
|
||||
|
||||
// 占满唯一槽位
|
||||
holder, err := svc.AcquireAccountSlot(context.Background(), accountID, 1)
|
||||
require.NoError(t, err)
|
||||
require.True(t, holder.Acquired)
|
||||
|
||||
c, _ := schedInvNewGinContext(t)
|
||||
var ss bool
|
||||
release, err := helper.AcquireAccountSlotWithWaitTimeout(c, accountID, 1, 300*time.Millisecond, false, &ss)
|
||||
require.Nil(t, release)
|
||||
require.Error(t, err)
|
||||
var concurrencyErr *ConcurrencyError
|
||||
require.ErrorAs(t, err, &concurrencyErr)
|
||||
require.True(t, concurrencyErr.IsTimeout, "等待超时必须返回 IsTimeout 的 ConcurrencyError")
|
||||
require.Equal(t, "account", concurrencyErr.SlotType)
|
||||
|
||||
holder.ReleaseFunc()
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-6.1 正常路径与 early-return 路径配平(完整 Messages 链路)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_SlotBalance_NormalSuccessPath 固化:上游 200 正常完成时,
|
||||
// 账号槽与用户槽各获取/释放一次,严格配平。
|
||||
func TestSchedulingInvariant_SlotBalance_NormalSuccessPath(t *testing.T) {
|
||||
groupID := int64(9003)
|
||||
group := schedInvGroup(groupID)
|
||||
account := schedInvPassthroughAccount(9301, groupID, nil)
|
||||
|
||||
upstream := &schedInvUpstream{
|
||||
status: http.StatusOK,
|
||||
body: `{"id":"msg_sched_inv","type":"message","role":"assistant","model":"claude-sonnet-4-5",` +
|
||||
`"content":[{"type":"text","text":"ok"}],"stop_reason":"end_turn",` +
|
||||
`"usage":{"input_tokens":3,"output_tokens":2}}`,
|
||||
}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, []*service.Account{account}, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
c, rec, cancel := schedInvNewMessagesContext(t, group, schedInvMessagesBody())
|
||||
defer cancel()
|
||||
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "msg_sched_inv", "上游 200 响应体应透传给客户端")
|
||||
require.Len(t, upstream.attemptedAccounts(), 1)
|
||||
require.Equal(t, 1, cc.accountAcquired)
|
||||
require.Equal(t, 1, cc.userAcquired)
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// TestSchedulingInvariant_SlotBalance_InterceptEarlyReturnPath 固化 early-return 路径:
|
||||
// 预热拦截在选号成功后、转发上游前直接返回 mock 响应,账号槽必须被释放。
|
||||
func TestSchedulingInvariant_SlotBalance_InterceptEarlyReturnPath(t *testing.T) {
|
||||
groupID := int64(9004)
|
||||
group := schedInvGroup(groupID)
|
||||
account := schedInvPassthroughAccount(9401, groupID, map[string]any{
|
||||
"intercept_warmup_requests": true,
|
||||
})
|
||||
|
||||
upstream := &schedInvUpstream{status: http.StatusOK, body: `{}`}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, []*service.Account{account}, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
warmupBody := []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"max_tokens": 256,
|
||||
"messages": [{"role":"user","content":[{"type":"text","text":"Warmup"}]}]
|
||||
}`)
|
||||
c, rec, cancel := schedInvNewMessagesContext(t, group, warmupBody)
|
||||
defer cancel()
|
||||
|
||||
h.Messages(c)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Contains(t, rec.Body.String(), "msg_mock_warmup", "预热请求应被拦截返回 mock 响应")
|
||||
require.Empty(t, upstream.attemptedAccounts(), "early-return 路径不应触达上游")
|
||||
require.Equal(t, 1, cc.accountAcquired, "选号阶段获取过账号槽")
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-6.1 panic 路径:defer 释放用户槽 + context 取消兜底回收账号槽
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_SlotBalance_PanicPath 固化当前 panic 语义:
|
||||
// - 用户槽通过 defer userReleaseFunc() 在 panic 展开时立即释放;
|
||||
// - 账号槽的释放调用位于 Forward 之后(非 defer),panic 时依赖
|
||||
// wrapReleaseOnDone 注册的 context.AfterFunc 在请求 context 取消时兜底回收
|
||||
// (生产环境由 net/http 在连接结束时取消请求 context)。
|
||||
func TestSchedulingInvariant_SlotBalance_PanicPath(t *testing.T) {
|
||||
groupID := int64(9005)
|
||||
group := schedInvGroup(groupID)
|
||||
account := schedInvPassthroughAccount(9501, groupID, nil)
|
||||
|
||||
upstream := &schedInvUpstream{status: http.StatusOK, body: `{}`, panicOn: true}
|
||||
cc := schedInvNewCountingCache()
|
||||
h, cleanup := schedInvNewHandler(t, group, []*service.Account{account}, upstream, cc)
|
||||
defer cleanup()
|
||||
|
||||
c, _, cancel := schedInvNewMessagesContext(t, group, schedInvMessagesBody())
|
||||
defer cancel()
|
||||
|
||||
func() {
|
||||
defer func() {
|
||||
require.NotNil(t, recover(), "上游 panic 应穿透 Messages(由外层 gin recovery 兜底)")
|
||||
}()
|
||||
h.Messages(c)
|
||||
}()
|
||||
|
||||
// 用户槽:defer 在 panic 展开时已释放
|
||||
cc.mu.Lock()
|
||||
userReleased := cc.userReleased
|
||||
cc.mu.Unlock()
|
||||
require.Equal(t, 1, userReleased, "panic 展开时 defer 必须释放用户槽")
|
||||
|
||||
// 账号槽:panic 跳过了显式释放调用,由请求 context 取消兜底回收
|
||||
cancel()
|
||||
require.Eventually(t, func() bool {
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
return cc.accountReleased == cc.accountAcquired
|
||||
}, 5*time.Second, 10*time.Millisecond, "context 取消后账号槽必须被兜底回收")
|
||||
schedInvRequireBalanced(t, cc)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-6.1 wrapReleaseOnDone 恰好一次语义
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_WrapReleaseOnDone_ExactlyOnce(t *testing.T) {
|
||||
t.Run("重复显式调用只释放一次", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
release := wrapReleaseOnDone(context.Background(), func() { calls.Add(1) })
|
||||
release()
|
||||
release()
|
||||
require.Equal(t, int32(1), calls.Load(), "重复调用不得重复释放")
|
||||
})
|
||||
|
||||
t.Run("context取消自动触发释放", func(t *testing.T) {
|
||||
var calls atomic.Int32
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
release := wrapReleaseOnDone(ctx, func() { calls.Add(1) })
|
||||
cancel()
|
||||
require.Eventually(t, func() bool { return calls.Load() == 1 },
|
||||
2*time.Second, 5*time.Millisecond, "context 取消必须自动触发释放")
|
||||
// 取消后再显式调用也不重复释放
|
||||
release()
|
||||
require.Equal(t, int32(1), calls.Load())
|
||||
})
|
||||
|
||||
t.Run("nil释放函数返回nil", func(t *testing.T) {
|
||||
require.Nil(t, wrapReleaseOnDone(context.Background(), nil))
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// service 层 ReleaseFunc 重复调用特征化
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingCharacterization_ServiceReleaseFuncNotOnceGuarded 固化当前实际行为:
|
||||
// ConcurrencyService 返回的 AcquireResult.ReleaseFunc 本身没有 once 保护,
|
||||
// 重复调用会向缓存重复发出释放请求;配平依赖两点:
|
||||
// 1. 缓存释放按 requestID 幂等(Redis ZREM 不存在的成员是 no-op);
|
||||
// 2. handler 层统一经 wrapReleaseOnDone 包装后才暴露。
|
||||
func TestSchedulingCharacterization_ServiceReleaseFuncNotOnceGuarded(t *testing.T) {
|
||||
cc := schedInvNewCountingCache()
|
||||
svc := service.NewConcurrencyService(cc)
|
||||
|
||||
result, err := svc.AcquireAccountSlot(context.Background(), 901, 5)
|
||||
require.NoError(t, err)
|
||||
require.True(t, result.Acquired)
|
||||
|
||||
result.ReleaseFunc()
|
||||
result.ReleaseFunc()
|
||||
|
||||
cc.mu.Lock()
|
||||
defer cc.mu.Unlock()
|
||||
require.Equal(t, 1, cc.accountReleased, "第一次释放生效")
|
||||
require.Equal(t, 1, cc.accountReleasedUnknown,
|
||||
"当前行为:第二次释放仍会调用缓存(按 requestID 幂等,不破坏配平)")
|
||||
require.Empty(t, cc.accountHeld[901])
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-002 特征化测试:gemini/antigravity 路径流式/非流式透传(INVARIANTS I-1.6)。
|
||||
// ForwardGemini 的外部可观测语义:
|
||||
// - 流式:上游 v1internal SSE(data: {"response":{...}})被解包后逐事件转发给客户端;
|
||||
// - 非流式:上游流式响应被收集合并为单个 JSON(文本片段拼接)后返回;
|
||||
// - 非 failover 上游错误(如 404):解包后的错误体 + 上游状态码原样返回。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// passCharAntigravityService 构造最小可运行的 AntigravityGatewayService。
|
||||
func passCharAntigravityService(upstream HTTPUpstream) *AntigravityGatewayService {
|
||||
return &AntigravityGatewayService{
|
||||
settingService: NewSettingService(&antigravitySettingRepoStub{}, &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}),
|
||||
tokenProvider: &AntigravityTokenProvider{},
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
}
|
||||
|
||||
// passCharAntigravityAccount 返回带模型映射的 antigravity OAuth 账号夹具。
|
||||
func passCharAntigravityAccount(id int64, mapping map[string]any) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "pass-char-antigravity",
|
||||
Platform: PlatformAntigravity,
|
||||
Type: AccountTypeOAuth,
|
||||
Status: StatusActive,
|
||||
Concurrency: 1,
|
||||
Schedulable: true,
|
||||
Credentials: map[string]any{
|
||||
"access_token": "ag-token",
|
||||
"model_mapping": mapping,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func passCharGeminiBody(t *testing.T) []byte {
|
||||
t.Helper()
|
||||
body, err := json.Marshal(map[string]any{
|
||||
"contents": []map[string]any{
|
||||
{"role": "user", "parts": []map[string]any{{"text": "hello"}}},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
return body
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_GeminiStreamUnwrapsV1Internal 固化 I-1.6 流式侧:
|
||||
// 上游 data 行的 v1internal 包裹({"response":{...}})被解包,客户端按事件收到内层 JSON 原文。
|
||||
func TestGatewayCharacterization_GeminiStreamUnwrapsV1Internal(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
innerChunk1 := `{"candidates":[{"content":{"role":"model","parts":[{"text":"Hel"}]}}]}`
|
||||
innerChunk2 := `{"candidates":[{"content":{"role":"model","parts":[{"text":"lo ✓"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3,"cachedContentTokenCount":2}}`
|
||||
upstreamSSE := "data: {\"response\":" + innerChunk1 + "}\n\n" +
|
||||
"data: {\"response\":" + innerChunk2 + "}\n\n"
|
||||
|
||||
upstream := &httpUpstreamStub{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"rid-char-gemini-stream"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(upstreamSSE))),
|
||||
}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:streamGenerateContent", bytes.NewReader(passCharGeminiBody(t)))
|
||||
|
||||
account := passCharAntigravityAccount(9201, map[string]any{"gemini-2.5-flash": "gemini-3-pro-high"})
|
||||
|
||||
result, err := svc.ForwardGemini(context.Background(), c, account, "gemini-2.5-flash", "streamGenerateContent", true, passCharGeminiBody(t), false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
|
||||
wantEvents := []string{
|
||||
"data: " + innerChunk1,
|
||||
"data: " + innerChunk2,
|
||||
}
|
||||
require.Equal(t, wantEvents, passCharSplitSSEEvents(rec.Body.String()),
|
||||
"客户端应按事件收到解包后的内层 JSON 原文")
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-gemini-stream", rec.Header().Get("x-request-id"))
|
||||
|
||||
require.Equal(t, "gemini-2.5-flash", result.Model)
|
||||
require.Equal(t, "gemini-3-pro-high", result.UpstreamModel, "计费模型使用映射后的模型")
|
||||
require.Equal(t, 6, result.Usage.InputTokens, "input = promptTokenCount - cachedContentTokenCount")
|
||||
require.Equal(t, 3, result.Usage.OutputTokens)
|
||||
require.Equal(t, 2, result.Usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_GeminiNonStreamCollectsStream 固化 I-1.6 非流式侧:
|
||||
// 客户端请求非流式时,网关收集上游流式 chunk,将文本片段拼接进最后一个含 parts 的
|
||||
// 响应中,作为单个 JSON(HTTP 200, application/json)返回。
|
||||
func TestGatewayCharacterization_GeminiNonStreamCollectsStream(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
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":8,"candidatesTokenCount":3,"cachedContentTokenCount":2}}}` + "\n\n"
|
||||
|
||||
upstream := &httpUpstreamStub{resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"X-Request-Id": []string{"rid-char-gemini-collect"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(upstreamSSE))),
|
||||
}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(passCharGeminiBody(t)))
|
||||
|
||||
account := passCharAntigravityAccount(9202, 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, passCharGeminiBody(t), false)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.Stream)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-gemini-collect", rec.Header().Get("x-request-id"))
|
||||
|
||||
// 合并语义:以最后一个含 parts 的 chunk 为基底,第一个 text part 替换为全部文本片段的拼接。
|
||||
require.JSONEq(t, `{
|
||||
"candidates":[{
|
||||
"content":{"role":"model","parts":[{"text":"Hello ✓"}]},
|
||||
"finishReason":"STOP"
|
||||
}],
|
||||
"usageMetadata":{"promptTokenCount":8,"candidatesTokenCount":3,"cachedContentTokenCount":2}
|
||||
}`, rec.Body.String())
|
||||
|
||||
require.Equal(t, 6, result.Usage.InputTokens)
|
||||
require.Equal(t, 3, result.Usage.OutputTokens)
|
||||
require.Equal(t, 2, result.Usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_GeminiUpstreamErrorUnwrappedPassthrough 固化 I-1.6 错误侧:
|
||||
// 非 failover 上游错误(404)时,客户端收到上游状态码 + 解包后的错误体原文。
|
||||
func TestGatewayCharacterization_GeminiUpstreamErrorUnwrappedPassthrough(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
|
||||
innerErr := `{"error":{"code":404,"message":"model not found: gemini-3-pro-high","status":"NOT_FOUND"}}`
|
||||
upstream := &httpUpstreamStub{resp: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"X-Request-Id": []string{"rid-char-gemini-err"},
|
||||
},
|
||||
Body: io.NopCloser(bytes.NewReader([]byte(`{"response":` + innerErr + `}`))),
|
||||
}}
|
||||
svc := passCharAntigravityService(upstream)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1beta/models/gemini-2.5-flash:generateContent", bytes.NewReader(passCharGeminiBody(t)))
|
||||
|
||||
account := passCharAntigravityAccount(9203, 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, passCharGeminiBody(t), false)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
|
||||
require.Equal(t, http.StatusNotFound, rec.Code, "非 failover 上游错误状态码原样返回")
|
||||
require.Equal(t, innerErr, rec.Body.String(), "错误体应为解包后的内层 JSON 原文")
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-gemini-err", rec.Header().Get("x-request-id"))
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
//go:build unit
|
||||
|
||||
// TASK-003 计费精度不变量测试(INVARIANTS.md ② 计费精度)。
|
||||
//
|
||||
// 本文件覆盖纯计算层(BillingService)的不变量:
|
||||
// - I-2.2: 5m 与 1h 缓存写入差异化计价(两档金额断言 + 无明细回退 + breakdown 开关门控)
|
||||
// - I-2.4: 倍率叠加顺序 serviceTier → rateMultiplier;rateMultiplier=0 时
|
||||
// ActualCost=0 但 TotalCost 保留
|
||||
//
|
||||
// 所有期望值均为人工核算的固定值(算式在行内注释),容差 1e-10。
|
||||
// 这些是 characterization 测试:固化当前 main 上的实际行为,插件化改造期间
|
||||
// 任何金额漂移都应使其失败。
|
||||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// billInvManualPricingService 构造带手工定价表的 BillingService(无动态价格源)。
|
||||
func billInvManualPricingService(pricing *ModelPricing) *BillingService {
|
||||
return &BillingService{
|
||||
cfg: &config.Config{},
|
||||
fallbackPrices: map[string]*ModelPricing{
|
||||
"claude-sonnet-4": pricing,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_CacheTier5mVs1h 锁定 I-2.2:cache_creation 分
|
||||
// CacheCreation5mPrice / CacheCreation1hPrice 两档分别计价。
|
||||
// 定价:input $3/MTok, output $15/MTok, cache_read $0.3/MTok,
|
||||
// 5m 写入 $4/MTok, 1h 写入 $5/MTok(SupportsCacheBreakdown=true)。
|
||||
func TestBillingInvariant_CacheTier5mVs1h(t *testing.T) {
|
||||
breakdownPricing := &ModelPricing{
|
||||
InputPricePerToken: 3e-6,
|
||||
OutputPricePerToken: 15e-6,
|
||||
CacheReadPricePerToken: 0.3e-6,
|
||||
CacheCreationPricePerToken: 3.75e-6,
|
||||
SupportsCacheBreakdown: true,
|
||||
CacheCreation5mPrice: 4e-6,
|
||||
CacheCreation1hPrice: 5e-6,
|
||||
}
|
||||
noBreakdownPricing := &ModelPricing{
|
||||
InputPricePerToken: 3e-6,
|
||||
OutputPricePerToken: 15e-6,
|
||||
CacheReadPricePerToken: 0.3e-6,
|
||||
CacheCreationPricePerToken: 3.75e-6,
|
||||
SupportsCacheBreakdown: false,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pricing *ModelPricing
|
||||
tokens UsageTokens
|
||||
// 期望值(固定值 + 1e-10 容差)
|
||||
wantInput float64
|
||||
wantOutput float64
|
||||
wantCacheCreation float64
|
||||
wantCacheRead float64
|
||||
wantTotal float64
|
||||
}{
|
||||
{
|
||||
name: "5m与1h两档分别计价",
|
||||
pricing: breakdownPricing,
|
||||
tokens: UsageTokens{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
// 上游通常同时给出总量与 ephemeral 明细;有明细时按明细计价
|
||||
CacheCreationTokens: 12000,
|
||||
CacheCreation5mTokens: 8000,
|
||||
CacheCreation1hTokens: 4000,
|
||||
CacheReadTokens: 2000,
|
||||
},
|
||||
wantInput: 0.003, // 1000 × $3/MTok = 0.003
|
||||
wantOutput: 0.0075, // 500 × $15/MTok = 0.0075
|
||||
// 8000 × 4e-6 = 0.032; 4000 × 5e-6 = 0.020; 合计 0.052
|
||||
wantCacheCreation: 0.052,
|
||||
wantCacheRead: 0.0006, // 2000 × $0.3/MTok = 0.0006
|
||||
wantTotal: 0.0631, // 0.003+0.0075+0.052+0.0006
|
||||
},
|
||||
{
|
||||
name: "仅1h写入按1h单价",
|
||||
pricing: breakdownPricing,
|
||||
tokens: UsageTokens{
|
||||
CacheCreationTokens: 10000,
|
||||
CacheCreation1hTokens: 10000,
|
||||
},
|
||||
wantCacheCreation: 0.05, // 10000 × 5e-6 = 0.05
|
||||
wantTotal: 0.05,
|
||||
},
|
||||
{
|
||||
name: "无ephemeral明细时全部回退5m单价",
|
||||
pricing: breakdownPricing,
|
||||
tokens: UsageTokens{
|
||||
CacheCreationTokens: 6000, // 5m/1h 明细均为 0
|
||||
},
|
||||
wantCacheCreation: 0.024, // 6000 × 4e-6 = 0.024(回退 5m 档)
|
||||
wantTotal: 0.024,
|
||||
},
|
||||
{
|
||||
name: "breakdown关闭时按标准单价计CacheCreationTokens并忽略5m1h明细",
|
||||
pricing: noBreakdownPricing,
|
||||
tokens: UsageTokens{
|
||||
CacheCreationTokens: 6000,
|
||||
CacheCreation5mTokens: 4000,
|
||||
CacheCreation1hTokens: 2000,
|
||||
},
|
||||
wantCacheCreation: 0.0225, // 6000 × 3.75e-6 = 0.0225(明细被忽略)
|
||||
wantTotal: 0.0225,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := billInvManualPricingService(tt.pricing)
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", tt.tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, tt.wantInput, cost.InputCost, 1e-10)
|
||||
require.InDelta(t, tt.wantOutput, cost.OutputCost, 1e-10)
|
||||
require.InDelta(t, tt.wantCacheCreation, cost.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, tt.wantCacheRead, cost.CacheReadCost, 1e-10)
|
||||
require.InDelta(t, tt.wantTotal, cost.TotalCost, 1e-10)
|
||||
require.InDelta(t, tt.wantTotal, cost.ActualCost, 1e-10) // 倍率 1.0
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_CacheTierBreakdownGating 锁定 I-2.2 的动态定价门控:
|
||||
// 仅当 LiteLLM 的 1h 单价存在且严格大于 5m 单价时启用两档计费
|
||||
// (防止上游数据错误导致少收费,见 billing_service.go GetModelPricing)。
|
||||
func TestBillingInvariant_CacheTierBreakdownGating(t *testing.T) {
|
||||
tokens := UsageTokens{
|
||||
CacheCreationTokens: 1000,
|
||||
CacheCreation5mTokens: 600,
|
||||
CacheCreation1hTokens: 400,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
price5m, price1h float64
|
||||
wantBreakdown bool
|
||||
wantCacheCreation float64
|
||||
}{
|
||||
{
|
||||
name: "1h大于5m启用两档",
|
||||
price5m: 4e-6, price1h: 5e-6,
|
||||
wantBreakdown: true,
|
||||
// 600 × 4e-6 = 0.0024; 400 × 5e-6 = 0.002; 合计 0.0044
|
||||
wantCacheCreation: 0.0044,
|
||||
},
|
||||
{
|
||||
name: "1h等于5m禁用两档防少收费",
|
||||
price5m: 4e-6, price1h: 4e-6,
|
||||
wantBreakdown: false,
|
||||
// 回退标准单价:1000 × 4e-6 = 0.004
|
||||
wantCacheCreation: 0.004,
|
||||
},
|
||||
{
|
||||
name: "1h缺失禁用两档",
|
||||
price5m: 4e-6, price1h: 0,
|
||||
wantBreakdown: false,
|
||||
// 1000 × 4e-6 = 0.004
|
||||
wantCacheCreation: 0.004,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := NewBillingService(&config.Config{}, &PricingService{
|
||||
pricingData: map[string]*LiteLLMModelPricing{
|
||||
"tier-gate-model": {
|
||||
InputCostPerToken: 3e-6,
|
||||
OutputCostPerToken: 15e-6,
|
||||
CacheCreationInputTokenCost: tt.price5m,
|
||||
CacheCreationInputTokenCostAbove1hr: tt.price1h,
|
||||
CacheReadInputTokenCost: 0.3e-6,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
pricing, err := svc.GetModelPricing("tier-gate-model")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, tt.wantBreakdown, pricing.SupportsCacheBreakdown)
|
||||
|
||||
cost, err := svc.CalculateCost("tier-gate-model", tokens, 1.0)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, tt.wantCacheCreation, cost.CacheCreationCost, 1e-10)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_ServiceTierThenRateMultiplier 锁定 I-2.4 的应用顺序:
|
||||
// 各分项成本先应用 serviceTier 倍率(或 priority 显式价),TotalCost 为分项之和
|
||||
// (含 tier、不含 rateMultiplier),最后 ActualCost = TotalCost × rateMultiplier。
|
||||
func TestBillingInvariant_ServiceTierThenRateMultiplier(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
serviceTier string
|
||||
rateMultiplier float64
|
||||
tokens UsageTokens
|
||||
wantInput float64
|
||||
wantOutput float64
|
||||
wantCacheWrite float64
|
||||
wantCacheRead float64
|
||||
wantTotal float64
|
||||
wantActual float64
|
||||
}{
|
||||
{
|
||||
// gpt-5.4 fallback 价:in $2.5/MTok, out $15/MTok, cache_write $2.5/MTok,
|
||||
// cache_read $0.25/MTok;flex 无显式价 → 0.5 倍 tier multiplier。
|
||||
name: "flex_tier后再乘rateMultiplier",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: "flex",
|
||||
rateMultiplier: 1.5,
|
||||
tokens: UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheCreationTokens: 400, CacheReadTokens: 2000},
|
||||
wantInput: 0.00125, // 1000×2.5e-6=0.0025 ×0.5
|
||||
wantOutput: 0.00375, // 500×15e-6=0.0075 ×0.5
|
||||
wantCacheWrite: 0.0005, // 400×2.5e-6=0.001 ×0.5
|
||||
wantCacheRead: 0.00025, // 2000×0.25e-6=0.0005 ×0.5
|
||||
wantTotal: 0.00575, // 分项之和(含 tier、不含 rate)
|
||||
wantActual: 0.008625, // 0.00575 × 1.5
|
||||
},
|
||||
{
|
||||
// gpt-5.4 有显式 priority 价:in $5/MTok, out $30/MTok, cache_read $0.5/MTok。
|
||||
// 注意(characterization):cache_write 无 priority 价,且显式 priority 价
|
||||
// 生效时 tierMultiplier 固定 1.0,因此 cache_write 仍按基础价 $2.5/MTok
|
||||
// 计费、不做 2 倍上浮——与下方"无显式价回退 2 倍"的行为不同。
|
||||
name: "priority显式价后再乘rateMultiplier",
|
||||
model: "gpt-5.4",
|
||||
serviceTier: "priority",
|
||||
rateMultiplier: 2.0,
|
||||
tokens: UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheCreationTokens: 1000, CacheReadTokens: 2000},
|
||||
wantInput: 0.005, // 1000 × 5e-6(priority 价)
|
||||
wantOutput: 0.015, // 500 × 30e-6(priority 价)
|
||||
wantCacheWrite: 0.0025, // 1000 × 2.5e-6(基础价,无 priority 上浮)
|
||||
wantCacheRead: 0.001, // 2000 × 0.5e-6(priority 价)
|
||||
wantTotal: 0.0235,
|
||||
wantActual: 0.047, // 0.0235 × 2.0
|
||||
},
|
||||
{
|
||||
// claude-sonnet-4 fallback 无 priority 显式价 → 回退 2.0 倍 tier multiplier,
|
||||
// 此路径下 cache_write 也参与 2 倍上浮。
|
||||
name: "priority无显式价回退2倍tier后再乘rateMultiplier",
|
||||
model: "claude-sonnet-4",
|
||||
serviceTier: "priority",
|
||||
rateMultiplier: 1.5,
|
||||
tokens: UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheCreationTokens: 2000, CacheReadTokens: 3000},
|
||||
wantInput: 0.006, // 1000×3e-6=0.003 ×2
|
||||
wantOutput: 0.015, // 500×15e-6=0.0075 ×2
|
||||
wantCacheWrite: 0.015, // 2000×3.75e-6=0.0075 ×2
|
||||
wantCacheRead: 0.0018, // 3000×0.3e-6=0.0009 ×2
|
||||
wantTotal: 0.0378,
|
||||
wantActual: 0.0567, // 0.0378 × 1.5
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
cost, err := svc.CalculateCostWithServiceTier(tt.model, tt.tokens, tt.rateMultiplier, tt.serviceTier)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, tt.wantInput, cost.InputCost, 1e-10)
|
||||
require.InDelta(t, tt.wantOutput, cost.OutputCost, 1e-10)
|
||||
require.InDelta(t, tt.wantCacheWrite, cost.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, tt.wantCacheRead, cost.CacheReadCost, 1e-10)
|
||||
require.InDelta(t, tt.wantTotal, cost.TotalCost, 1e-10)
|
||||
require.InDelta(t, tt.wantActual, cost.ActualCost, 1e-10)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_ZeroAndNegativeRateMultiplier 锁定 I-2.4:
|
||||
// rateMultiplier=0(免费账号)时 ActualCost=0 但 TotalCost 保留;
|
||||
// 负数倍率按 0 处理(防缓存/迁移残留导致按 1x 误扣)。
|
||||
func TestBillingInvariant_ZeroAndNegativeRateMultiplier(t *testing.T) {
|
||||
svc := newTestBillingService()
|
||||
tokens := UsageTokens{InputTokens: 1000, OutputTokens: 500, CacheCreationTokens: 2000, CacheReadTokens: 3000}
|
||||
// claude-sonnet-4: 0.003 + 0.0075 + 2000×3.75e-6(=0.0075) + 3000×0.3e-6(=0.0009) = 0.0189
|
||||
const wantTotal = 0.0189
|
||||
|
||||
for _, multiplier := range []float64{0, -2} {
|
||||
cost, err := svc.CalculateCost("claude-sonnet-4", tokens, multiplier)
|
||||
require.NoError(t, err)
|
||||
require.InDelta(t, wantTotal, cost.TotalCost, 1e-10, "multiplier=%v 时 TotalCost 应保留原始费用", multiplier)
|
||||
require.InDelta(t, 0.0, cost.ActualCost, 1e-10, "multiplier=%v 时 ActualCost 应为 0", multiplier)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,546 @@
|
||||
//go:build unit
|
||||
|
||||
// TASK-003 计费/配额端到端不变量测试(INVARIANTS.md ②③)。
|
||||
//
|
||||
// 本文件通过 GatewayService.RecordUsage 走完整后扣链路,锁定:
|
||||
// - I-2.6: 完整 usage 输入 → 最终扣费金额 + 余额扣减/订阅用量增量二选一分支
|
||||
// (含 image 计价路径与 ImageOutputTokens 独立计价,呼应 I-2.3 端到端联动)
|
||||
// - I-2.2: 5m/1h 两档缓存写入的端到端金额
|
||||
// - I-3.3: API Key quota_used 增量金额(统一命令路径 + legacy 直写路径)
|
||||
// - I-3.4: Account 级配额增量金额 = TotalCost × AccountRateMultiplier
|
||||
// (注意:用 TotalCost 而非 ActualCost,分组倍率不影响账号配额消耗)
|
||||
//
|
||||
// 断言对象为 UsageBillingRepository.Apply 收到的 UsageBillingCommand(生产原子
|
||||
// 扣费路径的唯一输入)与 UsageLog 金额字段。所有期望值均人工核算(算式见注释)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// billInvAccountRepoStub 捕获 legacy 路径下的账号配额增量。
|
||||
type billInvAccountRepoStub struct {
|
||||
AccountRepository
|
||||
|
||||
quotaIncrCalls int
|
||||
lastQuotaAmount float64
|
||||
}
|
||||
|
||||
func (s *billInvAccountRepoStub) IncrementQuotaUsed(ctx context.Context, id int64, amount float64) error {
|
||||
s.quotaIncrCalls++
|
||||
s.lastQuotaAmount = amount
|
||||
return nil
|
||||
}
|
||||
|
||||
// billInvSubRepoStub 捕获 legacy 路径下的订阅用量增量金额。
|
||||
type billInvSubRepoStub struct {
|
||||
UserSubscriptionRepository
|
||||
|
||||
incrementCalls int
|
||||
lastCost float64
|
||||
}
|
||||
|
||||
func (s *billInvSubRepoStub) IncrementUsage(ctx context.Context, id int64, costUSD float64) error {
|
||||
s.incrementCalls++
|
||||
s.lastCost = costUSD
|
||||
return nil
|
||||
}
|
||||
|
||||
func billInvF64Ptr(v float64) *float64 { return &v }
|
||||
|
||||
// billInvNewGatewayService 构造带可注入 BillingService / 计费仓储的网关服务。
|
||||
func billInvNewGatewayService(
|
||||
billingSvc *BillingService,
|
||||
accountRepo AccountRepository,
|
||||
usageRepo UsageLogRepository,
|
||||
billingRepo UsageBillingRepository,
|
||||
userRepo UserRepository,
|
||||
subRepo UserSubscriptionRepository,
|
||||
) *GatewayService {
|
||||
cfg := &config.Config{}
|
||||
cfg.Default.RateMultiplier = 1.0
|
||||
if billingSvc == nil {
|
||||
billingSvc = NewBillingService(cfg, nil)
|
||||
}
|
||||
svc := NewGatewayService(
|
||||
accountRepo,
|
||||
nil, // groupRepo
|
||||
usageRepo,
|
||||
billingRepo,
|
||||
userRepo,
|
||||
subRepo,
|
||||
nil, // userGroupRateRepo
|
||||
nil, // cache
|
||||
cfg,
|
||||
nil, // schedulerSnapshot
|
||||
nil, // concurrencyService
|
||||
billingSvc,
|
||||
nil, // rateLimitService
|
||||
&BillingCacheService{},
|
||||
nil, // identityService
|
||||
nil, // httpUpstream
|
||||
&DeferredService{},
|
||||
nil, // claudeTokenProvider
|
||||
nil, // sessionLimitCache
|
||||
nil, // rpmCache
|
||||
nil, // digestStore
|
||||
nil, // settingService
|
||||
nil, // tlsFPProfileService
|
||||
nil, // channelService
|
||||
nil, // resolver
|
||||
nil, // balanceNotifyService
|
||||
nil, // userPlatformQuotaRepo
|
||||
)
|
||||
return svc
|
||||
}
|
||||
|
||||
// TestBillingInvariant_EndToEndUsageBillingCommand 表驱动锁定 I-2.6 / I-3.3 / I-3.4:
|
||||
// 每行 = (定价/分组倍率/账号倍率/usage 输入) → (UsageBillingCommand 各项金额 + UsageLog 金额)。
|
||||
func TestBillingInvariant_EndToEndUsageBillingCommand(t *testing.T) {
|
||||
groupID := int64(11)
|
||||
type expected struct {
|
||||
balanceCost float64
|
||||
subscriptionCost float64
|
||||
apiKeyQuotaCost float64
|
||||
apiKeyRateLimitCost float64
|
||||
accountQuotaCost float64
|
||||
subscriptionID *int64
|
||||
logTotal float64
|
||||
logActual float64
|
||||
billingType int8
|
||||
}
|
||||
|
||||
// 公共 usage:claude-sonnet-4 fallback 价(in $3/MTok, out $15/MTok,
|
||||
// cache_write $3.75/MTok, cache_read $0.3/MTok)。
|
||||
// TotalCost = 1000×3e-6 + 500×15e-6 + 2000×3.75e-6 + 3000×0.3e-6
|
||||
// = 0.003 + 0.0075 + 0.0075 + 0.0009 = 0.0189
|
||||
fourDimUsage := ClaudeUsage{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
CacheCreationInputTokens: 2000,
|
||||
CacheReadInputTokens: 3000,
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
pricingData map[string]*LiteLLMModelPricing // 可选:动态定价
|
||||
model string
|
||||
usage ClaudeUsage
|
||||
imageCount int
|
||||
imageSize string
|
||||
group *Group
|
||||
account *Account
|
||||
apiKeyQuota float64
|
||||
rateLimit5h float64
|
||||
sub *UserSubscription
|
||||
want expected
|
||||
}{
|
||||
{
|
||||
name: "余额模式_四维token组合计价",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
// API Key Quota=100 → quota 增量 = ActualCost
|
||||
apiKeyQuota: 100,
|
||||
want: expected{
|
||||
balanceCost: 0.0189, // ActualCost = 0.0189 × 1.0
|
||||
apiKeyQuotaCost: 0.0189, // I-3.3: = ActualCost
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0189,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "余额模式_分组倍率2x只影响Actual不影响Total",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 2.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
apiKeyQuota: 100,
|
||||
want: expected{
|
||||
balanceCost: 0.0378, // 0.0189 × 2.0
|
||||
apiKeyQuotaCost: 0.0378, // I-3.3: 跟随 ActualCost
|
||||
logTotal: 0.0189, // TotalCost 不含分组倍率
|
||||
logActual: 0.0378,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "订阅模式_订阅用量增量替代余额扣减",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{
|
||||
ID: groupID, Platform: PlatformAnthropic,
|
||||
RateMultiplier: 1.5, SubscriptionType: SubscriptionTypeSubscription,
|
||||
},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
sub: &UserSubscription{ID: 42},
|
||||
want: expected{
|
||||
balanceCost: 0, // 二选一:订阅模式不扣余额
|
||||
subscriptionCost: 0.02835, // 0.0189 × 1.5
|
||||
subscriptionID: i64p(42),
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.02835,
|
||||
billingType: BillingTypeSubscription,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "订阅模式_免费分组倍率0时订阅增量为0但Total保留",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{
|
||||
ID: groupID, Platform: PlatformAnthropic,
|
||||
RateMultiplier: 0, SubscriptionType: SubscriptionTypeSubscription,
|
||||
},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
sub: &UserSubscription{ID: 42},
|
||||
want: expected{
|
||||
balanceCost: 0,
|
||||
subscriptionCost: 0, // ActualCost = 0
|
||||
subscriptionID: i64p(42), // TotalCost > 0 仍记录订阅归属
|
||||
logTotal: 0.0189,
|
||||
logActual: 0,
|
||||
billingType: BillingTypeSubscription,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "账号配额增量用TotalCost乘账号倍率而非ActualCost",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 2.0},
|
||||
account: &Account{
|
||||
ID: 702, Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"quota_limit": 100.0},
|
||||
RateMultiplier: billInvF64Ptr(0.5),
|
||||
},
|
||||
want: expected{
|
||||
balanceCost: 0.0378, // 余额按分组倍率 2.0
|
||||
accountQuotaCost: 0.00945, // I-3.4: 0.0189(Total) × 0.5(账号倍率)
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0378,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "OAuth账号不计账号配额增量",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0},
|
||||
account: &Account{
|
||||
ID: 703, Type: AccountTypeOAuth,
|
||||
Extra: map[string]any{"quota_limit": 100.0},
|
||||
},
|
||||
want: expected{
|
||||
balanceCost: 0.0189,
|
||||
accountQuotaCost: 0, // 仅 apikey/bedrock 账号计配额
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0189,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "APIKey无限额时quota增量为0",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
apiKeyQuota: 0, // Quota=0 表示不限额
|
||||
want: expected{
|
||||
balanceCost: 0.0189,
|
||||
apiKeyQuotaCost: 0,
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0189,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "APIKey限速窗口增量等于ActualCost",
|
||||
model: "claude-sonnet-4",
|
||||
usage: fourDimUsage,
|
||||
group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
rateLimit5h: 10,
|
||||
want: expected{
|
||||
balanceCost: 0.0189,
|
||||
apiKeyRateLimitCost: 0.0189,
|
||||
logTotal: 0.0189,
|
||||
logActual: 0.0189,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "图片按张计价_分组2K价格",
|
||||
model: "gemini-3-pro-image",
|
||||
imageCount: 2,
|
||||
imageSize: "2K",
|
||||
group: &Group{
|
||||
ID: groupID, Platform: PlatformGemini, RateMultiplier: 1.0,
|
||||
ImagePrice2K: billInvF64Ptr(0.19),
|
||||
},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
want: expected{
|
||||
balanceCost: 0.38, // 2 × $0.19
|
||||
logTotal: 0.38,
|
||||
logActual: 0.38,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "图片独立倍率只影响Actual",
|
||||
model: "gemini-3-pro-image",
|
||||
imageCount: 2,
|
||||
imageSize: "2K",
|
||||
group: &Group{
|
||||
ID: groupID, Platform: PlatformGemini, RateMultiplier: 1.0,
|
||||
ImagePrice2K: billInvF64Ptr(0.19),
|
||||
ImageRateIndependent: true,
|
||||
ImageRateMultiplier: 2.0,
|
||||
},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
want: expected{
|
||||
balanceCost: 0.76, // 0.38 × 2.0(图片独立倍率)
|
||||
logTotal: 0.38,
|
||||
logActual: 0.76,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ImageOutputTokens按独立单价从输出中拆分计价",
|
||||
pricingData: map[string]*LiteLLMModelPricing{
|
||||
"img-token-model": {
|
||||
InputCostPerToken: 3e-6,
|
||||
OutputCostPerToken: 15e-6,
|
||||
OutputCostPerImageToken: 30e-6,
|
||||
},
|
||||
},
|
||||
model: "img-token-model",
|
||||
usage: ClaudeUsage{
|
||||
InputTokens: 100,
|
||||
OutputTokens: 200, // 其中 50 为图片输出 token
|
||||
ImageOutputTokens: 50,
|
||||
},
|
||||
group: &Group{ID: groupID, Platform: PlatformGemini, RateMultiplier: 1.0},
|
||||
account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
want: expected{
|
||||
// input: 100×3e-6 = 0.0003
|
||||
// 文本输出: (200-50)×15e-6 = 0.00225
|
||||
// 图片输出: 50×30e-6 = 0.0015
|
||||
// Total = 0.00405
|
||||
balanceCost: 0.00405,
|
||||
logTotal: 0.00405,
|
||||
logActual: 0.00405,
|
||||
billingType: BillingTypeBalance,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
|
||||
var billingSvc *BillingService
|
||||
if tt.pricingData != nil {
|
||||
cfg := &config.Config{}
|
||||
billingSvc = NewBillingService(cfg, &PricingService{pricingData: tt.pricingData})
|
||||
}
|
||||
svc := billInvNewGatewayService(billingSvc, nil, usageRepo, billingRepo,
|
||||
&openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{})
|
||||
quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{}
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "bill-inv-" + tt.name,
|
||||
Usage: tt.usage,
|
||||
Model: tt.model,
|
||||
ImageCount: tt.imageCount,
|
||||
ImageSize: tt.imageSize,
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 501,
|
||||
Quota: tt.apiKeyQuota,
|
||||
RateLimit5h: tt.rateLimit5h,
|
||||
GroupID: i64p(tt.group.ID),
|
||||
Group: tt.group,
|
||||
},
|
||||
User: &User{ID: 601},
|
||||
Account: tt.account,
|
||||
Subscription: tt.sub,
|
||||
APIKeyService: quotaSvc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
cmd := billingRepo.lastCmd
|
||||
require.NotNil(t, cmd, "应走统一计费命令路径")
|
||||
require.InDelta(t, tt.want.balanceCost, cmd.BalanceCost, 1e-10, "BalanceCost")
|
||||
require.InDelta(t, tt.want.subscriptionCost, cmd.SubscriptionCost, 1e-10, "SubscriptionCost")
|
||||
require.InDelta(t, tt.want.apiKeyQuotaCost, cmd.APIKeyQuotaCost, 1e-10, "APIKeyQuotaCost")
|
||||
require.InDelta(t, tt.want.apiKeyRateLimitCost, cmd.APIKeyRateLimitCost, 1e-10, "APIKeyRateLimitCost")
|
||||
require.InDelta(t, tt.want.accountQuotaCost, cmd.AccountQuotaCost, 1e-10, "AccountQuotaCost")
|
||||
if tt.want.subscriptionID != nil {
|
||||
require.NotNil(t, cmd.SubscriptionID)
|
||||
require.Equal(t, *tt.want.subscriptionID, *cmd.SubscriptionID)
|
||||
} else {
|
||||
require.Nil(t, cmd.SubscriptionID)
|
||||
}
|
||||
|
||||
log := usageRepo.lastLog
|
||||
require.NotNil(t, log)
|
||||
require.InDelta(t, tt.want.logTotal, log.TotalCost, 1e-10, "UsageLog.TotalCost")
|
||||
require.InDelta(t, tt.want.logActual, log.ActualCost, 1e-10, "UsageLog.ActualCost")
|
||||
require.Equal(t, tt.want.billingType, log.BillingType)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_CacheTier5mVs1hEndToEnd 锁定 I-2.2 端到端:上游返回的
|
||||
// 5m/1h ephemeral 明细流经 RecordUsage 后按两档单价分别计费(PR3061 漂移点②)。
|
||||
func TestBillingInvariant_CacheTier5mVs1hEndToEnd(t *testing.T) {
|
||||
cfg := &config.Config{}
|
||||
billingSvc := NewBillingService(cfg, &PricingService{
|
||||
pricingData: map[string]*LiteLLMModelPricing{
|
||||
"claude-sonnet-4": {
|
||||
InputCostPerToken: 3e-6,
|
||||
OutputCostPerToken: 15e-6,
|
||||
CacheCreationInputTokenCost: 3.75e-6, // 5m 档
|
||||
CacheCreationInputTokenCostAbove1hr: 6e-6, // 1h 档(> 5m → 启用两档)
|
||||
CacheReadInputTokenCost: 0.3e-6,
|
||||
},
|
||||
},
|
||||
})
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
billingRepo := &openAIRecordUsageBillingRepoStub{result: &UsageBillingApplyResult{Applied: true}}
|
||||
svc := billInvNewGatewayService(billingSvc, nil, usageRepo, billingRepo,
|
||||
&openAIRecordUsageUserRepoStub{}, &openAIRecordUsageSubRepoStub{})
|
||||
|
||||
groupID := int64(11)
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "bill-inv-cache-tier-e2e",
|
||||
Usage: ClaudeUsage{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
CacheCreationInputTokens: 12000, // 总量 = 5m + 1h
|
||||
CacheCreation5mTokens: 8000,
|
||||
CacheCreation1hTokens: 4000,
|
||||
CacheReadInputTokens: 2000,
|
||||
},
|
||||
Model: "claude-sonnet-4",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{ID: 501, GroupID: i64p(groupID), Group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 1.0}},
|
||||
User: &User{ID: 601},
|
||||
Account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// cache_creation = 8000×3.75e-6 + 4000×6e-6 = 0.03 + 0.024 = 0.054
|
||||
// Total = 1000×3e-6 + 500×15e-6 + 0.054 + 2000×0.3e-6
|
||||
// = 0.003 + 0.0075 + 0.054 + 0.0006 = 0.0651
|
||||
log := usageRepo.lastLog
|
||||
require.NotNil(t, log)
|
||||
require.InDelta(t, 0.054, log.CacheCreationCost, 1e-10)
|
||||
require.InDelta(t, 0.0651, log.TotalCost, 1e-10)
|
||||
require.InDelta(t, 0.0651, log.ActualCost, 1e-10)
|
||||
|
||||
require.NotNil(t, billingRepo.lastCmd)
|
||||
require.InDelta(t, 0.0651, billingRepo.lastCmd.BalanceCost, 1e-10)
|
||||
}
|
||||
|
||||
// TestBillingInvariant_LegacyPathIncrements 锁定 legacy 兜底路径(统一计费仓储
|
||||
// 不可用时)的各级增量金额与生产命令路径一致:
|
||||
// - 余额扣减 = ActualCost(I-2.6)
|
||||
// - API Key quota_used 增量 = ActualCost(I-3.3)
|
||||
// - 账号配额增量 = TotalCost × AccountRateMultiplier(I-3.4)
|
||||
// - 订阅模式:IncrementUsage(ActualCost),不扣余额(I-2.6 二选一)
|
||||
func TestBillingInvariant_LegacyPathIncrements(t *testing.T) {
|
||||
groupID := int64(11)
|
||||
fourDimUsage := ClaudeUsage{
|
||||
InputTokens: 1000,
|
||||
OutputTokens: 500,
|
||||
CacheCreationInputTokens: 2000,
|
||||
CacheReadInputTokens: 3000,
|
||||
}
|
||||
|
||||
t.Run("余额模式各级增量", func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &billInvSubRepoStub{}
|
||||
accountRepo := &billInvAccountRepoStub{}
|
||||
quotaSvc := &openAIRecordUsageAPIKeyQuotaStub{}
|
||||
// billingRepo == nil → applyUsageBilling 回退 postUsageBilling 直写
|
||||
svc := billInvNewGatewayService(nil, accountRepo, usageRepo, nil, userRepo, subRepo)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "bill-inv-legacy-balance",
|
||||
Usage: fourDimUsage,
|
||||
Model: "claude-sonnet-4",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 501, Quota: 100,
|
||||
GroupID: i64p(groupID),
|
||||
Group: &Group{ID: groupID, Platform: PlatformAnthropic, RateMultiplier: 2.0},
|
||||
},
|
||||
User: &User{ID: 601},
|
||||
Account: &Account{
|
||||
ID: 702, Type: AccountTypeAPIKey,
|
||||
Extra: map[string]any{"quota_limit": 100.0},
|
||||
RateMultiplier: billInvF64Ptr(0.5),
|
||||
},
|
||||
APIKeyService: quotaSvc,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// ActualCost = 0.0189 × 2.0 = 0.0378
|
||||
require.Equal(t, 1, userRepo.deductCalls)
|
||||
require.InDelta(t, 0.0378, userRepo.lastAmount, 1e-10, "余额扣减 = ActualCost")
|
||||
require.Equal(t, 1, quotaSvc.quotaCalls)
|
||||
require.InDelta(t, 0.0378, quotaSvc.lastAmount, 1e-10, "API Key quota 增量 = ActualCost")
|
||||
// 账号配额 = TotalCost(0.0189) × 账号倍率(0.5) = 0.00945
|
||||
require.Equal(t, 1, accountRepo.quotaIncrCalls)
|
||||
require.InDelta(t, 0.00945, accountRepo.lastQuotaAmount, 1e-10, "账号配额增量 = Total × 账号倍率")
|
||||
// 余额模式不应触发订阅增量
|
||||
require.Equal(t, 0, subRepo.incrementCalls)
|
||||
})
|
||||
|
||||
t.Run("订阅模式增量替代余额扣减", func(t *testing.T) {
|
||||
usageRepo := &openAIRecordUsageLogRepoStub{inserted: true}
|
||||
userRepo := &openAIRecordUsageUserRepoStub{}
|
||||
subRepo := &billInvSubRepoStub{}
|
||||
svc := billInvNewGatewayService(nil, &billInvAccountRepoStub{}, usageRepo, nil, userRepo, subRepo)
|
||||
|
||||
err := svc.RecordUsage(context.Background(), &RecordUsageInput{
|
||||
Result: &ForwardResult{
|
||||
RequestID: "bill-inv-legacy-subscription",
|
||||
Usage: fourDimUsage,
|
||||
Model: "claude-sonnet-4",
|
||||
Duration: time.Second,
|
||||
},
|
||||
APIKey: &APIKey{
|
||||
ID: 501,
|
||||
GroupID: i64p(groupID),
|
||||
Group: &Group{
|
||||
ID: groupID, Platform: PlatformAnthropic,
|
||||
RateMultiplier: 1.5, SubscriptionType: SubscriptionTypeSubscription,
|
||||
},
|
||||
},
|
||||
User: &User{ID: 601},
|
||||
Account: &Account{ID: 701, Type: AccountTypeOAuth},
|
||||
Subscription: &UserSubscription{ID: 42},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// 订阅增量 = ActualCost = 0.0189 × 1.5 = 0.02835;余额不扣
|
||||
require.Equal(t, 1, subRepo.incrementCalls)
|
||||
require.InDelta(t, 0.02835, subRepo.lastCost, 1e-10, "订阅用量增量 = ActualCost")
|
||||
require.Equal(t, 0, userRepo.deductCalls, "订阅模式不应扣余额(二选一)")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
//go:build unit
|
||||
|
||||
// TASK-003 overages 不变量测试(INVARIANTS.md I-2.5,PR3061 漂移点①)。
|
||||
//
|
||||
// overages(AI Credits 超量请求)由 accounts.extra["allow_overages"] 控制,仅
|
||||
// antigravity 平台生效。本文件锁定:
|
||||
// - 开关解析的允许/拒绝语义(平台门控 + 字段缺失/类型错误按拒绝处理)
|
||||
// - 拒绝路径:上游 429 quota_exhausted 时不注入 enabledCreditTypes、不发起
|
||||
// credits 重试(开关关闭 / 积分已耗尽两种拒绝原因)
|
||||
//
|
||||
// 允许路径(注入 credits 并继续请求)已由 antigravity_credits_overages_test.go
|
||||
// 覆盖。超额部分的"计价"没有独立分支:credits 重试成功后的响应走与普通请求
|
||||
// 完全相同的 RecordUsage 计费链路(由本任务 I-2.6 端到端测试锁定金额),因此
|
||||
// 这里只需锁定允许/拒绝语义。
|
||||
package service
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// TestBillingInvariant_OveragesFlagSemantics 锁定 allow_overages 开关解析语义。
|
||||
func TestBillingInvariant_OveragesFlagSemantics(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
account Account
|
||||
want bool
|
||||
}{
|
||||
{
|
||||
name: "antigravity平台显式true允许",
|
||||
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"allow_overages": true}},
|
||||
want: true,
|
||||
},
|
||||
{
|
||||
name: "antigravity平台显式false拒绝",
|
||||
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"allow_overages": false}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "字段缺失默认拒绝",
|
||||
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "Extra为nil默认拒绝",
|
||||
account: Account{Platform: PlatformAntigravity},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "非bool类型按拒绝处理",
|
||||
account: Account{Platform: PlatformAntigravity, Extra: map[string]any{"allow_overages": "true"}},
|
||||
want: false,
|
||||
},
|
||||
{
|
||||
name: "非antigravity平台即使为true也拒绝",
|
||||
account: Account{Platform: PlatformAnthropic, Extra: map[string]any{"allow_overages": true}},
|
||||
want: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
require.Equal(t, tt.want, tt.account.IsOveragesEnabled())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_OveragesDeniedNoCreditsInjection 锁定拒绝路径:
|
||||
// 上游返回 429 quota_exhausted 时,若 overages 关闭或积分已耗尽,
|
||||
// handleSmartRetry 不得注入 enabledCreditTypes 发起 credits 重试,
|
||||
// 而是落入默认重试逻辑(smartRetryActionContinue)。
|
||||
func TestBillingInvariant_OveragesDeniedNoCreditsInjection(t *testing.T) {
|
||||
quotaExhaustedBody := []byte(`{"error":{"status":"RESOURCE_EXHAUSTED","message":"QUOTA_EXHAUSTED"}}`)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
extra map[string]any
|
||||
}{
|
||||
{
|
||||
name: "开关关闭时不注入credits",
|
||||
extra: map[string]any{}, // allow_overages 缺失 → 拒绝
|
||||
},
|
||||
{
|
||||
name: "积分已耗尽时不注入credits",
|
||||
extra: map[string]any{
|
||||
"allow_overages": true,
|
||||
modelRateLimitsKey: map[string]any{
|
||||
creditsExhaustedKey: map[string]any{
|
||||
"rate_limited_at": time.Now().UTC().Format(time.RFC3339),
|
||||
"rate_limit_reset_at": time.Now().Add(5 * time.Hour).UTC().Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &mockSmartRetryUpstream{}
|
||||
account := &Account{
|
||||
ID: 201,
|
||||
Name: "acc-201",
|
||||
Type: AccountTypeOAuth,
|
||||
Platform: PlatformAntigravity,
|
||||
Extra: tt.extra,
|
||||
}
|
||||
resp := &http.Response{
|
||||
StatusCode: http.StatusTooManyRequests,
|
||||
Header: http.Header{},
|
||||
Body: io.NopCloser(bytes.NewReader(quotaExhaustedBody)),
|
||||
}
|
||||
params := antigravityRetryLoopParams{
|
||||
ctx: context.Background(),
|
||||
prefix: "[bill-inv]",
|
||||
account: account,
|
||||
accessToken: "token",
|
||||
action: "generateContent",
|
||||
body: []byte(`{"model":"claude-sonnet-4-5","request":{}}`),
|
||||
httpUpstream: upstream,
|
||||
accountRepo: &stubAntigravityAccountRepo{},
|
||||
requestedModel: "claude-sonnet-4-5",
|
||||
handleError: func(ctx context.Context, prefix string, account *Account, statusCode int, headers http.Header, body []byte, requestedModel string, groupID int64, sessionHash string, isStickySession bool) *handleModelRateLimitResult {
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
svc := &AntigravityGatewayService{}
|
||||
result := svc.handleSmartRetry(params, resp, quotaExhaustedBody, "https://ag-1.test", 0, []string{"https://ag-1.test"})
|
||||
|
||||
require.NotNil(t, result)
|
||||
require.Equal(t, smartRetryActionContinue, result.action, "拒绝 overages 后应落入默认重试逻辑")
|
||||
require.Empty(t, upstream.requestBodies, "不得发起 credits 注入重试请求")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
//go:build unit
|
||||
|
||||
// TASK-003 preflight 配额/余额拒绝不变量测试(INVARIANTS.md I-3.6)。
|
||||
//
|
||||
// CheckBillingEligibility 是网关入口的统一计费资格检查;handler 在
|
||||
// AcquireUserSlotWithWait 等待结束后会用同一函数做"二次检查"
|
||||
// (internal/handler/gateway_handler.go:255),因此这里锁定其无状态语义:
|
||||
// - 余额模式:余额 <= 0 → ErrInsufficientBalance
|
||||
// - 订阅模式:日/周/月用量达到分组限额 → ErrDaily/Weekly/MonthlyLimitExceeded;
|
||||
// 订阅过期或非 active → ErrSubscriptionInvalid
|
||||
// - user×platform 配额:日限额耗尽 → ErrUserPlatformDailyQuotaExhausted(429);
|
||||
// 订阅模式豁免该检查
|
||||
// - 并发等待后二次检查:第一次放行后用量/余额变化,再次调用即拒绝
|
||||
// - simple 运行模式跳过所有计费检查
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// billInvBillingCacheStub 只实现 CheckBillingEligibility 路径会触达的读方法,
|
||||
// 其余方法走嵌入接口(不会被调用)。
|
||||
type billInvBillingCacheStub struct {
|
||||
BillingCache
|
||||
|
||||
balance float64
|
||||
sub *SubscriptionCacheData
|
||||
quotaEntry *UserPlatformQuotaCacheEntry
|
||||
}
|
||||
|
||||
func (s *billInvBillingCacheStub) GetUserBalance(ctx context.Context, userID int64) (float64, error) {
|
||||
return s.balance, nil
|
||||
}
|
||||
|
||||
func (s *billInvBillingCacheStub) GetSubscriptionCache(ctx context.Context, userID, groupID int64) (*SubscriptionCacheData, error) {
|
||||
return s.sub, nil
|
||||
}
|
||||
|
||||
func (s *billInvBillingCacheStub) GetUserPlatformQuotaCache(ctx context.Context, userID int64, platform string) (*UserPlatformQuotaCacheEntry, bool, error) {
|
||||
if s.quotaEntry == nil {
|
||||
return nil, false, nil
|
||||
}
|
||||
return s.quotaEntry, true, nil
|
||||
}
|
||||
|
||||
// billInvQuotaRepoStub 仅用于让 userPlatformQuotaRepo 非 nil(cache HIT 路径不
|
||||
// 会触达 DB),嵌入接口的其余方法不会被调用。
|
||||
type billInvQuotaRepoStub struct {
|
||||
UserPlatformQuotaRepository
|
||||
}
|
||||
|
||||
func (s *billInvQuotaRepoStub) GetByUserPlatform(ctx context.Context, userID int64, platform string) (*UserPlatformQuotaRecord, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func billInvNewBillingCacheService(t *testing.T, cache BillingCache, cfg *config.Config) *BillingCacheService {
|
||||
t.Helper()
|
||||
if cfg == nil {
|
||||
cfg = &config.Config{}
|
||||
}
|
||||
svc := NewBillingCacheService(cache, nil, nil, nil, nil, nil, cfg, &billInvQuotaRepoStub{})
|
||||
t.Cleanup(svc.Stop)
|
||||
return svc
|
||||
}
|
||||
|
||||
// billInvQuotaEntryV1 构造当前窗口内的 SchemaV1 user×platform 配额缓存条目。
|
||||
func billInvQuotaEntryV1(dailyLimit, dailyUsage float64) *UserPlatformQuotaCacheEntry {
|
||||
now := time.Now()
|
||||
return &UserPlatformQuotaCacheEntry{
|
||||
SchemaVersion: UserPlatformQuotaCacheSchemaV1,
|
||||
DailyLimitUSD: &dailyLimit,
|
||||
DailyUsageUSD: dailyUsage,
|
||||
DailyWindowStart: &now,
|
||||
WeeklyWindowStart: &now,
|
||||
MonthlyWindowStart: &now,
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_PreflightBalanceEligibility 锁定余额模式 preflight 语义。
|
||||
func TestBillingInvariant_PreflightBalanceEligibility(t *testing.T) {
|
||||
user := &User{ID: 601}
|
||||
|
||||
t.Run("余额耗尽拒绝", func(t *testing.T) {
|
||||
svc := billInvNewBillingCacheService(t, &billInvBillingCacheStub{balance: 0}, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, "")
|
||||
require.ErrorIs(t, err, ErrInsufficientBalance)
|
||||
})
|
||||
|
||||
t.Run("余额为正放行", func(t *testing.T) {
|
||||
svc := billInvNewBillingCacheService(t, &billInvBillingCacheStub{balance: 5.0}, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, "")
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("并发等待后二次检查反映最新余额", func(t *testing.T) {
|
||||
cache := &billInvBillingCacheStub{balance: 0.01}
|
||||
svc := billInvNewBillingCacheService(t, cache, nil)
|
||||
|
||||
// 第一次检查(获取并发槽前):余额尚存 → 放行
|
||||
require.NoError(t, svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, ""))
|
||||
|
||||
// 等待期间其他请求把余额扣到 0 → 等待结束后的二次检查必须拒绝
|
||||
cache.balance = 0
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, "")
|
||||
require.ErrorIs(t, err, ErrInsufficientBalance)
|
||||
})
|
||||
}
|
||||
|
||||
// TestBillingInvariant_PreflightSubscriptionLimits 锁定订阅模式 preflight 语义:
|
||||
// 日/周/月任一窗口用量达到分组限额即拒绝;订阅非 active 或已过期拒绝。
|
||||
func TestBillingInvariant_PreflightSubscriptionLimits(t *testing.T) {
|
||||
user := &User{ID: 601}
|
||||
subscription := &UserSubscription{ID: 42}
|
||||
group := &Group{
|
||||
ID: 7,
|
||||
SubscriptionType: SubscriptionTypeSubscription,
|
||||
DailyLimitUSD: billInvF64Ptr(10),
|
||||
WeeklyLimitUSD: billInvF64Ptr(50),
|
||||
MonthlyLimitUSD: billInvF64Ptr(100),
|
||||
}
|
||||
activeFuture := time.Now().Add(24 * time.Hour)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
sub *SubscriptionCacheData
|
||||
wantErr error
|
||||
}{
|
||||
{
|
||||
name: "限额内放行",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: activeFuture, DailyUsage: 9.99, WeeklyUsage: 49.99, MonthlyUsage: 99.99},
|
||||
wantErr: nil,
|
||||
},
|
||||
{
|
||||
name: "日限额达到拒绝",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: activeFuture, DailyUsage: 10},
|
||||
wantErr: ErrDailyLimitExceeded,
|
||||
},
|
||||
{
|
||||
name: "周限额达到拒绝",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: activeFuture, WeeklyUsage: 50},
|
||||
wantErr: ErrWeeklyLimitExceeded,
|
||||
},
|
||||
{
|
||||
name: "月限额达到拒绝",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: activeFuture, MonthlyUsage: 100},
|
||||
wantErr: ErrMonthlyLimitExceeded,
|
||||
},
|
||||
{
|
||||
name: "订阅过期拒绝",
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: time.Now().Add(-time.Minute)},
|
||||
wantErr: ErrSubscriptionInvalid,
|
||||
},
|
||||
{
|
||||
name: "订阅非active拒绝",
|
||||
sub: &SubscriptionCacheData{Status: "cancelled", ExpiresAt: activeFuture},
|
||||
wantErr: ErrSubscriptionInvalid,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
svc := billInvNewBillingCacheService(t, &billInvBillingCacheStub{sub: tt.sub}, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, group, subscription, "")
|
||||
if tt.wantErr == nil {
|
||||
require.NoError(t, err)
|
||||
} else {
|
||||
require.ErrorIs(t, err, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestBillingInvariant_PreflightUserPlatformQuota 锁定 user×platform 配额的
|
||||
// preflight 语义:余额模式下日限额耗尽 → 429 拒绝;订阅模式豁免该检查。
|
||||
func TestBillingInvariant_PreflightUserPlatformQuota(t *testing.T) {
|
||||
user := &User{ID: 601}
|
||||
|
||||
t.Run("日配额耗尽拒绝", func(t *testing.T) {
|
||||
cache := &billInvBillingCacheStub{
|
||||
balance: 5.0, // 余额充足,确保拒绝来自 platform quota
|
||||
quotaEntry: billInvQuotaEntryV1(5.0, 5.0),
|
||||
}
|
||||
svc := billInvNewBillingCacheService(t, cache, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, PlatformAnthropic)
|
||||
require.ErrorIs(t, err, ErrUserPlatformDailyQuotaExhausted)
|
||||
})
|
||||
|
||||
t.Run("日配额未满放行", func(t *testing.T) {
|
||||
cache := &billInvBillingCacheStub{
|
||||
balance: 5.0,
|
||||
quotaEntry: billInvQuotaEntryV1(5.0, 4.99),
|
||||
}
|
||||
svc := billInvNewBillingCacheService(t, cache, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, nil, nil, PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
|
||||
t.Run("订阅模式豁免platform配额检查", func(t *testing.T) {
|
||||
group := &Group{
|
||||
ID: 7,
|
||||
SubscriptionType: SubscriptionTypeSubscription,
|
||||
DailyLimitUSD: billInvF64Ptr(10),
|
||||
}
|
||||
cache := &billInvBillingCacheStub{
|
||||
sub: &SubscriptionCacheData{Status: SubscriptionStatusActive, ExpiresAt: time.Now().Add(24 * time.Hour)},
|
||||
quotaEntry: billInvQuotaEntryV1(5.0, 999), // platform 配额早已超限
|
||||
}
|
||||
svc := billInvNewBillingCacheService(t, cache, nil)
|
||||
err := svc.CheckBillingEligibility(context.Background(), user, nil, group, &UserSubscription{ID: 42}, PlatformAnthropic)
|
||||
require.NoError(t, err, "订阅模式下 user×platform 配额不应生效")
|
||||
})
|
||||
}
|
||||
|
||||
// TestBillingInvariant_PreflightSimpleModeBypass 锁定 simple 运行模式跳过所有
|
||||
// 计费检查(余额为 0 也放行)。
|
||||
func TestBillingInvariant_PreflightSimpleModeBypass(t *testing.T) {
|
||||
cfg := &config.Config{RunMode: config.RunModeSimple}
|
||||
svc := billInvNewBillingCacheService(t, &billInvBillingCacheStub{balance: 0}, cfg)
|
||||
err := svc.CheckBillingEligibility(context.Background(), &User{ID: 601}, nil, nil, nil, PlatformAnthropic)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-005 热路径基准:覆盖 anthropic API Key 透传账号的完整
|
||||
// Forward 路径(非流式 + 流式 SSE)。与 scripts/bench-baseline.sh 配套使用,
|
||||
// 基线存于 testdata/bench/baseline.txt;对比策略:allocs/op 严格(不允许增加)、
|
||||
// ns/op 宽松(容忍 15% 抖动)。复用 TASK-002 特征化测试的夹具(passChar* 前缀)。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
func benchFwdRun(b *testing.B, header http.Header, upstreamBody string, reqBody string, stream bool) {
|
||||
b.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
// 透传分支每请求有一条 stdlib log,静音以降低基准 I/O 噪声
|
||||
// (日志成本在 baseline 与 compare 两侧同等消除,不影响对比有效性)。
|
||||
prevLogOut := log.Writer()
|
||||
log.SetOutput(io.Discard)
|
||||
b.Cleanup(func() { log.SetOutput(prevLogOut) })
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
account := passCharAnthropicAccount(9100)
|
||||
|
||||
b.ReportAllocs()
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: header.Clone(),
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
},
|
||||
}
|
||||
svc := passCharGatewayService(cfg, upstream)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(reqBody)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
Stream: stream,
|
||||
}
|
||||
if _, err := svc.Forward(context.Background(), c, account, parsed); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGatewayForward_AnthropicNonStreamPassthrough(b *testing.B) {
|
||||
upstreamJSON := `{"id":"msg_bench_01","type":"message","content":[{"type":"text","text":"hello"}],` +
|
||||
`"usage":{"input_tokens":12,"output_tokens":7,"cache_read_input_tokens":3}}`
|
||||
benchFwdRun(b,
|
||||
http.Header{"Content-Type": []string{"application/json"}},
|
||||
upstreamJSON,
|
||||
`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
func BenchmarkGatewayForward_AnthropicStreamPassthrough(b *testing.B) {
|
||||
events := []string{
|
||||
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_bench_02\",\"usage\":{\"input_tokens\":9,\"cached_tokens\":3}}}",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"lo\"}}",
|
||||
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}",
|
||||
"event: message_stop\ndata: {\"type\":\"message_stop\"}",
|
||||
}
|
||||
benchFwdRun(b,
|
||||
http.Header{"Content-Type": []string{"text/event-stream"}},
|
||||
strings.Join(events, "\n\n")+"\n\n",
|
||||
`{"model":"claude-sonnet-4-20250514","stream":true,"messages":[{"role":"user","content":"hi"}]}`,
|
||||
true,
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
//go:build unit
|
||||
|
||||
// 本文件是 Phase-0 TASK-002 的特征化测试(characterization tests)。
|
||||
// 它把"客户端最终收到的内容"固化为基线:固定 mock 上游,逐字节/逐事件断言
|
||||
// 状态码、响应体与响应头。任何后续重构若改变这些外部可观测行为,测试会立即失败。
|
||||
//
|
||||
// 覆盖的不变量(见 .claude/plugin-refactor/INVARIANTS.md):
|
||||
// - I-1.1 anthropic 非流式 2xx body/状态码逐字节透传(升级自 JSONEq 级断言)
|
||||
// - I-1.4 响应 header 白名单过滤(默认白名单 / additional_allowed / force_remove)
|
||||
// - I-1.7 上游 4xx/5xx 错误按 ErrorPassthroughService 规则透传或转换
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/Wei-Shaw/sub2api/internal/model"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// passCharSplitSSEEvents 按 SSE 事件边界(空行 "\n\n")分帧。
|
||||
// 特征化测试只按事件比对,不按网络 chunk 比对;帧首尾多余的换行
|
||||
// (合法的 SSE 分帧差异,如额外空行)会被剥离,但帧内内容保持原文。
|
||||
func passCharSplitSSEEvents(body string) []string {
|
||||
frames := strings.Split(body, "\n\n")
|
||||
out := make([]string, 0, len(frames))
|
||||
for _, f := range frames {
|
||||
f = strings.Trim(f, "\r\n")
|
||||
if f == "" {
|
||||
continue
|
||||
}
|
||||
out = append(out, f)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// passCharAnthropicAccount 返回 anthropic API Key 透传账号夹具。
|
||||
func passCharAnthropicAccount(id int64) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "pass-char-anthropic",
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "upstream-anthropic-key",
|
||||
"base_url": "https://api.anthropic.com",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"anthropic_passthrough": true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// passCharGinContext 构造带 /v1/messages POST 请求的 gin 测试上下文。
|
||||
func passCharGinContext(t *testing.T, path string) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, path, nil)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
func passCharGatewayService(cfg *config.Config, upstream HTTPUpstream) *GatewayService {
|
||||
return &GatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
rateLimitService: &RateLimitService{},
|
||||
deferredService: &DeferredService{},
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_AnthropicNonStreamPassthroughByteExact 固化 I-1.1:
|
||||
// anthropic 透传账号非流式转发时,上游 2xx 响应体逐字节透传、状态码透传。
|
||||
// 上游 body 故意包含字段顺序、多余空白、Unicode、HTML 字符与未知字段,
|
||||
// 任何 JSON 重新序列化都会破坏逐字节相等。
|
||||
func TestGatewayCharacterization_AnthropicNonStreamPassthroughByteExact(t *testing.T) {
|
||||
upstreamJSON := "{\"id\":\"msg_char_01\", \"type\":\"message\",\n" +
|
||||
"\"unknown_field\":{\"nested\":[1,2.50,\"<&>\"]},\n" +
|
||||
"\"content\":[{\"type\":\"text\",\"text\":\"héllo ✓ <b>&</b>\"}],\n" +
|
||||
"\"usage\":{\"input_tokens\":12,\"output_tokens\":7}}"
|
||||
|
||||
for _, status := range []int{http.StatusOK, http.StatusAccepted} {
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: status,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json; charset=utf-8"},
|
||||
"x-request-id": []string{"rid-char-nonstream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamJSON)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharGatewayService(cfg, upstream)
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9001), parsed)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
|
||||
require.Equal(t, status, rec.Code, "上游 2xx 状态码必须透传")
|
||||
require.Equal(t, upstreamJSON, rec.Body.String(), "上游 2xx body 必须逐字节透传")
|
||||
require.Equal(t, "application/json; charset=utf-8", rec.Header().Get("Content-Type"), "Content-Type 必须透传")
|
||||
require.Equal(t, "rid-char-nonstream", rec.Header().Get("x-request-id"), "x-request-id 必须透传")
|
||||
require.Equal(t, 12, result.Usage.InputTokens)
|
||||
require.Equal(t, 7, result.Usage.OutputTokens)
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_AnthropicStreamPassthroughEventSequence 固化 I-1.1/I-1.2 流式侧:
|
||||
// anthropic 透传账号流式转发时,客户端收到的 SSE 事件序列(按 \n\n 分帧)与上游完全一致,
|
||||
// 含 event: 行、usage 事件与 cached_tokens 兼容字段,且网关不改写事件内容。
|
||||
func TestGatewayCharacterization_AnthropicStreamPassthroughEventSequence(t *testing.T) {
|
||||
upstreamEvents := []string{
|
||||
"event: message_start\ndata: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_char_02\",\"usage\":{\"input_tokens\":9,\"cached_tokens\":3}}}",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel\"}}",
|
||||
"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"lo ✓\"}}",
|
||||
"event: message_delta\ndata: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"end_turn\"},\"usage\":{\"output_tokens\":5}}",
|
||||
"event: message_stop\ndata: {\"type\":\"message_stop\"}",
|
||||
}
|
||||
upstreamSSE := strings.Join(upstreamEvents, "\n\n") + "\n\n"
|
||||
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"x-request-id": []string{"rid-char-stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharGatewayService(cfg, upstream)
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","stream":true,"messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
Stream: true,
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9002), parsed)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
|
||||
gotEvents := passCharSplitSSEEvents(rec.Body.String())
|
||||
require.Equal(t, upstreamEvents, gotEvents, "客户端收到的 SSE 事件序列必须与上游逐事件一致")
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-stream", rec.Header().Get("x-request-id"))
|
||||
require.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
|
||||
require.Equal(t, "no", rec.Header().Get("X-Accel-Buffering"))
|
||||
|
||||
require.Equal(t, 9, result.Usage.InputTokens)
|
||||
require.Equal(t, 5, result.Usage.OutputTokens)
|
||||
require.Equal(t, 3, result.Usage.CacheReadInputTokens, "cached_tokens 应被解析进计费 usage")
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_ResponseHeaderFilter 固化 I-1.4:
|
||||
// 响应 header 白名单过滤语义。
|
||||
// - 关闭(security.response_headers.enabled=false,默认):只用内置白名单,
|
||||
// additional_allowed / force_remove 均不生效;
|
||||
// - 开启:additional_allowed 追加放行,force_remove 即使是默认白名单键也强制移除。
|
||||
func TestGatewayCharacterization_ResponseHeaderFilter(t *testing.T) {
|
||||
upstreamHeaders := func() http.Header {
|
||||
return http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"Cache-Control": []string{"no-store"},
|
||||
"x-request-id": []string{"rid-char-headers"},
|
||||
"X-Ratelimit-Remaining-Requests": []string{"99"},
|
||||
"Retry-After": []string{"17"},
|
||||
"Www-Authenticate": []string{"Bearer realm=api"},
|
||||
"Set-Cookie": []string{"secret=upstream"},
|
||||
"X-Custom-Upstream": []string{"custom-value"},
|
||||
}
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
headersCfg config.ResponseHeaderConfig
|
||||
wantPresent map[string]string
|
||||
wantAbsentKeys []string
|
||||
}{
|
||||
{
|
||||
name: "默认关闭_只用内置白名单且additional与force_remove不生效",
|
||||
headersCfg: config.ResponseHeaderConfig{
|
||||
Enabled: false,
|
||||
AdditionalAllowed: []string{"x-custom-upstream"}, // 关闭时不生效
|
||||
ForceRemove: []string{"x-request-id"}, // 关闭时不生效
|
||||
},
|
||||
wantPresent: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"Cache-Control": "no-store",
|
||||
"x-request-id": "rid-char-headers",
|
||||
"X-Ratelimit-Remaining-Requests": "99",
|
||||
"Retry-After": "17",
|
||||
"Www-Authenticate": "Bearer realm=api",
|
||||
},
|
||||
wantAbsentKeys: []string{"Set-Cookie", "X-Custom-Upstream"},
|
||||
},
|
||||
{
|
||||
name: "开启_additional追加放行且force_remove覆盖默认白名单",
|
||||
headersCfg: config.ResponseHeaderConfig{
|
||||
Enabled: true,
|
||||
AdditionalAllowed: []string{"x-custom-upstream"},
|
||||
ForceRemove: []string{"x-request-id"},
|
||||
},
|
||||
wantPresent: map[string]string{
|
||||
"Content-Type": "application/json",
|
||||
"X-Custom-Upstream": "custom-value",
|
||||
"Retry-After": "17",
|
||||
},
|
||||
wantAbsentKeys: []string{"Set-Cookie", "x-request-id"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: upstreamHeaders(),
|
||||
Body: io.NopCloser(strings.NewReader(`{"id":"msg_h","usage":{"input_tokens":1,"output_tokens":1}}`)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
cfg.Security.ResponseHeaders = tt.headersCfg
|
||||
svc := passCharGatewayService(cfg, upstream)
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}
|
||||
|
||||
_, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9003), parsed)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
|
||||
for key, want := range tt.wantPresent {
|
||||
require.Equal(t, want, rec.Header().Get(key), "响应头 %s 应保留", key)
|
||||
}
|
||||
for _, key := range tt.wantAbsentKeys {
|
||||
require.Empty(t, rec.Header().Get(key), "响应头 %s 应被过滤", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func passCharIntPtr(v int) *int { return &v }
|
||||
func passCharStrPtr(v string) *string { return &v }
|
||||
|
||||
// passCharErrorPassthroughService 构造带固定规则的错误透传服务(不连缓存)。
|
||||
func passCharErrorPassthroughService(rules []*model.ErrorPassthroughRule) *ErrorPassthroughService {
|
||||
return NewErrorPassthroughService(&mockErrorPassthroughRepo{rules: rules}, nil)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_UpstreamErrorPassthroughRules 固化 I-1.7:
|
||||
// 上游非 failover 错误(如 404)经 ErrorPassthroughService.MatchRule 决定透传或转换:
|
||||
// - 规则命中 + passthrough_code/passthrough_body:透传上游状态码 + 提取的上游 message;
|
||||
// - 规则命中 + 自定义 response_code/custom_message:使用自定义值;
|
||||
// - 规则未命中:默认 502 + "Upstream request failed"。
|
||||
//
|
||||
// 三种情况下错误体均为 {"type":"error","error":{"type":"upstream_error","message":...}}。
|
||||
func TestGatewayCharacterization_UpstreamErrorPassthroughRules(t *testing.T) {
|
||||
passthroughRule := &model.ErrorPassthroughRule{
|
||||
ID: 1,
|
||||
Name: "404-keyword-passthrough",
|
||||
Enabled: true,
|
||||
Priority: 1,
|
||||
ErrorCodes: []int{404},
|
||||
Keywords: []string{"quota_exceeded_marker"},
|
||||
MatchMode: model.MatchModeAll,
|
||||
Platforms: []string{model.PlatformAnthropic},
|
||||
PassthroughCode: true,
|
||||
PassthroughBody: true,
|
||||
}
|
||||
overrideRule := &model.ErrorPassthroughRule{
|
||||
ID: 2,
|
||||
Name: "404-keyword-override",
|
||||
Enabled: true,
|
||||
Priority: 1,
|
||||
ErrorCodes: []int{404},
|
||||
Keywords: []string{"quota_exceeded_marker"},
|
||||
MatchMode: model.MatchModeAll,
|
||||
Platforms: []string{model.PlatformAnthropic},
|
||||
PassthroughCode: false,
|
||||
ResponseCode: passCharIntPtr(http.StatusTooManyRequests),
|
||||
PassthroughBody: false,
|
||||
CustomMessage: passCharStrPtr("custom upstream busy"),
|
||||
}
|
||||
|
||||
upstreamHitBody := `{"type":"error","error":{"type":"not_found_error","message":"quota_exceeded_marker: model not found"}}`
|
||||
upstreamMissBody := `{"type":"error","error":{"type":"not_found_error","message":"plain not found"}}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
rules []*model.ErrorPassthroughRule
|
||||
upstreamBody string
|
||||
wantStatus int
|
||||
wantMessage string
|
||||
}{
|
||||
{
|
||||
name: "规则命中_透传上游状态码与消息",
|
||||
rules: []*model.ErrorPassthroughRule{passthroughRule},
|
||||
upstreamBody: upstreamHitBody,
|
||||
wantStatus: http.StatusNotFound,
|
||||
wantMessage: "quota_exceeded_marker: model not found",
|
||||
},
|
||||
{
|
||||
name: "规则命中_自定义状态码与消息",
|
||||
rules: []*model.ErrorPassthroughRule{overrideRule},
|
||||
upstreamBody: upstreamHitBody,
|
||||
wantStatus: http.StatusTooManyRequests,
|
||||
wantMessage: "custom upstream busy",
|
||||
},
|
||||
{
|
||||
name: "规则未命中_默认502转换",
|
||||
rules: []*model.ErrorPassthroughRule{passthroughRule},
|
||||
upstreamBody: upstreamMissBody,
|
||||
wantStatus: http.StatusBadGateway,
|
||||
wantMessage: "Upstream request failed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusNotFound,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(tt.upstreamBody)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := &GatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
BindErrorPassthroughService(c, passCharErrorPassthroughService(tt.rules))
|
||||
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9004), parsed)
|
||||
require.Error(t, err, "上游错误时 Forward 必须返回 error(响应已写给客户端)")
|
||||
require.Nil(t, result)
|
||||
|
||||
require.Equal(t, tt.wantStatus, rec.Code)
|
||||
require.JSONEq(t,
|
||||
`{"type":"error","error":{"type":"upstream_error","message":"`+tt.wantMessage+`"}}`,
|
||||
rec.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_Upstream400BodyPassthrough 固化 handleErrorResponse 对 400 的
|
||||
// 特殊语义:未命中透传规则时,上游 400 响应体原样透传给客户端(状态码 400 + 原 body)。
|
||||
func TestGatewayCharacterization_Upstream400BodyPassthrough(t *testing.T) {
|
||||
upstreamBody := `{"type":"error","error":{"type":"invalid_request_error","message":"max_tokens: required"}}`
|
||||
upstream := &anthropicHTTPUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamBody)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := &GatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
|
||||
c, rec := passCharGinContext(t, "/v1/messages")
|
||||
parsed := &ParsedRequest{
|
||||
Body: NewRequestBodyRef([]byte(`{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"hi"}]}`)),
|
||||
Model: "claude-sonnet-4-20250514",
|
||||
}
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharAnthropicAccount(9005), parsed)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code)
|
||||
require.Equal(t, upstreamBody, rec.Body.String(), "上游 400 body 应原样透传")
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-002 特征化测试:OpenAI 路径流式/非流式透传(INVARIANTS I-1.5)。
|
||||
// 固定 mock 上游,断言客户端最终收到的字节/SSE 事件序列/状态码/响应头。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/config"
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// passCharOpenAIAccount 返回 OpenAI API Key 自动透传账号夹具。
|
||||
func passCharOpenAIAccount(id int64) *Account {
|
||||
return &Account{
|
||||
ID: id,
|
||||
Name: "pass-char-openai",
|
||||
Platform: PlatformOpenAI,
|
||||
Type: AccountTypeAPIKey,
|
||||
Concurrency: 1,
|
||||
Credentials: map[string]any{
|
||||
"api_key": "sk-upstream-openai",
|
||||
"base_url": "https://upstream.example.com",
|
||||
},
|
||||
Extra: map[string]any{
|
||||
"use_responses_api": true,
|
||||
"openai_passthrough": true,
|
||||
},
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
func passCharOpenAIService(cfg *config.Config, upstream HTTPUpstream) *OpenAIGatewayService {
|
||||
return &OpenAIGatewayService{
|
||||
cfg: cfg,
|
||||
responseHeaderFilter: compileResponseHeaderFilter(cfg),
|
||||
httpUpstream: upstream,
|
||||
}
|
||||
}
|
||||
|
||||
func passCharOpenAIGinContext(t *testing.T) (*gin.Context, *httptest.ResponseRecorder) {
|
||||
t.Helper()
|
||||
gin.SetMode(gin.TestMode)
|
||||
rec := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(rec)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/openai/v1/responses", nil)
|
||||
SetOpenAIClientTransport(c, OpenAIClientTransportHTTP)
|
||||
return c, rec
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_OpenAIStreamPassthrough 固化 I-1.5 流式侧:
|
||||
// OpenAI 透传账号流式转发时,客户端收到的 SSE 内容与上游逐事件一致(按 \n\n 分帧),
|
||||
// 包含 event: 行、preamble 事件(response.created)与终止事件(response.completed/[DONE])。
|
||||
func TestGatewayCharacterization_OpenAIStreamPassthrough(t *testing.T) {
|
||||
upstreamEvents := []string{
|
||||
"event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_char_1\"}}",
|
||||
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"Hel\"}",
|
||||
"event: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"delta\":\"lo ✓\"}",
|
||||
"event: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_char_1\",\"usage\":{\"input_tokens\":11,\"output_tokens\":4,\"input_tokens_details\":{\"cached_tokens\":2}}}}",
|
||||
"data: [DONE]",
|
||||
}
|
||||
upstreamSSE := strings.Join(upstreamEvents, "\n\n") + "\n\n"
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"text/event-stream"},
|
||||
"x-request-id": []string{"rid-char-openai-stream"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamSSE)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharOpenAIService(cfg, upstream)
|
||||
|
||||
c, rec := passCharOpenAIGinContext(t)
|
||||
body := []byte(`{"model":"gpt-5","stream":true,"instructions":"be brief","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharOpenAIAccount(9101), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.True(t, result.Stream)
|
||||
|
||||
gotEvents := passCharSplitSSEEvents(rec.Body.String())
|
||||
require.Equal(t, upstreamEvents, gotEvents, "客户端收到的 SSE 事件序列必须与上游逐事件一致")
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, "text/event-stream", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-openai-stream", rec.Header().Get("x-request-id"))
|
||||
require.Equal(t, "no-cache", rec.Header().Get("Cache-Control"))
|
||||
|
||||
// 透传模式下请求侧只替换认证:上游应收到原始 body + Bearer 上游 key,不残留入站鉴权。
|
||||
require.NotNil(t, upstream.lastReq)
|
||||
require.Equal(t, string(body), string(upstream.lastBody), "透传模式上游请求体应与客户端原始 body 一致")
|
||||
require.Equal(t, "Bearer sk-upstream-openai", upstream.lastReq.Header.Get("Authorization"))
|
||||
|
||||
require.Equal(t, 11, result.Usage.InputTokens)
|
||||
require.Equal(t, 4, result.Usage.OutputTokens)
|
||||
require.Equal(t, 2, result.Usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_OpenAINonStreamPassthroughByteExact 固化 I-1.5 非流式侧:
|
||||
// 上游 2xx JSON 响应体逐字节透传、状态码/Content-Type 透传,x-codex-* 配额头强制放行。
|
||||
func TestGatewayCharacterization_OpenAINonStreamPassthroughByteExact(t *testing.T) {
|
||||
upstreamJSON := "{\"id\":\"resp_char_2\", \"object\":\"response\",\n" +
|
||||
"\"output\":[{\"type\":\"message\",\"content\":[{\"type\":\"output_text\",\"text\":\"héllo <&> ✓\"}]}],\n" +
|
||||
"\"usage\":{\"input_tokens\":21,\"output_tokens\":8,\"input_tokens_details\":{\"cached_tokens\":5}}}"
|
||||
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusOK,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"x-request-id": []string{"rid-char-openai-nonstream"},
|
||||
"Set-Cookie": []string{"secret=upstream"},
|
||||
"X-Codex-Primary-Used-Percent": []string{"42.5"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamJSON)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharOpenAIService(cfg, upstream)
|
||||
|
||||
c, rec := passCharOpenAIGinContext(t)
|
||||
body := []byte(`{"model":"gpt-5","stream":false,"instructions":"be brief","input":[{"type":"message","role":"user","content":[{"type":"input_text","text":"hi"}]}]}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharOpenAIAccount(9102), body)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
require.False(t, result.Stream)
|
||||
|
||||
require.Equal(t, http.StatusOK, rec.Code)
|
||||
require.Equal(t, upstreamJSON, rec.Body.String(), "上游 2xx body 必须逐字节透传")
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
require.Equal(t, "rid-char-openai-nonstream", rec.Header().Get("x-request-id"))
|
||||
require.Equal(t, "42.5", rec.Header().Get("X-Codex-Primary-Used-Percent"), "x-codex-* 配额头透传模式强制放行")
|
||||
require.Empty(t, rec.Header().Get("Set-Cookie"), "Set-Cookie 应被响应头过滤移除")
|
||||
|
||||
require.Equal(t, 21, result.Usage.InputTokens)
|
||||
require.Equal(t, 8, result.Usage.OutputTokens)
|
||||
require.Equal(t, 5, result.Usage.CacheReadInputTokens)
|
||||
}
|
||||
|
||||
// TestGatewayCharacterization_OpenAIPassthroughUpstreamErrorBodyVerbatim 固化 OpenAI 透传
|
||||
// 模式的错误语义:非容量类 4xx(如 400)保持原样代理——上游状态码 + 原始错误体透传,
|
||||
// Forward 返回 error(供 handler 记日志,但响应已写完)。
|
||||
func TestGatewayCharacterization_OpenAIPassthroughUpstreamErrorBodyVerbatim(t *testing.T) {
|
||||
upstreamErrJSON := `{"error":{"type":"invalid_request_error","message":"Unsupported parameter: 'foo'","param":"foo"}}`
|
||||
upstream := &httpUpstreamRecorder{
|
||||
resp: &http.Response{
|
||||
StatusCode: http.StatusBadRequest,
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/json"},
|
||||
"x-request-id": []string{"rid-char-openai-err"},
|
||||
},
|
||||
Body: io.NopCloser(strings.NewReader(upstreamErrJSON)),
|
||||
},
|
||||
}
|
||||
cfg := &config.Config{Gateway: config.GatewayConfig{MaxLineSize: defaultMaxLineSize}}
|
||||
svc := passCharOpenAIService(cfg, upstream)
|
||||
|
||||
c, rec := passCharOpenAIGinContext(t)
|
||||
body := []byte(`{"model":"gpt-5","stream":false,"instructions":"be brief","input":"hi","foo":1}`)
|
||||
|
||||
result, err := svc.Forward(context.Background(), c, passCharOpenAIAccount(9103), body)
|
||||
require.Error(t, err)
|
||||
require.Nil(t, result)
|
||||
|
||||
require.Equal(t, http.StatusBadRequest, rec.Code, "透传模式上游错误状态码原样代理")
|
||||
require.Equal(t, upstreamErrJSON, rec.Body.String(), "透传模式上游错误体逐字节透传")
|
||||
require.Equal(t, "application/json", rec.Header().Get("Content-Type"))
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
//go:build unit
|
||||
|
||||
// Phase-0 TASK-004 调度不变量测试(INVARIANTS I-4.1 / I-4.2 / I-4.3 / I-4.5)。
|
||||
//
|
||||
// 本文件固化粘性会话与选号过滤的外部可观测行为:
|
||||
// - I-4.1 绑定后二次请求命中同账号;session hash 输入优先级
|
||||
// (metadata.user_id → cacheable content → IP+UA+APIKeyID+system+messages);
|
||||
// - I-4.2 粘性绑定 TTL = 1 小时(常量引用 + 硬编码双断言 + miniredis 过期语义);
|
||||
// - I-4.3 sticky_escape:粘性账号不可用时能逃逸并重选成功(只断言硬语义,
|
||||
// 不断言触发阈值细节);
|
||||
// - I-4.5 账号级模型映射白名单(model_mapping)与渠道模型映射/定价限制对选号的影响。
|
||||
//
|
||||
// 断言纪律:不断言选号的具体排序结果(负载感知排序属可演化策略),
|
||||
// 只断言"命中/逃逸/排除/拒绝"等硬语义。
|
||||
//
|
||||
// 复用同包既有夹具:mockAccountRepoForPlatform / mockGroupRepoForGateway /
|
||||
// mockConcurrencyCache(gateway_multiplatform_test.go)、newTestChannelService /
|
||||
// makeStandardRepo(channel_service_test.go)、mustParseSessionHashRequest /
|
||||
// anthropicSessionBody / msg(generate_session_hash_test.go)。
|
||||
// 本文件新增的包级辅助类型/函数一律带 schedInv 前缀。
|
||||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wei-Shaw/sub2api/internal/pkg/ctxkey"
|
||||
|
||||
"github.com/alicebob/miniredis/v2"
|
||||
"github.com/redis/go-redis/v9"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// schedInv 夹具
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// schedInvSetCall 记录一次 SetSessionAccountID / RefreshSessionTTL 调用。
|
||||
type schedInvSetCall struct {
|
||||
groupID int64
|
||||
hash string
|
||||
accountID int64
|
||||
ttl time.Duration
|
||||
}
|
||||
|
||||
// schedInvGatewayCache 是 GatewayCache 的内存实现,记录 TTL 与删除调用。
|
||||
type schedInvGatewayCache struct {
|
||||
mu sync.Mutex
|
||||
bindings map[string]int64
|
||||
setCalls []schedInvSetCall
|
||||
refreshCalls []schedInvSetCall
|
||||
deleteCalls []string
|
||||
}
|
||||
|
||||
var _ GatewayCache = (*schedInvGatewayCache)(nil)
|
||||
|
||||
func schedInvNewGatewayCache() *schedInvGatewayCache {
|
||||
return &schedInvGatewayCache{bindings: make(map[string]int64)}
|
||||
}
|
||||
|
||||
func schedInvCacheKey(groupID int64, hash string) string {
|
||||
return fmt.Sprintf("%d:%s", groupID, hash)
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) GetSessionAccountID(_ context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if id, ok := c.bindings[schedInvCacheKey(groupID, sessionHash)]; ok {
|
||||
return id, nil
|
||||
}
|
||||
return 0, errors.New("schedInv: session binding not found")
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) SetSessionAccountID(_ context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.bindings[schedInvCacheKey(groupID, sessionHash)] = accountID
|
||||
c.setCalls = append(c.setCalls, schedInvSetCall{groupID: groupID, hash: sessionHash, accountID: accountID, ttl: ttl})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) RefreshSessionTTL(_ context.Context, groupID int64, sessionHash string, ttl time.Duration) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
c.refreshCalls = append(c.refreshCalls, schedInvSetCall{groupID: groupID, hash: sessionHash, ttl: ttl})
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) DeleteSessionAccountID(_ context.Context, groupID int64, sessionHash string) error {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
delete(c.bindings, schedInvCacheKey(groupID, sessionHash))
|
||||
c.deleteCalls = append(c.deleteCalls, schedInvCacheKey(groupID, sessionHash))
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *schedInvGatewayCache) binding(groupID int64, hash string) int64 {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
return c.bindings[schedInvCacheKey(groupID, hash)]
|
||||
}
|
||||
|
||||
// schedInvRedisGatewayCache 是 miniredis 后端的 GatewayCache,
|
||||
// 与 repository/gateway_cache.go 的键语义一致,用于验证 TTL 过期行为。
|
||||
type schedInvRedisGatewayCache struct {
|
||||
rdb *redis.Client
|
||||
}
|
||||
|
||||
var _ GatewayCache = (*schedInvRedisGatewayCache)(nil)
|
||||
|
||||
func (c *schedInvRedisGatewayCache) key(groupID int64, hash string) string {
|
||||
return fmt.Sprintf("sticky_session:%d:%s", groupID, hash)
|
||||
}
|
||||
|
||||
func (c *schedInvRedisGatewayCache) GetSessionAccountID(ctx context.Context, groupID int64, sessionHash string) (int64, error) {
|
||||
return c.rdb.Get(ctx, c.key(groupID, sessionHash)).Int64()
|
||||
}
|
||||
|
||||
func (c *schedInvRedisGatewayCache) SetSessionAccountID(ctx context.Context, groupID int64, sessionHash string, accountID int64, ttl time.Duration) error {
|
||||
return c.rdb.Set(ctx, c.key(groupID, sessionHash), accountID, ttl).Err()
|
||||
}
|
||||
|
||||
func (c *schedInvRedisGatewayCache) RefreshSessionTTL(ctx context.Context, groupID int64, sessionHash string, ttl time.Duration) error {
|
||||
return c.rdb.Expire(ctx, c.key(groupID, sessionHash), ttl).Err()
|
||||
}
|
||||
|
||||
func (c *schedInvRedisGatewayCache) DeleteSessionAccountID(ctx context.Context, groupID int64, sessionHash string) error {
|
||||
return c.rdb.Del(ctx, c.key(groupID, sessionHash)).Err()
|
||||
}
|
||||
|
||||
// schedInvAccountRepo 构造账号 repo mock(复用 mockAccountRepoForPlatform)。
|
||||
func schedInvAccountRepo(accounts ...Account) *mockAccountRepoForPlatform {
|
||||
repo := &mockAccountRepoForPlatform{
|
||||
accounts: accounts,
|
||||
accountsByID: map[int64]*Account{},
|
||||
}
|
||||
for i := range repo.accounts {
|
||||
repo.accountsByID[repo.accounts[i].ID] = &repo.accounts[i]
|
||||
}
|
||||
return repo
|
||||
}
|
||||
|
||||
// schedInvLoadAwareService 构造启用负载感知调度(Layer1.5/Layer2)的 GatewayService。
|
||||
func schedInvLoadAwareService(repo *mockAccountRepoForPlatform, cache GatewayCache) *GatewayService {
|
||||
cfg := testConfig()
|
||||
cfg.Gateway.Scheduling.LoadBatchEnabled = true
|
||||
return &GatewayService{
|
||||
accountRepo: repo,
|
||||
cache: cache,
|
||||
cfg: cfg,
|
||||
concurrencyService: NewConcurrencyService(&mockConcurrencyCache{}),
|
||||
}
|
||||
}
|
||||
|
||||
func schedInvAnthropicAccount(id int64, priority int) Account {
|
||||
return Account{
|
||||
ID: id,
|
||||
Name: fmt.Sprintf("sched-inv-%d", id),
|
||||
Platform: PlatformAnthropic,
|
||||
Type: AccountTypeAPIKey,
|
||||
Priority: priority,
|
||||
Concurrency: 5,
|
||||
Status: StatusActive,
|
||||
Schedulable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.1 session hash 输入优先级
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// TestSchedulingInvariant_SessionHashSourcePriority 固化 session hash 三级输入优先级:
|
||||
// metadata.user_id 的 session_xxx > cache_control(ephemeral) 内容 >
|
||||
// IP+UA+APIKeyID+system+messages 完整摘要兜底。
|
||||
func TestSchedulingInvariant_SessionHashSourcePriority(t *testing.T) {
|
||||
svc := &GatewayService{}
|
||||
sessionCtx := &SessionContext{ClientIP: "10.1.2.3", UserAgent: "claude-cli/2.0.0", APIKeyID: 77}
|
||||
metadata := "user_a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2_account__session_123e4567-e89b-12d3-a456-426614174000"
|
||||
cacheableSystem := []any{map[string]any{
|
||||
"type": "text",
|
||||
"text": "long cacheable system prompt",
|
||||
"cache_control": map[string]any{"type": "ephemeral"},
|
||||
}}
|
||||
messages := []any{msg("user", "hello invariants")}
|
||||
|
||||
// 1. 三个来源齐备 → metadata session_id 胜出
|
||||
full := mustParseSessionHashRequest(t, anthropicSessionBody(cacheableSystem, messages, metadata), sessionCtx)
|
||||
require.Equal(t, "123e4567-e89b-12d3-a456-426614174000", svc.GenerateSessionHash(full),
|
||||
"metadata.user_id 的 session_id 必须有最高优先级")
|
||||
|
||||
// 2. 去掉 metadata → cacheable content hash 胜出(与 SessionContext 无关)
|
||||
cacheableOnly := mustParseSessionHashRequest(t, anthropicSessionBody(cacheableSystem, messages, ""), sessionCtx)
|
||||
cacheableNoCtx := mustParseSessionHashRequest(t, anthropicSessionBody(cacheableSystem, messages, ""), nil)
|
||||
h2 := svc.GenerateSessionHash(cacheableOnly)
|
||||
require.NotEmpty(t, h2)
|
||||
require.Equal(t, svc.GenerateSessionHash(cacheableNoCtx), h2,
|
||||
"存在 cacheable content 时 SessionContext 不参与 hash(第 2 级优先于第 3 级)")
|
||||
|
||||
// 3. 无 metadata 且无 cacheable content → 兜底摘要,SessionContext 参与区分
|
||||
fallbackA := mustParseSessionHashRequest(t, anthropicSessionBody("plain system", messages, ""), sessionCtx)
|
||||
fallbackB := mustParseSessionHashRequest(t, anthropicSessionBody("plain system", messages, ""),
|
||||
&SessionContext{ClientIP: "10.9.9.9", UserAgent: "other-agent/1.0", APIKeyID: 88})
|
||||
h3a := svc.GenerateSessionHash(fallbackA)
|
||||
h3b := svc.GenerateSessionHash(fallbackB)
|
||||
require.NotEmpty(t, h3a)
|
||||
require.NotEqual(t, h3a, h3b, "兜底级 hash 必须混入 IP+UA+APIKeyID 区分因子")
|
||||
require.NotEqual(t, h2, h3a, "第 2 级与第 3 级来源应产生不同 hash")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.1 绑定后二次请求命中同账号
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_StickySession_SecondSelectionHitsSameAccount(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := schedInvAccountRepo(
|
||||
schedInvAnthropicAccount(1, 1),
|
||||
schedInvAnthropicAccount(2, 1),
|
||||
schedInvAnthropicAccount(3, 1),
|
||||
)
|
||||
cache := schedInvNewGatewayCache()
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
const sessionHash = "sched-inv-sticky-hit"
|
||||
|
||||
// 第一次选号:无绑定 → 任选一个账号并写入粘性绑定
|
||||
first, err := svc.SelectAccountWithLoadAwareness(ctx, nil, sessionHash, "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, first)
|
||||
require.NotNil(t, first.Account)
|
||||
require.True(t, first.Acquired)
|
||||
boundID := cache.binding(0, sessionHash)
|
||||
require.Equal(t, first.Account.ID, boundID, "首次选号成功后必须建立粘性绑定")
|
||||
|
||||
// 第二次选号:同 sessionHash 必须命中同一账号(无论负载排序如何演化)
|
||||
second, err := svc.SelectAccountWithLoadAwareness(ctx, nil, sessionHash, "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, second.Account)
|
||||
require.Equal(t, first.Account.ID, second.Account.ID, "二次请求必须命中粘性绑定的同一账号")
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.2 粘性绑定 TTL = 1 小时
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_StickySession_TTLIsOneHour(t *testing.T) {
|
||||
// 双断言①:常量引用(常量被改动时此处失败)
|
||||
require.Equal(t, time.Hour, stickySessionTTL, "粘性会话 TTL 常量必须保持 1 小时")
|
||||
|
||||
t.Run("BindStickySession传递TTL", func(t *testing.T) {
|
||||
cache := schedInvNewGatewayCache()
|
||||
svc := &GatewayService{cache: cache}
|
||||
require.NoError(t, svc.BindStickySession(context.Background(), nil, "sess-ttl", 42))
|
||||
require.Len(t, cache.setCalls, 1)
|
||||
// 双断言②:硬编码当前值 + 常量引用
|
||||
require.Equal(t, time.Hour, cache.setCalls[0].ttl)
|
||||
require.Equal(t, stickySessionTTL, cache.setCalls[0].ttl)
|
||||
})
|
||||
|
||||
t.Run("粘性命中刷新TTL为1小时", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
repo := schedInvAccountRepo(schedInvAnthropicAccount(1, 1), schedInvAnthropicAccount(2, 1))
|
||||
cache := schedInvNewGatewayCache()
|
||||
require.NoError(t, cache.SetSessionAccountID(ctx, 0, "sess-refresh", 1, stickySessionTTL))
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-refresh", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), result.Account.ID, "应命中粘性账号")
|
||||
require.NotEmpty(t, cache.refreshCalls, "粘性命中后必须刷新 TTL")
|
||||
require.Equal(t, time.Hour, cache.refreshCalls[0].ttl)
|
||||
})
|
||||
|
||||
t.Run("miniredis过期语义", func(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
mr := miniredis.RunT(t)
|
||||
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})
|
||||
t.Cleanup(func() { _ = rdb.Close() })
|
||||
svc := &GatewayService{cache: &schedInvRedisGatewayCache{rdb: rdb}}
|
||||
|
||||
require.NoError(t, svc.BindStickySession(ctx, nil, "sess-expiry", 7))
|
||||
|
||||
got, err := svc.GetCachedSessionAccountID(ctx, nil, "sess-expiry")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(7), got)
|
||||
|
||||
// TTL 内(59 分钟后)仍然命中
|
||||
mr.FastForward(stickySessionTTL - time.Minute)
|
||||
got, err = svc.GetCachedSessionAccountID(ctx, nil, "sess-expiry")
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(7), got)
|
||||
|
||||
// 超过 TTL 后绑定消失(当前实现返回 0 + 底层 miss error,handler 忽略 error)
|
||||
mr.FastForward(2 * time.Minute)
|
||||
got, _ = svc.GetCachedSessionAccountID(ctx, nil, "sess-expiry")
|
||||
require.Equal(t, int64(0), got, "TTL 过期后粘性绑定必须失效")
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.3 sticky_escape:粘性账号不可用时逃逸重选
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_StickyEscape_ReselectsWhenBoundAccountUnavailable(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
const model = "claude-sonnet-4-5"
|
||||
|
||||
t.Run("绑定账号不可调度_逃逸并重绑", func(t *testing.T) {
|
||||
// 账号 1 状态 error → 不在可调度列表;粘性绑定仍指向它
|
||||
broken := schedInvAnthropicAccount(1, 1)
|
||||
broken.Status = StatusError
|
||||
healthy := schedInvAnthropicAccount(2, 1)
|
||||
repo := schedInvAccountRepo(broken, healthy)
|
||||
cache := schedInvNewGatewayCache()
|
||||
require.NoError(t, cache.SetSessionAccountID(ctx, 0, "sess-escape-a", 1, time.Hour))
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-escape-a", model, nil, "", 0)
|
||||
require.NoError(t, err, "粘性账号不可用时必须能逃逸重选,而不是失败")
|
||||
require.NotNil(t, result.Account)
|
||||
require.NotEqual(t, int64(1), result.Account.ID, "不得再选中不可用的粘性账号")
|
||||
require.Equal(t, result.Account.ID, cache.binding(0, "sess-escape-a"),
|
||||
"逃逸成功后粘性绑定更新为新账号(当前实现行为)")
|
||||
})
|
||||
|
||||
t.Run("绑定账号模型限流_清除绑定并逃逸", func(t *testing.T) {
|
||||
limited := schedInvAnthropicAccount(1, 1)
|
||||
limited.Extra = map[string]any{
|
||||
"model_rate_limits": map[string]any{
|
||||
model: map[string]any{
|
||||
"rate_limit_reset_at": time.Now().Add(30 * time.Minute).Format(time.RFC3339),
|
||||
},
|
||||
},
|
||||
}
|
||||
healthy := schedInvAnthropicAccount(2, 1)
|
||||
repo := schedInvAccountRepo(limited, healthy)
|
||||
cache := schedInvNewGatewayCache()
|
||||
require.NoError(t, cache.SetSessionAccountID(ctx, 0, "sess-escape-b", 1, time.Hour))
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-escape-b", model, nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), result.Account.ID, "逃逸后应重选到可用账号")
|
||||
require.NotEmpty(t, cache.deleteCalls, "模型限流的粘性绑定应被清除(当前实现行为)")
|
||||
})
|
||||
|
||||
t.Run("绑定账号在failover排除集合_跳过粘性", func(t *testing.T) {
|
||||
repo := schedInvAccountRepo(schedInvAnthropicAccount(1, 1), schedInvAnthropicAccount(2, 1))
|
||||
cache := schedInvNewGatewayCache()
|
||||
require.NoError(t, cache.SetSessionAccountID(ctx, 0, "sess-escape-c", 1, time.Hour))
|
||||
svc := schedInvLoadAwareService(repo, cache)
|
||||
|
||||
excluded := map[int64]struct{}{1: {}}
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "sess-escape-c", model, excluded, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), result.Account.ID, "排除集合中的粘性账号必须被跳过")
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.5 账号级模型映射白名单对选号的影响
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_AccountModelMappingFiltersSelection(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
t.Run("不支持请求模型的账号被排除", func(t *testing.T) {
|
||||
// 账号 1 配了 model_mapping 白名单(只支持 opus),优先级更高;
|
||||
// 账号 2 未配映射(支持所有模型)。请求 sonnet 必须落到账号 2。
|
||||
restricted := schedInvAnthropicAccount(1, 0)
|
||||
restricted.Credentials = map[string]any{
|
||||
"model_mapping": map[string]any{"claude-opus-4-6": "claude-opus-4-6"},
|
||||
}
|
||||
open := schedInvAnthropicAccount(2, 9)
|
||||
repo := schedInvAccountRepo(restricted, open)
|
||||
svc := schedInvLoadAwareService(repo, schedInvNewGatewayCache())
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(2), result.Account.ID,
|
||||
"配置了 model_mapping 白名单且不含请求模型的账号必须被排除(即使优先级更高)")
|
||||
})
|
||||
|
||||
t.Run("支持模型的账号正常命中映射", func(t *testing.T) {
|
||||
restricted := schedInvAnthropicAccount(1, 0)
|
||||
restricted.Credentials = map[string]any{
|
||||
"model_mapping": map[string]any{"claude-opus-4-6": "claude-opus-4-6"},
|
||||
}
|
||||
repo := schedInvAccountRepo(restricted)
|
||||
svc := schedInvLoadAwareService(repo, schedInvNewGatewayCache())
|
||||
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-opus-4-6", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), result.Account.ID)
|
||||
})
|
||||
|
||||
t.Run("所有账号都不支持模型_返回无可用账号", func(t *testing.T) {
|
||||
a := schedInvAnthropicAccount(1, 1)
|
||||
a.Credentials = map[string]any{"model_mapping": map[string]any{"claude-opus-4-6": "claude-opus-4-6"}}
|
||||
b := schedInvAnthropicAccount(2, 1)
|
||||
b.Credentials = map[string]any{"model_mapping": map[string]any{"claude-haiku-4-5": "claude-haiku-4-5"}}
|
||||
repo := schedInvAccountRepo(a, b)
|
||||
svc := schedInvLoadAwareService(repo, schedInvNewGatewayCache())
|
||||
|
||||
_, err := svc.SelectAccountWithLoadAwareness(ctx, nil, "", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrNoAvailableAccounts)
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// I-4.5 渠道模型映射 + 定价限制对选号的影响
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
func TestSchedulingInvariant_ChannelMappingPricingRestrictionAffectsSelection(t *testing.T) {
|
||||
const groupID = int64(10)
|
||||
newSvc := func(pricingModels []string) (*GatewayService, *mockGroupRepoForGateway) {
|
||||
ch := Channel{
|
||||
ID: 1,
|
||||
Status: StatusActive,
|
||||
GroupIDs: []int64{groupID},
|
||||
RestrictModels: true,
|
||||
BillingModelSource: BillingModelSourceChannelMapped,
|
||||
ModelPricing: []ChannelModelPricing{
|
||||
{Platform: PlatformAnthropic, Models: pricingModels},
|
||||
},
|
||||
ModelMapping: map[string]map[string]string{
|
||||
PlatformAnthropic: {"claude-sonnet-4-5": "claude-sonnet-4-6"},
|
||||
},
|
||||
}
|
||||
channelSvc := newTestChannelService(makeStandardRepo(ch, map[int64]string{groupID: PlatformAnthropic}))
|
||||
repo := schedInvAccountRepo(func() Account {
|
||||
a := schedInvAnthropicAccount(1, 1)
|
||||
a.AccountGroups = []AccountGroup{{AccountID: 1, GroupID: groupID}}
|
||||
return a
|
||||
}())
|
||||
groupRepo := &mockGroupRepoForGateway{
|
||||
groups: map[int64]*Group{
|
||||
groupID: {ID: groupID, Platform: PlatformAnthropic, Status: StatusActive, Hydrated: true},
|
||||
},
|
||||
}
|
||||
svc := &GatewayService{
|
||||
accountRepo: repo,
|
||||
groupRepo: groupRepo,
|
||||
channelService: channelSvc,
|
||||
cache: schedInvNewGatewayCache(),
|
||||
cfg: testConfig(),
|
||||
}
|
||||
return svc, groupRepo
|
||||
}
|
||||
ctxWithGroup := func(groupRepo *mockGroupRepoForGateway) context.Context {
|
||||
return context.WithValue(context.Background(), ctxkey.Group, groupRepo.groups[groupID])
|
||||
}
|
||||
|
||||
t.Run("映射目标不在渠道定价_选号被拒绝", func(t *testing.T) {
|
||||
// 渠道映射 sonnet-4-5 → sonnet-4-6,但定价列表只有 opus:
|
||||
// 限制检查对象是"映射后模型",因此请求 sonnet-4-5 必须被拒绝。
|
||||
gid := groupID
|
||||
svc, groupRepo := newSvc([]string{"claude-opus-4-6"})
|
||||
_, err := svc.SelectAccountWithLoadAwareness(ctxWithGroup(groupRepo), &gid, "", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrNoAvailableAccounts)
|
||||
require.Contains(t, err.Error(), "channel pricing restriction",
|
||||
"渠道定价限制必须以专属错误语义拒绝选号")
|
||||
})
|
||||
|
||||
t.Run("映射目标在渠道定价_正常选号", func(t *testing.T) {
|
||||
// 定价列表含映射后的 sonnet-4-6(即使不含原始请求模型 sonnet-4-5)→ 放行。
|
||||
// 这证明渠道映射结果(而非原始模型名)决定选号阶段的限制判定。
|
||||
gid := groupID
|
||||
svc, groupRepo := newSvc([]string{"claude-sonnet-4-6"})
|
||||
result, err := svc.SelectAccountWithLoadAwareness(ctxWithGroup(groupRepo), &gid, "", "claude-sonnet-4-5", nil, "", 0)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, int64(1), result.Account.ID)
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
#!/usr/bin/env bash
|
||||
# Phase-0 TASK-005 热路径基准采集与对比(插件化改造回归安全网)。
|
||||
#
|
||||
# 用法:
|
||||
# ./scripts/bench-baseline.sh collect # 采集基线 -> testdata/bench/baseline.txt
|
||||
# ./scripts/bench-baseline.sh compare # 当前代码 vs 基线,违反阈值时退出码 1
|
||||
#
|
||||
# 对比策略(每个基准按多次采样的均值比较):
|
||||
# - allocs/op 严格:增加 > ALLOC_TOLERANCE(默认 0)即失败
|
||||
# - ns/op 宽松:劣化 > TIME_TOLERANCE_PCT%(默认 15,吸收 runner 抖动)
|
||||
# 环境变量:BENCH_COUNT(采样次数,默认 6)、TIME_TOLERANCE_PCT、ALLOC_TOLERANCE
|
||||
#
|
||||
# 注意:基线与对比应在同类机器上进行;换机器/换 Go 版本后请重新 collect
|
||||
# (环境信息记录在 testdata/bench/baseline.env.txt)。
|
||||
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")/.."
|
||||
|
||||
PATTERN='BenchmarkGatewayForward|BenchmarkGatewayService_ParseSSEUsage|BenchmarkParseClaudeUsageFromResponseBody'
|
||||
PKGS='./internal/service/'
|
||||
BASELINE=testdata/bench/baseline.txt
|
||||
COUNT="${BENCH_COUNT:-6}"
|
||||
TIME_TOLERANCE_PCT="${TIME_TOLERANCE_PCT:-15}"
|
||||
ALLOC_TOLERANCE="${ALLOC_TOLERANCE:-0}"
|
||||
|
||||
run_bench() {
|
||||
go test -tags=unit -run '^$' -bench "$PATTERN" -benchmem -count="$COUNT" "$PKGS"
|
||||
}
|
||||
|
||||
# 从基准输出中提取每个基准的均值:name mean_ns mean_allocs
|
||||
# ns 与 allocs 独立计数;缺任一指标(如未带 -benchmem)的基准跳过并警告,
|
||||
# 避免除零产生 inf/nan 静默污染对比结果。
|
||||
summarize() {
|
||||
awk '/^Benchmark/ {
|
||||
name=$1; sub(/-[0-9]+$/, "", name)
|
||||
for (i=2; i<=NF; i++) {
|
||||
if ($(i)=="ns/op") { ns[name]+=$(i-1); nns[name]++ }
|
||||
if ($(i)=="allocs/op") { al[name]+=$(i-1); nal[name]++ }
|
||||
}
|
||||
}
|
||||
END {
|
||||
for (k in nns) {
|
||||
if (nns[k]>0 && nal[k]>0) printf "%s %.2f %.2f\n", k, ns[k]/nns[k], al[k]/nal[k]
|
||||
else print "warn: skip " k " (missing ns/op or allocs/op samples, run with -benchmem)" > "/dev/stderr"
|
||||
}
|
||||
}' "$1" | sort
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
collect)
|
||||
mkdir -p "$(dirname "$BASELINE")"
|
||||
{
|
||||
echo "go: $(go version)"
|
||||
echo "cpu: $(grep -m1 'model name' /proc/cpuinfo 2>/dev/null | cut -d: -f2- | xargs || echo unknown)"
|
||||
echo "date: $(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||||
echo "count: $COUNT"
|
||||
} > "$(dirname "$BASELINE")/baseline.env.txt"
|
||||
run_bench | tee "$BASELINE"
|
||||
echo "基线已写入 $BASELINE"
|
||||
;;
|
||||
compare)
|
||||
[ -f "$BASELINE" ] || { echo "缺少基线 $BASELINE,先运行: $0 collect" >&2; exit 2; }
|
||||
new="$(mktemp)"
|
||||
run_bench | tee "$new"
|
||||
echo
|
||||
if command -v benchstat >/dev/null 2>&1; then
|
||||
benchstat "$BASELINE" "$new" || true
|
||||
echo
|
||||
fi
|
||||
base_sum="$(mktemp)"; new_sum="$(mktemp)"
|
||||
summarize "$BASELINE" > "$base_sum"
|
||||
summarize "$new" > "$new_sum"
|
||||
# 基准名集合对比(审计 F-1):join 只输出两侧共有的基准,增删/改名若不检查
|
||||
# 会被静默丢弃,造成"基准消失但 compare 仍绿"的假阴性。
|
||||
added="$(join -v2 "$base_sum" "$new_sum" | awk '{print $1}')"
|
||||
missing="$(join -v1 "$base_sum" "$new_sum" | awk '{print $1}')"
|
||||
table_status=0
|
||||
join "$base_sum" "$new_sum" | awk -v tp="$TIME_TOLERANCE_PCT" -v at="$ALLOC_TOLERANCE" '
|
||||
{
|
||||
name=$1; bns=$2; bal=$3; nns=$4; nal=$5
|
||||
dns = (bns>0) ? (nns-bns)/bns*100 : 0
|
||||
dal = nal-bal
|
||||
status="ok"
|
||||
if (dal > at) { status="FAIL(allocs/op +" dal ")"; fail=1 }
|
||||
else if (dns > tp) { status="FAIL(ns/op +" sprintf("%.1f", dns) "%)"; fail=1 }
|
||||
printf "%-60s ns/op %10.0f -> %10.0f (%+6.1f%%) allocs/op %7.1f -> %7.1f %s\n", name, bns, nns, dns, bal, nal, status
|
||||
}
|
||||
END { exit fail ? 1 : 0 }' || table_status=$?
|
||||
if [ -n "$added" ]; then
|
||||
printf 'warn: 以下基准在基线中不存在,未参与对比(新增基准请重新 collect 基线):\n%s\n' "$added" >&2
|
||||
fi
|
||||
if [ -n "$missing" ]; then
|
||||
printf 'FAIL: 基线中的以下基准在本次运行缺失(被删除/改名?回归网已失效,须修复或重新 collect):\n%s\n' "$missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
exit "$table_status"
|
||||
;;
|
||||
*)
|
||||
echo "用法: $0 {collect|compare}" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
+4
@@ -0,0 +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
|
||||
count: 6
|
||||
Vendored
+48
@@ -0,0 +1,48 @@
|
||||
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
|
||||
PASS
|
||||
ok github.com/Wei-Shaw/sub2api/internal/service 66.644s
|
||||
Reference in New Issue
Block a user