new-api/middleware/muse_request_context_test.go
zizi 1612c30de6 Preserve Muse relay tags without hijacking new-api request ids
Muse now forwards business context alongside each per-user token request, so new-api needs to ingest those tags for relay selection, billing audit, and log correlation without breaking its own internal request-id semantics.

This adds a dedicated Muse request-context middleware, carries Muse tags into RelayInfo, and appends them into consume/error log payloads. Muse request ids are stored as separate business metadata instead of overwriting the internal request id used for pre-consume idempotency.

Constraint: The relay layer must keep working for both /v1 and /v1beta request paths
Constraint: Client-supplied Muse request ids cannot replace the internal request id used by new-api billing/idempotency logic
Rejected: Overwriting common.RequestIdKey with X-Request-Id | mixes external business ids with internal idempotency ids
Rejected: Adding new log table columns in phase 1 | this task only needs relay/log propagation, so other JSON is the minimal compatible path
Confidence: medium
Scope-risk: moderate
Reversibility: clean
Directive: Keep Muse business identifiers in dedicated context keys and log other fields; do not reuse internal request-id slots for external correlation ids
Tested: go test ./middleware ./relay/common ./controller -run MuseRequestContext -count=1
Tested: go test ./middleware ./relay/common ./controller -count=1
Not-tested: Live relay call from Muse into a running new-api instance
2026-04-16 19:25:29 +08:00

177 lines
5.8 KiB
Go

package middleware
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/QuantumNous/new-api/model"
"github.com/gin-gonic/gin"
"github.com/glebarez/sqlite"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type museRequestContextResponse struct {
MuseUserID string `json:"muse_user_id"`
MuseWorkID string `json:"muse_work_id"`
MuseScene string `json:"muse_scene"`
MuseRequestID string `json:"muse_request_id"`
TraceID string `json:"trace_id"`
}
func setupMuseRequestContextTestDB(t *testing.T) *gorm.DB {
t.Helper()
common.UsingSQLite = true
common.UsingMySQL = false
common.UsingPostgreSQL = false
common.RedisEnabled = false
db, err := gorm.Open(sqlite.Open("file:muse_request_context_test?mode=memory&cache=shared"), &gorm.Config{})
require.NoError(t, err)
model.DB = db
model.LOG_DB = db
require.NoError(t, db.AutoMigrate(&model.User{}, &model.Log{}))
require.NoError(t, db.Create(&model.User{
Id: 1,
Username: "alice",
Password: "hashed",
Status: common.UserStatusEnabled,
}).Error)
t.Cleanup(func() {
sqlDB, err := db.DB()
if err == nil {
_ = sqlDB.Close()
}
})
return db
}
func TestMuseRequestContextExtractsHeaders(t *testing.T) {
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(
http.MethodPost,
"/v1/chat/completions",
strings.NewReader(`{"metadata":{"work_id":"42","scene":"suggestion_generation","muse_user_id":"u-1"}}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(museHeaderUserID, "u-1")
req.Header.Set(museHeaderWorkID, "42")
req.Header.Set(museHeaderRequest, "req-1")
req.Header.Set(museHeaderTraceID, "trace-1")
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(MuseRequestContext())
router.POST("/v1/chat/completions", func(c *gin.Context) {
c.JSON(http.StatusOK, museRequestContextResponse{
MuseUserID: common.GetContextKeyString(c, constant.ContextKeyMuseUserID),
MuseWorkID: common.GetContextKeyString(c, constant.ContextKeyMuseWorkID),
MuseScene: common.GetContextKeyString(c, constant.ContextKeyMuseScene),
MuseRequestID: common.GetContextKeyString(c, constant.ContextKeyMuseRequestID),
TraceID: common.GetContextKeyString(c, constant.ContextKeyMuseTraceID),
})
})
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
var response museRequestContextResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.Equal(t, "u-1", response.MuseUserID)
require.Equal(t, "42", response.MuseWorkID)
require.Equal(t, "suggestion_generation", response.MuseScene)
require.Equal(t, "trace-1", response.TraceID)
require.Equal(t, "req-1", response.MuseRequestID)
}
func TestMuseRequestContextFallsBackToMetadata(t *testing.T) {
gin.SetMode(gin.TestMode)
req := httptest.NewRequest(
http.MethodPost,
"/v1/chat/completions",
strings.NewReader(`{"metadata":{"muse_user_id":"u-2","work_id":42,"scene":"suggestion_generation","request_id":"req-meta","trace_id":"trace-meta"}}`),
)
req.Header.Set("Content-Type", "application/json")
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(MuseRequestContext())
router.POST("/v1/chat/completions", func(c *gin.Context) {
c.JSON(http.StatusOK, museRequestContextResponse{
MuseUserID: common.GetContextKeyString(c, constant.ContextKeyMuseUserID),
MuseWorkID: common.GetContextKeyString(c, constant.ContextKeyMuseWorkID),
MuseScene: common.GetContextKeyString(c, constant.ContextKeyMuseScene),
MuseRequestID: common.GetContextKeyString(c, constant.ContextKeyMuseRequestID),
TraceID: common.GetContextKeyString(c, constant.ContextKeyMuseTraceID),
})
})
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
var response museRequestContextResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.Equal(t, "u-2", response.MuseUserID)
require.Equal(t, "42", response.MuseWorkID)
require.Equal(t, "suggestion_generation", response.MuseScene)
require.Equal(t, "trace-meta", response.TraceID)
require.Equal(t, "req-meta", response.MuseRequestID)
}
func TestMuseRequestContextRecordConsumeLogIncludesMuseFields(t *testing.T) {
gin.SetMode(gin.TestMode)
setupMuseRequestContextTestDB(t)
req := httptest.NewRequest(
http.MethodPost,
"/v1/chat/completions",
strings.NewReader(`{"metadata":{"scene":"suggestion_generation","request_id":"req-log","trace_id":"trace-log"}}`),
)
req.Header.Set("Content-Type", "application/json")
req.Header.Set(museHeaderUserID, "u-log")
req.Header.Set(museHeaderWorkID, "77")
recorder := httptest.NewRecorder()
router := gin.New()
router.Use(MuseRequestContext())
router.POST("/v1/chat/completions", func(c *gin.Context) {
model.RecordConsumeLog(c, 1, model.RecordConsumeLogParams{
ModelName: "gpt-4o",
Content: "ok",
Group: "default",
Other: map[string]interface{}{"existing": true},
})
var logEntry model.Log
require.NoError(t, model.LOG_DB.Last(&logEntry).Error)
c.JSON(http.StatusOK, gin.H{
"request_id": logEntry.RequestId,
"other": logEntry.Other,
})
})
router.ServeHTTP(recorder, req)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
RequestID string `json:"request_id"`
Other string `json:"other"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.Empty(t, response.RequestID)
require.Contains(t, response.Other, `"muse_user_id":"u-log"`)
require.Contains(t, response.Other, `"muse_work_id":"77"`)
require.Contains(t, response.Other, `"muse_request_id":"req-log"`)
require.Contains(t, response.Other, `"muse_scene":"suggestion_generation"`)
require.Contains(t, response.Other, `"muse_trace_id":"trace-log"`)
}