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:
Calcium-Ion
2026-09-03 10:40:05 +08:00
committed by GitHub
parent 0ed497f066
commit bbd97446c2
18 changed files with 958 additions and 103 deletions
+13 -3
View File
@@ -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)
+93
View File
@@ -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
}