feat(relay): hosted-tool conversion fidelity, reasoning normalization, and billing usage integrity (#7137)

* feat(relaykit): preserve hosted tools across conversions

- add protocol-neutral hosted-tool DTOs, conversion metadata, and loss policies
- bridge citations, grounding metadata, and hosted-tool stream lifecycles
- document the public conversion behavior and channel policy controls

* refactor(relaykit): normalize reasoning and thinking intent

- centralize provider-neutral reasoning intent, effort, and budget mappings
- parse model suffixes at the host entry boundary while preserving provider-owned tails
- keep adaptive Claude thinking and explicit zero-token compatibility consistent

* fix(billing): preserve authoritative usage across relay hops

- carry native BillingUsage sidecars through direct and streamed protocol bridges
- merge partial and terminal usage monotonically with safe fallback settlement
- retain cache metadata, penultimate usage, and per-call Gemini tool surcharges

* feat(relay): bridge Responses with Claude and Gemini protocols

- add direct request, response, and stream converters across supported relay formats
- expose Claude count_tokens and Chat-to-Responses compatibility endpoints
- carry conversion diagnostics through the host while retaining the curated public goldens

* fix(relay): wire relaykit conversions into host channels

- connect handlers, adaptors, and channel settings to the standalone conversion layer
- keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned
- ignore local audit artifacts and retain focused public regression coverage
This commit is contained in:
Calcium-Ion
2026-09-01 21:53:35 +08:00
committed by GitHub
parent b7017c251b
commit 0ed497f066
149 changed files with 14729 additions and 3044 deletions
+4
View File
@@ -39,6 +39,10 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
claudeAdaptor := claude.Adaptor{}
if _, err := claudeAdaptor.ConvertClaudeRequest(c, info, request); err != nil {
return nil, err
}
for i, message := range request.Messages {
updated := false
if !message.IsStringContent() {
+1 -7
View File
@@ -357,7 +357,7 @@ func TestAwsStreamHandlerUsesFinalUpstreamUsage(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "[DONE]")
}
func TestAwsStreamHandlerStopsAtClientCancellationAndKeepsPartialBillingUsage(t *testing.T) {
func TestAwsStreamHandlerStopsAtClientCancellation(t *testing.T) {
originalRelayTimeout := common.RelayTimeout
common.RelayTimeout = 0
t.Cleanup(func() {
@@ -439,12 +439,6 @@ func TestAwsStreamHandlerStopsAtClientCancellationAndKeepsPartialBillingUsage(t
require.ErrorIs(t, upstreamContext.Err(), context.Canceled)
require.Nil(t, result.err)
require.NotNil(t, result.usage)
require.NotNil(t, result.usage.BillingUsage)
require.NotNil(t, result.usage.BillingUsage.ClaudeUsage)
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, result.usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticAnthropic, result.usage.BillingUsage.Semantic)
assert.Equal(t, 100, result.usage.BillingUsage.ClaudeUsage.InputTokens)
assert.Equal(t, 1, result.usage.BillingUsage.ClaudeUsage.OutputTokens)
assert.Equal(t, bodyLengthBeforeCancel, responseWriter.Body.Len())
assert.NotContains(t, responseWriter.Body.String(), "[DONE]")
+30 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
@@ -26,6 +27,22 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
if request.MaxTokens != nil && *request.MaxTokens == 0 {
request.MaxTokens = nil
}
if err := relayconvert.ApplyClaudeThinkingModel(request, info); err != nil {
return nil, err
}
if request.MaxTokens == nil {
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
request.MaxTokens = &defaultMaxTokens
}
// ApplyClaudeThinkingModel no longer rewrites request.Model. Do not write
// a still-suffixed name back over the entry-normalized UpstreamModelName
// (AWS/Vertex look up getAwsModelID / claudeModelMap from that field).
if info.UpstreamModelName == "" {
info.UpstreamModelName = request.Model
}
return request, nil
}
@@ -96,7 +113,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatClaude, request)
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
if err != nil {
return nil, err
}
@@ -113,8 +130,15 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, &request)
if err != nil {
return nil, err
}
claudeRequest, ok := result.Value.(*dto.ClaudeRequest)
if !ok {
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", result.Value)
}
return claudeRequest, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
@@ -123,6 +147,9 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
info.FinalRequestRelayFormat = types.RelayFormatClaude
if info.RelayFormat == types.RelayFormatOpenAIResponses && info.IsStream {
return ClaudeResponsesStreamHandler(c, resp, info)
}
if info.IsStream {
return ClaudeStreamHandler(c, resp, info)
} else {
+90
View File
@@ -0,0 +1,90 @@
package claude
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertClaudeRequestTreatsZeroMaxTokensAsUnset(t *testing.T) {
zero := uint(0)
req := &dto.ClaudeRequest{
Model: "claude-sonnet-4-5",
MaxTokens: &zero,
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-sonnet-4-5",
},
}
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
require.NotNil(t, converted.MaxTokens)
assert.Equal(t, uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model)), *converted.MaxTokens)
}
func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
zero := uint(0)
original := &dto.ClaudeRequest{
Model: "claude-3-7-sonnet-thinking",
MaxTokens: &zero,
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
Request: original,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
outbound, err := common.DeepCopy(original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
assert.Equal(t, "claude-3-7-sonnet", converted.Model)
require.NotNil(t, converted.Thinking)
require.NotNil(t, converted.MaxTokens)
assert.Greater(t, *converted.MaxTokens, uint(1024))
}
func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing.T) {
req := &dto.ClaudeRequest{
Model: "claude-3-7-sonnet-thinking",
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet",
},
}
_, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
require.NoError(t, err)
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
}
+49 -5
View File
@@ -1,6 +1,7 @@
package claude
import (
"fmt"
"io"
"net/http"
"strings"
@@ -19,6 +20,8 @@ import (
"github.com/gin-gonic/gin"
)
const claudeToChatStreamStateKey = "relaykit.claude_to_chat_stream_state"
func stopReasonClaude2OpenAI(reason string) string {
return relayconvert.StopReasonClaudeToOpenAI(reason)
}
@@ -117,7 +120,14 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
countClaudeStreamBillableTools(c, info, &claudeResponse)
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
response := StreamResponseClaude2OpenAI(&claudeResponse)
state, err := claudeToChatStreamState(c)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
response, err := state.ConvertChunk(&claudeResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
if !FormatClaudeResponseInfo(&claudeResponse, response, claudeInfo) {
return nil
@@ -125,6 +135,9 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
countClaudeStreamBillableTools(c, info, &claudeResponse)
if response == nil {
return nil
}
err = helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "send_stream_response_failed: "+err.Error())
@@ -133,6 +146,20 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
func claudeToChatStreamState(c *gin.Context) (*relayconvert.ClaudeToChatStreamState, error) {
if value, ok := c.Get(claudeToChatStreamStateKey); ok {
state, ok := value.(*relayconvert.ClaudeToChatStreamState)
if !ok || state == nil {
return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", value)
}
return state, nil
}
state := relayconvert.NewClaudeToChatStreamState()
c.Set(claudeToChatStreamStateKey, state)
return state, nil
}
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
if claudeResponse == nil {
return
@@ -172,9 +199,7 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
}
if claudeInfo.Usage != nil && claudeInfo.Usage.BillingUsage == nil {
claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(buildMessageDeltaPatchUsage(nil, claudeInfo))
}
relayconvert.FinalizeClaudeStreamBillingUsage(claudeInfo)
if info.RelayFormat == types.RelayFormatClaude {
//
@@ -232,7 +257,10 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(claudeResponse.Usage)
claudeInfo.Usage.BillingUsage = dto.CloneBillingUsage(claudeResponse.Usage.BillingUsage)
if claudeInfo.Usage.BillingUsage == nil {
claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(claudeResponse.Usage)
}
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
@@ -247,6 +275,22 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatOpenAIResponses:
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &claudeResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
responsesResponse, ok := convertResult.Value.(*dto.OpenAIResponsesResponse)
if !ok {
return types.NewError(fmt.Errorf("expected OpenAI Responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody)
}
if responseID := helper.GetResponseID(c); responseID != "" {
responsesResponse.ID = responseID
}
responseData, err = common.Marshal(responsesResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatClaude:
responseData = data
}
+29 -4
View File
@@ -1,12 +1,16 @@
package claude
import (
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -323,8 +327,27 @@ func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
}
func applyOpenAIChatReasoningThroughHandlerOrder(t *testing.T, original dto.GeneralOpenAIRequest) (*dto.GeneralOpenAIRequest, *relaycommon.RelayInfo) {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
OriginModelName: original.Model,
Request: &original,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: original.Model,
},
}
outbound, err := common.DeepCopy(&original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
return outbound, info
}
func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
request := dto.GeneralOpenAIRequest{
original := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-high",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
@@ -337,7 +360,8 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(
},
}
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, &relaycommon.RelayInfo{}, request)
outbound, info := applyOpenAIChatReasoningThroughHandlerOrder(t, original)
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, info, *outbound)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
@@ -350,7 +374,7 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(
}
func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
request := dto.GeneralOpenAIRequest{
original := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-thinking",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
@@ -363,7 +387,8 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighE
},
}
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, &relaycommon.RelayInfo{}, request)
outbound, info := applyOpenAIChatReasoningThroughHandlerOrder(t, original)
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, info, *outbound)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
+180
View File
@@ -0,0 +1,180 @@
package claude
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseID := helper.GetResponseID(c)
created := common.GetTimestamp()
state, err := relayconvert.NewResponseStreamState(types.RelayFormatClaude, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
ID: responseID,
Model: info.UpstreamModelName,
Created: created,
EmitSequenceNumber: true,
})
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
hostedBridge := relayconvert.NewClaudeHostedStreamBridge()
claudeInfo := &ClaudeResponseInfo{
ResponseId: responseID,
Created: created,
Model: info.UpstreamModelName,
ResponseText: strings.Builder{},
Usage: &dto.Usage{},
}
var streamErr *types.NewAPIError
// streamFailed means a Responses-native terminal error was sent successfully.
// In that case the scanner stops without a transport error and the partial
// upstream usage remains billable.
streamFailed := false
sendResponsesEvent := func(eventType string, payload dto.ResponsesStreamResponse) bool {
payload.Type = eventType
data, err := common.Marshal(payload)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventType}, string(data)); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
return true
}
sendResult := func(result relayconvert.ResponseResult) bool {
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
if !ok {
streamErr = types.NewOpenAIError(
fmt.Errorf("expected OpenAI Responses stream event, got %T", result.Value),
types.ErrorCodeBadResponse,
http.StatusInternalServerError,
)
return false
}
return sendResponsesEvent(event.Type, event.Payload)
}
failResponsesStream := func(err error) bool {
failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
if !handled {
return false
}
for _, result := range failureResults {
if !sendResult(result) {
return true
}
}
streamFailed = true
return true
}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var claudeResponse dto.ClaudeResponse
if err := common.UnmarshalJsonStr(data, &claudeResponse); err != nil {
logger.LogError(c, "failed to unmarshal Claude stream event: "+err.Error())
if failResponsesStream(err) {
// A nil streamErr here is intentional: the protocol-level failure
// event was delivered, so only the scanner needs to stop.
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" {
if failResponsesStream(fmt.Errorf("%s", claudeError.Message)) {
sr.Stop(streamErr)
return
}
streamErr = types.WithClaudeError(*claudeError, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
if claudeResponse.StopReason != "" {
maybeMarkClaudeRefusal(c, claudeResponse.StopReason)
}
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
maybeMarkClaudeRefusal(c, *claudeResponse.Delta.StopReason)
}
if claudeResponse.Type == "message_start" && claudeResponse.Message != nil {
info.UpstreamModelName = claudeResponse.Message.Model
}
FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo)
countClaudeStreamBillableTools(c, info, &claudeResponse)
hostedEvents, consumed, err := hostedBridge.Convert(&claudeResponse, state)
if err != nil {
if failResponsesStream(err) {
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
for _, event := range hostedEvents {
if !sendResponsesEvent(event.Type, event.Payload) {
sr.Stop(streamErr)
return
}
}
if consumed {
return
}
results, err := service.ConvertStreamResponseChunk(c, info, state, &claudeResponse)
if err != nil {
if failResponsesStream(err) {
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
for _, result := range results {
if !sendResult(result) {
sr.Stop(streamErr)
return
}
}
})
if streamErr != nil {
return nil, streamErr
}
if streamFailed {
return claudeInfo.Usage, nil
}
HandleStreamFinalResponse(c, info, claudeInfo)
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage)
state.SetUsage(&openAIUsage)
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
if failResponsesStream(err) {
return claudeInfo.Usage, streamErr
}
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, result := range finalResults {
if !sendResult(result) {
return nil, streamErr
}
}
return claudeInfo.Usage, nil
}
+7 -19
View File
@@ -13,8 +13,8 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
@@ -24,6 +24,9 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
if err := relayconvert.ApplyGeminiThinkingConfigChecked(request, info); err != nil {
return nil, err
}
if len(request.Contents) > 0 {
for i, content := range request.Contents {
if i == 0 {
@@ -44,7 +47,7 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, req)
result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, req)
if err != nil {
return nil, err
}
@@ -132,21 +135,6 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
info.UpstreamModelName = parts[0]
} else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
} else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
info.UpstreamModelName = baseModel
}
}
version := model_setting.GetGeminiVersionSetting(info.UpstreamModelName)
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
@@ -183,7 +171,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, request)
result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, request)
if err != nil {
return nil, err
}
@@ -239,7 +227,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, &request)
result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, &request)
if err != nil {
return nil, err
}
@@ -34,6 +34,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
+54 -8
View File
@@ -55,10 +55,13 @@ func patchGeminiZeroCompletionUsage(c *gin.Context, info *relaycommon.RelayInfo,
usage.CompletionTokens = imageCount * 1400
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
// Overwrite the metadata-derived billing usage: effectiveBillingUsage prefers
// BillingUsage during settlement, so keeping the prompt-only metadata there
// would still bill zero completion tokens.
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
// Settlement prefers BillingUsage, so fill the missing completion in the
// original upstream dialect without discarding cache or modality details.
if usage.BillingUsage != nil {
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
} else {
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
}
}
func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
@@ -88,6 +91,23 @@ func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse
}
}
func countGeminiBillableFunctionCalls(info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) {
if info == nil || response == nil {
return
}
for _, candidate := range response.Candidates {
for _, part := range candidate.Content.Parts {
if part.FunctionCall == nil {
continue
}
if part.FunctionCall.WillContinue != nil && *part.FunctionCall.WillContinue {
continue
}
info.CountBillableToolCall(dto.BuildInCallFunctionCall, part.FunctionCall.FunctionName)
}
}
}
func buildUsageFromGeminiResponse(c *gin.Context, info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) dto.Usage {
metadata := response.GetUsageMetadata()
if dto.HasGeminiUsageMetadataTokens(metadata) {
@@ -148,12 +168,15 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var usage = &dto.Usage{}
var imageCount int
var hasBillableUsageMetadata bool
var streamErr error
var accumulatedUsageMetadata *dto.GeminiUsageMetadata
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var geminiResponse dto.GeminiChatResponse
if err := common.UnmarshalJsonStr(data, &geminiResponse); err != nil {
sr.Stop(fmt.Errorf("unmarshal: %w", err))
streamErr = fmt.Errorf("unmarshal Gemini stream response: %w", err)
sr.Stop(streamErr)
return
}
@@ -162,6 +185,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
markGeminiGoogleSearchCall(c, &geminiResponse)
countGeminiBillableFunctionCalls(info, &geminiResponse)
// 统计图片数量
for _, candidate := range geminiResponse.Candidates {
@@ -177,13 +201,19 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
// 更新使用量统计
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
mappedUsage := buildUsageFromGeminiMetadata(metadata, info.GetEstimatePromptTokens())
accumulatedUsageMetadata = dto.MergeGeminiUsageMetadataNonZero(accumulatedUsageMetadata, metadata)
mappedUsage := buildUsageFromGeminiMetadata(accumulatedUsageMetadata, info.GetEstimatePromptTokens())
*usage = mappedUsage
hasBillableUsageMetadata = true
}
if !callback(data, &geminiResponse) {
sr.Stop(fmt.Errorf("gemini callback stopped"))
if isGeminiDownstreamStop(c, info) {
sr.Stop(nil)
return
}
streamErr = errors.New("Gemini stream callback stopped")
sr.Stop(streamErr)
}
})
@@ -203,9 +233,24 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
patchGeminiZeroCompletionUsage(c, info, usage, responseText.String(), imageCount)
}
if streamErr != nil {
return usage, types.NewOpenAIError(streamErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
logger.LogWarn(c, fmt.Sprintf("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
}
return usage, nil
}
func isGeminiDownstreamStop(c *gin.Context, info *relaycommon.RelayInfo) bool {
if c != nil && c.Request != nil && c.Request.Context().Err() != nil {
return true
}
return info != nil && info.StreamStatus != nil &&
info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone
}
func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
id := helper.GetResponseID(c)
createAt := common.GetTimestamp()
@@ -323,6 +368,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
@@ -371,7 +417,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatClaude:
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
+72 -61
View File
@@ -32,6 +32,7 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
@@ -50,15 +51,9 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
)
}
chatResp := responseGeminiChat2OpenAI(c, &geminiResponse)
chatResp.Model = info.UpstreamModelName
if responseID := helper.GetResponseID(c); responseID != "" {
chatResp.Id = responseID
}
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
chatResp.Usage = usage
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, chatResp)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &geminiResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
@@ -66,10 +61,11 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
if !ok {
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
responsesUsage := convertResult.Usage
if responsesUsage == nil || responsesUsage.TotalTokens == 0 {
responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
if responseID := helper.GetResponseID(c); responseID != "" {
responsesResp.ID = responseID
}
responsesResp.Model = info.UpstreamModelName
responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
responseBody, err = common.Marshal(responsesResp)
if err != nil {
@@ -82,17 +78,16 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseID := helper.GetResponseID(c)
created := common.GetTimestamp()
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
ID: responseID,
Model: info.UpstreamModelName,
Created: created,
state, err := relayconvert.NewResponseStreamState(types.RelayFormatGemini, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
ID: responseID,
Model: info.UpstreamModelName,
Created: created,
EmitSequenceNumber: true,
})
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
finishReason := constant.FinishReasonStop
toolCallIndexByChoice := make(map[int]map[string]int)
nextToolCallIndexByChoice := make(map[int]int)
hostedBridge := relayconvert.NewGeminiHostedStreamBridge()
var streamErr *types.NewAPIError
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
@@ -101,12 +96,37 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data))
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
if info.StreamStatus != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
}
return false
}
return true
}
sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool {
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, chunk)
failResponsesStream := func(err error) bool {
failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
if !handled {
return false
}
for _, result := range failureResults {
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
if !ok {
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
return true
}
if !sendEvent(event) {
return true
}
}
return true
}
sendChunk := func(chunk *dto.GeminiChatResponse) bool {
results, err := service.ConvertStreamResponseChunk(c, info, state, chunk)
if err != nil {
if failResponsesStream(err) {
return false
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
@@ -123,58 +143,46 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
return true
}
usage, streamAPIError := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
response.Id = responseID
response.Created = created
response.Model = info.UpstreamModelName
if response.IsToolCall() {
finishReason = constant.FinishReasonToolCalls
}
for choiceIdx := range response.Choices {
choiceKey := response.Choices[choiceIdx].Index
for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx]
if tool.ID == "" {
continue
}
indexByID := toolCallIndexByChoice[choiceKey]
if indexByID == nil {
indexByID = make(map[string]int)
toolCallIndexByChoice[choiceKey] = indexByID
}
if idx, ok := indexByID[tool.ID]; ok {
tool.SetIndex(idx)
continue
}
idx := nextToolCallIndexByChoice[choiceKey]
nextToolCallIndexByChoice[choiceKey] = idx + 1
indexByID[tool.ID] = idx
tool.SetIndex(idx)
}
}
if !sendChunk(response) {
return false
}
if isStop {
return sendChunk(helper.GenerateStopResponse(responseID, created, info.UpstreamModelName, finishReason))
}
return true
usage, streamAPIError := geminiStreamHandler(c, info, resp, func(_ string, geminiResponse *dto.GeminiChatResponse) bool {
hostedBridge.Observe(geminiResponse)
return sendChunk(geminiResponse)
})
if streamAPIError != nil {
if failResponsesStream(streamAPIError) && streamErr == nil {
return usage, nil
}
return usage, streamAPIError
}
if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
if info.StreamStatus.EndReason != relaycommon.StreamEndReasonClientGone {
failResponsesStream(fmt.Errorf("gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
}
return usage, nil
}
if streamErr != nil {
return nil, streamErr
}
hostedEvents, err := hostedBridge.Finalize(state)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, event := range hostedEvents {
if !sendEvent(event) {
if streamErr != nil {
return usage, streamErr
}
return usage, nil
}
}
if usage != nil {
state.SetUsage(usage)
}
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
if failResponsesStream(err) {
return usage, streamErr
}
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, result := range finalResults {
@@ -183,7 +191,10 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
if !sendEvent(event) {
return nil, streamErr
if streamErr != nil {
return usage, streamErr
}
return usage, nil
}
}
return usage, nil
+2 -2
View File
@@ -75,14 +75,14 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
}
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")
}
return request, nil
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
+201 -87
View File
@@ -19,14 +19,15 @@ import (
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/ai360"
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
"github.com/QuantumNous/new-api/relay/channel/openrouter"
"github.com/QuantumNous/new-api/relaykit/dto"
//"github.com/QuantumNous/new-api/relay/channel/minimax"
"github.com/QuantumNous/new-api/relay/channel/openrouter"
"github.com/QuantumNous/new-api/relay/channel/xinference"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/common_handler"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
@@ -249,80 +250,122 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.StreamOptions = nil
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
initialIntent, err := kitreasoning.FromOpenAIChat(request)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
var thinking dto.Thinking
if err := common.Unmarshal(request.THINKING, &thinking); err != nil {
return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
}
legacyIntent, err := kitreasoning.FromClaude(&dto.ClaudeRequest{Thinking: &thinking})
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
initialIntent, err = kitreasoning.MergeExplicit(initialIntent, legacyIntent, request.Model)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
request.THINKING = nil
}
if len(request.Usage) == 0 {
request.Usage = json.RawMessage(`{"include":true}`)
}
// 适配 OpenRouter 的 thinking 后缀
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) &&
strings.HasSuffix(info.UpstreamModelName, "-thinking") {
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
mergeEffortSuffix := func(modelName string) error {
rawEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName)
if rawEffort == "" {
return nil
}
effort, err := kitreasoning.ParseEffort(rawEffort)
if err != nil {
return err
}
mode := kitreasoning.ModeEnabled
if effort == kitreasoning.EffortNone {
mode = kitreasoning.ModeDisabled
}
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(initialIntent, kitreasoning.Intent{Mode: mode, Effort: effort, Source: kitreasoning.SourceSuffix}, modelName)
return err
}
if !preserveSuffix {
if err := mergeEffortSuffix(info.UpstreamModelName); err != nil {
return nil, kitreasoning.AsClientError(err)
}
if _, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName); baseModel != info.UpstreamModelName {
info.UpstreamModelName = baseModel
request.Model = baseModel
}
if info.OriginModelName != info.UpstreamModelName {
if err := mergeEffortSuffix(info.OriginModelName); err != nil {
return nil, kitreasoning.AsClientError(err)
}
}
}
if !preserveSuffix && strings.HasSuffix(info.UpstreamModelName, "-thinking") {
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
initialIntent,
kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
info.UpstreamModelName,
)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
request.Model = info.UpstreamModelName
if len(request.Reasoning) == 0 {
reasoning := map[string]any{
"enabled": true,
}
if request.ReasoningEffort != "" && request.ReasoningEffort != "none" {
reasoning["effort"] = request.ReasoningEffort
}
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
// 清空多余的ReasoningEffort
request.ReasoningEffort = ""
} else {
if len(request.Reasoning) == 0 {
// 适配 OpenAI 的 ReasoningEffort 格式
if request.ReasoningEffort != "" {
reasoning := map[string]any{
"enabled": true,
}
if request.ReasoningEffort != "none" {
reasoning["effort"] = request.ReasoningEffort
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
}
}
request.ReasoningEffort = ""
}
// https://docs.anthropic.com/en/api/openai-sdk#extended-thinking-support
// 没有做排除3.5Haiku等,要出问题再加吧,最佳兼容性(不是
if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
var thinking dto.Thinking // Claude标准Thinking格式
if err := json.Unmarshal(request.THINKING, &thinking); err != nil {
return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
if !preserveSuffix && info.OriginModelName != info.UpstreamModelName && strings.HasSuffix(info.OriginModelName, "-thinking") {
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
initialIntent,
kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
info.OriginModelName,
)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
// 只有当 thinking.Type 是 "enabled" 时才处理
if thinking.Type == "enabled" {
// 检查 BudgetTokens 是否为 nil
if thinking.BudgetTokens == nil {
return nil, fmt.Errorf("BudgetTokens is nil when thinking is enabled")
}
reasoning := openrouter.RequestReasoning{
Enabled: true,
MaxTokens: *thinking.BudgetTokens,
}
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
// 清空 THINKING
request.THINKING = nil
}
if !initialIntent.IsEmpty() {
reasoningConfig := make(map[string]any)
if len(request.Reasoning) > 0 {
if err := common.Unmarshal(request.Reasoning, &reasoningConfig); err != nil {
return nil, fmt.Errorf("error unmarshalling reasoning: %w", err)
}
if reasoningConfig == nil {
reasoningConfig = make(map[string]any)
}
}
disabled := initialIntent.Mode == kitreasoning.ModeDisabled || initialIntent.Effort == kitreasoning.EffortNone
if initialIntent.HasStrength() {
reasoningConfig["enabled"] = !disabled
if disabled {
delete(reasoningConfig, "effort")
delete(reasoningConfig, "max_tokens")
}
}
if !disabled && initialIntent.BudgetTokens != nil {
reasoningConfig["max_tokens"] = *initialIntent.BudgetTokens
delete(reasoningConfig, "effort")
} else if !disabled && initialIntent.Effort != "" && initialIntent.Effort != kitreasoning.EffortNone {
reasoningConfig["effort"] = string(initialIntent.Effort)
delete(reasoningConfig, "max_tokens")
}
if initialIntent.IncludeThoughts != nil {
reasoningConfig["exclude"] = !*initialIntent.IncludeThoughts
}
marshal, err := common.Marshal(reasoningConfig)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
request.ReasoningEffort = ""
effectiveEffort := kitreasoning.EffectiveEffort(initialIntent)
if initialIntent.BudgetTokens != nil {
effectiveEffort = kitreasoning.EffortFromBudget(*initialIntent.BudgetTokens)
}
info.SetReasoningEffort(string(effectiveEffort))
}
isOModel := dto.IsOpenAIReasoningOModel(info.UpstreamModelName)
@@ -344,16 +387,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.LogProbs = nil
}
// 转换模型推理力度后缀
effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
if effort != "" {
request.ReasoningEffort = effort
info.UpstreamModelName = originModel
request.Model = originModel
}
info.SetReasoningEffort(request.ReasoningEffort)
// o系列模型developer适配(o1-mini除外)
if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") {
//修改第一个Message的内容,将system改为developer
@@ -363,6 +396,53 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
if info.ChannelType != constant.ChannelTypeOpenRouter {
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
effort, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
if preserveSuffix {
effort = ""
}
currentIntent, err := kitreasoning.FromOpenAIChat(request)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
mergeSuffix := func(modelName, rawEffort string) error {
if rawEffort == "" {
return nil
}
suffixEffort, err := kitreasoning.ParseEffort(rawEffort)
if err != nil {
return err
}
mode := kitreasoning.ModeEnabled
if suffixEffort == kitreasoning.EffortNone {
mode = kitreasoning.ModeDisabled
}
currentIntent, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
return err
}
if err := mergeSuffix(info.UpstreamModelName, effort); err != nil {
return nil, kitreasoning.AsClientError(err)
}
if !preserveSuffix && info.OriginModelName != info.UpstreamModelName {
originEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
if err := mergeSuffix(info.OriginModelName, originEffort); err != nil {
return nil, kitreasoning.AsClientError(err)
}
}
if effort != "" {
info.UpstreamModelName = baseModel
request.Model = baseModel
}
if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
request.ReasoningEffort = string(canonicalEffort)
info.SetReasoningEffort(string(canonicalEffort))
}
if info.ChannelType == constant.ChannelTypeOpenAI || info.ChannelType == constant.ChannelTypeAzure {
request.Reasoning = nil
}
}
return request, nil
}
@@ -604,18 +684,52 @@ func detectImageMimeType(filename string) string {
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// 转换模型推理力度后缀
effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(request.Model)
if effort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{
Effort: effort,
}
} else {
request.Reasoning.Effort = effort
}
request.Model = originModel
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(request.Model) || (info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName))
if preserveSuffix {
effort = ""
}
if info != nil && request.Reasoning != nil && request.Reasoning.Effort != "" {
info.SetReasoningEffort(request.Reasoning.Effort)
currentIntent, err := kitreasoning.FromOpenAIResponses(&request)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
mergeSuffix := func(modelName, rawEffort string) error {
if rawEffort == "" {
return nil
}
suffixEffort, err := kitreasoning.ParseEffort(rawEffort)
if err != nil {
return err
}
mode := kitreasoning.ModeEnabled
if suffixEffort == kitreasoning.EffortNone {
mode = kitreasoning.ModeDisabled
}
currentIntent, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
return err
}
if err := mergeSuffix(request.Model, effort); err != nil {
return nil, kitreasoning.AsClientError(err)
}
if !preserveSuffix && info != nil && info.OriginModelName != request.Model {
originEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
if err := mergeSuffix(info.OriginModelName, originEffort); err != nil {
return nil, kitreasoning.AsClientError(err)
}
}
if effort != "" {
request.Model = originModel
if info != nil {
info.UpstreamModelName = originModel
}
}
if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
request.Reasoning.Effort = string(canonicalEffort)
if info != nil {
info.SetReasoningEffort(string(canonicalEffort))
}
}
return request, nil
}
+26 -49
View File
@@ -41,33 +41,10 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, &responsesResp)
responseValue, usage, err := convertResponsesResponseForClient(c, info, &responsesResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
chatResp, ok := chatResult.Value.(*dto.OpenAITextResponse)
if !ok {
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI chat response, got %T", chatResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if chatID := helper.GetResponseID(c); chatID != "" {
chatResp.Id = chatID
}
usage := chatResult.Usage
if usage == nil || usage.TotalTokens == 0 {
text := service.ExtractOutputTextFromResponses(&responsesResp)
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
chatResp.Usage = *usage
}
responseValue := any(chatResp)
if info.RelayFormat != types.RelayFormatOpenAI {
targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
responseValue = targetResult.Value
}
responseBody, err := common.Marshal(responseValue)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
@@ -150,32 +127,10 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
}
accumulator.SupplementResponseOutput(finalResponse)
chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, finalResponse)
responseValue, usage, err := convertResponsesResponseForClient(c, info, finalResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
chatResp, ok := chatResult.Value.(*dto.OpenAITextResponse)
if !ok {
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI chat response, got %T", chatResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if chatID := helper.GetResponseID(c); chatID != "" {
chatResp.Id = chatID
}
usage := chatResult.Usage
if usage == nil || usage.TotalTokens == 0 {
text := service.ExtractOutputTextFromResponses(finalResponse)
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
chatResp.Usage = *usage
}
responseValue := any(chatResp)
if info.RelayFormat != types.RelayFormatOpenAI {
targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
responseValue = targetResult.Value
}
responseBody, err := common.Marshal(responseValue)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
@@ -185,6 +140,28 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
return usage, nil
}
func convertResponsesResponseForClient(c *gin.Context, info *relaycommon.RelayInfo, response *dto.OpenAIResponsesResponse) (any, *dto.Usage, error) {
if responseID := helper.GetResponseID(c); responseID != "" {
response.ID = responseID
}
usage := relayconvert.UsageFromResponsesUsage(response.Usage)
if usage == nil || usage.TotalTokens == 0 {
text := service.ExtractOutputTextFromResponses(response)
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
response.Usage = relayconvert.UsageFromChatUsage(usage)
}
result, err := service.ConvertResponse(c, info, info.RelayFormat, response)
if err != nil {
return nil, nil, err
}
if result.Usage != nil && result.Usage.TotalTokens != 0 {
usage = result.Usage
}
return result.Value, usage, nil
}
func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
@@ -293,7 +270,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return
}
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &streamResp)
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResp)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
@@ -320,7 +297,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
}
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -171,6 +172,50 @@ func TestOaiResponsesToChatBufferedStreamHandlerReturnsJSONFromSSE(t *testing.T)
require.Contains(t, got, `"finish_reason":"tool_calls"`)
}
func TestOaiResponsesToChatBufferedStreamHandlerPreservesInterleavedClaudeContent(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
body := strings.Join([]string{
`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","summary":[]}}`,
`data: {"type":"response.reasoning_summary_text.delta","output_index":0,"item_id":"rs_1","delta":"**Planning file inspection**"}`,
`data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","id":"msg_1","role":"assistant","content":[]}}`,
`data: {"type":"response.output_text.delta","output_index":1,"item_id":"msg_1","delta":"Ill inspect the starter repository."}`,
`data: {"type":"response.output_item.added","output_index":2,"item":{"type":"reasoning","id":"rs_2","summary":[]}}`,
`data: {"type":"response.reasoning_summary_text.delta","output_index":2,"item_id":"rs_2","delta":"**Clarifying environment task requirements**"}`,
`data: {"type":"response.output_item.added","output_index":3,"item":{"type":"message","id":"msg_2","role":"assistant","content":[]}}`,
`data: {"type":"response.output_text.delta","output_index":3,"item_id":"msg_2","delta":"What would you like me to build?"}`,
`data: {"type":"response.done","response":{"id":"resp_1","model":"gpt-test","status":"completed","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`,
`data: [DONE]`,
``,
}, "\n")
c, recorder, resp, info := newResponsesChatTestContext(t, body, false)
info.RelayFormat = types.RelayFormatClaude
usage, apiErr := OaiResponsesToChatBufferedStreamHandler(c, info, resp)
require.Nil(t, apiErr)
require.NotNil(t, usage)
assert.Equal(t, 3, usage.TotalTokens)
var claudeResponse dto.ClaudeResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &claudeResponse))
require.Len(t, claudeResponse.Content, 4)
assert.Equal(t, []string{"thinking", "text", "thinking", "text"}, []string{
claudeResponse.Content[0].Type,
claudeResponse.Content[1].Type,
claudeResponse.Content[2].Type,
claudeResponse.Content[3].Type,
})
require.NotNil(t, claudeResponse.Content[0].Thinking)
require.NotNil(t, claudeResponse.Content[2].Thinking)
assert.Equal(t, "**Planning file inspection**", *claudeResponse.Content[0].Thinking)
assert.Equal(t, "Ill inspect the starter repository.", claudeResponse.Content[1].GetText())
assert.Equal(t, "**Clarifying environment task requirements**", *claudeResponse.Content[2].Thinking)
assert.Equal(t, "What would you like me to build?", claudeResponse.Content[3].GetText())
}
func TestOaiChatToResponsesStreamHandlerConvertsSSEOrderAndUsage(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
+69 -44
View File
@@ -19,16 +19,20 @@ 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 {
info.SendResponseCount++
switch info.RelayFormat {
case types.RelayFormatOpenAI:
info.SendResponseCount++
return sendStreamData(c, info, data, forceFormat, thinkToContent)
case types.RelayFormatClaude:
info.SendResponseCount++
return handleClaudeFormat(c, data, info)
case types.RelayFormatGemini:
// The stateful relaykit path owns its chunk counter so multi-hop and
// direct conversions observe the same stream state semantics.
return handleGeminiFormat(c, data, info)
}
return nil
@@ -41,9 +45,9 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
}
if streamResponse.Usage != nil {
info.ClaudeConvertInfo.Usage = streamResponse.Usage
info.EnsureClaudeConvertInfo().Usage = streamResponse.Usage
}
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
if err != nil {
return err
}
@@ -64,29 +68,55 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
return err
}
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatGemini, &streamResponse)
state, err := chatToGeminiStreamState(c, &streamResponse)
if err != nil {
return err
}
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
if !ok {
return fmt.Errorf("expected Gemini stream response, got %T", result.Value)
}
// 如果返回 nil,表示没有实际内容,跳过发送
if geminiResponse == nil {
return nil
}
geminiResponseStr, err := common.Marshal(geminiResponse)
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
if err != nil {
logger.LogError(c, "failed to marshal gemini response: "+err.Error())
return err
}
return sendGeminiStreamResults(c, results)
}
// send gemini format response
c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
_ = helper.FlushWriter(c)
func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
if value, ok := c.Get(chatToGeminiStreamStateKey); ok {
state, ok := value.(*relayconvert.ResponseStreamState)
if !ok || state == nil {
return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", value)
}
return state, nil
}
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{
ID: streamResponse.Id,
Model: streamResponse.Model,
Created: streamResponse.Created,
})
if err != nil {
return nil, err
}
c.Set(chatToGeminiStreamStateKey, state)
return state, nil
}
func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResult) error {
for _, result := range results {
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
if !ok {
return fmt.Errorf("expected Gemini stream response, got %T", result.Value)
}
if geminiResponse == nil {
continue
}
data, err := common.Marshal(geminiResponse)
if err != nil {
logger.LogError(c, "failed to marshal gemini response: "+err.Error())
return err
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
_ = helper.FlushWriter(c)
}
return nil
}
@@ -148,7 +178,7 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int
if service.ValidUsage(lastStreamResponse.Usage) {
*containStreamUsage = true
*usage = lastStreamResponse.Usage
*usage = dto.MergeUsageNonZero(*usage, lastStreamResponse.Usage)
if !info.ShouldIncludeUsage {
*shouldSendLastResp = lo.SomeBy(lastStreamResponse.Choices, func(choice dto.ChatCompletionsStreamResponseChoice) bool {
return choice.Delta.GetContentString() != "" || choice.Delta.GetReasoningContent() != ""
@@ -181,7 +211,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
info.ClaudeConvertInfo.Usage = usage
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
if err != nil {
common.SysLog("error converting Claude stream response: " + err.Error())
return
@@ -203,36 +233,31 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
return
}
// 这里处理的是 openai 最后一个流响应,其 delta 为空,有 finish_reason 字段
// 因此相比较于 google 官方的流响应,由 openai 转换而来会多一个 parts 为空,finishReason 为 STOP 的响应
// 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null
// 暂不知是否有程序会不兼容。
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatGemini, &streamResponse)
state, err := chatToGeminiStreamState(c, &streamResponse)
if err != nil {
common.SysLog("error converting Gemini stream response: " + err.Error())
return
}
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
if !ok {
common.SysLog(fmt.Sprintf("expected Gemini stream response, got %T", result.Value))
common.SysLog("error creating Gemini stream state: " + err.Error())
return
}
state.SetUsage(usage)
// openai 流响应开头的空数据
if geminiResponse == nil {
return
}
geminiResponseStr, err := common.Marshal(geminiResponse)
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
if err != nil {
common.SysLog("error marshalling gemini response: " + err.Error())
common.SysLog("error converting final Gemini stream response: " + err.Error())
return
}
if err := sendGeminiStreamResults(c, results); err != nil {
common.SysLog("error sending final Gemini stream response: " + err.Error())
return
}
// 发送最终的 Gemini 响应
c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
_ = helper.FlushWriter(c)
results, err = service.FinalizeStreamResponse(c, info, state)
if err != nil {
common.SysLog("error finalizing Gemini stream response: " + err.Error())
return
}
if err := sendGeminiStreamResults(c, results); err != nil {
common.SysLog("error sending finalized Gemini stream response: " + err.Error())
}
}
}
+54 -34
View File
@@ -13,7 +13,6 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
@@ -118,13 +117,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
var toolCount int
var usage = &dto.Usage{}
var lastStreamData string
var secondLastStreamData string // 存储倒数第二个stream data,用于音频模型
var secondLastStreamData string // 保留倒数第二个stream data;部分兼容网关把完整usage放在倒数第二个事件
seenStreamToolCalls := make(map[string]struct{})
var streamFunctionCallNames []string
// 检查是否为音频模型
isAudioModel := strings.Contains(strings.ToLower(model), "audio")
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if lastStreamData != "" {
if err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil {
@@ -133,8 +129,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
}
if len(data) > 0 {
// 对音频模型,保存倒数第二个stream data
if isAudioModel && lastStreamData != "" {
if lastStreamData != "" {
secondLastStreamData = lastStreamData
}
@@ -147,24 +142,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
})
// 对音频模型,从倒数第二个stream data中提取usage信息
if isAudioModel && secondLastStreamData != "" {
var streamResp struct {
Usage *dto.Usage `json:"usage"`
}
err := common.Unmarshal([]byte(secondLastStreamData), &streamResp)
if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) {
usage = streamResp.Usage
containStreamUsage = true
if common.DebugEnabled {
logger.LogDebug(c, "Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
usage.InputTokens, usage.OutputTokens)
}
}
}
// 处理最后的响应
shouldSendLastResp := true
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
@@ -172,6 +149,29 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
}
// 部分兼容网关把完整的累计usage附在倒数第二个事件上,随后发送一个空的最后事件。
// 仅当最后一个事件没有有效usage时,回退到倒数第二个事件的完整快照。
usageFrame := lastStreamData
if !containStreamUsage && secondLastStreamData != "" {
var streamResp struct {
Usage *dto.Usage `json:"usage"`
}
err := common.Unmarshal([]byte(secondLastStreamData), &streamResp)
if err == nil && streamResp.Usage != nil &&
streamResp.Usage.PromptTokens > 0 &&
(streamResp.Usage.CompletionTokens > 0 || streamResp.Usage.TotalTokens > 0) {
usage = dto.MergeUsageNonZero(usage, streamResp.Usage)
containStreamUsage = true
usageFrame = secondLastStreamData
if common.DebugEnabled {
logger.LogDebug(c, "usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
usage.InputTokens, usage.OutputTokens)
}
}
}
if info.RelayFormat == types.RelayFormatOpenAI {
if shouldSendLastResp {
_ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
@@ -183,7 +183,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
usage.CompletionTokens += toolCount * 7
}
applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData))
applyUsagePostProcessing(info, usage, common.StringToByteSlice(usageFrame))
for _, name := range streamFunctionCallNames {
info.CountBillableToolCall(dto.BuildInCallFunctionCall, name)
@@ -201,7 +201,7 @@ func collectStreamFunctionCallNames(data string, seen map[string]struct{}, names
}
for _, choice := range streamResponse.Choices {
for i, tc := range choice.Delta.ToolCalls {
name := tc.Function.Name
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
@@ -209,11 +209,30 @@ func collectStreamFunctionCallNames(data string, seen map[string]struct{}, names
if tc.Index != nil {
toolIdx = *tc.Index
}
key := fmt.Sprintf("%d-%d", choice.Index, toolIdx)
if _, ok := seen[key]; ok {
continue
fallbackKey := fmt.Sprintf("index\x00%d\x00%d\x00%s", choice.Index, toolIdx, name)
activeKey := fmt.Sprintf("active\x00%d\x00%d\x00%s", choice.Index, toolIdx, name)
callID := strings.TrimSpace(tc.ID)
if callID != "" {
idKey := fmt.Sprintf("id\x00%d\x00%s", choice.Index, callID)
if _, ok := seen[idKey]; ok {
continue
}
seen[idKey] = struct{}{}
seen[activeKey] = struct{}{}
if _, delayedID := seen[fallbackKey]; delayedID {
delete(seen, fallbackKey)
continue
}
} else {
if _, ok := seen[fallbackKey]; ok {
continue
}
if _, ok := seen[activeKey]; ok {
continue
}
seen[fallbackKey] = struct{}{}
seen[activeKey] = struct{}{}
}
seen[key] = struct{}{}
*names = append(*names, name)
}
}
@@ -280,11 +299,12 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
completionTokens += ctkm
}
}
simpleResponse.Usage = dto.Usage{
fallbackUsage := &dto.Usage{
PromptTokens: info.GetEstimatePromptTokens(),
CompletionTokens: completionTokens,
TotalTokens: info.GetEstimatePromptTokens() + completionTokens,
}
simpleResponse.Usage = *fallbackUsage
usageModified = true
}
@@ -310,7 +330,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
break
}
case types.RelayFormatClaude:
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, &simpleResponse)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatClaude, &simpleResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
@@ -320,7 +340,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
}
responseBody = claudeRespStr
case types.RelayFormatGemini:
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatGemini, &simpleResponse)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatGemini, &simpleResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
+8 -24
View File
@@ -11,6 +11,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
@@ -38,16 +39,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
service.IOCopyBytesGracefully(c, resp, responseBody)
// compute usage
usage := dto.Usage{}
if responsesResponse.Usage != nil {
usage.PromptTokens = responsesResponse.Usage.InputTokens
usage.CompletionTokens = responsesResponse.Usage.OutputTokens
usage.TotalTokens = responsesResponse.Usage.TotalTokens
if responsesResponse.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens
}
}
usage := relayconvert.NormalizeResponsesUsage(responsesResponse.Usage)
// Count actual tool invocations from Output (not tool declarations).
for _, output := range responsesResponse.Output {
switch output.Type {
@@ -69,7 +61,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
imageCounter.Commit(info)
return &usage, nil
return usage, nil
}
func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
@@ -99,19 +91,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
case "response.completed", "response.done":
if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.CacheWriteTokens = streamResponse.Response.Usage.InputTokensDetails.CacheWriteTokens
}
incomingUsage := relayconvert.NormalizeResponsesUsage(streamResponse.Response.Usage)
usage = dto.MergeUsageNonZero(usage, incomingUsage)
}
if !imageCommitted {
if relaycommon.IsNonBillableResponsesStatus(streamResponse.Response.Status) {
@@ -173,6 +154,9 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
if usage.BillingUsage != nil {
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
}
return usage, nil
}
+44 -7
View File
@@ -38,7 +38,7 @@ func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
if responseID := helper.GetResponseID(c); responseID != "" {
chatResp.Id = responseID
}
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &chatResp)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &chatResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
@@ -70,8 +70,9 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
responseID := helper.GetResponseID(c)
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
ID: responseID,
Model: info.UpstreamModelName,
ID: responseID,
Model: info.UpstreamModelName,
EmitSequenceNumber: true,
})
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
@@ -84,7 +85,27 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data))
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
return true
}
failResponsesStream := func(err error) bool {
failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
if !handled {
return false
}
for _, result := range failureResults {
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
if !ok {
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
return true
}
if !sendEvent(event) {
return true
}
}
return true
}
@@ -97,6 +118,10 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
var errorResp dto.OpenAITextResponse
if err := common.UnmarshalJsonStr(data, &errorResp); err == nil {
if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
if failResponsesStream(fmt.Errorf("%s", oaiError.Message)) {
sr.Stop(streamErr)
return
}
streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
sr.Stop(streamErr)
return
@@ -106,12 +131,21 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
var chunk dto.ChatCompletionsStreamResponse
if err := common.UnmarshalJsonStr(data, &chunk); err != nil {
logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error())
sr.Error(err)
if failResponsesStream(err) {
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &chunk)
results, err := service.ConvertStreamResponseChunk(c, info, state, &chunk)
if err != nil {
if failResponsesStream(err) {
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
@@ -140,8 +174,11 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
state.SetUsage(usage)
}
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
if failResponsesStream(err) {
return usage, streamErr
}
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, result := range finalResults {
+39
View File
@@ -1,11 +1,13 @@
package sub2api
import (
"encoding/json"
"testing"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -44,3 +46,40 @@ func TestAdaptorInheritsNewAPIResponsesCompactSupport(t *testing.T) {
assert.Equal(t, "sub2api", adaptor.GetChannelName())
assert.Empty(t, adaptor.GetModelList())
}
func TestConvertClaudeRequestPreservesAdaptiveThinkingForCompatibleModel(t *testing.T) {
adaptor := &Adaptor{}
maxTokens := uint(8192)
temperature := 0.2
topP := 0.99
request := &dto.ClaudeRequest{
Model: "gpt-5.6-sol",
MaxTokens: &maxTokens,
Temperature: &temperature,
TopP: &topP,
Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
OutputConfig: json.RawMessage(`{"effort":"xhigh","provider_option":true}`),
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.6-sol",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeSub2API,
},
}
converted, err := adaptor.ConvertClaudeRequest(nil, info, request)
require.NoError(t, err)
assert.Same(t, request, converted)
require.NotNil(t, request.Thinking)
assert.Equal(t, "adaptive", request.Thinking.Type)
assert.Equal(t, "summarized", request.Thinking.Display)
assert.JSONEq(t, `{"effort":"xhigh","provider_option":true}`, string(request.OutputConfig))
assert.Same(t, &temperature, request.Temperature)
assert.Same(t, &topP, request.TopP)
assert.Equal(t, "xhigh", info.ReasoningEffort)
assert.Equal(t, "gpt-5.6-sol", info.UpstreamModelName)
}
+15 -23
View File
@@ -18,7 +18,6 @@ import (
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
@@ -56,15 +55,16 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
// Vertex AI does not support functionResponse.id; keep it stripped here for consistency.
// Vertex AI's generateContent schema does not expose the Gemini API's
// function-call identity fields. Strip both sides at this provider boundary.
if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
removeFunctionResponseID(request)
removeFunctionCallIDs(request)
}
geminiAdaptor := gemini.Adaptor{}
return geminiAdaptor.ConvertGeminiRequest(c, info, request)
}
func removeFunctionResponseID(request *dto.GeminiChatRequest) {
func removeFunctionCallIDs(request *dto.GeminiChatRequest) {
if request == nil {
return
}
@@ -76,10 +76,10 @@ func removeFunctionResponseID(request *dto.GeminiChatRequest) {
}
for j := range request.Contents[i].Parts {
part := &request.Contents[i].Parts[j]
if part.FunctionResponse == nil {
continue
if part.FunctionCall != nil {
part.FunctionCall.ID = ""
}
if len(part.FunctionResponse.ID) > 0 {
if part.FunctionResponse != nil && len(part.FunctionResponse.ID) > 0 {
part.FunctionResponse.ID = nil
}
}
@@ -88,12 +88,16 @@ func removeFunctionResponseID(request *dto.GeminiChatRequest) {
if len(request.Requests) > 0 {
for i := range request.Requests {
removeFunctionResponseID(&request.Requests[i])
removeFunctionCallIDs(&request.Requests[i])
}
}
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
claudeAdaptor := claude.Adaptor{}
if _, err := claudeAdaptor.ConvertClaudeRequest(c, info, request); err != nil {
return nil, err
}
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
c.Set("request_model", v)
} else {
@@ -170,21 +174,6 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix := ""
if a.RequestMode == RequestModeGemini {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
info.UpstreamModelName = parts[0]
} else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
} else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
info.UpstreamModelName = baseModel
}
}
if info.IsStream {
suffix = "streamGenerateContent?alt=sse"
} else {
@@ -310,6 +299,9 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if !ok {
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
}
if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
removeFunctionCallIDs(geminiRequest)
}
c.Set("request_model", request.Model)
return geminiRequest, nil
} else if a.RequestMode == RequestModeOpenSource {
+2 -1
View File
@@ -28,7 +28,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
return req, nil
claudeAdaptor := claude.Adaptor{}
return claudeAdaptor.ConvertClaudeRequest(c, info, req)
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
+35 -21
View File
@@ -70,30 +70,35 @@ func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, requ
}
}
func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) {
chatJSON, err := common.Marshal(request)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if len(info.ParamOverride) > 0 {
chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info)
func textRequestViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request any) (*dto.Usage, *types.NewAPIError) {
paramOverrideApplied := false
if chatRequest, ok := request.(*dto.GeneralOpenAIRequest); ok {
chatJSON, err := common.Marshal(chatRequest)
if err != nil {
return nil, newAPIErrorFromParamOverride(err)
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if len(info.ParamOverride) > 0 {
chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info)
if err != nil {
return nil, newAPIErrorFromParamOverride(err)
}
paramOverrideApplied = true
}
var overriddenChatReq dto.GeneralOpenAIRequest
if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil {
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}
request = &overriddenChatReq
}
var overriddenChatReq dto.GeneralOpenAIRequest
if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil {
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}
result, err := service.ConvertRequestVia(c, info, &overriddenChatReq, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses)
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAIResponses, request)
if err != nil {
return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
@@ -101,7 +106,10 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
if !ok {
return nil, types.NewError(fmt.Errorf("expected OpenAI responses request, got %T", result.Value), types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
return relayResponsesRequest(c, info, adaptor, responsesReq, paramOverrideApplied)
}
func relayResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, responsesReq *dto.OpenAIResponsesRequest, paramOverrideApplied bool) (*dto.Usage, *types.NewAPIError) {
savedRelayMode := info.RelayMode
savedRequestURLPath := info.RequestURLPath
defer func() {
@@ -114,7 +122,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *responsesReq)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return nil, newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
@@ -127,6 +135,12 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if !paramOverrideApplied && len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil {
return nil, newAPIErrorFromParamOverride(err)
}
}
body, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
if err != nil {
+87 -3
View File
@@ -1,11 +1,21 @@
package relay
import (
"io"
"math"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
openaichannel "github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
relaytypes "github.com/QuantumNous/new-api/relaykit/types"
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,7 +41,7 @@ func TestIsResponsesEventStreamContentType(t *testing.T) {
func TestRecalcQuotaFromRatiosIgnoresInvalidMultipliers(t *testing.T) {
info := &relaycommon.RelayInfo{
PriceData: types.PriceData{
PriceData: hosttypes.PriceData{
Quota: 100,
},
}
@@ -52,7 +62,7 @@ func TestRecalcQuotaFromRatiosIgnoresInvalidMultipliers(t *testing.T) {
func TestRecalcQuotaFromRatiosRejectsAllInvalidAdjustedRatios(t *testing.T) {
info := &relaycommon.RelayInfo{
PriceData: types.PriceData{
PriceData: hosttypes.PriceData{
Quota: 100,
},
}
@@ -69,3 +79,77 @@ func TestRecalcQuotaFromRatiosRejectsAllInvalidAdjustedRatios(t *testing.T) {
assert.Equal(t, 0, quota)
assert.True(t, info.PriceData.HasOtherRatio("duration"))
}
func TestTextRequestViaResponsesConvertsClaudeDirectly(t *testing.T) {
type capturedRequest struct {
path string
body []byte
}
captured := make(chan capturedRequest, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
captured <- capturedRequest{path: r.URL.Path, body: body}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"resp_1",
"object":"response",
"status":"completed",
"model":"gpt-5.6-sol",
"output":[{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],
"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}
}`))
}))
defer server.Close()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("Content-Type", "application/json")
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeChatCompletions,
RelayFormat: relaytypes.RelayFormatClaude,
OriginModelName: "gpt-5.6-sol",
RequestConversionChain: []relaytypes.RelayFormat{relaytypes.RelayFormatClaude},
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenAI,
ChannelBaseUrl: server.URL,
ApiKey: "test-key",
UpstreamModelName: "gpt-5.6-sol",
},
}
adaptor := &openaichannel.Adaptor{}
adaptor.Init(info)
request := &dto.ClaudeRequest{
Model: "gpt-5.6-sol",
Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
Messages: []dto.ClaudeMessage{{Role: "user", Content: "hello"}},
}
usage, apiErr := textRequestViaResponses(c, info, adaptor, request)
require.Nil(t, apiErr)
require.NotNil(t, usage)
assert.Equal(t, 5, usage.TotalTokens)
assert.Equal(t, []relaytypes.RelayFormat{relaytypes.RelayFormatClaude, relaytypes.RelayFormatOpenAIResponses}, info.RequestConversionChain)
upstream := <-captured
assert.Equal(t, "/v1/responses", upstream.path)
var upstreamBody map[string]any
require.NoError(t, common.Unmarshal(upstream.body, &upstreamBody))
assert.NotContains(t, upstreamBody, "messages")
reasoning, ok := upstreamBody["reasoning"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "high", reasoning["effort"])
assert.Equal(t, "detailed", reasoning["summary"])
var response dto.ClaudeResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.Len(t, response.Content, 1)
assert.Equal(t, "ok", response.Content[0].GetText())
}
+5 -78
View File
@@ -1,7 +1,6 @@
package relay
import (
"encoding/json"
"fmt"
"io"
"net/http"
@@ -16,7 +15,6 @@ import (
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
)
@@ -40,6 +38,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
@@ -47,71 +48,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
adaptor.Init(info)
if request.MaxTokens == nil || *request.MaxTokens == 0 {
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
request.MaxTokens = &defaultMaxTokens
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
(strings.HasPrefix(request.Model, "claude-opus-4-6") ||
strings.HasPrefix(request.Model, "claude-opus-4-7") ||
strings.HasPrefix(request.Model, "claude-opus-4-8")) {
request.Model = baseModel
request.Thinking = &dto.Thinking{
Type: "adaptive",
}
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
strings.HasPrefix(request.Model, "claude-opus-4-8") {
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
request.Thinking.Display = "summarized"
request.Temperature = nil
request.TopP = nil
request.TopK = nil
} else {
request.Temperature = common.GetPointer[float64](1.0)
}
info.UpstreamModelName = request.Model
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(request.Model, "-thinking") {
if request.Thinking == nil {
baseModel := strings.TrimSuffix(request.Model, "-thinking")
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
request.Temperature = nil
request.TopP = nil
request.TopK = nil
} else {
// 因为BudgetTokens 必须大于1024
if request.MaxTokens == nil || *request.MaxTokens < 1280 {
request.MaxTokens = common.GetPointer[uint](1280)
}
// BudgetTokens 为 max_tokens 的 80%
request.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
request.Temperature = common.GetPointer[float64](1.0)
}
}
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
request.Model = strings.TrimSuffix(request.Model, "-thinking")
}
info.UpstreamModelName = request.Model
}
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled && !info.ChannelSetting.PassThroughBodyEnabled {
if effort := request.GetEfforts(); effort != "" {
info.SetReasoningEffort(effort)
}
}
if info.ChannelSetting.SystemPrompt != "" {
if request.System == nil {
request.SetStringSystem(info.ChannelSetting.SystemPrompt)
@@ -140,16 +76,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
!info.ChannelSetting.PassThroughBodyEnabled &&
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
result, convErr := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
if convErr != nil {
return types.NewError(convErr, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
if !ok {
return types.NewError(fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value), types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, openAIRequest)
usage, newApiErr := textRequestViaResponses(c, info, adaptor, request)
if newApiErr != nil {
return newApiErr
}
@@ -168,7 +95,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
} else {
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)
+71
View File
@@ -0,0 +1,71 @@
package common
import (
"context"
"fmt"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
const maxConversionDiagnostics = 32
type conversionDiagnosticKey struct {
code string
path string
severity types.ConversionDiagnosticSeverity
from types.RelayFormat
to types.RelayFormat
}
// RecordConversionDiagnostics retains conversion losses for the consume log
// and emits one request-correlated warning per distinct diagnostic. The cap
// prevents malformed streams from growing request state without bound.
func (info *RelayInfo) RecordConversionDiagnostics(ctx context.Context, diagnostics []types.ConversionDiagnostic) {
if info == nil || len(diagnostics) == 0 {
return
}
if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx == nil {
ctx = nil
}
if info.conversionDiagnosticKeys == nil {
info.conversionDiagnosticKeys = make(map[conversionDiagnosticKey]struct{})
}
for _, diagnostic := range diagnostics {
key := conversionDiagnosticKey{
code: diagnostic.Code,
path: diagnostic.Path,
severity: diagnostic.Severity,
from: diagnostic.From,
to: diagnostic.To,
}
if _, exists := info.conversionDiagnosticKeys[key]; exists {
continue
}
if len(info.conversionDiagnostics) >= maxConversionDiagnostics {
if !info.conversionDiagnosticsTruncated {
info.conversionDiagnosticsTruncated = true
logger.LogWarn(ctx, fmt.Sprintf("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
}
continue
}
info.conversionDiagnosticKeys[key] = struct{}{}
info.conversionDiagnostics = append(info.conversionDiagnostics, diagnostic)
logger.LogWarn(ctx, fmt.Sprintf(
"conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q",
diagnostic.Code, diagnostic.Severity, diagnostic.From, diagnostic.To, diagnostic.Path, diagnostic.Message,
))
}
}
func (info *RelayInfo) ConversionDiagnostics() []types.ConversionDiagnostic {
if info == nil || len(info.conversionDiagnostics) == 0 {
return nil
}
return append([]types.ConversionDiagnostic(nil), info.conversionDiagnostics...)
}
func (info *RelayInfo) ConversionDiagnosticsTruncated() bool {
return info != nil && info.conversionDiagnosticsTruncated
}
+76 -5
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/samber/lo"
"github.com/tidwall/gjson"
@@ -224,22 +225,73 @@ func syncReasoningEffortAfterParamOverride(info *RelayInfo, before, after []byte
}
func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (string, bool) {
var paths []string
switch format {
case types.RelayFormatOpenAI:
paths = []string{"reasoning_effort", "reasoning.effort"}
if effort, exists := firstStringValue(data, "reasoning_effort"); exists && effort != "" {
return effort, true
}
if enabled := gjson.GetBytes(data, "reasoning.enabled"); enabled.Exists() {
if enabled.Type != gjson.True && enabled.Type != gjson.False {
return "", true
}
if !enabled.Bool() {
return string(kitreasoning.EffortNone), true
}
if effort, exists := firstStringValue(data, "reasoning.effort"); exists && effort != "" {
return effort, true
}
if budget := gjson.GetBytes(data, "reasoning.max_tokens"); budget.Exists() {
return reasoningEffortFromBudgetValue(budget)
}
return string(kitreasoning.EffortHigh), true
}
if effort, exists := firstStringValue(data, "reasoning.effort"); exists && effort != "" {
return effort, true
}
if budget := gjson.GetBytes(data, "reasoning.max_tokens"); budget.Exists() {
return reasoningEffortFromBudgetValue(budget)
}
return "", false
case types.RelayFormatOpenAIResponses:
paths = []string{"reasoning.effort"}
return firstStringValue(data, "reasoning.effort")
case types.RelayFormatClaude:
paths = []string{"output_config.effort"}
if effort, exists := firstStringValue(data, "output_config.effort"); exists && effort != "" {
return effort, true
}
thinkingType, hasThinkingType := firstStringValue(data, "thinking.type")
if thinkingType == "disabled" {
return string(kitreasoning.EffortNone), true
}
if budget := gjson.GetBytes(data, "thinking.budget_tokens"); budget.Exists() {
return reasoningEffortFromBudgetValue(budget)
}
if thinkingType == "enabled" || thinkingType == "adaptive" {
return string(kitreasoning.EffortHigh), true
}
return "", hasThinkingType
case types.RelayFormatGemini:
paths = []string{
level, hasLevel := firstStringValue(data,
"generationConfig.thinkingConfig.thinkingLevel",
"generation_config.thinking_config.thinking_level",
)
if level != "" {
return level, true
}
for _, path := range []string{
"generationConfig.thinkingConfig.thinkingBudget",
"generation_config.thinking_config.thinking_budget",
} {
if budget := gjson.GetBytes(data, path); budget.Exists() {
return reasoningEffortFromBudgetValue(budget)
}
}
return "", hasLevel
default:
return "", false
}
}
func firstStringValue(data []byte, paths ...string) (string, bool) {
for _, path := range paths {
value := gjson.GetBytes(data, path)
if !value.Exists() {
@@ -253,6 +305,25 @@ func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (stri
return "", false
}
func reasoningEffortFromBudgetValue(value gjson.Result) (string, bool) {
if value.Type != gjson.Number {
return "", true
}
budget := value.Float()
switch {
case budget == 0:
return string(kitreasoning.EffortNone), true
case budget < 0:
return string(kitreasoning.EffortHigh), true
case budget <= 1024:
return string(kitreasoning.EffortLow), true
case budget <= 8192:
return string(kitreasoning.EffortMedium), true
default:
return string(kitreasoning.EffortHigh), true
}
}
func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
if common.DebugEnabled {
return true
+68 -20
View File
@@ -14,6 +14,7 @@ import (
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
hosttypes "github.com/QuantumNous/new-api/types"
@@ -98,25 +99,34 @@ type RelayInfo struct {
UsePrice bool
RelayMode int
OriginModelName string
RequestURLPath string
RequestHeaders map[string]string
ShouldIncludeUsage bool
DisablePing bool // 是否禁止向下游发送自定义 Ping
ClientWs *websocket.Conn
TargetWs *websocket.Conn
InputAudioFormat string
OutputAudioFormat string
RealtimeTools []dto.RealTimeTool
IsFirstRequest bool
AudioUsage bool
ReasoningEffort string
UserSetting dto.UserSetting
UserEmail string
UserQuota int
RelayFormat types.RelayFormat
SendResponseCount int
ReceivedResponseCount int
FinalPreConsumedQuota int // 最终预消耗的配额
// BillingModelName is the pricing identity for this request. It is kept
// separate from OriginModelName and UpstreamModelName so virtual pricing
// aliases never participate in channel selection or upstream routing.
BillingModelName string
RequestURLPath string
RequestHeaders map[string]string
ShouldIncludeUsage bool
DisablePing bool // 是否禁止向下游发送自定义 Ping
ClientWs *websocket.Conn
TargetWs *websocket.Conn
InputAudioFormat string
OutputAudioFormat string
RealtimeTools []dto.RealTimeTool
IsFirstRequest bool
AudioUsage bool
ReasoningEffort string
// ReasoningConversion is the suffix-derived reasoning intent attached
// after model mapping. Converters read it via ReasoningState().
ReasoningConversion *dto.ReasoningConversionState
UserSetting dto.UserSetting
UserEmail string
UserQuota int
RelayFormat types.RelayFormat
SendResponseCount int
ReceivedResponseCount int
FinalPreConsumedQuota int // 最终预消耗的配额
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
// 必须在提交前锁定全额。
@@ -176,6 +186,10 @@ type RelayInfo struct {
// convOptions caches the converter settings snapshot (see ConvOptions).
convOptions *convmeta.Options
conversionDiagnostics []types.ConversionDiagnostic
conversionDiagnosticKeys map[conversionDiagnosticKey]struct{}
conversionDiagnosticsTruncated bool
ThinkingContentInfo
TokenCountMeta
*ClaudeConvertInfo
@@ -186,6 +200,9 @@ type RelayInfo struct {
}
func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
info.FinalRequestRelayFormat = ""
info.RequestConversionChain = nil
info.InitRequestConversionChain()
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride)
@@ -236,8 +253,10 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
info.convOptions = nil
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelMeta.ChannelSetting.PassThroughBodyEnabled {
info.ReasoningEffort = ""
info.ReasoningConversion = nil
} else {
info.ReasoningEffort = reasoningEffortFromRequest(info.Request)
info.ReasoningConversion = nil
}
// reset some fields based on channel meta
@@ -261,6 +280,9 @@ func (info *RelayInfo) ToString() string {
fmt.Fprintf(b, "IsPlayground: %t, ", info.IsPlayground)
fmt.Fprintf(b, "RequestURLPath: %q, ", info.RequestURLPath)
fmt.Fprintf(b, "OriginModelName: %q, ", info.OriginModelName)
if info.BillingModelName != "" && info.BillingModelName != info.OriginModelName {
fmt.Fprintf(b, "BillingModelName: %q, ", info.BillingModelName)
}
fmt.Fprintf(b, "EstimatePromptTokens: %d, ", info.estimatePromptTokens)
fmt.Fprintf(b, "ShouldIncludeUsage: %t, ", info.ShouldIncludeUsage)
fmt.Fprintf(b, "DisablePing: %t, ", info.DisablePing)
@@ -464,7 +486,10 @@ func reasoningEffortFromRequest(request dto.Request) string {
}
case *dto.GeminiChatRequest:
if req != nil && req.GenerationConfig.ThinkingConfig != nil {
effort = req.GenerationConfig.ThinkingConfig.ThinkingLevel
intent, err := kitreasoning.FromGemini(req)
if err == nil {
effort = string(kitreasoning.EffectiveEffort(intent))
}
}
}
return strings.TrimSpace(effort)
@@ -739,6 +764,18 @@ func (info *RelayInfo) GetOriginModelName() string {
return info.OriginModelName
}
// GetBillingModelName returns the effective pricing identity without changing
// either the client-visible model or the model sent to the selected channel.
func (info *RelayInfo) GetBillingModelName() string {
if info == nil {
return ""
}
if info.BillingModelName != "" {
return info.BillingModelName
}
return info.OriginModelName
}
func (info *RelayInfo) GetUpstreamModelName() string {
if info == nil || info.ChannelMeta == nil {
return ""
@@ -780,6 +817,13 @@ func (info *RelayInfo) SetReasoningEffort(effort string) {
info.ReasoningEffort = strings.TrimSpace(effort)
}
func (info *RelayInfo) ReasoningState() *dto.ReasoningConversionState {
if info == nil {
return nil
}
return info.ReasoningConversion
}
func (info *RelayInfo) EnsureClaudeConvertInfo() *convmeta.ClaudeConvertInfo {
if info == nil {
return &convmeta.ClaudeConvertInfo{
@@ -832,8 +876,12 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options {
},
OpenRouterDialect: info != nil && info.GetChannelType() == constant.ChannelTypeOpenRouter,
PreserveThinkingSuffix: model_setting.ShouldPreserveThinkingSuffix,
PreserveEffortTail: model_setting.ShouldPreserveEffortTail,
}
if info != nil {
if info.ChannelMeta != nil {
options.ToolLossPolicy = types.ConversionLossPolicy(info.ChannelOtherSettings.ToolLossPolicy)
}
info.convOptions = options
}
return options
+2
View File
@@ -56,6 +56,7 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
assert.Zero(t, meta.GetChannelType())
assert.False(t, meta.GetIsStream())
assert.Empty(t, meta.GetReasoningEffort())
assert.Nil(t, meta.ReasoningState())
assert.Zero(t, meta.GetEstimatePromptTokens())
assert.Zero(t, meta.GetSendResponseCount())
@@ -81,6 +82,7 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
assert.NotNil(t, firstOptions.Gemini.SupportsImagine)
assert.NotNil(t, firstOptions.Gemini.SafetySetting)
assert.NotNil(t, firstOptions.PreserveThinkingSuffix)
assert.NotNil(t, firstOptions.PreserveEffortTail)
}
func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
+1 -1
View File
@@ -46,7 +46,7 @@ func (info *RelayInfo) CountBillableToolCall(itemType string, functionName strin
if _, reserved := reservedBillableToolNames[functionName]; reserved {
return
}
if operation_setting.GetToolPriceForModel(functionName, info.OriginModelName) <= 0 {
if operation_setting.GetToolPriceForModel(functionName, info.GetBillingModelName()) <= 0 {
return
}
info.incrementBillableToolCall(functionName)
+5 -2
View File
@@ -43,6 +43,9 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
includeUsage := true
// 判断用户是否需要返回使用情况
@@ -76,7 +79,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
!info.ChannelSetting.PassThroughBodyEnabled &&
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
applySystemPromptIfNeeded(c, info, request)
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
usage, newApiErr := textRequestViaResponses(c, info, adaptor, request)
if newApiErr != nil {
return newApiErr
}
@@ -108,7 +111,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
} else {
convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
+23
View File
@@ -0,0 +1,23 @@
package relay
import (
"errors"
"net/http"
relaycommon "github.com/QuantumNous/new-api/relay/common"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func newConvertRequestFailedError(c *gin.Context, info *relaycommon.RelayInfo, err error) *types.NewAPIError {
var loss *types.ConversionLossError
if errors.As(err, &loss) {
info.RecordConversionDiagnostics(c, loss.Diagnostics)
return types.NewErrorWithStatusCode(err, types.ErrorCodeConvertRequestFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
if kitreasoning.IsClientError(err) {
return types.NewErrorWithStatusCode(err, types.ErrorCodeConvertRequestFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
+72
View File
@@ -0,0 +1,72 @@
package relay
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOptInSafeToolLossRejectedAsBadRequestWithAdminDiagnostics(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-4o",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gpt-4o",
ChannelOtherSettings: dto.ChannelOtherSettings{
ToolLossPolicy: string(types.ConversionLossPolicySafe),
},
},
}
tools, err := common.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
require.NoError(t, err)
req := &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{
{Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
},
Tools: tools,
}
result, convErr := service.ConvertRequest(c, info, types.RelayFormatOpenAI, req)
require.Error(t, convErr)
var loss *types.ConversionLossError
require.ErrorAs(t, convErr, &loss)
require.NotEmpty(t, loss.Diagnostics)
require.NotNil(t, result)
apiErr := newConvertRequestFailedError(c, info, convErr)
require.NotNil(t, apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
assert.Equal(t, types.ErrorCodeConvertRequestFailed, apiErr.GetErrorCode())
assert.True(t, types.IsSkipRetryError(apiErr))
diagnostics := info.ConversionDiagnostics()
require.NotEmpty(t, diagnostics)
assert.True(t, hasHostDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
other := service.GenerateTextOtherInfo(c, info, 1, 1, 1, 0, 0, 0, 1)
adminInfo, ok := other["admin_info"].(map[string]interface{})
require.True(t, ok)
require.Contains(t, adminInfo, "conversion_diagnostics")
}
func hasHostDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
return true
}
}
return false
}
+6 -50
View File
@@ -12,7 +12,6 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
@@ -20,37 +19,6 @@ import (
"github.com/gin-gonic/gin"
)
func isNoThinkingRequest(req *dto.GeminiChatRequest) bool {
if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil {
configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget
if configBudget != nil && *configBudget == 0 {
// 如果思考预算为 0,则认为是非思考请求
return true
}
}
return false
}
func trimModelThinking(modelName string) string {
// 去除模型名称中的 -nothinking 后缀
if strings.HasSuffix(modelName, "-nothinking") {
return strings.TrimSuffix(modelName, "-nothinking")
}
// 去除模型名称中的 -thinking 后缀
if strings.HasSuffix(modelName, "-thinking") {
return strings.TrimSuffix(modelName, "-thinking")
}
// 去除模型名称中的 -thinking-number
if strings.Contains(modelName, "-thinking-") {
parts := strings.Split(modelName, "-thinking-")
if len(parts) > 1 {
return parts[0] + "-thinking"
}
}
return modelName
}
func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
info.InitChannelMeta(c)
@@ -69,23 +37,8 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
if isNoThinkingRequest(request) {
// check is thinking
if !strings.Contains(info.OriginModelName, "-nothinking") {
// try to get no thinking model price
noThinkingModelName := info.OriginModelName + "-nothinking"
containPrice := helper.HasModelBillingConfig(noThinkingModelName)
if containPrice {
info.OriginModelName = noThinkingModelName
info.UpstreamModelName = noThinkingModelName
}
}
}
if request.GenerationConfig.ThinkingConfig == nil {
relayconvert.ApplyGeminiThinkingConfig(request, info)
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
adaptor := GetAdaptor(info.ApiType)
@@ -146,7 +99,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
// 使用 ConvertGeminiRequest 转换请求格式
convertedRequest, err := adaptor.ConvertGeminiRequest(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)
@@ -245,6 +198,9 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, req); err != nil {
return newConvertRequestFailedError(c, info, err)
}
req.SetModelName("models/" + info.UpstreamModelName)
+17 -16
View File
@@ -71,13 +71,14 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hostty
}
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (hosttypes.PriceData, error) {
modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false)
billingModelName := info.GetBillingModelName()
modelPrice, usePrice := ratio_setting.GetModelPrice(billingModelName, false)
groupRatioInfo := HandleGroupRatio(c, info)
// Check if this model uses tiered_expr billing
if billing_setting.GetBillingMode(info.OriginModelName) == billing_setting.BillingModeTieredExpr {
return modelPriceHelperTiered(c, info, promptTokens, meta, groupRatioInfo)
if billing_setting.GetBillingMode(billingModelName) == billing_setting.BillingModeTieredExpr {
return modelPriceHelperTiered(c, info, billingModelName, promptTokens, meta, groupRatioInfo)
}
var preConsumedQuota int
@@ -98,7 +99,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
}
var success bool
var matchName string
modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
modelRatio, success, matchName = ratio_setting.GetModelRatio(billingModelName)
if !success {
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
@@ -108,15 +109,15 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return hosttypes.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
}
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
completionRatio = ratio_setting.GetCompletionRatio(billingModelName)
cacheRatio, _ = ratio_setting.GetCacheRatio(billingModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(billingModelName)
cacheCreationRatio5m = cacheCreationRatio
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
imageRatio, _ = ratio_setting.GetImageRatio(billingModelName)
audioRatio = ratio_setting.GetAudioRatio(billingModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(billingModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
quota, err := common.QuotaFromFloatStrict(float64(preConsumedTokens) * ratio)
if err != nil {
@@ -266,10 +267,10 @@ func HasModelBillingConfig(modelName string) bool {
return ok && strings.TrimSpace(expr) != ""
}
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
exprStr, ok := billing_setting.GetBillingExpr(info.OriginModelName)
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
exprStr, ok := billing_setting.GetBillingExpr(billingModelName)
if !ok {
return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", info.OriginModelName)
return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", billingModelName)
}
estimatedCompletionTokens := meta.MaxTokens
@@ -288,7 +289,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
Len: float64(promptTokens),
}, requestInput)
if err != nil {
return hosttypes.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", info.OriginModelName, err)
return hosttypes.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", billingModelName, err)
}
// Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does.
@@ -309,7 +310,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
exprHash := billingexpr.ExprHashString(exprStr)
snapshot := &billingexpr.BillingSnapshot{
BillingMode: billing_setting.BillingModeTieredExpr,
ModelName: info.OriginModelName,
ModelName: billingModelName,
ExprString: exprStr,
ExprHash: exprHash,
GroupRatio: groupRatioInfo.GroupRatio,
@@ -330,7 +331,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
QuotaToPreConsume: preConsumedQuota,
}
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", billingModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
info.PriceData = priceData
return priceData, nil
+98
View File
@@ -8,11 +8,15 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -272,3 +276,97 @@ func TestModelPriceHelperRequestBillingRatiosOnlyApplyToFixedPrice(t *testing.T)
require.Equal(t, common.QuotaClampOverflow, clamp.Kind)
require.Nil(t, info.Billing)
}
// Pricing at controller/relay.go runs before ApplyReasoningModelSuffix.
// Identity is GetBillingModelName() → OriginModelName (the suffixed client
// name), matching main's info.OriginModelName lookup. Wildcard entries such
// as gemini-2.5-flash-thinking-* depend on that unstripped origin form.
func TestModelPriceHelperUsesSuffixedOriginLikeMain(t *testing.T) {
gin.SetMode(gin.TestMode)
savedRatios := ratio_setting.ModelRatio2JSONString()
t.Cleanup(func() {
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
})
ratios := ratio_setting.GetModelRatioCopy()
ratios["gemini-2.5-flash"] = 0.15
ratios["gemini-2.5-flash-thinking-*"] = 0.075
ratioJSON, err := common.Marshal(ratios)
require.NoError(t, err)
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
oldSelfUse := operation_setting.SelfUseModeEnabled
operation_setting.SelfUseModeEnabled = true
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set("group", "default")
suffixed := &relaycommon.RelayInfo{
OriginModelName: "gemini-2.5-flash-thinking-8192",
UserGroup: "default",
UsingGroup: "default",
}
suffixedPrice, err := ModelPriceHelper(ctx, suffixed, 1000, &types.TokenCountMeta{})
require.NoError(t, err)
assert.Empty(t, suffixed.BillingModelName)
assert.Equal(t, "gemini-2.5-flash-thinking-8192", suffixed.GetBillingModelName())
assert.Equal(t, 0.075, suffixedPrice.ModelRatio)
base := &relaycommon.RelayInfo{
OriginModelName: "gemini-2.5-flash",
UserGroup: "default",
UsingGroup: "default",
}
basePrice, err := ModelPriceHelper(ctx, base, 1000, &types.TokenCountMeta{})
require.NoError(t, err)
assert.Empty(t, base.BillingModelName)
assert.Equal(t, "gemini-2.5-flash", base.GetBillingModelName())
assert.Equal(t, 0.15, basePrice.ModelRatio)
}
func TestModelPriceHelperNativeGeminiNoThinkingDoesNotAliasBillingModel(t *testing.T) {
gin.SetMode(gin.TestMode)
savedRatios := ratio_setting.ModelRatio2JSONString()
t.Cleanup(func() {
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
})
ratios := ratio_setting.GetModelRatioCopy()
ratios["gemini-3-pro"] = 1.25
ratioJSON, err := common.Marshal(ratios)
require.NoError(t, err)
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
oldSelfUse := operation_setting.SelfUseModeEnabled
operation_setting.SelfUseModeEnabled = true
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
geminiSettings := model_setting.GetGeminiSettings()
oldThinking := geminiSettings.ThinkingAdapterEnabled
geminiSettings.ThinkingAdapterEnabled = true
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldThinking })
budget := 0
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set("group", "default")
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-3-pro",
UserGroup: "default",
UsingGroup: "default",
Request: &dto.GeminiChatRequest{
GenerationConfig: dto.GeminiChatGenerationConfig{
ThinkingConfig: &dto.GeminiThinkingConfig{
ThinkingBudget: &budget,
},
},
},
}
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
require.NoError(t, err)
assert.Empty(t, info.BillingModelName)
assert.Equal(t, "gemini-3-pro", info.GetBillingModelName())
assert.Equal(t, 1.25, priceData.ModelRatio)
assert.NotEqual(t, 37.5, priceData.ModelRatio)
}
+145
View File
@@ -0,0 +1,145 @@
package helper
import (
"strings"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/setting/model_setting"
)
// ApplyReasoningModelSuffix parses host-private reasoning suffixes from the
// origin and mapped model names, attaches the resulting intent to RelayInfo,
// and normalizes UpstreamModelName to the unsuffixed base. Optional outbound
// requests are the DeepCopy the handler will send upstream; they must be
// synced here because info.Request is the original, not that copy. Conflict
// between an explicit request field and a suffix is a client error.
func ApplyReasoningModelSuffix(info *relaycommon.RelayInfo, outbound ...dto.Request) error {
if info == nil {
return nil
}
passThrough := model_setting.GetGlobalSettings().PassThroughRequestEnabled
if info.ChannelMeta != nil && info.ChannelSetting.PassThroughBodyEnabled {
passThrough = true
}
if passThrough {
return nil
}
opts := info.ConvOptions()
origin := info.GetOriginModelName()
upstream := ""
if info.ChannelMeta != nil {
upstream = info.UpstreamModelName
}
if opts.ShouldPreserveThinkingSuffix(origin) || opts.ShouldPreserveThinkingSuffix(upstream) {
return nil
}
originBase, originIntent, originFound, err := parseHostModelSuffix(origin, opts)
if err != nil {
return reasoning.AsClientError(err)
}
upstreamBase, upstreamIntent, upstreamFound, err := parseHostModelSuffix(upstream, opts)
if err != nil {
return reasoning.AsClientError(err)
}
suffix := originIntent
if originFound && upstreamFound {
suffix, err = reasoning.MergeExplicitAndSuffix(originIntent, upstreamIntent, origin)
if err != nil {
return reasoning.AsClientError(err)
}
} else if upstreamFound {
suffix = upstreamIntent
}
explicit, err := explicitIntentFromRequest(info.Request)
if err != nil {
return reasoning.AsClientError(err)
}
conflictModel := upstream
if conflictModel == "" {
conflictModel = origin
}
if _, err = reasoning.MergeExplicitAndSuffix(explicit, suffix, conflictModel); err != nil {
return reasoning.AsClientError(err)
}
if !suffix.IsEmpty() {
info.ReasoningConversion = reasoning.StateFromIntent(suffix)
}
if upstreamFound && info.ChannelMeta != nil {
info.UpstreamModelName = upstreamBase
} else if !info.IsModelMapped && originFound && info.ChannelMeta != nil {
info.UpstreamModelName = originBase
}
// Handlers DeepCopy before this helper; info.Request is the original.
// Sync every outbound copy the caller is about to send upstream.
for _, outbound := range outbound {
if outbound != nil {
outbound.SetModelName(info.UpstreamModelName)
}
}
if info.Request != nil {
info.Request.SetModelName(info.UpstreamModelName)
}
return nil
}
func parseHostModelSuffix(name string, opts *convmeta.Options) (string, reasoning.Intent, bool, error) {
if name == "" {
return name, reasoning.Intent{}, false, nil
}
if strings.HasPrefix(name, "claude-") {
return reasoning.ParseClaudeModelSuffix(name, opts.Claude.ThinkingAdapterEnabled)
}
if strings.HasPrefix(name, "gemini-") {
if !opts.Gemini.ThinkingAdapterEnabled {
return name, reasoning.Intent{}, false, nil
}
return reasoning.ParseGeminiModelSuffix(name, true)
}
// deepseek-v4 effort tails are consumed by ParseDeepSeekV4ThinkingSuffix
// in the DeepSeek adaptor; stripping them here drops THINKING+effort.
if strings.HasPrefix(name, "deepseek-v4-") {
return name, reasoning.Intent{}, false, nil
}
effort, base := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(name, opts.PreserveEffortTail)
if effort != "" {
parsed, err := reasoning.ParseEffort(effort)
if err != nil {
return name, reasoning.Intent{}, false, err
}
mode := reasoning.ModeEnabled
if parsed == reasoning.EffortNone {
mode = reasoning.ModeDisabled
}
return base, reasoning.Intent{Mode: mode, Effort: parsed, Source: reasoning.SourceSuffix}, true, nil
}
// Generic -thinking trim is OpenRouter-only. Volcengine/DeepSeek adaptors
// read the suffix off UpstreamModelName themselves.
if opts != nil && opts.OpenRouterDialect && strings.HasSuffix(name, "-thinking") {
return strings.TrimSuffix(name, "-thinking"), reasoning.Intent{Mode: reasoning.ModeEnabled, Source: reasoning.SourceSuffix}, true, nil
}
return name, reasoning.Intent{}, false, nil
}
func explicitIntentFromRequest(req dto.Request) (reasoning.Intent, error) {
switch r := req.(type) {
case *dto.ClaudeRequest:
return reasoning.FromClaude(r)
case *dto.GeminiChatRequest:
return reasoning.FromGemini(r)
case *dto.GeneralOpenAIRequest:
return reasoning.FromOpenAIChat(r)
case *dto.OpenAIResponsesRequest:
return reasoning.FromOpenAIResponses(r)
default:
return reasoning.Intent{}, nil
}
}
+218
View File
@@ -0,0 +1,218 @@
package helper
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyReasoningModelSuffixTrimsUpstreamAndAttachesState(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
}
func TestApplyReasoningModelSuffixRetryKeepsEquivalentState(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "claude-opus-4-8-high",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-opus-4-8-high",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
require.NotNil(t, info.ReasoningConversion)
firstMode := info.ReasoningConversion.Mode
firstEffort := info.ReasoningConversion.Effort
info.UpstreamModelName = info.OriginModelName
require.NoError(t, ApplyReasoningModelSuffix(info))
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, firstMode, info.ReasoningConversion.Mode)
assert.Equal(t, firstEffort, info.ReasoningConversion.Effort)
assert.Equal(t, "claude-opus-4-8", info.UpstreamModelName)
}
func TestApplyReasoningModelSuffixRetryClearsStateWhenNewChannelHasNoSuffix(t *testing.T) {
gin.SetMode(gin.TestMode)
req := &dto.ClaudeRequest{Model: "claude-3-7-sonnet"}
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet",
Request: req,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
IsModelMapped: true,
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
require.NotNil(t, info.ReasoningState())
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
common.SetContextKey(ctx, constant.ContextKeyOriginalModel, "claude-3-7-sonnet")
common.SetContextKey(ctx, constant.ContextKeyChannelType, constant.ChannelTypeAnthropic)
info.InitChannelMeta(ctx)
assert.Nil(t, info.ReasoningState())
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Nil(t, info.ReasoningState())
}
func TestApplyReasoningModelSuffixPassThroughDoesNotTrim(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := settings.PassThroughRequestEnabled
t.Cleanup(func() { settings.PassThroughRequestEnabled = original })
settings.PassThroughRequestEnabled = true
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixBlacklistDoesNotTrim(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
settings.ThinkingModelBlacklist = append(settings.ThinkingModelBlacklist, "claude-3-7-sonnet-thinking")
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixRejectsExplicitSuffixConflict(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
Request: &dto.ClaudeRequest{
Model: "claude-3-7-sonnet-thinking",
Thinking: &dto.Thinking{Type: "disabled"},
},
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
err := ApplyReasoningModelSuffix(info)
require.Error(t, err)
}
func TestApplyReasoningModelSuffixGeminiNoThinkingWhenAdapterEnabled(t *testing.T) {
settings := model_setting.GetGeminiSettings()
original := settings.ThinkingAdapterEnabled
t.Cleanup(func() { settings.ThinkingAdapterEnabled = original })
settings.ThinkingAdapterEnabled = true
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-2.5-flash-nothinking",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gemini-2.5-flash-nothinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "gemini-2.5-flash", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
assert.Equal(t, "none", info.ReasoningConversion.Effort)
}
func TestApplyReasoningModelSuffixPreservesEffortTailModelID(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "qwen-max",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "qwen-max",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "qwen-max", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixLeavesDeepSeekV4SuffixForAdaptor(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "deepseek-v4-chat-max",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeDeepSeek,
UpstreamModelName: "deepseek-v4-chat-max",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "deepseek-v4-chat-max", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixLeavesVolcengineDeepSeekThinkingForAdaptor(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "deepseek-r1-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeVolcEngine,
UpstreamModelName: "deepseek-r1-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "deepseek-r1-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixStillParsesOpenAIEffortTail(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.1-high",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenAI,
UpstreamModelName: "gpt-5.1-high",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "gpt-5.1", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
assert.Equal(t, "high", info.ReasoningConversion.Effort)
}
func TestApplyReasoningModelSuffixTrimsOpenRouterThinkingOnly(t *testing.T) {
openRouter := &relaycommon.RelayInfo{
OriginModelName: "some-model-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenRouter,
UpstreamModelName: "some-model-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(openRouter))
assert.Equal(t, "some-model", openRouter.UpstreamModelName)
require.NotNil(t, openRouter.ReasoningConversion)
assert.Equal(t, "enabled", openRouter.ReasoningConversion.Mode)
}
+4 -1
View File
@@ -70,6 +70,9 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
@@ -86,7 +89,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
} else {
convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)