mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 06:30:21 +00:00
fix(relay): follow-up billing integrity and conversion completions (#7170)
Deferred follow-ups from the relaykit-tools review cycle, verified by live end-to-end billing tests: - billing: normalize Gemini modality keys consistently between stream merge and settlement (case/whitespace variants no longer drop independent audio/image pricing) and sum duplicate modality entries on both paths - billing: sync legacy flat Claude cache-creation fields from the CacheCreation sub-object (including zeroing) and fall back to flat fields only when the snapshot never carried a sub-object, closing a stale 1h-cache overcharge path in cascaded deployments - relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state from gin.Context onto RelayInfo and reset it with SendResponseCount in InitChannelMeta, so channel retries start clean while per-request state (stream error collection, conversion diagnostics, channel chain, billing accumulators) survives - relay: Claude channel now serves Gemini-format clients (request via registry conversion, response and stream composed through the Chat pivot), removing the last unimplemented conversion direction - relaykit: recognize legacy pseudo tool names (googleSearch, codeExecution, urlContext) in the toolconv decode stage and drop the string-matching bypass in the Chat-to-Gemini converter; native Gemini tool output is restored and non-Gemini targets follow standard loss diagnostics - relaykit: attach upstream Gemini usage (with billing_usage sidecar) to intermediate stream chunks so converted Claude streams report upstream truth from message_start, and preserve the sidecar through Claude stream usage merges; billing settlement unchanged - billing: clamp negative Total-Prompt completion derivation, OR the Estimated flag across cross-dialect snapshot replacement, and fill canonical OpenAI prompt details via field-wise merge
This commit is contained in:
@@ -21,9 +21,15 @@ import (
|
||||
type Adaptor struct {
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
||||
//TODO implement me
|
||||
return nil, errors.New("not implemented")
|
||||
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
|
||||
if request == nil {
|
||||
return nil, errors.New("request is nil")
|
||||
}
|
||||
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result.Value, nil
|
||||
}
|
||||
|
||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
||||
|
||||
@@ -88,3 +88,100 @@ func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
|
||||
}
|
||||
|
||||
func geminiToClaudeInfo() *relaycommon.RelayInfo {
|
||||
return &relaycommon.RelayInfo{
|
||||
OriginModelName: "claude-3-7-sonnet",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "claude-3-7-sonnet",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestMapsSystemInstructionToolsAndMultimodal(t *testing.T) {
|
||||
req := &dto.GeminiChatRequest{
|
||||
Contents: []dto.GeminiChatContent{
|
||||
{
|
||||
Role: "user",
|
||||
Parts: []dto.GeminiPart{
|
||||
{Text: "What is in this image?"},
|
||||
{InlineData: &dto.GeminiInlineData{MimeType: "image/png", Data: "aGVsbG8="}},
|
||||
},
|
||||
},
|
||||
},
|
||||
SystemInstructions: &dto.GeminiChatContent{
|
||||
Parts: []dto.GeminiPart{{Text: "You are a helpful assistant."}},
|
||||
},
|
||||
}
|
||||
req.SetTools([]dto.GeminiChatTool{
|
||||
{
|
||||
FunctionDeclarations: []dto.FunctionRequest{
|
||||
{
|
||||
Name: "lookup",
|
||||
Description: "Lookup data",
|
||||
Parameters: map[string]any{
|
||||
"type": "object",
|
||||
"properties": map[string]any{"q": map[string]any{"type": "string"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
out, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), req)
|
||||
require.NoError(t, err)
|
||||
converted, ok := out.(*dto.ClaudeRequest)
|
||||
require.True(t, ok)
|
||||
|
||||
system := converted.ParseSystem()
|
||||
require.NotEmpty(t, system)
|
||||
assert.Contains(t, system[0].GetText(), "You are a helpful assistant.")
|
||||
require.NotEmpty(t, converted.Messages)
|
||||
assert.Equal(t, "user", converted.Messages[0].Role)
|
||||
|
||||
blocks, parseErr := converted.Messages[0].ParseContent()
|
||||
require.NoError(t, parseErr)
|
||||
var foundImage bool
|
||||
for _, block := range blocks {
|
||||
if block.Type == "image" || (block.Source != nil && block.Source.Type == "base64") {
|
||||
foundImage = true
|
||||
break
|
||||
}
|
||||
}
|
||||
assert.True(t, foundImage)
|
||||
|
||||
require.NotNil(t, converted.Tools)
|
||||
tools, err := common.Marshal(converted.Tools)
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(tools), `"lookup"`)
|
||||
require.NotNil(t, converted.MaxTokens)
|
||||
assert.Greater(t, *converted.MaxTokens, uint(0))
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestThinkingConfigUsesReasoningIntent(t *testing.T) {
|
||||
budget := 1024
|
||||
maxTokens := uint(4096)
|
||||
req := &dto.GeminiChatRequest{
|
||||
Contents: []dto.GeminiChatContent{
|
||||
{Role: "user", Parts: []dto.GeminiPart{{Text: "think"}}},
|
||||
},
|
||||
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||
MaxOutputTokens: &maxTokens,
|
||||
ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingBudget: &budget},
|
||||
},
|
||||
}
|
||||
|
||||
out, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), req)
|
||||
require.NoError(t, err)
|
||||
converted, ok := out.(*dto.ClaudeRequest)
|
||||
require.True(t, ok)
|
||||
require.NotNil(t, converted.Thinking)
|
||||
assert.Equal(t, "enabled", converted.Thinking.Type)
|
||||
require.NotNil(t, converted.Thinking.BudgetTokens)
|
||||
assert.Equal(t, 1024, *converted.Thinking.BudgetTokens)
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestNilRequest(t *testing.T) {
|
||||
_, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), nil)
|
||||
require.Error(t, err)
|
||||
}
|
||||
|
||||
@@ -20,8 +20,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const claudeToChatStreamStateKey = "relaykit.claude_to_chat_stream_state"
|
||||
|
||||
func stopReasonClaude2OpenAI(reason string) string {
|
||||
return relayconvert.StopReasonClaudeToOpenAI(reason)
|
||||
}
|
||||
@@ -120,7 +118,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
countClaudeStreamBillableTools(c, info, &claudeResponse)
|
||||
helper.ClaudeChunkData(c, claudeResponse, data)
|
||||
} else if info.RelayFormat == types.RelayFormatOpenAI {
|
||||
state, err := claudeToChatStreamState(c)
|
||||
state, err := claudeToChatStreamState(info)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
@@ -142,24 +140,80 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
if err != nil {
|
||||
logger.LogError(c, "send_stream_response_failed: "+err.Error())
|
||||
}
|
||||
} else if info.RelayFormat == types.RelayFormatGemini {
|
||||
state, err := claudeToGeminiStreamState(info)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
results, err := service.ConvertStreamResponseChunk(c, info, state, &claudeResponse)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
if !FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo) {
|
||||
return nil
|
||||
}
|
||||
countClaudeStreamBillableTools(c, info, &claudeResponse)
|
||||
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
|
||||
return sendErr
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func claudeToChatStreamState(c *gin.Context) (*relayconvert.ClaudeToChatStreamState, error) {
|
||||
if value, ok := c.Get(claudeToChatStreamStateKey); ok {
|
||||
state, ok := value.(*relayconvert.ClaudeToChatStreamState)
|
||||
func claudeToChatStreamState(info *relaycommon.RelayInfo) (*relayconvert.ClaudeToChatStreamState, error) {
|
||||
if info != nil && info.ClaudeToChatStreamState != nil {
|
||||
state, ok := info.ClaudeToChatStreamState.(*relayconvert.ClaudeToChatStreamState)
|
||||
if !ok || state == nil {
|
||||
return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", value)
|
||||
return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", info.ClaudeToChatStreamState)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
state := relayconvert.NewClaudeToChatStreamState()
|
||||
c.Set(claudeToChatStreamStateKey, state)
|
||||
if info != nil {
|
||||
info.ClaudeToChatStreamState = state
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func claudeToGeminiStreamState(info *relaycommon.RelayInfo) (*relayconvert.ResponseStreamState, error) {
|
||||
if info != nil && info.ChatToGeminiStreamState != nil {
|
||||
state, ok := info.ChatToGeminiStreamState.(*relayconvert.ResponseStreamState)
|
||||
if !ok || state == nil {
|
||||
return nil, fmt.Errorf("invalid Claude-to-Gemini stream state %T", info.ChatToGeminiStreamState)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
state, err := relayconvert.NewResponseStreamState(types.RelayFormatClaude, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if info != nil {
|
||||
info.ChatToGeminiStreamState = state
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResult) *types.NewAPIError {
|
||||
for _, result := range results {
|
||||
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
|
||||
if !ok {
|
||||
return types.NewError(fmt.Errorf("expected Gemini stream response, got %T", result.Value), types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
if geminiResponse == nil {
|
||||
continue
|
||||
}
|
||||
data, err := common.Marshal(geminiResponse)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
|
||||
_ = helper.FlushWriter(c)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
|
||||
if claudeResponse == nil {
|
||||
return
|
||||
@@ -213,6 +267,20 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
|
||||
}
|
||||
}
|
||||
helper.Done(c)
|
||||
} else if info.RelayFormat == types.RelayFormatGemini {
|
||||
state, err := claudeToGeminiStreamState(info)
|
||||
if err != nil {
|
||||
common.SysLog("error creating Gemini stream state: " + err.Error())
|
||||
return
|
||||
}
|
||||
results, err := service.FinalizeStreamResponse(c, info, state)
|
||||
if err != nil {
|
||||
common.SysLog("error finalizing Gemini stream response: " + err.Error())
|
||||
return
|
||||
}
|
||||
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
|
||||
common.SysLog("send final Gemini stream response failed: " + sendErr.Error())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,6 +361,21 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
||||
}
|
||||
case types.RelayFormatClaude:
|
||||
responseData = data
|
||||
case types.RelayFormatGemini:
|
||||
{
|
||||
convertResult, convertErr := service.ConvertResponse(c, info, types.RelayFormatGemini, &claudeResponse)
|
||||
if convertErr != nil {
|
||||
return types.NewError(convertErr, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
geminiResponse, ok := convertResult.Value.(*dto.GeminiChatResponse)
|
||||
if !ok {
|
||||
return types.NewError(fmt.Errorf("expected Gemini generateContent response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
responseData, err = common.Marshal(geminiResponse)
|
||||
if err != nil {
|
||||
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
|
||||
|
||||
@@ -299,6 +299,9 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
||||
if info.SendResponseCount == 0 {
|
||||
// send first response
|
||||
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
||||
// Claude message_start is emitted from this first OpenAI chunk.
|
||||
// Carry upstream usage when the current Gemini frame provided it.
|
||||
emptyResponse.Usage = response.Usage
|
||||
if response.IsToolCall() {
|
||||
if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 {
|
||||
toolCalls := response.Choices[0].Delta.ToolCalls
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/common"
|
||||
@@ -16,6 +17,130 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestStreamResponseGeminiChat2OpenAIAttachesUsageMetadata(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
withUsage, isStop := streamResponseGeminiChat2OpenAI(&dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{{Text: "hello"}},
|
||||
},
|
||||
}},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 3868,
|
||||
CandidatesTokenCount: 0,
|
||||
TotalTokenCount: 3868,
|
||||
},
|
||||
})
|
||||
require.False(t, isStop)
|
||||
require.NotNil(t, withUsage)
|
||||
require.NotNil(t, withUsage.Usage)
|
||||
require.Equal(t, 3868, withUsage.Usage.PromptTokens)
|
||||
require.Equal(t, 3868, withUsage.Usage.TotalTokens)
|
||||
require.NotNil(t, withUsage.Usage.BillingUsage)
|
||||
require.Equal(t, dto.BillingUsageSourceGeminiChat, withUsage.Usage.BillingUsage.Source)
|
||||
require.Equal(t, dto.BillingUsageSemanticGemini, withUsage.Usage.BillingUsage.Semantic)
|
||||
require.NotNil(t, withUsage.Usage.BillingUsage.GeminiUsageMetadata)
|
||||
require.Equal(t, 3868, withUsage.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||
require.False(t, withUsage.Usage.BillingUsage.Estimated)
|
||||
|
||||
withoutUsage, _ := streamResponseGeminiChat2OpenAI(&dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{{Text: "hello"}},
|
||||
},
|
||||
}},
|
||||
})
|
||||
require.NotNil(t, withoutUsage)
|
||||
require.Nil(t, withoutUsage.Usage)
|
||||
}
|
||||
|
||||
func TestGeminiChatStreamHandlerClaudeFirstFrameUsesUpstreamUsage(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
recorder := httptest.NewRecorder()
|
||||
c, _ := gin.CreateTestContext(recorder)
|
||||
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||
|
||||
oldStreamingTimeout := constant.StreamingTimeout
|
||||
constant.StreamingTimeout = 300
|
||||
t.Cleanup(func() {
|
||||
constant.StreamingTimeout = oldStreamingTimeout
|
||||
})
|
||||
|
||||
info := &relaycommon.RelayInfo{
|
||||
RelayFormat: types.RelayFormatClaude,
|
||||
OriginModelName: "gemini-2.5-flash",
|
||||
ChannelMeta: &relaycommon.ChannelMeta{
|
||||
UpstreamModelName: "gemini-2.5-flash",
|
||||
},
|
||||
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||
},
|
||||
}
|
||||
info.SetEstimatePromptTokens(4994)
|
||||
|
||||
chunkData, err := common.Marshal(dto.GeminiChatResponse{
|
||||
Candidates: []dto.GeminiChatCandidate{{
|
||||
Content: dto.GeminiChatContent{
|
||||
Role: "model",
|
||||
Parts: []dto.GeminiPart{{Text: "hello"}},
|
||||
},
|
||||
}},
|
||||
UsageMetadata: dto.GeminiUsageMetadata{
|
||||
PromptTokenCount: 3868,
|
||||
TotalTokenCount: 3868,
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
resp := &http.Response{
|
||||
Body: io.NopCloser(bytes.NewReader([]byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n"))),
|
||||
}
|
||||
|
||||
usage, newAPIError := GeminiChatStreamHandler(c, info, resp)
|
||||
require.Nil(t, newAPIError)
|
||||
require.NotNil(t, usage)
|
||||
require.Equal(t, 3868, usage.PromptTokens)
|
||||
|
||||
var startUsage, deltaUsage *dto.ClaudeUsage
|
||||
for _, line := range strings.Split(recorder.Body.String(), "\n") {
|
||||
payload, ok := strings.CutPrefix(strings.TrimSpace(line), "data: ")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
var event dto.ClaudeResponse
|
||||
if err := common.UnmarshalJsonStr(payload, &event); err != nil {
|
||||
continue
|
||||
}
|
||||
switch event.Type {
|
||||
case "message_start":
|
||||
if event.Message != nil {
|
||||
startUsage = event.Message.Usage
|
||||
}
|
||||
case "message_delta":
|
||||
deltaUsage = event.Usage
|
||||
}
|
||||
}
|
||||
|
||||
require.NotNil(t, startUsage)
|
||||
require.Equal(t, 3868, startUsage.InputTokens)
|
||||
require.NotNil(t, startUsage.BillingUsage)
|
||||
require.Equal(t, dto.BillingUsageSourceGeminiChat, startUsage.BillingUsage.Source)
|
||||
require.Equal(t, dto.BillingUsageSemanticGemini, startUsage.BillingUsage.Semantic)
|
||||
require.NotNil(t, startUsage.BillingUsage.GeminiUsageMetadata)
|
||||
require.Equal(t, 3868, startUsage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||
require.False(t, startUsage.BillingUsage.Estimated)
|
||||
|
||||
require.NotNil(t, deltaUsage)
|
||||
require.Equal(t, 3868, deltaUsage.InputTokens)
|
||||
require.NotNil(t, deltaUsage.BillingUsage)
|
||||
require.Equal(t, dto.BillingUsageSourceGeminiChat, deltaUsage.BillingUsage.Source)
|
||||
require.Equal(t, dto.BillingUsageSemanticGemini, deltaUsage.BillingUsage.Semantic)
|
||||
require.NotNil(t, deltaUsage.BillingUsage.GeminiUsageMetadata)
|
||||
require.Equal(t, 3868, deltaUsage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||
}
|
||||
|
||||
func TestGeminiChatHandlerCompletionTokensExcludeToolUsePromptTokens(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
@@ -19,8 +19,6 @@ import (
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
const chatToGeminiStreamStateKey = "relaykit.chat_to_gemini_stream_state"
|
||||
|
||||
// 辅助函数
|
||||
func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
|
||||
switch info.RelayFormat {
|
||||
@@ -68,7 +66,7 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
|
||||
return err
|
||||
}
|
||||
|
||||
state, err := chatToGeminiStreamState(c, &streamResponse)
|
||||
state, err := chatToGeminiStreamState(info, &streamResponse)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -79,11 +77,11 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
|
||||
return sendGeminiStreamResults(c, results)
|
||||
}
|
||||
|
||||
func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
|
||||
if value, ok := c.Get(chatToGeminiStreamStateKey); ok {
|
||||
state, ok := value.(*relayconvert.ResponseStreamState)
|
||||
func chatToGeminiStreamState(info *relaycommon.RelayInfo, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
|
||||
if info != nil && info.ChatToGeminiStreamState != nil {
|
||||
state, ok := info.ChatToGeminiStreamState.(*relayconvert.ResponseStreamState)
|
||||
if !ok || state == nil {
|
||||
return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", value)
|
||||
return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", info.ChatToGeminiStreamState)
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
@@ -96,7 +94,9 @@ func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletions
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Set(chatToGeminiStreamStateKey, state)
|
||||
if info != nil {
|
||||
info.ChatToGeminiStreamState = state
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
@@ -233,7 +233,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
|
||||
return
|
||||
}
|
||||
|
||||
state, err := chatToGeminiStreamState(c, &streamResponse)
|
||||
state, err := chatToGeminiStreamState(info, &streamResponse)
|
||||
if err != nil {
|
||||
common.SysLog("error creating Gemini stream state: " + err.Error())
|
||||
return
|
||||
|
||||
@@ -123,9 +123,14 @@ type RelayInfo struct {
|
||||
UserSetting dto.UserSetting
|
||||
UserEmail string
|
||||
UserQuota int
|
||||
RelayFormat types.RelayFormat
|
||||
SendResponseCount int
|
||||
ReceivedResponseCount int
|
||||
RelayFormat types.RelayFormat
|
||||
SendResponseCount int
|
||||
// ClaudeToChatStreamState / ChatToGeminiStreamState hold per-attempt
|
||||
// stream converters. InitChannelMeta nils them so a retry cannot resume a
|
||||
// dirty converter (advanced tool index / finalized).
|
||||
ClaudeToChatStreamState any
|
||||
ChatToGeminiStreamState any
|
||||
ReceivedResponseCount int
|
||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
|
||||
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
|
||||
@@ -203,6 +208,11 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
|
||||
info.FinalRequestRelayFormat = ""
|
||||
info.RequestConversionChain = nil
|
||||
info.InitRequestConversionChain()
|
||||
// Per-attempt only. Do not clear StreamStatus, conversion diagnostics,
|
||||
// LastError, or billing accumulators — those are request-scoped.
|
||||
info.SendResponseCount = 0
|
||||
info.ClaudeToChatStreamState = nil
|
||||
info.ChatToGeminiStreamState = nil
|
||||
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
|
||||
paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
|
||||
headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride)
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
package common
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/gin-gonic/gin"
|
||||
@@ -178,3 +180,94 @@ func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) {
|
||||
info.InitChannelMeta(ctx)
|
||||
assert.Equal(t, "max", info.ReasoningEffort)
|
||||
}
|
||||
|
||||
func TestInitChannelMetaResetsPerAttemptStreamStateAndPreservesRequestState(t *testing.T) {
|
||||
gin.SetMode(gin.TestMode)
|
||||
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||
ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
|
||||
|
||||
info, err := GenRelayInfo(ctx, types.RelayFormatOpenAI, &dto.GeneralOpenAIRequest{Model: "gpt-test"}, nil)
|
||||
require.NoError(t, err)
|
||||
|
||||
claudeState := relayconvert.NewClaudeToChatStreamState()
|
||||
_, err = claudeState.ConvertChunk(&dto.ClaudeResponse{
|
||||
Type: "content_block_start",
|
||||
Index: ptr(7),
|
||||
ContentBlock: &dto.ClaudeMediaMessage{
|
||||
Type: "tool_use",
|
||||
Id: "toolu_1",
|
||||
Name: "lookup",
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
_, err = claudeState.ConvertChunk(&dto.ClaudeResponse{
|
||||
Type: "content_block_delta",
|
||||
Index: ptr(7),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "input_json_delta",
|
||||
PartialJson: ptr(`{"q":"x"}`),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
geminiState, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{
|
||||
ID: "chatcmpl_1",
|
||||
Model: "gpt-test",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
info.SendResponseCount = 3
|
||||
info.ClaudeToChatStreamState = claudeState
|
||||
info.ChatToGeminiStreamState = geminiState
|
||||
info.LastError = types.NewError(assert.AnError, types.ErrorCodeBadResponseBody)
|
||||
info.StreamStatus = NewStreamStatus()
|
||||
info.StreamStatus.RecordError("attempt 1 soft error")
|
||||
info.RecordConversionDiagnostics(context.Background(), []types.ConversionDiagnostic{{
|
||||
Code: "test.loss",
|
||||
Message: "attempt 1 conversion loss",
|
||||
Severity: types.ConversionDiagnosticWarning,
|
||||
From: types.RelayFormatClaude,
|
||||
To: types.RelayFormatOpenAI,
|
||||
}})
|
||||
|
||||
info.InitChannelMeta(ctx)
|
||||
|
||||
assert.Zero(t, info.SendResponseCount)
|
||||
assert.Nil(t, info.ClaudeToChatStreamState)
|
||||
assert.Nil(t, info.ChatToGeminiStreamState)
|
||||
|
||||
require.NotNil(t, info.StreamStatus)
|
||||
assert.True(t, info.StreamStatus.HasErrors())
|
||||
assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
|
||||
diagnostics := info.ConversionDiagnostics()
|
||||
require.Len(t, diagnostics, 1)
|
||||
assert.Equal(t, "test.loss", diagnostics[0].Code)
|
||||
require.NotNil(t, info.LastError)
|
||||
|
||||
freshClaude := relayconvert.NewClaudeToChatStreamState()
|
||||
_, err = freshClaude.ConvertChunk(&dto.ClaudeResponse{
|
||||
Type: "content_block_delta",
|
||||
Index: ptr(7),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
Type: "input_json_delta",
|
||||
PartialJson: ptr(`{"q":"x"}`),
|
||||
},
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "unknown content block index")
|
||||
|
||||
info.IncrSendResponseCount()
|
||||
responses := relayconvert.StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||
Id: "chatcmpl_retry",
|
||||
Model: "gpt-test",
|
||||
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
|
||||
}},
|
||||
}, info)
|
||||
require.NotEmpty(t, responses)
|
||||
assert.Equal(t, "message_start", responses[0].Type)
|
||||
}
|
||||
|
||||
func ptr[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user