Merge branch 'main' into fix/issue-4798-gemini-image-chat-content

解决 frontend/pnpm-lock.yaml 冲突:采用 main 的 frontend/package.json 与
frontend/pnpm-lock.yaml,回退本 PR 附带的 postcss ^8.4.32 -> ^8.5.12 改动。
main 已通过 pnpm.overrides ("postcss@<8.5.18": ">=8.5.18") 强制安全下限,
本 PR 的直接依赖下限低于该值,保留会与 overrides 冲突且无安全收益。
本 PR 的功能改动(gemini_* compat service 及其测试)未受影响。
This commit is contained in:
shaw
2026-07-25 14:33:06 +08:00
61 changed files with 1493 additions and 103 deletions
+1
View File
@@ -133,6 +133,7 @@ var DefaultBedrockModelMapping = map[string]string{
// Claude Fable
"claude-fable-5": "anthropic.claude-fable-5",
// Claude Opus
"claude-opus-5": "us.anthropic.claude-opus-5-v1",
"claude-opus-4-8": "us.anthropic.claude-opus-4-8-v1",
"claude-opus-4-7": "us.anthropic.claude-opus-4-7-v1",
"claude-opus-4-6-thinking": "us.anthropic.claude-opus-4-6-v1",
@@ -42,6 +42,9 @@ func (h *BatchImageHandler) Submit(c *gin.Context) {
if !h.checkSecurityAuditBeforeSubmit(c, &req) {
return
}
if sessionID := service.ExtractClientSessionID(c); sessionID != "" {
req.SessionID = &sessionID
}
got, err := h.service.Submit(c.Request.Context(), owner, req, c.GetHeader("Idempotency-Key"))
if err != nil {
batchImageError(c, err)
+1
View File
@@ -669,6 +669,7 @@ func usageLogFromServiceUser(l *service.UsageLog) UsageLog {
MediaType: l.MediaType,
UserAgent: l.UserAgent,
IPAddress: l.IPAddress,
SessionID: l.SessionID,
CacheTTLOverridden: l.CacheTTLOverridden,
BillingMode: l.BillingMode,
CreatedAt: l.CreatedAt,
+3
View File
@@ -521,6 +521,9 @@ type UsageLog struct {
UserAgent *string `json:"user_agent"`
// IPAddress is visible to the owner of the usage record.
IPAddress *string `json:"ip_address,omitempty"`
// SessionID is the explicit client-provided request correlation identifier
// (e.g. the session_id / X-Session-Id headers). Omitted when absent.
SessionID *string `json:"session_id,omitempty"`
// Cache TTL Override 标记
CacheTTLOverridden bool `json:"cache_ttl_overridden"`
@@ -535,6 +535,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -547,6 +548,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
@@ -970,6 +972,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), currentAPIKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -982,6 +985,7 @@ func (h *GatewayHandler) Messages(c *gin.Context) {
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIP,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
@@ -298,6 +298,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -312,6 +313,7 @@ func (h *GatewayHandler) ChatCompletions(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
reqLog.Error("gateway.cc.record_usage_failed",
@@ -276,6 +276,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.RecordUsageInput{
Result: result,
@@ -290,6 +291,7 @@ func (h *GatewayHandler) Responses(c *gin.Context) {
IPAddress: clientIP,
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
reqLog.Error("gateway.responses.record_usage_failed",
@@ -532,6 +532,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
// ForceCacheBilling 提前拍成标量,避免 worker 闭包保活 failover 状态里的响应体。
forceCacheBilling := fs.ForceCacheBilling
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsageWithLongContext(ctx, &service.RecordUsageLongContextInput{
Result: result,
@@ -549,6 +550,7 @@ func (h *GatewayHandler) GeminiV1BetaModels(c *gin.Context) {
LongContextMultiplier: 2.0, // 超出部分双倍计费
ForceCacheBilling: forceCacheBilling,
APIKeyService: h.apiKeyService,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
+2
View File
@@ -465,6 +465,7 @@ func recordGrokMediaUsage(
) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
sessionID := service.ExtractClientSessionID(c)
payloadForHash := body
if len(payloadForHash) == 0 && strings.TrimSpace(requestID) != "" {
payloadForHash = []byte(requestID)
@@ -493,6 +494,7 @@ func recordGrokMediaUsage(
RequestPayloadHash: service.HashUsageRequestPayload(payloadForHash),
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: channelUsageFields,
}); err != nil {
logger.L().With(
@@ -233,6 +233,7 @@ func (h *OpenAIGatewayHandler) recordAlphaSearchUsage(
) {
userAgent := c.GetHeader("User-Agent")
clientIP := ip.GetClientIP(c)
sessionID := service.ExtractClientSessionID(c)
requestPayloadHash := service.HashUsageRequestPayload(body)
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
@@ -252,6 +253,7 @@ func (h *OpenAIGatewayHandler) recordAlphaSearchUsage(
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: channelMapping.ToUsageFields(requestedModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
@@ -340,6 +340,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
@@ -355,6 +356,7 @@ func (h *OpenAIGatewayHandler) ChatCompletions(c *gin.Context) {
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -243,6 +243,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := GetUpstreamEndpoint(c, account.Platform)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
@@ -257,6 +258,7 @@ func (h *OpenAIGatewayHandler) Embeddings(c *gin.Context) {
IPAddress: clientIP,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
}); err != nil {
logger.L().With(
@@ -627,6 +627,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
// 使用量记录通过有界 worker 池提交,避免请求热路径创建无界 goroutine。
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
@@ -644,6 +645,7 @@ func (h *OpenAIGatewayHandler) Responses(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -1138,6 +1140,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(c.Request.Context(), result, func(ctx context.Context) {
@@ -1154,6 +1157,7 @@ func (h *OpenAIGatewayHandler) Messages(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMappingMsg, reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -1862,6 +1866,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
inboundEndpoint := GetInboundEndpoint(c)
upstreamEndpoint := resolveOpenAIUpstreamEndpoint(c, account, result)
quotaPlatform := service.QuotaPlatform(c.Request.Context(), apiKey)
sessionID := service.ExtractClientSessionID(c)
cyberBlocked := service.GetOpsCyberPolicy(c) != nil
h.submitOpenAIUsageRecordTask(ctx, result, func(taskCtx context.Context) {
if err := h.gatewayService.RecordUsage(taskCtx, &service.OpenAIRecordUsageInput{
@@ -1877,6 +1882,7 @@ func (h *OpenAIGatewayHandler) ResponsesWebSocket(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMappingWS, reqModel, result.UpstreamModel),
CyberBlocked: cyberBlocked,
}); err != nil {
@@ -2884,6 +2890,8 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
userAgent = c.GetHeader("User-Agent")
clientIPStr = strings.TrimSpace(ip.GetClientIP(c))
}
// 提前拍成标量,避免在下方 goroutine 内访问 gin.Context。
sessionID := service.ExtractClientSessionID(c)
apiKeyPrefix := ""
if apiKey != nil {
apiKeyPrefix = keyPrefix(apiKey.Key, 8)
@@ -2940,6 +2948,7 @@ func (h *OpenAIGatewayHandler) recordCyberPolicyIfMarked(c *gin.Context, apiKey
UpstreamEndpoint: upstreamEndpoint,
UserAgent: userAgent,
IPAddress: clientIPStr,
SessionID: sessionID,
RequestPayloadHash: requestPayloadHash,
APIKeyService: apiKeySvc,
ChannelUsageFields: channelFields,
@@ -91,6 +91,8 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
zap.Bool("stream", parsed.Stream),
zap.Bool("multipart", parsed.Multipart),
zap.String("capability", string(parsed.RequiredCapability)),
zap.String("img_quality", parsed.Quality),
zap.String("img_size", parsed.Size),
)
if !service.GroupAllowsImageGeneration(apiKey.Group) {
@@ -374,6 +376,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
if result != nil {
upstreamModel = result.UpstreamModel
}
sessionID := service.ExtractClientSessionID(c)
h.submitMandatoryUsageRecordTask(c.Request.Context(), func(ctx context.Context) {
if err := h.gatewayService.RecordUsage(ctx, &service.OpenAIRecordUsageInput{
Result: result,
@@ -388,6 +391,7 @@ func (h *OpenAIGatewayHandler) Images(c *gin.Context) {
RequestPayloadHash: requestPayloadHash,
APIKeyService: h.apiKeyService,
QuotaPlatform: quotaPlatform,
SessionID: sessionID,
ChannelUsageFields: clientRequestedUsageFields(c, channelMapping, requestModel, upstreamModel),
}); err != nil {
logger.L().With(
@@ -12,11 +12,14 @@ import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/pkg/logger"
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
"go.uber.org/zap"
"go.uber.org/zap/zaptest/observer"
)
type openAIImagesFailoverAccountRepo struct {
@@ -154,8 +157,10 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
)
handler.maxAccountSwitches = 10
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat"}`)
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body))
body := []byte(`{"model":"gpt-image-2","prompt":"draw a cat","quality":"high","size":"1536x1024"}`)
core, observedLogs := observer.New(zap.DebugLevel)
requestCtx := logger.IntoContext(context.Background(), zap.New(core))
req := httptest.NewRequest(http.MethodPost, "/v1/images/generations", bytes.NewReader(body)).WithContext(requestCtx)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
@@ -173,6 +178,16 @@ func TestOpenAIGatewayHandlerImages_ServerErrorFailsOverAndReturnsClearErrorWhen
handler.Images(c)
accountSelectingLogs := observedLogs.FilterMessage("openai.images.account_selecting").All()
require.NotEmpty(t, accountSelectingLogs)
loggedFields := make(map[string]string)
for _, field := range accountSelectingLogs[0].Context {
loggedFields[field.Key] = field.String
}
require.Equal(t, "high", loggedFields["img_quality"])
require.Equal(t, "1536x1024", loggedFields["img_size"])
require.NotContains(t, loggedFields, "prompt")
require.Equal(t, []int64{1, 2}, upstream.calls())
require.Equal(t, http.StatusBadGateway, rec.Code)
require.Equal(t, "upstream_error", gjson.GetBytes(rec.Body.Bytes(), "error.type").String())
+6
View File
@@ -147,6 +147,12 @@ var DefaultModels = []Model{
DisplayName: "Claude Opus 4.8",
CreatedAt: "2026-05-29T00:00:00Z",
},
{
ID: "claude-opus-5",
Type: "model",
DisplayName: "Claude Opus 5",
CreatedAt: "2026-07-25T00:00:00Z",
},
{
ID: "claude-sonnet-5",
Type: "model",
@@ -750,7 +750,7 @@ INSERT INTO batch_image_jobs (
batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price,
pricing_snapshot_version,
currency, hold_id,
idempotency_key, request_hash, manifest_hash, retry_count, output_expires_at
idempotency_key, request_hash, manifest_hash, retry_count, session_id, output_expires_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8, $9,
$10, $11, $12, $13, $14,
@@ -760,7 +760,7 @@ INSERT INTO batch_image_jobs (
$25, $26, $27, $28,
$29,
$30, $31,
$32, $33, $34, $35, $36
$32, $33, $34, $35, $36, $37
)
RETURNING `+batchImageJobColumns,
params.BatchID, params.UserID, params.APIKeyID, params.AccountID, params.Provider, params.Model, params.TaskName, params.ParentBatchID, params.Status,
@@ -771,7 +771,7 @@ RETURNING `+batchImageJobColumns,
params.BatchDiscountMultiplier, params.HoldMultiplier, params.BillableUnitPrice, params.HoldUnitPrice,
params.PricingSnapshotVersion,
params.Currency, params.HoldID,
params.IdempotencyKey, params.RequestHash, params.ManifestHash, params.RetryCount, params.OutputExpiresAt,
params.IdempotencyKey, params.RequestHash, params.ManifestHash, params.RetryCount, params.SessionID, params.OutputExpiresAt,
))
}
@@ -825,7 +825,7 @@ batch_discount_multiplier, hold_multiplier, billable_unit_price, hold_unit_price
pricing_snapshot_version,
currency, hold_id,
idempotency_key, request_hash, manifest_hash,
retry_count, version, output_expires_at, input_deleted_at, output_deleted_at, downloaded_at, user_deleted_at,
retry_count, version, session_id, output_expires_at, input_deleted_at, output_deleted_at, downloaded_at, user_deleted_at,
last_error_code, last_error_message,
created_at, updated_at, submitted_at, started_at, finished_at, settled_at`
@@ -838,6 +838,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) {
var parentBatchID sql.NullString
var holdAmount, actualCost sql.NullFloat64
var holdID, idempotencyKey, requestHash, manifestHash sql.NullString
var sessionID sql.NullString
var outputExpiresAt, inputDeletedAt, outputDeletedAt, downloadedAt, userDeletedAt sql.NullTime
var lastErrorCode, lastErrorMessage sql.NullString
var submittedAt, startedAt, finishedAt, settledAt sql.NullTime
@@ -852,7 +853,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) {
&job.PricingSnapshotVersion,
&job.Currency, &holdID,
&idempotencyKey, &requestHash, &manifestHash,
&job.RetryCount, &job.Version, &outputExpiresAt, &inputDeletedAt, &outputDeletedAt, &downloadedAt, &userDeletedAt,
&job.RetryCount, &job.Version, &sessionID, &outputExpiresAt, &inputDeletedAt, &outputDeletedAt, &downloadedAt, &userDeletedAt,
&lastErrorCode, &lastErrorMessage,
&job.CreatedAt, &job.UpdatedAt, &submittedAt, &startedAt, &finishedAt, &settledAt,
)
@@ -874,6 +875,7 @@ func scanBatchImageJob(row rowScanner) (*service.BatchImageJob, error) {
job.IdempotencyKey = batchImageNullStringPtr(idempotencyKey)
job.RequestHash = batchImageNullStringPtr(requestHash)
job.ManifestHash = batchImageNullStringPtr(manifestHash)
job.SessionID = batchImageNullStringPtr(sessionID)
job.OutputExpiresAt = batchImageNullTimePtr(outputExpiresAt)
job.InputDeletedAt = batchImageNullTimePtr(inputDeletedAt)
job.OutputDeletedAt = batchImageNullTimePtr(outputDeletedAt)
@@ -79,6 +79,7 @@ var usageLogInsertArgTypes = [...]string{
"text", // billing_tier
"text", // billing_mode
"numeric", // account_stats_cost
"text", // session_id
"timestamptz", // created_at
}
@@ -274,6 +275,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
billing_tier,
billing_mode,
account_stats_cost,
session_id,
created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
@@ -281,7 +283,7 @@ func (r *usageLogRepository) createSingle(ctx context.Context, sqlq sqlExecutor,
$10, $11, $12, $13,
$14, $15, $16, $17,
$18, $19, $20, $21, $22, $23,
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
RETURNING id, created_at
@@ -728,10 +730,13 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
billing_tier,
billing_mode,
account_stats_cost,
session_id,
created_at
) AS (VALUES `)
args := make([]any, 0, len(keys)*56)
// Each batch row prepends the synthetic input_index before the 57
// usage-log column values.
args := make([]any, 0, len(keys)*58)
argPos := 1
for idx, key := range keys {
if idx > 0 {
@@ -815,6 +820,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
billing_tier,
billing_mode,
account_stats_cost,
session_id,
created_at
)
SELECT
@@ -873,6 +879,7 @@ func buildUsageLogBatchInsertQuery(keys []string, preparedByKey map[string]usage
billing_tier,
billing_mode,
account_stats_cost,
session_id,
created_at
FROM input
ON CONFLICT (request_id, api_key_id) DO NOTHING
@@ -971,10 +978,11 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
billing_tier,
billing_mode,
account_stats_cost,
session_id,
created_at
) AS (VALUES `)
args := make([]any, 0, len(preparedList)*56)
args := make([]any, 0, len(preparedList)*57)
argPos := 1
for idx, prepared := range preparedList {
if idx > 0 {
@@ -1055,6 +1063,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
billing_tier,
billing_mode,
account_stats_cost,
session_id,
created_at
)
SELECT
@@ -1113,6 +1122,7 @@ func buildUsageLogBestEffortInsertQuery(preparedList []usageLogInsertPrepared) (
billing_tier,
billing_mode,
account_stats_cost,
session_id,
created_at
FROM input
ON CONFLICT (request_id, api_key_id) DO NOTHING
@@ -1179,6 +1189,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
billing_tier,
billing_mode,
account_stats_cost,
session_id,
created_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7,
@@ -1186,7 +1197,7 @@ func execUsageLogInsertNoResult(ctx context.Context, sqlq sqlExecutor, prepared
$10, $11, $12, $13,
$14, $15, $16, $17,
$18, $19, $20, $21, $22, $23,
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56
$24, $25, $26, $27, $28, $29, $30, $31, $32, $33, $34, $35, $36, $37, $38, $39, $40, $41, $42, $43, $44, $45, $46, $47, $48, $49, $50, $51, $52, $53, $54, $55, $56, $57
)
ON CONFLICT (request_id, api_key_id) DO NOTHING
`, prepared.args...)
@@ -1227,6 +1238,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
modelMappingChain := nullString(log.ModelMappingChain)
billingTier := nullString(log.BillingTier)
billingMode := nullString(log.BillingMode)
sessionID := nullString(log.SessionID)
requestedModel := strings.TrimSpace(log.RequestedModel)
if requestedModel == "" {
requestedModel = strings.TrimSpace(log.Model)
@@ -1299,6 +1311,7 @@ func prepareUsageLogInsert(log *service.UsageLog) usageLogInsertPrepared {
billingTier,
billingMode,
log.AccountStatsCost, // account_stats_cost
sessionID, // session_id
createdAt,
},
}
@@ -19,7 +19,7 @@ import (
"github.com/Wei-Shaw/sub2api/internal/service"
)
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, created_at"
const usageLogSelectColumns = "id, user_id, api_key_id, account_id, request_id, model, requested_model, upstream_model, group_id, subscription_id, input_tokens, output_tokens, cache_creation_tokens, cache_read_tokens, cache_creation_5m_tokens, cache_creation_1h_tokens, image_output_tokens, image_output_cost, image_input_tokens, image_input_cost, input_cost, output_cost, cache_creation_cost, cache_read_cost, total_cost, actual_cost, rate_multiplier, account_rate_multiplier, billing_type, request_type, stream, openai_ws_mode, duration_ms, first_token_ms, user_agent, ip_address, image_count, image_size, image_input_size, image_output_size, image_size_source, image_size_breakdown, video_count, video_resolution, video_duration_seconds, service_tier, reasoning_effort, inbound_endpoint, upstream_endpoint, cache_ttl_overridden, long_context_billing_applied, channel_id, model_mapping_chain, billing_tier, billing_mode, account_stats_cost, session_id, created_at"
func (r *usageLogRepository) GetByID(ctx context.Context, id int64) (log *service.UsageLog, err error) {
query := "SELECT " + usageLogSelectColumns + " FROM usage_logs WHERE id = $1"
@@ -481,6 +481,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
billingTier sql.NullString
billingMode sql.NullString
accountStatsCost sql.NullFloat64
sessionID sql.NullString
createdAt time.Time
)
@@ -541,6 +542,7 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
&billingTier,
&billingMode,
&accountStatsCost,
&sessionID,
&createdAt,
); err != nil {
return nil, err
@@ -661,6 +663,9 @@ func scanUsageLog(scanner interface{ Scan(...any) error }) (*service.UsageLog, e
if accountStatsCost.Valid {
log.AccountStatsCost = &accountStatsCost.Float64
}
if sessionID.Valid {
log.SessionID = &sessionID.String
}
return log, nil
}
@@ -96,6 +96,7 @@ func TestUsageLogRepositoryCreateSyncRequestTypeAndLegacyFields(t *testing.T) {
sqlmock.AnyArg(), // billing_tier
sqlmock.AnyArg(), // billing_mode
sqlmock.AnyArg(), // account_stats_cost
sqlmock.AnyArg(), // session_id
createdAt,
).
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(99), createdAt))
@@ -185,6 +186,7 @@ func TestUsageLogRepositoryCreate_PersistsServiceTier(t *testing.T) {
sqlmock.AnyArg(), // billing_tier
sqlmock.AnyArg(), // billing_mode
sqlmock.AnyArg(), // account_stats_cost
sqlmock.AnyArg(), // session_id
createdAt,
).
WillReturnRows(sqlmock.NewRows([]string{"id", "created_at"}).AddRow(int64(100), createdAt))
@@ -826,6 +828,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{},
sql.NullString{},
sql.NullFloat64{},
sql.NullString{},
now,
}})
require.NoError(t, err)
@@ -900,6 +903,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, // billing_tier
sql.NullString{}, // billing_mode
sql.NullFloat64{}, // account_stats_cost
sql.NullString{}, // session_id
now,
}})
require.NoError(t, err)
@@ -957,6 +961,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, // billing_tier
sql.NullString{}, // billing_mode
sql.NullFloat64{}, // account_stats_cost
sql.NullString{}, // session_id
now,
}})
require.NoError(t, err)
@@ -1014,6 +1019,7 @@ func TestScanUsageLogRequestTypeAndLegacyFallback(t *testing.T) {
sql.NullString{}, // billing_tier
sql.NullString{}, // billing_mode
sql.NullFloat64{}, // account_stats_cost
sql.NullString{}, // session_id
now,
}})
require.NoError(t, err)
@@ -0,0 +1,71 @@
//go:build integration
package repository
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
// TestUsageLog_SessionIDPersistence proves session_id round-trips from insert to
// read and is omitted (NULL) when absent.
func TestUsageLog_SessionIDPersistence(t *testing.T) {
ctx := context.Background()
client := testEntClient(t)
repo := newUsageLogRepositoryWithSQL(client, integrationDB)
user := mustCreateUser(t, client, &service.User{Email: "session-id-" + uuid.NewString() + "@example.com"})
apiKey := mustCreateApiKey(t, client, &service.APIKey{UserID: user.ID, Key: "sk-session-" + uuid.NewString(), Name: "k"})
account := mustCreateAccount(t, client, &service.Account{Name: "acc-session-" + uuid.NewString()})
sessionID := "sess-" + uuid.NewString()
withSession := &service.UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: uuid.NewString(),
Model: "claude-3",
InputTokens: 10,
OutputTokens: 5,
TotalCost: 1.0,
ActualCost: 1.0,
SessionID: &sessionID,
CreatedAt: time.Now().UTC(),
}
_, err := repo.Create(ctx, withSession)
require.NoError(t, err)
require.NotZero(t, withSession.ID)
withoutSession := &service.UsageLog{
UserID: user.ID,
APIKeyID: apiKey.ID,
AccountID: account.ID,
RequestID: uuid.NewString(),
Model: "claude-3",
InputTokens: 7,
OutputTokens: 3,
TotalCost: 0.5,
ActualCost: 0.5,
CreatedAt: time.Now().UTC(),
}
_, err = repo.Create(ctx, withoutSession)
require.NoError(t, err)
// Round-trip: session id survives insert → read.
got, err := repo.GetByID(ctx, withSession.ID)
require.NoError(t, err)
require.NotNil(t, got.SessionID)
require.Equal(t, sessionID, *got.SessionID)
// Omission: absent session id reads back as nil (NULL), not empty string.
gotNone, err := repo.GetByID(ctx, withoutSession.ID)
require.NoError(t, err)
require.Nil(t, gotNone.SessionID)
}
@@ -0,0 +1,91 @@
//go:build unit
package repository
import (
"database/sql"
"strings"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/service"
"github.com/stretchr/testify/require"
)
func newSessionIDUsageLog(sessionID *string) *service.UsageLog {
return &service.UsageLog{
UserID: 1,
APIKeyID: 2,
AccountID: 3,
RequestID: "req-session-id",
Model: "claude-3",
InputTokens: 10,
OutputTokens: 5,
TotalCost: 1.0,
ActualCost: 1.0,
SessionID: sessionID,
CreatedAt: time.Now().UTC(),
}
}
// TestPrepareUsageLogInsert_SessionIDArgWiring pins the session_id column to the
// arg slice / arg-type table so the five INSERT column lists stay in sync. session_id
// is the penultimate arg (created_at is always last).
func TestPrepareUsageLogInsert_SessionIDArgWiring(t *testing.T) {
require.Len(t, usageLogInsertArgTypes, 57, "arg-type table must include session_id")
sessionID := "sess-persisted-123"
prepared := prepareUsageLogInsert(newSessionIDUsageLog(&sessionID))
require.Len(t, prepared.args, len(usageLogInsertArgTypes),
"prepared args must match the arg-type table length")
// created_at is last; session_id is the arg immediately before it.
sessionArg := prepared.args[len(prepared.args)-2]
ns, ok := sessionArg.(sql.NullString)
require.True(t, ok, "session_id arg should be a sql.NullString, got %T", sessionArg)
require.True(t, ns.Valid)
require.Equal(t, sessionID, ns.String)
require.Equal(t, "text", usageLogInsertArgTypes[len(usageLogInsertArgTypes)-2],
"session_id arg type must be text")
}
// TestPrepareUsageLogInsert_SessionIDNullWhenAbsent proves an absent session id is
// persisted as SQL NULL rather than an empty string.
func TestPrepareUsageLogInsert_SessionIDNullWhenAbsent(t *testing.T) {
prepared := prepareUsageLogInsert(newSessionIDUsageLog(nil))
sessionArg := prepared.args[len(prepared.args)-2]
ns, ok := sessionArg.(sql.NullString)
require.True(t, ok, "session_id arg should be a sql.NullString, got %T", sessionArg)
require.False(t, ns.Valid, "absent session id must be NULL, not empty string")
empty := ""
preparedEmpty := prepareUsageLogInsert(newSessionIDUsageLog(&empty))
nsEmpty := preparedEmpty.args[len(preparedEmpty.args)-2].(sql.NullString)
require.False(t, nsEmpty.Valid, "empty session id must also be NULL")
}
// TestUsageLogInsertQueries_IncludeSessionID guards that every generated INSERT path
// and the SELECT column list reference session_id.
func TestUsageLogInsertQueries_IncludeSessionID(t *testing.T) {
require.Contains(t, usageLogSelectColumns, "session_id",
"SELECT column list must include session_id")
sessionID := "sess-in-query"
log := newSessionIDUsageLog(&sessionID)
prepared := prepareUsageLogInsert(log)
key := usageLogBatchKey(log.RequestID, log.APIKeyID)
batchQuery, batchArgs := buildUsageLogBatchInsertQuery([]string{key},
map[string]usageLogInsertPrepared{key: prepared})
require.Contains(t, batchQuery, "session_id")
// Two column references (INSERT column list + SELECT ... FROM input) plus the CTE def.
require.GreaterOrEqual(t, strings.Count(batchQuery, "session_id"), 3)
require.Len(t, batchArgs, len(prepared.args)+1,
"batch args include the synthetic input_index before usage-log values")
bestEffortQuery, bestEffortArgs := buildUsageLogBestEffortInsertQuery([]usageLogInsertPrepared{prepared})
require.Contains(t, bestEffortQuery, "session_id")
require.Len(t, bestEffortArgs, len(prepared.args))
}
+2
View File
@@ -137,6 +137,7 @@ type BatchImageJob struct {
IdempotencyKey *string
RequestHash *string
ManifestHash *string
SessionID *string
RetryCount int
Version int
@@ -196,6 +197,7 @@ type CreateBatchImageJobParams struct {
IdempotencyKey *string
RequestHash *string
ManifestHash *string
SessionID *string
RetryCount int
@@ -425,6 +425,7 @@ func (r *fakeBatchImageRepository) CreateBatchImageJob(_ context.Context, params
Currency: params.Currency,
IdempotencyKey: params.IdempotencyKey,
RequestHash: params.RequestHash,
SessionID: params.SessionID,
CreatedAt: time.Now(),
}
r.jobs[job.BatchID] = job
@@ -57,6 +57,7 @@ type BatchImageSubmitRequest struct {
AspectRatio string `json:"aspect_ratio"`
ImageSize string `json:"image_size"`
Metadata map[string]string `json:"metadata"`
SessionID *string `json:"-"`
}
type BatchImageSubmitItem struct {
@@ -281,6 +282,7 @@ func (s *BatchImagePublicService) Submit(ctx context.Context, owner BatchImageOw
HoldID: &holdID,
IdempotencyKey: batchImageOptionalStringPtr(idempotencyKey),
RequestHash: batchImageStringPtr(requestHash),
SessionID: normalized.SessionID,
})
if err != nil {
return nil, err
@@ -27,8 +27,10 @@ func TestBatchImagePublicService_Submit(t *testing.T) {
t.Run("accepts valid request stores refs and enqueues once", func(t *testing.T) {
svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
req := validBatchImageSubmitRequest()
req.SessionID = batchImageStringPtr("batch-session-123")
got, err := svc.Submit(ctx, testBatchImageOwner(), validBatchImageSubmitRequest(), "")
got, err := svc.Submit(ctx, testBatchImageOwner(), req, "")
require.NoError(t, err)
require.Equal(t, "image.batch", got.Object)
require.Equal(t, "queued", got.Status)
@@ -61,6 +63,7 @@ func TestBatchImagePublicService_Submit(t *testing.T) {
require.InDelta(t, 0.6, job.HoldMultiplier, 1e-12)
require.InDelta(t, 0.125, job.BillableUnitPrice, 1e-12)
require.InDelta(t, 0.15, job.HoldUnitPrice, 1e-12)
require.Equal(t, "batch-session-123", batchImageDerefString(job.SessionID))
})
t.Run("combines user group image rate account rate discount and hold margin", func(t *testing.T) {
@@ -389,15 +392,18 @@ func TestBatchImagePublicService_Submit(t *testing.T) {
})
t.Run("idempotency returns same batch without provider resubmit", func(t *testing.T) {
svc, _, queue, gemini, _ := newTestBatchImagePublicService(true)
svc, repo, queue, gemini, _ := newTestBatchImagePublicService(true)
req := validBatchImageSubmitRequest()
req.SessionID = batchImageStringPtr("original-session")
first, err := svc.Submit(ctx, testBatchImageOwner(), req, "client-key")
require.NoError(t, err)
req.SessionID = batchImageStringPtr("retry-session")
second, err := svc.Submit(ctx, testBatchImageOwner(), req, "client-key")
require.NoError(t, err)
require.Equal(t, first.ID, second.ID)
require.Equal(t, "original-session", batchImageDerefString(repo.jobs[first.ID].SessionID))
require.Len(t, gemini.submits, 1)
require.Equal(t, []string{first.ID}, queue.enqueued)
})
@@ -277,6 +277,7 @@ func (s *BatchImageSettlementService) recordUsageLog(ctx context.Context, job *B
RequestType: RequestTypeSync,
BillingMode: &billingMode,
ImageSize: &imageSize,
SessionID: job.SessionID,
CreatedAt: createdAt,
}
writeUsageLogBestEffort(ctx, s.UsageLogRepo, usageLog, "service.batch_image_settlement")
@@ -18,9 +18,14 @@ func TestBatchImageSettlementService_SettlesAndChargesSuccessfulImagesOnly(t *te
job.SuccessCount = 3
job.FailCount = 2
job.ItemCount = 5
job.SessionID = batchImageStringPtr("batch-settlement-session")
repo.jobs[job.BatchID] = job
billing := &fakeBatchImageBillingRepo{}
svc := &BatchImageSettlementService{Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25}}
usageLogs := &openAIRecordUsageLogRepoStub{}
svc := &BatchImageSettlementService{
Repo: repo, BillingRepo: billing, Pricing: &fakeBatchImagePricingResolver{unitPrice: 0.25},
UsageLogRepo: usageLogs,
}
result, err := svc.Settle(context.Background(), job.BatchID)
require.NoError(t, err)
@@ -32,6 +37,7 @@ func TestBatchImageSettlementService_SettlesAndChargesSuccessfulImagesOnly(t *te
require.Equal(t, 0.75, *repo.jobs[job.BatchID].ActualCost)
require.NotEmpty(t, batchImageDerefString(repo.jobs[job.BatchID].ManifestHash))
require.NotNil(t, repo.jobs[job.BatchID].SettledAt)
require.Equal(t, "batch-settlement-session", batchImageDerefString(usageLogs.lastLog.SessionID))
require.Len(t, billing.captures, 1)
require.Equal(t, int64(321), billing.captures[0].APIKeyID)
require.Equal(t, job.UserID, billing.captures[0].UserID)
+7 -2
View File
@@ -347,8 +347,13 @@ func removeCustomFieldFromTools(body []byte) []byte {
}
// claudeVersionRe 匹配 Claude 模型 ID 中的版本号部分
// 支持 claude-{tier}-{major}-{minor} 和 claude-{tier}-{major}.{minor} 格式
var claudeVersionRe = regexp.MustCompile(`claude-(?:haiku|sonnet|opus)-(\d+)[-.](\d+)`)
// 支持 claude-{tier}-{major}-{minor} 和 claude-{tier}-{major}.{minor} 格式;
// minor 可选,用于覆盖只有主版本号的新模型 ID(claude-opus-5 / claude-sonnet-5),
// 此时 matches[2] 为空串,调用方 strconv.Atoi 得到 minor=0。
// 缺少可选 minor 时这类 ID 完全不匹配,会被当作"旧模型"降级处理:
// thinking.enabled 不转 adaptive(Opus 5 上游已移除 budget_tokens,直接 400)、
// cache_control.ttl 被剥离、tool search 被过滤。
var claudeVersionRe = regexp.MustCompile(`claude-(?:haiku|sonnet|opus)-(\d+)(?:[-.](\d+))?`)
// isBedrockClaude45OrNewer 判断 Bedrock 模型 ID 是否为 Claude 4.5 或更新版本
// Claude 4.5+ 支持 cache_control 中的 ttl 字段("5m" 和 "1h")
@@ -258,6 +258,11 @@ func (s *BillingService) initFallbackPricing() {
// Claude 4.7 Opus (暂与4.6同价,待官方定价更新)
s.fallbackPrices["claude-opus-4.7"] = s.fallbackPrices["claude-opus-4.6"]
// Claude 4.8 Opus / Claude Opus 5(官方同价:$5 输入 / $25 输出 per MTok)。
// 缺少这两条时 getFallbackPricing 会掉到 claude-3-opus($15/$75),造成 3 倍超收。
s.fallbackPrices["claude-opus-4.8"] = s.fallbackPrices["claude-opus-4.7"]
s.fallbackPrices["claude-opus-5"] = s.fallbackPrices["claude-opus-4.8"]
// Gemini 3.1 Pro
s.fallbackPrices["gemini-3.1-pro"] = &ModelPricing{
InputPricePerToken: 2e-6, // $2 per MTok
@@ -588,6 +593,13 @@ func (s *BillingService) getFallbackPricing(model string) *ModelPricing {
// 按模型系列匹配
if strings.Contains(modelLower, "opus") {
// "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
if strings.Contains(modelLower, "opus-5") || strings.Contains(modelLower, "opus5") {
return s.fallbackPrices["claude-opus-5"]
}
if strings.Contains(modelLower, "4.8") || strings.Contains(modelLower, "4-8") {
return s.fallbackPrices["claude-opus-4.8"]
}
if strings.Contains(modelLower, "4.7") || strings.Contains(modelLower, "4-7") {
return s.fallbackPrices["claude-opus-4.7"]
}
@@ -0,0 +1,139 @@
package service
import (
"testing"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/Wei-Shaw/sub2api/internal/domain"
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// Claude Opus 5 官方定价(USD per token):$5 输入 / $25 输出 per MTok。
const (
opus5InputPricePerToken = 5e-6
opus5OutputPricePerToken = 25e-6
opus5CacheCreationPricePerToken = 6.25e-6
opus5CacheReadPricePerToken = 0.5e-6
)
// TestClaudeOpus5_FamilyFallbackDoesNotUseOpus4Rates 覆盖定价数据里还没有
// claude-opus-5 条目的场景(远端价格表滞后于模型发布)。
// 修复前 matchByModelFamily 会把 claude-opus-5 归到 "opus-4" 系列,
// 按 $15/$75 计费 —— 输入与输出双双 3 倍超收。
func TestClaudeOpus5_FamilyFallbackDoesNotUseOpus4Rates(t *testing.T) {
svc := NewBillingService(&config.Config{}, &PricingService{
pricingData: map[string]*LiteLLMModelPricing{
// 有 4.8(同价),故意不放 claude-opus-5
"claude-opus-4-8": {
InputCostPerToken: opus5InputPricePerToken,
OutputCostPerToken: opus5OutputPricePerToken,
CacheCreationInputTokenCost: opus5CacheCreationPricePerToken,
CacheReadInputTokenCost: opus5CacheReadPricePerToken,
},
// 修复前会被误命中的旧 Opus 条目($15/$75)
"claude-opus-4-1": {InputCostPerToken: 15e-6, OutputCostPerToken: 75e-6},
"claude-opus-4-20250514": {InputCostPerToken: 15e-6, OutputCostPerToken: 75e-6},
"claude-3-opus-20240229": {InputCostPerToken: 15e-6, OutputCostPerToken: 75e-6},
},
})
for _, model := range []string{"claude-opus-5", "us.anthropic.claude-opus-5-v1"} {
t.Run(model, func(t *testing.T) {
pricing, err := svc.GetModelPricing(model)
require.NoError(t, err)
require.NotNil(t, pricing)
assert.InDelta(t, opus5InputPricePerToken, pricing.InputPricePerToken, 1e-12)
assert.InDelta(t, opus5OutputPricePerToken, pricing.OutputPricePerToken, 1e-12)
})
}
}
// TestClaudeOpus5_HardcodedFallbackPricing 覆盖动态价格服务完全不可用时的
// 硬编码兜底表。同时锁定不能被 "opus-5" 子串误伤的相邻型号。
func TestClaudeOpus5_HardcodedFallbackPricing(t *testing.T) {
// pricingService 为 nil,强制走硬编码兜底表
svc := NewBillingService(&config.Config{}, nil)
tests := []struct {
model string
input float64
output float64
}{
{"claude-opus-5", opus5InputPricePerToken, opus5OutputPricePerToken},
{"us.anthropic.claude-opus-5-v1", opus5InputPricePerToken, opus5OutputPricePerToken},
// 4.8 与 5 同价;修复前兜底表缺失,会掉到 claude-3-opus 的 $15/$75
{"claude-opus-4-8", opus5InputPricePerToken, opus5OutputPricePerToken},
// 相邻型号不能被 "opus-5" 误匹配
{"claude-opus-4-5-20251101", 5e-6, 25e-6},
{"claude-opus-4-1-20250805", 15e-6, 75e-6},
{"claude-3-opus-20240229", 15e-6, 75e-6},
}
for _, tt := range tests {
t.Run(tt.model, func(t *testing.T) {
pricing, err := svc.GetModelPricing(tt.model)
require.NoError(t, err)
require.NotNil(t, pricing)
assert.InDelta(t, tt.input, pricing.InputPricePerToken, 1e-12)
assert.InDelta(t, tt.output, pricing.OutputPricePerToken, 1e-12)
})
}
opus5, err := svc.GetModelPricing("claude-opus-5")
require.NoError(t, err)
assert.InDelta(t, opus5CacheCreationPricePerToken, opus5.CacheCreationPricePerToken, 1e-12)
assert.InDelta(t, opus5CacheReadPricePerToken, opus5.CacheReadPricePerToken, 1e-12)
}
// TestClaudeOpus5_BedrockCapabilityGates 锁定只有主版本号的模型 ID
// (claude-opus-5 / claude-sonnet-5)能被版本闸门识别。
// 修复前 claudeVersionRe 强制要求 major-minor,这类 ID 完全不匹配,
// 会被当成旧模型降级。
func TestClaudeOpus5_BedrockCapabilityGates(t *testing.T) {
tests := []struct {
modelID string
claude45Newer bool
toolSearch bool
opus47Newer bool
}{
{"claude-opus-5", true, true, true},
{"us.anthropic.claude-opus-5-v1", true, true, true},
{"eu.anthropic.claude-opus-5-v1", true, true, true},
{"claude-sonnet-5", true, true, false},
{"us.anthropic.claude-sonnet-5-v1", true, true, false},
// 回归保护:旧模型不能因为 minor 可选而被误判为新版本
{"anthropic.claude-opus-4-1-v1", false, false, false},
{"anthropic.claude-sonnet-4-0-v1", false, false, false},
{"anthropic.claude-3-opus-20240229-v1:0", false, false, false},
{"us.anthropic.claude-opus-4-8-v1", true, true, true},
// Haiku 不支持 tool search
{"us.anthropic.claude-haiku-4-5-20251001-v1:0", true, false, false},
}
for _, tt := range tests {
t.Run(tt.modelID, func(t *testing.T) {
assert.Equal(t, tt.claude45Newer, isBedrockClaude45OrNewer(tt.modelID), "isBedrockClaude45OrNewer")
assert.Equal(t, tt.toolSearch, bedrockModelSupportsToolSearch(tt.modelID), "bedrockModelSupportsToolSearch")
assert.Equal(t, tt.opus47Newer, isBedrockOpus47OrNewer(tt.modelID), "isBedrockOpus47OrNewer")
})
}
}
// TestClaudeOpus5_BedrockThinkingConvertedToAdaptive 验证 Opus 5 在 Bedrock 路径上
// 会把 thinking.type=enabled 转成 adaptive 并移除 budget_tokens。
// 上游 Opus 5 已移除 budget_tokens,透传过去会直接 400。
func TestClaudeOpus5_BedrockThinkingConvertedToAdaptive(t *testing.T) {
body := []byte(`{"thinking":{"type":"enabled","budget_tokens":10000}}`)
got := sanitizeBedrockThinking(body, "us.anthropic.claude-opus-5-v1")
assert.JSONEq(t, `{"thinking":{"type":"adaptive"}}`, string(got))
}
// TestClaudeOpus5_CatalogAndBedrockMapping 锁定模型清单与 Bedrock 默认映射。
func TestClaudeOpus5_CatalogAndBedrockMapping(t *testing.T) {
assert.Contains(t, claude.DefaultModelIDs(), "claude-opus-5")
mapped, ok := domain.DefaultBedrockModelMapping["claude-opus-5"]
require.True(t, ok, "claude-opus-5 missing from DefaultBedrockModelMapping")
assert.Equal(t, "us.anthropic.claude-opus-5-v1", mapped)
}
@@ -46,6 +46,7 @@ type RecordUsageInput struct {
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
ForceCacheBilling bool // 强制缓存计费:将 input_tokens 转为 cache_read 计费(用于粘性会话切换)
APIKeyService APIKeyQuotaUpdater // 可选:用于更新API Key配额
@@ -572,6 +573,7 @@ func (s *GatewayService) RecordUsage(ctx context.Context, input *RecordUsageInpu
UpstreamEndpoint: input.UpstreamEndpoint,
UserAgent: input.UserAgent,
IPAddress: input.IPAddress,
SessionID: input.SessionID,
RequestPayloadHash: input.RequestPayloadHash,
ForceCacheBilling: input.ForceCacheBilling,
APIKeyService: input.APIKeyService,
@@ -591,6 +593,7 @@ type RecordUsageLongContextInput struct {
UpstreamEndpoint string // 上游端点(标准化后的上游路径)
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
RequestPayloadHash string // 请求体语义哈希,用于降低 request_id 误复用时的静默误去重风险
LongContextThreshold int // 长上下文阈值(如 200000)
LongContextMultiplier float64 // 超出阈值部分的倍率(如 2.0)
@@ -613,6 +616,7 @@ func (s *GatewayService) RecordUsageWithLongContext(ctx context.Context, input *
UpstreamEndpoint: input.UpstreamEndpoint,
UserAgent: input.UserAgent,
IPAddress: input.IPAddress,
SessionID: input.SessionID,
RequestPayloadHash: input.RequestPayloadHash,
ForceCacheBilling: input.ForceCacheBilling,
APIKeyService: input.APIKeyService,
@@ -635,6 +639,7 @@ type recordUsageCoreInput struct {
UpstreamEndpoint string
UserAgent string
IPAddress string
SessionID string
RequestPayloadHash string
ForceCacheBilling bool
APIKeyService APIKeyQuotaUpdater
@@ -1010,6 +1015,7 @@ func (s *GatewayService) buildRecordUsageLog(
ModelMappingChain: optionalTrimmedStringPtr(input.ModelMappingChain),
UserAgent: optionalTrimmedStringPtr(input.UserAgent),
IPAddress: optionalTrimmedStringPtr(input.IPAddress),
SessionID: optionalTrimmedStringPtr(input.SessionID),
GroupID: apiKey.GroupID,
SubscriptionID: optionalSubscriptionID(subscription),
CreatedAt: time.Now(),
@@ -91,7 +91,12 @@ func (s *OpenAIGatewayService) handleOpenAIAccountUpstreamError(ctx context.Cont
if shouldDisable && !modelTempMatched {
s.BlockAccountScheduling(account, time.Time{}, "upstream_disable")
}
if !shouldDisable && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey && shouldCooldownOpenAITransientUpstreamError(statusCode, responseBody) {
// Pool-mode retryable upstream errors are already bounded by the request-local
// same-account retry budget. Recording the generic account+model transient
// cooldown here would block the next approved retry before that budget is used.
poolModeRetryable := account.IsPoolMode() && account.IsPoolModeRetryableStatus(statusCode)
if !shouldDisable && account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey &&
shouldCooldownOpenAITransientUpstreamError(statusCode, responseBody) && !poolModeRetryable {
model := ""
if len(canonicalModel) > 0 {
model = canonicalModel[0]
@@ -142,6 +142,89 @@ func TestOpenAIPoolModeTempRule_StopsSameAccountRetryAndIsolatesBlockToModel(t *
require.False(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.5"))
}
func TestOpenAIPoolModeRetryable5xx_DoesNotCreateModelTransientBlock(t *testing.T) {
repo := &errorPolicyRepoStub{}
rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
gateway := &OpenAIGatewayService{rateLimitService: rateLimitService}
account := &Account{
ID: 47,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"pool_mode": true,
"pool_mode_retry_status_codes": []any{float64(524)},
},
}
for i := 0; i < 2; i++ {
shouldDisable := gateway.handleOpenAIAccountUpstreamError(
context.Background(),
account,
524,
http.Header{},
[]byte(`{"error":{"message":"upstream timeout"}}`),
"gpt-5.4",
)
require.False(t, shouldDisable)
}
require.False(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4"))
}
func TestOpenAIPoolModeNonRetryable5xx_StillCreatesModelTransientBlock(t *testing.T) {
repo := &errorPolicyRepoStub{}
rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
gateway := &OpenAIGatewayService{rateLimitService: rateLimitService}
account := &Account{
ID: 48,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"pool_mode": true,
"pool_mode_retry_status_codes": []any{float64(http.StatusGatewayTimeout)},
},
}
for i := 0; i < 2; i++ {
shouldDisable := gateway.handleOpenAIAccountUpstreamError(
context.Background(),
account,
http.StatusServiceUnavailable,
http.Header{},
[]byte(`{"error":{"message":"upstream unavailable"}}`),
"gpt-5.4",
)
require.False(t, shouldDisable)
}
require.True(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4"))
}
func TestOpenAINonPoolAPIKey5xx_StillCreatesModelTransientBlock(t *testing.T) {
repo := &errorPolicyRepoStub{}
rateLimitService := NewRateLimitService(repo, nil, &config.Config{}, nil, nil)
gateway := &OpenAIGatewayService{rateLimitService: rateLimitService}
account := &Account{
ID: 49,
Platform: PlatformOpenAI,
Type: AccountTypeAPIKey,
}
for i := 0; i < 2; i++ {
shouldDisable := gateway.handleOpenAIAccountUpstreamError(
context.Background(),
account,
http.StatusGatewayTimeout,
http.Header{},
[]byte(`{"error":{"message":"upstream timeout"}}`),
"gpt-5.4",
)
require.False(t, shouldDisable)
}
require.True(t, gateway.isOpenAIAccountRequestRuntimeBlocked(account, "gpt-5.4"))
}
func TestOpenAIModelNotFound_DoesNotRuntimeBlockWholeAccount(t *testing.T) {
repo := &modelNotFoundAccountRepoStub{}
svc := &OpenAIGatewayService{
@@ -1494,26 +1494,10 @@ func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []an
if !opts.PreserveReferences {
ensureCopy()
delete(newItem, "id")
} else if isCodexToolCallInputType(typ) {
// 续链模式下保留 id 以维持上下文引用,但 function_call 等
// call-input 类 item 的 id 必须以 "fc" 开头(上游校验
// "Expected an ID that begins with 'fc'")。item_* 形式的 id
// 来自客户端回放,需要删除。
// 注意:function_call_output 等 output 类的 id 无此约束,不动。
if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "fc") {
} else if id, ok := m["id"].(string); ok && shouldStripOpenAIResponsesInputItemID(typ, id) {
ensureCopy()
delete(newItem, "id")
}
} else if typ == "message" {
// 同理,message 类 item 的 id 必须以 "msg" 开头(上游校验
// "Expected an ID that begins with 'msg'")。item_* 形式的 id
// 来自客户端回放,需要删除。
// 注意:不改写成 msg_*,改写出的 id 未必对应真实的上游对象。
if id, ok := m["id"].(string); ok && id != "" && !strings.HasPrefix(id, "msg") {
ensureCopy()
delete(newItem, "id")
}
}
filtered = append(filtered, newItem)
}
@@ -0,0 +1,90 @@
//go:build unit
package service
import (
"context"
"fmt"
"io"
"net/http"
"runtime"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestOpenAIGatewayService_APIKeyPassthrough_StripsInvalidInputItemIDs(t *testing.T) {
gin.SetMode(gin.TestMode)
upstream := &httpUpstreamRecorder{resp: &http.Response{
StatusCode: http.StatusOK,
Header: http.Header{"Content-Type": []string{"application/json"}},
Body: io.NopCloser(strings.NewReader(
`{"id":"resp_test","model":"gpt-5.6-sol","output":[],"usage":{"input_tokens":1,"output_tokens":1,"total_tokens":2}}`,
)),
}}
svc := newOpenAIImageGenerationControlTestService(upstream)
c, _ := newOpenAIImageGenerationControlTestContext(true, "codex_cli_rs/0.144.1")
account := newOpenAIImageGenerationControlTestAccount()
account.Extra = map[string]any{"openai_passthrough": true}
body := []byte(`{
"model":"gpt-5.6-sol",
"stream":false,
"input":[
{"type":"message","id":"item_bad_message","role":"assistant","content":[{"type":"output_text","text":"hello"}]},
{"type":"function_call","id":"item_bad_call","call_id":"call_123","name":"exec_command","arguments":"{}"},
{"type":"message","id":"msg_valid","role":"user","content":[{"type":"input_text","text":"continue"}]},
{"type":"function_call","id":"fc_valid","call_id":"call_456","name":"apply_patch","arguments":"{}"},
{"type":"function_call_output","id":"item_output","call_id":"call_123","output":"done"},
{"type":"web_search_call","id":"item_unconstrained"}
]
}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
require.NotNil(t, upstream.lastReq)
forwarded := upstream.lastBody
require.False(t, gjson.GetBytes(forwarded, "input.0.id").Exists())
require.Equal(t, "hello", gjson.GetBytes(forwarded, "input.0.content.0.text").String())
require.False(t, gjson.GetBytes(forwarded, "input.1.id").Exists())
require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.1.call_id").String())
require.Equal(t, "exec_command", gjson.GetBytes(forwarded, "input.1.name").String())
require.Equal(t, "{}", gjson.GetBytes(forwarded, "input.1.arguments").String())
require.Equal(t, "msg_valid", gjson.GetBytes(forwarded, "input.2.id").String())
require.Equal(t, "fc_valid", gjson.GetBytes(forwarded, "input.3.id").String())
require.Equal(t, "item_output", gjson.GetBytes(forwarded, "input.4.id").String())
require.Equal(t, "call_123", gjson.GetBytes(forwarded, "input.4.call_id").String())
require.Equal(t, "item_unconstrained", gjson.GetBytes(forwarded, "input.5.id").String())
}
func TestSanitizeOpenAIResponsesInputItemIDs_AllocationGrowthIsLinear(t *testing.T) {
makeBody := func(itemCount int) []byte {
items := make([]string, itemCount)
for i := range items {
items[i] = fmt.Sprintf(`{"type":"message","id":"item_%d","role":"user","content":[{"type":"input_text","text":"hello"}]}`, i)
}
return []byte(`{"model":"gpt-5.6-sol","input":[` + strings.Join(items, ",") + `]}`)
}
allocatedBytes := func(body []byte) uint64 {
runtime.GC()
var before, after runtime.MemStats
runtime.ReadMemStats(&before)
sanitized, changed, err := sanitizeOpenAIResponsesInputItemIDs(body)
runtime.ReadMemStats(&after)
require.NoError(t, err)
require.True(t, changed)
require.NotEmpty(t, sanitized)
return after.TotalAlloc - before.TotalAlloc
}
smallAllocated := allocatedBytes(makeBody(20))
largeAllocated := allocatedBytes(makeBody(200))
require.Less(t, largeAllocated, smallAllocated*30,
"10x more input items must not cause quadratic whole-body allocation growth")
}
@@ -72,6 +72,16 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
return nil, err
}
}
if shouldStripOpenAIResponsesInputNamespaces(account, wsDecision.Transport, passthroughEnabled) {
body, err = stripOpenAIResponsesInputNamespaces(body)
if err != nil {
setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "")
c.JSON(http.StatusBadRequest, gin.H{"error": gin.H{
"type": "invalid_request_error", "message": err.Error(), "param": "input",
}})
return nil, err
}
}
originalBody := body
requestView := newOpenAIRequestView(body)
@@ -85,6 +95,19 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco
if account.Type == AccountTypeAPIKey && !openai_compat.ShouldUseResponsesAPI(account.Extra) {
return s.forwardResponsesViaRawChatCompletions(ctx, c, account, body)
}
if account.Platform == PlatformOpenAI && account.Type == AccountTypeAPIKey {
sanitizedBody, changed, sanitizeErr := sanitizeOpenAIResponsesInputItemIDs(body)
if sanitizeErr != nil {
return nil, fmt.Errorf("sanitize OpenAI Responses input item IDs: %w", sanitizeErr)
}
if changed {
body = sanitizedBody
originalBody = sanitizedBody
requestView = newOpenAIRequestView(sanitizedBody)
reqModel, reqStream, promptCacheKey = requestView.Model, requestView.Stream, requestView.PromptCacheKey
originalModel = reqModel
}
}
compatMessagesBridge := isOpenAICompatMessagesBridgeBody(body)
setOpenAICompatMessagesBridgeContext(c, compatMessagesBridge)
@@ -1363,7 +1363,7 @@ func (s *OpenAIGatewayService) handleGrokAccountUpstreamError(ctx context.Contex
case http.StatusTooManyRequests:
// updateGrokUsageSnapshot installs both runtime and durable rate-limit state.
default:
if statusCode >= 500 {
if statusCode >= 500 && !account.IsPoolMode() {
s.tempUnscheduleGrok(ctx, account, 2*time.Minute, "grok upstream temporary error")
}
}
@@ -125,6 +125,11 @@ func isGrokRequestContext(c *gin.Context) bool {
if c == nil {
return false
}
if c.Request != nil {
if platform, ok := ResolvedTargetPlatformFromContext(c.Request.Context()); ok {
return platform == PlatformGrok
}
}
v, exists := c.Get("api_key")
if !exists {
return false
@@ -2475,6 +2475,43 @@ func TestHandleGrokAccountUpstreamErrorTempUnschedulesNonRateLimitStates(t *test
}
}
func TestHandleGrokAccountUpstreamError5xxRespectsPoolMode(t *testing.T) {
t.Run("pool mode keeps scheduling state", func(t *testing.T) {
account := &Account{
ID: 611,
Platform: PlatformGrok,
Type: AccountTypeAPIKey,
Credentials: map[string]any{
"pool_mode": true,
},
}
repo := &grokQuotaAccountRepo{}
svc := &OpenAIGatewayService{accountRepo: repo}
svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusBadGateway, nil, nil)
require.False(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.Zero(t, repo.tempUnschedCalls)
require.Nil(t, account.TempUnschedulableUntil)
require.Empty(t, account.TempUnschedulableReason)
})
t.Run("non-pool mode keeps two minute cooldown", func(t *testing.T) {
account := &Account{ID: 612, Platform: PlatformGrok, Type: AccountTypeAPIKey}
repo := &grokQuotaAccountRepo{}
svc := &OpenAIGatewayService{accountRepo: repo}
before := time.Now()
svc.handleGrokAccountUpstreamError(context.Background(), account, http.StatusBadGateway, nil, nil)
require.True(t, svc.isOpenAIAccountRuntimeBlocked(account))
require.Equal(t, 1, repo.tempUnschedCalls)
require.Equal(t, account.ID, repo.lastTempUnschedID)
require.Equal(t, "grok upstream temporary error", repo.lastTempUnschedReason)
require.WithinDuration(t, before.Add(2*time.Minute), repo.lastTempUnschedUntil, time.Second)
})
}
func TestHandleGrokAccountUpstreamError429SetsRateLimitedFromRetryAfter(t *testing.T) {
account := &Account{ID: 61, Platform: PlatformGrok, Type: AccountTypeOAuth}
repo := &grokQuotaAccountRepo{}
@@ -26,6 +26,15 @@ const (
codeBuddyConversationHeader = "X-Conversation-ID"
)
var explicitOpenAIHeaderSessionNames = []string{
"session_id",
"conversation_id",
openCodeSessionAffinityHeader,
openCodeSessionIDHeader,
openCodeNativeSessionHeader,
codeBuddyConversationHeader,
}
// explicitOpenAIHeaderSessionID resolves stable conversation identifiers sent
// by OpenAI-compatible clients. Keep this list limited to session-scoped
// fields: request/message IDs rotate every turn and would defeat sticky routing
@@ -35,14 +44,7 @@ func explicitOpenAIHeaderSessionID(c *gin.Context) string {
return ""
}
for _, header := range []string{
"session_id",
"conversation_id",
openCodeSessionAffinityHeader,
openCodeSessionIDHeader,
openCodeNativeSessionHeader,
codeBuddyConversationHeader,
} {
for _, header := range explicitOpenAIHeaderSessionNames {
if sessionID := strings.TrimSpace(c.GetHeader(header)); sessionID != "" {
return sessionID
}
@@ -29,6 +29,7 @@ type OpenAIRecordUsageInput struct {
UpstreamEndpoint string
UserAgent string // 请求的 User-Agent
IPAddress string // 请求的客户端 IP 地址
SessionID string // 客户端显式会话标识(session_id / X-Session-Id 等请求头),仅用于用量行会话关联
RequestPayloadHash string
APIKeyService APIKeyQuotaUpdater
QuotaPlatform string // user×platform quota platform resolved by the handler before async billing.
@@ -55,6 +56,7 @@ type CyberPolicyUsageInput struct {
UpstreamEndpoint string
UserAgent string
IPAddress string
SessionID string
RequestPayloadHash string
APIKeyService APIKeyQuotaUpdater
ChannelUsageFields
@@ -89,6 +91,7 @@ func (s *OpenAIGatewayService) RecordCyberPolicyUsageLog(ctx context.Context, in
UpstreamEndpoint: in.UpstreamEndpoint,
UserAgent: in.UserAgent,
IPAddress: in.IPAddress,
SessionID: in.SessionID,
RequestPayloadHash: in.RequestPayloadHash,
APIKeyService: in.APIKeyService,
ChannelUsageFields: in.ChannelUsageFields,
@@ -333,6 +336,9 @@ func (s *OpenAIGatewayService) RecordUsage(ctx context.Context, input *OpenAIRec
usageLog.IPAddress = &input.IPAddress
}
// 添加 SessionID(客户端显式会话标识;缺失/无效时保持 nil)
usageLog.SessionID = optionalTrimmedStringPtr(input.SessionID)
if apiKey.GroupID != nil {
usageLog.GroupID = apiKey.GroupID
}
@@ -450,7 +450,10 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
{"type":"namespace","name":"collaboration","tools":[{"type":"function","name":"spawn_agent","description":"spawn","parameters":{"type":"object"}}]}
],
"tool_choice":{"type":"function","name":"spawn_agent","namespace":"collaboration"},
"input":[{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"}]
"input":[
{"type":"function_call","call_id":"call_old","name":"spawn_agent","namespace":"collaboration","arguments":"{}"},
{"type":"message","role":"user","namespace":"residual","content":[{"type":"input_text","text":"keep","namespace":"nested"}]}
]
}`)
upstreamSSE := strings.Join([]string{
@@ -488,6 +491,9 @@ func TestOpenAIGatewayService_OAuthPassthrough_NamespaceRequestAndStreamResponse
require.False(t, gjson.GetBytes(upstream.lastBody, "tool_choice.namespace").Exists())
require.Equal(t, "collaboration__spawn_agent", gjson.GetBytes(upstream.lastBody, "input.0.name").String())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.0.namespace").Exists())
require.False(t, gjson.GetBytes(upstream.lastBody, "input.1.namespace").Exists())
require.Equal(t, "nested", gjson.GetBytes(upstream.lastBody, "input.1.content.0.namespace").String())
require.Len(t, upstream.bodies, 1)
downstream := rec.Body.String()
require.NotContains(t, downstream, "collaboration__spawn_agent")
@@ -0,0 +1,78 @@
package service
import (
"fmt"
"strings"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
// Invalid replayed IDs are removed rather than rewritten because a fabricated
// msg/fc ID may point at a different upstream object.
func shouldStripOpenAIResponsesInputItemID(itemType, id string) bool {
if id == "" {
return false
}
if itemType == "message" {
return !strings.HasPrefix(id, "msg")
}
if isCodexToolCallInputType(itemType) {
return !strings.HasPrefix(id, "fc")
}
return false
}
func sanitizeOpenAIResponsesInputItemIDs(body []byte) ([]byte, bool, error) {
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, false, nil
}
items := make([][]byte, 0)
changed := false
var sanitizeErr error
index := 0
input.ForEach(func(_, item gjson.Result) bool {
currentIndex := index
index++
itemBody := []byte(item.Raw)
if item.IsObject() {
itemType := item.Get("type")
id := item.Get("id")
if itemType.Type == gjson.String && id.Type == gjson.String &&
shouldStripOpenAIResponsesInputItemID(itemType.String(), id.String()) {
itemBody, sanitizeErr = sjson.DeleteBytes(itemBody, "id")
if sanitizeErr != nil {
sanitizeErr = fmt.Errorf("delete input.%d.id: %w", currentIndex, sanitizeErr)
return false
}
changed = true
}
}
items = append(items, itemBody)
return true
})
if sanitizeErr != nil {
return nil, false, sanitizeErr
}
if !changed {
return body, false, nil
}
rebuiltInput := make([]byte, 0, len(input.Raw))
rebuiltInput = append(rebuiltInput, '[')
for i, item := range items {
if i > 0 {
rebuiltInput = append(rebuiltInput, ',')
}
rebuiltInput = append(rebuiltInput, item...)
}
rebuiltInput = append(rebuiltInput, ']')
sanitized, err := sjson.SetRawBytes(body, "input", rebuiltInput)
if err != nil {
return nil, false, fmt.Errorf("replace sanitized input: %w", err)
}
return sanitized, true, nil
}
@@ -7,6 +7,8 @@ import (
"github.com/Wei-Shaw/sub2api/internal/pkg/apicompat"
"github.com/gin-gonic/gin"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
const openAIResponsesNamespaceNamesContextKey = "openai_responses_namespace_names"
@@ -26,6 +28,19 @@ func shouldFlattenOpenAIResponsesNamespaces(account *Account, transport OpenAIUp
return true
}
// shouldStripOpenAIResponsesInputNamespaces removes residual input item
// namespaces for OpenAI OAuth and API Key HTTP forwarding. Native WSv2 keeps
// namespaces because that protocol supports them and does not restore payloads.
func shouldStripOpenAIResponsesInputNamespaces(account *Account, transport OpenAIUpstreamTransport, passthroughEnabled bool) bool {
if account == nil || (!account.IsOpenAIOAuth() && !account.IsOpenAIApiKey()) {
return false
}
if transport == OpenAIUpstreamTransportResponsesWebsocketV2 && !passthroughEnabled {
return false
}
return true
}
func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"namespace"`)) {
return body, nil
@@ -49,6 +64,55 @@ func flattenOpenAIResponsesNamespaces(c *gin.Context, body []byte) ([]byte, erro
return rebuilt, nil
}
// stripOpenAIResponsesInputNamespaces removes namespace only from direct input
// array items. Namespace declarations and nested namespace fields are left
// untouched. Rebuilding the input array once keeps this linear for long
// histories and avoids decoding JSON numbers through float64.
func stripOpenAIResponsesInputNamespaces(body []byte) ([]byte, error) {
if !bytes.Contains(body, []byte(`"namespace"`)) {
return body, nil
}
input := gjson.GetBytes(body, "input")
if !input.IsArray() {
return body, nil
}
var rebuilt bytes.Buffer
rebuilt.Grow(len(input.Raw))
_ = rebuilt.WriteByte('[')
changed := false
first := true
var stripErr error
input.ForEach(func(_, item gjson.Result) bool {
if !first {
_ = rebuilt.WriteByte(',')
}
first = false
itemBody := []byte(item.Raw)
if item.IsObject() && item.Get("namespace").Exists() {
itemBody, stripErr = sjson.DeleteBytes(itemBody, "namespace")
if stripErr != nil {
return false
}
changed = true
}
_, _ = rebuilt.Write(itemBody)
return true
})
if stripErr != nil {
return body, fmt.Errorf("delete OpenAI input namespace: %w", stripErr)
}
if !changed {
return body, nil
}
_ = rebuilt.WriteByte(']')
stripped, err := sjson.SetRawBytes(body, "input", rebuilt.Bytes())
if err != nil {
return body, fmt.Errorf("replace OpenAI input after namespace deletion: %w", err)
}
return stripped, nil
}
func setOpenAIResponsesNamespaceNames(c *gin.Context, names map[string]apicompat.ResponsesNamespaceName) {
if c != nil && len(names) > 0 {
c.Set(openAIResponsesNamespaceNamesContextKey, names)
@@ -1,9 +1,11 @@
package service
import (
"strconv"
"testing"
"github.com/stretchr/testify/require"
"github.com/tidwall/gjson"
)
func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
@@ -34,3 +36,78 @@ func TestShouldFlattenOpenAIResponsesNamespaces(t *testing.T) {
})
}
}
func TestShouldStripOpenAIResponsesInputNamespaces(t *testing.T) {
oauth := &Account{Platform: PlatformOpenAI, Type: AccountTypeOAuth}
apiKey := &Account{Platform: PlatformOpenAI, Type: AccountTypeAPIKey}
setupToken := &Account{Platform: PlatformOpenAI, Type: AccountTypeSetupToken}
grokOAuth := &Account{Platform: PlatformGrok, Type: AccountTypeOAuth}
tests := []struct {
name string
account *Account
transport OpenAIUpstreamTransport
passthroughEnabled bool
want bool
}{
{name: "oauth_http", account: oauth, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "apikey_http", account: apiKey, transport: OpenAIUpstreamTransportHTTPSSE, want: true},
{name: "oauth_wsv2", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
{name: "apikey_wsv2", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, want: false},
{name: "oauth_wsv2_passthrough", account: oauth, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
{name: "apikey_wsv2_passthrough", account: apiKey, transport: OpenAIUpstreamTransportResponsesWebsocketV2, passthroughEnabled: true, want: true},
{name: "setup_token_http", account: setupToken, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "grok_oauth_http", account: grokOAuth, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
{name: "nil_account", account: nil, transport: OpenAIUpstreamTransportHTTPSSE, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
require.Equal(t, tt.want, shouldStripOpenAIResponsesInputNamespaces(tt.account, tt.transport, tt.passthroughEnabled))
})
}
}
func TestStripOpenAIResponsesInputNamespaces(t *testing.T) {
body := []byte(`{
"meta":9007199254740993,
"scientific":1.25e+42,
"escaped":"line\\n\\u003ctag\\u003e",
"tools":[{"type":"function","name":"keep","namespace":"tool-namespace"}],
"input":[
{"type":"function_call","namespace":"n0","name":"one","content":{"namespace":"nested"},"large":9007199254740993},
{"type":"message","namespace":"n1","content":[{"type":"input_text","text":"hello","namespace":"nested-content"}]},
{"type":"custom_tool_call","namespace":"n2","input":"{}"},
{"type":"function_call_output","namespace":"n3","output":"ok"},
{"type":"item","namespace":"n4"},
{"type":"item","namespace":"n5"},
{"type":"item","namespace":"n6"},
{"type":"item","namespace":"n7"}
]
}`)
stripped, err := stripOpenAIResponsesInputNamespaces(body)
require.NoError(t, err)
for index := 0; index < 8; index++ {
require.False(t, gjson.GetBytes(stripped, "input."+strconv.Itoa(index)+".namespace").Exists())
}
require.Equal(t, "nested", gjson.GetBytes(stripped, "input.0.content.namespace").String())
require.Equal(t, "nested-content", gjson.GetBytes(stripped, "input.1.content.0.namespace").String())
require.Equal(t, "tool-namespace", gjson.GetBytes(stripped, "tools.0.namespace").String())
require.Equal(t, gjson.GetBytes(body, "meta").Raw, gjson.GetBytes(stripped, "meta").Raw)
require.Equal(t, gjson.GetBytes(body, "scientific").Raw, gjson.GetBytes(stripped, "scientific").Raw)
require.Equal(t, gjson.GetBytes(body, "escaped").Raw, gjson.GetBytes(stripped, "escaped").Raw)
require.Equal(t, gjson.GetBytes(body, "input.0.large").Raw, gjson.GetBytes(stripped, "input.0.large").Raw)
}
func TestStripOpenAIResponsesInputNamespacesLeavesOtherShapesByteExact(t *testing.T) {
tests := [][]byte{
[]byte(`{"input":"text","namespace":"top-level"}`),
[]byte(`{"input":{"namespace":"single-object"}}`),
[]byte(`{"input":[{"content":{"namespace":"nested-only"}}],"tools":[{"namespace":"keep"}]}`),
}
for _, body := range tests {
stripped, err := stripOpenAIResponsesInputNamespaces(body)
require.NoError(t, err)
require.Equal(t, body, stripped)
}
}
@@ -114,10 +114,9 @@ func TestNormalizeOpenAIResponsesRejectedFieldRetryBodyBindsMaxOutputTokensToRej
require.False(t, gjson.GetBytes(retryBody, "max_output_tokens").Exists())
}
func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`)
func TestOpenAIGatewayService_APIKeyStripsAllIndexedNamespacesBeforeFirstForward(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace","type":"invalid_request_error"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
@@ -130,9 +129,44 @@ func TestOpenAIGatewayService_RetriesRejectedIndexedNamespaceField(t *testing.T)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 2)
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.namespace").String())
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists())
require.Len(t, upstream.bodies, 1)
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
}
func TestOpenAIGatewayService_OpenAIHTTPStripsInputNamespacesBeforeFirstForward(t *testing.T) {
accounts := []struct {
name string
account *Account
}{
{name: "oauth", account: newOpenAIOAuthNamespaceTestAccount()},
{name: "apikey", account: newOpenAIRejectedFieldTestAccount()},
}
for _, tt := range accounts {
for _, path := range []string{"/v1/responses", "/v1/responses/compact"} {
t.Run(tt.name+path, func(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"instructions":"test","input":[{"type":"message","role":"user","namespace":"remove","content":[{"type":"input_text","text":"hello","namespace":"nested-keep"}]}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"id":"resp_namespace_ok","output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
c := newOpenAIRejectedFieldTestContext(body)
c.Request.URL.Path = path
result, err := newOpenAIRejectedFieldTestService(upstream).Forward(
context.Background(),
c,
tt.account,
body,
)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 1, "namespace must be removed before the first upstream request")
require.False(t, gjson.GetBytes(upstream.bodies[0], "input.0.namespace").Exists())
require.Equal(t, "nested-keep", gjson.GetBytes(upstream.bodies[0], "input.0.content.0.namespace").String())
})
}
}
}
func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing.T) {
@@ -157,10 +191,9 @@ func TestOpenAIGatewayService_RetriesExplicitMaxOutputTokensRejection(t *testing
require.Equal(t, "keep", gjson.GetBytes(upstream.bodies[1], "input.0.content.max_output_tokens").String())
}
func TestOpenAIGatewayService_ComposesDistinctRejectedFieldRetries(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"keep","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove","input":"{}"}]}`)
func TestOpenAIGatewayService_ComposesProactiveNamespaceStripWithRejectedFieldRetry(t *testing.T) {
body := []byte(`{"model":"gpt-5.5","stream":false,"max_output_tokens":2048,"input":[{"type":"function_call","name":"first","namespace":"remove-first","arguments":"{}"},{"type":"custom_tool_call","name":"second","namespace":"remove-second","input":"{}"}]}`)
upstream := &httpUpstreamRecorder{responses: []*http.Response{
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unknown_parameter","message":"Unknown parameter: 'input[1].namespace'.","param":"input[1].namespace"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusBadRequest, `{"error":{"code":"unsupported_parameter","message":"Unsupported parameter: max_output_tokens","param":"max_output_tokens"}}`),
newOpenAIRejectedFieldTestResponse(http.StatusOK, `{"output":[],"usage":{"input_tokens":1,"output_tokens":1,"input_tokens_details":{"cached_tokens":0}}}`),
}}
@@ -174,12 +207,13 @@ func TestOpenAIGatewayService_ComposesDistinctRejectedFieldRetries(t *testing.T)
require.NoError(t, err)
require.NotNil(t, result)
require.Len(t, upstream.bodies, 3)
require.True(t, gjson.GetBytes(upstream.bodies[0], "input.1.namespace").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[1], "input.1.namespace").Exists())
require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Int())
require.False(t, gjson.GetBytes(upstream.bodies[2], "input.1.namespace").Exists())
require.False(t, gjson.GetBytes(upstream.bodies[2], "max_output_tokens").Exists())
require.Len(t, upstream.bodies, 2)
for _, forwardedBody := range upstream.bodies {
require.False(t, gjson.GetBytes(forwardedBody, "input.0.namespace").Exists())
require.False(t, gjson.GetBytes(forwardedBody, "input.1.namespace").Exists())
}
require.Equal(t, int64(2048), gjson.GetBytes(upstream.bodies[0], "max_output_tokens").Int())
require.False(t, gjson.GetBytes(upstream.bodies[1], "max_output_tokens").Exists())
}
func newOpenAIRejectedFieldTestService(upstream *httpUpstreamRecorder) *OpenAIGatewayService {
@@ -221,6 +255,22 @@ func newOpenAIRejectedFieldTestAccount() *Account {
}
}
func newOpenAIOAuthNamespaceTestAccount() *Account {
return &Account{
ID: 5108,
Name: "openai-oauth-namespace",
Platform: PlatformOpenAI,
Type: AccountTypeOAuth,
Concurrency: 1,
Credentials: map[string]any{
"access_token": "oauth-token",
"chatgpt_account_id": "chatgpt-account",
},
Status: StatusActive,
Schedulable: true,
}
}
func newOpenAIRejectedFieldTestResponse(status int, body string) *http.Response {
return &http.Response{
StatusCode: status,
@@ -733,7 +733,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
},
}
body := []byte(`{"model":"gpt-5.1","stream":false,"store":true,"input":[{"type":"input_text","text":"hello"}]}`)
body := []byte(`{"model":"gpt-5.1","stream":false,"store":true,"input":[{"type":"input_text","text":"hello","namespace":"native-wsv2"}]}`)
result, err := svc.Forward(context.Background(), c, account, body)
require.NoError(t, err)
require.NotNil(t, result)
@@ -745,6 +745,7 @@ func TestOpenAIGatewayService_Forward_WSv2_OAuthStoreFalseByDefault(t *testing.T
require.False(t, gjson.Get(requestJSON, "store").Bool(), "默认策略应将 OAuth store 置为 false")
require.True(t, gjson.Get(requestJSON, "stream").Exists(), "WSv2 payload 应保留 stream 字段")
require.True(t, gjson.Get(requestJSON, "stream").Bool(), "OAuth Codex 规范化后应强制 stream=true")
require.Equal(t, "native-wsv2", gjson.Get(requestJSON, "input.0.namespace").String(), "OAuth WSv2 应保留原生 namespace")
require.Equal(t, openAIWSBetaV2Value, captureDialer.lastHeaders.Get("OpenAI-Beta"))
require.Equal(t, "remote_compaction_v2", captureDialer.lastHeaders.Get("x-codex-beta-features"))
// OAuth 账号的 session_id/conversation_id 应被 isolateOpenAISessionID 隔离,
@@ -218,6 +218,11 @@ func (s *PricingService) Stop() {
// startUpdateScheduler 启动定时更新调度器
func (s *PricingService) startUpdateScheduler() {
if s == nil || s.cfg == nil || strings.TrimSpace(s.cfg.Pricing.RemoteURL) == "" {
logger.LegacyPrintf("service.pricing", "%s", "[Pricing] Remote sync disabled: pricing remote URL is empty")
return
}
// 定期检查哈希更新
hashInterval := time.Duration(s.cfg.Pricing.HashCheckIntervalMinutes) * time.Minute
if hashInterval < time.Minute {
@@ -789,6 +794,10 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
// 因子串关系误匹配 "claude-opus-4-7"(opus-4.7 系列)。
// 注意:原 map 实现存在 Go map 迭代随机性导致的同类 bug,此处改为有序切片修复。
families := []modelFamily{
// Opus 5 与 Opus 4.8 同价($5/$25 per MTok)。定价数据缺失 claude-opus-5 时
// 必须回退到 4.8,否则会掉进 "opus-4" 系列按 $15/$75 计费(3 倍超收)。
{name: "opus-5", match: []string{"claude-opus-5"}, pricing: []string{"claude-opus-5", "claude-opus-4-8"}},
{name: "opus-4.8", match: []string{"claude-opus-4-8", "claude-opus-4.8"}, pricing: []string{"claude-opus-4-8", "claude-opus-4.8", "claude-opus-4-7"}},
{name: "opus-4.7", match: []string{"claude-opus-4-7", "claude-opus-4.7"}, pricing: []string{"claude-opus-4-7", "claude-opus-4.7", "claude-opus-4-6"}},
{name: "opus-4.6", match: []string{"claude-opus-4-6", "claude-opus-4.6"}},
{name: "opus-4.5", match: []string{"claude-opus-4-5", "claude-opus-4.5"}},
@@ -821,6 +830,11 @@ func (s *PricingService) matchByModelFamily(model string) *LiteLLMModelPricing {
switch {
case strings.Contains(model, "opus"):
switch {
// "opus-5" 必须先判:不能用裸 "5" 匹配,否则 claude-opus-4-5 会被误判。
case strings.Contains(model, "opus-5") || strings.Contains(model, "opus5"):
fallbackName = "opus-5"
case strings.Contains(model, "4.8") || strings.Contains(model, "4-8"):
fallbackName = "opus-4.8"
case strings.Contains(model, "4.7") || strings.Contains(model, "4-7"):
fallbackName = "opus-4.7"
case strings.Contains(model, "4.6") || strings.Contains(model, "4-6"):
@@ -5,11 +5,42 @@ import (
"os"
"path/filepath"
"testing"
"time"
"github.com/Wei-Shaw/sub2api/internal/config"
"github.com/stretchr/testify/require"
)
func TestPricingSchedulerBlankRemoteURLDoesNotStart(t *testing.T) {
svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{RemoteURL: " \t "}}, nil)
defer svc.Stop()
svc.startUpdateScheduler()
done := make(chan struct{})
go func() {
svc.wg.Wait()
close(done)
}()
select {
case <-done:
case <-time.After(100 * time.Millisecond):
t.Fatal("blank remote URL must not start scheduler")
}
}
func TestPricingNonEmptyInvalidRemoteURLStillReturnsValidationError(t *testing.T) {
svc := NewPricingService(&config.Config{Pricing: config.PricingConfig{
RemoteURL: "://invalid",
DataDir: t.TempDir(),
}}, nil)
err := svc.ForceUpdate()
require.Error(t, err)
require.Contains(t, err.Error(), "invalid pricing url")
}
func TestParsePricingData_ParsesPriorityAndServiceTierFields(t *testing.T) {
svc := &PricingService{}
body := []byte(`{
+75
View File
@@ -0,0 +1,75 @@
package service
import (
"strings"
"unicode/utf8"
"github.com/gin-gonic/gin"
)
// maxPersistedSessionIDLength bounds the persisted client session identifier to the
// usage_logs.session_id column width (VARCHAR(255)). Longer values are rejected so
// distinct identifiers can never alias through truncation.
const maxPersistedSessionIDLength = 255
// clientSessionIDHeaders extends the OpenAI-compatible sticky-session signals with
// native protocol identifiers that are safe to persist but must not alter OpenAI
// scheduling behavior.
var clientSessionIDHeaders = append(
append([]string(nil), explicitOpenAIHeaderSessionNames...),
claudeCodeSessionHeader,
)
// ExtractClientSessionID resolves the explicit client-provided session identifier from
// request headers for usage-log correlation and returns it sanitized. It is
// protocol-agnostic and shared by every gateway handler so all supported protocols
// record session_id through one seam. Returns "" when no valid identifier is present.
//
// This value feeds only usage_logs.session_id persistence. It does NOT affect sticky
// routing, account selection, request_id semantics, or upstream prompt caching, which
// keep their own (intentionally broader) session-signal resolution.
func ExtractClientSessionID(c *gin.Context) string {
if c == nil || c.Request == nil {
return ""
}
for _, header := range clientSessionIDHeaders {
if sessionID := sanitizeSessionID(c.GetHeader(header)); sessionID != "" {
return sessionID
}
}
if isGrokRequestContext(c) {
if sessionID := sanitizeSessionID(c.GetHeader(grokConversationIDHeader)); sessionID != "" {
return sessionID
}
}
return ""
}
// sanitizeSessionID normalizes a raw client-supplied session identifier for safe
// persistence: it trims surrounding whitespace, rejects the value outright if it
// contains any control character (CR/LF/tab/NUL/…) so a log- or header-injection style
// payload cannot slip into stored correlation data, and rejects values longer than
// the DB column bound. Absent or invalid input yields "".
func sanitizeSessionID(raw string) string {
if !utf8.ValidString(raw) {
return ""
}
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return ""
}
count := 0
for _, r := range trimmed {
if r < 0x20 || r == 0x7f {
// An explicit correlation id never legitimately contains control
// characters; drop the whole value rather than persist a mangled or
// partially-injected identifier.
return ""
}
count++
if count > maxPersistedSessionIDLength {
return ""
}
}
return trimmed
}
+155
View File
@@ -0,0 +1,155 @@
//go:build unit
package service
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
func newSessionHeaderContext(t *testing.T, headers map[string]string) *gin.Context {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
req := httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
for k, v := range headers {
req.Header.Set(k, v)
}
c.Request = req
return c
}
func TestSanitizeSessionID(t *testing.T) {
longRunes := strings.Repeat("a", maxPersistedSessionIDLength+50)
multibyte := strings.Repeat("好", maxPersistedSessionIDLength+10)
tests := []struct {
name string
in string
want string
}{
{"empty", "", ""},
{"whitespace only", " \t ", ""},
{"trims surrounding whitespace", " sess-123 ", "sess-123"},
{"plain value", "conv_abc-123.XYZ", "conv_abc-123.XYZ"},
{"uuid", "550e8400-e29b-41d4-a716-446655440000", "550e8400-e29b-41d4-a716-446655440000"},
{"reject CR", "sess\r123", ""},
{"reject LF", "sess\n123", ""},
{"reject CRLF injection", "sess-1\r\nSet-Cookie: x=y", ""},
{"reject tab inside", "sess\t123", ""},
{"reject NUL", "sess\x00123", ""},
{"reject DEL", "sess\x7f123", ""},
{"reject invalid UTF-8", string([]byte{'s', 'e', 's', 's', '-', 0xff}), ""},
{"accepts value at column bound", strings.Repeat("b", maxPersistedSessionIDLength), strings.Repeat("b", maxPersistedSessionIDLength)},
{"rejects overlong value", longRunes, ""},
{"rejects overlong multibyte value", multibyte, ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
got := sanitizeSessionID(tc.in)
require.Equal(t, tc.want, got, "sanitizeSessionID(%q)", tc.in)
// Sanitized output must never exceed the DB column bound (rune-counted).
require.LessOrEqual(t, len([]rune(got)), maxPersistedSessionIDLength)
})
}
}
func TestExtractClientSessionID_NilContext(t *testing.T) {
require.Equal(t, "", ExtractClientSessionID(nil))
}
func TestExtractClientSessionID_NilRequest(t *testing.T) {
require.Equal(t, "", ExtractClientSessionID(&gin.Context{}))
}
func TestExtractClientSessionID_AbsentReturnsEmpty(t *testing.T) {
c := newSessionHeaderContext(t, nil)
require.Equal(t, "", ExtractClientSessionID(c))
}
func TestExtractClientSessionID_SupportedHeaders(t *testing.T) {
tests := []struct {
name string
header string
value string
}{
{"session_id", "session_id", "sess-A"},
{"conversation_id", "conversation_id", "conv-B"},
{"X-Session-Affinity", openCodeSessionAffinityHeader, "aff-C"},
{"X-Session-Id", openCodeSessionIDHeader, "sid-D"},
{"X-OpenCode-Session", openCodeNativeSessionHeader, "oc-E"},
{"X-Conversation-ID", codeBuddyConversationHeader, "cb-F"},
{"X-Claude-Code-Session-Id", claudeCodeSessionHeader, "cc-G"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
c := newSessionHeaderContext(t, map[string]string{tc.header: tc.value})
require.Equal(t, tc.value, ExtractClientSessionID(c))
})
}
}
func TestExtractClientSessionID_HeaderPrecedence(t *testing.T) {
// session_id ranks ahead of conversation_id and the X-* variants.
c := newSessionHeaderContext(t, map[string]string{
"session_id": "primary",
"conversation_id": "secondary",
openCodeSessionIDHeader: "tertiary",
codeBuddyConversationHeader: "quaternary",
})
require.Equal(t, "primary", ExtractClientSessionID(c))
}
func TestExtractClientSessionID_Sanitizes(t *testing.T) {
c := newSessionHeaderContext(t, map[string]string{openCodeSessionIDHeader: " clean-123 "})
require.Equal(t, "clean-123", ExtractClientSessionID(c))
}
func TestExtractClientSessionID_IgnoresNonSessionHeaders(t *testing.T) {
// prompt_cache_key, request/message ids, and a Grok conversation header on a
// non-Grok request are NOT persisted as session_id.
c := newSessionHeaderContext(t, map[string]string{
"prompt_cache_key": "cache-key-should-not-persist",
"X-Request-Id": "req-should-not-persist",
"x-grok-conv-id": "grok-conv-should-not-persist",
})
require.Equal(t, "", ExtractClientSessionID(c))
}
func TestExtractClientSessionID_GrokConversationHeader(t *testing.T) {
c := newSessionHeaderContext(t, map[string]string{
grokConversationIDHeader: "grok-native-session",
})
c.Set("api_key", &APIKey{
ID: 42,
Group: &Group{Platform: PlatformGrok},
})
require.Equal(t, "grok-native-session", ExtractClientSessionID(c))
}
func TestExtractClientSessionID_GrokConversationHeaderForCompositeRoute(t *testing.T) {
c := newSessionHeaderContext(t, map[string]string{
grokConversationIDHeader: "grok-composite-session",
})
c.Set("api_key", &APIKey{
ID: 43,
Group: &Group{Platform: PlatformComposite},
})
c.Request = c.Request.WithContext(WithResolvedTargetPlatform(context.Background(), PlatformGrok))
require.Equal(t, "grok-composite-session", ExtractClientSessionID(c))
}
func TestExtractClientSessionID_InjectionHeaderDropped(t *testing.T) {
// A supported header carrying a CRLF payload is rejected, not persisted mangled.
c := newSessionHeaderContext(t, map[string]string{"session_id": "abc"})
c.Request.Header.Set("session_id", "abc\r\nX-Injected: 1")
require.Equal(t, "", ExtractClientSessionID(c))
}
+4
View File
@@ -165,6 +165,10 @@ type UsageLog struct {
FirstTokenMs *int
UserAgent *string
IPAddress *string
// SessionID is the explicit client-provided request correlation identifier
// (e.g. the session_id / X-Session-Id headers). Nil when the client sent no
// valid session header. It is never derived from prompt_cache_key or content.
SessionID *string
// Cache TTL Override 标记(管理员强制替换了缓存 TTL 计费)
CacheTTLOverridden bool
@@ -0,0 +1,14 @@
-- Persist the explicit client-provided request session/conversation identifier
-- (e.g. the session_id / X-Session-Id / X-Conversation-ID request headers) so
-- usage rows can be correlated by session for querying.
--
-- Nullable with no default: on PostgreSQL 11+ this is a metadata-only change, so
-- it does NOT rewrite the (potentially large) usage_logs table. Absent/invalid
-- session identifiers stay NULL. Only explicit client correlation identity is
-- stored here; prompt_cache_key and content-derived sticky hashes are never
-- persisted as session_id.
ALTER TABLE usage_logs ADD COLUMN IF NOT EXISTS session_id VARCHAR(255);
-- Batch image usage is recorded asynchronously after the originating HTTP
-- request has finished, so retain the same sanitized identifier on the job.
ALTER TABLE batch_image_jobs ADD COLUMN IF NOT EXISTS session_id VARCHAR(255);
@@ -520,6 +520,42 @@
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346
},
"claude-opus-5": {
"cache_creation_input_token_cost": 6.25e-06,
"cache_creation_input_token_cost_above_1hr": 1e-05,
"cache_read_input_token_cost": 5e-07,
"input_cost_per_token": 5e-06,
"litellm_provider": "anthropic",
"max_input_tokens": 1000000,
"max_output_tokens": 128000,
"max_tokens": 128000,
"mode": "chat",
"output_cost_per_token": 2.5e-05,
"provider_specific_entry": {
"fast": 6.0,
"us": 1.1
},
"search_context_cost_per_query": {
"search_context_size_high": 0.01,
"search_context_size_low": 0.01,
"search_context_size_medium": 0.01
},
"supports_adaptive_thinking": true,
"supports_assistant_prefill": false,
"supports_computer_use": true,
"supports_function_calling": true,
"supports_max_reasoning_effort": true,
"supports_minimal_reasoning_effort": true,
"supports_output_config": true,
"supports_pdf_input": true,
"supports_prompt_caching": true,
"supports_reasoning": true,
"supports_response_schema": true,
"supports_tool_choice": true,
"supports_vision": true,
"supports_xhigh_reasoning_effort": true,
"tool_use_system_prompt_tokens": 346
},
"claude-sonnet-4-20250514": {
"cache_creation_input_token_cost": 3.75e-06,
"cache_creation_input_token_cost_above_1hr": 6e-06,
+3 -2
View File
@@ -51,7 +51,7 @@
"eslint": "^8.57.0",
"eslint-plugin-vue": "^9.25.0",
"jsdom": "^24.1.3",
"postcss": "^8.5.12",
"postcss": "^8.4.32",
"tailwindcss": "^3.4.0",
"typescript": "~5.6.0",
"vite": "^5.0.10",
@@ -62,7 +62,8 @@
"pnpm": {
"overrides": {
"js-cookie": "3.0.7",
"form-data@<4.0.6": ">=4.0.6"
"form-data@<4.0.6": ">=4.0.6",
"postcss@<8.5.18": ">=8.5.18"
}
}
}
+33 -32
View File
@@ -7,6 +7,7 @@ settings:
overrides:
js-cookie: 3.0.7
form-data@<4.0.6: '>=4.0.6'
postcss@<8.5.18: '>=8.5.18'
importers:
@@ -105,7 +106,7 @@ importers:
version: 2.4.6
autoprefixer:
specifier: ^10.4.16
version: 10.4.23(postcss@8.5.12)
version: 10.4.23(postcss@8.5.23)
eslint:
specifier: ^8.57.0
version: 8.57.1
@@ -116,8 +117,8 @@ importers:
specifier: ^24.1.3
version: 24.1.3
postcss:
specifier: ^8.5.12
version: 8.5.12
specifier: '>=8.5.18'
version: 8.5.23
tailwindcss:
specifier: ^3.4.0
version: 3.4.19
@@ -1876,7 +1877,7 @@ packages:
engines: {node: ^10 || ^12 || >=14}
hasBin: true
peerDependencies:
postcss: ^8.1.0
postcss: '>=8.5.18'
axios@1.18.1:
resolution: {integrity: sha512-3nTvFlvpn9Zu/RkHUqtc7/+al4UpRW5az71ap5zccp6e8RAYEzhMTecX8Dz1wWDYrPpUoB1HAQEGEAEvUr7S9g==}
@@ -3321,8 +3322,8 @@ packages:
mz@2.7.0:
resolution: {integrity: sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==}
nanoid@3.3.11:
resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
@@ -3511,20 +3512,20 @@ packages:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
peerDependencies:
postcss: ^8.0.0
postcss: '>=8.5.18'
postcss-js@4.1.0:
resolution: {integrity: sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==}
engines: {node: ^12 || ^14 || >= 16}
peerDependencies:
postcss: ^8.4.21
postcss: '>=8.5.18'
postcss-load-config@6.0.1:
resolution: {integrity: sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==}
engines: {node: '>= 18'}
peerDependencies:
jiti: '>=1.21.0'
postcss: '>=8.0.9'
postcss: '>=8.5.18'
tsx: ^4.8.1
yaml: ^2.4.2
peerDependenciesMeta:
@@ -3541,7 +3542,7 @@ packages:
resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==}
engines: {node: '>=12.0'}
peerDependencies:
postcss: ^8.2.14
postcss: '>=8.5.18'
postcss-selector-parser@6.1.2:
resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==}
@@ -3550,8 +3551,8 @@ packages:
postcss-value-parser@4.2.0:
resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==}
postcss@8.5.12:
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
postcss@8.5.23:
resolution: {integrity: sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==}
engines: {node: ^10 || ^12 || >=14}
prelude-ls@1.2.1:
@@ -6284,7 +6285,7 @@ snapshots:
'@vue/shared': 3.5.26
estree-walker: 2.0.2
magic-string: 0.30.21
postcss: 8.5.12
postcss: 8.5.23
source-map-js: 1.2.1
'@vue/compiler-ssr@3.5.26':
@@ -6515,13 +6516,13 @@ snapshots:
attr-accept@2.2.5: {}
autoprefixer@10.4.23(postcss@8.5.12):
autoprefixer@10.4.23(postcss@8.5.23):
dependencies:
browserslist: 4.28.1
caniuse-lite: 1.0.30001761
fraction.js: 5.3.4
picocolors: 1.1.1
postcss: 8.5.12
postcss: 8.5.23
postcss-value-parser: 4.2.0
axios@1.18.1:
@@ -8442,7 +8443,7 @@ snapshots:
object-assign: 4.1.1
thenify-all: 1.6.0
nanoid@3.3.11: {}
nanoid@3.3.16: {}
natural-compare@1.4.0: {}
@@ -8607,28 +8608,28 @@ snapshots:
dependencies:
'@babel/runtime': 7.28.4
postcss-import@15.1.0(postcss@8.5.12):
postcss-import@15.1.0(postcss@8.5.23):
dependencies:
postcss: 8.5.12
postcss: 8.5.23
postcss-value-parser: 4.2.0
read-cache: 1.0.0
resolve: 1.22.11
postcss-js@4.1.0(postcss@8.5.12):
postcss-js@4.1.0(postcss@8.5.23):
dependencies:
camelcase-css: 2.0.1
postcss: 8.5.12
postcss: 8.5.23
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.12):
postcss-load-config@6.0.1(jiti@1.21.7)(postcss@8.5.23):
dependencies:
lilconfig: 3.1.3
optionalDependencies:
jiti: 1.21.7
postcss: 8.5.12
postcss: 8.5.23
postcss-nested@6.2.0(postcss@8.5.12):
postcss-nested@6.2.0(postcss@8.5.23):
dependencies:
postcss: 8.5.12
postcss: 8.5.23
postcss-selector-parser: 6.1.2
postcss-selector-parser@6.1.2:
@@ -8638,9 +8639,9 @@ snapshots:
postcss-value-parser@4.2.0: {}
postcss@8.5.12:
postcss@8.5.23:
dependencies:
nanoid: 3.3.11
nanoid: 3.3.16
picocolors: 1.1.1
source-map-js: 1.2.1
@@ -9253,11 +9254,11 @@ snapshots:
normalize-path: 3.0.0
object-hash: 3.0.0
picocolors: 1.1.1
postcss: 8.5.12
postcss-import: 15.1.0(postcss@8.5.12)
postcss-js: 4.1.0(postcss@8.5.12)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.12)
postcss-nested: 6.2.0(postcss@8.5.12)
postcss: 8.5.23
postcss-import: 15.1.0(postcss@8.5.23)
postcss-js: 4.1.0(postcss@8.5.23)
postcss-load-config: 6.0.1(jiti@1.21.7)(postcss@8.5.23)
postcss-nested: 6.2.0(postcss@8.5.23)
postcss-selector-parser: 6.1.2
resolve: 1.22.11
sucrase: 3.35.1
@@ -9489,7 +9490,7 @@ snapshots:
vite@5.4.21(@types/node@20.19.27):
dependencies:
esbuild: 0.21.5
postcss: 8.5.12
postcss: 8.5.23
rollup: 4.54.0
optionalDependencies:
'@types/node': 20.19.27
@@ -225,6 +225,7 @@ const formatScopeName = (scope: string): string => {
'claude-opus-4-6-thinking': 'COpus46T',
'claude-opus-4-7': 'COpus47',
'claude-opus-4-8': 'COpus48',
'claude-opus-5': 'COpus5',
'claude-sonnet-4-6': 'CSon46',
'claude-sonnet-4-5': 'CSon45',
'claude-sonnet-4-5-thinking': 'CSon45T',
@@ -32,6 +32,7 @@ export const claudeModels = [
'claude-opus-4-6',
'claude-opus-4-7',
'claude-opus-4-8',
'claude-opus-5',
'claude-sonnet-4-6',
'claude-sonnet-5',
'claude-fable-5'
@@ -267,6 +268,7 @@ const anthropicPresetMappings = [
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'claude-opus-4-6', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'claude-opus-4-7', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'claude-opus-4-8', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Opus 5', from: 'claude-opus-5', to: 'claude-opus-5', color: 'bg-purple-100 text-purple-700 hover:bg-purple-200 dark:bg-purple-900/30 dark:text-purple-400' },
{ label: 'Haiku 3.5', from: 'claude-3-5-haiku-20241022', to: 'claude-3-5-haiku-20241022', color: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400' },
{ label: 'Haiku 4.5', from: 'claude-haiku-4-5-20251001', to: 'claude-haiku-4-5-20251001', color: 'bg-emerald-100 text-emerald-700 hover:bg-emerald-200 dark:bg-emerald-900/30 dark:text-emerald-400' },
{ label: 'Opus->Sonnet', from: 'claude-opus-4-6', to: 'claude-sonnet-4-5-20250929', color: 'bg-amber-100 text-amber-700 hover:bg-amber-200 dark:bg-amber-900/30 dark:text-amber-400' }
@@ -360,6 +362,7 @@ const bedrockPresetMappings = [
{ label: 'Opus 4.6', from: 'claude-opus-4-6', to: 'us.anthropic.claude-opus-4-6-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 4.7', from: 'claude-opus-4-7', to: 'us.anthropic.claude-opus-4-7-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 4.8', from: 'claude-opus-4-8', to: 'us.anthropic.claude-opus-4-8-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Opus 5', from: 'claude-opus-5', to: 'us.anthropic.claude-opus-5-v1', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
{ label: 'Sonnet 5', from: 'claude-sonnet-5', to: 'us.anthropic.claude-sonnet-5-v1', color: 'bg-indigo-100 text-indigo-700 hover:bg-indigo-200 dark:bg-indigo-900/30 dark:text-indigo-400' },
{ label: 'Sonnet 4.6', from: 'claude-sonnet-4-6', to: 'us.anthropic.claude-sonnet-4-6', color: 'bg-cyan-100 text-cyan-700 hover:bg-cyan-200 dark:bg-cyan-900/30 dark:text-cyan-400' },
{ label: 'Opus 4.5', from: 'claude-opus-4-5-thinking', to: 'us.anthropic.claude-opus-4-5-20251101-v1:0', color: 'bg-pink-100 text-pink-700 hover:bg-pink-200 dark:bg-pink-900/30 dark:text-pink-400' },
+6 -6
View File
@@ -51,9 +51,9 @@
<div class="mt-5 grid gap-4 md:grid-cols-2">
<div class="space-y-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.yourCode') }}</p>
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
<code class="flex-1 truncate text-sm font-semibold text-gray-900 dark:text-white">{{ detail.aff_code }}</code>
<button class="btn btn-secondary btn-sm" @click="copyCode">
<div class="flex flex-col items-stretch gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900 sm:flex-row sm:items-center">
<code class="min-w-0 break-all text-sm font-semibold text-gray-900 dark:text-white sm:flex-1 sm:truncate">{{ detail.aff_code }}</code>
<button class="btn btn-secondary btn-sm w-full sm:w-auto sm:shrink-0" @click="copyCode">
<Icon name="copy" size="sm" />
<span>{{ t('affiliate.copyCode') }}</span>
</button>
@@ -62,9 +62,9 @@
<div class="space-y-2">
<p class="text-sm font-medium text-gray-700 dark:text-gray-300">{{ t('affiliate.inviteLink') }}</p>
<div class="flex items-center gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900">
<code class="flex-1 truncate text-sm text-gray-700 dark:text-gray-300">{{ inviteLink }}</code>
<button class="btn btn-secondary btn-sm" @click="copyInviteLink">
<div class="flex flex-col items-stretch gap-2 rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 dark:border-dark-700 dark:bg-dark-900 sm:flex-row sm:items-center">
<code class="min-w-0 break-all text-sm text-gray-700 dark:text-gray-300 sm:flex-1 sm:truncate">{{ inviteLink }}</code>
<button class="btn btn-secondary btn-sm w-full sm:w-auto sm:shrink-0" @click="copyInviteLink">
<Icon name="copy" size="sm" />
<span>{{ t('affiliate.copyLink') }}</span>
</button>
@@ -0,0 +1,116 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import AffiliateView from '../AffiliateView.vue'
const { copyToClipboard, getAffiliateDetail } = vi.hoisted(() => ({
copyToClipboard: vi.fn(),
getAffiliateDetail: vi.fn(),
}))
vi.mock('@/api/user', () => ({
default: {
getAffiliateDetail,
transferAffiliateQuota: vi.fn(),
},
}))
vi.mock('@/stores/app', () => ({
useAppStore: () => ({
showError: vi.fn(),
showSuccess: vi.fn(),
}),
}))
vi.mock('@/stores/auth', () => ({
useAuthStore: () => ({
refreshUser: vi.fn(),
}),
}))
vi.mock('@/composables/useClipboard', () => ({
useClipboard: () => ({ copyToClipboard }),
}))
vi.mock('vue-i18n', async (importOriginal) => {
const actual = await importOriginal<typeof import('vue-i18n')>()
return {
...actual,
useI18n: () => ({
t: (key: string) => key,
}),
}
})
describe('AffiliateView', () => {
const affiliateCode = 'affiliate-code-that-is-long-enough-to-overflow-a-mobile-viewport'
beforeEach(() => {
vi.clearAllMocks()
copyToClipboard.mockResolvedValue(true)
getAffiliateDetail.mockResolvedValue({
user_id: 1,
aff_code: affiliateCode,
inviter_id: null,
aff_count: 0,
aff_quota: 0,
aff_frozen_quota: 0,
aff_history_quota: 0,
effective_rebate_rate_percent: 10,
invitees: [],
})
})
it('stacks long values and copy controls on mobile while retaining desktop rows', async () => {
const wrapper = mount(AffiliateView, {
global: {
stubs: {
AppLayout: { template: '<main><slot /></main>' },
Icon: true,
},
},
})
await flushPromises()
const values = wrapper.findAll('code')
expect(values).toHaveLength(2)
for (const value of values) {
expect(value.classes()).toEqual(expect.arrayContaining([
'min-w-0',
'break-all',
'sm:flex-1',
'sm:truncate',
]))
expect(Array.from(value.element.parentElement?.classList ?? [])).toEqual(expect.arrayContaining([
'flex-col',
'items-stretch',
'sm:flex-row',
'sm:items-center',
]))
}
const copyButtons = wrapper.findAll('button').filter((button) =>
['affiliate.copyCode', 'affiliate.copyLink'].includes(button.text()),
)
expect(copyButtons).toHaveLength(2)
for (const button of copyButtons) {
expect(button.classes()).toEqual(expect.arrayContaining([
'w-full',
'sm:w-auto',
'sm:shrink-0',
]))
}
await copyButtons[0].trigger('click')
await copyButtons[1].trigger('click')
await flushPromises()
expect(copyToClipboard).toHaveBeenNthCalledWith(1, affiliateCode, 'affiliate.codeCopied')
expect(copyToClipboard).toHaveBeenNthCalledWith(
2,
`${window.location.origin}/register?aff=${encodeURIComponent(affiliateCode)}`,
'affiliate.linkCopied',
)
})
})