mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41: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:
@@ -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