diff --git a/backend/internal/handler/batch_image_handler.go b/backend/internal/handler/batch_image_handler.go
index b93c5a65ae..62990d251a 100644
--- a/backend/internal/handler/batch_image_handler.go
+++ b/backend/internal/handler/batch_image_handler.go
@@ -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)
diff --git a/backend/internal/handler/dto/mappers.go b/backend/internal/handler/dto/mappers.go
index 567d12c13a..04c811cedf 100644
--- a/backend/internal/handler/dto/mappers.go
+++ b/backend/internal/handler/dto/mappers.go
@@ -670,6 +670,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,
diff --git a/backend/internal/handler/dto/types.go b/backend/internal/handler/dto/types.go
index d52f81afe3..d5ba4e9db6 100644
--- a/backend/internal/handler/dto/types.go
+++ b/backend/internal/handler/dto/types.go
@@ -523,6 +523,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"`
diff --git a/backend/internal/handler/gateway_handler.go b/backend/internal/handler/gateway_handler.go
index 1ea81d1fb5..3d014f9f45 100644
--- a/backend/internal/handler/gateway_handler.go
+++ b/backend/internal/handler/gateway_handler.go
@@ -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,
diff --git a/backend/internal/handler/gateway_handler_chat_completions.go b/backend/internal/handler/gateway_handler_chat_completions.go
index 3cfb9026e2..7061325f8a 100644
--- a/backend/internal/handler/gateway_handler_chat_completions.go
+++ b/backend/internal/handler/gateway_handler_chat_completions.go
@@ -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",
diff --git a/backend/internal/handler/gateway_handler_responses.go b/backend/internal/handler/gateway_handler_responses.go
index bd545d2cf6..8f5d8531bb 100644
--- a/backend/internal/handler/gateway_handler_responses.go
+++ b/backend/internal/handler/gateway_handler_responses.go
@@ -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",
diff --git a/backend/internal/handler/gemini_v1beta_handler.go b/backend/internal/handler/gemini_v1beta_handler.go
index 7b349ae1db..40470ac418 100644
--- a/backend/internal/handler/gemini_v1beta_handler.go
+++ b/backend/internal/handler/gemini_v1beta_handler.go
@@ -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(
diff --git a/backend/internal/handler/grok_media.go b/backend/internal/handler/grok_media.go
index 0522d689b1..fd0697884e 100644
--- a/backend/internal/handler/grok_media.go
+++ b/backend/internal/handler/grok_media.go
@@ -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(
diff --git a/backend/internal/handler/openai_alpha_search.go b/backend/internal/handler/openai_alpha_search.go
index eca8931e87..c76b7d9fa5 100644
--- a/backend/internal/handler/openai_alpha_search.go
+++ b/backend/internal/handler/openai_alpha_search.go
@@ -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(
diff --git a/backend/internal/handler/openai_chat_completions.go b/backend/internal/handler/openai_chat_completions.go
index 7477057113..39c12253b1 100644
--- a/backend/internal/handler/openai_chat_completions.go
+++ b/backend/internal/handler/openai_chat_completions.go
@@ -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 {
diff --git a/backend/internal/handler/openai_embeddings.go b/backend/internal/handler/openai_embeddings.go
index b7bf64ce0e..3ed44b12b8 100644
--- a/backend/internal/handler/openai_embeddings.go
+++ b/backend/internal/handler/openai_embeddings.go
@@ -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(
diff --git a/backend/internal/handler/openai_gateway_handler.go b/backend/internal/handler/openai_gateway_handler.go
index 1e5949f36f..761f291e90 100644
--- a/backend/internal/handler/openai_gateway_handler.go
+++ b/backend/internal/handler/openai_gateway_handler.go
@@ -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,
diff --git a/backend/internal/handler/openai_images.go b/backend/internal/handler/openai_images.go
index 7ae070f6c6..72e2f462ad 100644
--- a/backend/internal/handler/openai_images.go
+++ b/backend/internal/handler/openai_images.go
@@ -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(
diff --git a/backend/internal/handler/openai_images_failover_test.go b/backend/internal/handler/openai_images_failover_test.go
index e5101d0148..8b07cb9a52 100644
--- a/backend/internal/handler/openai_images_failover_test.go
+++ b/backend/internal/handler/openai_images_failover_test.go
@@ -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())
diff --git a/backend/internal/repository/batch_image_repo.go b/backend/internal/repository/batch_image_repo.go
index 6531f75fd5..7c8991c8d4 100644
--- a/backend/internal/repository/batch_image_repo.go
+++ b/backend/internal/repository/batch_image_repo.go
@@ -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)
diff --git a/backend/internal/repository/usage_log_repo_insert.go b/backend/internal/repository/usage_log_repo_insert.go
index 54d24b5957..809b0652b5 100644
--- a/backend/internal/repository/usage_log_repo_insert.go
+++ b/backend/internal/repository/usage_log_repo_insert.go
@@ -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,
},
}
diff --git a/backend/internal/repository/usage_log_repo_query.go b/backend/internal/repository/usage_log_repo_query.go
index fb0e03ed60..88b7eeaf35 100644
--- a/backend/internal/repository/usage_log_repo_query.go
+++ b/backend/internal/repository/usage_log_repo_query.go
@@ -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
}
diff --git a/backend/internal/repository/usage_log_repo_request_type_test.go b/backend/internal/repository/usage_log_repo_request_type_test.go
index f039029608..aff472b0cb 100644
--- a/backend/internal/repository/usage_log_repo_request_type_test.go
+++ b/backend/internal/repository/usage_log_repo_request_type_test.go
@@ -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)
diff --git a/backend/internal/repository/usage_log_session_id_integration_test.go b/backend/internal/repository/usage_log_session_id_integration_test.go
new file mode 100644
index 0000000000..76778b4162
--- /dev/null
+++ b/backend/internal/repository/usage_log_session_id_integration_test.go
@@ -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)
+}
diff --git a/backend/internal/repository/usage_log_session_id_unit_test.go b/backend/internal/repository/usage_log_session_id_unit_test.go
new file mode 100644
index 0000000000..7804dce306
--- /dev/null
+++ b/backend/internal/repository/usage_log_session_id_unit_test.go
@@ -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))
+}
diff --git a/backend/internal/service/batch_image.go b/backend/internal/service/batch_image.go
index af0206b8b0..130428f18e 100644
--- a/backend/internal/service/batch_image.go
+++ b/backend/internal/service/batch_image.go
@@ -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
diff --git a/backend/internal/service/batch_image_processor_test.go b/backend/internal/service/batch_image_processor_test.go
index f54a1a99ec..ca3a583c99 100644
--- a/backend/internal/service/batch_image_processor_test.go
+++ b/backend/internal/service/batch_image_processor_test.go
@@ -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
diff --git a/backend/internal/service/batch_image_public.go b/backend/internal/service/batch_image_public.go
index 2960d4cd9f..580d1f4934 100644
--- a/backend/internal/service/batch_image_public.go
+++ b/backend/internal/service/batch_image_public.go
@@ -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
diff --git a/backend/internal/service/batch_image_public_test.go b/backend/internal/service/batch_image_public_test.go
index 2e5b930a7b..a69468f846 100644
--- a/backend/internal/service/batch_image_public_test.go
+++ b/backend/internal/service/batch_image_public_test.go
@@ -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)
})
diff --git a/backend/internal/service/batch_image_settlement.go b/backend/internal/service/batch_image_settlement.go
index 26d2abb0fd..e9b8a3685e 100644
--- a/backend/internal/service/batch_image_settlement.go
+++ b/backend/internal/service/batch_image_settlement.go
@@ -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")
diff --git a/backend/internal/service/batch_image_settlement_test.go b/backend/internal/service/batch_image_settlement_test.go
index a1fe3e899d..f1fc3a15eb 100644
--- a/backend/internal/service/batch_image_settlement_test.go
+++ b/backend/internal/service/batch_image_settlement_test.go
@@ -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)
diff --git a/backend/internal/service/gateway_usage_billing.go b/backend/internal/service/gateway_usage_billing.go
index 1606f0f3c1..f445814d01 100644
--- a/backend/internal/service/gateway_usage_billing.go
+++ b/backend/internal/service/gateway_usage_billing.go
@@ -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(),
diff --git a/backend/internal/service/gemini_chat_completions_compat_service.go b/backend/internal/service/gemini_chat_completions_compat_service.go
index de996649b8..5f868a5d66 100644
--- a/backend/internal/service/gemini_chat_completions_compat_service.go
+++ b/backend/internal/service/gemini_chat_completions_compat_service.go
@@ -473,7 +473,7 @@ func geminiResponseToChatCompletions(
rawData []byte,
usageOverride *ClaudeUsage,
) (*apicompat.ChatCompletionsResponse, *ClaudeUsage, error) {
- claudeRespMap, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, rawData)
+ claudeRespMap, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, rawData, true)
if usageOverride != nil && (usageOverride.InputTokens > 0 || usageOverride.OutputTokens > 0 || usageOverride.CacheReadInputTokens > 0) {
usage = usageOverride
if usageMap, ok := claudeRespMap["usage"].(map[string]any); ok {
diff --git a/backend/internal/service/gemini_chat_completions_compat_service_test.go b/backend/internal/service/gemini_chat_completions_compat_service_test.go
new file mode 100644
index 0000000000..e4ebf7a838
--- /dev/null
+++ b/backend/internal/service/gemini_chat_completions_compat_service_test.go
@@ -0,0 +1,163 @@
+package service
+
+import (
+ "encoding/json"
+ "testing"
+
+ "github.com/stretchr/testify/require"
+)
+
+func TestGeminiResponseToChatCompletionsPreservesInlineData(t *testing.T) {
+ tests := []struct {
+ name string
+ parts []any
+ want string
+ }{
+ {
+ name: "image only",
+ parts: []any{
+ map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": "aW1hZ2U="}},
+ },
+ want: "",
+ },
+ {
+ name: "text and image",
+ parts: []any{
+ map[string]any{"text": "rendered image:\n"},
+ map[string]any{"inlineData": map[string]any{"mimeType": "image/webp", "data": "d2VicA=="}},
+ },
+ want: "rendered image:\n",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ geminiResp := map[string]any{
+ "candidates": []any{map[string]any{
+ "content": map[string]any{"parts": tt.parts},
+ "finishReason": "STOP",
+ }},
+ }
+ rawData, err := json.Marshal(geminiResp)
+ require.NoError(t, err)
+
+ got, _, err := geminiResponseToChatCompletions(geminiResp, "gemini-test", rawData, nil)
+ require.NoError(t, err)
+ require.Len(t, got.Choices, 1)
+
+ var content string
+ require.NoError(t, json.Unmarshal(got.Choices[0].Message.Content, &content))
+ require.Equal(t, tt.want, content)
+ require.Equal(t, "stop", got.Choices[0].FinishReason)
+ })
+ }
+}
+
+func TestGeminiResponseToChatCompletionsOmitsInvalidInlineData(t *testing.T) {
+ tests := []struct {
+ name string
+ inlineData map[string]any
+ }{
+ {
+ name: "unsupported MIME type",
+ inlineData: map[string]any{"mimeType": "image/svg+xml", "data": "PHN2Zz48L3N2Zz4="},
+ },
+ {
+ name: "malformed MIME type",
+ inlineData: map[string]any{"mimeType": "image/png; charset=utf-8", "data": "aW1hZ2U="},
+ },
+ {
+ name: "malformed base64",
+ inlineData: map[string]any{"mimeType": "image/png", "data": "not-valid-base64!!!"},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ geminiResp := map[string]any{
+ "candidates": []any{map[string]any{
+ "content": map[string]any{"parts": []any{map[string]any{"text": "before"}, map[string]any{"inlineData": tt.inlineData}, map[string]any{"text": "after"}}},
+ "finishReason": "STOP",
+ }},
+ }
+ rawData, err := json.Marshal(geminiResp)
+ require.NoError(t, err)
+
+ got, _, err := geminiResponseToChatCompletions(geminiResp, "gemini-test", rawData, nil)
+ require.NoError(t, err)
+
+ var content string
+ require.NoError(t, json.Unmarshal(got.Choices[0].Message.Content, &content))
+ require.Equal(t, "beforeafter", content)
+ })
+ }
+}
+
+func TestConvertGeminiToClaudeMessageOmitsInlineDataForAnthropicMessages(t *testing.T) {
+ geminiResp := map[string]any{
+ "candidates": []any{map[string]any{
+ "content": map[string]any{"parts": []any{
+ map[string]any{"text": "before"},
+ map[string]any{"inlineData": map[string]any{"mimeType": "image/png", "data": "aW1hZ2U="}},
+ map[string]any{"functionCall": map[string]any{"name": "get_weather", "args": map[string]any{"city": "Paris"}}},
+ map[string]any{"text": "after"},
+ }},
+ "finishReason": "STOP",
+ }},
+ }
+ rawData, err := json.Marshal(geminiResp)
+ require.NoError(t, err)
+
+ withInlineData, _ := convertGeminiToClaudeMessage(geminiResp, "gemini-test", rawData, true)
+ contentWithInlineData, ok := withInlineData["content"].([]any)
+ require.True(t, ok)
+ require.Len(t, contentWithInlineData, 4)
+ require.Equal(t, map[string]any{"type": "text", "text": "before"}, contentWithInlineData[0])
+ require.Equal(t, map[string]any{"type": "text", "text": ""}, contentWithInlineData[1])
+ toolUse, ok := contentWithInlineData[2].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "tool_use", toolUse["type"])
+ require.Equal(t, "get_weather", toolUse["name"])
+ require.Equal(t, map[string]any{"type": "text", "text": "after"}, contentWithInlineData[3])
+
+ withoutInlineData, _ := convertGeminiToClaudeMessage(geminiResp, "gemini-test", rawData, false)
+ contentWithoutInlineData, ok := withoutInlineData["content"].([]any)
+ require.True(t, ok)
+ require.Len(t, contentWithoutInlineData, 3)
+ require.Equal(t, map[string]any{"type": "text", "text": "before"}, contentWithoutInlineData[0])
+ toolUseWithoutInlineData, ok := contentWithoutInlineData[1].(map[string]any)
+ require.True(t, ok)
+ require.Equal(t, "tool_use", toolUseWithoutInlineData["type"])
+ require.Equal(t, "get_weather", toolUseWithoutInlineData["name"])
+ require.Equal(t, map[string]any{"type": "text", "text": "after"}, contentWithoutInlineData[2])
+}
+
+func TestGeminiResponseToChatCompletionsRetainsTextAndToolBehavior(t *testing.T) {
+ geminiResp := map[string]any{
+ "candidates": []any{map[string]any{
+ "content": map[string]any{"parts": []any{
+ map[string]any{"text": "checking"},
+ map[string]any{"functionCall": map[string]any{
+ "name": "get_weather",
+ "args": map[string]any{"city": "Paris"},
+ }},
+ }},
+ "finishReason": "STOP",
+ }},
+ }
+ rawData, err := json.Marshal(geminiResp)
+ require.NoError(t, err)
+
+ got, _, err := geminiResponseToChatCompletions(geminiResp, "gemini-test", rawData, nil)
+ require.NoError(t, err)
+ require.Len(t, got.Choices, 1)
+
+ choice := got.Choices[0]
+ var content string
+ require.NoError(t, json.Unmarshal(choice.Message.Content, &content))
+ require.Equal(t, "checking", content)
+ require.Equal(t, "tool_calls", choice.FinishReason)
+ require.Len(t, choice.Message.ToolCalls, 1)
+ require.Equal(t, "get_weather", choice.Message.ToolCalls[0].Function.Name)
+ require.JSONEq(t, `{"city":"Paris"}`, choice.Message.ToolCalls[0].Function.Arguments)
+}
diff --git a/backend/internal/service/gemini_messages_compat_service.go b/backend/internal/service/gemini_messages_compat_service.go
index 2aaba767e9..1a3821847d 100644
--- a/backend/internal/service/gemini_messages_compat_service.go
+++ b/backend/internal/service/gemini_messages_compat_service.go
@@ -5,6 +5,7 @@ import (
"bytes"
"context"
"crypto/rand"
+ "encoding/base64"
"encoding/hex"
"encoding/json"
"errors"
@@ -1068,7 +1069,7 @@ func (s *GeminiMessagesCompatService) Forward(ctx context.Context, c *gin.Contex
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to read upstream stream")
}
collectedBytes, _ := json.Marshal(collected)
- claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel, collectedBytes)
+ claudeResp, usageObj2 := convertGeminiToClaudeMessage(collected, originalModel, collectedBytes, false)
c.JSON(http.StatusOK, claudeResp)
usage = usageObj2
if usageObj != nil && (usageObj.InputTokens > 0 || usageObj.OutputTokens > 0) {
@@ -1965,7 +1966,7 @@ func (s *GeminiMessagesCompatService) handleNonStreamingResponse(c *gin.Context,
return nil, s.writeClaudeError(c, http.StatusBadGateway, "upstream_error", "Failed to parse upstream response")
}
- claudeResp, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, unwrappedBody)
+ claudeResp, usage := convertGeminiToClaudeMessage(geminiResp, originalModel, unwrappedBody, false)
c.JSON(http.StatusOK, claudeResp)
return usage, nil
@@ -2717,7 +2718,7 @@ func unwrapGeminiResponse(raw []byte) ([]byte, error) {
return raw, nil
}
-func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel string, rawData []byte) (map[string]any, *ClaudeUsage) {
+func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel string, rawData []byte, includeInlineData bool) (map[string]any, *ClaudeUsage) {
usage := extractGeminiUsage(rawData)
if usage == nil {
usage = &ClaudeUsage{}
@@ -2740,6 +2741,16 @@ func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel strin
"text": text,
})
}
+ if inlineData, ok := pm["inlineData"].(map[string]any); includeInlineData && ok {
+ mimeType, _ := inlineData["mimeType"].(string)
+ data, _ := inlineData["data"].(string)
+ if isGeminiInlineImageMIMEType(mimeType) && isValidBase64(data) {
+ contentBlocks = append(contentBlocks, map[string]any{
+ "type": "text",
+ "text": fmt.Sprintf("", mimeType, data),
+ })
+ }
+ }
if fc, ok := pm["functionCall"].(map[string]any); ok {
name, _ := fc["name"].(string)
if strings.TrimSpace(name) == "" {
@@ -2782,6 +2793,23 @@ func convertGeminiToClaudeMessage(geminiResp map[string]any, originalModel strin
return resp, usage
}
+func isGeminiInlineImageMIMEType(mimeType string) bool {
+ switch mimeType {
+ case "image/gif", "image/jpeg", "image/png", "image/webp":
+ return true
+ default:
+ return false
+ }
+}
+
+func isValidBase64(data string) bool {
+ if data == "" {
+ return false
+ }
+ _, err := base64.StdEncoding.DecodeString(data)
+ return err == nil
+}
+
func extractGeminiUsage(data []byte) *ClaudeUsage {
usage := gjson.GetBytes(data, "usageMetadata")
if !usage.Exists() {
diff --git a/backend/internal/service/openai_account_runtime_block_fastpath.go b/backend/internal/service/openai_account_runtime_block_fastpath.go
index 991a1e5170..c61f317859 100644
--- a/backend/internal/service/openai_account_runtime_block_fastpath.go
+++ b/backend/internal/service/openai_account_runtime_block_fastpath.go
@@ -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]
diff --git a/backend/internal/service/openai_account_runtime_block_fastpath_test.go b/backend/internal/service/openai_account_runtime_block_fastpath_test.go
index ae4bfa91dc..09d4662468 100644
--- a/backend/internal/service/openai_account_runtime_block_fastpath_test.go
+++ b/backend/internal/service/openai_account_runtime_block_fastpath_test.go
@@ -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{
diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go
index 2d6766af4f..c89fd038d0 100644
--- a/backend/internal/service/openai_codex_transform.go
+++ b/backend/internal/service/openai_codex_transform.go
@@ -1494,25 +1494,9 @@ 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") {
- 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")
- }
+ } else if id, ok := m["id"].(string); ok && shouldStripOpenAIResponsesInputItemID(typ, id) {
+ ensureCopy()
+ delete(newItem, "id")
}
filtered = append(filtered, newItem)
diff --git a/backend/internal/service/openai_gateway_apikey_item_id_test.go b/backend/internal/service/openai_gateway_apikey_item_id_test.go
new file mode 100644
index 0000000000..b7af70ec75
--- /dev/null
+++ b/backend/internal/service/openai_gateway_apikey_item_id_test.go
@@ -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")
+}
diff --git a/backend/internal/service/openai_gateway_forward.go b/backend/internal/service/openai_gateway_forward.go
index 7a42a445ef..b3963535b1 100644
--- a/backend/internal/service/openai_gateway_forward.go
+++ b/backend/internal/service/openai_gateway_forward.go
@@ -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)
diff --git a/backend/internal/service/openai_gateway_grok.go b/backend/internal/service/openai_gateway_grok.go
index d887ff73fc..fdfd0bf321 100644
--- a/backend/internal/service/openai_gateway_grok.go
+++ b/backend/internal/service/openai_gateway_grok.go
@@ -663,7 +663,13 @@ var grokResponsesSupportedToolTypes = map[string]struct{}{
func sanitizeGrokResponsesTools(body []byte) ([]byte, error) {
tools := gjson.GetBytes(body, "tools")
- if !tools.Exists() || !tools.IsArray() {
+ if !tools.Exists() {
+ if gjson.GetBytes(body, "tool_choice").Exists() {
+ return sjson.DeleteBytes(body, "tool_choice")
+ }
+ return body, nil
+ }
+ if !tools.IsArray() {
return body, nil
}
@@ -1363,7 +1369,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")
}
}
diff --git a/backend/internal/service/openai_gateway_grok_cache.go b/backend/internal/service/openai_gateway_grok_cache.go
index ca798d8141..170528ce76 100644
--- a/backend/internal/service/openai_gateway_grok_cache.go
+++ b/backend/internal/service/openai_gateway_grok_cache.go
@@ -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
diff --git a/backend/internal/service/openai_gateway_grok_test.go b/backend/internal/service/openai_gateway_grok_test.go
index 3a8e347332..d13cfa19fc 100644
--- a/backend/internal/service/openai_gateway_grok_test.go
+++ b/backend/internal/service/openai_gateway_grok_test.go
@@ -209,6 +209,60 @@ func TestPatchGrokResponsesBodyDropsToolChoiceWhenNoSupportedToolsRemain(t *test
require.False(t, gjson.GetBytes(patched, "tool_choice").Exists())
}
+func TestSanitizeGrokResponsesToolsKeepsToolChoiceOnlyWithSupportedTools(t *testing.T) {
+ t.Parallel()
+
+ tests := []struct {
+ name string
+ body string
+ wantTools bool
+ wantToolChoice bool
+ }{
+ {
+ name: "missing tools with string tool choice",
+ body: `{"input":"hello","tool_choice":"auto"}`,
+ },
+ {
+ name: "missing tools with object tool choice",
+ body: `{"input":"hello","tool_choice":{"type":"function","name":"lookup"}}`,
+ },
+ {
+ name: "empty tools",
+ body: `{"input":"hello","tools":[],"tool_choice":"auto"}`,
+ wantTools: true,
+ },
+ {
+ name: "all tools unsupported",
+ body: `{"input":"hello","tools":[{"type":"namespace","name":"client_tools"}],"tool_choice":"auto"}`,
+ },
+ {
+ name: "supported tool",
+ body: `{"input":"hello","tools":[{"type":"function","name":"lookup"}],"tool_choice":"auto"}`,
+ wantTools: true,
+ wantToolChoice: true,
+ },
+ {
+ name: "malformed non-array tools remain untouched",
+ body: `{"input":"hello","tools":{"type":"function","name":"lookup"},"tool_choice":"auto"}`,
+ wantTools: true,
+ wantToolChoice: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ patched, err := sanitizeGrokResponsesTools([]byte(tt.body))
+ require.NoError(t, err)
+ require.True(t, json.Valid(patched))
+ require.Equal(t, tt.wantTools, gjson.GetBytes(patched, "tools").Exists())
+ require.Equal(t, tt.wantToolChoice, gjson.GetBytes(patched, "tool_choice").Exists())
+ if tt.wantToolChoice {
+ require.Equal(t, "auto", gjson.GetBytes(patched, "tool_choice").String())
+ }
+ })
+ }
+}
+
func TestPatchGrokResponsesBodyPromotesCodexAdditionalTools(t *testing.T) {
t.Parallel()
@@ -2475,6 +2529,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{}
diff --git a/backend/internal/service/openai_gateway_scheduling.go b/backend/internal/service/openai_gateway_scheduling.go
index a895b2ebf5..01316cb58a 100644
--- a/backend/internal/service/openai_gateway_scheduling.go
+++ b/backend/internal/service/openai_gateway_scheduling.go
@@ -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
}
diff --git a/backend/internal/service/openai_gateway_usage.go b/backend/internal/service/openai_gateway_usage.go
index 773b31d625..16429f7402 100644
--- a/backend/internal/service/openai_gateway_usage.go
+++ b/backend/internal/service/openai_gateway_usage.go
@@ -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
}
diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go
index d4f6e22203..43f8109f88 100644
--- a/backend/internal/service/openai_oauth_passthrough_test.go
+++ b/backend/internal/service/openai_oauth_passthrough_test.go
@@ -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")
diff --git a/backend/internal/service/openai_responses_item_id.go b/backend/internal/service/openai_responses_item_id.go
new file mode 100644
index 0000000000..27b7dea0f5
--- /dev/null
+++ b/backend/internal/service/openai_responses_item_id.go
@@ -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
+}
diff --git a/backend/internal/service/openai_responses_namespace.go b/backend/internal/service/openai_responses_namespace.go
index 4c3f433bc4..bd548b69ec 100644
--- a/backend/internal/service/openai_responses_namespace.go
+++ b/backend/internal/service/openai_responses_namespace.go
@@ -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)
diff --git a/backend/internal/service/openai_responses_namespace_test.go b/backend/internal/service/openai_responses_namespace_test.go
index 00b8772d13..0b12355a3d 100644
--- a/backend/internal/service/openai_responses_namespace_test.go
+++ b/backend/internal/service/openai_responses_namespace_test.go
@@ -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)
+ }
+}
diff --git a/backend/internal/service/openai_responses_rejected_field_retry_test.go b/backend/internal/service/openai_responses_rejected_field_retry_test.go
index 96c20de508..724fd90c37 100644
--- a/backend/internal/service/openai_responses_rejected_field_retry_test.go
+++ b/backend/internal/service/openai_responses_rejected_field_retry_test.go
@@ -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,
diff --git a/backend/internal/service/openai_ws_forwarder_success_test.go b/backend/internal/service/openai_ws_forwarder_success_test.go
index 5872f125bf..c7cbbc0b2f 100644
--- a/backend/internal/service/openai_ws_forwarder_success_test.go
+++ b/backend/internal/service/openai_ws_forwarder_success_test.go
@@ -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 隔离,
diff --git a/backend/internal/service/pricing_service.go b/backend/internal/service/pricing_service.go
index 8bd916f4fc..636bd690cb 100644
--- a/backend/internal/service/pricing_service.go
+++ b/backend/internal/service/pricing_service.go
@@ -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 {
diff --git a/backend/internal/service/pricing_service_test.go b/backend/internal/service/pricing_service_test.go
index b68d034638..88553f0fbf 100644
--- a/backend/internal/service/pricing_service_test.go
+++ b/backend/internal/service/pricing_service_test.go
@@ -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(`{
diff --git a/backend/internal/service/session_id.go b/backend/internal/service/session_id.go
new file mode 100644
index 0000000000..8cf39f056b
--- /dev/null
+++ b/backend/internal/service/session_id.go
@@ -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
+}
diff --git a/backend/internal/service/session_id_test.go b/backend/internal/service/session_id_test.go
new file mode 100644
index 0000000000..1eb746d6ef
--- /dev/null
+++ b/backend/internal/service/session_id_test.go
@@ -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))
+}
diff --git a/backend/internal/service/usage_log.go b/backend/internal/service/usage_log.go
index f0f586b977..b35d149ea8 100644
--- a/backend/internal/service/usage_log.go
+++ b/backend/internal/service/usage_log.go
@@ -170,6 +170,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
diff --git a/backend/migrations/187_add_usage_log_session_id.sql b/backend/migrations/187_add_usage_log_session_id.sql
new file mode 100644
index 0000000000..7d6a4f6e21
--- /dev/null
+++ b/backend/migrations/187_add_usage_log_session_id.sql
@@ -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);
diff --git a/frontend/src/components/common/AnnouncementBell.vue b/frontend/src/components/common/AnnouncementBell.vue
index cfbd62a0b8..e80e449f28 100644
--- a/frontend/src/components/common/AnnouncementBell.vue
+++ b/frontend/src/components/common/AnnouncementBell.vue
@@ -322,6 +322,7 @@ import { useAnnouncementStore } from '@/stores/announcements'
import { formatRelativeTime, formatRelativeWithDateTime } from '@/utils/format'
import type { UserAnnouncement } from '@/types'
import Icon from '@/components/icons/Icon.vue'
+import '@/styles/announcement-markdown.css'
const { t } = useI18n()
const appStore = useAppStore()
@@ -471,120 +472,3 @@ watch(
background: linear-gradient(to bottom, #6b7280, #4b5563);
}
-
-
diff --git a/frontend/src/components/common/AnnouncementPopup.vue b/frontend/src/components/common/AnnouncementPopup.vue
index e26d9ee30b..99e10ed461 100644
--- a/frontend/src/components/common/AnnouncementPopup.vue
+++ b/frontend/src/components/common/AnnouncementPopup.vue
@@ -2,7 +2,7 @@
- {{ announcementStore.currentPopup.title }}
+ {{ displayedAnnouncement.title }}
@@ -43,7 +43,7 @@
-
+
@@ -66,13 +66,17 @@
@@ -84,15 +88,34 @@