mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-14 00:01:53 +00:00
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:
@@ -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() {
|
||||
|
||||
@@ -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]")
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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":"I’ll 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, "I’ll 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)
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user