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

* feat(relaykit): preserve hosted tools across conversions

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

* refactor(relaykit): normalize reasoning and thinking intent

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

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

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

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

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

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

- connect handlers, adaptors, and channel settings to the standalone conversion layer
- keep model mapping, pricing identity, retries, and provider-specific suffix behavior aligned
- ignore local audit artifacts and retain focused public regression coverage
This commit is contained in:
Calcium-Ion
2026-09-01 21:53:35 +08:00
committed by GitHub
parent b7017c251b
commit 0ed497f066
149 changed files with 14729 additions and 3044 deletions
+3 -2
View File
@@ -1,4 +1,5 @@
.idea
.review
.vscode
.zed
.history
@@ -20,7 +21,7 @@ tiktoken_cache
.gocache
.gomodcache/
.cache
plans
.plans
.claude
.cursor
@@ -37,7 +38,7 @@ skills-lock.json
# Local-only live probes and scratch test workspaces.
.local-tests/
service/relayconvert/chat_responses_live_local_test.go
relaykit/relayconvert/chat_responses_live_local_test.go
service/openaicompat/chat_responses_live_local_test.go
go.work
go.work.sum
+8 -1
View File
@@ -259,6 +259,13 @@ func testChannel(ctx context.Context, channel *model.Channel, testUserID int, te
newAPIError: types.NewError(err, types.ErrorCodeChannelModelMappedError),
}
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return testResult{
context: c,
localErr: err,
newAPIError: types.NewErrorWithStatusCode(err, types.ErrorCodeConvertRequestFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry()),
}
}
testModel = info.UpstreamModelName
// 更新请求中的模型名称
@@ -943,7 +950,7 @@ func testChannelForHealthCheck(ctx context.Context, channel *model.Channel, test
}
if allowDisable && isChannelEnabled && shouldBanChannel && channel.GetAutoBan() {
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
processChannelError(result.context, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(result.context, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError, nil)
summary.Disabled++
}
+44 -3
View File
@@ -238,7 +238,7 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
newAPIError = service.NormalizeViolationFeeError(newAPIError)
relayInfo.LastError = newAPIError
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError)
processChannelError(c, *types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey, common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()), newAPIError, relayInfo)
if !shouldRetry(c, newAPIError, common.RetryTimes-retryParam.GetRetry()) {
break
@@ -257,6 +257,38 @@ func Relay(c *gin.Context, relayFormat types.RelayFormat) {
}
}
// CountClaudeTokens implements Anthropic's token-counting utility endpoint.
// It deliberately skips upstream generation and billing; callers use this
// endpoint to size prompts before creating a Message.
func CountClaudeTokens(c *gin.Context) {
request, err := helper.GetAndValidateClaudeRequest(c)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{
"type": "error",
"error": gin.H{
"type": "invalid_request_error",
"message": common.MessageWithRequestId(err.Error(), c.GetString(common.RequestIdKey)),
},
})
return
}
info := relaycommon.GenRelayInfoClaude(c, request)
inputTokens, err := service.CountRequestToken(c, request.GetTokenCountMeta(), info)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"type": "error",
"error": gin.H{
"type": "api_error",
"message": common.MessageWithRequestId(err.Error(), c.GetString(common.RequestIdKey)),
},
})
return
}
c.JSON(http.StatusOK, gin.H{"input_tokens": inputTokens})
}
var upgrader = websocket.Upgrader{
Subprotocols: []string{"realtime"}, // WS 握手支持的协议,如果有使用 Sec-WebSocket-Protocol,则必须在此声明对应的 Protocol TODO add other protocol
CheckOrigin: func(r *http.Request) bool {
@@ -362,7 +394,7 @@ func shouldRetry(c *gin.Context, openaiErr *types.NewAPIError, retryTimes int) b
return operation_setting.ShouldRetryByStatusCode(code)
}
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError) {
func processChannelError(c *gin.Context, channelError types.ChannelError, err *types.NewAPIError, relayInfo *relaycommon.RelayInfo) {
logger.LogError(c, fmt.Sprintf("channel error (channel #%d, status code: %d): %s", channelError.ChannelId, err.StatusCode, common.LocalLogPreview(err.Error())))
// 不要使用context获取渠道信息,异步处理时可能会出现渠道信息不一致的情况
// do not use context to get channel info, there may be inconsistent channel info when processing asynchronously
@@ -392,6 +424,14 @@ func processChannelError(c *gin.Context, channelError types.ChannelError, err *t
other["channel_type"] = c.GetInt("channel_type")
adminInfo := make(map[string]interface{})
adminInfo["use_channel"] = c.GetStringSlice("use_channel")
if relayInfo != nil {
if diagnostics := relayInfo.ConversionDiagnostics(); len(diagnostics) > 0 {
adminInfo["conversion_diagnostics"] = diagnostics
}
if relayInfo.ConversionDiagnosticsTruncated() {
adminInfo["conversion_diagnostics_truncated"] = true
}
}
isMultiKey := common.GetContextKeyBool(c, constant.ContextKeyChannelIsMultiKey)
if isMultiKey {
adminInfo["is_multi_key"] = true
@@ -655,7 +695,8 @@ func executeTaskSubmissionWith(
processChannelError(c,
*types.NewChannelError(channel.Id, channel.Type, channel.Name, channel.ChannelInfo.IsMultiKey,
common.GetContextKeyString(c, constant.ContextKeyChannelKey), channel.GetAutoBan()),
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode))
types.NewOpenAIError(taskErr.Error, types.ErrorCodeBadResponseStatusCode, taskErr.StatusCode),
relayInfo)
}
willRetry := shouldRetryTaskRelay(c, channel.Id, taskErr, common.RetryTimes-retryParam.GetRetry())
+73
View File
@@ -0,0 +1,73 @@
package controller
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestCountClaudeTokensReturnsInputTokensWhenRelayCountingDisabled(t *testing.T) {
gin.SetMode(gin.TestMode)
originalCountToken := constant.CountToken
constant.CountToken = false
t.Cleanup(func() {
constant.CountToken = originalCountToken
})
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodPost,
"/v1/messages/count_tokens?beta=true",
strings.NewReader(`{
"model":"gemini-3.6-flash",
"messages":[{"role":"user","content":"count this prompt"}],
"tools":[{"name":"lookup","description":"Look up a value","input_schema":{"type":"object","properties":{"query":{"type":"string"}}}}]
}`),
)
ctx.Request.Header.Set("Content-Type", "application/json")
common.SetContextKey(ctx, constant.ContextKeyOriginalModel, "gemini-3.6-flash")
CountClaudeTokens(ctx)
require.Equal(t, http.StatusOK, recorder.Code)
var response struct {
InputTokens int `json:"input_tokens"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Positive(t, response.InputTokens)
}
func TestCountClaudeTokensRejectsMissingMessages(t *testing.T) {
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(
http.MethodPost,
"/v1/messages/count_tokens",
strings.NewReader(`{"model":"gemini-3.6-flash"}`),
)
ctx.Request.Header.Set("Content-Type", "application/json")
CountClaudeTokens(ctx)
require.Equal(t, http.StatusBadRequest, recorder.Code)
var response struct {
Type string `json:"type"`
Error struct {
Type string `json:"type"`
Message string `json:"message"`
} `json:"error"`
}
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
assert.Equal(t, "error", response.Type)
assert.Equal(t, "invalid_request_error", response.Error.Type)
assert.Contains(t, response.Error.Message, "messages")
}
+3
View File
@@ -989,6 +989,9 @@ func (channel *Channel) ValidateSettings() error {
return err
}
}
if err := channelOtherSettings.ValidateToolLossPolicy(); err != nil {
return err
}
if channel.Type == constant.ChannelTypeAdvancedCustom {
if channelOtherSettings.AdvancedCustom == nil {
return fmt.Errorf("advanced_custom is required")
+4
View File
@@ -39,6 +39,10 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
claudeAdaptor := claude.Adaptor{}
if _, err := claudeAdaptor.ConvertClaudeRequest(c, info, request); err != nil {
return nil, err
}
for i, message := range request.Messages {
updated := false
if !message.IsStringContent() {
+1 -7
View File
@@ -357,7 +357,7 @@ func TestAwsStreamHandlerUsesFinalUpstreamUsage(t *testing.T) {
assert.Contains(t, recorder.Body.String(), "[DONE]")
}
func TestAwsStreamHandlerStopsAtClientCancellationAndKeepsPartialBillingUsage(t *testing.T) {
func TestAwsStreamHandlerStopsAtClientCancellation(t *testing.T) {
originalRelayTimeout := common.RelayTimeout
common.RelayTimeout = 0
t.Cleanup(func() {
@@ -439,12 +439,6 @@ func TestAwsStreamHandlerStopsAtClientCancellationAndKeepsPartialBillingUsage(t
require.ErrorIs(t, upstreamContext.Err(), context.Canceled)
require.Nil(t, result.err)
require.NotNil(t, result.usage)
require.NotNil(t, result.usage.BillingUsage)
require.NotNil(t, result.usage.BillingUsage.ClaudeUsage)
assert.Equal(t, dto.BillingUsageSourceClaudeMessages, result.usage.BillingUsage.Source)
assert.Equal(t, dto.BillingUsageSemanticAnthropic, result.usage.BillingUsage.Semantic)
assert.Equal(t, 100, result.usage.BillingUsage.ClaudeUsage.InputTokens)
assert.Equal(t, 1, result.usage.BillingUsage.ClaudeUsage.OutputTokens)
assert.Equal(t, bodyLengthBeforeCancel, responseWriter.Body.Len())
assert.NotContains(t, responseWriter.Body.String(), "[DONE]")
+30 -3
View File
@@ -12,6 +12,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
@@ -26,6 +27,22 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
if request.MaxTokens != nil && *request.MaxTokens == 0 {
request.MaxTokens = nil
}
if err := relayconvert.ApplyClaudeThinkingModel(request, info); err != nil {
return nil, err
}
if request.MaxTokens == nil {
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
request.MaxTokens = &defaultMaxTokens
}
// ApplyClaudeThinkingModel no longer rewrites request.Model. Do not write
// a still-suffixed name back over the entry-normalized UpstreamModelName
// (AWS/Vertex look up getAwsModelID / claudeModelMap from that field).
if info.UpstreamModelName == "" {
info.UpstreamModelName = request.Model
}
return request, nil
}
@@ -96,7 +113,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatClaude, request)
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
if err != nil {
return nil, err
}
@@ -113,8 +130,15 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// TODO implement me
return nil, errors.New("not implemented")
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, &request)
if err != nil {
return nil, err
}
claudeRequest, ok := result.Value.(*dto.ClaudeRequest)
if !ok {
return nil, fmt.Errorf("expected Anthropic Messages request, got %T", result.Value)
}
return claudeRequest, nil
}
func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, requestBody io.Reader) (any, error) {
@@ -123,6 +147,9 @@ func (a *Adaptor) DoRequest(c *gin.Context, info *relaycommon.RelayInfo, request
func (a *Adaptor) DoResponse(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (usage any, err *types.NewAPIError) {
info.FinalRequestRelayFormat = types.RelayFormatClaude
if info.RelayFormat == types.RelayFormatOpenAIResponses && info.IsStream {
return ClaudeResponsesStreamHandler(c, resp, info)
}
if info.IsStream {
return ClaudeStreamHandler(c, resp, info)
} else {
+90
View File
@@ -0,0 +1,90 @@
package claude
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestConvertClaudeRequestTreatsZeroMaxTokensAsUnset(t *testing.T) {
zero := uint(0)
req := &dto.ClaudeRequest{
Model: "claude-sonnet-4-5",
MaxTokens: &zero,
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-sonnet-4-5",
},
}
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
require.NotNil(t, converted.MaxTokens)
assert.Equal(t, uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(req.Model)), *converted.MaxTokens)
}
func TestConvertClaudeRequestZeroMaxTokensStillRaisesThinkingBudget(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
zero := uint(0)
original := &dto.ClaudeRequest{
Model: "claude-3-7-sonnet-thinking",
MaxTokens: &zero,
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
Request: original,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
outbound, err := common.DeepCopy(original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
out, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, outbound)
require.NoError(t, err)
converted, ok := out.(*dto.ClaudeRequest)
require.True(t, ok)
assert.Equal(t, "claude-3-7-sonnet", converted.Model)
require.NotNil(t, converted.Thinking)
require.NotNil(t, converted.MaxTokens)
assert.Greater(t, *converted.MaxTokens, uint(1024))
}
func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing.T) {
req := &dto.ClaudeRequest{
Model: "claude-3-7-sonnet-thinking",
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet",
},
}
_, err := (&Adaptor{}).ConvertClaudeRequest(nil, info, req)
require.NoError(t, err)
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
}
+49 -5
View File
@@ -1,6 +1,7 @@
package claude
import (
"fmt"
"io"
"net/http"
"strings"
@@ -19,6 +20,8 @@ import (
"github.com/gin-gonic/gin"
)
const claudeToChatStreamStateKey = "relaykit.claude_to_chat_stream_state"
func stopReasonClaude2OpenAI(reason string) string {
return relayconvert.StopReasonClaudeToOpenAI(reason)
}
@@ -117,7 +120,14 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
countClaudeStreamBillableTools(c, info, &claudeResponse)
helper.ClaudeChunkData(c, claudeResponse, data)
} else if info.RelayFormat == types.RelayFormatOpenAI {
response := StreamResponseClaude2OpenAI(&claudeResponse)
state, err := claudeToChatStreamState(c)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
response, err := state.ConvertChunk(&claudeResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
if !FormatClaudeResponseInfo(&claudeResponse, response, claudeInfo) {
return nil
@@ -125,6 +135,9 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
countClaudeStreamBillableTools(c, info, &claudeResponse)
if response == nil {
return nil
}
err = helper.ObjectData(c, response)
if err != nil {
logger.LogError(c, "send_stream_response_failed: "+err.Error())
@@ -133,6 +146,20 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
return nil
}
func claudeToChatStreamState(c *gin.Context) (*relayconvert.ClaudeToChatStreamState, error) {
if value, ok := c.Get(claudeToChatStreamStateKey); ok {
state, ok := value.(*relayconvert.ClaudeToChatStreamState)
if !ok || state == nil {
return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", value)
}
return state, nil
}
state := relayconvert.NewClaudeToChatStreamState()
c.Set(claudeToChatStreamStateKey, state)
return state, nil
}
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
if claudeResponse == nil {
return
@@ -172,9 +199,7 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
if claudeInfo.Usage != nil {
claudeInfo.Usage.UsageSemantic = "anthropic"
}
if claudeInfo.Usage != nil && claudeInfo.Usage.BillingUsage == nil {
claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(buildMessageDeltaPatchUsage(nil, claudeInfo))
}
relayconvert.FinalizeClaudeStreamBillingUsage(claudeInfo)
if info.RelayFormat == types.RelayFormatClaude {
//
@@ -232,7 +257,10 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
claudeInfo.Usage.TotalTokens = claudeResponse.Usage.InputTokens + claudeResponse.Usage.OutputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(claudeResponse.Usage)
claudeInfo.Usage.BillingUsage = dto.CloneBillingUsage(claudeResponse.Usage.BillingUsage)
if claudeInfo.Usage.BillingUsage == nil {
claudeInfo.Usage.BillingUsage = dto.NewClaudeMessagesBillingUsage(claudeResponse.Usage)
}
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Usage.GetCacheCreation5mTokens()
@@ -247,6 +275,22 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatOpenAIResponses:
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &claudeResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
responsesResponse, ok := convertResult.Value.(*dto.OpenAIResponsesResponse)
if !ok {
return types.NewError(fmt.Errorf("expected OpenAI Responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody)
}
if responseID := helper.GetResponseID(c); responseID != "" {
responsesResponse.ID = responseID
}
responseData, err = common.Marshal(responsesResponse)
if err != nil {
return types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatClaude:
responseData = data
}
+29 -4
View File
@@ -1,12 +1,16 @@
package claude
import (
"net/http/httptest"
"strings"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -323,8 +327,27 @@ func TestBuildOpenAIStyleUsageFromClaudeUsageDefaultsAggregateCacheCreationTo5m(
require.Equal(t, 0, openAIUsage.ClaudeCacheCreation1hTokens)
}
func applyOpenAIChatReasoningThroughHandlerOrder(t *testing.T, original dto.GeneralOpenAIRequest) (*dto.GeneralOpenAIRequest, *relaycommon.RelayInfo) {
t.Helper()
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
info := &relaycommon.RelayInfo{
OriginModelName: original.Model,
Request: &original,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: original.Model,
},
}
outbound, err := common.DeepCopy(&original)
require.NoError(t, err)
require.NoError(t, helper.ModelMappedHelper(c, info, outbound))
require.NoError(t, helper.ApplyReasoningModelSuffix(info, outbound))
return outbound, info
}
func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(t *testing.T) {
request := dto.GeneralOpenAIRequest{
original := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-high",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
@@ -337,7 +360,8 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(
},
}
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, &relaycommon.RelayInfo{}, request)
outbound, info := applyOpenAIChatReasoningThroughHandlerOrder(t, original)
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, info, *outbound)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
@@ -350,7 +374,7 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48HighUsesAdaptiveThinking(
}
func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighEffort(t *testing.T) {
request := dto.GeneralOpenAIRequest{
original := dto.GeneralOpenAIRequest{
Model: "claude-opus-4-8-thinking",
Temperature: commonPointer(0.7),
TopP: commonPointer(0.9),
@@ -363,7 +387,8 @@ func TestOpenAIChatRequestToClaudeMessages_ClaudeOpus48ThinkingUsesAdaptiveHighE
},
}
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, &relaycommon.RelayInfo{}, request)
outbound, info := applyOpenAIChatReasoningThroughHandlerOrder(t, original)
claudeRequest, err := relayconvert.OpenAIChatRequestToClaudeMessages(nil, info, *outbound)
require.NoError(t, err)
require.Equal(t, "claude-opus-4-8", claudeRequest.Model)
require.NotNil(t, claudeRequest.Thinking)
+180
View File
@@ -0,0 +1,180 @@
package claude
import (
"fmt"
"net/http"
"strings"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/logger"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
)
func ClaudeResponsesStreamHandler(c *gin.Context, resp *http.Response, info *relaycommon.RelayInfo) (*dto.Usage, *types.NewAPIError) {
responseID := helper.GetResponseID(c)
created := common.GetTimestamp()
state, err := relayconvert.NewResponseStreamState(types.RelayFormatClaude, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
ID: responseID,
Model: info.UpstreamModelName,
Created: created,
EmitSequenceNumber: true,
})
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
hostedBridge := relayconvert.NewClaudeHostedStreamBridge()
claudeInfo := &ClaudeResponseInfo{
ResponseId: responseID,
Created: created,
Model: info.UpstreamModelName,
ResponseText: strings.Builder{},
Usage: &dto.Usage{},
}
var streamErr *types.NewAPIError
// streamFailed means a Responses-native terminal error was sent successfully.
// In that case the scanner stops without a transport error and the partial
// upstream usage remains billable.
streamFailed := false
sendResponsesEvent := func(eventType string, payload dto.ResponsesStreamResponse) bool {
payload.Type = eventType
data, err := common.Marshal(payload)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: eventType}, string(data)); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
return true
}
sendResult := func(result relayconvert.ResponseResult) bool {
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
if !ok {
streamErr = types.NewOpenAIError(
fmt.Errorf("expected OpenAI Responses stream event, got %T", result.Value),
types.ErrorCodeBadResponse,
http.StatusInternalServerError,
)
return false
}
return sendResponsesEvent(event.Type, event.Payload)
}
failResponsesStream := func(err error) bool {
failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
if !handled {
return false
}
for _, result := range failureResults {
if !sendResult(result) {
return true
}
}
streamFailed = true
return true
}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var claudeResponse dto.ClaudeResponse
if err := common.UnmarshalJsonStr(data, &claudeResponse); err != nil {
logger.LogError(c, "failed to unmarshal Claude stream event: "+err.Error())
if failResponsesStream(err) {
// A nil streamErr here is intentional: the protocol-level failure
// event was delivered, so only the scanner needs to stop.
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
if claudeError := claudeResponse.GetClaudeError(); claudeError != nil && claudeError.Type != "" {
if failResponsesStream(fmt.Errorf("%s", claudeError.Message)) {
sr.Stop(streamErr)
return
}
streamErr = types.WithClaudeError(*claudeError, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
if claudeResponse.StopReason != "" {
maybeMarkClaudeRefusal(c, claudeResponse.StopReason)
}
if claudeResponse.Delta != nil && claudeResponse.Delta.StopReason != nil {
maybeMarkClaudeRefusal(c, *claudeResponse.Delta.StopReason)
}
if claudeResponse.Type == "message_start" && claudeResponse.Message != nil {
info.UpstreamModelName = claudeResponse.Message.Model
}
FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo)
countClaudeStreamBillableTools(c, info, &claudeResponse)
hostedEvents, consumed, err := hostedBridge.Convert(&claudeResponse, state)
if err != nil {
if failResponsesStream(err) {
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
for _, event := range hostedEvents {
if !sendResponsesEvent(event.Type, event.Payload) {
sr.Stop(streamErr)
return
}
}
if consumed {
return
}
results, err := service.ConvertStreamResponseChunk(c, info, state, &claudeResponse)
if err != nil {
if failResponsesStream(err) {
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
for _, result := range results {
if !sendResult(result) {
sr.Stop(streamErr)
return
}
}
})
if streamErr != nil {
return nil, streamErr
}
if streamFailed {
return claudeInfo.Usage, nil
}
HandleStreamFinalResponse(c, info, claudeInfo)
openAIUsage := buildOpenAIStyleUsageFromClaudeUsage(claudeInfo.Usage)
state.SetUsage(&openAIUsage)
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
if failResponsesStream(err) {
return claudeInfo.Usage, streamErr
}
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, result := range finalResults {
if !sendResult(result) {
return nil, streamErr
}
}
return claudeInfo.Usage, nil
}
+7 -19
View File
@@ -13,8 +13,8 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
@@ -24,6 +24,9 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
if err := relayconvert.ApplyGeminiThinkingConfigChecked(request, info); err != nil {
return nil, err
}
if len(request.Contents) > 0 {
for i, content := range request.Contents {
if i == 0 {
@@ -44,7 +47,7 @@ func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayIn
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, req)
result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, req)
if err != nil {
return nil, err
}
@@ -132,21 +135,6 @@ func (a *Adaptor) Init(info *relaycommon.RelayInfo) {
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
info.UpstreamModelName = parts[0]
} else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
} else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
info.UpstreamModelName = baseModel
}
}
version := model_setting.GetGeminiVersionSetting(info.UpstreamModelName)
if strings.HasPrefix(info.UpstreamModelName, "imagen") {
@@ -183,7 +171,7 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, request)
result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, request)
if err != nil {
return nil, err
}
@@ -239,7 +227,7 @@ func (a *Adaptor) ConvertEmbeddingRequest(c *gin.Context, info *relaycommon.Rela
}
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
result, err := relayconvert.ConvertRequest(c, info, types.RelayFormatGemini, &request)
result, err := service.ConvertRequest(c, info, types.RelayFormatGemini, &request)
if err != nil {
return nil, err
}
@@ -34,6 +34,7 @@ func GeminiTextGenerationHandler(c *gin.Context, info *relaycommon.RelayInfo, re
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 && geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
common.SetContextKey(c, constant.ContextKeyAdminRejectReason, fmt.Sprintf("gemini_block_reason=%s", *geminiResponse.PromptFeedback.BlockReason))
+54 -8
View File
@@ -55,10 +55,13 @@ func patchGeminiZeroCompletionUsage(c *gin.Context, info *relaycommon.RelayInfo,
usage.CompletionTokens = imageCount * 1400
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
// Overwrite the metadata-derived billing usage: effectiveBillingUsage prefers
// BillingUsage during settlement, so keeping the prompt-only metadata there
// would still bill zero completion tokens.
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
// Settlement prefers BillingUsage, so fill the missing completion in the
// original upstream dialect without discarding cache or modality details.
if usage.BillingUsage != nil {
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
} else {
usage.BillingUsage = dto.NewEstimatedGeminiChatBillingUsage(usage)
}
}
func geminiResponseUsageText(response *dto.GeminiChatResponse) string {
@@ -88,6 +91,23 @@ func markGeminiGoogleSearchCall(c *gin.Context, response *dto.GeminiChatResponse
}
}
func countGeminiBillableFunctionCalls(info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) {
if info == nil || response == nil {
return
}
for _, candidate := range response.Candidates {
for _, part := range candidate.Content.Parts {
if part.FunctionCall == nil {
continue
}
if part.FunctionCall.WillContinue != nil && *part.FunctionCall.WillContinue {
continue
}
info.CountBillableToolCall(dto.BuildInCallFunctionCall, part.FunctionCall.FunctionName)
}
}
}
func buildUsageFromGeminiResponse(c *gin.Context, info *relaycommon.RelayInfo, response *dto.GeminiChatResponse) dto.Usage {
metadata := response.GetUsageMetadata()
if dto.HasGeminiUsageMetadataTokens(metadata) {
@@ -148,12 +168,15 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
var usage = &dto.Usage{}
var imageCount int
var hasBillableUsageMetadata bool
var streamErr error
var accumulatedUsageMetadata *dto.GeminiUsageMetadata
responseText := strings.Builder{}
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
var geminiResponse dto.GeminiChatResponse
if err := common.UnmarshalJsonStr(data, &geminiResponse); err != nil {
sr.Stop(fmt.Errorf("unmarshal: %w", err))
streamErr = fmt.Errorf("unmarshal Gemini stream response: %w", err)
sr.Stop(streamErr)
return
}
@@ -162,6 +185,7 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
markGeminiGoogleSearchCall(c, &geminiResponse)
countGeminiBillableFunctionCalls(info, &geminiResponse)
// 统计图片数量
for _, candidate := range geminiResponse.Candidates {
@@ -177,13 +201,19 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
// 更新使用量统计
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
mappedUsage := buildUsageFromGeminiMetadata(metadata, info.GetEstimatePromptTokens())
accumulatedUsageMetadata = dto.MergeGeminiUsageMetadataNonZero(accumulatedUsageMetadata, metadata)
mappedUsage := buildUsageFromGeminiMetadata(accumulatedUsageMetadata, info.GetEstimatePromptTokens())
*usage = mappedUsage
hasBillableUsageMetadata = true
}
if !callback(data, &geminiResponse) {
sr.Stop(fmt.Errorf("gemini callback stopped"))
if isGeminiDownstreamStop(c, info) {
sr.Stop(nil)
return
}
streamErr = errors.New("Gemini stream callback stopped")
sr.Stop(streamErr)
}
})
@@ -203,9 +233,24 @@ func geminiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
patchGeminiZeroCompletionUsage(c, info, usage, responseText.String(), imageCount)
}
if streamErr != nil {
return usage, types.NewOpenAIError(streamErr, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
logger.LogWarn(c, fmt.Sprintf("Gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
}
return usage, nil
}
func isGeminiDownstreamStop(c *gin.Context, info *relaycommon.RelayInfo) bool {
if c != nil && c.Request != nil && c.Request.Context().Err() != nil {
return true
}
return info != nil && info.StreamStatus != nil &&
info.StreamStatus.EndReason == relaycommon.StreamEndReasonClientGone
}
func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
id := helper.GetResponseID(c)
createAt := common.GetTimestamp()
@@ -323,6 +368,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
@@ -371,7 +417,7 @@ func GeminiChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.R
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
case types.RelayFormatClaude:
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatClaude, fullTextResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
+72 -61
View File
@@ -32,6 +32,7 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
markGeminiGoogleSearchCall(c, &geminiResponse)
countGeminiBillableFunctionCalls(info, &geminiResponse)
if len(geminiResponse.Candidates) == 0 {
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
if geminiResponse.PromptFeedback != nil && geminiResponse.PromptFeedback.BlockReason != nil {
@@ -50,15 +51,9 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
)
}
chatResp := responseGeminiChat2OpenAI(c, &geminiResponse)
chatResp.Model = info.UpstreamModelName
if responseID := helper.GetResponseID(c); responseID != "" {
chatResp.Id = responseID
}
usage := buildUsageFromGeminiResponse(c, info, &geminiResponse)
chatResp.Usage = usage
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, chatResp)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &geminiResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
@@ -66,10 +61,11 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
if !ok {
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI responses response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
responsesUsage := convertResult.Usage
if responsesUsage == nil || responsesUsage.TotalTokens == 0 {
responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
if responseID := helper.GetResponseID(c); responseID != "" {
responsesResp.ID = responseID
}
responsesResp.Model = info.UpstreamModelName
responsesResp.Usage = relayconvert.UsageFromChatUsage(&usage)
responseBody, err = common.Marshal(responsesResp)
if err != nil {
@@ -82,17 +78,16 @@ func GeminiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *h
func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
responseID := helper.GetResponseID(c)
created := common.GetTimestamp()
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
ID: responseID,
Model: info.UpstreamModelName,
Created: created,
state, err := relayconvert.NewResponseStreamState(types.RelayFormatGemini, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
ID: responseID,
Model: info.UpstreamModelName,
Created: created,
EmitSequenceNumber: true,
})
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
finishReason := constant.FinishReasonStop
toolCallIndexByChoice := make(map[int]map[string]int)
nextToolCallIndexByChoice := make(map[int]int)
hostedBridge := relayconvert.NewGeminiHostedStreamBridge()
var streamErr *types.NewAPIError
sendEvent := func(event relayconvert.ChatToResponsesStreamEvent) bool {
@@ -101,12 +96,37 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data))
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
if info.StreamStatus != nil {
info.StreamStatus.SetEndReason(relaycommon.StreamEndReasonClientGone, err)
}
return false
}
return true
}
sendChunk := func(chunk *dto.ChatCompletionsStreamResponse) bool {
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, chunk)
failResponsesStream := func(err error) bool {
failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
if !handled {
return false
}
for _, result := range failureResults {
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
if !ok {
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
return true
}
if !sendEvent(event) {
return true
}
}
return true
}
sendChunk := func(chunk *dto.GeminiChatResponse) bool {
results, err := service.ConvertStreamResponseChunk(c, info, state, chunk)
if err != nil {
if failResponsesStream(err) {
return false
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
@@ -123,58 +143,46 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
return true
}
usage, streamAPIError := geminiStreamHandler(c, info, resp, func(data string, geminiResponse *dto.GeminiChatResponse) bool {
response, isStop := streamResponseGeminiChat2OpenAI(geminiResponse)
response.Id = responseID
response.Created = created
response.Model = info.UpstreamModelName
if response.IsToolCall() {
finishReason = constant.FinishReasonToolCalls
}
for choiceIdx := range response.Choices {
choiceKey := response.Choices[choiceIdx].Index
for toolIdx := range response.Choices[choiceIdx].Delta.ToolCalls {
tool := &response.Choices[choiceIdx].Delta.ToolCalls[toolIdx]
if tool.ID == "" {
continue
}
indexByID := toolCallIndexByChoice[choiceKey]
if indexByID == nil {
indexByID = make(map[string]int)
toolCallIndexByChoice[choiceKey] = indexByID
}
if idx, ok := indexByID[tool.ID]; ok {
tool.SetIndex(idx)
continue
}
idx := nextToolCallIndexByChoice[choiceKey]
nextToolCallIndexByChoice[choiceKey] = idx + 1
indexByID[tool.ID] = idx
tool.SetIndex(idx)
}
}
if !sendChunk(response) {
return false
}
if isStop {
return sendChunk(helper.GenerateStopResponse(responseID, created, info.UpstreamModelName, finishReason))
}
return true
usage, streamAPIError := geminiStreamHandler(c, info, resp, func(_ string, geminiResponse *dto.GeminiChatResponse) bool {
hostedBridge.Observe(geminiResponse)
return sendChunk(geminiResponse)
})
if streamAPIError != nil {
if failResponsesStream(streamAPIError) && streamErr == nil {
return usage, nil
}
return usage, streamAPIError
}
if info.StreamStatus != nil && !info.StreamStatus.IsNormalEnd() {
if info.StreamStatus.EndReason != relaycommon.StreamEndReasonClientGone {
failResponsesStream(fmt.Errorf("gemini stream ended unexpectedly: %s", info.StreamStatus.Summary()))
}
return usage, nil
}
if streamErr != nil {
return nil, streamErr
}
hostedEvents, err := hostedBridge.Finalize(state)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, event := range hostedEvents {
if !sendEvent(event) {
if streamErr != nil {
return usage, streamErr
}
return usage, nil
}
}
if usage != nil {
state.SetUsage(usage)
}
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
if failResponsesStream(err) {
return usage, streamErr
}
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, result := range finalResults {
@@ -183,7 +191,10 @@ func GeminiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, r
return nil, types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
if !sendEvent(event) {
return nil, streamErr
if streamErr != nil {
return usage, streamErr
}
return usage, nil
}
}
return usage, nil
+2 -2
View File
@@ -75,14 +75,14 @@ func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayIn
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
return a.claudeAdaptor.ConvertClaudeRequest(c, info, request)
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
if request == nil {
return nil, errors.New("request is nil")
}
return request, nil
return a.geminiAdaptor.ConvertGeminiRequest(c, info, request)
}
func (a *Adaptor) ConvertImageRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.ImageRequest) (any, error) {
+201 -87
View File
@@ -19,14 +19,15 @@ import (
"github.com/QuantumNous/new-api/relay/channel"
"github.com/QuantumNous/new-api/relay/channel/ai360"
"github.com/QuantumNous/new-api/relay/channel/lingyiwanwu"
"github.com/QuantumNous/new-api/relay/channel/openrouter"
"github.com/QuantumNous/new-api/relaykit/dto"
//"github.com/QuantumNous/new-api/relay/channel/minimax"
"github.com/QuantumNous/new-api/relay/channel/openrouter"
"github.com/QuantumNous/new-api/relay/channel/xinference"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/common_handler"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
@@ -249,80 +250,122 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.StreamOptions = nil
}
if info.ChannelType == constant.ChannelTypeOpenRouter {
initialIntent, err := kitreasoning.FromOpenAIChat(request)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
var thinking dto.Thinking
if err := common.Unmarshal(request.THINKING, &thinking); err != nil {
return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
}
legacyIntent, err := kitreasoning.FromClaude(&dto.ClaudeRequest{Thinking: &thinking})
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
initialIntent, err = kitreasoning.MergeExplicit(initialIntent, legacyIntent, request.Model)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
request.THINKING = nil
}
if len(request.Usage) == 0 {
request.Usage = json.RawMessage(`{"include":true}`)
}
// 适配 OpenRouter 的 thinking 后缀
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) &&
strings.HasSuffix(info.UpstreamModelName, "-thinking") {
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
mergeEffortSuffix := func(modelName string) error {
rawEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(modelName)
if rawEffort == "" {
return nil
}
effort, err := kitreasoning.ParseEffort(rawEffort)
if err != nil {
return err
}
mode := kitreasoning.ModeEnabled
if effort == kitreasoning.EffortNone {
mode = kitreasoning.ModeDisabled
}
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(initialIntent, kitreasoning.Intent{Mode: mode, Effort: effort, Source: kitreasoning.SourceSuffix}, modelName)
return err
}
if !preserveSuffix {
if err := mergeEffortSuffix(info.UpstreamModelName); err != nil {
return nil, kitreasoning.AsClientError(err)
}
if _, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName); baseModel != info.UpstreamModelName {
info.UpstreamModelName = baseModel
request.Model = baseModel
}
if info.OriginModelName != info.UpstreamModelName {
if err := mergeEffortSuffix(info.OriginModelName); err != nil {
return nil, kitreasoning.AsClientError(err)
}
}
}
if !preserveSuffix && strings.HasSuffix(info.UpstreamModelName, "-thinking") {
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
initialIntent,
kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
info.UpstreamModelName,
)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
request.Model = info.UpstreamModelName
if len(request.Reasoning) == 0 {
reasoning := map[string]any{
"enabled": true,
}
if request.ReasoningEffort != "" && request.ReasoningEffort != "none" {
reasoning["effort"] = request.ReasoningEffort
}
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
// 清空多余的ReasoningEffort
request.ReasoningEffort = ""
} else {
if len(request.Reasoning) == 0 {
// 适配 OpenAI 的 ReasoningEffort 格式
if request.ReasoningEffort != "" {
reasoning := map[string]any{
"enabled": true,
}
if request.ReasoningEffort != "none" {
reasoning["effort"] = request.ReasoningEffort
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
}
}
request.ReasoningEffort = ""
}
// https://docs.anthropic.com/en/api/openai-sdk#extended-thinking-support
// 没有做排除3.5Haiku等,要出问题再加吧,最佳兼容性(不是
if request.THINKING != nil && strings.HasPrefix(info.UpstreamModelName, "anthropic") {
var thinking dto.Thinking // Claude标准Thinking格式
if err := json.Unmarshal(request.THINKING, &thinking); err != nil {
return nil, fmt.Errorf("error Unmarshal thinking: %w", err)
if !preserveSuffix && info.OriginModelName != info.UpstreamModelName && strings.HasSuffix(info.OriginModelName, "-thinking") {
initialIntent, err = kitreasoning.MergeExplicitAndSuffix(
initialIntent,
kitreasoning.Intent{Mode: kitreasoning.ModeEnabled},
info.OriginModelName,
)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
// 只有当 thinking.Type 是 "enabled" 时才处理
if thinking.Type == "enabled" {
// 检查 BudgetTokens 是否为 nil
if thinking.BudgetTokens == nil {
return nil, fmt.Errorf("BudgetTokens is nil when thinking is enabled")
}
reasoning := openrouter.RequestReasoning{
Enabled: true,
MaxTokens: *thinking.BudgetTokens,
}
marshal, err := common.Marshal(reasoning)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
// 清空 THINKING
request.THINKING = nil
}
if !initialIntent.IsEmpty() {
reasoningConfig := make(map[string]any)
if len(request.Reasoning) > 0 {
if err := common.Unmarshal(request.Reasoning, &reasoningConfig); err != nil {
return nil, fmt.Errorf("error unmarshalling reasoning: %w", err)
}
if reasoningConfig == nil {
reasoningConfig = make(map[string]any)
}
}
disabled := initialIntent.Mode == kitreasoning.ModeDisabled || initialIntent.Effort == kitreasoning.EffortNone
if initialIntent.HasStrength() {
reasoningConfig["enabled"] = !disabled
if disabled {
delete(reasoningConfig, "effort")
delete(reasoningConfig, "max_tokens")
}
}
if !disabled && initialIntent.BudgetTokens != nil {
reasoningConfig["max_tokens"] = *initialIntent.BudgetTokens
delete(reasoningConfig, "effort")
} else if !disabled && initialIntent.Effort != "" && initialIntent.Effort != kitreasoning.EffortNone {
reasoningConfig["effort"] = string(initialIntent.Effort)
delete(reasoningConfig, "max_tokens")
}
if initialIntent.IncludeThoughts != nil {
reasoningConfig["exclude"] = !*initialIntent.IncludeThoughts
}
marshal, err := common.Marshal(reasoningConfig)
if err != nil {
return nil, fmt.Errorf("error marshalling reasoning: %w", err)
}
request.Reasoning = marshal
}
request.ReasoningEffort = ""
effectiveEffort := kitreasoning.EffectiveEffort(initialIntent)
if initialIntent.BudgetTokens != nil {
effectiveEffort = kitreasoning.EffortFromBudget(*initialIntent.BudgetTokens)
}
info.SetReasoningEffort(string(effectiveEffort))
}
isOModel := dto.IsOpenAIReasoningOModel(info.UpstreamModelName)
@@ -344,16 +387,6 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
request.LogProbs = nil
}
// 转换模型推理力度后缀
effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
if effort != "" {
request.ReasoningEffort = effort
info.UpstreamModelName = originModel
request.Model = originModel
}
info.SetReasoningEffort(request.ReasoningEffort)
// o系列模型developer适配(o1-mini除外)
if !strings.HasPrefix(info.UpstreamModelName, "o1-mini") && !strings.HasPrefix(info.UpstreamModelName, "o1-preview") {
//修改第一个Message的内容,将system改为developer
@@ -363,6 +396,53 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
}
}
if info.ChannelType != constant.ChannelTypeOpenRouter {
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) || model_setting.ShouldPreserveThinkingSuffix(info.UpstreamModelName)
effort, baseModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.UpstreamModelName)
if preserveSuffix {
effort = ""
}
currentIntent, err := kitreasoning.FromOpenAIChat(request)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
mergeSuffix := func(modelName, rawEffort string) error {
if rawEffort == "" {
return nil
}
suffixEffort, err := kitreasoning.ParseEffort(rawEffort)
if err != nil {
return err
}
mode := kitreasoning.ModeEnabled
if suffixEffort == kitreasoning.EffortNone {
mode = kitreasoning.ModeDisabled
}
currentIntent, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
return err
}
if err := mergeSuffix(info.UpstreamModelName, effort); err != nil {
return nil, kitreasoning.AsClientError(err)
}
if !preserveSuffix && info.OriginModelName != info.UpstreamModelName {
originEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
if err := mergeSuffix(info.OriginModelName, originEffort); err != nil {
return nil, kitreasoning.AsClientError(err)
}
}
if effort != "" {
info.UpstreamModelName = baseModel
request.Model = baseModel
}
if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
request.ReasoningEffort = string(canonicalEffort)
info.SetReasoningEffort(string(canonicalEffort))
}
if info.ChannelType == constant.ChannelTypeOpenAI || info.ChannelType == constant.ChannelTypeAzure {
request.Reasoning = nil
}
}
return request, nil
}
@@ -604,18 +684,52 @@ func detectImageMimeType(filename string) string {
func (a *Adaptor) ConvertOpenAIResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.OpenAIResponsesRequest) (any, error) {
// 转换模型推理力度后缀
effort, originModel := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(request.Model)
if effort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{
Effort: effort,
}
} else {
request.Reasoning.Effort = effort
}
request.Model = originModel
preserveSuffix := model_setting.ShouldPreserveThinkingSuffix(request.Model) || (info != nil && model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName))
if preserveSuffix {
effort = ""
}
if info != nil && request.Reasoning != nil && request.Reasoning.Effort != "" {
info.SetReasoningEffort(request.Reasoning.Effort)
currentIntent, err := kitreasoning.FromOpenAIResponses(&request)
if err != nil {
return nil, kitreasoning.AsClientError(err)
}
mergeSuffix := func(modelName, rawEffort string) error {
if rawEffort == "" {
return nil
}
suffixEffort, err := kitreasoning.ParseEffort(rawEffort)
if err != nil {
return err
}
mode := kitreasoning.ModeEnabled
if suffixEffort == kitreasoning.EffortNone {
mode = kitreasoning.ModeDisabled
}
currentIntent, err = kitreasoning.MergeExplicitAndSuffix(currentIntent, kitreasoning.Intent{Mode: mode, Effort: suffixEffort, Source: kitreasoning.SourceSuffix}, modelName)
return err
}
if err := mergeSuffix(request.Model, effort); err != nil {
return nil, kitreasoning.AsClientError(err)
}
if !preserveSuffix && info != nil && info.OriginModelName != request.Model {
originEffort, _ := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(info.OriginModelName)
if err := mergeSuffix(info.OriginModelName, originEffort); err != nil {
return nil, kitreasoning.AsClientError(err)
}
}
if effort != "" {
request.Model = originModel
if info != nil {
info.UpstreamModelName = originModel
}
}
if canonicalEffort := kitreasoning.OpenAIEffort(kitreasoning.EffectiveEffort(currentIntent)); canonicalEffort != "" {
if request.Reasoning == nil {
request.Reasoning = &dto.Reasoning{}
}
request.Reasoning.Effort = string(canonicalEffort)
if info != nil {
info.SetReasoningEffort(string(canonicalEffort))
}
}
return request, nil
}
+26 -49
View File
@@ -41,33 +41,10 @@ func OaiResponsesToChatHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
return nil, types.WithOpenAIError(*oaiError, resp.StatusCode)
}
chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, &responsesResp)
responseValue, usage, err := convertResponsesResponseForClient(c, info, &responsesResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
chatResp, ok := chatResult.Value.(*dto.OpenAITextResponse)
if !ok {
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI chat response, got %T", chatResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if chatID := helper.GetResponseID(c); chatID != "" {
chatResp.Id = chatID
}
usage := chatResult.Usage
if usage == nil || usage.TotalTokens == 0 {
text := service.ExtractOutputTextFromResponses(&responsesResp)
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
chatResp.Usage = *usage
}
responseValue := any(chatResp)
if info.RelayFormat != types.RelayFormatOpenAI {
targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
responseValue = targetResult.Value
}
responseBody, err := common.Marshal(responseValue)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
@@ -150,32 +127,10 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
}
accumulator.SupplementResponseOutput(finalResponse)
chatResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAI, finalResponse)
responseValue, usage, err := convertResponsesResponseForClient(c, info, finalResponse)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
chatResp, ok := chatResult.Value.(*dto.OpenAITextResponse)
if !ok {
return nil, types.NewOpenAIError(fmt.Errorf("expected OpenAI chat response, got %T", chatResult.Value), types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
if chatID := helper.GetResponseID(c); chatID != "" {
chatResp.Id = chatID
}
usage := chatResult.Usage
if usage == nil || usage.TotalTokens == 0 {
text := service.ExtractOutputTextFromResponses(finalResponse)
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
chatResp.Usage = *usage
}
responseValue := any(chatResp)
if info.RelayFormat != types.RelayFormatOpenAI {
targetResult, err := relayconvert.ConvertResponse(c, info, info.RelayFormat, chatResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
responseValue = targetResult.Value
}
responseBody, err := common.Marshal(responseValue)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
@@ -185,6 +140,28 @@ func OaiResponsesToChatBufferedStreamHandler(c *gin.Context, info *relaycommon.R
return usage, nil
}
func convertResponsesResponseForClient(c *gin.Context, info *relaycommon.RelayInfo, response *dto.OpenAIResponsesResponse) (any, *dto.Usage, error) {
if responseID := helper.GetResponseID(c); responseID != "" {
response.ID = responseID
}
usage := relayconvert.UsageFromResponsesUsage(response.Usage)
if usage == nil || usage.TotalTokens == 0 {
text := service.ExtractOutputTextFromResponses(response)
usage = service.ResponseText2Usage(c, text, info.UpstreamModelName, info.GetEstimatePromptTokens())
response.Usage = relayconvert.UsageFromChatUsage(usage)
}
result, err := service.ConvertResponse(c, info, info.RelayFormat, response)
if err != nil {
return nil, nil, err
}
if result.Usage != nil && result.Usage.TotalTokens != 0 {
usage = result.Usage
}
return result.Value, usage, nil
}
func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
if resp == nil || resp.Body == nil {
return nil, types.NewOpenAIError(fmt.Errorf("invalid response"), types.ErrorCodeBadResponse, http.StatusInternalServerError)
@@ -293,7 +270,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
return
}
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &streamResp)
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResp)
if err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
@@ -320,7 +297,7 @@ func OaiResponsesToChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
if info.RelayFormat == types.RelayFormatClaude && info.ClaudeConvertInfo != nil {
info.ClaudeConvertInfo.Usage = usage
}
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
@@ -10,6 +10,7 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
@@ -171,6 +172,50 @@ func TestOaiResponsesToChatBufferedStreamHandlerReturnsJSONFromSSE(t *testing.T)
require.Contains(t, got, `"finish_reason":"tool_calls"`)
}
func TestOaiResponsesToChatBufferedStreamHandlerPreservesInterleavedClaudeContent(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
t.Cleanup(func() { gin.SetMode(oldMode) })
body := strings.Join([]string{
`data: {"type":"response.output_item.added","output_index":0,"item":{"type":"reasoning","id":"rs_1","summary":[]}}`,
`data: {"type":"response.reasoning_summary_text.delta","output_index":0,"item_id":"rs_1","delta":"**Planning file inspection**"}`,
`data: {"type":"response.output_item.added","output_index":1,"item":{"type":"message","id":"msg_1","role":"assistant","content":[]}}`,
`data: {"type":"response.output_text.delta","output_index":1,"item_id":"msg_1","delta":"Ill inspect the starter repository."}`,
`data: {"type":"response.output_item.added","output_index":2,"item":{"type":"reasoning","id":"rs_2","summary":[]}}`,
`data: {"type":"response.reasoning_summary_text.delta","output_index":2,"item_id":"rs_2","delta":"**Clarifying environment task requirements**"}`,
`data: {"type":"response.output_item.added","output_index":3,"item":{"type":"message","id":"msg_2","role":"assistant","content":[]}}`,
`data: {"type":"response.output_text.delta","output_index":3,"item_id":"msg_2","delta":"What would you like me to build?"}`,
`data: {"type":"response.done","response":{"id":"resp_1","model":"gpt-test","status":"completed","usage":{"input_tokens":1,"output_tokens":2,"total_tokens":3}}}`,
`data: [DONE]`,
``,
}, "\n")
c, recorder, resp, info := newResponsesChatTestContext(t, body, false)
info.RelayFormat = types.RelayFormatClaude
usage, apiErr := OaiResponsesToChatBufferedStreamHandler(c, info, resp)
require.Nil(t, apiErr)
require.NotNil(t, usage)
assert.Equal(t, 3, usage.TotalTokens)
var claudeResponse dto.ClaudeResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &claudeResponse))
require.Len(t, claudeResponse.Content, 4)
assert.Equal(t, []string{"thinking", "text", "thinking", "text"}, []string{
claudeResponse.Content[0].Type,
claudeResponse.Content[1].Type,
claudeResponse.Content[2].Type,
claudeResponse.Content[3].Type,
})
require.NotNil(t, claudeResponse.Content[0].Thinking)
require.NotNil(t, claudeResponse.Content[2].Thinking)
assert.Equal(t, "**Planning file inspection**", *claudeResponse.Content[0].Thinking)
assert.Equal(t, "Ill inspect the starter repository.", claudeResponse.Content[1].GetText())
assert.Equal(t, "**Clarifying environment task requirements**", *claudeResponse.Content[2].Thinking)
assert.Equal(t, "What would you like me to build?", claudeResponse.Content[3].GetText())
}
func TestOaiChatToResponsesStreamHandlerConvertsSSEOrderAndUsage(t *testing.T) {
oldMode := gin.Mode()
gin.SetMode(gin.TestMode)
+69 -44
View File
@@ -19,16 +19,20 @@ import (
"github.com/gin-gonic/gin"
)
const chatToGeminiStreamStateKey = "relaykit.chat_to_gemini_stream_state"
// 辅助函数
func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
info.SendResponseCount++
switch info.RelayFormat {
case types.RelayFormatOpenAI:
info.SendResponseCount++
return sendStreamData(c, info, data, forceFormat, thinkToContent)
case types.RelayFormatClaude:
info.SendResponseCount++
return handleClaudeFormat(c, data, info)
case types.RelayFormatGemini:
// The stateful relaykit path owns its chunk counter so multi-hop and
// direct conversions observe the same stream state semantics.
return handleGeminiFormat(c, data, info)
}
return nil
@@ -41,9 +45,9 @@ func handleClaudeFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
}
if streamResponse.Usage != nil {
info.ClaudeConvertInfo.Usage = streamResponse.Usage
info.EnsureClaudeConvertInfo().Usage = streamResponse.Usage
}
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
if err != nil {
return err
}
@@ -64,29 +68,55 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
return err
}
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatGemini, &streamResponse)
state, err := chatToGeminiStreamState(c, &streamResponse)
if err != nil {
return err
}
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
if !ok {
return fmt.Errorf("expected Gemini stream response, got %T", result.Value)
}
// 如果返回 nil,表示没有实际内容,跳过发送
if geminiResponse == nil {
return nil
}
geminiResponseStr, err := common.Marshal(geminiResponse)
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
if err != nil {
logger.LogError(c, "failed to marshal gemini response: "+err.Error())
return err
}
return sendGeminiStreamResults(c, results)
}
// send gemini format response
c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
_ = helper.FlushWriter(c)
func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
if value, ok := c.Get(chatToGeminiStreamStateKey); ok {
state, ok := value.(*relayconvert.ResponseStreamState)
if !ok || state == nil {
return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", value)
}
return state, nil
}
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{
ID: streamResponse.Id,
Model: streamResponse.Model,
Created: streamResponse.Created,
})
if err != nil {
return nil, err
}
c.Set(chatToGeminiStreamStateKey, state)
return state, nil
}
func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResult) error {
for _, result := range results {
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
if !ok {
return fmt.Errorf("expected Gemini stream response, got %T", result.Value)
}
if geminiResponse == nil {
continue
}
data, err := common.Marshal(geminiResponse)
if err != nil {
logger.LogError(c, "failed to marshal gemini response: "+err.Error())
return err
}
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
_ = helper.FlushWriter(c)
}
return nil
}
@@ -148,7 +178,7 @@ func handleLastResponse(lastStreamData string, responseId *string, createAt *int
if service.ValidUsage(lastStreamResponse.Usage) {
*containStreamUsage = true
*usage = lastStreamResponse.Usage
*usage = dto.MergeUsageNonZero(*usage, lastStreamResponse.Usage)
if !info.ShouldIncludeUsage {
*shouldSendLastResp = lo.SomeBy(lastStreamResponse.Choices, func(choice dto.ChatCompletionsStreamResponseChoice) bool {
return choice.Delta.GetContentString() != "" || choice.Delta.GetReasoningContent() != ""
@@ -181,7 +211,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
info.ClaudeConvertInfo.Usage = usage
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
result, err := service.ConvertStreamResponse(c, info, types.RelayFormatClaude, &streamResponse)
if err != nil {
common.SysLog("error converting Claude stream response: " + err.Error())
return
@@ -203,36 +233,31 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
return
}
// 这里处理的是 openai 最后一个流响应,其 delta 为空,有 finish_reason 字段
// 因此相比较于 google 官方的流响应,由 openai 转换而来会多一个 parts 为空,finishReason 为 STOP 的响应
// 而包含最后一段文本输出的响应(倒数第二个)的 finishReason 为 null
// 暂不知是否有程序会不兼容。
result, err := relayconvert.ConvertStreamResponse(c, info, types.RelayFormatGemini, &streamResponse)
state, err := chatToGeminiStreamState(c, &streamResponse)
if err != nil {
common.SysLog("error converting Gemini stream response: " + err.Error())
return
}
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
if !ok {
common.SysLog(fmt.Sprintf("expected Gemini stream response, got %T", result.Value))
common.SysLog("error creating Gemini stream state: " + err.Error())
return
}
state.SetUsage(usage)
// openai 流响应开头的空数据
if geminiResponse == nil {
return
}
geminiResponseStr, err := common.Marshal(geminiResponse)
results, err := service.ConvertStreamResponseChunk(c, info, state, &streamResponse)
if err != nil {
common.SysLog("error marshalling gemini response: " + err.Error())
common.SysLog("error converting final Gemini stream response: " + err.Error())
return
}
if err := sendGeminiStreamResults(c, results); err != nil {
common.SysLog("error sending final Gemini stream response: " + err.Error())
return
}
// 发送最终的 Gemini 响应
c.Render(-1, common.CustomEvent{Data: "data: " + string(geminiResponseStr)})
_ = helper.FlushWriter(c)
results, err = service.FinalizeStreamResponse(c, info, state)
if err != nil {
common.SysLog("error finalizing Gemini stream response: " + err.Error())
return
}
if err := sendGeminiStreamResults(c, results); err != nil {
common.SysLog("error sending finalized Gemini stream response: " + err.Error())
}
}
}
+54 -34
View File
@@ -13,7 +13,6 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
@@ -118,13 +117,10 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
var toolCount int
var usage = &dto.Usage{}
var lastStreamData string
var secondLastStreamData string // 存储倒数第二个stream data,用于音频模型
var secondLastStreamData string // 保留倒数第二个stream data;部分兼容网关把完整usage放在倒数第二个事件
seenStreamToolCalls := make(map[string]struct{})
var streamFunctionCallNames []string
// 检查是否为音频模型
isAudioModel := strings.Contains(strings.ToLower(model), "audio")
helper.StreamScannerHandler(c, resp, info, func(data string, sr *helper.StreamResult) {
if lastStreamData != "" {
if err := HandleStreamFormat(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent); err != nil {
@@ -133,8 +129,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
}
if len(data) > 0 {
// 对音频模型,保存倒数第二个stream data
if isAudioModel && lastStreamData != "" {
if lastStreamData != "" {
secondLastStreamData = lastStreamData
}
@@ -147,24 +142,6 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
}
})
// 对音频模型,从倒数第二个stream data中提取usage信息
if isAudioModel && secondLastStreamData != "" {
var streamResp struct {
Usage *dto.Usage `json:"usage"`
}
err := common.Unmarshal([]byte(secondLastStreamData), &streamResp)
if err == nil && streamResp.Usage != nil && service.ValidUsage(streamResp.Usage) {
usage = streamResp.Usage
containStreamUsage = true
if common.DebugEnabled {
logger.LogDebug(c, "Audio model usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
usage.InputTokens, usage.OutputTokens)
}
}
}
// 处理最后的响应
shouldSendLastResp := true
if err := handleLastResponse(lastStreamData, &responseId, &createAt, &systemFingerprint, &model, &usage,
@@ -172,6 +149,29 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
logger.LogError(c, fmt.Sprintf("error handling last response: %s, lastStreamData: [%s]", err.Error(), lastStreamData))
}
// 部分兼容网关把完整的累计usage附在倒数第二个事件上,随后发送一个空的最后事件。
// 仅当最后一个事件没有有效usage时,回退到倒数第二个事件的完整快照。
usageFrame := lastStreamData
if !containStreamUsage && secondLastStreamData != "" {
var streamResp struct {
Usage *dto.Usage `json:"usage"`
}
err := common.Unmarshal([]byte(secondLastStreamData), &streamResp)
if err == nil && streamResp.Usage != nil &&
streamResp.Usage.PromptTokens > 0 &&
(streamResp.Usage.CompletionTokens > 0 || streamResp.Usage.TotalTokens > 0) {
usage = dto.MergeUsageNonZero(usage, streamResp.Usage)
containStreamUsage = true
usageFrame = secondLastStreamData
if common.DebugEnabled {
logger.LogDebug(c, "usage extracted from second last SSE: PromptTokens=%d, CompletionTokens=%d, TotalTokens=%d, InputTokens=%d, OutputTokens=%d",
usage.PromptTokens, usage.CompletionTokens, usage.TotalTokens,
usage.InputTokens, usage.OutputTokens)
}
}
}
if info.RelayFormat == types.RelayFormatOpenAI {
if shouldSendLastResp {
_ = sendStreamData(c, info, lastStreamData, info.ChannelSetting.ForceFormat, info.ChannelSetting.ThinkingToContent)
@@ -183,7 +183,7 @@ func OaiStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Re
usage.CompletionTokens += toolCount * 7
}
applyUsagePostProcessing(info, usage, common.StringToByteSlice(lastStreamData))
applyUsagePostProcessing(info, usage, common.StringToByteSlice(usageFrame))
for _, name := range streamFunctionCallNames {
info.CountBillableToolCall(dto.BuildInCallFunctionCall, name)
@@ -201,7 +201,7 @@ func collectStreamFunctionCallNames(data string, seen map[string]struct{}, names
}
for _, choice := range streamResponse.Choices {
for i, tc := range choice.Delta.ToolCalls {
name := tc.Function.Name
name := strings.TrimSpace(tc.Function.Name)
if name == "" {
continue
}
@@ -209,11 +209,30 @@ func collectStreamFunctionCallNames(data string, seen map[string]struct{}, names
if tc.Index != nil {
toolIdx = *tc.Index
}
key := fmt.Sprintf("%d-%d", choice.Index, toolIdx)
if _, ok := seen[key]; ok {
continue
fallbackKey := fmt.Sprintf("index\x00%d\x00%d\x00%s", choice.Index, toolIdx, name)
activeKey := fmt.Sprintf("active\x00%d\x00%d\x00%s", choice.Index, toolIdx, name)
callID := strings.TrimSpace(tc.ID)
if callID != "" {
idKey := fmt.Sprintf("id\x00%d\x00%s", choice.Index, callID)
if _, ok := seen[idKey]; ok {
continue
}
seen[idKey] = struct{}{}
seen[activeKey] = struct{}{}
if _, delayedID := seen[fallbackKey]; delayedID {
delete(seen, fallbackKey)
continue
}
} else {
if _, ok := seen[fallbackKey]; ok {
continue
}
if _, ok := seen[activeKey]; ok {
continue
}
seen[fallbackKey] = struct{}{}
seen[activeKey] = struct{}{}
}
seen[key] = struct{}{}
*names = append(*names, name)
}
}
@@ -280,11 +299,12 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
completionTokens += ctkm
}
}
simpleResponse.Usage = dto.Usage{
fallbackUsage := &dto.Usage{
PromptTokens: info.GetEstimatePromptTokens(),
CompletionTokens: completionTokens,
TotalTokens: info.GetEstimatePromptTokens() + completionTokens,
}
simpleResponse.Usage = *fallbackUsage
usageModified = true
}
@@ -310,7 +330,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
break
}
case types.RelayFormatClaude:
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatClaude, &simpleResponse)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatClaude, &simpleResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
@@ -320,7 +340,7 @@ func OpenaiHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Respo
}
responseBody = claudeRespStr
case types.RelayFormatGemini:
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatGemini, &simpleResponse)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatGemini, &simpleResponse)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeBadResponseBody)
}
+8 -24
View File
@@ -11,6 +11,7 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
@@ -38,16 +39,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
service.IOCopyBytesGracefully(c, resp, responseBody)
// compute usage
usage := dto.Usage{}
if responsesResponse.Usage != nil {
usage.PromptTokens = responsesResponse.Usage.InputTokens
usage.CompletionTokens = responsesResponse.Usage.OutputTokens
usage.TotalTokens = responsesResponse.Usage.TotalTokens
if responsesResponse.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = responsesResponse.Usage.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.CacheWriteTokens = responsesResponse.Usage.InputTokensDetails.CacheWriteTokens
}
}
usage := relayconvert.NormalizeResponsesUsage(responsesResponse.Usage)
// Count actual tool invocations from Output (not tool declarations).
for _, output := range responsesResponse.Output {
switch output.Type {
@@ -69,7 +61,7 @@ func OaiResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http
}
imageCounter.Commit(info)
return &usage, nil
return usage, nil
}
func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *http.Response) (*dto.Usage, *types.NewAPIError) {
@@ -99,19 +91,8 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
case "response.completed", "response.done":
if streamResponse.Response != nil {
if streamResponse.Response.Usage != nil {
if streamResponse.Response.Usage.InputTokens != 0 {
usage.PromptTokens = streamResponse.Response.Usage.InputTokens
}
if streamResponse.Response.Usage.OutputTokens != 0 {
usage.CompletionTokens = streamResponse.Response.Usage.OutputTokens
}
if streamResponse.Response.Usage.TotalTokens != 0 {
usage.TotalTokens = streamResponse.Response.Usage.TotalTokens
}
if streamResponse.Response.Usage.InputTokensDetails != nil {
usage.PromptTokensDetails.CachedTokens = streamResponse.Response.Usage.InputTokensDetails.CachedTokens
usage.PromptTokensDetails.CacheWriteTokens = streamResponse.Response.Usage.InputTokensDetails.CacheWriteTokens
}
incomingUsage := relayconvert.NormalizeResponsesUsage(streamResponse.Response.Usage)
usage = dto.MergeUsageNonZero(usage, incomingUsage)
}
if !imageCommitted {
if relaycommon.IsNonBillableResponsesStatus(streamResponse.Response.Status) {
@@ -173,6 +154,9 @@ func OaiResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
}
usage.TotalTokens = usage.PromptTokens + usage.CompletionTokens
if usage.BillingUsage != nil {
usage.BillingUsage = dto.CloneBillingUsageWithEstimatedCompletion(usage.BillingUsage, usage.CompletionTokens)
}
return usage, nil
}
+44 -7
View File
@@ -38,7 +38,7 @@ func OaiChatToResponsesHandler(c *gin.Context, info *relaycommon.RelayInfo, resp
if responseID := helper.GetResponseID(c); responseID != "" {
chatResp.Id = responseID
}
convertResult, err := relayconvert.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &chatResp)
convertResult, err := service.ConvertResponse(c, info, types.RelayFormatOpenAIResponses, &chatResp)
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
}
@@ -70,8 +70,9 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
responseID := helper.GetResponseID(c)
state, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses, relayconvert.ResponseStreamOptions{
ID: responseID,
Model: info.UpstreamModelName,
ID: responseID,
Model: info.UpstreamModelName,
EmitSequenceNumber: true,
})
if err != nil {
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
@@ -84,7 +85,27 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
streamErr = types.NewOpenAIError(err, types.ErrorCodeJsonMarshalFailed, http.StatusInternalServerError)
return false
}
helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data))
if err := helper.ResponseChunkData(c, dto.ResponsesStreamResponse{Type: event.Type}, string(data)); err != nil {
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
return false
}
return true
}
failResponsesStream := func(err error) bool {
failureResults, handled := state.FailResponsesStream("server_error", err.Error(), "")
if !handled {
return false
}
for _, result := range failureResults {
event, ok := result.Value.(relayconvert.ChatToResponsesStreamEvent)
if !ok {
streamErr = types.NewOpenAIError(fmt.Errorf("expected OAI responses stream event, got %T", result.Value), types.ErrorCodeBadResponse, http.StatusInternalServerError)
return true
}
if !sendEvent(event) {
return true
}
}
return true
}
@@ -97,6 +118,10 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
var errorResp dto.OpenAITextResponse
if err := common.UnmarshalJsonStr(data, &errorResp); err == nil {
if oaiError := errorResp.GetOpenAIError(); oaiError != nil && oaiError.Type != "" {
if failResponsesStream(fmt.Errorf("%s", oaiError.Message)) {
sr.Stop(streamErr)
return
}
streamErr = types.WithOpenAIError(*oaiError, resp.StatusCode)
sr.Stop(streamErr)
return
@@ -106,12 +131,21 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
var chunk dto.ChatCompletionsStreamResponse
if err := common.UnmarshalJsonStr(data, &chunk); err != nil {
logger.LogError(c, "failed to unmarshal chat stream response: "+err.Error())
sr.Error(err)
if failResponsesStream(err) {
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponseBody, http.StatusInternalServerError)
sr.Stop(streamErr)
return
}
results, err := relayconvert.ConvertStreamResponseChunk(c, info, state, &chunk)
results, err := service.ConvertStreamResponseChunk(c, info, state, &chunk)
if err != nil {
if failResponsesStream(err) {
sr.Stop(streamErr)
return
}
streamErr = types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
sr.Stop(streamErr)
return
@@ -140,8 +174,11 @@ func OaiChatToResponsesStreamHandler(c *gin.Context, info *relaycommon.RelayInfo
state.SetUsage(usage)
}
finalResults, err := relayconvert.FinalizeStreamResponse(c, info, state)
finalResults, err := service.FinalizeStreamResponse(c, info, state)
if err != nil {
if failResponsesStream(err) {
return usage, streamErr
}
return nil, types.NewOpenAIError(err, types.ErrorCodeBadResponse, http.StatusInternalServerError)
}
for _, result := range finalResults {
+39
View File
@@ -1,11 +1,13 @@
package sub2api
import (
"encoding/json"
"testing"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -44,3 +46,40 @@ func TestAdaptorInheritsNewAPIResponsesCompactSupport(t *testing.T) {
assert.Equal(t, "sub2api", adaptor.GetChannelName())
assert.Empty(t, adaptor.GetModelList())
}
func TestConvertClaudeRequestPreservesAdaptiveThinkingForCompatibleModel(t *testing.T) {
adaptor := &Adaptor{}
maxTokens := uint(8192)
temperature := 0.2
topP := 0.99
request := &dto.ClaudeRequest{
Model: "gpt-5.6-sol",
MaxTokens: &maxTokens,
Temperature: &temperature,
TopP: &topP,
Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
OutputConfig: json.RawMessage(`{"effort":"xhigh","provider_option":true}`),
Messages: []dto.ClaudeMessage{
{Role: "user", Content: "hello"},
},
}
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.6-sol",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeSub2API,
},
}
converted, err := adaptor.ConvertClaudeRequest(nil, info, request)
require.NoError(t, err)
assert.Same(t, request, converted)
require.NotNil(t, request.Thinking)
assert.Equal(t, "adaptive", request.Thinking.Type)
assert.Equal(t, "summarized", request.Thinking.Display)
assert.JSONEq(t, `{"effort":"xhigh","provider_option":true}`, string(request.OutputConfig))
assert.Same(t, &temperature, request.Temperature)
assert.Same(t, &topP, request.TopP)
assert.Equal(t, "xhigh", info.ReasoningEffort)
assert.Equal(t, "gpt-5.6-sol", info.UpstreamModelName)
}
+15 -23
View File
@@ -18,7 +18,6 @@ import (
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
@@ -56,15 +55,16 @@ type Adaptor struct {
}
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
// Vertex AI does not support functionResponse.id; keep it stripped here for consistency.
// Vertex AI's generateContent schema does not expose the Gemini API's
// function-call identity fields. Strip both sides at this provider boundary.
if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
removeFunctionResponseID(request)
removeFunctionCallIDs(request)
}
geminiAdaptor := gemini.Adaptor{}
return geminiAdaptor.ConvertGeminiRequest(c, info, request)
}
func removeFunctionResponseID(request *dto.GeminiChatRequest) {
func removeFunctionCallIDs(request *dto.GeminiChatRequest) {
if request == nil {
return
}
@@ -76,10 +76,10 @@ func removeFunctionResponseID(request *dto.GeminiChatRequest) {
}
for j := range request.Contents[i].Parts {
part := &request.Contents[i].Parts[j]
if part.FunctionResponse == nil {
continue
if part.FunctionCall != nil {
part.FunctionCall.ID = ""
}
if len(part.FunctionResponse.ID) > 0 {
if part.FunctionResponse != nil && len(part.FunctionResponse.ID) > 0 {
part.FunctionResponse.ID = nil
}
}
@@ -88,12 +88,16 @@ func removeFunctionResponseID(request *dto.GeminiChatRequest) {
if len(request.Requests) > 0 {
for i := range request.Requests {
removeFunctionResponseID(&request.Requests[i])
removeFunctionCallIDs(&request.Requests[i])
}
}
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
claudeAdaptor := claude.Adaptor{}
if _, err := claudeAdaptor.ConvertClaudeRequest(c, info, request); err != nil {
return nil, err
}
if v, ok := claudeModelMap[info.UpstreamModelName]; ok {
c.Set("request_model", v)
} else {
@@ -170,21 +174,6 @@ func (a *Adaptor) getRequestUrl(info *relaycommon.RelayInfo, modelName, suffix s
func (a *Adaptor) GetRequestURL(info *relaycommon.RelayInfo) (string, error) {
suffix := ""
if a.RequestMode == RequestModeGemini {
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled &&
!model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
// 新增逻辑:处理 -thinking-<budget> 格式
if strings.Contains(info.UpstreamModelName, "-thinking-") {
parts := strings.Split(info.UpstreamModelName, "-thinking-")
info.UpstreamModelName = parts[0]
} else if strings.HasSuffix(info.UpstreamModelName, "-thinking") { // 旧的适配
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-thinking")
} else if strings.HasSuffix(info.UpstreamModelName, "-nothinking") {
info.UpstreamModelName = strings.TrimSuffix(info.UpstreamModelName, "-nothinking")
} else if baseModel, level, ok := reasoning.TrimEffortSuffix(info.UpstreamModelName); ok && level != "" {
info.UpstreamModelName = baseModel
}
}
if info.IsStream {
suffix = "streamGenerateContent?alt=sse"
} else {
@@ -310,6 +299,9 @@ func (a *Adaptor) ConvertOpenAIRequest(c *gin.Context, info *relaycommon.RelayIn
if !ok {
return nil, fmt.Errorf("expected Gemini generateContent request, got %T", result.Value)
}
if model_setting.GetGeminiSettings().RemoveFunctionResponseIdEnabled {
removeFunctionCallIDs(geminiRequest)
}
c.Set("request_model", request.Model)
return geminiRequest, nil
} else if a.RequestMode == RequestModeOpenSource {
+2 -1
View File
@@ -28,7 +28,8 @@ func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dt
}
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, req *dto.ClaudeRequest) (any, error) {
return req, nil
claudeAdaptor := claude.Adaptor{}
return claudeAdaptor.ConvertClaudeRequest(c, info, req)
}
func (a *Adaptor) ConvertAudioRequest(c *gin.Context, info *relaycommon.RelayInfo, request dto.AudioRequest) (io.Reader, error) {
+35 -21
View File
@@ -70,30 +70,35 @@ func applySystemPromptIfNeeded(c *gin.Context, info *relaycommon.RelayInfo, requ
}
}
func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request *dto.GeneralOpenAIRequest) (*dto.Usage, *types.NewAPIError) {
chatJSON, err := common.Marshal(request)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if len(info.ParamOverride) > 0 {
chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info)
func textRequestViaResponses(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, request any) (*dto.Usage, *types.NewAPIError) {
paramOverrideApplied := false
if chatRequest, ok := request.(*dto.GeneralOpenAIRequest); ok {
chatJSON, err := common.Marshal(chatRequest)
if err != nil {
return nil, newAPIErrorFromParamOverride(err)
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
chatJSON, err = relaycommon.RemoveDisabledFields(chatJSON, info.ChannelOtherSettings, info.ChannelSetting.PassThroughBodyEnabled)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if len(info.ParamOverride) > 0 {
chatJSON, err = relaycommon.ApplyParamOverrideWithRelayInfo(chatJSON, info)
if err != nil {
return nil, newAPIErrorFromParamOverride(err)
}
paramOverrideApplied = true
}
var overriddenChatReq dto.GeneralOpenAIRequest
if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil {
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}
request = &overriddenChatReq
}
var overriddenChatReq dto.GeneralOpenAIRequest
if err := common.Unmarshal(chatJSON, &overriddenChatReq); err != nil {
return nil, types.NewError(err, types.ErrorCodeChannelParamOverrideInvalid, types.ErrOptionWithSkipRetry())
}
result, err := service.ConvertRequestVia(c, info, &overriddenChatReq, types.RelayFormatOpenAI, types.RelayFormatOpenAIResponses)
result, err := service.ConvertRequest(c, info, types.RelayFormatOpenAIResponses, request)
if err != nil {
return nil, types.NewErrorWithStatusCode(err, types.ErrorCodeInvalidRequest, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
@@ -101,7 +106,10 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
if !ok {
return nil, types.NewError(fmt.Errorf("expected OpenAI responses request, got %T", result.Value), types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
return relayResponsesRequest(c, info, adaptor, responsesReq, paramOverrideApplied)
}
func relayResponsesRequest(c *gin.Context, info *relaycommon.RelayInfo, adaptor channel.Adaptor, responsesReq *dto.OpenAIResponsesRequest, paramOverrideApplied bool) (*dto.Usage, *types.NewAPIError) {
savedRelayMode := info.RelayMode
savedRequestURLPath := info.RequestURLPath
defer func() {
@@ -114,7 +122,7 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *responsesReq)
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return nil, newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
@@ -127,6 +135,12 @@ func chatCompletionsViaResponses(c *gin.Context, info *relaycommon.RelayInfo, ad
if err != nil {
return nil, types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
if !paramOverrideApplied && len(info.ParamOverride) > 0 {
jsonData, err = relaycommon.ApplyParamOverrideWithRelayInfo(jsonData, info)
if err != nil {
return nil, newAPIErrorFromParamOverride(err)
}
}
body, closer, err := relaycommon.NewOutboundJSONBody(jsonData)
if err != nil {
+87 -3
View File
@@ -1,11 +1,21 @@
package relay
import (
"io"
"math"
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
openaichannel "github.com/QuantumNous/new-api/relay/channel/openai"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/types"
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
relaytypes "github.com/QuantumNous/new-api/relaykit/types"
hosttypes "github.com/QuantumNous/new-api/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -31,7 +41,7 @@ func TestIsResponsesEventStreamContentType(t *testing.T) {
func TestRecalcQuotaFromRatiosIgnoresInvalidMultipliers(t *testing.T) {
info := &relaycommon.RelayInfo{
PriceData: types.PriceData{
PriceData: hosttypes.PriceData{
Quota: 100,
},
}
@@ -52,7 +62,7 @@ func TestRecalcQuotaFromRatiosIgnoresInvalidMultipliers(t *testing.T) {
func TestRecalcQuotaFromRatiosRejectsAllInvalidAdjustedRatios(t *testing.T) {
info := &relaycommon.RelayInfo{
PriceData: types.PriceData{
PriceData: hosttypes.PriceData{
Quota: 100,
},
}
@@ -69,3 +79,77 @@ func TestRecalcQuotaFromRatiosRejectsAllInvalidAdjustedRatios(t *testing.T) {
assert.Equal(t, 0, quota)
assert.True(t, info.PriceData.HasOtherRatio("duration"))
}
func TestTextRequestViaResponsesConvertsClaudeDirectly(t *testing.T) {
type capturedRequest struct {
path string
body []byte
}
captured := make(chan capturedRequest, 1)
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
captured <- capturedRequest{path: r.URL.Path, body: body}
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{
"id":"resp_1",
"object":"response",
"status":"completed",
"model":"gpt-5.6-sol",
"output":[{"type":"message","id":"msg_1","role":"assistant","content":[{"type":"output_text","text":"ok"}]}],
"usage":{"input_tokens":3,"output_tokens":2,"total_tokens":5}
}`))
}))
defer server.Close()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
c.Request.Header.Set("Content-Type", "application/json")
info := &relaycommon.RelayInfo{
RelayMode: relayconstant.RelayModeChatCompletions,
RelayFormat: relaytypes.RelayFormatClaude,
OriginModelName: "gpt-5.6-sol",
RequestConversionChain: []relaytypes.RelayFormat{relaytypes.RelayFormatClaude},
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenAI,
ChannelBaseUrl: server.URL,
ApiKey: "test-key",
UpstreamModelName: "gpt-5.6-sol",
},
}
adaptor := &openaichannel.Adaptor{}
adaptor.Init(info)
request := &dto.ClaudeRequest{
Model: "gpt-5.6-sol",
Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
Messages: []dto.ClaudeMessage{{Role: "user", Content: "hello"}},
}
usage, apiErr := textRequestViaResponses(c, info, adaptor, request)
require.Nil(t, apiErr)
require.NotNil(t, usage)
assert.Equal(t, 5, usage.TotalTokens)
assert.Equal(t, []relaytypes.RelayFormat{relaytypes.RelayFormatClaude, relaytypes.RelayFormatOpenAIResponses}, info.RequestConversionChain)
upstream := <-captured
assert.Equal(t, "/v1/responses", upstream.path)
var upstreamBody map[string]any
require.NoError(t, common.Unmarshal(upstream.body, &upstreamBody))
assert.NotContains(t, upstreamBody, "messages")
reasoning, ok := upstreamBody["reasoning"].(map[string]any)
require.True(t, ok)
assert.Equal(t, "high", reasoning["effort"])
assert.Equal(t, "detailed", reasoning["summary"])
var response dto.ClaudeResponse
require.NoError(t, common.Unmarshal(recorder.Body.Bytes(), &response))
require.Len(t, response.Content, 1)
assert.Equal(t, "ok", response.Content[0].GetText())
}
+5 -78
View File
@@ -1,7 +1,6 @@
package relay
import (
"encoding/json"
"fmt"
"io"
"net/http"
@@ -16,7 +15,6 @@ import (
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/reasoning"
"github.com/gin-gonic/gin"
)
@@ -40,6 +38,9 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
@@ -47,71 +48,6 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
}
adaptor.Init(info)
if request.MaxTokens == nil || *request.MaxTokens == 0 {
defaultMaxTokens := uint(model_setting.GetClaudeSettings().GetDefaultMaxTokens(request.Model))
request.MaxTokens = &defaultMaxTokens
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(request.Model); ok && effortLevel != "" &&
(strings.HasPrefix(request.Model, "claude-opus-4-6") ||
strings.HasPrefix(request.Model, "claude-opus-4-7") ||
strings.HasPrefix(request.Model, "claude-opus-4-8")) {
request.Model = baseModel
request.Thinking = &dto.Thinking{
Type: "adaptive",
}
request.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(request.Model, "claude-opus-4-7") ||
strings.HasPrefix(request.Model, "claude-opus-4-8") {
// Opus 4.7/4.8 reject non-default temperature/top_p/top_k with 400
// and defaults display to "omitted"; restore the 4.6 visible summary.
request.Thinking.Display = "summarized"
request.Temperature = nil
request.TopP = nil
request.TopK = nil
} else {
request.Temperature = common.GetPointer[float64](1.0)
}
info.UpstreamModelName = request.Model
} else if model_setting.GetClaudeSettings().ThinkingAdapterEnabled &&
strings.HasSuffix(request.Model, "-thinking") {
if request.Thinking == nil {
baseModel := strings.TrimSuffix(request.Model, "-thinking")
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
// Opus 4.7/4.8 reject thinking.type="enabled"; use adaptive at high effort.
request.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
request.OutputConfig = json.RawMessage(`{"effort":"high"}`)
request.Temperature = nil
request.TopP = nil
request.TopK = nil
} else {
// 因为BudgetTokens 必须大于1024
if request.MaxTokens == nil || *request.MaxTokens < 1280 {
request.MaxTokens = common.GetPointer[uint](1280)
}
// BudgetTokens 为 max_tokens 的 80%
request.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: common.GetPointer[int](int(float64(*request.MaxTokens) * model_setting.GetClaudeSettings().ThinkingAdapterBudgetTokensPercentage)),
}
// TODO: 临时处理
// https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#important-considerations-when-using-extended-thinking
request.Temperature = common.GetPointer[float64](1.0)
}
}
if !model_setting.ShouldPreserveThinkingSuffix(info.OriginModelName) {
request.Model = strings.TrimSuffix(request.Model, "-thinking")
}
info.UpstreamModelName = request.Model
}
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled && !info.ChannelSetting.PassThroughBodyEnabled {
if effort := request.GetEfforts(); effort != "" {
info.SetReasoningEffort(effort)
}
}
if info.ChannelSetting.SystemPrompt != "" {
if request.System == nil {
request.SetStringSystem(info.ChannelSetting.SystemPrompt)
@@ -140,16 +76,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if !model_setting.GetGlobalSettings().PassThroughRequestEnabled &&
!info.ChannelSetting.PassThroughBodyEnabled &&
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
result, convErr := service.ConvertRequest(c, info, types.RelayFormatOpenAI, request)
if convErr != nil {
return types.NewError(convErr, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
openAIRequest, ok := result.Value.(*dto.GeneralOpenAIRequest)
if !ok {
return types.NewError(fmt.Errorf("expected OpenAI chat completions request, got %T", result.Value), types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, openAIRequest)
usage, newApiErr := textRequestViaResponses(c, info, adaptor, request)
if newApiErr != nil {
return newApiErr
}
@@ -168,7 +95,7 @@ func ClaudeHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
} else {
convertedRequest, err := adaptor.ConvertClaudeRequest(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)
+71
View File
@@ -0,0 +1,71 @@
package common
import (
"context"
"fmt"
"github.com/QuantumNous/new-api/logger"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
const maxConversionDiagnostics = 32
type conversionDiagnosticKey struct {
code string
path string
severity types.ConversionDiagnosticSeverity
from types.RelayFormat
to types.RelayFormat
}
// RecordConversionDiagnostics retains conversion losses for the consume log
// and emits one request-correlated warning per distinct diagnostic. The cap
// prevents malformed streams from growing request state without bound.
func (info *RelayInfo) RecordConversionDiagnostics(ctx context.Context, diagnostics []types.ConversionDiagnostic) {
if info == nil || len(diagnostics) == 0 {
return
}
if ginCtx, ok := ctx.(*gin.Context); ok && ginCtx == nil {
ctx = nil
}
if info.conversionDiagnosticKeys == nil {
info.conversionDiagnosticKeys = make(map[conversionDiagnosticKey]struct{})
}
for _, diagnostic := range diagnostics {
key := conversionDiagnosticKey{
code: diagnostic.Code,
path: diagnostic.Path,
severity: diagnostic.Severity,
from: diagnostic.From,
to: diagnostic.To,
}
if _, exists := info.conversionDiagnosticKeys[key]; exists {
continue
}
if len(info.conversionDiagnostics) >= maxConversionDiagnostics {
if !info.conversionDiagnosticsTruncated {
info.conversionDiagnosticsTruncated = true
logger.LogWarn(ctx, fmt.Sprintf("conversion diagnostics truncated after %d distinct entries", maxConversionDiagnostics))
}
continue
}
info.conversionDiagnosticKeys[key] = struct{}{}
info.conversionDiagnostics = append(info.conversionDiagnostics, diagnostic)
logger.LogWarn(ctx, fmt.Sprintf(
"conversion diagnostic: code=%q severity=%q from=%q to=%q path=%q message=%q",
diagnostic.Code, diagnostic.Severity, diagnostic.From, diagnostic.To, diagnostic.Path, diagnostic.Message,
))
}
}
func (info *RelayInfo) ConversionDiagnostics() []types.ConversionDiagnostic {
if info == nil || len(info.conversionDiagnostics) == 0 {
return nil
}
return append([]types.ConversionDiagnostic(nil), info.conversionDiagnostics...)
}
func (info *RelayInfo) ConversionDiagnosticsTruncated() bool {
return info != nil && info.conversionDiagnosticsTruncated
}
+76 -5
View File
@@ -10,6 +10,7 @@ import (
"strings"
"github.com/QuantumNous/new-api/common"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/samber/lo"
"github.com/tidwall/gjson"
@@ -224,22 +225,73 @@ func syncReasoningEffortAfterParamOverride(info *RelayInfo, before, after []byte
}
func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (string, bool) {
var paths []string
switch format {
case types.RelayFormatOpenAI:
paths = []string{"reasoning_effort", "reasoning.effort"}
if effort, exists := firstStringValue(data, "reasoning_effort"); exists && effort != "" {
return effort, true
}
if enabled := gjson.GetBytes(data, "reasoning.enabled"); enabled.Exists() {
if enabled.Type != gjson.True && enabled.Type != gjson.False {
return "", true
}
if !enabled.Bool() {
return string(kitreasoning.EffortNone), true
}
if effort, exists := firstStringValue(data, "reasoning.effort"); exists && effort != "" {
return effort, true
}
if budget := gjson.GetBytes(data, "reasoning.max_tokens"); budget.Exists() {
return reasoningEffortFromBudgetValue(budget)
}
return string(kitreasoning.EffortHigh), true
}
if effort, exists := firstStringValue(data, "reasoning.effort"); exists && effort != "" {
return effort, true
}
if budget := gjson.GetBytes(data, "reasoning.max_tokens"); budget.Exists() {
return reasoningEffortFromBudgetValue(budget)
}
return "", false
case types.RelayFormatOpenAIResponses:
paths = []string{"reasoning.effort"}
return firstStringValue(data, "reasoning.effort")
case types.RelayFormatClaude:
paths = []string{"output_config.effort"}
if effort, exists := firstStringValue(data, "output_config.effort"); exists && effort != "" {
return effort, true
}
thinkingType, hasThinkingType := firstStringValue(data, "thinking.type")
if thinkingType == "disabled" {
return string(kitreasoning.EffortNone), true
}
if budget := gjson.GetBytes(data, "thinking.budget_tokens"); budget.Exists() {
return reasoningEffortFromBudgetValue(budget)
}
if thinkingType == "enabled" || thinkingType == "adaptive" {
return string(kitreasoning.EffortHigh), true
}
return "", hasThinkingType
case types.RelayFormatGemini:
paths = []string{
level, hasLevel := firstStringValue(data,
"generationConfig.thinkingConfig.thinkingLevel",
"generation_config.thinking_config.thinking_level",
)
if level != "" {
return level, true
}
for _, path := range []string{
"generationConfig.thinkingConfig.thinkingBudget",
"generation_config.thinking_config.thinking_budget",
} {
if budget := gjson.GetBytes(data, path); budget.Exists() {
return reasoningEffortFromBudgetValue(budget)
}
}
return "", hasLevel
default:
return "", false
}
}
func firstStringValue(data []byte, paths ...string) (string, bool) {
for _, path := range paths {
value := gjson.GetBytes(data, path)
if !value.Exists() {
@@ -253,6 +305,25 @@ func extractReasoningEffortFromJSON(format types.RelayFormat, data []byte) (stri
return "", false
}
func reasoningEffortFromBudgetValue(value gjson.Result) (string, bool) {
if value.Type != gjson.Number {
return "", true
}
budget := value.Float()
switch {
case budget == 0:
return string(kitreasoning.EffortNone), true
case budget < 0:
return string(kitreasoning.EffortHigh), true
case budget <= 1024:
return string(kitreasoning.EffortLow), true
case budget <= 8192:
return string(kitreasoning.EffortMedium), true
default:
return string(kitreasoning.EffortHigh), true
}
}
func shouldEnableParamOverrideAudit(paramOverride map[string]interface{}) bool {
if common.DebugEnabled {
return true
+68 -20
View File
@@ -14,6 +14,7 @@ import (
relayconstant "github.com/QuantumNous/new-api/relay/constant"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/model_setting"
hosttypes "github.com/QuantumNous/new-api/types"
@@ -98,25 +99,34 @@ type RelayInfo struct {
UsePrice bool
RelayMode int
OriginModelName string
RequestURLPath string
RequestHeaders map[string]string
ShouldIncludeUsage bool
DisablePing bool // 是否禁止向下游发送自定义 Ping
ClientWs *websocket.Conn
TargetWs *websocket.Conn
InputAudioFormat string
OutputAudioFormat string
RealtimeTools []dto.RealTimeTool
IsFirstRequest bool
AudioUsage bool
ReasoningEffort string
UserSetting dto.UserSetting
UserEmail string
UserQuota int
RelayFormat types.RelayFormat
SendResponseCount int
ReceivedResponseCount int
FinalPreConsumedQuota int // 最终预消耗的配额
// BillingModelName is the pricing identity for this request. It is kept
// separate from OriginModelName and UpstreamModelName so virtual pricing
// aliases never participate in channel selection or upstream routing.
BillingModelName string
RequestURLPath string
RequestHeaders map[string]string
ShouldIncludeUsage bool
DisablePing bool // 是否禁止向下游发送自定义 Ping
ClientWs *websocket.Conn
TargetWs *websocket.Conn
InputAudioFormat string
OutputAudioFormat string
RealtimeTools []dto.RealTimeTool
IsFirstRequest bool
AudioUsage bool
ReasoningEffort string
// ReasoningConversion is the suffix-derived reasoning intent attached
// after model mapping. Converters read it via ReasoningState().
ReasoningConversion *dto.ReasoningConversionState
UserSetting dto.UserSetting
UserEmail string
UserQuota int
RelayFormat types.RelayFormat
SendResponseCount int
ReceivedResponseCount int
FinalPreConsumedQuota int // 最终预消耗的配额
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
// 必须在提交前锁定全额。
@@ -176,6 +186,10 @@ type RelayInfo struct {
// convOptions caches the converter settings snapshot (see ConvOptions).
convOptions *convmeta.Options
conversionDiagnostics []types.ConversionDiagnostic
conversionDiagnosticKeys map[conversionDiagnosticKey]struct{}
conversionDiagnosticsTruncated bool
ThinkingContentInfo
TokenCountMeta
*ClaudeConvertInfo
@@ -186,6 +200,9 @@ type RelayInfo struct {
}
func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
info.FinalRequestRelayFormat = ""
info.RequestConversionChain = nil
info.InitRequestConversionChain()
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride)
@@ -236,8 +253,10 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
info.convOptions = nil
if model_setting.GetGlobalSettings().PassThroughRequestEnabled || channelMeta.ChannelSetting.PassThroughBodyEnabled {
info.ReasoningEffort = ""
info.ReasoningConversion = nil
} else {
info.ReasoningEffort = reasoningEffortFromRequest(info.Request)
info.ReasoningConversion = nil
}
// reset some fields based on channel meta
@@ -261,6 +280,9 @@ func (info *RelayInfo) ToString() string {
fmt.Fprintf(b, "IsPlayground: %t, ", info.IsPlayground)
fmt.Fprintf(b, "RequestURLPath: %q, ", info.RequestURLPath)
fmt.Fprintf(b, "OriginModelName: %q, ", info.OriginModelName)
if info.BillingModelName != "" && info.BillingModelName != info.OriginModelName {
fmt.Fprintf(b, "BillingModelName: %q, ", info.BillingModelName)
}
fmt.Fprintf(b, "EstimatePromptTokens: %d, ", info.estimatePromptTokens)
fmt.Fprintf(b, "ShouldIncludeUsage: %t, ", info.ShouldIncludeUsage)
fmt.Fprintf(b, "DisablePing: %t, ", info.DisablePing)
@@ -464,7 +486,10 @@ func reasoningEffortFromRequest(request dto.Request) string {
}
case *dto.GeminiChatRequest:
if req != nil && req.GenerationConfig.ThinkingConfig != nil {
effort = req.GenerationConfig.ThinkingConfig.ThinkingLevel
intent, err := kitreasoning.FromGemini(req)
if err == nil {
effort = string(kitreasoning.EffectiveEffort(intent))
}
}
}
return strings.TrimSpace(effort)
@@ -739,6 +764,18 @@ func (info *RelayInfo) GetOriginModelName() string {
return info.OriginModelName
}
// GetBillingModelName returns the effective pricing identity without changing
// either the client-visible model or the model sent to the selected channel.
func (info *RelayInfo) GetBillingModelName() string {
if info == nil {
return ""
}
if info.BillingModelName != "" {
return info.BillingModelName
}
return info.OriginModelName
}
func (info *RelayInfo) GetUpstreamModelName() string {
if info == nil || info.ChannelMeta == nil {
return ""
@@ -780,6 +817,13 @@ func (info *RelayInfo) SetReasoningEffort(effort string) {
info.ReasoningEffort = strings.TrimSpace(effort)
}
func (info *RelayInfo) ReasoningState() *dto.ReasoningConversionState {
if info == nil {
return nil
}
return info.ReasoningConversion
}
func (info *RelayInfo) EnsureClaudeConvertInfo() *convmeta.ClaudeConvertInfo {
if info == nil {
return &convmeta.ClaudeConvertInfo{
@@ -832,8 +876,12 @@ func (info *RelayInfo) ConvOptions() *convmeta.Options {
},
OpenRouterDialect: info != nil && info.GetChannelType() == constant.ChannelTypeOpenRouter,
PreserveThinkingSuffix: model_setting.ShouldPreserveThinkingSuffix,
PreserveEffortTail: model_setting.ShouldPreserveEffortTail,
}
if info != nil {
if info.ChannelMeta != nil {
options.ToolLossPolicy = types.ConversionLossPolicy(info.ChannelOtherSettings.ToolLossPolicy)
}
info.convOptions = options
}
return options
+2
View File
@@ -56,6 +56,7 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
assert.Zero(t, meta.GetChannelType())
assert.False(t, meta.GetIsStream())
assert.Empty(t, meta.GetReasoningEffort())
assert.Nil(t, meta.ReasoningState())
assert.Zero(t, meta.GetEstimatePromptTokens())
assert.Zero(t, meta.GetSendResponseCount())
@@ -81,6 +82,7 @@ func TestRelayInfoMetaTypedNilReceiver(t *testing.T) {
assert.NotNil(t, firstOptions.Gemini.SupportsImagine)
assert.NotNil(t, firstOptions.Gemini.SafetySetting)
assert.NotNil(t, firstOptions.PreserveThinkingSuffix)
assert.NotNil(t, firstOptions.PreserveEffortTail)
}
func TestGenRelayInfoCapturesRequestReasoningEffort(t *testing.T) {
+1 -1
View File
@@ -46,7 +46,7 @@ func (info *RelayInfo) CountBillableToolCall(itemType string, functionName strin
if _, reserved := reservedBillableToolNames[functionName]; reserved {
return
}
if operation_setting.GetToolPriceForModel(functionName, info.OriginModelName) <= 0 {
if operation_setting.GetToolPriceForModel(functionName, info.GetBillingModelName()) <= 0 {
return
}
info.incrementBillableToolCall(functionName)
+5 -2
View File
@@ -43,6 +43,9 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
includeUsage := true
// 判断用户是否需要返回使用情况
@@ -76,7 +79,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
!info.ChannelSetting.PassThroughBodyEnabled &&
service.ShouldChatCompletionsUseResponsesGlobal(info.ChannelId, info.ChannelType, info.OriginModelName) {
applySystemPromptIfNeeded(c, info, request)
usage, newApiErr := chatCompletionsViaResponses(c, info, adaptor, request)
usage, newApiErr := textRequestViaResponses(c, info, adaptor, request)
if newApiErr != nil {
return newApiErr
}
@@ -108,7 +111,7 @@ func TextHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types
} else {
convertedRequest, err := adaptor.ConvertOpenAIRequest(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
+23
View File
@@ -0,0 +1,23 @@
package relay
import (
"errors"
"net/http"
relaycommon "github.com/QuantumNous/new-api/relay/common"
kitreasoning "github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/gin-gonic/gin"
)
func newConvertRequestFailedError(c *gin.Context, info *relaycommon.RelayInfo, err error) *types.NewAPIError {
var loss *types.ConversionLossError
if errors.As(err, &loss) {
info.RecordConversionDiagnostics(c, loss.Diagnostics)
return types.NewErrorWithStatusCode(err, types.ErrorCodeConvertRequestFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
if kitreasoning.IsClientError(err) {
return types.NewErrorWithStatusCode(err, types.ErrorCodeConvertRequestFailed, http.StatusBadRequest, types.ErrOptionWithSkipRetry())
}
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
}
+72
View File
@@ -0,0 +1,72 @@
package relay
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestOptInSafeToolLossRejectedAsBadRequestWithAdminDiagnostics(t *testing.T) {
gin.SetMode(gin.TestMode)
c, _ := gin.CreateTestContext(httptest.NewRecorder())
c.Request = httptest.NewRequest(http.MethodPost, "/v1/chat/completions", nil)
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-4o",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gpt-4o",
ChannelOtherSettings: dto.ChannelOtherSettings{
ToolLossPolicy: string(types.ConversionLossPolicySafe),
},
},
}
tools, err := common.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
require.NoError(t, err)
req := &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{
{Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
},
Tools: tools,
}
result, convErr := service.ConvertRequest(c, info, types.RelayFormatOpenAI, req)
require.Error(t, convErr)
var loss *types.ConversionLossError
require.ErrorAs(t, convErr, &loss)
require.NotEmpty(t, loss.Diagnostics)
require.NotNil(t, result)
apiErr := newConvertRequestFailedError(c, info, convErr)
require.NotNil(t, apiErr)
assert.Equal(t, http.StatusBadRequest, apiErr.StatusCode)
assert.Equal(t, types.ErrorCodeConvertRequestFailed, apiErr.GetErrorCode())
assert.True(t, types.IsSkipRetryError(apiErr))
diagnostics := info.ConversionDiagnostics()
require.NotEmpty(t, diagnostics)
assert.True(t, hasHostDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
other := service.GenerateTextOtherInfo(c, info, 1, 1, 1, 0, 0, 0, 1)
adminInfo, ok := other["admin_info"].(map[string]interface{})
require.True(t, ok)
require.Contains(t, adminInfo, "conversion_diagnostics")
}
func hasHostDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
return true
}
}
return false
}
+6 -50
View File
@@ -12,7 +12,6 @@ import (
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relay/helper"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/service"
"github.com/QuantumNous/new-api/setting/model_setting"
@@ -20,37 +19,6 @@ import (
"github.com/gin-gonic/gin"
)
func isNoThinkingRequest(req *dto.GeminiChatRequest) bool {
if req.GenerationConfig.ThinkingConfig != nil && req.GenerationConfig.ThinkingConfig.ThinkingBudget != nil {
configBudget := req.GenerationConfig.ThinkingConfig.ThinkingBudget
if configBudget != nil && *configBudget == 0 {
// 如果思考预算为 0,则认为是非思考请求
return true
}
}
return false
}
func trimModelThinking(modelName string) string {
// 去除模型名称中的 -nothinking 后缀
if strings.HasSuffix(modelName, "-nothinking") {
return strings.TrimSuffix(modelName, "-nothinking")
}
// 去除模型名称中的 -thinking 后缀
if strings.HasSuffix(modelName, "-thinking") {
return strings.TrimSuffix(modelName, "-thinking")
}
// 去除模型名称中的 -thinking-number
if strings.Contains(modelName, "-thinking-") {
parts := strings.Split(modelName, "-thinking-")
if len(parts) > 1 {
return parts[0] + "-thinking"
}
}
return modelName
}
func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *types.NewAPIError) {
info.InitChannelMeta(c)
@@ -69,23 +37,8 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if model_setting.GetGeminiSettings().ThinkingAdapterEnabled {
if isNoThinkingRequest(request) {
// check is thinking
if !strings.Contains(info.OriginModelName, "-nothinking") {
// try to get no thinking model price
noThinkingModelName := info.OriginModelName + "-nothinking"
containPrice := helper.HasModelBillingConfig(noThinkingModelName)
if containPrice {
info.OriginModelName = noThinkingModelName
info.UpstreamModelName = noThinkingModelName
}
}
}
if request.GenerationConfig.ThinkingConfig == nil {
relayconvert.ApplyGeminiThinkingConfig(request, info)
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
adaptor := GetAdaptor(info.ApiType)
@@ -146,7 +99,7 @@ func GeminiHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *typ
// 使用 ConvertGeminiRequest 转换请求格式
convertedRequest, err := adaptor.ConvertGeminiRequest(c, info, request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)
@@ -245,6 +198,9 @@ func GeminiEmbeddingHandler(c *gin.Context, info *relaycommon.RelayInfo) (newAPI
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, req); err != nil {
return newConvertRequestFailedError(c, info, err)
}
req.SetModelName("models/" + info.UpstreamModelName)
+17 -16
View File
@@ -71,13 +71,14 @@ func HandleGroupRatio(ctx *gin.Context, relayInfo *relaycommon.RelayInfo) hostty
}
func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta) (hosttypes.PriceData, error) {
modelPrice, usePrice := ratio_setting.GetModelPrice(info.OriginModelName, false)
billingModelName := info.GetBillingModelName()
modelPrice, usePrice := ratio_setting.GetModelPrice(billingModelName, false)
groupRatioInfo := HandleGroupRatio(c, info)
// Check if this model uses tiered_expr billing
if billing_setting.GetBillingMode(info.OriginModelName) == billing_setting.BillingModeTieredExpr {
return modelPriceHelperTiered(c, info, promptTokens, meta, groupRatioInfo)
if billing_setting.GetBillingMode(billingModelName) == billing_setting.BillingModeTieredExpr {
return modelPriceHelperTiered(c, info, billingModelName, promptTokens, meta, groupRatioInfo)
}
var preConsumedQuota int
@@ -98,7 +99,7 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
}
var success bool
var matchName string
modelRatio, success, matchName = ratio_setting.GetModelRatio(info.OriginModelName)
modelRatio, success, matchName = ratio_setting.GetModelRatio(billingModelName)
if !success {
acceptUnsetRatio := false
if info.UserSetting.AcceptUnsetRatioModel {
@@ -108,15 +109,15 @@ func ModelPriceHelper(c *gin.Context, info *relaycommon.RelayInfo, promptTokens
return hosttypes.PriceData{}, modelPriceNotConfiguredError(matchName, info.UserId)
}
}
completionRatio = ratio_setting.GetCompletionRatio(info.OriginModelName)
cacheRatio, _ = ratio_setting.GetCacheRatio(info.OriginModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(info.OriginModelName)
completionRatio = ratio_setting.GetCompletionRatio(billingModelName)
cacheRatio, _ = ratio_setting.GetCacheRatio(billingModelName)
cacheCreationRatio, _ = ratio_setting.GetCreateCacheRatio(billingModelName)
cacheCreationRatio5m = cacheCreationRatio
// 固定1h和5min缓存写入价格的比例
cacheCreationRatio1h = cacheCreationRatio * claudeCacheCreation1hMultiplier
imageRatio, _ = ratio_setting.GetImageRatio(info.OriginModelName)
audioRatio = ratio_setting.GetAudioRatio(info.OriginModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(info.OriginModelName)
imageRatio, _ = ratio_setting.GetImageRatio(billingModelName)
audioRatio = ratio_setting.GetAudioRatio(billingModelName)
audioCompletionRatio = ratio_setting.GetAudioCompletionRatio(billingModelName)
ratio := modelRatio * groupRatioInfo.GroupRatio
quota, err := common.QuotaFromFloatStrict(float64(preConsumedTokens) * ratio)
if err != nil {
@@ -266,10 +267,10 @@ func HasModelBillingConfig(modelName string) bool {
return ok && strings.TrimSpace(expr) != ""
}
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
exprStr, ok := billing_setting.GetBillingExpr(info.OriginModelName)
func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, billingModelName string, promptTokens int, meta *types.TokenCountMeta, groupRatioInfo hosttypes.GroupRatioInfo) (hosttypes.PriceData, error) {
exprStr, ok := billing_setting.GetBillingExpr(billingModelName)
if !ok {
return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", info.OriginModelName)
return hosttypes.PriceData{}, fmt.Errorf("model %s is configured as tiered_expr but has no billing expression", billingModelName)
}
estimatedCompletionTokens := meta.MaxTokens
@@ -288,7 +289,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
Len: float64(promptTokens),
}, requestInput)
if err != nil {
return hosttypes.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", info.OriginModelName, err)
return hosttypes.PriceData{}, fmt.Errorf("model %s tiered expr run failed: %w", billingModelName, err)
}
// Expression coefficients are $/1M tokens prices; convert to quota the same way per-call billing does.
@@ -309,7 +310,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
exprHash := billingexpr.ExprHashString(exprStr)
snapshot := &billingexpr.BillingSnapshot{
BillingMode: billing_setting.BillingModeTieredExpr,
ModelName: info.OriginModelName,
ModelName: billingModelName,
ExprString: exprStr,
ExprHash: exprHash,
GroupRatio: groupRatioInfo.GroupRatio,
@@ -330,7 +331,7 @@ func modelPriceHelperTiered(c *gin.Context, info *relaycommon.RelayInfo, promptT
QuotaToPreConsume: preConsumedQuota,
}
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", info.OriginModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
logger.LogDebug(c, "model_price_helper_tiered result: model=%s preConsume=%d quotaBeforeGroup=%.2f groupRatio=%.2f tier=%s", billingModelName, preConsumedQuota, quotaBeforeGroup, groupRatioInfo.GroupRatio, trace.MatchedTier)
info.PriceData = priceData
return priceData, nil
+98
View File
@@ -8,11 +8,15 @@ import (
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/pkg/billingexpr"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/QuantumNous/new-api/setting/billing_setting"
"github.com/QuantumNous/new-api/setting/config"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/QuantumNous/new-api/setting/operation_setting"
"github.com/QuantumNous/new-api/setting/ratio_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -272,3 +276,97 @@ func TestModelPriceHelperRequestBillingRatiosOnlyApplyToFixedPrice(t *testing.T)
require.Equal(t, common.QuotaClampOverflow, clamp.Kind)
require.Nil(t, info.Billing)
}
// Pricing at controller/relay.go runs before ApplyReasoningModelSuffix.
// Identity is GetBillingModelName() → OriginModelName (the suffixed client
// name), matching main's info.OriginModelName lookup. Wildcard entries such
// as gemini-2.5-flash-thinking-* depend on that unstripped origin form.
func TestModelPriceHelperUsesSuffixedOriginLikeMain(t *testing.T) {
gin.SetMode(gin.TestMode)
savedRatios := ratio_setting.ModelRatio2JSONString()
t.Cleanup(func() {
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
})
ratios := ratio_setting.GetModelRatioCopy()
ratios["gemini-2.5-flash"] = 0.15
ratios["gemini-2.5-flash-thinking-*"] = 0.075
ratioJSON, err := common.Marshal(ratios)
require.NoError(t, err)
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
oldSelfUse := operation_setting.SelfUseModeEnabled
operation_setting.SelfUseModeEnabled = true
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set("group", "default")
suffixed := &relaycommon.RelayInfo{
OriginModelName: "gemini-2.5-flash-thinking-8192",
UserGroup: "default",
UsingGroup: "default",
}
suffixedPrice, err := ModelPriceHelper(ctx, suffixed, 1000, &types.TokenCountMeta{})
require.NoError(t, err)
assert.Empty(t, suffixed.BillingModelName)
assert.Equal(t, "gemini-2.5-flash-thinking-8192", suffixed.GetBillingModelName())
assert.Equal(t, 0.075, suffixedPrice.ModelRatio)
base := &relaycommon.RelayInfo{
OriginModelName: "gemini-2.5-flash",
UserGroup: "default",
UsingGroup: "default",
}
basePrice, err := ModelPriceHelper(ctx, base, 1000, &types.TokenCountMeta{})
require.NoError(t, err)
assert.Empty(t, base.BillingModelName)
assert.Equal(t, "gemini-2.5-flash", base.GetBillingModelName())
assert.Equal(t, 0.15, basePrice.ModelRatio)
}
func TestModelPriceHelperNativeGeminiNoThinkingDoesNotAliasBillingModel(t *testing.T) {
gin.SetMode(gin.TestMode)
savedRatios := ratio_setting.ModelRatio2JSONString()
t.Cleanup(func() {
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(savedRatios))
})
ratios := ratio_setting.GetModelRatioCopy()
ratios["gemini-3-pro"] = 1.25
ratioJSON, err := common.Marshal(ratios)
require.NoError(t, err)
require.NoError(t, ratio_setting.UpdateModelRatioByJSONString(string(ratioJSON)))
oldSelfUse := operation_setting.SelfUseModeEnabled
operation_setting.SelfUseModeEnabled = true
t.Cleanup(func() { operation_setting.SelfUseModeEnabled = oldSelfUse })
geminiSettings := model_setting.GetGeminiSettings()
oldThinking := geminiSettings.ThinkingAdapterEnabled
geminiSettings.ThinkingAdapterEnabled = true
t.Cleanup(func() { geminiSettings.ThinkingAdapterEnabled = oldThinking })
budget := 0
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Set("group", "default")
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-3-pro",
UserGroup: "default",
UsingGroup: "default",
Request: &dto.GeminiChatRequest{
GenerationConfig: dto.GeminiChatGenerationConfig{
ThinkingConfig: &dto.GeminiThinkingConfig{
ThinkingBudget: &budget,
},
},
},
}
priceData, err := ModelPriceHelper(ctx, info, 1000, &types.TokenCountMeta{})
require.NoError(t, err)
assert.Empty(t, info.BillingModelName)
assert.Equal(t, "gemini-3-pro", info.GetBillingModelName())
assert.Equal(t, 1.25, priceData.ModelRatio)
assert.NotEqual(t, 37.5, priceData.ModelRatio)
}
+145
View File
@@ -0,0 +1,145 @@
package helper
import (
"strings"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/QuantumNous/new-api/setting/model_setting"
)
// ApplyReasoningModelSuffix parses host-private reasoning suffixes from the
// origin and mapped model names, attaches the resulting intent to RelayInfo,
// and normalizes UpstreamModelName to the unsuffixed base. Optional outbound
// requests are the DeepCopy the handler will send upstream; they must be
// synced here because info.Request is the original, not that copy. Conflict
// between an explicit request field and a suffix is a client error.
func ApplyReasoningModelSuffix(info *relaycommon.RelayInfo, outbound ...dto.Request) error {
if info == nil {
return nil
}
passThrough := model_setting.GetGlobalSettings().PassThroughRequestEnabled
if info.ChannelMeta != nil && info.ChannelSetting.PassThroughBodyEnabled {
passThrough = true
}
if passThrough {
return nil
}
opts := info.ConvOptions()
origin := info.GetOriginModelName()
upstream := ""
if info.ChannelMeta != nil {
upstream = info.UpstreamModelName
}
if opts.ShouldPreserveThinkingSuffix(origin) || opts.ShouldPreserveThinkingSuffix(upstream) {
return nil
}
originBase, originIntent, originFound, err := parseHostModelSuffix(origin, opts)
if err != nil {
return reasoning.AsClientError(err)
}
upstreamBase, upstreamIntent, upstreamFound, err := parseHostModelSuffix(upstream, opts)
if err != nil {
return reasoning.AsClientError(err)
}
suffix := originIntent
if originFound && upstreamFound {
suffix, err = reasoning.MergeExplicitAndSuffix(originIntent, upstreamIntent, origin)
if err != nil {
return reasoning.AsClientError(err)
}
} else if upstreamFound {
suffix = upstreamIntent
}
explicit, err := explicitIntentFromRequest(info.Request)
if err != nil {
return reasoning.AsClientError(err)
}
conflictModel := upstream
if conflictModel == "" {
conflictModel = origin
}
if _, err = reasoning.MergeExplicitAndSuffix(explicit, suffix, conflictModel); err != nil {
return reasoning.AsClientError(err)
}
if !suffix.IsEmpty() {
info.ReasoningConversion = reasoning.StateFromIntent(suffix)
}
if upstreamFound && info.ChannelMeta != nil {
info.UpstreamModelName = upstreamBase
} else if !info.IsModelMapped && originFound && info.ChannelMeta != nil {
info.UpstreamModelName = originBase
}
// Handlers DeepCopy before this helper; info.Request is the original.
// Sync every outbound copy the caller is about to send upstream.
for _, outbound := range outbound {
if outbound != nil {
outbound.SetModelName(info.UpstreamModelName)
}
}
if info.Request != nil {
info.Request.SetModelName(info.UpstreamModelName)
}
return nil
}
func parseHostModelSuffix(name string, opts *convmeta.Options) (string, reasoning.Intent, bool, error) {
if name == "" {
return name, reasoning.Intent{}, false, nil
}
if strings.HasPrefix(name, "claude-") {
return reasoning.ParseClaudeModelSuffix(name, opts.Claude.ThinkingAdapterEnabled)
}
if strings.HasPrefix(name, "gemini-") {
if !opts.Gemini.ThinkingAdapterEnabled {
return name, reasoning.Intent{}, false, nil
}
return reasoning.ParseGeminiModelSuffix(name, true)
}
// deepseek-v4 effort tails are consumed by ParseDeepSeekV4ThinkingSuffix
// in the DeepSeek adaptor; stripping them here drops THINKING+effort.
if strings.HasPrefix(name, "deepseek-v4-") {
return name, reasoning.Intent{}, false, nil
}
effort, base := reasoning.ParseOpenAIReasoningEffortFromModelSuffix(name, opts.PreserveEffortTail)
if effort != "" {
parsed, err := reasoning.ParseEffort(effort)
if err != nil {
return name, reasoning.Intent{}, false, err
}
mode := reasoning.ModeEnabled
if parsed == reasoning.EffortNone {
mode = reasoning.ModeDisabled
}
return base, reasoning.Intent{Mode: mode, Effort: parsed, Source: reasoning.SourceSuffix}, true, nil
}
// Generic -thinking trim is OpenRouter-only. Volcengine/DeepSeek adaptors
// read the suffix off UpstreamModelName themselves.
if opts != nil && opts.OpenRouterDialect && strings.HasSuffix(name, "-thinking") {
return strings.TrimSuffix(name, "-thinking"), reasoning.Intent{Mode: reasoning.ModeEnabled, Source: reasoning.SourceSuffix}, true, nil
}
return name, reasoning.Intent{}, false, nil
}
func explicitIntentFromRequest(req dto.Request) (reasoning.Intent, error) {
switch r := req.(type) {
case *dto.ClaudeRequest:
return reasoning.FromClaude(r)
case *dto.GeminiChatRequest:
return reasoning.FromGemini(r)
case *dto.GeneralOpenAIRequest:
return reasoning.FromOpenAIChat(r)
case *dto.OpenAIResponsesRequest:
return reasoning.FromOpenAIResponses(r)
default:
return reasoning.Intent{}, nil
}
}
+218
View File
@@ -0,0 +1,218 @@
package helper
import (
"net/http/httptest"
"testing"
"github.com/QuantumNous/new-api/common"
"github.com/QuantumNous/new-api/constant"
relaycommon "github.com/QuantumNous/new-api/relay/common"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/setting/model_setting"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestApplyReasoningModelSuffixTrimsUpstreamAndAttachesState(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
}
func TestApplyReasoningModelSuffixRetryKeepsEquivalentState(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "claude-opus-4-8-high",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-opus-4-8-high",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
require.NotNil(t, info.ReasoningConversion)
firstMode := info.ReasoningConversion.Mode
firstEffort := info.ReasoningConversion.Effort
info.UpstreamModelName = info.OriginModelName
require.NoError(t, ApplyReasoningModelSuffix(info))
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, firstMode, info.ReasoningConversion.Mode)
assert.Equal(t, firstEffort, info.ReasoningConversion.Effort)
assert.Equal(t, "claude-opus-4-8", info.UpstreamModelName)
}
func TestApplyReasoningModelSuffixRetryClearsStateWhenNewChannelHasNoSuffix(t *testing.T) {
gin.SetMode(gin.TestMode)
req := &dto.ClaudeRequest{Model: "claude-3-7-sonnet"}
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet",
Request: req,
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
IsModelMapped: true,
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
require.NotNil(t, info.ReasoningState())
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
ctx.Request = httptest.NewRequest("POST", "/v1/messages", nil)
common.SetContextKey(ctx, constant.ContextKeyOriginalModel, "claude-3-7-sonnet")
common.SetContextKey(ctx, constant.ContextKeyChannelType, constant.ChannelTypeAnthropic)
info.InitChannelMeta(ctx)
assert.Nil(t, info.ReasoningState())
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Nil(t, info.ReasoningState())
}
func TestApplyReasoningModelSuffixPassThroughDoesNotTrim(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := settings.PassThroughRequestEnabled
t.Cleanup(func() { settings.PassThroughRequestEnabled = original })
settings.PassThroughRequestEnabled = true
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixBlacklistDoesNotTrim(t *testing.T) {
settings := model_setting.GetGlobalSettings()
original := append([]string(nil), settings.ThinkingModelBlacklist...)
t.Cleanup(func() { settings.ThinkingModelBlacklist = original })
settings.ThinkingModelBlacklist = append(settings.ThinkingModelBlacklist, "claude-3-7-sonnet-thinking")
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "claude-3-7-sonnet-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixRejectsExplicitSuffixConflict(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "claude-3-7-sonnet-thinking",
Request: &dto.ClaudeRequest{
Model: "claude-3-7-sonnet-thinking",
Thinking: &dto.Thinking{Type: "disabled"},
},
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "claude-3-7-sonnet-thinking",
},
}
err := ApplyReasoningModelSuffix(info)
require.Error(t, err)
}
func TestApplyReasoningModelSuffixGeminiNoThinkingWhenAdapterEnabled(t *testing.T) {
settings := model_setting.GetGeminiSettings()
original := settings.ThinkingAdapterEnabled
t.Cleanup(func() { settings.ThinkingAdapterEnabled = original })
settings.ThinkingAdapterEnabled = true
info := &relaycommon.RelayInfo{
OriginModelName: "gemini-2.5-flash-nothinking",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "gemini-2.5-flash-nothinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "gemini-2.5-flash", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "disabled", info.ReasoningConversion.Mode)
assert.Equal(t, "none", info.ReasoningConversion.Effort)
}
func TestApplyReasoningModelSuffixPreservesEffortTailModelID(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "qwen-max",
ChannelMeta: &relaycommon.ChannelMeta{
UpstreamModelName: "qwen-max",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "qwen-max", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixLeavesDeepSeekV4SuffixForAdaptor(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "deepseek-v4-chat-max",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeDeepSeek,
UpstreamModelName: "deepseek-v4-chat-max",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "deepseek-v4-chat-max", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixLeavesVolcengineDeepSeekThinkingForAdaptor(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "deepseek-r1-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeVolcEngine,
UpstreamModelName: "deepseek-r1-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "deepseek-r1-thinking", info.UpstreamModelName)
assert.Nil(t, info.ReasoningConversion)
}
func TestApplyReasoningModelSuffixStillParsesOpenAIEffortTail(t *testing.T) {
info := &relaycommon.RelayInfo{
OriginModelName: "gpt-5.1-high",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenAI,
UpstreamModelName: "gpt-5.1-high",
},
}
require.NoError(t, ApplyReasoningModelSuffix(info))
assert.Equal(t, "gpt-5.1", info.UpstreamModelName)
require.NotNil(t, info.ReasoningConversion)
assert.Equal(t, "enabled", info.ReasoningConversion.Mode)
assert.Equal(t, "high", info.ReasoningConversion.Effort)
}
func TestApplyReasoningModelSuffixTrimsOpenRouterThinkingOnly(t *testing.T) {
openRouter := &relaycommon.RelayInfo{
OriginModelName: "some-model-thinking",
ChannelMeta: &relaycommon.ChannelMeta{
ChannelType: constant.ChannelTypeOpenRouter,
UpstreamModelName: "some-model-thinking",
},
}
require.NoError(t, ApplyReasoningModelSuffix(openRouter))
assert.Equal(t, "some-model", openRouter.UpstreamModelName)
require.NotNil(t, openRouter.ReasoningConversion)
assert.Equal(t, "enabled", openRouter.ReasoningConversion.Mode)
}
+4 -1
View File
@@ -70,6 +70,9 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
if err != nil {
return types.NewError(err, types.ErrorCodeChannelModelMappedError, types.ErrOptionWithSkipRetry())
}
if err = helper.ApplyReasoningModelSuffix(info, request); err != nil {
return newConvertRequestFailedError(c, info, err)
}
adaptor := GetAdaptor(info.ApiType)
if adaptor == nil {
@@ -86,7 +89,7 @@ func ResponsesHelper(c *gin.Context, info *relaycommon.RelayInfo) (newAPIError *
} else {
convertedRequest, err := adaptor.ConvertOpenAIResponsesRequest(c, info, *request)
if err != nil {
return types.NewError(err, types.ErrorCodeConvertRequestFailed, types.ErrOptionWithSkipRetry())
return newConvertRequestFailedError(c, info, err)
}
relaycommon.AppendRequestConversionFromRequest(info, convertedRequest)
jsonData, err := common.Marshal(convertedRequest)
+3
View File
@@ -199,6 +199,9 @@ meta := &convmeta.Values{
- OpenAI Chat 或 OpenAI Responses 转 Claude 时,Claude 请求必须具有 `max_tokens`。源请求未提供时,需要配置 `Claude.DefaultMaxTokens`,否则转换会返回错误。
- RelayKit 不负责选择渠道或映射模型名。调用转换前,应将请求中的 `Model` 设置为目标上游使用的模型名。
- 自定义 `convmeta.Meta` 的指针实现必须保证所有方法对 nil receiver 安全,完整约束见 `convmeta.Meta` 的接口注释。
- 工具损耗策略默认是 `allow`:跨协议转换会成功,损耗以诊断形式返回。`safe` / `strict` 只在请求阶段 opt-in 拒绝;响应和流式转换无论策略如何都不会因损耗失败。
- `ThinkingAdapterEnabled` 只控制是否把已解析的推理意图渲染到 Claude / Gemini 请求上。`-thinking` / `-nothinking` / effort 尾缀等命名约定不再由转换器自动解释。
- 若你的入口仍使用这些模型名后缀,请在调用转换前自行调用 `relayconvert/reasoning``Parse*` 帮助函数,把结果写成 `dto.ReasoningConversionState`,并通过 `convmeta.Meta.ReasoningState()``convmeta.Values.ReasoningConversion`)传入。同时把发给上游的模型名裁成无后缀基础名。
## 多模态内容
+252 -6
View File
@@ -1,5 +1,7 @@
package dto
import "strings"
const (
BillingUsageSourceClaudeMessages = "claude_messages"
BillingUsageSourceGeminiChat = "gemini_chat"
@@ -100,7 +102,15 @@ func HasOpenAIUsageTokens(usage *Usage) bool {
usage.CompletionTokenDetails.AudioTokens != 0 {
return true
}
return usage.InputTokensDetails != nil
if usage.InputTokensDetails == nil {
return false
}
return usage.InputTokensDetails.CachedTokens != 0 ||
usage.InputTokensDetails.CachedCreationTokens != 0 ||
usage.InputTokensDetails.CacheWriteTokens != 0 ||
usage.InputTokensDetails.TextTokens != 0 ||
usage.InputTokensDetails.ImageTokens != 0 ||
usage.InputTokensDetails.AudioTokens != 0
}
func NewGeminiChatBillingUsage(metadata *GeminiUsageMetadata) *BillingUsage {
@@ -111,15 +121,92 @@ func NewEstimatedGeminiChatBillingUsage(usage *Usage) *BillingUsage {
if usage == nil {
return nil
}
reasoningTokens := usage.CompletionTokenDetails.ReasoningTokens
candidateTokens := usage.CompletionTokens - reasoningTokens
if candidateTokens < 0 {
candidateTokens = 0
}
totalTokens := usage.TotalTokens
if totalTokens == 0 {
totalTokens = usage.PromptTokens + usage.CompletionTokens
}
return newGeminiChatBillingUsage(&GeminiUsageMetadata{
PromptTokenCount: usage.PromptTokens,
CandidatesTokenCount: usage.CompletionTokens,
TotalTokenCount: totalTokens,
}, true)
metadata := &GeminiUsageMetadata{
PromptTokenCount: usage.PromptTokens,
CandidatesTokenCount: candidateTokens,
TotalTokenCount: totalTokens,
ThoughtsTokenCount: reasoningTokens,
CachedContentTokenCount: usage.PromptTokensDetails.CachedTokens,
}
for _, detail := range []GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: usage.PromptTokensDetails.TextTokens},
{Modality: "IMAGE", TokenCount: usage.PromptTokensDetails.ImageTokens},
{Modality: "AUDIO", TokenCount: usage.PromptTokensDetails.AudioTokens},
} {
if detail.TokenCount != 0 {
metadata.PromptTokensDetails = append(metadata.PromptTokensDetails, detail)
}
}
for _, detail := range []GeminiPromptTokensDetails{
{Modality: "TEXT", TokenCount: usage.CompletionTokenDetails.TextTokens},
{Modality: "IMAGE", TokenCount: usage.CompletionTokenDetails.ImageTokens},
{Modality: "AUDIO", TokenCount: usage.CompletionTokenDetails.AudioTokens},
} {
if detail.TokenCount != 0 {
metadata.CandidatesTokensDetails = append(metadata.CandidatesTokensDetails, detail)
}
}
return newGeminiChatBillingUsage(metadata, true)
}
// CloneBillingUsageWithEstimatedCompletion preserves the original upstream
// billing dialect and fills a missing completion count without rebuilding the
// payload from a converted, potentially lossy Usage value.
func CloneBillingUsageWithEstimatedCompletion(usage *BillingUsage, completionTokens int) *BillingUsage {
clone := CloneBillingUsage(usage)
if clone == nil || completionTokens <= 0 {
return clone
}
updated := false
switch {
case clone.OpenAIUsage != nil:
openAIUsage := clone.OpenAIUsage
if openAIUsage.CompletionTokens == 0 && openAIUsage.OutputTokens == 0 {
openAIUsage.CompletionTokens = completionTokens
openAIUsage.OutputTokens = completionTokens
inputTokens := openAIUsage.PromptTokens
if inputTokens == 0 {
inputTokens = openAIUsage.InputTokens
}
if totalTokens := inputTokens + completionTokens; openAIUsage.TotalTokens < totalTokens {
openAIUsage.TotalTokens = totalTokens
}
updated = true
}
case clone.ClaudeUsage != nil:
if clone.ClaudeUsage.OutputTokens == 0 {
clone.ClaudeUsage.OutputTokens = completionTokens
updated = true
}
case clone.GeminiUsageMetadata != nil:
metadata := clone.GeminiUsageMetadata
if metadata.CandidatesTokenCount == 0 {
candidateTokens := completionTokens - metadata.ThoughtsTokenCount
if candidateTokens < 0 {
candidateTokens = 0
}
metadata.CandidatesTokenCount = candidateTokens
totalTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount + metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount
if metadata.TotalTokenCount < totalTokens {
metadata.TotalTokenCount = totalTokens
}
updated = true
}
}
if updated {
clone.Estimated = true
}
return clone
}
func newGeminiChatBillingUsage(metadata *GeminiUsageMetadata, estimated bool) *BillingUsage {
@@ -149,6 +236,165 @@ func CloneBillingUsage(usage *BillingUsage) *BillingUsage {
return &clone
}
// CanonicalUsage decodes the original provider usage carried across relay
// hops into the shared accounting shape. The BillingUsage snapshot remains the
// source of truth and is cloned onto the returned value for further relays.
func (usage *BillingUsage) CanonicalUsage() (*Usage, bool) {
if usage == nil {
return nil, false
}
source := strings.TrimSpace(usage.Source)
semantic := strings.TrimSpace(usage.Semantic)
// A structurally recognized but all-zero payload must not become the
// settlement source of truth; rejecting it lets settlement fall back to a
// non-zero top-level usage.
if HasOpenAIUsageTokens(usage.OpenAIUsage) &&
(strings.EqualFold(source, BillingUsageSourceOAIChat) ||
strings.EqualFold(source, BillingUsageSourceOAIResponses) ||
strings.EqualFold(semantic, BillingUsageSemanticOpenAI)) {
return usage.canonicalOpenAIUsage(), true
}
if HasClaudeUsageTokens(usage.ClaudeUsage) &&
(strings.EqualFold(source, BillingUsageSourceClaudeMessages) ||
strings.EqualFold(semantic, BillingUsageSemanticAnthropic)) {
return usage.canonicalClaudeUsage(), true
}
if HasGeminiUsageMetadataTokens(usage.GeminiUsageMetadata) &&
(strings.EqualFold(source, BillingUsageSourceGeminiChat) ||
strings.EqualFold(semantic, BillingUsageSemanticGemini)) {
return usage.canonicalGeminiUsage(), true
}
return nil, false
}
func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
canonical := cloneOpenAIUsage(usage.OpenAIUsage)
if inputDetails := canonical.InputTokensDetails; inputDetails != nil {
if canonical.PromptTokensDetails.CachedTokens == 0 && inputDetails.CachedTokens > 0 {
canonical.PromptTokensDetails.CachedTokens = inputDetails.CachedTokens
}
if canonical.PromptTokensDetails.CachedCreationTokens == 0 && inputDetails.CachedCreationTokens > 0 {
canonical.PromptTokensDetails.CachedCreationTokens = inputDetails.CachedCreationTokens
}
if canonical.PromptTokensDetails.CacheWriteTokens == 0 && inputDetails.CacheWriteTokens > 0 {
canonical.PromptTokensDetails.CacheWriteTokens = inputDetails.CacheWriteTokens
}
if canonical.PromptTokensDetails.TextTokens == 0 && inputDetails.TextTokens > 0 {
canonical.PromptTokensDetails.TextTokens = inputDetails.TextTokens
}
if canonical.PromptTokensDetails.ImageTokens == 0 && inputDetails.ImageTokens > 0 {
canonical.PromptTokensDetails.ImageTokens = inputDetails.ImageTokens
}
if canonical.PromptTokensDetails.AudioTokens == 0 && inputDetails.AudioTokens > 0 {
canonical.PromptTokensDetails.AudioTokens = inputDetails.AudioTokens
}
}
if canonical.PromptTokensDetails.CachedTokens == 0 && canonical.PromptCacheHitTokens > 0 {
canonical.PromptTokensDetails.CachedTokens = canonical.PromptCacheHitTokens
}
if canonical.PromptTokens == 0 && canonical.InputTokens > 0 {
canonical.PromptTokens = canonical.InputTokens
}
if canonical.CompletionTokens == 0 && canonical.OutputTokens > 0 {
canonical.CompletionTokens = canonical.OutputTokens
}
if canonical.InputTokens == 0 && canonical.PromptTokens > 0 {
canonical.InputTokens = canonical.PromptTokens
}
if canonical.OutputTokens == 0 && canonical.CompletionTokens > 0 {
canonical.OutputTokens = canonical.CompletionTokens
}
if canonical.TotalTokens == 0 {
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
}
canonical.UsageSemantic = BillingUsageSemanticOpenAI
canonical.UsageSource = usage.Source
canonical.BillingUsage = CloneBillingUsage(usage)
return canonical
}
func (usage *BillingUsage) canonicalClaudeUsage() *Usage {
claudeUsage := usage.ClaudeUsage
cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
if cacheCreation5m == 0 {
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
}
cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
if cacheCreation1h == 0 {
cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
}
canonical := &Usage{
PromptTokens: claudeUsage.InputTokens,
CompletionTokens: claudeUsage.OutputTokens,
TotalTokens: claudeUsage.InputTokens + claudeUsage.OutputTokens,
InputTokens: claudeUsage.InputTokens + claudeUsage.CacheReadInputTokens + claudeUsage.CacheCreationInputTokens,
OutputTokens: claudeUsage.OutputTokens,
UsageSemantic: BillingUsageSemanticAnthropic,
UsageSource: BillingUsageSourceClaudeMessages,
BillingUsage: CloneBillingUsage(usage),
ClaudeCacheCreation5mTokens: cacheCreation5m,
ClaudeCacheCreation1hTokens: cacheCreation1h,
}
canonical.PromptTokensDetails.CachedTokens = claudeUsage.CacheReadInputTokens
canonical.PromptTokensDetails.CachedCreationTokens = claudeUsage.CacheCreationInputTokens
return canonical
}
func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
metadata := usage.GeminiUsageMetadata
promptTokens := metadata.PromptTokenCount + metadata.ToolUsePromptTokenCount
canonical := &Usage{
PromptTokens: promptTokens,
CompletionTokens: metadata.CandidatesTokenCount + metadata.ThoughtsTokenCount,
TotalTokens: metadata.TotalTokenCount,
UsageSemantic: BillingUsageSemanticGemini,
UsageSource: BillingUsageSourceGeminiChat,
BillingUsage: CloneBillingUsage(usage),
}
canonical.CompletionTokenDetails.ReasoningTokens = metadata.ThoughtsTokenCount
canonical.PromptTokensDetails.CachedTokens = metadata.CachedContentTokenCount
for _, detail := range metadata.PromptTokensDetails {
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
}
for _, detail := range metadata.ToolUsePromptTokensDetails {
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
}
for _, detail := range metadata.CandidatesTokensDetails {
switch detail.Modality {
case "IMAGE":
canonical.CompletionTokenDetails.ImageTokens += detail.TokenCount
case "AUDIO":
canonical.CompletionTokenDetails.AudioTokens += detail.TokenCount
case "TEXT":
canonical.CompletionTokenDetails.TextTokens += detail.TokenCount
}
}
if canonical.TotalTokens == 0 {
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
} else if canonical.CompletionTokens <= 0 {
canonical.CompletionTokens = canonical.TotalTokens - canonical.PromptTokens
}
if canonical.PromptTokens > 0 && canonical.PromptTokensDetails.TextTokens == 0 && canonical.PromptTokensDetails.AudioTokens == 0 {
canonical.PromptTokensDetails.TextTokens = canonical.PromptTokens
}
return canonical
}
func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
switch detail.Modality {
case "AUDIO":
details.AudioTokens += detail.TokenCount
case "IMAGE":
details.ImageTokens += detail.TokenCount
case "TEXT":
details.TextTokens += detail.TokenCount
}
}
func cloneOpenAIUsage(usage *Usage) *Usage {
if usage == nil {
return nil
+18
View File
@@ -86,6 +86,10 @@ type ChannelOtherSettings struct {
UpstreamModelUpdateLastRemovedModels []string `json:"upstream_model_update_last_removed_models,omitempty"` // 上次检测到的可删除模型
UpstreamModelUpdateIgnoredModels []string `json:"upstream_model_update_ignored_models,omitempty"` // 手动忽略的模型
AdvancedCustom *AdvancedCustomConfig `json:"advanced_custom,omitempty"`
// ToolLossPolicy is a channel-level opt-in for request-phase conversion
// rejection. Empty follows the default allow policy. Accepted values:
// "", "allow", "safe", "strict".
ToolLossPolicy string `json:"tool_loss_policy,omitempty"`
}
func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
@@ -95,6 +99,20 @@ func (s *ChannelOtherSettings) IsOpenRouterEnterprise() bool {
return *s.OpenRouterEnterprise
}
// ValidateToolLossPolicy validates the channel-level request-phase tool-loss
// policy. Empty keeps the default allow policy.
func (s *ChannelOtherSettings) ValidateToolLossPolicy() error {
if s == nil {
return nil
}
switch strings.TrimSpace(s.ToolLossPolicy) {
case "", string(types.ConversionLossPolicyAllow), string(types.ConversionLossPolicySafe), string(types.ConversionLossPolicyStrict):
return nil
default:
return fmt.Errorf("invalid tool_loss_policy: %s", s.ToolLossPolicy)
}
}
const (
advancedCustomConverterNone = "none"
advancedCustomConverterClaudeMessagesToOpenAIChat = "anthropic_messages_to_openai_chat_completions"
+12
View File
@@ -642,3 +642,15 @@ func TestChannelSettingsValidateHTTPTransport(t *testing.T) {
require.Error(t, err)
assert.Contains(t, err.Error(), "http2_connection_shards")
}
func TestChannelOtherSettingsValidateToolLossPolicy(t *testing.T) {
require.NoError(t, (*ChannelOtherSettings)(nil).ValidateToolLossPolicy())
require.NoError(t, (&ChannelOtherSettings{}).ValidateToolLossPolicy())
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "allow"}).ValidateToolLossPolicy())
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "safe"}).ValidateToolLossPolicy())
require.NoError(t, (&ChannelOtherSettings{ToolLossPolicy: "strict"}).ValidateToolLossPolicy())
err := (&ChannelOtherSettings{ToolLossPolicy: "drop"}).ValidateToolLossPolicy()
require.Error(t, err)
assert.Contains(t, err.Error(), "tool_loss_policy")
}
+29 -7
View File
@@ -24,10 +24,24 @@ type ClaudeMediaMessage struct {
PartialJson *string `json:"partial_json,omitempty"`
Role string `json:"role,omitempty"`
Thinking *string `json:"thinking,omitempty"`
Data string `json:"data,omitempty"`
Signature string `json:"signature,omitempty"`
Delta string `json:"delta,omitempty"`
CacheControl json.RawMessage `json:"cache_control,omitempty"`
// tool_calls
// Text blocks and citations_delta events.
Citations json.RawMessage `json:"citations,omitempty"`
Citation json.RawMessage `json:"citation,omitempty"`
// Server-tool and tool-result blocks.
Caller json.RawMessage `json:"caller,omitempty"`
ServerName string `json:"server_name,omitempty"`
IsError *bool `json:"is_error,omitempty"`
// ErrorCode is a relaykit compatibility extension. Claude places provider
// error codes inside nested tool-result error content.
ErrorCode string `json:"error_code,omitempty"`
// Tool-use and tool-result blocks.
Id string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Input any `json:"input,omitempty"`
@@ -173,6 +187,7 @@ type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema map[string]interface{} `json:"input_schema"`
Strict *bool `json:"strict,omitempty"`
}
type InputSchema struct {
@@ -182,10 +197,14 @@ type InputSchema struct {
}
type ClaudeWebSearchTool struct {
Type string `json:"type"`
Name string `json:"name"`
MaxUses int `json:"max_uses,omitempty"`
UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
Type string `json:"type"`
Name string `json:"name"`
MaxUses int `json:"max_uses,omitempty"`
AllowedDomains []string `json:"allowed_domains,omitempty"`
BlockedDomains []string `json:"blocked_domains,omitempty"`
AllowedCallers []string `json:"allowed_callers,omitempty"`
ResponseInclusion string `json:"response_inclusion,omitempty"`
UserLocation *ClaudeWebSearchUserLocation `json:"user_location,omitempty"`
}
type ClaudeWebSearchUserLocation struct {
@@ -413,7 +432,7 @@ func (c *ClaudeRequest) GetTools() []any {
func (c *ClaudeRequest) GetEfforts() string {
var OutputConfig OutputConfigForEffort
if err := json.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
if err := kitutil.Unmarshal(c.OutputConfig, &OutputConfig); err == nil {
effort := OutputConfig.Effort
return effort
}
@@ -596,5 +615,8 @@ func (u *ClaudeUsage) GetCacheCreationTotalTokens() int {
}
type ClaudeServerToolUse struct {
WebSearchRequests int `json:"web_search_requests"`
WebSearchRequests int `json:"web_search_requests,omitempty"`
WebFetchRequests int `json:"web_fetch_requests,omitempty"`
CodeExecutionRequests int `json:"code_execution_requests,omitempty"`
ToolSearchRequests int `json:"tool_search_requests,omitempty"`
}
+39 -13
View File
@@ -48,8 +48,9 @@ type ToolConfig struct {
}
type FunctionCallingConfig struct {
Mode FunctionCallingConfigMode `json:"mode,omitempty"`
AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
Mode FunctionCallingConfigMode `json:"mode,omitempty"`
AllowedFunctionNames []string `json:"allowedFunctionNames,omitempty"`
StreamFunctionCallArguments *bool `json:"streamFunctionCallArguments,omitempty"`
}
type FunctionCallingConfigMode string
@@ -161,8 +162,8 @@ func (r *GeminiChatRequest) SetTools(tools []GeminiChatTool) {
}
type GeminiThinkingConfig struct {
IncludeThoughts bool `json:"includeThoughts,omitempty"`
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
IncludeThoughts *bool `json:"includeThoughts,omitempty"`
ThinkingBudget *int `json:"thinkingBudget,omitempty"`
// TODO Conflict with thinkingbudget.
ThinkingLevel string `json:"thinkingLevel,omitempty"`
}
@@ -184,7 +185,7 @@ func (c *GeminiThinkingConfig) UnmarshalJSON(data []byte) error {
*c = GeminiThinkingConfig(aux.Alias)
if aux.IncludeThoughtsSnake != nil {
c.IncludeThoughts = *aux.IncludeThoughtsSnake
c.IncludeThoughts = aux.IncludeThoughtsSnake
}
if aux.ThinkingBudgetSnake != nil {
@@ -239,8 +240,21 @@ func (g *GeminiInlineData) UnmarshalJSON(data []byte) error {
}
type FunctionCall struct {
FunctionName string `json:"name"`
Arguments any `json:"args"`
// ID is optional in the Gemini protocol and identifies the matching function response.
ID string `json:"id,omitempty"`
FunctionName string `json:"name"`
Arguments any `json:"args"`
PartialArgs []GeminiPartialArg `json:"partialArgs,omitempty"`
WillContinue *bool `json:"willContinue,omitempty"`
}
type GeminiPartialArg struct {
JSONPath string `json:"jsonPath"`
NumberValue *float64 `json:"numberValue,omitempty"`
StringValue *string `json:"stringValue,omitempty"`
BoolValue *bool `json:"boolValue,omitempty"`
NullValue json.RawMessage `json:"nullValue,omitempty"`
WillContinue *bool `json:"willContinue,omitempty"`
}
type GeminiFunctionResponse struct {
@@ -320,11 +334,16 @@ type GeminiChatSafetySettings struct {
}
type GeminiChatTool struct {
GoogleSearch any `json:"googleSearch,omitempty"`
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
CodeExecution any `json:"codeExecution,omitempty"`
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
URLContext any `json:"urlContext,omitempty"`
GoogleSearch any `json:"googleSearch,omitempty"`
GoogleSearchRetrieval any `json:"googleSearchRetrieval,omitempty"`
GoogleMaps json.RawMessage `json:"googleMaps,omitempty"`
EnterpriseWebSearch json.RawMessage `json:"enterpriseWebSearch,omitempty"`
CodeExecution any `json:"codeExecution,omitempty"`
FunctionDeclarations any `json:"functionDeclarations,omitempty"`
URLContext any `json:"urlContext,omitempty"`
FileSearch json.RawMessage `json:"fileSearch,omitempty"`
ComputerUse json.RawMessage `json:"computerUse,omitempty"`
Retrieval json.RawMessage `json:"retrieval,omitempty"`
}
type GeminiChatGenerationConfig struct {
@@ -447,7 +466,14 @@ type GeminiChatCandidate struct {
}
type GeminiGroundingMetadata struct {
WebSearchQueries []string `json:"webSearchQueries,omitempty"`
WebSearchQueries []string `json:"webSearchQueries,omitempty"`
RetrievalQueries []string `json:"retrievalQueries,omitempty"`
GroundingChunks json.RawMessage `json:"groundingChunks,omitempty"`
GroundingSupports json.RawMessage `json:"groundingSupports,omitempty"`
SearchEntryPoint json.RawMessage `json:"searchEntryPoint,omitempty"`
RetrievalMetadata json.RawMessage `json:"retrievalMetadata,omitempty"`
SourceFlaggingUris json.RawMessage `json:"sourceFlaggingUris,omitempty"`
GoogleMapsWidgetContextToken string `json:"googleMapsWidgetContextToken,omitempty"`
}
type GeminiChatSafetyRating struct {
+25 -10
View File
@@ -81,7 +81,7 @@ type GeneralOpenAIRequest struct {
ExtraBody json.RawMessage `json:"extra_body,omitempty"`
//xai
SearchParameters json.RawMessage `json:"search_parameters,omitempty"`
// claude
// OpenAI Chat web search.
WebSearchOptions *WebSearchOptions `json:"web_search_options,omitempty"`
// OpenRouter Params
Usage json.RawMessage `json:"usage,omitempty"`
@@ -108,6 +108,9 @@ type GeneralOpenAIRequest struct {
ReasoningSplit json.RawMessage `json:"reasoning_split,omitempty"`
// vLLM
ThinkingTokenBudget json.RawMessage `json:"thinking_token_budget,omitempty"`
// Internal conversion state; never serialized to an upstream protocol.
ReasoningConversion *ReasoningConversionState `json:"-"`
}
func (r GeneralOpenAIRequest) MarshalJSON() ([]byte, error) {
@@ -266,6 +269,7 @@ type FunctionRequest struct {
Name string `json:"name"`
Parameters any `json:"parameters,omitempty"`
Arguments string `json:"arguments,omitempty"`
Strict *bool `json:"strict,omitempty"`
}
type StreamOptions struct {
@@ -311,7 +315,10 @@ type Message struct {
Reasoning *string `json:"reasoning,omitempty"`
ToolCalls json.RawMessage `json:"tool_calls,omitempty"`
ToolCallId string `json:"tool_call_id,omitempty"`
parsedContent []MediaContent
// Annotations is an official Chat response field. Keeping it on the shared
// message type also preserves annotations when clients replay assistant output.
Annotations json.RawMessage `json:"annotations,omitempty"`
parsedContent []MediaContent
//parsedStringContent *string
}
@@ -485,14 +492,14 @@ func (m *Message) ParseToolCalls() []ToolCallRequest {
return nil
}
var toolCalls []ToolCallRequest
if err := json.Unmarshal(m.ToolCalls, &toolCalls); err == nil {
if err := kitutil.Unmarshal(m.ToolCalls, &toolCalls); err == nil {
return toolCalls
}
return toolCalls
}
func (m *Message) SetToolCalls(toolCalls any) {
toolCallsJson, _ := json.Marshal(toolCalls)
toolCallsJson, _ := kitutil.Marshal(toolCalls)
m.ToolCalls = toolCallsJson
}
@@ -562,6 +569,11 @@ func (m *Message) ParseContent() []MediaContent {
return contentList
}
if content, ok := m.Content.([]MediaContent); ok {
m.parsedContent = content
return content
}
// 尝试解析为数组
//var arrayContent []map[string]interface{}
@@ -682,7 +694,7 @@ func (m *Message) ParseContent() []MediaContent {
}
var stringContent string
if err := json.Unmarshal(m.Content, &stringContent); err == nil {
if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
m.parsedStringContent = &stringContent
return stringContent
}
@@ -707,14 +719,14 @@ func (m *Message) SetNullContent() {
}
func (m *Message) SetStringContent(content string) {
jsonContent, _ := json.Marshal(content)
jsonContent, _ := kitutil.Marshal(content)
m.Content = jsonContent
m.parsedStringContent = &content
m.parsedContent = nil
}
func (m *Message) SetMediaContent(content []MediaContent) {
jsonContent, _ := json.Marshal(content)
jsonContent, _ := kitutil.Marshal(content)
m.Content = jsonContent
m.parsedContent = nil
m.parsedStringContent = nil
@@ -725,7 +737,7 @@ func (m *Message) IsStringContent() bool {
return true
}
var stringContent string
if err := json.Unmarshal(m.Content, &stringContent); err == nil {
if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
m.parsedStringContent = &stringContent
return true
}
@@ -741,7 +753,7 @@ func (m *Message) ParseContent() []MediaContent {
// 先尝试解析为字符串
var stringContent string
if err := json.Unmarshal(m.Content, &stringContent); err == nil {
if err := kitutil.Unmarshal(m.Content, &stringContent); err == nil {
contentList = []MediaContent{{
Type: ContentTypeText,
Text: stringContent,
@@ -752,7 +764,7 @@ func (m *Message) ParseContent() []MediaContent {
// 尝试解析为数组
var arrayContent []map[string]interface{}
if err := json.Unmarshal(m.Content, &arrayContent); err == nil {
if err := kitutil.Unmarshal(m.Content, &arrayContent); err == nil {
for _, contentItem := range arrayContent {
contentType, ok := contentItem["type"].(string)
if !ok {
@@ -907,6 +919,9 @@ type OpenAIResponsesRequest struct {
ThinkingBudget json.RawMessage `json:"thinking_budget,omitempty"`
// perplexity
Preset json.RawMessage `json:"preset,omitempty"`
// Internal conversion state; never serialized to an upstream protocol.
ReasoningConversion *ReasoningConversionState `json:"-"`
}
func (r OpenAIResponsesRequest) MarshalJSON() ([]byte, error) {
+156 -15
View File
@@ -3,6 +3,7 @@ package dto
import (
"encoding/json"
"fmt"
"strings"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
@@ -91,6 +92,10 @@ type ChatCompletionsStreamResponseChoiceDelta struct {
Reasoning *string `json:"reasoning,omitempty"`
Role string `json:"role,omitempty"`
ToolCalls []ToolCallResponse `json:"tool_calls,omitempty"`
// Annotations is an OpenAI-compatible streaming extension supported by
// providers such as OpenRouter. Relaykit uses it to preserve streaming URL
// citations, including Claude round-trip metadata.
Annotations json.RawMessage `json:"annotations,omitempty"`
}
func (c *ChatCompletionsStreamResponseChoiceDelta) SetContentString(s string) {
@@ -325,17 +330,143 @@ type IncompleteDetails struct {
}
type ResponsesOutput struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
Quality string `json:"quality"`
Size string `json:"size"`
Result string `json:"result,omitempty"`
CallId string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status"`
Role string `json:"role"`
Content []ResponsesOutputContent `json:"content"`
Summary []ResponsesReasoningSummaryPart `json:"summary,omitempty"`
Quality string `json:"quality"`
Size string `json:"size"`
Result string `json:"result,omitempty"`
CallId string `json:"call_id,omitempty"`
Name string `json:"name,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Action json.RawMessage `json:"action,omitempty"`
Queries json.RawMessage `json:"queries,omitempty"`
Results json.RawMessage `json:"results,omitempty"`
Sources json.RawMessage `json:"sources,omitempty"`
Code json.RawMessage `json:"code,omitempty"`
Outputs json.RawMessage `json:"outputs,omitempty"`
ContainerID string `json:"container_id,omitempty"`
PendingSafetyChecks json.RawMessage `json:"pending_safety_checks,omitempty"`
Caller json.RawMessage `json:"caller,omitempty"`
ServerLabel string `json:"server_label,omitempty"`
Output json.RawMessage `json:"output,omitempty"`
ItemError json.RawMessage `json:"error,omitempty"`
ApprovalRequestID string `json:"approval_request_id,omitempty"`
MCPTools json.RawMessage `json:"tools,omitempty"`
}
// MarshalJSON keeps hosted-tool variants within their protocol-specific
// schemas. ResponsesOutput also represents messages, images, and function
// calls, whose fields must not leak into web_search_call or mcp_call items.
func (r ResponsesOutput) MarshalJSON() ([]byte, error) {
switch r.Type {
case "web_search_call":
return kitutil.Marshal(struct {
Type string `json:"type"`
ID string `json:"id"`
Status string `json:"status,omitempty"`
Action json.RawMessage `json:"action,omitempty"`
}{Type: r.Type, ID: r.ID, Status: r.Status, Action: r.Action})
case "mcp_call":
return kitutil.Marshal(struct {
Type string `json:"type"`
ID string `json:"id"`
Name string `json:"name"`
ServerLabel string `json:"server_label"`
Arguments json.RawMessage `json:"arguments"`
Status string `json:"status,omitempty"`
Output json.RawMessage `json:"output,omitempty"`
Error json.RawMessage `json:"error,omitempty"`
ApprovalRequestID string `json:"approval_request_id,omitempty"`
}{
Type: r.Type,
ID: r.ID,
Name: r.Name,
ServerLabel: r.ServerLabel,
Arguments: r.Arguments,
Status: r.Status,
Output: r.Output,
Error: r.ItemError,
ApprovalRequestID: r.ApprovalRequestID,
})
default:
type responsesOutputAlias ResponsesOutput
return kitutil.Marshal(responsesOutputAlias(r))
}
}
// NormalizeResponsesWebSearchAction validates and canonicalizes the current
// Responses web_search_call action union. Claude emits {"query": ...}; the
// Responses representation additionally requires a discriminator.
func NormalizeResponsesWebSearchAction(raw json.RawMessage) (json.RawMessage, error) {
var action struct {
Type string `json:"type"`
Query string `json:"query"`
Queries []string `json:"queries"`
Sources json.RawMessage `json:"sources"`
URL string `json:"url"`
Pattern string `json:"pattern"`
}
if err := kitutil.Unmarshal(raw, &action); err != nil {
return nil, fmt.Errorf("decode Responses web-search action: %w", err)
}
action.Type = strings.TrimSpace(action.Type)
action.Query = strings.TrimSpace(action.Query)
action.URL = strings.TrimSpace(action.URL)
action.Pattern = strings.TrimSpace(action.Pattern)
for index := range action.Queries {
action.Queries[index] = strings.TrimSpace(action.Queries[index])
if action.Queries[index] == "" {
return nil, fmt.Errorf("Responses web-search action queries[%d] must not be empty", index)
}
}
if action.Type == "" && (action.Query != "" || len(action.Queries) > 0) {
action.Type = "search"
}
var canonical any
switch action.Type {
case "search":
if action.Query == "" && len(action.Queries) == 0 {
return nil, fmt.Errorf("Responses web-search action %q requires query or queries", action.Type)
}
if len(action.Sources) > 0 && kitutil.GetJsonType(action.Sources) != "array" && kitutil.GetJsonType(action.Sources) != "null" {
return nil, fmt.Errorf("Responses web-search action sources must be an array")
}
canonical = struct {
Type string `json:"type"`
Query string `json:"query,omitempty"`
Queries []string `json:"queries,omitempty"`
Sources json.RawMessage `json:"sources,omitempty"`
}{Type: action.Type, Query: action.Query, Queries: action.Queries, Sources: action.Sources}
case "open_page":
if action.URL == "" {
return nil, fmt.Errorf("Responses web-search action %q requires url", action.Type)
}
canonical = struct {
Type string `json:"type"`
URL string `json:"url"`
}{Type: action.Type, URL: action.URL}
case "find", "find_in_page":
if action.URL == "" || action.Pattern == "" {
return nil, fmt.Errorf("Responses web-search action %q requires url and pattern", action.Type)
}
canonical = struct {
Type string `json:"type"`
URL string `json:"url"`
Pattern string `json:"pattern"`
}{Type: "find_in_page", URL: action.URL, Pattern: action.Pattern}
default:
return nil, fmt.Errorf("unsupported Responses web-search action type %q", action.Type)
}
encoded, err := kitutil.Marshal(canonical)
if err != nil {
return nil, fmt.Errorf("encode Responses web-search action: %w", err)
}
return encoded, nil
}
// ArgumentsString returns function call arguments in the string form expected by Chat Completions.
@@ -384,10 +515,20 @@ const (
// ResponsesStreamResponse 用于处理 /v1/responses 流式响应
type ResponsesStreamResponse struct {
Type string `json:"type"`
Response *OpenAIResponsesResponse `json:"response,omitempty"`
Delta string `json:"delta,omitempty"`
Item *ResponsesOutput `json:"item,omitempty"`
Type string `json:"type"`
Response *OpenAIResponsesResponse `json:"response,omitempty"`
Code string `json:"code,omitempty"`
Message string `json:"message,omitempty"`
Param string `json:"param,omitempty"`
Delta string `json:"delta,omitempty"`
Arguments *string `json:"arguments,omitempty"`
Name string `json:"name,omitempty"`
Text *string `json:"text,omitempty"`
Item *ResponsesOutput `json:"item,omitempty"`
SequenceNumber *int `json:"sequence_number,omitempty"`
Annotation json.RawMessage `json:"annotation,omitempty"`
AnnotationIndex *int `json:"annotation_index,omitempty"`
Obfuscation string `json:"obfuscation,omitempty"`
// - response.function_call_arguments.delta
// - response.function_call_arguments.done
OutputIndex *int `json:"output_index,omitempty"`
+14
View File
@@ -0,0 +1,14 @@
package dto
// ReasoningConversionState carries provider-native reasoning controls between
// in-process conversion steps. It is not part of any provider wire protocol;
// request fields that reference it must use json:"-".
//
// Converters that rebuild an OpenAI request must copy this state so exact
// budgets and explicit include-thoughts choices survive multi-step routes.
type ReasoningConversionState struct {
Mode string
Effort string
BudgetTokens *int
IncludeThoughts *bool
}
+281
View File
@@ -0,0 +1,281 @@
package dto
import (
"reflect"
"strings"
)
// MergeUsageNonZero overlays usage snapshots: a later non-zero field
// overwrites the current value, while a later zero value never erases an
// earlier positive count. Compatible BillingUsage snapshots follow the same
// rule within their provider-native payload.
func MergeUsageNonZero(current *Usage, incoming *Usage) *Usage {
if current == nil {
current = &Usage{}
}
if incoming == nil {
return current
}
if incoming.PromptTokens > 0 {
current.PromptTokens = incoming.PromptTokens
}
if incoming.CompletionTokens > 0 {
current.CompletionTokens = incoming.CompletionTokens
}
if incoming.TotalTokens > 0 {
current.TotalTokens = incoming.TotalTokens
}
if incoming.PromptCacheHitTokens > 0 {
current.PromptCacheHitTokens = incoming.PromptCacheHitTokens
}
if incoming.InputTokens > 0 {
current.InputTokens = incoming.InputTokens
}
if incoming.OutputTokens > 0 {
current.OutputTokens = incoming.OutputTokens
}
if incoming.ClaudeCacheCreation5mTokens > 0 {
current.ClaudeCacheCreation5mTokens = incoming.ClaudeCacheCreation5mTokens
}
if incoming.ClaudeCacheCreation1hTokens > 0 {
current.ClaudeCacheCreation1hTokens = incoming.ClaudeCacheCreation1hTokens
}
mergeInputTokenDetails(&current.PromptTokensDetails, incoming.PromptTokensDetails)
if incoming.InputTokensDetails != nil {
details := *incoming.InputTokensDetails
if details.CachedTokens > 0 ||
details.CachedCreationTokens > 0 ||
details.CacheWriteTokens > 0 ||
details.TextTokens > 0 ||
details.AudioTokens > 0 ||
details.ImageTokens > 0 {
if current.InputTokensDetails == nil {
current.InputTokensDetails = &InputTokenDetails{}
}
mergeInputTokenDetails(current.InputTokensDetails, details)
}
}
if incoming.CompletionTokenDetails.TextTokens > 0 {
current.CompletionTokenDetails.TextTokens = incoming.CompletionTokenDetails.TextTokens
}
if incoming.CompletionTokenDetails.AudioTokens > 0 {
current.CompletionTokenDetails.AudioTokens = incoming.CompletionTokenDetails.AudioTokens
}
if incoming.CompletionTokenDetails.ImageTokens > 0 {
current.CompletionTokenDetails.ImageTokens = incoming.CompletionTokenDetails.ImageTokens
}
if incoming.CompletionTokenDetails.ReasoningTokens > 0 {
current.CompletionTokenDetails.ReasoningTokens = incoming.CompletionTokenDetails.ReasoningTokens
}
if incoming.UsageSemantic != "" {
current.UsageSemantic = incoming.UsageSemantic
}
if incoming.UsageSource != "" {
current.UsageSource = incoming.UsageSource
}
if incoming.BillingUsage != nil {
current.BillingUsage = MergeBillingUsageNonZero(current.BillingUsage, incoming.BillingUsage)
}
if incoming.Cost != nil && !reflect.ValueOf(incoming.Cost).IsZero() {
current.Cost = incoming.Cost
}
if total := current.PromptTokens + current.CompletionTokens; total > current.TotalTokens {
current.TotalTokens = total
}
if total := current.InputTokens + current.OutputTokens; total > current.TotalTokens {
current.TotalTokens = total
}
return current
}
// MergeBillingUsageNonZero preserves non-zero provider-native fields across
// partial stream snapshots. A snapshot from a different billing dialect
// remains authoritative and replaces the previous payload.
func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *BillingUsage {
if incoming == nil {
return CloneBillingUsage(current)
}
if current == nil || !sameBillingUsageDialect(current, incoming) {
return CloneBillingUsage(incoming)
}
merged := CloneBillingUsage(current)
if incoming.Source != "" {
merged.Source = incoming.Source
}
if incoming.Semantic != "" {
merged.Semantic = incoming.Semantic
}
merged.Estimated = current.Estimated || incoming.Estimated
switch {
case current.OpenAIUsage != nil && incoming.OpenAIUsage != nil:
merged.OpenAIUsage = MergeUsageNonZero(
cloneOpenAIUsage(current.OpenAIUsage),
cloneOpenAIUsage(incoming.OpenAIUsage),
)
case current.ClaudeUsage != nil && incoming.ClaudeUsage != nil:
merged.ClaudeUsage = mergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
case current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil:
merged.GeminiUsageMetadata = MergeGeminiUsageMetadataNonZero(current.GeminiUsageMetadata, incoming.GeminiUsageMetadata)
}
return merged
}
func sameBillingUsageDialect(current *BillingUsage, incoming *BillingUsage) bool {
if current.Source != "" && incoming.Source != "" && !strings.EqualFold(current.Source, incoming.Source) {
return false
}
if current.Semantic != "" && incoming.Semantic != "" && !strings.EqualFold(current.Semantic, incoming.Semantic) {
return false
}
return current.OpenAIUsage != nil && incoming.OpenAIUsage != nil ||
current.ClaudeUsage != nil && incoming.ClaudeUsage != nil ||
current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil
}
func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
merged := cloneClaudeUsage(current)
if merged == nil {
merged = &ClaudeUsage{}
}
if incoming == nil {
return merged
}
if incoming.InputTokens > 0 {
merged.InputTokens = incoming.InputTokens
}
if incoming.CacheCreationInputTokens > 0 {
merged.CacheCreationInputTokens = incoming.CacheCreationInputTokens
}
if incoming.CacheReadInputTokens > 0 {
merged.CacheReadInputTokens = incoming.CacheReadInputTokens
}
if incoming.OutputTokens > 0 {
merged.OutputTokens = incoming.OutputTokens
}
if incoming.ClaudeCacheCreation5mTokens > 0 {
merged.ClaudeCacheCreation5mTokens = incoming.ClaudeCacheCreation5mTokens
}
if incoming.ClaudeCacheCreation1hTokens > 0 {
merged.ClaudeCacheCreation1hTokens = incoming.ClaudeCacheCreation1hTokens
}
if incoming.CacheCreation != nil {
cacheCreation := *incoming.CacheCreation
merged.CacheCreation = &cacheCreation
}
if incoming.ServerToolUse != nil {
if merged.ServerToolUse == nil {
merged.ServerToolUse = &ClaudeServerToolUse{}
}
if incoming.ServerToolUse.WebSearchRequests > 0 {
merged.ServerToolUse.WebSearchRequests = incoming.ServerToolUse.WebSearchRequests
}
if incoming.ServerToolUse.WebFetchRequests > 0 {
merged.ServerToolUse.WebFetchRequests = incoming.ServerToolUse.WebFetchRequests
}
if incoming.ServerToolUse.CodeExecutionRequests > 0 {
merged.ServerToolUse.CodeExecutionRequests = incoming.ServerToolUse.CodeExecutionRequests
}
if incoming.ServerToolUse.ToolSearchRequests > 0 {
merged.ServerToolUse.ToolSearchRequests = incoming.ServerToolUse.ToolSearchRequests
}
}
return merged
}
// MergeGeminiUsageMetadataNonZero overlays Gemini's cumulative usage
// snapshots: a later non-zero field overwrites the current value without
// dropping fields omitted by a later chunk.
func MergeGeminiUsageMetadataNonZero(current *GeminiUsageMetadata, incoming *GeminiUsageMetadata) *GeminiUsageMetadata {
if current == nil && incoming == nil {
return nil
}
if current == nil {
metadata := cloneGeminiUsageMetadata(*incoming)
metadata.BillingUsage = CloneBillingUsage(incoming.BillingUsage)
return &metadata
}
merged := cloneGeminiUsageMetadata(*current)
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
if incoming == nil {
return &merged
}
if incoming.PromptTokenCount > 0 {
merged.PromptTokenCount = incoming.PromptTokenCount
}
if incoming.ToolUsePromptTokenCount > 0 {
merged.ToolUsePromptTokenCount = incoming.ToolUsePromptTokenCount
}
if incoming.CandidatesTokenCount > 0 {
merged.CandidatesTokenCount = incoming.CandidatesTokenCount
merged.ThoughtsTokenCount = incoming.ThoughtsTokenCount
} else if incoming.ThoughtsTokenCount > 0 {
merged.ThoughtsTokenCount = incoming.ThoughtsTokenCount
}
if incoming.TotalTokenCount > 0 {
merged.TotalTokenCount = incoming.TotalTokenCount
}
if incoming.CachedContentTokenCount > 0 {
merged.CachedContentTokenCount = incoming.CachedContentTokenCount
}
merged.PromptTokensDetails = mergeGeminiTokenDetails(merged.PromptTokensDetails, incoming.PromptTokensDetails)
merged.ToolUsePromptTokensDetails = mergeGeminiTokenDetails(merged.ToolUsePromptTokensDetails, incoming.ToolUsePromptTokensDetails)
merged.CandidatesTokensDetails = mergeGeminiTokenDetails(merged.CandidatesTokensDetails, incoming.CandidatesTokensDetails)
if incoming.BillingUsage != nil {
merged.BillingUsage = MergeBillingUsageNonZero(merged.BillingUsage, incoming.BillingUsage)
}
if total := merged.PromptTokenCount + merged.ToolUsePromptTokenCount + merged.CandidatesTokenCount + merged.ThoughtsTokenCount; total > merged.TotalTokenCount {
merged.TotalTokenCount = total
}
return &merged
}
func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
merged := append([]GeminiPromptTokensDetails{}, current...)
indexes := make(map[string]int, len(merged))
for index, detail := range merged {
indexes[strings.ToUpper(strings.TrimSpace(detail.Modality))] = index
}
for _, detail := range incoming {
if detail.TokenCount <= 0 {
continue
}
key := strings.ToUpper(strings.TrimSpace(detail.Modality))
if index, ok := indexes[key]; ok {
merged[index] = detail
continue
}
indexes[key] = len(merged)
merged = append(merged, detail)
}
return merged
}
func mergeInputTokenDetails(current *InputTokenDetails, incoming InputTokenDetails) {
if incoming.CachedTokens > 0 {
current.CachedTokens = incoming.CachedTokens
}
if incoming.CachedCreationTokens > 0 {
current.CachedCreationTokens = incoming.CachedCreationTokens
}
if incoming.CacheWriteTokens > 0 {
current.CacheWriteTokens = incoming.CacheWriteTokens
}
if incoming.TextTokens > 0 {
current.TextTokens = incoming.TextTokens
}
if incoming.AudioTokens > 0 {
current.AudioTokens = incoming.AudioTokens
}
if incoming.ImageTokens > 0 {
current.ImageTokens = incoming.ImageTokens
}
}
+67
View File
@@ -0,0 +1,67 @@
package dto
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
t.Parallel()
merged := mergeClaudeUsageNonZero(
&ClaudeUsage{
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
},
&ClaudeUsage{
CacheCreation: &ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: 1000,
Ephemeral1hInputTokens: 0,
},
},
)
require.NotNil(t, merged.CacheCreation)
assert.Equal(t, 1000, merged.CacheCreation.Ephemeral5mInputTokens)
assert.Equal(t, 0, merged.CacheCreation.Ephemeral1hInputTokens)
}
func TestMergeGeminiUsageMetadataCandidatesAndThoughtsReplacedAsPair(t *testing.T) {
t.Parallel()
merged := MergeGeminiUsageMetadataNonZero(
&GeminiUsageMetadata{
PromptTokenCount: 10,
ThoughtsTokenCount: 100,
},
&GeminiUsageMetadata{
PromptTokenCount: 10,
CandidatesTokenCount: 150,
ThoughtsTokenCount: 0,
TotalTokenCount: 160,
},
)
require.NotNil(t, merged)
assert.Equal(t, 150, merged.CandidatesTokenCount)
assert.Equal(t, 0, merged.ThoughtsTokenCount)
billing := NewGeminiChatBillingUsage(merged)
usage, ok := billing.CanonicalUsage()
require.True(t, ok)
assert.Equal(t, 150, usage.CompletionTokens)
}
func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) {
t.Parallel()
merged := MergeUsageNonZero(
&Usage{PromptTokens: 10, CompletionTokens: 5, TotalTokens: 15},
&Usage{PromptTokens: 0, CompletionTokens: 0, TotalTokens: 20},
)
require.NotNil(t, merged)
assert.Equal(t, 10, merged.PromptTokens)
assert.Equal(t, 5, merged.CompletionTokens)
assert.Equal(t, 20, merged.TotalTokens)
}
+5
View File
@@ -16,6 +16,11 @@ func ClaudeStopReasonToOpenAIFinishReason(stopReason string) string {
return "length"
case "tool_use":
return "tool_calls"
case "pause_turn":
// Responses has no pause_turn finish reason. Treat the provider's
// resumable server-side loop as an incomplete response instead of a
// successful stop; the hosted output items preserve continuation state.
return "length"
case "refusal":
return types.FinishReasonContentFilter
default:
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
@@ -80,6 +81,21 @@ func TestClaudeDefaultMaxTokensPresence(t *testing.T) {
require.NotNil(t, got.MaxTokens)
assert.Equal(t, clientMaxTokens, *got.MaxTokens)
})
t.Run("client zero same as absent, hook fills", func(t *testing.T) {
clientMaxTokens := uint(0)
got, err := converter.convert(t, claudeDefaultsMeta(func(string) int { return 512 }), &clientMaxTokens)
require.NoError(t, err)
require.NotNil(t, got.MaxTokens)
assert.Equal(t, uint(512), *got.MaxTokens)
})
t.Run("client zero same as absent, no hook fails", func(t *testing.T) {
clientMaxTokens := uint(0)
got, err := converter.convert(t, &convmeta.Values{}, &clientMaxTokens)
require.ErrorIs(t, err, sharedclaude.ErrMissingMaxTokens)
assert.Nil(t, got)
})
})
}
}
@@ -88,12 +104,18 @@ func TestClaudeDefaultMaxTokensPresence(t *testing.T) {
// "-thinking" request without max_tokens must keep converting even when no
// DefaultMaxTokens hook is configured.
func TestClaudeThinkingAdapterSatisfiesMaxTokensWithoutCallback(t *testing.T) {
meta := &convmeta.Values{Options: &convmeta.Options{
Claude: convmeta.ClaudeOptions{
ThinkingAdapterEnabled: true,
ThinkingAdapterBudgetTokensPercentage: 0.8,
_, intent, found, err := reasoning.ParseClaudeModelSuffix("claude-test-thinking", true)
require.NoError(t, err)
require.True(t, found)
meta := &convmeta.Values{
ReasoningConversion: reasoning.StateFromIntent(intent),
Options: &convmeta.Options{
Claude: convmeta.ClaudeOptions{
ThinkingAdapterEnabled: true,
ThinkingAdapterBudgetTokensPercentage: 0.8,
},
},
}}
}
got, err := OpenAIChatRequestToClaudeMessages(context.Background(), meta, dto.GeneralOpenAIRequest{
Model: "claude-test-thinking",
Messages: []dto.Message{
+34
View File
@@ -28,6 +28,10 @@ type Meta interface {
// SetReasoningEffort records the effort level a converter derived from a
// model-name suffix so downstream billing/logging can see it.
SetReasoningEffort(effort string)
// ReasoningState returns the suffix-derived reasoning intent attached at
// the host entry layer. Standalone callers that do not set it receive nil;
// converters then use only explicit request fields.
ReasoningState() *dto.ReasoningConversionState
GetEstimatePromptTokens() int
// EnsureClaudeConvertInfo lazily creates and returns the mutable
@@ -60,6 +64,20 @@ type ClaudeConvertInfo struct {
ToolCallBaseIndex int
ToolCallMaxIndexOffset int
ToolCalls []*ClaudeStreamToolCall
ToolCallByIndex map[int]*ClaudeStreamToolCall
ToolCallByID map[string]*ClaudeStreamToolCall
}
// ClaudeStreamToolCall tracks one OpenAI tool_calls entry while it is encoded
// as a Claude tool_use content block. Chat tool indexes and Claude content
// block indexes are separate domains, so the mapping must remain explicit.
type ClaudeStreamToolCall struct {
BlockIndex int
ID string
Name string
PendingArguments string
Started bool
}
const (
@@ -79,6 +97,7 @@ type Values struct {
ChannelType int
IsStream bool
ReasoningEffort string
ReasoningConversion *dto.ReasoningConversionState
EstimatePromptTokens int
ClaudeConvertInfo *ClaudeConvertInfo
@@ -139,6 +158,13 @@ func (v *Values) SetReasoningEffort(effort string) {
}
}
func (v *Values) ReasoningState() *dto.ReasoningConversionState {
if v == nil {
return nil
}
return v.ReasoningConversion
}
func (v *Values) GetEstimatePromptTokens() int {
if v == nil {
return 0
@@ -213,3 +239,11 @@ func OptionsOf(m Meta) *Options {
}
return m.ConvOptions()
}
// ReasoningStateOf is a nil-safe reader for Meta.ReasoningState.
func ReasoningStateOf(m Meta) *dto.ReasoningConversionState {
if m == nil {
return nil
}
return m.ReasoningState()
}
@@ -19,6 +19,7 @@ func TestValuesTypedNilMetaIsSafe(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())
+34 -4
View File
@@ -1,5 +1,7 @@
package convmeta
import "github.com/QuantumNous/new-api/relaykit/types"
// Options is the per-request snapshot of host configuration that converters
// consult. The host fills it from its settings system when constructing the
// Meta (see relaycommon.RelayInfo.ConvOptions); relaykit users fill it
@@ -8,6 +10,13 @@ type Options struct {
Claude ClaudeOptions
Gemini GeminiOptions
// ToolLossPolicy controls whether a cross-protocol conversion may omit or
// approximate built-in-tool semantics. The zero value uses the allow
// policy: conversion succeeds and every loss is returned as a diagnostic.
// safe/strict rejection is request-phase opt-in only; response and stream
// conversion never reject regardless of this field.
ToolLossPolicy types.ConversionLossPolicy
// OpenRouterDialect marks the upstream as OpenRouter's OpenAI-compatible
// surface, which accepts extra fields (reasoning config, cache_control on
// system parts) that converters emit only for that dialect. The host sets
@@ -18,11 +27,16 @@ type Options struct {
// suffix must be kept on the outgoing model name (host blacklist lookup).
// Nil means "never preserve".
PreserveThinkingSuffix func(modelName string) bool
// PreserveEffortTail reports real model IDs whose names already end in an
// effort-like token (for example qwen-max). Nil means "never preserve".
PreserveEffortTail func(modelName string) bool
}
type ClaudeOptions struct {
// ThinkingAdapterEnabled turns "-thinking"-suffixed OpenAI model names
// into Claude extended-thinking requests.
// ThinkingAdapterEnabled controls whether suffix-derived reasoning intent
// is rendered onto Claude thinking / output_config. Suffix parsing itself
// is the host entry layer's job (standalone users call Parse* themselves).
ThinkingAdapterEnabled bool
// ThinkingAdapterBudgetTokensPercentage sizes thinking budget_tokens as a
// fraction of max_tokens when the adapter fires.
@@ -36,11 +50,16 @@ type ClaudeOptions struct {
// standalone relaykit users must supply one or guarantee max_tokens on
// every request.
DefaultMaxTokens func(modelName string) int
// WebSearchToolVersion selects the Claude hosted web-search tool version
// emitted by cross-protocol conversion. Empty keeps the compatibility
// baseline web_search_20250305.
WebSearchToolVersion string
}
type GeminiOptions struct {
// ThinkingAdapterEnabled maps -thinking/-nothinking/effort suffixes to
// Gemini thinkingConfig.
// ThinkingAdapterEnabled controls whether suffix-derived reasoning intent
// is rendered onto Gemini thinkingConfig. Suffix parsing itself is the
// host entry layer's job (standalone users call Parse* themselves).
ThinkingAdapterEnabled bool
// ThinkingAdapterBudgetTokensPercentage sizes thinkingBudget as a fraction
// of maxOutputTokens when the adapter fires.
@@ -77,3 +96,14 @@ func (o *GeminiOptions) SafetySettingFor(category string) string {
func (o *Options) ShouldPreserveThinkingSuffix(modelName string) bool {
return o != nil && o.PreserveThinkingSuffix != nil && o.PreserveThinkingSuffix(modelName)
}
func (o *Options) ShouldPreserveEffortTail(modelName string) bool {
return o != nil && o.PreserveEffortTail != nil && o.PreserveEffortTail(modelName)
}
func (o *Options) EffectiveToolLossPolicy() types.ConversionLossPolicy {
if o == nil || o.ToolLossPolicy == "" {
return types.ConversionLossPolicyAllow
}
return o.ToolLossPolicy
}
+39 -50
View File
@@ -1,8 +1,7 @@
package relayconvert
// golden_test.go pins the byte-level output of every registered (from, to)
// conversion route so the relaykit extraction refactor can prove behavior is
// unchanged at each phase. Run with -update to regenerate testdata/golden.
// golden_test.go pins the byte-level output of selected public conversion
// routes. Run with -update to regenerate testdata/golden.
//
// Volatile values (generated UUID-based ids, unix timestamps) are normalized
// before comparison so the snapshots are deterministic.
@@ -69,10 +68,30 @@ func checkGolden(t *testing.T, name string, got []byte) {
return
}
want, err := os.ReadFile(path)
require.NoError(t, err, "golden file missing, run: go test ./service/relayconvert -run TestGolden -update")
require.NoError(t, err, "golden file missing, run: cd relaykit && GOWORK=off go test ./relayconvert -run TestGolden -update")
require.Equal(t, string(want), string(got), "conversion output drifted from golden snapshot %s", path)
}
func checkStreamEventsGolden(t *testing.T, name string, events []any) {
t.Helper()
got := marshalGolden(t, map[string]any{"events": events})
path := filepath.Join(goldenDir, name+".golden.json")
if *updateGolden {
require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755))
require.NoError(t, os.WriteFile(path, got, 0o644))
return
}
wantData, err := os.ReadFile(path)
require.NoError(t, err, "golden file missing, run: cd relaykit && GOWORK=off go test ./relayconvert -run TestGolden -update")
var wantSnapshot map[string]json.RawMessage
require.NoError(t, json.Unmarshal(wantData, &wantSnapshot))
wantEvents, ok := wantSnapshot["events"]
require.True(t, ok, "stream golden snapshot %s has no events", path)
want := marshalGolden(t, map[string]json.RawMessage{"events": wantEvents})
require.Equal(t, string(want), string(got), "conversion events drifted from golden snapshot %s", path)
}
// goldenInfo mirrors the host's default converter options (new-api's
// model_setting defaults at the time the snapshots were recorded) so the
// golden files stay comparable across the extraction.
@@ -120,42 +139,6 @@ func fixtureRequests() map[types.RelayFormat]any {
"tool_choice": "auto"
}`, openai)
claude := &dto.ClaudeRequest{}
mustUnmarshalFixture(`{
"model": "claude-test",
"max_tokens": 1024,
"stream": true,
"system": "You are a helpful assistant.",
"messages": [
{"role": "user", "content": [
{"type": "text", "text": "What is in this image?"},
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}}
]},
{"role": "assistant", "content": [
{"type": "thinking", "thinking": "Let me look.", "signature": "sig"},
{"type": "tool_use", "id": "toolu_abc", "name": "get_weather", "input": {"city": "Paris"}}
]},
{"role": "user", "content": [{"type": "tool_result", "tool_use_id": "toolu_abc", "content": "15 degrees"}]}
],
"tools": [{"name": "get_weather", "description": "Get weather by city", "input_schema": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}],
"thinking": {"type": "enabled", "budget_tokens": 512}
}`, claude)
gemini := &dto.GeminiChatRequest{}
mustUnmarshalFixture(`{
"contents": [
{"role": "user", "parts": [
{"text": "What is in this image?"},
{"inlineData": {"mimeType": "image/png", "data": "aGVsbG8="}}
]},
{"role": "model", "parts": [{"functionCall": {"name": "get_weather", "args": {"city": "Paris"}}}]},
{"role": "user", "parts": [{"functionResponse": {"name": "get_weather", "response": {"result": "15 degrees"}}}]}
],
"systemInstruction": {"parts": [{"text": "You are a helpful assistant."}]},
"tools": [{"functionDeclarations": [{"name": "get_weather", "description": "Get weather by city", "parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}]}],
"generationConfig": {"maxOutputTokens": 1024, "temperature": 0.7}
}`, gemini)
responses := &dto.OpenAIResponsesRequest{}
mustUnmarshalFixture(`{
"model": "gpt-test",
@@ -175,8 +158,6 @@ func fixtureRequests() map[types.RelayFormat]any {
return map[types.RelayFormat]any{
types.RelayFormatOpenAI: openai,
types.RelayFormatClaude: claude,
types.RelayFormatGemini: gemini,
types.RelayFormatOpenAIResponses: responses,
}
}
@@ -308,8 +289,17 @@ func allFormats() []types.RelayFormat {
func TestGoldenRequestConversionMatrix(t *testing.T) {
requests := fixtureRequests()
for _, from := range allFormats() {
for _, to := range allFormats() {
fromFormats := []types.RelayFormat{
types.RelayFormatOpenAI,
types.RelayFormatOpenAIResponses,
}
toFormats := []types.RelayFormat{
types.RelayFormatOpenAI,
types.RelayFormatClaude,
types.RelayFormatOpenAIResponses,
}
for _, from := range fromFormats {
for _, to := range toFormats {
if from == to {
continue
}
@@ -330,7 +320,8 @@ func TestGoldenResponseConversionMatrix(t *testing.T) {
responses := fixtureResponses()
for _, from := range allFormats() {
for _, to := range allFormats() {
if from == to {
if from == to || to == types.RelayFormatGemini ||
(from == types.RelayFormatOpenAI && to == types.RelayFormatClaude) {
continue
}
name := fmt.Sprintf("response/%s_to_%s", from, to)
@@ -373,11 +364,9 @@ func TestGoldenStreamConversionMatrix(t *testing.T) {
outputs = append(outputs, r.Value)
}
snapshot := map[string]any{
"events": outputs,
"usage": state.Usage(),
}
checkGolden(t, name, marshalGolden(t, snapshot))
// Billing usage has private, cross-module acceptance coverage. Keep
// the public golden focused on client-visible stream events.
checkStreamEventsGolden(t, name, outputs)
})
}
}
@@ -0,0 +1,58 @@
package claudemessages
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func claudeCitationsToChat(raw json.RawMessage, text string, textOffset int) ([]any, error) {
if len(raw) == 0 {
return nil, nil
}
var citations []map[string]any
if err := kitutil.Unmarshal(raw, &citations); err != nil {
return nil, fmt.Errorf("invalid Claude citations: %w", err)
}
annotations := make([]any, 0, len(citations))
for _, citation := range citations {
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
citedText := kitutil.Interface2String(citation["cited_text"])
if citedText != "" {
converted["cited_text"] = citedText
if index := strings.Index(text, citedText); index >= 0 {
startIndex := textOffset + utf8.RuneCountInString(text[:index])
converted["start_index"] = startIndex
converted["end_index"] = startIndex + utf8.RuneCountInString(citedText)
}
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
annotations = append(annotations, map[string]any{
"type": "url_citation",
"url_citation": converted,
})
}
return annotations, nil
}
func marshalChatAnnotations(annotations []any) (json.RawMessage, error) {
if len(annotations) == 0 {
return nil, nil
}
return kitutil.Marshal(annotations)
}
@@ -0,0 +1,66 @@
package claudemessages
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestMessageStartZeroOutputSidecarRemainsRefreshable(t *testing.T) {
t.Parallel()
info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_start",
Message: &dto.ClaudeMediaMessage{
Id: "msg_1",
Model: "claude-test",
Usage: &dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 0,
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 0,
}),
},
},
}, nil, info)
require.True(t, ok)
ok = FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
OutputTokens: 42,
},
}, nil, info)
require.True(t, ok)
require.NotNil(t, info.Usage.BillingUsage)
require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
assert.Equal(t, 42, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
}
func TestTerminalSidecarRemainsAuthoritativeAgainstFinalize(t *testing.T) {
t.Parallel()
info := &ClaudeResponseInfo{Usage: &dto.Usage{}}
ok := FormatClaudeResponseInfo(&dto.ClaudeResponse{
Type: "message_delta",
Usage: &dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 7,
BillingUsage: dto.NewClaudeMessagesBillingUsage(&dto.ClaudeUsage{
InputTokens: 10,
OutputTokens: 7,
}),
},
}, nil, info)
require.True(t, ok)
require.NotNil(t, info.Usage.BillingUsage)
require.NotNil(t, info.Usage.BillingUsage.ClaudeUsage)
info.Usage.CompletionTokens = 99
FinalizeClaudeStreamBillingUsage(info)
assert.Equal(t, 7, info.Usage.BillingUsage.ClaudeUsage.OutputTokens)
}
@@ -7,6 +7,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
@@ -16,7 +17,7 @@ const (
)
type openRouterRequestReasoning struct {
Enabled bool `json:"enabled"`
Enabled *bool `json:"enabled,omitempty"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
@@ -39,6 +40,10 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con
if claudeRequest.Stream != nil {
openAIRequest.Stream = kitutil.GetPointer(*claudeRequest.Stream)
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
isOpenRouter := convmeta.OptionsOf(info).OpenRouterDialect
if isOpenRouter {
@@ -46,17 +51,21 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con
effortBytes, _ := kitutil.Marshal(effort)
openAIRequest.Verbosity = effortBytes
}
if claudeRequest.Thinking != nil {
if !reasoningIntent.IsEmpty() {
var reasoningConfig openRouterRequestReasoning
if claudeRequest.Thinking.Type == "enabled" {
disabled := reasoningIntent.Mode == reasoning.ModeDisabled || reasoningIntent.Effort == reasoning.EffortNone
enabled := !disabled
reasoningConfig.Enabled = &enabled
if enabled && reasoningIntent.BudgetTokens != nil && reasoningIntent.Mode != reasoning.ModeAdaptive {
reasoningConfig = openRouterRequestReasoning{
Enabled: true,
MaxTokens: claudeRequest.Thinking.GetBudgetTokens(),
}
} else if claudeRequest.Thinking.Type == "adaptive" {
reasoningConfig = openRouterRequestReasoning{
Enabled: true,
Enabled: &enabled,
MaxTokens: *reasoningIntent.BudgetTokens,
}
} else if enabled {
reasoningConfig.Effort = string(reasoning.EffectiveEffort(reasoningIntent))
}
if reasoningIntent.IncludeThoughts != nil {
reasoningConfig.Exclude = !*reasoningIntent.IncludeThoughts
}
reasoningJSON, err := kitutil.Marshal(reasoningConfig)
if err != nil {
@@ -64,12 +73,23 @@ func ClaudeMessagesRequestToOpenAIChat(claudeRequest dto.ClaudeRequest, info con
}
openAIRequest.Reasoning = reasoningJSON
}
} else if info != nil {
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
} else {
if err := reasoning.ApplyToOpenAIChat(&openAIRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if info != nil {
// Keep the outgoing -thinking suffix so a cascaded downstream
// new-api can recover reasoning intent from the model name. This
// is an emission-side policy, not converter-side suffix parsing.
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) &&
!strings.HasSuffix(openAIRequest.Model, thinkingSuffix) {
openAIRequest.Model = openAIRequest.Model + thinkingSuffix
}
}
}
if info != nil && effectiveEffort != "" {
info.SetReasoningEffort(string(effectiveEffort))
}
if len(claudeRequest.StopSequences) == 1 {
@@ -1,8 +1,10 @@
package claudemessages
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
@@ -19,6 +21,10 @@ type ClaudeResponseInfo struct {
ResponseText strings.Builder
Usage *dto.Usage
Done bool
// Only snapshots synthesized from partial display usage may be refreshed by
// later display deltas. Serialized BillingUsage always remains authoritative.
billingUsageSynthesized bool
}
func StopReasonClaudeToOpenAI(reason string) string {
@@ -47,6 +53,10 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
if claudeResponse.ContentBlock != nil {
if claudeResponse.ContentBlock.Type == "text" && claudeResponse.ContentBlock.Text != nil {
choice.Delta.SetContentString(*claudeResponse.ContentBlock.Text)
annotations, err := claudeCitationsToChat(claudeResponse.ContentBlock.Citations, *claudeResponse.ContentBlock.Text, 0)
if err == nil {
choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
}
}
if claudeResponse.ContentBlock.Type == "tool_use" {
tools = append(tools, dto.ToolCallResponse{
@@ -79,6 +89,14 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
choice.Delta.ReasoningContent = &signatureContent
case "thinking_delta":
choice.Delta.ReasoningContent = claudeResponse.Delta.Thinking
case "citations_delta":
if len(claudeResponse.Delta.Citation) > 0 {
raw, _ := kitutil.Marshal([]json.RawMessage{claudeResponse.Delta.Citation})
annotations, err := claudeCitationsToChat(raw, "", 0)
if err == nil {
choice.Delta.Annotations, _ = marshalChatAnnotations(annotations)
}
}
}
}
} else if claudeResponse.Type == "message_delta" {
@@ -102,6 +120,101 @@ func StreamResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.ChatCo
return &response
}
// ClaudeToChatStreamState translates Anthropic content block indexes into the
// independent, dense index space used by Chat Completions tool_calls. Text and
// thinking blocks therefore do not create holes in the downstream tool array.
type ClaudeToChatStreamState struct {
toolIndexByContentBlock map[int]int
blockTypeByContentBlock map[int]string
nextToolIndex int
}
func NewClaudeToChatStreamState() *ClaudeToChatStreamState {
return &ClaudeToChatStreamState{
toolIndexByContentBlock: make(map[int]int),
blockTypeByContentBlock: make(map[int]string),
}
}
func (s *ClaudeToChatStreamState) ConvertChunk(claudeResponse *dto.ClaudeResponse) (*dto.ChatCompletionsStreamResponse, error) {
if s == nil {
return nil, fmt.Errorf("Claude-to-Chat stream state is required")
}
if claudeResponse == nil {
return nil, nil
}
if s.toolIndexByContentBlock == nil {
s.toolIndexByContentBlock = make(map[int]int)
}
if s.blockTypeByContentBlock == nil {
s.blockTypeByContentBlock = make(map[int]string)
}
converted := *claudeResponse
switch claudeResponse.Type {
case "content_block_start":
if claudeResponse.ContentBlock == nil {
break
}
blockType := strings.TrimSpace(claudeResponse.ContentBlock.Type)
if blockType == "" {
break
}
if claudeResponse.Index == nil {
return nil, fmt.Errorf("Claude content block stream start is missing index")
}
contentBlockIndex := *claudeResponse.Index
s.blockTypeByContentBlock[contentBlockIndex] = blockType
if blockType != "tool_use" {
if isClaudeHostedToolStreamBlock(blockType) {
return nil, nil
}
break
}
toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
if !exists {
toolIndex = s.nextToolIndex
s.nextToolIndex++
s.toolIndexByContentBlock[contentBlockIndex] = toolIndex
}
converted.Index = kitutil.GetPointer(toolIndex)
case "content_block_delta":
if claudeResponse.Delta == nil || claudeResponse.Delta.Type != "input_json_delta" {
break
}
if claudeResponse.Index == nil {
return nil, fmt.Errorf("Claude tool-use stream delta is missing content block index")
}
if claudeResponse.Delta.PartialJson == nil {
return nil, fmt.Errorf("Claude tool-use stream delta is missing partial JSON")
}
contentBlockIndex := *claudeResponse.Index
toolIndex, exists := s.toolIndexByContentBlock[contentBlockIndex]
if !exists {
if isClaudeHostedToolStreamBlock(s.blockTypeByContentBlock[contentBlockIndex]) {
return nil, nil
}
return nil, fmt.Errorf("Claude tool-use stream delta references unknown content block index %d", contentBlockIndex)
}
converted.Index = kitutil.GetPointer(toolIndex)
case "content_block_stop":
if claudeResponse.Index != nil {
delete(s.blockTypeByContentBlock, *claudeResponse.Index)
}
}
return StreamResponseClaude2OpenAI(&converted), nil
}
func isClaudeHostedToolStreamBlock(blockType string) bool {
switch blockType {
case "server_tool_use", "mcp_tool_use", "web_search_tool_result", "mcp_tool_result", "code_execution_tool_result", "web_fetch_tool_result":
return true
default:
return false
}
}
func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextResponse {
choices := make([]dto.OpenAITextResponseChoice, 0)
fullTextResponse := dto.OpenAITextResponse{
@@ -109,16 +222,17 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
Object: "chat.completion",
Created: kitutil.GetTimestamp(),
}
var responseText string
var responseText strings.Builder
responseTextOffset := 0
var responseThinking string
if len(claudeResponse.Content) > 0 {
responseText = claudeResponse.Content[0].GetText()
if claudeResponse.Content[0].Thinking != nil {
responseThinking = *claudeResponse.Content[0].Thinking
}
}
tools := make([]dto.ToolCallResponse, 0)
thinkingContent := ""
annotations := make([]any, 0)
fullTextResponse.Id = claudeResponse.Id
for _, message := range claudeResponse.Content {
@@ -138,7 +252,14 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
thinkingContent = *message.Thinking
}
case "text":
responseText = message.GetText()
text := message.GetText()
offset := responseTextOffset
responseText.WriteString(text)
responseTextOffset += utf8.RuneCountInString(text)
converted, err := claudeCitationsToChat(message.Citations, text, offset)
if err == nil {
annotations = append(annotations, converted...)
}
}
}
choice := dto.OpenAITextResponseChoice{
@@ -148,7 +269,10 @@ func ResponseClaude2OpenAI(claudeResponse *dto.ClaudeResponse) *dto.OpenAITextRe
},
FinishReason: StopReasonClaudeToOpenAI(claudeResponse.StopReason),
}
choice.SetStringContent(responseText)
choice.SetStringContent(responseText.String())
if encodedAnnotations, err := marshalChatAnnotations(annotations); err == nil && len(encodedAnnotations) > 0 {
choice.Message.Annotations = encodedAnnotations
}
if len(responseThinking) > 0 {
choice.ReasoningContent = &responseThinking
}
@@ -295,6 +419,45 @@ func claudeBillingUsageFromSemanticUsage(usage *dto.Usage) *dto.BillingUsage {
return dto.NewClaudeMessagesBillingUsage(claudeUsage)
}
func updateClaudeStreamBillingUsage(claudeUsage *dto.ClaudeUsage, claudeInfo *ClaudeResponseInfo, terminal bool) {
if claudeUsage == nil || claudeInfo == nil || claudeInfo.Usage == nil {
return
}
if billingUsage := dto.CloneBillingUsage(claudeUsage.BillingUsage); billingUsage != nil {
claudeInfo.Usage.BillingUsage = billingUsage
if terminal || claudeUsage.OutputTokens > 0 {
claudeInfo.billingUsageSynthesized = false
return
}
claudeInfo.billingUsageSynthesized = true
return
}
if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
return
}
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
claudeInfo.billingUsageSynthesized = claudeInfo.Usage.BillingUsage != nil
}
// FinalizeClaudeStreamBillingUsage refreshes only a locally synthesized
// snapshot after the host has applied its missing-usage fallback. A snapshot
// received on the wire remains authoritative and is never rewritten.
func FinalizeClaudeStreamBillingUsage(claudeInfo *ClaudeResponseInfo) {
if claudeInfo == nil || claudeInfo.Usage == nil {
return
}
if claudeInfo.Usage.BillingUsage != nil && !claudeInfo.billingUsageSynthesized {
return
}
billingUsage := claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
if billingUsage != nil && !claudeInfo.Done {
billingUsage.Estimated = true
}
claudeInfo.Usage.BillingUsage = billingUsage
claudeInfo.billingUsageSynthesized = billingUsage != nil
}
func PatchClaudeMessageDeltaUsageData(data string, usage *dto.ClaudeUsage) string {
if data == "" || usage == nil {
return data
@@ -343,14 +506,15 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
}
if claudeResponse.Message != nil && claudeResponse.Message.Usage != nil {
claudeInfo.Usage.PromptTokens = claudeResponse.Message.Usage.InputTokens
messageUsage := claudeResponse.Message.Usage
claudeInfo.Usage.PromptTokens = messageUsage.InputTokens
claudeInfo.Usage.UsageSemantic = "anthropic"
claudeInfo.Usage.PromptTokensDetails.CachedTokens = claudeResponse.Message.Usage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = claudeResponse.Message.Usage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = claudeResponse.Message.Usage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = claudeResponse.Message.Usage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = claudeResponse.Message.Usage.OutputTokens
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
claudeInfo.Usage.PromptTokensDetails.CachedTokens = messageUsage.CacheReadInputTokens
claudeInfo.Usage.PromptTokensDetails.CachedCreationTokens = messageUsage.CacheCreationInputTokens
claudeInfo.Usage.ClaudeCacheCreation5mTokens = messageUsage.GetCacheCreation5mTokens()
claudeInfo.Usage.ClaudeCacheCreation1hTokens = messageUsage.GetCacheCreation1hTokens()
claudeInfo.Usage.CompletionTokens = messageUsage.OutputTokens
updateClaudeStreamBillingUsage(messageUsage, claudeInfo, false)
}
} else if claudeResponse.Type == "content_block_delta" {
if claudeResponse.Delta != nil {
@@ -383,7 +547,7 @@ func FormatClaudeResponseInfo(claudeResponse *dto.ClaudeResponse, oaiResponse *d
claudeInfo.Usage.CompletionTokens = claudeResponse.Usage.OutputTokens
}
claudeInfo.Usage.TotalTokens = claudeInfo.Usage.PromptTokens + claudeInfo.Usage.CompletionTokens
claudeInfo.Usage.BillingUsage = claudeBillingUsageFromSemanticUsage(claudeInfo.Usage)
updateClaudeStreamBillingUsage(claudeResponse.Usage, claudeInfo, true)
}
claudeInfo.Done = true
@@ -0,0 +1,194 @@
package claudemessages
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// ClaudeHostedStreamBridge keeps Anthropic server-executed tool blocks out of
// the Chat Completions pivot. Anthropic streams a hosted call's input after the
// content_block_start event, so the bridge owns those input_json_delta events
// until content_block_stop and only then starts the Responses output item.
type ClaudeHostedStreamBridge struct {
pending map[int]*claudeHostedStreamCall
}
type claudeHostedStreamCall struct {
blockType string
id string
name string
serverName string
caller []byte
startInput []byte
input strings.Builder
}
func NewClaudeHostedStreamBridge() *ClaudeHostedStreamBridge {
return &ClaudeHostedStreamBridge{pending: make(map[int]*claudeHostedStreamCall)}
}
// Convert consumes provider-hosted stream frames and reports whether the frame
// must be skipped by the ordinary Claude-to-Chat converter.
func (b *ClaudeHostedStreamBridge) Convert(response *dto.ClaudeResponse, state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, bool, error) {
if response == nil || state == nil {
return nil, false, nil
}
if b == nil {
return nil, false, fmt.Errorf("Claude hosted stream bridge is required")
}
if b.pending == nil {
b.pending = make(map[int]*claudeHostedStreamCall)
}
index := response.GetIndex()
switch response.Type {
case "content_block_start":
if response.ContentBlock == nil {
return nil, false, nil
}
block := response.ContentBlock
blockType := strings.TrimSpace(block.Type)
switch blockType {
case "server_tool_use", "mcp_tool_use":
if _, exists := b.pending[index]; exists {
return nil, true, fmt.Errorf("duplicate Claude hosted-tool content block index %d", index)
}
if blockType == "mcp_tool_use" && (strings.TrimSpace(block.Name) == "" || strings.TrimSpace(block.ServerName) == "") {
return nil, true, fmt.Errorf("Claude MCP tool use must include name and server_name")
}
if _, err := claudeHostedCallOutputType(blockType, block.Name); err != nil {
return nil, true, err
}
pending := &claudeHostedStreamCall{
blockType: blockType,
id: block.Id,
name: block.Name,
serverName: block.ServerName,
caller: append([]byte(nil), block.Caller...),
}
// Non-stream-shaped gateways occasionally include the complete input
// on the start frame. Preserve it as a fallback, while streamed deltas
// replace the placeholder at block completion.
if block.Input != nil {
input, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, true, fmt.Errorf("marshal Claude hosted-tool input: %w", err)
}
if string(input) != "{}" && string(input) != "null" {
pending.startInput = input
}
}
b.pending[index] = pending
return nil, true, nil
case "web_search_tool_result", "mcp_tool_result":
outputType, err := claudeHostedResultOutputType(blockType)
if err != nil {
return nil, true, err
}
var result []byte
if outputType != "web_search_call" {
result, err = kitutil.Marshal(block.Content)
if err != nil {
return nil, true, fmt.Errorf("marshal Claude hosted-tool result: %w", err)
}
}
events, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
Type: outputType,
ID: block.ToolUseId,
Result: result,
ErrorCode: claudeHostedResultErrorCode(block.Content, block.ErrorCode),
IsError: block.IsError != nil && *block.IsError,
})
return events, true, err
default:
return nil, false, nil
}
case "content_block_delta":
pending := b.pending[index]
if pending == nil {
return nil, false, nil
}
if response.Delta != nil && response.Delta.Type == "input_json_delta" && response.Delta.PartialJson != nil {
pending.input.WriteString(*response.Delta.PartialJson)
}
return nil, true, nil
case "content_block_stop":
pending := b.pending[index]
if pending == nil {
return nil, false, nil
}
delete(b.pending, index)
action := []byte(pending.input.String())
if len(action) == 0 {
action = pending.startInput
}
if len(action) == 0 {
action = []byte("{}")
}
outputType, err := claudeHostedCallOutputType(pending.blockType, pending.name)
if err != nil {
return nil, true, err
}
events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
Type: outputType,
ID: pending.id,
Name: pending.name,
Action: action,
Caller: pending.caller,
ServerLabel: pending.serverName,
})
return events, true, err
default:
return nil, false, nil
}
}
func claudeHostedCallOutputType(blockType string, name string) (string, error) {
if blockType == "mcp_tool_use" {
return "mcp_call", nil
}
switch strings.TrimSpace(name) {
case "web_search":
return "web_search_call", nil
case "code_execution":
return "", fmt.Errorf("Claude code_execution has no valid OpenAI Responses mapping without a container_id")
case "web_fetch":
return "", fmt.Errorf("Claude web_fetch has no valid OpenAI Responses hosted-tool mapping")
default:
return "", fmt.Errorf("unknown Claude server tool %q cannot be represented as an OpenAI Responses hosted tool", name)
}
}
func claudeHostedResultOutputType(blockType string) (string, error) {
switch blockType {
case "web_search_tool_result":
return "web_search_call", nil
case "mcp_tool_result":
return "mcp_call", nil
case "code_execution_tool_result":
return "", fmt.Errorf("Claude code_execution result has no valid OpenAI Responses mapping without a container_id")
case "web_fetch_tool_result":
return "", fmt.Errorf("Claude web_fetch result has no valid OpenAI Responses hosted-tool mapping")
default:
return "", fmt.Errorf("unknown Claude hosted-tool result %q", blockType)
}
}
func claudeHostedResultErrorCode(content any, fallback string) string {
if strings.TrimSpace(fallback) != "" {
return strings.TrimSpace(fallback)
}
value, ok := content.(map[string]any)
if !ok {
return ""
}
contentType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
if !strings.HasSuffix(contentType, "_error") {
return ""
}
return strings.TrimSpace(kitutil.Interface2String(value["error_code"]))
}
@@ -0,0 +1,326 @@
package claudemessages
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func ClaudeMessagesRequestToOpenAIResponses(claudeRequest dto.ClaudeRequest, info convmeta.Meta) (*dto.OpenAIResponsesRequest, error) {
if strings.TrimSpace(claudeRequest.Model) == "" {
return nil, errors.New("model is required")
}
input, err := claudeMessagesToResponsesInput(claudeRequest.Messages)
if err != nil {
return nil, err
}
instructions, err := claudeSystemToResponsesInstructions(&claudeRequest)
if err != nil {
return nil, err
}
tools, err := claudeToolsToResponsesTools(claudeRequest.Tools)
if err != nil {
return nil, err
}
toolChoice, parallelToolCalls, err := claudeToolChoiceToResponses(claudeRequest.ToolChoice)
if err != nil {
return nil, err
}
// Claude context_management is an object containing protocol-specific edit
// strategies. Responses expects an array of compaction entries, so copying
// the raw Claude value would produce an invalid upstream request.
responsesRequest := &dto.OpenAIResponsesRequest{
Model: claudeRequest.Model,
Input: input,
Instructions: instructions,
Metadata: append(json.RawMessage(nil), claudeRequest.Metadata...),
ServiceTier: claudeRequest.ServiceTier,
Stream: claudeRequest.Stream,
Temperature: claudeRequest.Temperature,
Tools: tools,
ToolChoice: toolChoice,
ParallelToolCalls: parallelToolCalls,
TopP: claudeRequest.TopP,
}
if info != nil && !convmeta.OptionsOf(info).OpenRouterDialect {
// Keep the outgoing -thinking suffix so a cascaded downstream new-api
// can recover reasoning intent from the model name. This is an
// emission-side policy, not converter-side suffix parsing.
thinkingSuffix := "-thinking"
if strings.HasSuffix(info.GetOriginModelName(), thinkingSuffix) && !strings.HasSuffix(responsesRequest.Model, thinkingSuffix) {
responsesRequest.Model += thinkingSuffix
}
}
if claudeRequest.MaxTokens != nil {
maxOutputTokens := *claudeRequest.MaxTokens
responsesRequest.MaxOutputTokens = &maxOutputTokens
} else if claudeRequest.MaxTokensToSample != nil {
maxOutputTokens := *claudeRequest.MaxTokensToSample
responsesRequest.MaxOutputTokens = &maxOutputTokens
}
reasoningIntent, effectiveEffort, err := claudeRequestReasoningIntent(&claudeRequest, info)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := reasoning.ApplyToOpenAIResponses(responsesRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if info != nil && effectiveEffort != "" {
info.SetReasoningEffort(string(effectiveEffort))
}
return responsesRequest, nil
}
func claudeRequestReasoningIntent(claudeRequest *dto.ClaudeRequest, info convmeta.Meta) (reasoning.Intent, reasoning.Effort, error) {
reasoningIntent, err := reasoning.FromClaude(claudeRequest)
if err != nil {
return reasoning.Intent{}, "", err
}
sourceModel := claudeRequest.Model
if info != nil && info.GetOriginModelName() != "" {
sourceModel = info.GetOriginModelName()
}
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModel)
if err != nil {
return reasoning.Intent{}, "", err
}
}
reasoningIntent = reasoning.ResolveClaudeDefault(sourceModel, reasoningIntent)
return reasoningIntent, reasoning.EffectiveEffort(reasoningIntent), nil
}
func claudeSystemToResponsesInstructions(request *dto.ClaudeRequest) (json.RawMessage, error) {
if request == nil || request.System == nil {
return nil, nil
}
if request.IsStringSystem() {
return kitutil.Marshal(request.GetStringSystem())
}
var instructions strings.Builder
systemBlocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](request.System)
if err != nil {
return nil, fmt.Errorf("invalid Claude system content: %w", err)
}
for _, block := range systemBlocks {
if block.Type == "text" || block.Type == "input_text" || block.Type == "" {
instructions.WriteString(block.GetText())
}
}
if instructions.Len() == 0 {
return nil, nil
}
return kitutil.Marshal(instructions.String())
}
func claudeMessagesToResponsesInput(messages []dto.ClaudeMessage) (json.RawMessage, error) {
input := make([]map[string]any, 0, len(messages))
for messageIndex := range messages {
message := messages[messageIndex]
role := strings.TrimSpace(message.Role)
if role == "" {
continue
}
if message.IsStringContent() {
input = append(input, map[string]any{
"role": role,
"content": message.GetStringContent(),
})
continue
}
blocks, err := message.ParseContent()
if err != nil {
return nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
contentParts := make([]map[string]any, 0, len(blocks))
flushContent := func() {
if len(contentParts) == 0 {
return
}
input = append(input, map[string]any{
"role": role,
"content": contentParts,
})
contentParts = nil
}
for blockIndex := range blocks {
block := blocks[blockIndex]
switch block.Type {
case "text", "input_text":
partType := "input_text"
if role == "assistant" {
partType = "output_text"
}
contentParts = append(contentParts, map[string]any{
"type": partType,
"text": block.GetText(),
})
case "image":
if source := claudeSourceURL(block.Source); source != "" {
contentParts = append(contentParts, map[string]any{
"type": "input_image",
"image_url": source,
})
}
case "document":
if source := claudeSourceURL(block.Source); source != "" {
contentParts = append(contentParts, map[string]any{
"type": "input_file",
"file_data": source,
})
}
case "tool_use":
flushContent()
arguments, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, fmt.Errorf("messages[%d].content[%d].input: %w", messageIndex, blockIndex, err)
}
if block.Input == nil {
arguments = []byte("{}")
}
input = append(input, map[string]any{
"type": "function_call",
"call_id": block.Id,
"name": block.Name,
"arguments": string(arguments),
})
case "tool_result":
flushContent()
output, err := claudeToolResultToResponsesOutput(block.Content)
if err != nil {
return nil, fmt.Errorf("messages[%d].content[%d].content: %w", messageIndex, blockIndex, err)
}
input = append(input, map[string]any{
"type": "function_call_output",
"call_id": block.ToolUseId,
"output": output,
})
}
}
flushContent()
}
return kitutil.Marshal(input)
}
func claudeToolsToResponsesTools(value any) (json.RawMessage, error) {
if value == nil {
return nil, nil
}
tools, err := kitutil.Any2Type[[]dto.Tool](value)
if err != nil {
return nil, fmt.Errorf("invalid Claude tools: %w", err)
}
converted := make([]map[string]any, 0, len(tools))
for _, tool := range tools {
function := map[string]any{
"type": "function",
"name": tool.Name,
"description": tool.Description,
"parameters": tool.InputSchema,
}
if tool.Strict != nil {
function["strict"] = *tool.Strict
}
converted = append(converted, function)
}
return kitutil.Marshal(converted)
}
func claudeToolChoiceToResponses(value any) (json.RawMessage, json.RawMessage, error) {
if value == nil {
return nil, nil, nil
}
choice, err := kitutil.Any2Type[dto.ClaudeToolChoice](value)
if err != nil {
return nil, nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
var converted any
switch choice.Type {
case "", "auto":
converted = "auto"
case "any":
converted = "required"
case "none":
converted = "none"
case "tool":
converted = map[string]any{"type": "function", "name": choice.Name}
default:
return nil, nil, fmt.Errorf("unsupported Claude tool_choice type %q", choice.Type)
}
toolChoice, err := kitutil.Marshal(converted)
if err != nil {
return nil, nil, err
}
var parallelToolCalls json.RawMessage
if choice.DisableParallelToolUse && choice.Type != "none" {
parallelToolCalls, err = kitutil.Marshal(false)
if err != nil {
return nil, nil, err
}
}
return toolChoice, parallelToolCalls, nil
}
func claudeToolResultToResponsesOutput(content any) (any, error) {
if content == nil {
return "", nil
}
if text, ok := content.(string); ok {
return text, nil
}
blocks, err := kitutil.Any2Type[[]dto.ClaudeMediaMessage](content)
if err != nil {
return content, nil
}
parts := make([]map[string]any, 0, len(blocks))
for _, block := range blocks {
switch block.Type {
case "text", "input_text":
parts = append(parts, map[string]any{"type": "input_text", "text": block.GetText()})
case "image":
if source := claudeSourceURL(block.Source); source != "" {
parts = append(parts, map[string]any{"type": "input_image", "image_url": source})
}
case "document":
if source := claudeSourceURL(block.Source); source != "" {
parts = append(parts, map[string]any{"type": "input_file", "file_data": source})
}
}
}
if len(parts) == 0 {
return content, nil
}
return parts, nil
}
func claudeSourceURL(source *dto.ClaudeMessageSource) string {
if source == nil {
return ""
}
if strings.TrimSpace(source.Url) != "" {
return source.Url
}
data := kitutil.Interface2String(source.Data)
if data == "" {
return ""
}
if strings.HasPrefix(data, "data:") {
return data
}
return fmt.Sprintf("data:%s;base64,%s", source.MediaType, data)
}
@@ -0,0 +1,367 @@
package geminichat
import (
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type geminiGroundingChunk struct {
Web *geminiGroundingSource `json:"web,omitempty"`
RetrievedContext *geminiGroundingSource `json:"retrievedContext,omitempty"`
}
type geminiGroundingSource struct {
URI string `json:"uri,omitempty"`
Title string `json:"title,omitempty"`
}
type geminiGroundingSupport struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type renderedGeminiPart struct {
text string
startByte int
}
type streamedGeminiPartSpan struct {
partStartByte int
partEndByte int
renderedStartByte int
}
type streamedGeminiPart struct {
text strings.Builder
spans []streamedGeminiPartSpan
}
// geminiGroundingStreamCandidate retains the protocol state needed to resolve
// grounding metadata emitted after the text it describes. Gemini's streaming
// contract makes grounding chunk indexes cumulative across response chunks and
// keeps segment offsets relative to the accumulated candidate part.
type geminiGroundingStreamCandidate struct {
rendered strings.Builder
parts map[int]*streamedGeminiPart
chunks []geminiGroundingChunk
}
// GroundingWebSearchQueries returns the distinct hosted-search queries that
// Gemini reports for a response. The provider may repeat metadata across
// candidates or stream chunks, so callers can safely accumulate this result
// without manufacturing duplicate Responses tool calls.
func GroundingWebSearchQueries(response *dto.GeminiChatResponse) []string {
if response == nil {
return nil
}
queries := make([]string, 0)
seen := make(map[string]struct{})
for candidateIndex := range response.Candidates {
metadata := response.Candidates[candidateIndex].GroundingMetadata
if metadata == nil {
continue
}
for _, query := range metadata.WebSearchQueries {
query = strings.TrimSpace(query)
if query == "" {
continue
}
if _, exists := seen[query]; exists {
continue
}
seen[query] = struct{}{}
queries = append(queries, query)
}
}
return queries
}
func groundingAnnotationsToChat(metadata *dto.GeminiGroundingMetadata, content dto.GeminiChatContent, rendered string) []byte {
if metadata == nil || len(metadata.GroundingChunks) == 0 || len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []geminiGroundingChunk
if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
return nil
}
var supports []geminiGroundingSupport
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return nil
}
parts := locateRenderedGeminiParts(content, rendered)
textPartCount := 0
soleTextPart := -1
for index := range parts {
if parts[index].startByte < 0 {
continue
}
textPartCount++
soleTextPart = index
}
annotations := make([]any, 0)
seen := make(map[string]struct{})
for _, support := range supports {
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
continue
}
if partIndex < 0 || partIndex >= len(parts) || parts[partIndex].startByte < 0 {
continue
}
part := parts[partIndex]
start, end, ok := groundingRuneRange(rendered, part, support.Segment.StartIndex, support.Segment.EndIndex)
if !ok {
continue
}
if support.Segment.Text != "" && part.text[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
continue
}
annotations = appendGroundingAnnotations(annotations, chunks, support, start, end, "", seen)
}
return marshalGroundingAnnotations(annotations)
}
func newGeminiGroundingStreamCandidate() *geminiGroundingStreamCandidate {
return &geminiGroundingStreamCandidate{parts: make(map[int]*streamedGeminiPart)}
}
func (s *geminiGroundingStreamCandidate) appendContent(content dto.GeminiChatContent, rendered string) {
if s == nil {
return
}
if s.parts == nil {
s.parts = make(map[int]*streamedGeminiPart)
}
renderedParts := locateRenderedGeminiParts(content, rendered)
renderedBase := s.rendered.Len()
for index := range content.Parts {
partContent := content.Parts[index]
text := partContent.Text
if text == "" || partContent.Thought {
continue
}
part := s.parts[index]
if part == nil {
part = &streamedGeminiPart{}
s.parts[index] = part
}
partStart := part.text.Len()
part.text.WriteString(text)
// A standalone newline is intentionally omitted by the existing Gemini
// renderer. Keep it in the source part so later byte offsets stay correct,
// but do not claim that it has a corresponding rendered span.
if text == "\n" || index >= len(renderedParts) || renderedParts[index].startByte < 0 {
continue
}
renderedStart := renderedBase + renderedParts[index].startByte
part.spans = append(part.spans, streamedGeminiPartSpan{
partStartByte: partStart,
partEndByte: partStart + len(text),
renderedStartByte: renderedStart,
})
}
s.rendered.WriteString(rendered)
}
func (s *geminiGroundingStreamCandidate) appendGroundingChunks(metadata *dto.GeminiGroundingMetadata) {
if s == nil || metadata == nil || len(metadata.GroundingChunks) == 0 {
return
}
var chunks []geminiGroundingChunk
if err := kitutil.Unmarshal(metadata.GroundingChunks, &chunks); err != nil {
return
}
s.chunks = append(s.chunks, chunks...)
}
func (s *geminiGroundingStreamCandidate) groundingAnnotations(
metadata *dto.GeminiGroundingMetadata,
candidateIndex int64,
seen map[string]struct{},
) []byte {
if s == nil || metadata == nil {
return nil
}
s.appendGroundingChunks(metadata)
if len(s.chunks) == 0 || len(metadata.GroundingSupports) == 0 {
return nil
}
var supports []geminiGroundingSupport
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return nil
}
annotations := make([]any, 0)
keyPrefix := fmt.Sprintf("%d:", candidateIndex)
for _, support := range supports {
partIndex, ok := s.groundingPartIndex(support)
if !ok {
continue
}
start, end, ok := s.groundingRuneRange(partIndex, support.Segment.StartIndex, support.Segment.EndIndex)
if !ok {
continue
}
part := s.parts[partIndex]
if support.Segment.Text != "" && part.text.String()[support.Segment.StartIndex:support.Segment.EndIndex] != support.Segment.Text {
continue
}
annotations = appendGroundingAnnotations(annotations, s.chunks, support, start, end, keyPrefix, seen)
}
return marshalGroundingAnnotations(annotations)
}
func (s *geminiGroundingStreamCandidate) groundingPartIndex(support geminiGroundingSupport) (int, bool) {
if support.Segment.PartIndex != nil {
partIndex := *support.Segment.PartIndex
part := s.parts[partIndex]
return partIndex, part != nil && len(part.spans) > 0
}
solePartIndex := -1
for partIndex, part := range s.parts {
if part == nil || len(part.spans) == 0 {
continue
}
if solePartIndex >= 0 {
return 0, false
}
solePartIndex = partIndex
}
return solePartIndex, solePartIndex >= 0
}
func (s *geminiGroundingStreamCandidate) groundingRuneRange(partIndex int, startByte int, endByte int) (int, int, bool) {
if s == nil {
return 0, 0, false
}
part := s.parts[partIndex]
if part == nil {
return 0, 0, false
}
partText := part.text.String()
if startByte < 0 || endByte <= startByte || endByte > len(partText) {
return 0, 0, false
}
if !utf8.ValidString(partText[:startByte]) || !utf8.ValidString(partText[:endByte]) {
return 0, 0, false
}
renderedStart, renderedEnd := -1, -1
for _, span := range part.spans {
if renderedStart < 0 && startByte >= span.partStartByte && startByte < span.partEndByte {
renderedStart = span.renderedStartByte + startByte - span.partStartByte
}
if endByte > span.partStartByte && endByte <= span.partEndByte {
renderedEnd = span.renderedStartByte + endByte - span.partStartByte
}
}
if renderedStart < 0 || renderedEnd <= renderedStart {
return 0, 0, false
}
rendered := s.rendered.String()
if renderedEnd > len(rendered) || rendered[renderedStart:renderedEnd] != partText[startByte:endByte] {
return 0, 0, false
}
if !utf8.ValidString(rendered[:renderedStart]) || !utf8.ValidString(rendered[:renderedEnd]) {
return 0, 0, false
}
return utf8.RuneCountInString(rendered[:renderedStart]), utf8.RuneCountInString(rendered[:renderedEnd]), true
}
func appendGroundingAnnotations(
annotations []any,
chunks []geminiGroundingChunk,
support geminiGroundingSupport,
start int,
end int,
keyPrefix string,
seen map[string]struct{},
) []any {
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source == nil || source.URI == "" {
continue
}
key := fmt.Sprintf("%s%d:%d:%s", keyPrefix, start, end, source.URI)
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
annotations = append(annotations, map[string]any{
"type": "url_citation",
"url_citation": map[string]any{
"start_index": start,
"end_index": end,
"url": source.URI,
"title": source.Title,
},
})
}
return annotations
}
func marshalGroundingAnnotations(annotations []any) []byte {
if len(annotations) == 0 {
return nil
}
encoded, err := kitutil.Marshal(annotations)
if err != nil {
return nil
}
return encoded
}
func locateRenderedGeminiParts(content dto.GeminiChatContent, rendered string) []renderedGeminiPart {
parts := make([]renderedGeminiPart, len(content.Parts))
cursor := 0
for index := range content.Parts {
part := content.Parts[index]
text := part.Text
parts[index] = renderedGeminiPart{text: text, startByte: -1}
if text == "" || part.Thought || cursor > len(rendered) {
continue
}
relative := strings.Index(rendered[cursor:], text)
if relative < 0 {
continue
}
start := cursor + relative
parts[index].startByte = start
cursor = start + len(text)
}
return parts
}
func groundingRuneRange(rendered string, part renderedGeminiPart, startByte int, endByte int) (int, int, bool) {
if startByte < 0 || endByte <= startByte || endByte > len(part.text) {
return 0, 0, false
}
if !utf8.ValidString(part.text[:startByte]) || !utf8.ValidString(part.text[:endByte]) {
return 0, 0, false
}
partStartRunes := utf8.RuneCountInString(rendered[:part.startByte])
start := partStartRunes + utf8.RuneCountInString(part.text[:startByte])
end := partStartRunes + utf8.RuneCountInString(part.text[:endByte])
return start, end, true
}
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/jsonutil"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta) (*dto.GeneralOpenAIRequest, error) {
@@ -21,7 +22,41 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
Model: modelName,
Stream: kitutil.GetPointer(isStream),
}
reasoningIntent, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
sourceModelName := modelName
if info != nil && info.GetOriginModelName() != "" {
sourceModelName = info.GetOriginModelName()
}
baseSourceModel := sourceModelName
opts := convmeta.OptionsOf(info)
preserveSuffix := opts.ShouldPreserveThinkingSuffix(sourceModelName)
if !preserveSuffix {
if suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info)); !suffix.IsEmpty() {
reasoningIntent, err = reasoning.MergeExplicitAndSuffix(reasoningIntent, suffix, sourceModelName)
if err != nil {
return nil, reasoning.AsClientError(err)
}
}
}
if baseSourceModel != "" && geminiRequest.GenerationConfig.ThinkingConfig != nil {
_, err = reasoning.ValidateGeminiThinkingConfig(baseSourceModel, geminiRequest.GenerationConfig.ThinkingConfig)
if err != nil {
return nil, reasoning.AsClientError(err)
}
}
reasoningIntent = reasoning.ResolveGeminiDefault(baseSourceModel, reasoningIntent)
effectiveEffort := reasoning.EffectiveEffort(reasoningIntent)
if err := reasoning.ApplyToOpenAIChat(openaiRequest, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if effectiveEffort != "" && info != nil {
info.SetReasoningEffort(string(effectiveEffort))
}
callHistory := newGeminiFunctionCallHistory(geminiRequest.Contents)
var messages []dto.Message
for _, content := range geminiRequest.Contents {
message := dto.Message{
@@ -30,8 +65,13 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
var mediaContents []dto.MediaContent
var toolCalls []dto.ToolCallRequest
var reasoningTexts []string
for _, part := range content.Parts {
if part.Text != "" {
if part.Thought {
reasoningTexts = append(reasoningTexts, part.Text)
continue
}
mediaContent := dto.MediaContent{
Type: "text",
Text: part.Text,
@@ -59,7 +99,7 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
mediaContents = append(mediaContents, mediaContent)
} else if part.FunctionCall != nil {
toolCall := dto.ToolCallRequest{
ID: fmt.Sprintf("call_%d", len(toolCalls)+1),
ID: callHistory.add(part.FunctionCall),
Type: "function",
Function: dto.FunctionRequest{
Name: part.FunctionCall.FunctionName,
@@ -70,7 +110,7 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
} else if part.FunctionResponse != nil {
toolMessage := dto.Message{
Role: "tool",
ToolCallId: fmt.Sprintf("call_%d", len(toolCalls)),
ToolCallId: callHistory.match(part.FunctionResponse),
}
toolMessage.SetStringContent(jsonutil.ToJSONString(part.FunctionResponse.Response))
messages = append(messages, toolMessage)
@@ -84,8 +124,12 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
} else if len(mediaContents) > 0 {
message.SetMediaContent(mediaContents)
}
if len(reasoningTexts) > 0 {
reasoningContent := strings.Join(reasoningTexts, "\n")
message.ReasoningContent = &reasoningContent
}
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 {
if len(message.ParseContent()) > 0 || len(message.ToolCalls) > 0 || message.ReasoningContent != nil {
messages = append(messages, message)
}
}
@@ -95,19 +139,19 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
if geminiRequest.GenerationConfig.Temperature != nil {
openaiRequest.Temperature = geminiRequest.GenerationConfig.Temperature
}
if geminiRequest.GenerationConfig.TopP != nil && *geminiRequest.GenerationConfig.TopP > 0 {
if geminiRequest.GenerationConfig.TopP != nil {
openaiRequest.TopP = kitutil.GetPointer(*geminiRequest.GenerationConfig.TopP)
}
if geminiRequest.GenerationConfig.TopK != nil && *geminiRequest.GenerationConfig.TopK > 0 {
if geminiRequest.GenerationConfig.TopK != nil {
openaiRequest.TopK = kitutil.GetPointer(int(*geminiRequest.GenerationConfig.TopK))
}
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
if geminiRequest.GenerationConfig.MaxOutputTokens != nil {
openaiRequest.MaxTokens = kitutil.GetPointer(*geminiRequest.GenerationConfig.MaxOutputTokens)
}
if len(geminiRequest.GenerationConfig.StopSequences) > 0 {
openaiRequest.Stop = geminiRequest.GenerationConfig.StopSequences[:min(len(geminiRequest.GenerationConfig.StopSequences), 4)]
}
if geminiRequest.GenerationConfig.CandidateCount != nil && *geminiRequest.GenerationConfig.CandidateCount > 0 {
if geminiRequest.GenerationConfig.CandidateCount != nil {
openaiRequest.N = kitutil.GetPointer(*geminiRequest.GenerationConfig.CandidateCount)
}
@@ -150,6 +194,88 @@ func GeminiGenerateContentRequestToOpenAIChat(geminiRequest *dto.GeminiChatReque
return openaiRequest, nil
}
type geminiPendingFunctionCall struct {
id string
name string
}
// geminiFunctionCallHistory keeps legacy Gemini histories without call IDs
// correlated across content boundaries. Named matching permits results for
// different parallel functions to arrive out of order; same-name calls use
// their original call order because old payloads contain no stronger identity.
type geminiFunctionCallHistory struct {
reservedIDs map[string]struct{}
pending []geminiPendingFunctionCall
nextID int
}
func newGeminiFunctionCallHistory(contents []dto.GeminiChatContent) *geminiFunctionCallHistory {
history := &geminiFunctionCallHistory{
reservedIDs: make(map[string]struct{}),
nextID: 1,
}
for _, content := range contents {
for _, part := range content.Parts {
if part.FunctionCall != nil && part.FunctionCall.ID != "" {
history.reservedIDs[part.FunctionCall.ID] = struct{}{}
}
if part.FunctionResponse != nil {
if id := kitutil.JsonRawMessageToString(part.FunctionResponse.ID); id != "" {
history.reservedIDs[id] = struct{}{}
}
}
}
}
return history
}
func (h *geminiFunctionCallHistory) add(call *dto.FunctionCall) string {
id := call.ID
if id == "" {
id = h.newFallbackID()
}
h.pending = append(h.pending, geminiPendingFunctionCall{id: id, name: call.FunctionName})
return id
}
func (h *geminiFunctionCallHistory) match(response *dto.GeminiFunctionResponse) string {
if id := kitutil.JsonRawMessageToString(response.ID); id != "" {
h.removePendingByID(id)
return id
}
for i, call := range h.pending {
if response.Name != "" && call.name != response.Name {
continue
}
h.pending = append(h.pending[:i], h.pending[i+1:]...)
return call.id
}
return h.newFallbackID()
}
func (h *geminiFunctionCallHistory) removePendingByID(id string) {
for i, call := range h.pending {
if call.id != id {
continue
}
h.pending = append(h.pending[:i], h.pending[i+1:]...)
return
}
}
func (h *geminiFunctionCallHistory) newFallbackID() string {
for {
id := fmt.Sprintf("call_%d", h.nextID)
h.nextID++
if _, exists := h.reservedIDs[id]; exists {
continue
}
h.reservedIDs[id] = struct{}{}
return id
}
}
func convertGeminiRoleToOpenAI(geminiRole string) string {
switch geminiRole {
case "user":
@@ -2,6 +2,8 @@ package geminichat
import (
"fmt"
"sort"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
@@ -176,6 +178,7 @@ func ResponseGeminiChat2OpenAI(id string, created int64, response *dto.GeminiCha
if isToolCall {
choice.FinishReason = types.FinishReasonToolCalls
}
choice.Message.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, choice.Message.StringContent())
fullTextResponse.Choices = append(fullTextResponse.Choices, choice)
}
@@ -272,6 +275,7 @@ func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
if isTools {
choice.FinishReason = &types.FinishReasonToolCalls
}
choice.Delta.Annotations = groundingAnnotationsToChat(candidate.GroundingMetadata, candidate.Content, content.String())
choices = append(choices, choice)
}
@@ -288,8 +292,29 @@ type GeminiToChatStreamState struct {
sawToolCall bool
finishEmitted bool
latestUsage *dto.Usage
// Gemini generateContent streams complete function calls. Keep their
// occurrence indexes monotonic because chunk-local indexes restart at zero.
nextToolIndexByCandidate map[int64]int
toolIndexByCandidateID map[int64]map[string]int
partialToolByCandidate map[int64]*geminiPartialToolCall
groundingByCandidate map[int64]*geminiGroundingStreamCandidate
sentGroundingAnnotations map[string]struct{}
}
type geminiPartialToolCall struct {
id string
name string
arguments map[string]interface{}
}
type geminiPartialArgPathSegment struct {
member string
index int
isIndex bool
}
const maxGeminiPartialArgArrayIndex = 4095
func NewGeminiToChatStreamState(id string, created int64) *GeminiToChatStreamState {
id = strings.TrimSpace(id)
if id == "" {
@@ -298,12 +323,40 @@ func NewGeminiToChatStreamState(id string, created int64) *GeminiToChatStreamSta
if created == 0 {
created = kitutil.GetTimestamp()
}
return &GeminiToChatStreamState{id: id, created: created}
return &GeminiToChatStreamState{
id: id,
created: created,
nextToolIndexByCandidate: make(map[int64]int),
toolIndexByCandidateID: make(map[int64]map[string]int),
partialToolByCandidate: make(map[int64]*geminiPartialToolCall),
groundingByCandidate: make(map[int64]*geminiGroundingStreamCandidate),
sentGroundingAnnotations: make(map[string]struct{}),
}
}
func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatResponse, model string, usage *dto.Usage) []*dto.ChatCompletionsStreamResponse {
func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatResponse, model string, usage *dto.Usage) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil || geminiResponse == nil {
return nil
return nil, nil
}
if s.groundingByCandidate == nil {
s.groundingByCandidate = make(map[int64]*geminiGroundingStreamCandidate)
}
if s.sentGroundingAnnotations == nil {
s.sentGroundingAnnotations = make(map[string]struct{})
}
if s.nextToolIndexByCandidate == nil {
s.nextToolIndexByCandidate = make(map[int64]int)
}
if s.toolIndexByCandidateID == nil {
s.toolIndexByCandidateID = make(map[int64]map[string]int)
}
if s.partialToolByCandidate == nil {
s.partialToolByCandidate = make(map[int64]*geminiPartialToolCall)
}
var err error
geminiResponse, err = s.preparePartialFunctionCalls(geminiResponse)
if err != nil {
return nil, err
}
hasNonStopFinish := false
for _, candidate := range geminiResponse.Candidates {
@@ -314,12 +367,47 @@ func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatRes
}
response, isStop := StreamResponseGeminiChat2OpenAI(geminiResponse)
if response == nil {
return nil
return nil, nil
}
response.Id = s.id
response.Created = s.created
response.Model = model
response.Usage = usage
for index := range geminiResponse.Candidates {
if index >= len(response.Choices) {
break
}
candidate := &geminiResponse.Candidates[index]
choice := &response.Choices[index]
for toolIndex := range choice.Delta.ToolCalls {
callID := strings.TrimSpace(choice.Delta.ToolCalls[toolIndex].ID)
indexesByID := s.toolIndexByCandidateID[candidate.Index]
if indexesByID == nil {
indexesByID = make(map[string]int)
s.toolIndexByCandidateID[candidate.Index] = indexesByID
}
stableIndex, exists := indexesByID[callID]
if callID == "" || !exists {
stableIndex = s.nextToolIndexByCandidate[candidate.Index]
s.nextToolIndexByCandidate[candidate.Index] = stableIndex + 1
if callID != "" {
indexesByID[callID] = stableIndex
}
}
choice.Delta.ToolCalls[toolIndex].SetIndex(stableIndex)
}
grounding := s.groundingByCandidate[candidate.Index]
if grounding == nil {
grounding = newGeminiGroundingStreamCandidate()
s.groundingByCandidate[candidate.Index] = grounding
}
grounding.appendContent(candidate.Content, response.Choices[index].Delta.GetContentString())
response.Choices[index].Delta.Annotations = grounding.groundingAnnotations(
candidate.GroundingMetadata,
candidate.Index,
s.sentGroundingAnnotations,
)
}
if response.IsToolCall() {
s.sawToolCall = true
@@ -345,14 +433,29 @@ func (s *GeminiToChatStreamState) ConvertChunk(geminiResponse *dto.GeminiChatRes
if isStop && !s.finishEmitted {
responses = append(responses, s.terminalChunk(model))
}
return responses
return responses, nil
}
func (s *GeminiToChatStreamState) Finalize(model string) []*dto.ChatCompletionsStreamResponse {
if s == nil || s.finishEmitted {
return nil
func (s *GeminiToChatStreamState) Finalize(model string) ([]*dto.ChatCompletionsStreamResponse, error) {
if s == nil {
return nil, nil
}
return []*dto.ChatCompletionsStreamResponse{s.terminalChunk(model)}
if len(s.partialToolByCandidate) > 0 {
candidateIndexes := make([]int64, 0, len(s.partialToolByCandidate))
for candidateIndex := range s.partialToolByCandidate {
candidateIndexes = append(candidateIndexes, candidateIndex)
}
sort.Slice(candidateIndexes, func(i, j int) bool {
return candidateIndexes[i] < candidateIndexes[j]
})
candidateIndex := candidateIndexes[0]
partial := s.partialToolByCandidate[candidateIndex]
return nil, fmt.Errorf("Gemini stream ended with an incomplete function call for candidate %d (id %q, name %q)", candidateIndex, partial.id, partial.name)
}
if s.finishEmitted {
return nil, nil
}
return []*dto.ChatCompletionsStreamResponse{s.terminalChunk(model)}, nil
}
func (s *GeminiToChatStreamState) Usage() *dto.Usage {
@@ -362,6 +465,234 @@ func (s *GeminiToChatStreamState) Usage() *dto.Usage {
return s.latestUsage
}
func (s *GeminiToChatStreamState) preparePartialFunctionCalls(response *dto.GeminiChatResponse) (*dto.GeminiChatResponse, error) {
prepared := *response
prepared.Candidates = append([]dto.GeminiChatCandidate(nil), response.Candidates...)
for candidateIndex := range prepared.Candidates {
candidate := &prepared.Candidates[candidateIndex]
parts := make([]dto.GeminiPart, 0, len(candidate.Content.Parts))
for _, part := range candidate.Content.Parts {
call := part.FunctionCall
if call == nil || (s.partialToolByCandidate[candidate.Index] == nil && call.WillContinue == nil && len(call.PartialArgs) == 0) {
parts = append(parts, part)
continue
}
completed, ready, err := s.appendPartialFunctionCall(candidate.Index, call)
if err != nil {
return nil, fmt.Errorf("reconstruct Gemini streamed function arguments: %w", err)
}
if ready {
part.FunctionCall = completed
parts = append(parts, part)
}
}
candidate.Content.Parts = parts
}
return &prepared, nil
}
func (s *GeminiToChatStreamState) appendPartialFunctionCall(candidateIndex int64, call *dto.FunctionCall) (*dto.FunctionCall, bool, error) {
current := s.partialToolByCandidate[candidateIndex]
if current == nil {
current = &geminiPartialToolCall{arguments: make(map[string]interface{})}
s.partialToolByCandidate[candidateIndex] = current
}
if id := strings.TrimSpace(call.ID); id != "" {
if current.id != "" && current.id != id {
return nil, false, fmt.Errorf("candidate %d function call changed id from %q to %q", candidateIndex, current.id, id)
}
current.id = id
}
if name := strings.TrimSpace(call.FunctionName); name != "" {
if current.name != "" && current.name != name {
return nil, false, fmt.Errorf("candidate %d function call changed name from %q to %q", candidateIndex, current.name, name)
}
current.name = name
}
for _, partial := range call.PartialArgs {
path, err := parseGeminiPartialArgPath(partial.JSONPath)
if err != nil {
return nil, false, err
}
value, present := geminiPartialArgValue(partial)
if !present {
continue
}
updated, err := setGeminiPartialArgValue(current.arguments, path, value, partial.StringValue != nil)
if err != nil {
return nil, false, fmt.Errorf("set partial argument %q: %w", partial.JSONPath, err)
}
arguments, ok := updated.(map[string]interface{})
if !ok {
return nil, false, fmt.Errorf("partial argument path %q replaced the arguments object", partial.JSONPath)
}
current.arguments = arguments
}
if call.WillContinue != nil && *call.WillContinue {
return nil, false, nil
}
if current.name == "" {
return nil, false, fmt.Errorf("candidate %d completed a partial function call without a name", candidateIndex)
}
completed := &dto.FunctionCall{ID: current.id, FunctionName: current.name, Arguments: current.arguments}
delete(s.partialToolByCandidate, candidateIndex)
return completed, true, nil
}
func parseGeminiPartialArgPath(jsonPath string) ([]geminiPartialArgPathSegment, error) {
path := strings.TrimSpace(jsonPath)
if path == "" || path[0] != '$' {
return nil, fmt.Errorf("unsupported Gemini partial argument path %q", jsonPath)
}
segments := make([]geminiPartialArgPathSegment, 0)
for offset := 1; offset < len(path); {
switch path[offset] {
case '.':
offset++
start := offset
for offset < len(path) && path[offset] != '.' && path[offset] != '[' {
offset++
}
if start == offset {
return nil, fmt.Errorf("empty member in Gemini partial argument path %q", jsonPath)
}
member := path[start:offset]
if strings.ContainsAny(member, "]*?") {
return nil, fmt.Errorf("unsupported member %q in Gemini partial argument path", member)
}
segments = append(segments, geminiPartialArgPathSegment{member: member})
case '[':
offset++
if offset >= len(path) {
return nil, fmt.Errorf("unterminated selector in Gemini partial argument path %q", jsonPath)
}
if path[offset] == '\'' || path[offset] == '"' {
member, next, err := parseGeminiPartialArgMember(path, offset)
if err != nil {
return nil, fmt.Errorf("invalid Gemini partial argument path %q: %w", jsonPath, err)
}
offset = next
if offset >= len(path) || path[offset] != ']' {
return nil, fmt.Errorf("unterminated member selector in Gemini partial argument path %q", jsonPath)
}
offset++
segments = append(segments, geminiPartialArgPathSegment{member: member})
continue
}
start := offset
for offset < len(path) && path[offset] >= '0' && path[offset] <= '9' {
offset++
}
if start == offset || offset >= len(path) || path[offset] != ']' {
return nil, fmt.Errorf("unsupported selector in Gemini partial argument path %q", jsonPath)
}
index, err := strconv.Atoi(path[start:offset])
if err != nil {
return nil, fmt.Errorf("invalid array index in Gemini partial argument path %q: %w", jsonPath, err)
}
if index > maxGeminiPartialArgArrayIndex {
return nil, fmt.Errorf("array index %d exceeds Gemini partial argument materialization limit %d", index, maxGeminiPartialArgArrayIndex)
}
offset++
segments = append(segments, geminiPartialArgPathSegment{index: index, isIndex: true})
default:
return nil, fmt.Errorf("unsupported selector at offset %d in Gemini partial argument path %q", offset, jsonPath)
}
}
if len(segments) == 0 {
return nil, fmt.Errorf("Gemini partial argument path %q targets the arguments root", jsonPath)
}
return segments, nil
}
func geminiPartialArgValue(partial dto.GeminiPartialArg) (any, bool) {
switch {
case partial.StringValue != nil:
return *partial.StringValue, true
case partial.NumberValue != nil:
return *partial.NumberValue, true
case partial.BoolValue != nil:
return *partial.BoolValue, true
case partial.NullValue != nil:
return nil, true
default:
return nil, false
}
}
func parseGeminiPartialArgMember(path string, offset int) (string, int, error) {
quote := path[offset]
start := offset
offset++
for offset < len(path) {
if path[offset] == '\\' {
offset += 2
continue
}
if path[offset] == quote {
raw := path[start : offset+1]
if quote == '\'' {
raw = `"` + strings.ReplaceAll(strings.ReplaceAll(raw[1:len(raw)-1], `"`, `\"`), `\'`, `'`) + `"`
}
var member string
if err := kitutil.Unmarshal([]byte(raw), &member); err != nil {
return "", 0, err
}
return member, offset + 1, nil
}
offset++
}
return "", 0, fmt.Errorf("unterminated quoted member")
}
func setGeminiPartialArgValue(current any, path []geminiPartialArgPathSegment, value any, appendString bool) (any, error) {
if len(path) == 0 {
if appendString {
if existing, ok := current.(string); ok {
return existing + value.(string), nil
}
}
return value, nil
}
segment := path[0]
if segment.isIndex {
var array []interface{}
switch typed := current.(type) {
case nil:
array = make([]interface{}, segment.index+1)
case []interface{}:
array = typed
if len(array) <= segment.index {
array = append(array, make([]interface{}, segment.index-len(array)+1)...)
}
default:
return nil, fmt.Errorf("array index %d traverses %T", segment.index, current)
}
updated, err := setGeminiPartialArgValue(array[segment.index], path[1:], value, appendString)
if err != nil {
return nil, err
}
array[segment.index] = updated
return array, nil
}
var object map[string]interface{}
switch typed := current.(type) {
case nil:
object = make(map[string]interface{})
case map[string]interface{}:
object = typed
default:
return nil, fmt.Errorf("member %q traverses %T", segment.member, current)
}
updated, err := setGeminiPartialArgValue(object[segment.member], path[1:], value, appendString)
if err != nil {
return nil, err
}
object[segment.member] = updated
return object, nil
}
func (s *GeminiToChatStreamState) terminalChunk(model string) *dto.ChatCompletionsStreamResponse {
finishReason := types.FinishReasonStop
if s.sawToolCall {
@@ -388,8 +719,12 @@ func geminiResponseToolCall(item *dto.GeminiPart) *dto.ToolCallResponse {
if err != nil {
return nil
}
callID := strings.TrimSpace(item.FunctionCall.ID)
if callID == "" {
callID = fmt.Sprintf("call_%s", kitutil.GetUUID())
}
return &dto.ToolCallResponse{
ID: fmt.Sprintf("call_%s", kitutil.GetUUID()),
ID: callID,
Type: "function",
Function: dto.FunctionResponse{
Arguments: string(argsBytes),
@@ -0,0 +1,71 @@
package geminichat
import (
"fmt"
"github.com/QuantumNous/new-api/relaykit/dto"
oaichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/oai_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// GeminiHostedStreamBridge accumulates grounding metadata until the provider
// stream ends. Gemini commonly reports the search queries after emitting the
// answer text; delaying the synthetic Responses item keeps one complete,
// canonical action instead of emitting partial or duplicate tool calls.
type GeminiHostedStreamBridge struct {
queries []string
seen map[string]struct{}
}
func NewGeminiHostedStreamBridge() *GeminiHostedStreamBridge {
return &GeminiHostedStreamBridge{seen: make(map[string]struct{})}
}
func (b *GeminiHostedStreamBridge) Observe(response *dto.GeminiChatResponse) {
if b == nil {
return
}
if b.seen == nil {
b.seen = make(map[string]struct{})
}
for _, query := range GroundingWebSearchQueries(response) {
if _, exists := b.seen[query]; exists {
continue
}
b.seen[query] = struct{}{}
b.queries = append(b.queries, query)
}
}
func (b *GeminiHostedStreamBridge) Finalize(state *oaichat.ChatToResponsesStreamState) ([]oaichat.ChatToResponsesStreamEvent, error) {
if b == nil || len(b.queries) == 0 {
return nil, nil
}
if state == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
action, err := kitutil.Marshal(map[string]any{
"type": "search",
"queries": append([]string(nil), b.queries...),
})
if err != nil {
return nil, fmt.Errorf("marshal Gemini web-search action: %w", err)
}
callID := fmt.Sprintf("ws_%s", kitutil.GetUUID())
events, err := state.StartHostedTool(oaichat.HostedToolStreamStart{
Type: "web_search_call",
ID: callID,
Action: action,
})
if err != nil {
return nil, err
}
completed, err := state.CompleteHostedTool(oaichat.HostedToolStreamResult{
Type: "web_search_call",
ID: callID,
})
if err != nil {
return nil, err
}
return append(events, completed...), nil
}
@@ -0,0 +1,81 @@
package oaichat
import (
"encoding/json"
"strings"
"unicode/utf8"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func chatAnnotationsToClaude(raw json.RawMessage, text string) []json.RawMessage {
if len(raw) == 0 {
return nil
}
var annotations []map[string]any
if err := kitutil.Unmarshal(raw, &annotations); err != nil {
return nil
}
citations := make([]json.RawMessage, 0, len(annotations))
for _, annotation := range annotations {
if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
continue
}
citation, ok := annotation["url_citation"].(map[string]any)
if !ok {
citation = annotation
}
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"type": "web_search_result_location",
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
converted["cited_text"] = citedText
} else if citedText := citedTextFromAnnotation(text, citation); citedText != "" {
converted["cited_text"] = citedText
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
encoded, err := kitutil.Marshal(converted)
if err == nil {
citations = append(citations, encoded)
}
}
return citations
}
func citedTextFromAnnotation(text string, citation map[string]any) string {
start, startOK := annotationIndex(citation["start_index"])
end, endOK := annotationIndex(citation["end_index"])
if !startOK || !endOK || start < 0 || end <= start {
return ""
}
if end > utf8.RuneCountInString(text) {
return ""
}
runes := []rune(text)
return string(runes[start:end])
}
func annotationIndex(value any) (int, bool) {
switch number := value.(type) {
case float64:
return int(number), number >= 0 && number == float64(int(number))
case int:
return number, number >= 0
case json.Number:
parsed, err := number.Int64()
return int(parsed), err == nil && parsed >= 0
default:
return 0, false
}
}
@@ -1,7 +1,6 @@
package oaichat
import (
"encoding/json"
"fmt"
"strings"
@@ -14,19 +13,6 @@ import (
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
webSearchMaxUsesLow = 1
webSearchMaxUsesMedium = 5
webSearchMaxUsesHigh = 10
)
type openRouterRequestReasoning struct {
Enabled bool `json:"enabled"`
Effort string `json:"effort,omitempty"`
MaxTokens int `json:"max_tokens,omitempty"`
Exclude bool `json:"exclude,omitempty"`
}
func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, textRequest dto.GeneralOpenAIRequest) (*dto.ClaudeRequest, error) {
opts := convmeta.OptionsOf(info)
claudeTools := make([]any, 0, len(textRequest.Tools))
@@ -74,15 +60,6 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
webSearchTool.UserLocation = anthropicUserLocation
}
switch textRequest.WebSearchOptions.SearchContextSize {
case "low":
webSearchTool.MaxUses = webSearchMaxUsesLow
case "medium":
webSearchTool.MaxUses = webSearchMaxUsesMedium
case "high":
webSearchTool.MaxUses = webSearchMaxUsesHigh
}
claudeTools = append(claudeTools, &webSearchTool)
}
@@ -94,8 +71,10 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
if len(claudeTools) > 0 {
claudeRequest.Tools = claudeTools
}
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(maxTokens)
if textRequest.MaxCompletionTokens != nil && *textRequest.MaxCompletionTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
} else if textRequest.MaxTokens != nil && *textRequest.MaxTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
if textRequest.TopP != nil {
claudeRequest.TopP = kitutil.GetPointer(*textRequest.TopP)
@@ -114,95 +93,20 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
}
}
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
if defaultMaxTokens, configured := opts.Claude.DefaultMaxTokensFor(textRequest.Model); configured {
sourceReasoning, err := reasoning.FromOpenAIChat(&textRequest)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(&claudeRequest, info, sourceReasoning); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
if defaultMaxTokens, configured := opts.Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
if baseModel, effortLevel, ok := reasoning.TrimEffortSuffix(textRequest.Model); ok && effortLevel != "" &&
(strings.HasPrefix(textRequest.Model, "claude-opus-4-6") ||
strings.HasPrefix(textRequest.Model, "claude-opus-4-7") ||
strings.HasPrefix(textRequest.Model, "claude-opus-4-8")) {
claudeRequest.Model = baseModel
claudeRequest.Thinking = &dto.Thinking{
Type: "adaptive",
}
claudeRequest.OutputConfig = json.RawMessage(fmt.Sprintf(`{"effort":"%s"}`, effortLevel))
if strings.HasPrefix(baseModel, "claude-opus-4-7") ||
strings.HasPrefix(baseModel, "claude-opus-4-8") {
claudeRequest.Thinking.Display = "summarized"
claudeRequest.Temperature = nil
claudeRequest.TopP = nil
claudeRequest.TopK = nil
} else {
claudeRequest.TopP = nil
claudeRequest.Temperature = kitutil.GetPointer[float64](1.0)
}
} else if opts.Claude.ThinkingAdapterEnabled &&
strings.HasSuffix(textRequest.Model, "-thinking") {
trimmedModel := strings.TrimSuffix(textRequest.Model, "-thinking")
if strings.HasPrefix(trimmedModel, "claude-opus-4-7") ||
strings.HasPrefix(trimmedModel, "claude-opus-4-8") {
claudeRequest.Thinking = &dto.Thinking{Type: "adaptive", Display: "summarized"}
claudeRequest.OutputConfig = json.RawMessage(`{"effort":"high"}`)
claudeRequest.Temperature = nil
claudeRequest.TopP = nil
claudeRequest.TopK = nil
} else {
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens < 1280 {
claudeRequest.MaxTokens = kitutil.GetPointer[uint](1280)
}
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer[int](int(float64(*claudeRequest.MaxTokens) * opts.Claude.ThinkingAdapterBudgetTokensPercentage)),
}
claudeRequest.TopP = nil
claudeRequest.Temperature = kitutil.GetPointer[float64](1.0)
}
if !opts.ShouldPreserveThinkingSuffix(textRequest.Model) {
claudeRequest.Model = trimmedModel
}
}
if textRequest.ReasoningEffort != "" {
switch textRequest.ReasoningEffort {
case "low":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer[int](1280),
}
case "medium":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer[int](2048),
}
case "high":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer[int](4096),
}
}
}
if textRequest.Reasoning != nil {
var reasoningConfig openRouterRequestReasoning
if err := kitutil.Unmarshal(textRequest.Reasoning, &reasoningConfig); err != nil {
return nil, err
}
budgetTokens := reasoningConfig.MaxTokens
if budgetTokens > 0 {
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: &budgetTokens,
}
}
}
if textRequest.Stop != nil {
switch stop := textRequest.Stop.(type) {
case string:
@@ -220,9 +124,25 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
lastMessage := dto.Message{
Role: "tool",
}
for i, message := range textRequest.Messages {
if message.Role == "" {
textRequest.Messages[i].Role = "user"
for _, message := range textRequest.Messages {
switch message.Role {
case "":
message.Role = "user"
case "developer":
message.Role = "system"
case "function":
if message.ToolCallId != "" {
message.Role = "tool"
} else {
message.Role = "user"
}
case "tool":
if message.ToolCallId == "" {
message.Role = "user"
}
case "system", "user", "assistant":
default:
message.Role = "user"
}
fmtMessage := dto.Message{
Role: message.Role,
@@ -236,7 +156,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
}
if lastMessage.Role == message.Role && lastMessage.Role != "tool" {
if lastMessage.IsStringContent() && message.IsStringContent() {
fmtMessage.SetStringContent(strings.Trim(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()), "\""))
fmtMessage.SetStringContent(fmt.Sprintf("%s %s", lastMessage.StringContent(), message.StringContent()))
formatMessages = formatMessages[:len(formatMessages)-1]
}
}
@@ -250,6 +170,15 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
claudeMessages := make([]dto.ClaudeMessage, 0)
isFirstMessage := true
var systemMessages []dto.ClaudeMediaMessage
placeholderUserMessage := dto.ClaudeMessage{
Role: "user",
Content: []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string]("..."),
},
},
}
for _, message := range formatMessages {
if message.Role == "system" {
@@ -276,16 +205,7 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
if isFirstMessage {
isFirstMessage = false
if message.Role != "user" {
claudeMessage := dto.ClaudeMessage{
Role: "user",
Content: []dto.ClaudeMediaMessage{
{
Type: "text",
Text: kitutil.GetPointer[string]("..."),
},
},
}
claudeMessages = append(claudeMessages, claudeMessage)
claudeMessages = append(claudeMessages, placeholderUserMessage)
}
}
@@ -384,6 +304,9 @@ func OpenAIChatRequestToClaudeMessages(c context.Context, info convmeta.Meta, te
}
claudeMessages = append(claudeMessages, claudeMessage)
}
if len(claudeMessages) == 0 && len(systemMessages) > 0 {
claudeMessages = append(claudeMessages, placeholderUserMessage)
}
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
@@ -1,13 +1,14 @@
package oaichat
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/samber/lo"
)
func generateStopBlock(index int) *dto.ClaudeResponse {
@@ -25,9 +26,18 @@ func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
case convmeta.LastMessageTypeText, convmeta.LastMessageTypeThinking:
return []*dto.ClaudeResponse{generateStopBlock(state.Index)}
case convmeta.LastMessageTypeTools:
responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
if len(state.ToolCalls) == 0 {
responses := make([]*dto.ClaudeResponse, 0, state.ToolCallMaxIndexOffset+1)
for offset := 0; offset <= state.ToolCallMaxIndexOffset; offset++ {
responses = append(responses, generateStopBlock(state.ToolCallBaseIndex+offset))
}
return responses
}
responses := make([]*dto.ClaudeResponse, 0, len(state.ToolCalls))
for _, tool := range state.ToolCalls {
if tool != nil && tool.Started {
responses = append(responses, generateStopBlock(tool.BlockIndex))
}
}
return responses
default:
@@ -35,59 +45,52 @@ func stopOpenBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
}
}
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
if oaiUsage == nil {
func startPendingToolBlocks(state *convmeta.ClaudeConvertInfo) []*dto.ClaudeResponse {
if state == nil || state.LastMessagesType != convmeta.LastMessageTypeTools {
return nil
}
if billingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); billingUsage != nil && billingUsage.ClaudeUsage != nil {
if billingUsage.Source == dto.BillingUsageSourceClaudeMessages || billingUsage.Semantic == dto.BillingUsageSemanticAnthropic {
return billingUsage.ClaudeUsage
responses := make([]*dto.ClaudeResponse, 0)
for _, tool := range state.ToolCalls {
if tool == nil || tool.Started || tool.Name == "" {
continue
}
if tool.ID == "" {
tool.ID = fmt.Sprintf("toolu_%s", kitutil.GetUUID())
}
idx := tool.BlockIndex
responses = append(responses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: tool.ID,
Type: "tool_use",
Name: tool.Name,
Input: map[string]interface{}{},
},
})
tool.Started = true
if tool.PendingArguments != "" {
arguments := tool.PendingArguments
responses = append(responses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
tool.PendingArguments = ""
}
}
billingUsage := dto.NewOpenAIChatBillingUsage(oaiUsage)
if existingBillingUsage := dto.CloneBillingUsage(oaiUsage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
billingUsage = existingBillingUsage
}
}
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
oaiUsage.PromptTokensDetails.CachedCreationTokens,
oaiUsage.ClaudeCacheCreation5mTokens,
oaiUsage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := oaiUsage.PromptTokensDetails.CacheCreationTokensTotal()
inputTokens := oaiUsage.PromptTokens
if oaiUsage.PromptTokensDetails.CacheWriteTokens > 0 {
// OpenAI native cache-write usage counts cached and cache-write tokens
// inside prompt_tokens, while Claude semantics reports input_tokens
// excluding both. Both counts are unadjusted prefixes and may overlap,
// so clamp a negative remainder at zero.
inputTokens = oaiUsage.PromptTokens - oaiUsage.PromptTokensDetails.CachedTokens - cacheCreationTokens
if inputTokens < 0 {
inputTokens = 0
}
}
usage := &dto.ClaudeUsage{
InputTokens: inputTokens,
OutputTokens: oaiUsage.CompletionTokens,
CacheCreationInputTokens: cacheCreationTokens,
CacheReadInputTokens: oaiUsage.PromptTokensDetails.CachedTokens,
BillingUsage: billingUsage,
}
if cacheCreation5m > 0 || cacheCreation1h > 0 {
usage.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return usage
return responses
}
func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
return sharedclaude.UsageFromOpenAI(oaiUsage)
}
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
remainder := lo.Max([]int{totalTokens - tokens5m - tokens1h, 0})
return tokens5m + remainder, tokens1h
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
}
func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) []*dto.ClaudeResponse {
@@ -108,6 +111,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
// For tools, OpenAI tool_calls can stream multiple parallel tool_use blocks (indexed from 0),
// so we may have multiple open blocks and must stop each one explicitly.
appendStopOpenBlocks := func() {
claudeResponses = append(claudeResponses, startPendingToolBlocks(state)...)
claudeResponses = append(claudeResponses, stopOpenBlocks(state)...)
}
// stopOpenBlocksAndAdvance closes the currently open block(s) and advances the content block index
@@ -122,14 +126,47 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
appendStopOpenBlocks()
switch state.LastMessagesType {
case convmeta.LastMessageTypeTools:
state.Index = state.ToolCallBaseIndex + state.ToolCallMaxIndexOffset + 1
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls)
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
state.ToolCalls = nil
state.ToolCallByIndex = nil
state.ToolCallByID = nil
default:
state.Index++
}
state.LastMessagesType = convmeta.LastMessageTypeNone
}
appendCitationDeltas := func(raw []byte) {
citations := chatAnnotationsToClaude(raw, "")
if len(citations) == 0 {
return
}
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
state.LastMessagesType = convmeta.LastMessageTypeText
}
for _, citation := range citations {
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "citations_delta",
Citation: citation,
},
})
}
}
if info.GetSendResponseCount() == 1 {
msg := &dto.ClaudeMediaMessage{
Id: openAIResponse.Id,
@@ -146,128 +183,6 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
Type: "message_start",
Message: msg,
})
//claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
// Type: "ping",
//})
if openAIResponse.IsToolCall() {
state.LastMessagesType = convmeta.LastMessageTypeTools
state.ToolCallBaseIndex = 0
state.ToolCallMaxIndexOffset = 0
var toolCall dto.ToolCallResponse
if len(openAIResponse.Choices) > 0 && len(openAIResponse.Choices[0].Delta.ToolCalls) > 0 {
toolCall = openAIResponse.Choices[0].Delta.ToolCalls[0]
} else {
first := openAIResponse.GetFirstToolCall()
if first != nil {
toolCall = *first
} else {
toolCall = dto.ToolCallResponse{}
}
}
resp := &dto.ClaudeResponse{
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.ID,
Type: "tool_use",
Name: toolCall.Function.Name,
Input: map[string]interface{}{},
},
}
resp.SetIndex(0)
claudeResponses = append(claudeResponses, resp)
// 首块包含工具 delta,则追加 input_json_delta
if toolCall.Function.Arguments != "" {
idx := 0
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &toolCall.Function.Arguments,
},
})
}
} else {
}
// 判断首个响应是否存在内容(非标准的 OpenAI 响应)
if len(openAIResponse.Choices) > 0 {
reasoning := openAIResponse.Choices[0].Delta.GetReasoningContent()
content := openAIResponse.Choices[0].Delta.GetContentString()
if reasoning != "" {
if state.LastMessagesType != convmeta.LastMessageTypeThinking {
stopOpenBlocksAndAdvance()
}
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer[string](""),
},
})
idx2 := idx
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx2,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "thinking_delta",
Thinking: &reasoning,
},
})
state.LastMessagesType = convmeta.LastMessageTypeThinking
} else if content != "" {
if state.LastMessagesType != convmeta.LastMessageTypeText {
stopOpenBlocksAndAdvance()
}
idx := state.Index
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Type: "text",
Text: kitutil.GetPointer[string](""),
},
})
idx2 := idx
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx2,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "text_delta",
Text: kitutil.GetPointer[string](content),
},
})
state.LastMessagesType = convmeta.LastMessageTypeText
}
}
// A first chunk can carry finish_reason before usage; defer terminal events until usage arrives.
if len(openAIResponse.Choices) > 0 && openAIResponse.Choices[0].FinishReason != nil && *openAIResponse.Choices[0].FinishReason != "" {
state.FinishReason = *openAIResponse.Choices[0].FinishReason
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage == nil {
return claudeResponses
}
appendStopOpenBlocks()
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
state.Done = true
}
return claudeResponses
}
if len(openAIResponse.Choices) == 0 {
@@ -300,13 +215,6 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
doneChunk := chosenChoice.FinishReason != nil && *chosenChoice.FinishReason != ""
if doneChunk {
state.FinishReason = *chosenChoice.FinishReason
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Defer closing until usage is available so the final message_delta carries it.
return claudeResponses
}
}
var claudeResponse dto.ClaudeResponse
@@ -318,38 +226,80 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
stopOpenBlocksAndAdvance()
state.ToolCallBaseIndex = state.Index
state.ToolCallMaxIndexOffset = 0
state.ToolCalls = nil
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
state.LastMessagesType = convmeta.LastMessageTypeTools
base := state.ToolCallBaseIndex
maxOffset := state.ToolCallMaxIndexOffset
if state.ToolCallByIndex == nil {
state.ToolCallByIndex = make(map[int]*convmeta.ClaudeStreamToolCall)
}
if state.ToolCallByID == nil {
state.ToolCallByID = make(map[string]*convmeta.ClaudeStreamToolCall)
}
for i, toolCall := range toolCalls {
offset := 0
toolIndex := i
if toolCall.Index != nil {
offset = *toolCall.Index
} else {
offset = i
toolIndex = *toolCall.Index
}
if offset > maxOffset {
maxOffset = offset
incomingID := strings.TrimSpace(toolCall.ID)
var tool *convmeta.ClaudeStreamToolCall
if incomingID != "" {
tool = state.ToolCallByID[incomingID]
}
if tool == nil {
tool = state.ToolCallByIndex[toolIndex]
}
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
tool = nil
}
if tool == nil {
tool = &convmeta.ClaudeStreamToolCall{
BlockIndex: state.ToolCallBaseIndex + len(state.ToolCalls),
}
state.ToolCalls = append(state.ToolCalls, tool)
}
state.ToolCallByIndex[toolIndex] = tool
if tool.ID == "" && incomingID != "" {
tool.ID = incomingID
state.ToolCallByID[incomingID] = tool
}
if tool.Name == "" && strings.TrimSpace(toolCall.Function.Name) != "" {
tool.Name = strings.TrimSpace(toolCall.Function.Name)
}
if !tool.Started {
tool.PendingArguments += toolCall.Function.Arguments
}
blockIndex := base + offset
idx := blockIndex
if toolCall.Function.Name != "" {
idx := tool.BlockIndex
if !tool.Started && tool.ID != "" && tool.Name != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_start",
ContentBlock: &dto.ClaudeMediaMessage{
Id: toolCall.ID,
Id: tool.ID,
Type: "tool_use",
Name: toolCall.Function.Name,
Name: tool.Name,
Input: map[string]interface{}{},
},
})
tool.Started = true
if tool.PendingArguments != "" {
arguments := tool.PendingArguments
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
Delta: &dto.ClaudeMediaMessage{
Type: "input_json_delta",
PartialJson: &arguments,
},
})
tool.PendingArguments = ""
}
continue
}
if len(toolCall.Function.Arguments) > 0 {
if tool.Started && toolCall.Function.Arguments != "" {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Index: &idx,
Type: "content_block_delta",
@@ -360,8 +310,10 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
})
}
}
state.ToolCallMaxIndexOffset = maxOffset
state.Index = base + maxOffset
state.ToolCallMaxIndexOffset = len(state.ToolCalls) - 1
if len(state.ToolCalls) > 0 {
state.Index = state.ToolCallBaseIndex + len(state.ToolCalls) - 1
}
} else {
reasoning := chosenChoice.Delta.GetReasoningContent()
textContent := chosenChoice.Delta.GetContentString()
@@ -412,22 +364,27 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
if !isEmpty && claudeResponse.Delta != nil {
claudeResponses = append(claudeResponses, &claudeResponse)
}
appendCitationDeltas(chosenChoice.Delta.Annotations)
if doneChunk || state.Done {
appendStopOpenBlocks()
oaiUsage := openAIResponse.Usage
if oaiUsage == nil {
oaiUsage = state.Usage
}
if oaiUsage != nil {
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
if oaiUsage == nil {
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
// Keep content blocks open until usage is available so the terminal message_delta
// can carry both usage and the final stop reason.
return claudeResponses
}
appendStopOpenBlocks()
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_delta",
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
Delta: &dto.ClaudeMediaMessage{
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
},
})
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
Type: "message_stop",
})
@@ -452,7 +409,8 @@ func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeRespon
if stopReason == "" {
stopReason = "end_turn"
}
responses := stopOpenBlocks(state)
responses := startPendingToolBlocks(state)
responses = append(responses, stopOpenBlocks(state)...)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
@@ -478,12 +436,22 @@ func ResponseOpenAI2Claude(openAIResponse *dto.OpenAITextResponse, info convmeta
}
for _, choice := range openAIResponse.Choices {
stopReason = stopReasonOpenAI2Claude(choice.FinishReason)
reasoningContent := choice.Message.GetReasoningContent()
textContent := choice.Message.StringContent()
toolCalls := choice.Message.ParseToolCalls()
if textContent != "" || len(toolCalls) == 0 {
if reasoningContent != "" {
claudeContent := dto.ClaudeMediaMessage{Type: "thinking"}
claudeContent.Thinking = kitutil.GetPointer(reasoningContent)
contents = append(contents, claudeContent)
}
if textContent != "" || (reasoningContent == "" && len(toolCalls) == 0) {
claudeContent := dto.ClaudeMediaMessage{}
claudeContent.Type = "text"
claudeContent.SetText(textContent)
citations := chatAnnotationsToClaude(choice.Message.Annotations, textContent)
if len(citations) > 0 {
claudeContent.Citations, _ = kitutil.Marshal(citations)
}
contents = append(contents, claudeContent)
}
for _, toolUse := range toolCalls {
@@ -79,6 +79,25 @@ func TestResponseOpenAI2ClaudeUsageCarriesOpenAIBillingUsage(t *testing.T) {
assert.Nil(t, resp.Usage.BillingUsage.OpenAIUsage.BillingUsage)
}
func TestResponseOpenAI2ClaudePreservesReasoningBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = ptr("considering the request")
resp := ResponseOpenAI2Claude(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: message, FinishReason: "stop"},
},
}, nil)
require.Len(t, resp.Content, 2)
assert.Equal(t, "thinking", resp.Content[0].Type)
require.NotNil(t, resp.Content[0].Thinking)
assert.Equal(t, "considering the request", *resp.Content[0].Thinking)
assert.Equal(t, "text", resp.Content[1].Type)
assert.Equal(t, "final answer", resp.Content[1].GetText())
}
func TestBuildClaudeUsageFromOpenAICacheWriteUsage(t *testing.T) {
usage := buildClaudeUsageFromOpenAIUsage(&dto.Usage{
PromptTokens: 3619,
@@ -3,6 +3,7 @@ package oaichat
import (
"errors"
"fmt"
"math"
"strings"
"context"
@@ -11,6 +12,7 @@ import (
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto.GeneralOpenAIRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
@@ -22,13 +24,15 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
},
}
if textRequest.TopP != nil && *textRequest.TopP > 0 {
if textRequest.TopP != nil {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*textRequest.TopP)
}
if maxTokens := textRequest.GetMaxTokens(); maxTokens > 0 {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(maxTokens)
if textRequest.MaxCompletionTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxCompletionTokens)
} else if textRequest.MaxTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*textRequest.MaxTokens)
}
if textRequest.Seed != nil && *textRequest.Seed != 0 {
if textRequest.Seed != nil {
geminiRequest.GenerationConfig.Seed = kitutil.GetPointer(int64(*textRequest.Seed))
}
@@ -50,7 +54,6 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
geminiRequest.GenerationConfig.StopSequences = stopSequences
}
adaptorWithExtraBody := false
if len(textRequest.ExtraBody) > 0 {
var extraBody map[string]interface{}
if err := kitutil.Unmarshal(textRequest.ExtraBody, &extraBody); err != nil {
@@ -58,61 +61,47 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
}
if googleBody, ok := extraBody["google"].(map[string]interface{}); ok {
if !strings.HasSuffix(upstreamModelName, "-nothinking") {
adaptorWithExtraBody = true
if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead")
if _, hasErrorParam := googleBody["thinkingConfig"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinkingConfig is not supported, use extra_body.google.thinking_config instead")
}
if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok {
if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead")
}
var hasThinkingConfig bool
var tempThinkingConfig dto.GeminiThinkingConfig
if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists {
v, ok := thinkingBudget.(float64)
maxInt := int(^uint(0) >> 1)
if !ok || math.IsNaN(v) || math.IsInf(v, 0) || math.Trunc(v) != v || v > float64(maxInt) || v < float64(-maxInt-1) {
return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
}
budgetInt := int(v)
tempThinkingConfig.ThinkingBudget = kitutil.GetPointer(budgetInt)
hasThinkingConfig = true
}
if thinkingConfig, ok := googleBody["thinking_config"].(map[string]interface{}); ok {
if _, hasErrorParam := thinkingConfig["thinkingBudget"]; hasErrorParam {
return nil, errors.New("extra_body.google.thinking_config.thinkingBudget is not supported, use extra_body.google.thinking_config.thinking_budget instead")
if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
if v, ok := includeThoughts.(bool); ok {
tempThinkingConfig.IncludeThoughts = kitutil.GetPointer(v)
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean")
}
var hasThinkingConfig bool
var tempThinkingConfig dto.GeminiThinkingConfig
}
if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists {
if v, ok := thinkingLevel.(string); ok {
tempThinkingConfig.ThinkingLevel = v
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string")
}
}
if thinkingBudget, exists := thinkingConfig["thinking_budget"]; exists {
switch v := thinkingBudget.(type) {
case float64:
budgetInt := int(v)
tempThinkingConfig.ThinkingBudget = kitutil.GetPointer(budgetInt)
tempThinkingConfig.IncludeThoughts = budgetInt > 0
hasThinkingConfig = true
default:
return nil, errors.New("extra_body.google.thinking_config.thinking_budget must be an integer")
}
}
if includeThoughts, exists := thinkingConfig["include_thoughts"]; exists {
if v, ok := includeThoughts.(bool); ok {
tempThinkingConfig.IncludeThoughts = v
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.include_thoughts must be a boolean")
}
}
if thinkingLevel, exists := thinkingConfig["thinking_level"]; exists {
if v, ok := thinkingLevel.(string); ok {
tempThinkingConfig.ThinkingLevel = v
hasThinkingConfig = true
} else {
return nil, errors.New("extra_body.google.thinking_config.thinking_level must be a string")
}
}
if hasThinkingConfig {
if geminiRequest.GenerationConfig.ThinkingConfig == nil {
geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
} else {
if tempThinkingConfig.ThinkingBudget != nil {
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = tempThinkingConfig.ThinkingBudget
}
geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = tempThinkingConfig.IncludeThoughts
if tempThinkingConfig.ThinkingLevel != "" {
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingLevel = tempThinkingConfig.ThinkingLevel
}
}
}
if hasThinkingConfig {
geminiRequest.GenerationConfig.ThinkingConfig = &tempThinkingConfig
}
}
@@ -147,8 +136,8 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
}
}
if !adaptorWithExtraBody {
sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest)
if err := sharedgemini.ApplyThinkingConfig(&geminiRequest, info, textRequest); err != nil {
return nil, reasoning.AsClientError(err)
}
var safetySettings []dto.GeminiChatSafetySettings
@@ -270,6 +259,13 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
Name: name,
Response: contentMap,
}
if message.ToolCallId != "" {
id, err := kitutil.Marshal(message.ToolCallId)
if err != nil {
return nil, fmt.Errorf("failed to marshal function response ID: %w", err)
}
functionResp.ID = id
}
*parts = append(*parts, dto.GeminiPart{
FunctionResponse: functionResp,
@@ -293,6 +289,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
}
toolCall := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: call.ID,
FunctionName: call.Function.Name,
Arguments: args,
},
@@ -1,11 +1,53 @@
package oaichat
import (
"fmt"
"sort"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ChatToGeminiStreamState struct {
toolsByChoice map[int][]*chatToGeminiStreamTool
toolByIndex map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool
toolByID map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool
finishedChoices map[int]bool
seenChoices map[int]bool
usage *dto.Usage
usageEmitted bool
finalized bool
}
type chatToGeminiStreamToolKey struct {
ChoiceIndex int
ToolIndex int
}
type chatToGeminiStreamToolIDKey struct {
ChoiceIndex int
ID string
}
type chatToGeminiStreamTool struct {
ID string
Name string
Arguments strings.Builder
Emitted bool
}
func NewChatToGeminiStreamState() *ChatToGeminiStreamState {
return &ChatToGeminiStreamState{
toolsByChoice: make(map[int][]*chatToGeminiStreamTool),
toolByIndex: make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool),
toolByID: make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool),
finishedChoices: make(map[int]bool),
seenChoices: make(map[int]bool),
}
}
// ResponseOpenAI2Gemini 将 OpenAI 响应转换为 Gemini 格式
func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta.Meta) *dto.GeminiChatResponse {
totalTokens := openAIResponse.TotalTokens
@@ -64,19 +106,11 @@ func ResponseOpenAI2Gemini(openAIResponse *dto.OpenAITextResponse, info convmeta
toolCalls := choice.Message.ParseToolCalls()
for _, toolCall := range toolCalls {
var args map[string]interface{}
if toolCall.Function.Arguments != "" {
if err := kitutil.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
}
} else {
args = make(map[string]interface{})
}
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
Arguments: args,
Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
content.Parts = append(content.Parts, part)
@@ -165,20 +199,11 @@ func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamRespon
// 处理工具调用
if choice.Delta.ToolCalls != nil {
for _, toolCall := range choice.Delta.ToolCalls {
// 解析参数
var args map[string]interface{}
if toolCall.Function.Arguments != "" {
if err := kitutil.Unmarshal([]byte(toolCall.Function.Arguments), &args); err != nil {
args = map[string]interface{}{"arguments": toolCall.Function.Arguments}
}
} else {
args = make(map[string]interface{})
}
part := dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: toolCall.ID,
FunctionName: toolCall.Function.Name,
Arguments: args,
Arguments: geminiFunctionArguments(toolCall.Function.Arguments),
},
}
content.Parts = append(content.Parts, part)
@@ -201,6 +226,306 @@ func StreamResponseOpenAI2Gemini(openAIResponse *dto.ChatCompletionsStreamRespon
return geminiResponse
}
// ConvertChunk accumulates OpenAI tool-call deltas until their choice ends.
// Gemini functionCall parts are atomic, so emitting each OpenAI arguments
// fragment as a separate part would create duplicate calls with invalid input.
func (s *ChatToGeminiStreamState) ConvertChunk(openAIResponse *dto.ChatCompletionsStreamResponse, info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
if openAIResponse == nil {
return nil, nil
}
if s == nil {
return nil, fmt.Errorf("OpenAI chat to Gemini stream state is required")
}
if s.finalized {
return nil, fmt.Errorf("OpenAI chat to Gemini stream received data after finalization")
}
if s.toolsByChoice == nil {
s.toolsByChoice = make(map[int][]*chatToGeminiStreamTool)
}
if s.toolByIndex == nil {
s.toolByIndex = make(map[chatToGeminiStreamToolKey]*chatToGeminiStreamTool)
}
if s.toolByID == nil {
s.toolByID = make(map[chatToGeminiStreamToolIDKey]*chatToGeminiStreamTool)
}
if s.finishedChoices == nil {
s.finishedChoices = make(map[int]bool)
}
if s.seenChoices == nil {
s.seenChoices = make(map[int]bool)
}
if openAIResponse.Usage != nil {
s.usage = UsageFromChatUsage(openAIResponse.Usage)
}
candidates := make([]dto.GeminiChatCandidate, 0, len(openAIResponse.Choices))
for _, choice := range openAIResponse.Choices {
s.seenChoices[choice.Index] = true
hasText := choice.Delta.GetContentString() != ""
hasToolDelta := len(choice.Delta.ToolCalls) > 0
hasFinish := choice.FinishReason != nil && strings.TrimSpace(*choice.FinishReason) != ""
if s.finishedChoices[choice.Index] {
if hasText || hasToolDelta {
return nil, fmt.Errorf("OpenAI chat choice %d received data after completion", choice.Index)
}
continue
}
for position, toolCall := range choice.Delta.ToolCalls {
if toolCall.Index == nil {
toolCall.SetIndex(position)
}
if err := s.appendToolCallDelta(choice.Index, toolCall); err != nil {
return nil, err
}
}
candidate := dto.GeminiChatCandidate{
Index: int64(choice.Index),
SafetyRatings: []dto.GeminiChatSafetyRating{},
Content: dto.GeminiChatContent{
Role: "model",
Parts: make([]dto.GeminiPart, 0),
},
}
if hasText {
candidate.Content.Parts = append(candidate.Content.Parts, dto.GeminiPart{Text: choice.Delta.GetContentString()})
}
if hasFinish {
parts, err := s.finishChoice(choice.Index)
if err != nil {
return nil, err
}
candidate.Content.Parts = append(candidate.Content.Parts, parts...)
finishReason := geminiFinishReason(*choice.FinishReason)
candidate.FinishReason = &finishReason
s.finishedChoices[choice.Index] = true
}
if len(candidate.Content.Parts) > 0 || candidate.FinishReason != nil {
candidates = append(candidates, candidate)
}
}
if len(candidates) == 0 {
if openAIResponse.Usage != nil && len(s.finishedChoices) > 0 {
s.usageEmitted = true
return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
}
return nil, nil
}
if openAIResponse.Usage != nil {
s.usageEmitted = true
}
return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, openAIResponse.Usage, info)}, nil
}
// Finalize emits any calls left pending when an upstream closes without a
// finish-reason chunk. Calling Finalize more than once is safe.
func (s *ChatToGeminiStreamState) Finalize(info convmeta.Meta) ([]*dto.GeminiChatResponse, error) {
if s == nil || s.finalized {
return nil, nil
}
choiceIndexes := make(map[int]struct{})
for choiceIndex, tools := range s.toolsByChoice {
for _, tool := range tools {
if !tool.Emitted {
choiceIndexes[choiceIndex] = struct{}{}
break
}
}
}
for choiceIndex := range s.seenChoices {
if !s.finishedChoices[choiceIndex] {
choiceIndexes[choiceIndex] = struct{}{}
}
}
orderedChoices := make([]int, 0, len(choiceIndexes))
for choiceIndex := range choiceIndexes {
orderedChoices = append(orderedChoices, choiceIndex)
}
sort.Ints(orderedChoices)
candidates := make([]dto.GeminiChatCandidate, 0, len(orderedChoices))
for _, choiceIndex := range orderedChoices {
parts, err := s.finishChoice(choiceIndex)
if err != nil {
return nil, err
}
finishReason := "STOP"
candidates = append(candidates, dto.GeminiChatCandidate{
Index: int64(choiceIndex),
FinishReason: &finishReason,
SafetyRatings: []dto.GeminiChatSafetyRating{},
Content: dto.GeminiChatContent{
Role: "model",
Parts: parts,
},
})
}
if len(candidates) == 0 {
s.finalized = true
if s.usage == nil || s.usageEmitted {
return nil, nil
}
s.usageEmitted = true
return []*dto.GeminiChatResponse{newGeminiStreamResponse(nil, s.usage, info)}, nil
}
s.finalized = true
s.usageEmitted = s.usage != nil
return []*dto.GeminiChatResponse{newGeminiStreamResponse(candidates, s.usage, info)}, nil
}
func (s *ChatToGeminiStreamState) Usage() *dto.Usage {
if s == nil || s.usage == nil {
return nil
}
return UsageFromChatUsage(s.usage)
}
func (s *ChatToGeminiStreamState) SetUsage(usage *dto.Usage) {
if s == nil || usage == nil {
return
}
s.usage = UsageFromChatUsage(usage)
}
func (s *ChatToGeminiStreamState) StreamUsage() *dto.Usage {
return s.Usage()
}
func (s *ChatToGeminiStreamState) SetStreamUsage(usage *dto.Usage) {
s.SetUsage(usage)
}
func (s *ChatToGeminiStreamState) appendToolCallDelta(choiceIndex int, toolCall dto.ToolCallResponse) error {
toolIndex := 0
if toolCall.Index != nil {
toolIndex = *toolCall.Index
}
if toolIndex < 0 {
return fmt.Errorf("OpenAI chat choice %d has negative tool-call index %d", choiceIndex, toolIndex)
}
key := chatToGeminiStreamToolKey{ChoiceIndex: choiceIndex, ToolIndex: toolIndex}
incomingID := strings.TrimSpace(toolCall.ID)
var tool *chatToGeminiStreamTool
if incomingID != "" {
tool = s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}]
}
if tool == nil {
tool = s.toolByIndex[key]
}
if tool != nil && incomingID != "" && tool.ID != "" && tool.ID != incomingID {
tool = nil
}
if tool == nil {
tool = &chatToGeminiStreamTool{}
s.toolsByChoice[choiceIndex] = append(s.toolsByChoice[choiceIndex], tool)
}
s.toolByIndex[key] = tool
// Compatibility gateways may reset a source index for the next occurrence.
// Once identity changes, keep the new occurrence active for later metadata-free deltas.
if tool.Emitted {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d received data after completion", choiceIndex, toolIndex)
}
if incomingID != "" {
if tool.ID != "" && tool.ID != incomingID {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed id from %q to %q", choiceIndex, toolIndex, tool.ID, incomingID)
}
tool.ID = incomingID
s.toolByID[chatToGeminiStreamToolIDKey{ChoiceIndex: choiceIndex, ID: incomingID}] = tool
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
if tool.Name != "" && tool.Name != incomingName {
return fmt.Errorf("OpenAI chat choice %d tool-call index %d changed name from %q to %q", choiceIndex, toolIndex, tool.Name, incomingName)
}
tool.Name = incomingName
}
tool.Arguments.WriteString(toolCall.Function.Arguments)
return nil
}
func (s *ChatToGeminiStreamState) finishChoice(choiceIndex int) ([]dto.GeminiPart, error) {
tools := s.toolsByChoice[choiceIndex]
pending := make([]*chatToGeminiStreamTool, 0, len(tools))
for _, tool := range tools {
if !tool.Emitted {
pending = append(pending, tool)
}
}
parts := make([]dto.GeminiPart, 0, len(pending))
for _, tool := range pending {
if tool.Name == "" {
return nil, fmt.Errorf("OpenAI chat choice %d has a tool call without a function name", choiceIndex)
}
parts = append(parts, dto.GeminiPart{FunctionCall: &dto.FunctionCall{
ID: tool.ID,
FunctionName: tool.Name,
Arguments: geminiFunctionArguments(tool.Arguments.String()),
}})
}
for _, tool := range pending {
tool.Emitted = true
}
return parts, nil
}
func newGeminiStreamResponse(candidates []dto.GeminiChatCandidate, usage *dto.Usage, info convmeta.Meta) *dto.GeminiChatResponse {
if candidates == nil {
candidates = make([]dto.GeminiChatCandidate, 0)
}
estimatePromptTokens := 0
if info != nil {
estimatePromptTokens = info.GetEstimatePromptTokens()
}
response := &dto.GeminiChatResponse{
Candidates: candidates,
HasUsageMetadata: true,
UsageMetadata: dto.GeminiUsageMetadata{
PromptTokenCount: estimatePromptTokens,
TotalTokenCount: estimatePromptTokens,
},
}
if usage == nil {
return response
}
response.UsageMetadata.PromptTokenCount = usage.PromptTokens
response.UsageMetadata.CandidatesTokenCount = usage.CompletionTokens
response.UsageMetadata.TotalTokenCount = usage.TotalTokens
response.UsageMetadata.BillingUsage = openAIBillingUsageFromUsage(usage)
if metadata, ok := geminiBillingMetadataFromOpenAIUsage(usage); ok {
response.UsageMetadata = metadata
}
return response
}
func geminiFunctionArguments(raw string) map[string]interface{} {
if strings.TrimSpace(raw) == "" || strings.TrimSpace(raw) == "null" {
return map[string]interface{}{}
}
var args map[string]interface{}
if err := kitutil.Unmarshal([]byte(raw), &args); err == nil && args != nil {
return args
}
// Preserve historically accepted malformed/non-object input without
// emitting a non-object Gemini args value.
return map[string]interface{}{"arguments": raw}
}
func geminiFinishReason(finishReason string) string {
switch strings.TrimSpace(finishReason) {
case "length":
return "MAX_TOKENS"
case "content_filter":
return "SAFETY"
default:
return "STOP"
}
}
func geminiBillingMetadataFromOpenAIUsage(usage *dto.Usage) (dto.GeminiUsageMetadata, bool) {
if usage == nil || usage.BillingUsage == nil || usage.BillingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
@@ -212,19 +537,22 @@ func geminiBillingMetadataFromOpenAIUsage(usage *dto.Usage) (dto.GeminiUsageMeta
if billingUsage == nil || billingUsage.GeminiUsageMetadata == nil {
return dto.GeminiUsageMetadata{}, false
}
return *billingUsage.GeminiUsageMetadata, true
metadata := *billingUsage.GeminiUsageMetadata
// Restore the sidecar marker on the restored native payload so the next
// hop keeps settling on the original dialect (including Estimated).
metadata.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
return metadata, true
}
func openAIBillingUsageFromUsage(usage *dto.Usage) *dto.BillingUsage {
if usage == nil {
return nil
}
if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil && existingBillingUsage.OpenAIUsage != nil {
if existingBillingUsage.Source == dto.BillingUsageSourceOAIChat ||
existingBillingUsage.Source == dto.BillingUsageSourceOAIResponses ||
existingBillingUsage.Semantic == dto.BillingUsageSemanticOpenAI {
return existingBillingUsage
}
// An existing sidecar snapshots the original provider usage; carry it
// across this bridge unchanged regardless of its dialect. Only synthesize
// an OpenAI snapshot when no sidecar exists yet.
if existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage); existingBillingUsage != nil {
return existingBillingUsage
}
return dto.NewOpenAIChatBillingUsage(usage)
}
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
"github.com/samber/lo"
)
@@ -358,9 +359,8 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
textRaw := convertChatResponseFormatToResponsesText(req.ResponseFormat)
maxOutputTokens := lo.FromPtrOr(req.MaxTokens, uint(0))
maxCompletionTokens := lo.FromPtrOr(req.MaxCompletionTokens, uint(0))
if maxCompletionTokens > maxOutputTokens {
maxOutputTokens = maxCompletionTokens
if req.MaxCompletionTokens != nil {
maxOutputTokens = *req.MaxCompletionTokens
}
// OpenAI Responses API rejects max_output_tokens < 16 when explicitly provided.
//if maxOutputTokens > 0 && maxOutputTokens < 16 {
@@ -412,11 +412,12 @@ func ChatCompletionsRequestToResponsesRequest(req *dto.GeneralOpenAIRequest) (*d
out.MaxOutputTokens = lo.ToPtr(maxOutputTokens)
}
if req.ReasoningEffort != "" {
out.Reasoning = &dto.Reasoning{
Effort: req.ReasoningEffort,
Summary: "detailed",
}
reasoningIntent, err := reasoning.FromOpenAIChat(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := reasoning.ApplyToOpenAIResponses(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
return out, nil
@@ -14,21 +14,22 @@ const (
chatFinishReasonLength = "length"
chatFinishReasonContentFilter = "content_filter"
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventIncomplete = "response.incomplete"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventIncomplete = "response.incomplete"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id string) (*dto.OpenAIResponsesResponse, *dto.Usage, error) {
@@ -57,7 +58,24 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
out.IncompleteDetails = details
}
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: fmt.Sprintf("%s_reasoning_0", id),
Status: responseOutputStatus(out),
Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: reasoning,
},
},
})
}
if text := choice.Message.StringContent(); text != "" {
annotations, err := chatAnnotationsToResponses(choice.Message.Annotations)
if err != nil {
return nil, nil, err
}
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: fmt.Sprintf("%s_msg_0", id),
@@ -67,20 +85,7 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
{
Type: "output_text",
Text: text,
Annotations: []interface{}{},
},
},
})
}
if reasoning := choice.Message.GetReasoningContent(); reasoning != "" {
out.Output = append(out.Output, dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: fmt.Sprintf("%s_reasoning_0", id),
Status: responseOutputStatus(out),
Content: []dto.ResponsesOutputContent{
{
Type: "summary_text",
Text: reasoning,
Annotations: annotations,
},
},
})
@@ -97,6 +102,35 @@ func ChatCompletionsResponseToResponsesResponse(resp *dto.OpenAITextResponse, id
return out, usage, nil
}
func chatAnnotationsToResponses(raw []byte) ([]interface{}, error) {
if len(raw) == 0 {
return []interface{}{}, nil
}
var annotations []map[string]any
if err := kitutil.Unmarshal(raw, &annotations); err != nil {
return nil, fmt.Errorf("invalid Chat annotations: %w", err)
}
converted := make([]interface{}, 0, len(annotations))
for _, annotation := range annotations {
if strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
converted = append(converted, annotation)
continue
}
citation, ok := annotation["url_citation"].(map[string]any)
if !ok {
converted = append(converted, annotation)
continue
}
flattened := make(map[string]any, len(citation)+1)
flattened["type"] = "url_citation"
for key, value := range citation {
flattened[key] = value
}
converted = append(converted, flattened)
}
return converted, nil
}
func ResponsesStatusFromChatFinishReason(finishReason string) (string, *dto.IncompleteDetails) {
switch strings.TrimSpace(finishReason) {
case chatFinishReasonLength:
@@ -230,3 +264,7 @@ func responsesStreamEvent(eventType string, payload dto.ResponsesStreamResponse)
func intPtr(v int) *int {
return &v
}
func stringPtr(v string) *string {
return &v
}
@@ -41,6 +41,27 @@ func TestChatCompletionsResponseToResponsesPreservesTextToolCallsAndUsage(t *tes
assert.Equal(t, `"{\"q\":\"x\"}"`, string(resp.Output[1].Arguments))
}
func TestChatCompletionsResponseToResponsesEmitsReasoningSummaryBeforeText(t *testing.T) {
message := dto.Message{Role: "assistant", Content: "final answer"}
message.ReasoningContent = lo.ToPtr("thinking summary")
resp, _, err := ChatCompletionsResponseToResponsesResponse(&dto.OpenAITextResponse{
Id: "chatcmpl_1",
Model: "gpt-test",
Choices: []dto.OpenAITextResponseChoice{
{Message: message, FinishReason: "stop"},
},
}, "resp_1")
require.NoError(t, err)
require.Len(t, resp.Output, 2)
assert.Equal(t, responsesOutputTypeReasoning, resp.Output[0].Type)
require.Len(t, resp.Output[0].Summary, 1)
assert.Equal(t, "thinking summary", resp.Output[0].Summary[0].Text)
assert.Empty(t, resp.Output[0].Content)
assert.Equal(t, responsesOutputTypeMessage, resp.Output[1].Type)
assert.Equal(t, "final answer", resp.Output[1].Content[0].Text)
}
func TestChatCompletionsResponseToResponsesMapsIncompleteFinishReasons(t *testing.T) {
tests := []struct {
name string
@@ -1,12 +1,14 @@
package oaichat
import (
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
type ChatToResponsesStreamEvent struct {
@@ -20,27 +22,35 @@ type ChatToResponsesStreamState struct {
Created int64
Usage *dto.Usage
status string
incompleteDetails *dto.IncompleteDetails
sentCreated bool
textOutputIndex int
textStarted bool
textDone bool
reasoningIndex int
reasoningStarted bool
reasoningDone bool
finalized bool
nextOutputIndex int
toolsByIndex map[int]*chatToResponsesStreamTool
outputOrder []chatToResponsesOutputRef
text strings.Builder
reasoning strings.Builder
// EmitSequenceNumber enables the required sequence_number field for current
// Responses API SSE consumers while preserving the legacy relaykit default.
EmitSequenceNumber bool
status string
incompleteDetails *dto.IncompleteDetails
sentCreated bool
textOutputIndex int
textStarted bool
textDone bool
reasoningIndex int
reasoningStarted bool
reasoningDone bool
finalized bool
nextSequenceNumber int
nextOutputIndex int
toolsByIndex map[int]*chatToResponsesStreamTool
hostedByID map[string]*chatToResponsesHostedTool
outputOrder []chatToResponsesOutputRef
text strings.Builder
annotations []interface{}
reasoning strings.Builder
}
type chatToResponsesStreamTool struct {
ChatIndex int
OutputIndex int
ID string
ItemID string
CallID string
Name string
Arguments strings.Builder
Done bool
@@ -49,6 +59,34 @@ type chatToResponsesStreamTool struct {
type chatToResponsesOutputRef struct {
Kind string
ToolIndex int
HostedID string
}
// HostedToolStreamStart describes a provider-hosted tool call that is already
// being executed upstream. It is intentionally separate from function calls:
// hosted calls have their own Responses lifecycle and result fields.
type HostedToolStreamStart struct {
Type string
ID string
Name string
Action []byte
Caller []byte
ServerLabel string
}
// HostedToolStreamResult completes a previously started hosted tool call.
type HostedToolStreamResult struct {
Type string
ID string
Result []byte
ErrorCode string
IsError bool
}
type chatToResponsesHostedTool struct {
OutputIndex int
Output dto.ResponsesOutput
Done bool
}
func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStreamState {
@@ -61,9 +99,213 @@ func NewChatToResponsesStreamState(id string, model string) *ChatToResponsesStre
textOutputIndex: -1,
reasoningIndex: -1,
toolsByIndex: make(map[int]*chatToResponsesStreamTool),
hostedByID: make(map[string]*chatToResponsesHostedTool),
}
}
func (s *ChatToResponsesStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ChatToResponsesStreamState) SetStreamUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = UsageFromChatUsage(usage)
}
}
func (s *ChatToResponsesStreamState) StartHostedTool(start HostedToolStreamStart) ([]ChatToResponsesStreamEvent, error) {
if s == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
start.ID = strings.TrimSpace(start.ID)
if start.ID == "" {
return nil, fmt.Errorf("hosted-tool stream call is missing an id")
}
if _, exists := s.hostedByID[start.ID]; exists {
return nil, fmt.Errorf("duplicate hosted-tool stream call id %q", start.ID)
}
if hostedEventPrefix(start.Type) == "" {
return nil, fmt.Errorf("unsupported Responses hosted-tool output type %q", start.Type)
}
caller := strings.TrimSpace(string(start.Caller))
if caller != "" && caller != "null" {
return nil, fmt.Errorf("Responses %s cannot preserve Claude hosted-tool caller provenance", start.Type)
}
tool := &chatToResponsesHostedTool{
Output: dto.ResponsesOutput{
Type: start.Type,
ID: start.ID,
Status: "in_progress",
},
}
switch start.Type {
case "web_search_call":
action, err := dto.NormalizeResponsesWebSearchAction(start.Action)
if err != nil {
return nil, err
}
tool.Output.Action = action
case "code_interpreter_call":
return nil, fmt.Errorf("cannot map provider code execution to Responses code_interpreter_call without a container_id")
case "mcp_call":
if strings.TrimSpace(start.Name) == "" || strings.TrimSpace(start.ServerLabel) == "" {
return nil, fmt.Errorf("Responses MCP call requires name and server_label")
}
arguments, err := hostedJSONString(start.Action)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
}
tool.Output.Name = start.Name
tool.Output.ServerLabel = start.ServerLabel
tool.Output.Arguments = arguments
}
outputIndex := s.nextHostedIndex(start.ID)
tool.OutputIndex = outputIndex
s.hostedByID[start.ID] = tool
events := s.ensureCreated()
addedItem := cloneHostedOutput(&tool.Output)
if start.Type == "mcp_call" {
addedItem.Arguments = json.RawMessage(`""`)
}
events = append(events,
s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Item: addedItem,
}),
s.event(hostedEventPrefix(start.Type)+".in_progress", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
}),
)
if start.Type == "web_search_call" {
events = append(events, s.event(hostedEventPrefix(start.Type)+".searching", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
}))
}
if start.Type == "mcp_call" {
arguments := dto.ResponsesArgumentsString(tool.Output.Arguments)
events = append(events,
s.event("response.mcp_call_arguments.delta", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Delta: arguments,
}),
s.event("response.mcp_call_arguments.done", dto.ResponsesStreamResponse{
OutputIndex: intPtr(outputIndex),
ItemID: start.ID,
Arguments: kitutil.GetPointer(arguments),
}),
)
}
return events, nil
}
func (s *ChatToResponsesStreamState) CompleteHostedTool(result HostedToolStreamResult) ([]ChatToResponsesStreamEvent, error) {
if s == nil {
return nil, fmt.Errorf("Chat-to-Responses stream state is required")
}
result.ID = strings.TrimSpace(result.ID)
tool := s.hostedByID[result.ID]
if tool == nil {
return nil, fmt.Errorf("hosted-tool result references unknown call %q", result.ID)
}
if tool.Done {
return nil, fmt.Errorf("duplicate hosted-tool result for call %q", result.ID)
}
if result.Type != "" && result.Type != tool.Output.Type {
return nil, fmt.Errorf("hosted-tool result type %q does not match call type %q", result.Type, tool.Output.Type)
}
failed := result.IsError || strings.TrimSpace(result.ErrorCode) != ""
tool.Output.Status = "completed"
switch tool.Output.Type {
case "web_search_call":
// Responses exposes only the action and lifecycle status on a
// web_search_call. Claude's opaque result payload cannot be emitted
// as a top-level `results` field.
case "code_interpreter_call":
return nil, fmt.Errorf("Responses code_interpreter_call is not supported without a container_id")
case "mcp_call":
output, err := hostedResultString(result.Result)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP output: %w", err)
}
tool.Output.Output = output
}
if failed {
tool.Output.Status = "failed"
errorValue := result.ErrorCode
if errorValue == "" {
errorValue = "hosted tool execution failed"
}
if tool.Output.Type == "mcp_call" {
encoded, err := kitutil.Marshal(errorValue)
if err != nil {
return nil, fmt.Errorf("marshal hosted-tool error: %w", err)
}
tool.Output.ItemError = encoded
tool.Output.Output = nil
}
}
tool.Done = true
events := make([]ChatToResponsesStreamEvent, 0, 2)
if eventType := hostedTerminalEvent(tool.Output.Type, failed); eventType != "" {
events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: result.ID,
}))
}
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: result.ID,
Item: cloneHostedOutput(&tool.Output),
}))
return events, nil
}
// Fail emits a terminal Responses error using the same event allocator as the
// rest of the stream, so callers never have to append a JSON HTTP error to an
// already-started SSE response.
func (s *ChatToResponsesStreamState) Fail(code string, message string, param string) []ChatToResponsesStreamEvent {
if s == nil || s.finalized {
return nil
}
code = strings.TrimSpace(code)
if code == "" {
code = "server_error"
}
message = strings.TrimSpace(message)
if message == "" {
message = "upstream response stream failed"
}
s.status = "failed"
events := s.ensureCreated()
events = append(events, s.doneDeltaEvents()...)
s.finalized = true
events = append(events, s.event("error", dto.ResponsesStreamResponse{
Code: code,
Message: message,
Param: param,
}))
response := s.finalResponse()
response.Error = map[string]any{
"code": code,
"message": message,
}
events = append(events, s.event("response.failed", dto.ResponsesStreamResponse{
Response: response,
}))
return events
}
func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStreamResponse, state *ChatToResponsesStreamState) ([]ChatToResponsesStreamEvent, error) {
if chunk == nil || state == nil {
return nil, nil
@@ -81,14 +323,7 @@ func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStrea
state.Usage = UsageFromChatUsage(chunk.Usage)
}
events := make([]ChatToResponsesStreamEvent, 0)
if !state.sentCreated {
state.sentCreated = true
events = append(events, responsesStreamEvent(responsesEventCreated, dto.ResponsesStreamResponse{
Type: responsesEventCreated,
Response: state.createdResponse(),
}))
}
events := state.ensureCreated()
for _, choice := range chunk.Choices {
if choice.Delta.GetReasoningContent() != "" {
events = append(events, state.appendReasoningDelta(choice.Delta.GetReasoningContent())...)
@@ -96,6 +331,13 @@ func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStrea
if choice.Delta.GetContentString() != "" {
events = append(events, state.appendTextDelta(choice.Delta.GetContentString())...)
}
if len(choice.Delta.Annotations) > 0 {
annotationEvents, err := state.appendAnnotationDelta(choice.Delta.Annotations)
if err != nil {
return nil, err
}
events = append(events, annotationEvents...)
}
for _, toolCall := range choice.Delta.ToolCalls {
toolEvents, err := state.appendToolCallDelta(toolCall)
if err != nil {
@@ -111,6 +353,17 @@ func ChatCompletionsStreamChunkToResponsesEvents(chunk *dto.ChatCompletionsStrea
return events, nil
}
func (s *ChatToResponsesStreamState) ensureCreated() []ChatToResponsesStreamEvent {
if s.sentCreated {
return nil
}
s.sentCreated = true
return []ChatToResponsesStreamEvent{s.event(responsesEventCreated, dto.ResponsesStreamResponse{
Type: responsesEventCreated,
Response: s.createdResponse(),
})}
}
func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState) []ChatToResponsesStreamEvent {
if state == nil || state.finalized {
return nil
@@ -122,7 +375,7 @@ func FinalizeChatCompletionsStreamToResponses(state *ChatToResponsesStreamState)
if state.status == "incomplete" {
eventType = responsesEventIncomplete
}
events = append(events, responsesStreamEvent(eventType, dto.ResponsesStreamResponse{
events = append(events, state.event(eventType, dto.ResponsesStreamResponse{
Type: eventType,
Response: resp,
}))
@@ -137,24 +390,9 @@ func (s *ChatToResponsesStreamState) UsageText() string {
}
func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0, 2)
if !s.textStarted {
s.textStarted = true
s.textOutputIndex = s.nextIndex("message", -1)
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.textOutputIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: "in_progress",
Role: "assistant",
Content: []dto.ResponsesOutputContent{},
},
}))
}
events := s.startText()
s.text.WriteString(delta)
events = append(events, responsesStreamEvent(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
events = append(events, s.event(responsesEventOutputTextDelta, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextDelta,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
@@ -164,24 +402,68 @@ func (s *ChatToResponsesStreamState) appendTextDelta(delta string) []ChatToRespo
return events
}
func (s *ChatToResponsesStreamState) startText() []ChatToResponsesStreamEvent {
if !s.textStarted {
s.textStarted = true
s.textOutputIndex = s.nextIndex("message", -1)
return []ChatToResponsesStreamEvent{s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.textOutputIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
Status: "in_progress",
Role: "assistant",
Content: []dto.ResponsesOutputContent{},
},
})}
}
return nil
}
func (s *ChatToResponsesStreamState) appendAnnotationDelta(raw []byte) ([]ChatToResponsesStreamEvent, error) {
annotations, err := chatAnnotationsToResponses(raw)
if err != nil {
return nil, err
}
events := s.startText()
for _, annotation := range annotations {
annotationJSON, err := kitutil.Marshal(annotation)
if err != nil {
return nil, fmt.Errorf("marshal Responses annotation: %w", err)
}
annotationIndex := len(s.annotations)
s.annotations = append(s.annotations, annotation)
events = append(events, s.event(responsesEventOutputTextAnnotationAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputTextAnnotationAdded,
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
AnnotationIndex: intPtr(annotationIndex),
Annotation: annotationJSON,
ItemID: s.messageID(),
}))
}
return events, nil
}
func (s *ChatToResponsesStreamState) appendReasoningDelta(delta string) []ChatToResponsesStreamEvent {
events := make([]ChatToResponsesStreamEvent, 0, 2)
if !s.reasoningStarted {
s.reasoningStarted = true
s.reasoningIndex = s.nextIndex("reasoning", -1)
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(s.reasoningIndex),
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: "in_progress",
Content: []dto.ResponsesOutputContent{},
Summary: []dto.ResponsesReasoningSummaryPart{},
},
}))
}
s.reasoning.WriteString(delta)
events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
events = append(events, s.event(responsesEventReasoningSummaryDelta, dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDelta,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
@@ -196,45 +478,57 @@ func (s *ChatToResponsesStreamState) appendToolCallDelta(toolCall dto.ToolCallRe
if toolCall.Index != nil {
chatIndex = *toolCall.Index
}
incomingID := strings.TrimSpace(toolCall.ID)
tool := s.toolsByIndex[chatIndex]
events := make([]ChatToResponsesStreamEvent, 0, 2)
if tool == nil {
tool = &chatToResponsesStreamTool{
ChatIndex: chatIndex,
OutputIndex: s.nextIndex("tool", chatIndex),
ID: strings.TrimSpace(toolCall.ID),
CallID: incomingID,
Name: strings.TrimSpace(toolCall.Function.Name),
}
if tool.ID == "" {
tool.ID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
tool.ItemID = incomingID
if tool.ItemID == "" {
tool.ItemID = fmt.Sprintf("%s_call_%d", s.ID, chatIndex)
}
s.toolsByIndex[chatIndex] = tool
events = append(events, responsesStreamEvent(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
events = append(events, s.event(responsesEventOutputItemAdded, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ID,
ItemID: tool.ItemID,
Item: &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ID,
ID: tool.ItemID,
Status: "in_progress",
CallId: tool.ID,
CallId: tool.callID(),
Name: tool.Name,
Arguments: []byte(`""`),
},
}))
}
if strings.TrimSpace(toolCall.ID) != "" {
tool.ID = strings.TrimSpace(toolCall.ID)
if tool.Done {
return nil, fmt.Errorf("tool-call stream index %d received data after completion", chatIndex)
}
if strings.TrimSpace(toolCall.Function.Name) != "" {
tool.Name = strings.TrimSpace(toolCall.Function.Name)
if incomingID != "" {
if tool.CallID != "" && tool.CallID != incomingID {
return nil, fmt.Errorf("tool-call stream index %d changed id from %q to %q", chatIndex, tool.CallID, incomingID)
}
tool.CallID = incomingID
}
incomingName := strings.TrimSpace(toolCall.Function.Name)
if incomingName != "" {
if tool.Name != "" && tool.Name != incomingName {
return nil, fmt.Errorf("tool-call stream index %d changed name from %q to %q", chatIndex, tool.Name, incomingName)
}
tool.Name = incomingName
}
if toolCall.Function.Arguments != "" {
tool.Arguments.WriteString(toolCall.Function.Arguments)
events = append(events, responsesStreamEvent(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
events = append(events, s.event(responsesEventFunctionArgsDelta, dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDelta,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ID,
ItemID: tool.ItemID,
Delta: toolCall.Function.Arguments,
}))
}
@@ -246,13 +540,17 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
status := s.outputStatus()
if s.textStarted && !s.textDone {
s.textDone = true
events = append(events, responsesStreamEvent("response.output_text.done", dto.ResponsesStreamResponse{
textDone := dto.ResponsesStreamResponse{
Type: "response.output_text.done",
OutputIndex: intPtr(s.textOutputIndex),
ContentIndex: intPtr(0),
ItemID: s.messageID(),
}))
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
}
if s.EmitSequenceNumber {
textDone.Text = kitutil.GetPointer(s.text.String())
}
events = append(events, s.event("response.output_text.done", textDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.textOutputIndex),
Item: s.messageOutput(status),
@@ -260,7 +558,7 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
}
if s.reasoningStarted && !s.reasoningDone {
s.reasoningDone = true
events = append(events, responsesStreamEvent(responsesEventReasoningSummaryDone, dto.ResponsesStreamResponse{
reasoningDone := dto.ResponsesStreamResponse{
Type: responsesEventReasoningSummaryDone,
OutputIndex: intPtr(s.reasoningIndex),
SummaryIndex: intPtr(0),
@@ -269,8 +567,13 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
Type: "summary_text",
Text: s.reasoning.String(),
},
}))
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
}
if s.EmitSequenceNumber {
reasoningDone.Text = kitutil.GetPointer(s.reasoning.String())
reasoningDone.Part = nil
}
events = append(events, s.event(responsesEventReasoningSummaryDone, reasoningDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(s.reasoningIndex),
Item: s.reasoningOutput(status),
@@ -281,17 +584,55 @@ func (s *ChatToResponsesStreamState) doneDeltaEvents() []ChatToResponsesStreamEv
continue
}
tool.Done = true
events = append(events, responsesStreamEvent(responsesEventFunctionArgsDone, dto.ResponsesStreamResponse{
argumentsDone := dto.ResponsesStreamResponse{
Type: responsesEventFunctionArgsDone,
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.ID,
}))
events = append(events, responsesStreamEvent(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
ItemID: tool.ItemID,
}
if s.EmitSequenceNumber {
argumentsDone.Arguments = kitutil.GetPointer(tool.Arguments.String())
argumentsDone.Name = tool.Name
}
events = append(events, s.event(responsesEventFunctionArgsDone, argumentsDone))
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
Type: responsesEventOutputItemDone,
OutputIndex: intPtr(tool.OutputIndex),
Item: s.toolOutput(tool, status),
}))
}
for _, ref := range s.outputOrder {
if ref.Kind != "hosted" {
continue
}
tool := s.hostedByID[ref.HostedID]
if tool == nil || tool.Done {
continue
}
if s.status != "failed" {
s.status = "incomplete"
}
tool.Done = true
tool.Output.Status = "incomplete"
if s.status == "failed" {
tool.Output.Status = "failed"
errorValue, err := kitutil.Marshal("provider stream failed before hosted-tool result")
if err == nil && tool.Output.Type == "mcp_call" {
tool.Output.ItemError = errorValue
tool.Output.Output = nil
}
if eventType := hostedTerminalEvent(tool.Output.Type, true); eventType != "" {
events = append(events, s.event(eventType, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.Output.ID,
}))
}
}
events = append(events, s.event(responsesEventOutputItemDone, dto.ResponsesStreamResponse{
OutputIndex: intPtr(tool.OutputIndex),
ItemID: tool.Output.ID,
Item: cloneHostedOutput(&tool.Output),
}))
}
return events
}
@@ -315,6 +656,10 @@ func (s *ChatToResponsesStreamState) finalResponse() *dto.OpenAIResponsesRespons
if tool := s.toolsByIndex[ref.ToolIndex]; tool != nil {
output = append(output, *s.toolOutput(tool, status))
}
case "hosted":
if tool := s.hostedByID[ref.HostedID]; tool != nil {
output = append(output, *cloneHostedOutput(&tool.Output))
}
}
}
return &dto.OpenAIResponsesResponse{
@@ -347,6 +692,13 @@ func (s *ChatToResponsesStreamState) nextIndex(kind string, toolIndex int) int {
return index
}
func (s *ChatToResponsesStreamState) nextHostedIndex(id string) int {
index := s.nextOutputIndex
s.nextOutputIndex++
s.outputOrder = append(s.outputOrder, chatToResponsesOutputRef{Kind: "hosted", HostedID: id})
return index
}
func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool {
indexes := make([]int, 0, len(s.toolsByIndex))
for index := range s.toolsByIndex {
@@ -361,7 +713,7 @@ func (s *ChatToResponsesStreamState) sortedTools() []*chatToResponsesStreamTool
}
func (s *ChatToResponsesStreamState) outputStatus() string {
if s.status == "incomplete" {
if s.status == "incomplete" || s.status == "failed" {
return "incomplete"
}
return "completed"
@@ -376,6 +728,10 @@ func (s *ChatToResponsesStreamState) reasoningID() string {
}
func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.ResponsesOutput {
annotations := s.annotations
if annotations == nil {
annotations = []interface{}{}
}
return &dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: s.messageID(),
@@ -385,7 +741,7 @@ func (s *ChatToResponsesStreamState) messageOutput(status string) *dto.Responses
{
Type: "output_text",
Text: s.text.String(),
Annotations: []interface{}{},
Annotations: annotations,
},
},
}
@@ -396,7 +752,7 @@ func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.Respons
Type: responsesOutputTypeReasoning,
ID: s.reasoningID(),
Status: status,
Content: []dto.ResponsesOutputContent{
Summary: []dto.ResponsesReasoningSummaryPart{
{
Type: "summary_text",
Text: s.reasoning.String(),
@@ -408,10 +764,99 @@ func (s *ChatToResponsesStreamState) reasoningOutput(status string) *dto.Respons
func (s *ChatToResponsesStreamState) toolOutput(tool *chatToResponsesStreamTool, status string) *dto.ResponsesOutput {
return &dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ID,
ID: tool.ItemID,
Status: status,
CallId: tool.ID,
CallId: tool.callID(),
Name: tool.Name,
Arguments: chatArgumentsRawMessage(tool.Arguments.String()),
}
}
func (t *chatToResponsesStreamTool) callID() string {
if t == nil {
return ""
}
if t.CallID == "" {
return t.ItemID
}
return t.CallID
}
func hostedEventPrefix(outputType string) string {
switch outputType {
case "web_search_call":
return "response.web_search_call"
case "mcp_call":
return "response.mcp_call"
default:
return ""
}
}
func hostedTerminalEvent(outputType string, failed bool) string {
prefix := hostedEventPrefix(outputType)
if prefix == "" {
return ""
}
if !failed {
return prefix + ".completed"
}
// OpenAI currently defines a dedicated failed lifecycle event for MCP.
// Web search and code interpreter surface failure on output_item.done.
if outputType == "mcp_call" {
return prefix + ".failed"
}
return ""
}
func hostedJSONString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !json.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
encoded, err := kitutil.Marshal(string(value))
if err != nil {
return nil, err
}
return encoded, nil
}
func hostedResultString(value []byte) (json.RawMessage, error) {
if len(value) == 0 {
return json.RawMessage(`""`), nil
}
if !json.Valid(value) {
return nil, fmt.Errorf("invalid JSON payload")
}
if kitutil.GetJsonType(value) == "string" {
return append(json.RawMessage(nil), value...), nil
}
return hostedJSONString(value)
}
func cloneHostedOutput(output *dto.ResponsesOutput) *dto.ResponsesOutput {
if output == nil {
return nil
}
clone := *output
clone.Action = append([]byte(nil), output.Action...)
clone.Arguments = append([]byte(nil), output.Arguments...)
clone.Code = append([]byte(nil), output.Code...)
clone.Results = append([]byte(nil), output.Results...)
clone.Outputs = append([]byte(nil), output.Outputs...)
clone.Output = append([]byte(nil), output.Output...)
clone.ItemError = append([]byte(nil), output.ItemError...)
clone.Caller = append([]byte(nil), output.Caller...)
return &clone
}
func (s *ChatToResponsesStreamState) event(eventType string, payload dto.ResponsesStreamResponse) ChatToResponsesStreamEvent {
if s.EmitSequenceNumber {
sequenceNumber := s.nextSequenceNumber
s.nextSequenceNumber++
payload.SequenceNumber = &sequenceNumber
}
return responsesStreamEvent(eventType, payload)
}
@@ -1,15 +1,16 @@
package oairesponses
import (
"context"
"fmt"
"strings"
"context"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func convertOpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Meta, request any) (any, error) {
@@ -40,13 +41,6 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
claudeRequest.MaxTokens = kitutil.GetPointer(*req.MaxOutputTokens)
}
if claudeRequest.MaxTokens == nil || *claudeRequest.MaxTokens == 0 {
if defaultMaxTokens, configured := convmeta.OptionsOf(info).Claude.DefaultMaxTokensFor(req.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
functions, err := RequestFunctionDeclarations(req.Tools)
if err != nil {
return nil, err
@@ -62,7 +56,19 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if toolChoice != nil || RawJSONPresent(req.ParallelToolCalls) {
claudeRequest.ToolChoice = sharedclaude.MapOpenAIToolChoice(toolChoice, ParallelToolCalls(req.ParallelToolCalls))
}
applyResponsesReasoningToClaude(req, claudeRequest)
sourceReasoning, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
if err := sharedclaude.ApplyReasoning(claudeRequest, info, sourceReasoning); err != nil {
return nil, reasoning.AsClientError(err)
}
if claudeRequest.MaxTokens == nil {
if defaultMaxTokens, configured := convmeta.OptionsOf(info).Claude.DefaultMaxTokensFor(claudeRequest.Model); configured {
value := uint(defaultMaxTokens)
claudeRequest.MaxTokens = &value
}
}
systemMessages := make([]dto.ClaudeMediaMessage, 0)
if RawJSONPresent(req.Instructions) {
@@ -92,13 +98,21 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
claudeRequest.Messages = appendClaudeToolResult(claudeRequest.Messages, responsesFunctionOutputItemToClaudeToolResult(item))
default:
role := responsesClaudeRole(item)
sourceRole := strings.TrimSpace(kitutil.Interface2String(item["role"]))
role := responsesClaudeRole(sourceRole)
parts, err := responsesInputContentToClaudeMediaMessages(c, item["content"])
if err != nil {
return nil, err
}
if sourceRole == "" && len(parts) == 0 {
continue
}
if role == "system" {
systemMessages = append(systemMessages, parts...)
for _, part := range parts {
if part.Type == "text" {
systemMessages = append(systemMessages, part)
}
}
continue
}
if len(parts) == 0 {
@@ -119,7 +133,9 @@ func OpenAIResponsesRequestToClaudeMessages(c context.Context, info convmeta.Met
if len(systemMessages) > 0 {
claudeRequest.System = systemMessages
}
claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
if len(claudeRequest.Messages) > 0 || len(systemMessages) > 0 {
claudeRequest.Messages = ensureClaudeMessagesStartWithUser(claudeRequest.Messages)
}
// Checked last so every injection path has had its chance to satisfy the
// required field.
if claudeRequest.MaxTokens == nil {
@@ -140,27 +156,6 @@ func responsesFunctionDeclarationsToClaudeTools(functions []dto.FunctionRequest)
return tools
}
func applyResponsesReasoningToClaude(req *dto.OpenAIResponsesRequest, claudeRequest *dto.ClaudeRequest) {
effort := ReasoningEffort(req)
switch effort {
case "low":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer(1280),
}
case "medium":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer(2048),
}
case "high":
claudeRequest.Thinking = &dto.Thinking{
Type: "enabled",
BudgetTokens: kitutil.GetPointer(4096),
}
}
}
func responsesInputContentToClaudeMediaMessages(c context.Context, content any) ([]dto.ClaudeMediaMessage, error) {
contentParts, err := ContentParts(content)
if err != nil {
@@ -280,8 +275,8 @@ func claudeMessageContentParts(content any) []dto.ClaudeMediaMessage {
}
}
func responsesClaudeRole(item map[string]any) string {
switch strings.TrimSpace(kitutil.Interface2String(item["role"])) {
func responsesClaudeRole(role string) string {
switch role {
case "assistant":
return "assistant"
case "system", "developer":
@@ -292,7 +287,7 @@ func responsesClaudeRole(item map[string]any) string {
}
func ensureClaudeMessagesStartWithUser(messages []dto.ClaudeMessage) []dto.ClaudeMessage {
if len(messages) == 0 || messages[0].Role == "user" {
if len(messages) > 0 && messages[0].Role == "user" {
return messages
}
return append([]dto.ClaudeMessage{
@@ -0,0 +1,156 @@
package oairesponses
import (
"encoding/json"
"errors"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/reasonmap"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
func ResponsesResponseToClaudeMessagesResponse(resp *dto.OpenAIResponsesResponse) (*dto.ClaudeResponse, *dto.Usage, error) {
if resp == nil {
return nil, nil, errors.New("response is nil")
}
usage := UsageFromResponsesUsage(resp.Usage)
claudeResponse := &dto.ClaudeResponse{
Id: resp.ID,
Type: "message",
Role: "assistant",
Model: resp.Model,
Usage: sharedclaude.UsageFromOpenAI(usage),
}
sawToolCall := false
for index := range resp.Output {
output := resp.Output[index]
if output.Type == responsesOutputTypeMessage && output.Role != "" && output.Role != "assistant" {
continue
}
switch output.Type {
case responsesOutputTypeReasoning:
if thinking := reasoningOutputText(&output); thinking != "" {
claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
Type: "thinking",
Thinking: kitutil.GetPointer(thinking),
})
}
case responsesOutputTypeMessage:
for _, content := range output.Content {
if content.Type != "output_text" {
continue
}
block := dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer(content.Text)}
if citations := responsesAnnotationsToClaude(content.Annotations, content.Text); len(citations) > 0 {
block.Citations, _ = kitutil.Marshal(citations)
}
claudeResponse.Content = append(claudeResponse.Content, block)
}
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
sawToolCall = true
callID := strings.TrimSpace(output.CallId)
if callID == "" {
callID = strings.TrimSpace(output.ID)
}
claudeResponse.Content = append(claudeResponse.Content, dto.ClaudeMediaMessage{
Type: "tool_use",
Id: callID,
Name: output.Name,
Input: responsesArgumentsToClaudeInput(output.ArgumentsString()),
})
}
}
if len(claudeResponse.Content) == 0 {
claudeResponse.Content = []dto.ClaudeMediaMessage{{Type: "text", Text: kitutil.GetPointer("")}}
}
claudeResponse.StopReason = responsesClaudeStopReason(resp, sawToolCall)
return claudeResponse, usage, nil
}
func responsesArgumentsToClaudeInput(arguments string) map[string]any {
input := make(map[string]any)
if strings.TrimSpace(arguments) == "" {
return input
}
if err := kitutil.Unmarshal([]byte(arguments), &input); err == nil && input != nil {
return input
}
return map[string]any{"input": arguments}
}
func responsesClaudeStopReason(resp *dto.OpenAIResponsesResponse, sawToolCall bool) string {
if finishReason, ok := ResponsesFinishReasonFromStatus(resp); ok {
return reasonmap.OpenAIFinishReasonToClaudeStopReason(finishReason)
}
if sawToolCall {
return "tool_use"
}
return "end_turn"
}
func responsesAnnotationsToClaude(annotations []interface{}, text string) []json.RawMessage {
citations := make([]json.RawMessage, 0, len(annotations))
for _, rawAnnotation := range annotations {
annotation, err := kitutil.Any2Type[map[string]any](rawAnnotation)
if err != nil || strings.TrimSpace(kitutil.Interface2String(annotation["type"])) != "url_citation" {
continue
}
citation := annotation
if nested, ok := annotation["url_citation"].(map[string]any); ok {
citation = nested
}
url := strings.TrimSpace(kitutil.Interface2String(citation["url"]))
if url == "" {
continue
}
converted := map[string]any{
"type": "web_search_result_location",
"url": url,
"title": strings.TrimSpace(kitutil.Interface2String(citation["title"])),
}
if citedText := kitutil.Interface2String(citation["cited_text"]); citedText != "" {
converted["cited_text"] = citedText
} else if citedText := responsesCitedText(text, citation); citedText != "" {
converted["cited_text"] = citedText
}
if encryptedIndex := kitutil.Interface2String(citation["encrypted_index"]); encryptedIndex != "" {
converted["encrypted_index"] = encryptedIndex
}
if converted["title"] == "" {
delete(converted, "title")
}
encoded, err := kitutil.Marshal(converted)
if err == nil {
citations = append(citations, encoded)
}
}
return citations
}
func responsesCitedText(text string, citation map[string]any) string {
start, startOK := responsesAnnotationIndex(citation["start_index"])
end, endOK := responsesAnnotationIndex(citation["end_index"])
if !startOK || !endOK || start < 0 || end <= start || end > utf8.RuneCountInString(text) {
return ""
}
runes := []rune(text)
return string(runes[start:end])
}
func responsesAnnotationIndex(value any) (int, bool) {
switch number := value.(type) {
case float64:
return int(number), number >= 0 && number == float64(int(number))
case int:
return number, number >= 0
case json.Number:
parsed, err := number.Int64()
return int(parsed), err == nil && parsed >= 0
default:
return 0, false
}
}
@@ -0,0 +1,500 @@
package oairesponses
import (
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
sharedclaude "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/claude"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
const responsesEventOutputTextDone = "response.output_text.done"
type ResponsesToClaudeStreamState struct {
ID string
Model string
Usage *dto.Usage
sentMessageStart bool
done bool
sawToolCall bool
nextBlockIndex int
blocks []*responsesClaudeStreamBlock
byOutputIndex map[int]*responsesClaudeStreamBlock
byItemID map[string]*responsesClaudeStreamBlock
lastByKind map[string]*responsesClaudeStreamBlock
usageText strings.Builder
}
type responsesClaudeStreamBlock struct {
Index int
Kind string
ItemID string
CallID string
Name string
Started bool
Stopped bool
Value strings.Builder
SentBytes int
AnnotationCount int
NeedsReasoningBreak bool
}
func NewResponsesToClaudeStreamState(id string, model string) *ResponsesToClaudeStreamState {
return &ResponsesToClaudeStreamState{
ID: strings.TrimSpace(id),
Model: strings.TrimSpace(model),
byOutputIndex: make(map[int]*responsesClaudeStreamBlock),
byItemID: make(map[string]*responsesClaudeStreamBlock),
lastByKind: make(map[string]*responsesClaudeStreamBlock),
}
}
func (s *ResponsesToClaudeStreamState) UsageText() string {
if s == nil {
return ""
}
return s.usageText.String()
}
func (s *ResponsesToClaudeStreamState) Done() bool {
return s != nil && s.done
}
func (s *ResponsesToClaudeStreamState) SetUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = usage
}
}
func (s *ResponsesToClaudeStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ResponsesToClaudeStreamState) SetStreamUsage(usage *dto.Usage) {
s.SetUsage(usage)
}
func (s *ResponsesToClaudeStreamState) ConvertChunk(event *dto.ResponsesStreamResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, *dto.Usage, error) {
if s == nil {
return nil, nil, nil
}
if event == nil || s.done {
return nil, s.Usage, nil
}
s.applyResponseMetadata(event.Response)
switch event.Type {
case responsesEventCreated:
return s.ensureMessageStart(estimatedInputTokens), s.Usage, nil
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
block, err := s.ensureBlock(event, "thinking")
if err != nil {
return nil, s.Usage, err
}
delta := event.Delta
if block.NeedsReasoningBreak && delta != "" {
delta = separatedResponsesDelta(delta)
block.NeedsReasoningBreak = false
}
return s.appendDelta(block, delta, estimatedInputTokens), s.Usage, nil
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
block, err := s.ensureBlock(event, "thinking")
if err != nil {
return nil, s.Usage, err
}
var responses []*dto.ClaudeResponse
if event.Text != nil {
responses = append(responses, s.mergeFinalValue(block, *event.Text, estimatedInputTokens)...)
}
if block.Value.Len() > 0 {
block.NeedsReasoningBreak = true
}
return responses, s.Usage, nil
case responsesEventOutputTextDelta:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
case responsesEventOutputTextDone:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
if event.Text == nil {
return nil, s.Usage, nil
}
return s.mergeFinalValue(block, *event.Text, estimatedInputTokens), s.Usage, nil
case responsesEventOutputTextAnnotationAdded:
block, err := s.ensureBlock(event, "text")
if err != nil {
return nil, s.Usage, err
}
var annotation any
if err := kitutil.Unmarshal(event.Annotation, &annotation); err != nil {
return nil, s.Usage, fmt.Errorf("invalid Responses stream annotation: %w", err)
}
return s.appendAnnotations(block, []any{annotation}, estimatedInputTokens, false), s.Usage, nil
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
responses, err := s.applyOutputItem(event, estimatedInputTokens, event.Type == responsesEventOutputItemDone)
return responses, s.Usage, err
case responsesEventFunctionArgsDelta, responsesEventCustomToolInputDelta:
block, err := s.ensureBlock(event, "tool_use")
if err != nil {
return nil, s.Usage, err
}
return s.appendDelta(block, event.Delta, estimatedInputTokens), s.Usage, nil
case responsesEventFunctionArgsDone, responsesEventCustomToolInputDone:
block, err := s.ensureBlock(event, "tool_use")
if err != nil {
return nil, s.Usage, err
}
if event.Arguments == nil {
return nil, s.Usage, nil
}
return s.mergeFinalValue(block, *event.Arguments, estimatedInputTokens), s.Usage, nil
case responsesEventCompleted, responsesEventDone, responsesEventIncomplete:
responses, err := s.finish(event.Response, estimatedInputTokens)
return responses, s.Usage, err
case responsesEventFailed, responsesEventError:
message := strings.TrimSpace(event.Message)
if message == "" {
message = event.Type
}
return nil, s.Usage, fmt.Errorf("responses stream error: %s", message)
default:
return nil, s.Usage, nil
}
}
func (s *ResponsesToClaudeStreamState) Finalize(estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
if s == nil || s.done {
return nil, nil
}
return s.finish(nil, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) applyResponseMetadata(response *dto.OpenAIResponsesResponse) {
if s == nil || response == nil {
return
}
if response.ID != "" {
s.ID = response.ID
}
if response.Model != "" {
s.Model = response.Model
}
if response.Usage != nil {
s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
}
}
func (s *ResponsesToClaudeStreamState) ensureMessageStart(estimatedInputTokens int) []*dto.ClaudeResponse {
if s.sentMessageStart {
return nil
}
s.sentMessageStart = true
inputTokens := estimatedInputTokens
if s.Usage != nil {
if usage := sharedclaude.UsageFromOpenAI(s.Usage); usage != nil {
inputTokens = usage.InputTokens
}
}
message := &dto.ClaudeMediaMessage{
Id: s.ID,
Type: "message",
Role: "assistant",
Model: s.Model,
Usage: &dto.ClaudeUsage{InputTokens: inputTokens},
}
message.SetContent(make([]any, 0))
return []*dto.ClaudeResponse{{Type: "message_start", Message: message}}
}
func (s *ResponsesToClaudeStreamState) ensureBlock(event *dto.ResponsesStreamResponse, kind string) (*responsesClaudeStreamBlock, error) {
block := s.findBlock(event)
if block == nil {
if last := s.lastByKind[kind]; last != nil && !last.Stopped && event.OutputIndex == nil && responseStreamEventItemID(event) == "" {
block = last
}
}
if block == nil {
block = &responsesClaudeStreamBlock{Index: s.nextBlockIndex, Kind: kind}
s.nextBlockIndex++
s.blocks = append(s.blocks, block)
}
if block.Kind == "" {
block.Kind = kind
}
if block.Kind != kind {
return nil, fmt.Errorf("Responses output item changed from %s to %s", block.Kind, kind)
}
s.applyBlockMetadata(block, event)
s.lastByKind[kind] = block
return block, nil
}
func (s *ResponsesToClaudeStreamState) findBlock(event *dto.ResponsesStreamResponse) *responsesClaudeStreamBlock {
if event == nil {
return nil
}
if event.OutputIndex != nil {
if block := s.byOutputIndex[*event.OutputIndex]; block != nil {
return block
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
return s.byItemID[itemID]
}
return nil
}
func (s *ResponsesToClaudeStreamState) applyBlockMetadata(block *responsesClaudeStreamBlock, event *dto.ResponsesStreamResponse) {
if block == nil || event == nil {
return
}
if event.OutputIndex != nil {
s.byOutputIndex[*event.OutputIndex] = block
}
if itemID := responseStreamEventItemID(event); itemID != "" {
block.ItemID = itemID
s.byItemID[itemID] = block
}
if event.Item == nil {
return
}
if callID := strings.TrimSpace(event.Item.CallId); callID != "" {
block.CallID = callID
} else if block.CallID == "" {
block.CallID = strings.TrimSpace(event.Item.ID)
}
if name := strings.TrimSpace(event.Item.Name); name != "" {
block.Name = name
}
}
func (s *ResponsesToClaudeStreamState) startBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Started || block.Stopped {
return nil
}
var content dto.ClaudeMediaMessage
switch block.Kind {
case "text":
content = dto.ClaudeMediaMessage{Type: "text", Text: kitutil.GetPointer("")}
case "thinking":
content = dto.ClaudeMediaMessage{Type: "thinking", Thinking: kitutil.GetPointer("")}
case "tool_use":
if block.Name == "" {
return nil
}
callID := block.CallID
if callID == "" {
callID = block.ItemID
}
content = dto.ClaudeMediaMessage{Type: "tool_use", Id: callID, Name: block.Name, Input: map[string]any{}}
s.sawToolCall = true
default:
return nil
}
block.Started = true
responses := s.ensureMessageStart(estimatedInputTokens)
index := block.Index
responses = append(responses, &dto.ClaudeResponse{Type: "content_block_start", Index: &index, ContentBlock: &content})
return responses
}
func (s *ResponsesToClaudeStreamState) appendDelta(block *responsesClaudeStreamBlock, delta string, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped || delta == "" {
return nil
}
block.Value.WriteString(delta)
return s.flushBlock(block, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) mergeFinalValue(block *responsesClaudeStreamBlock, finalValue string, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
current := block.Value.String()
if current == "" {
block.Value.WriteString(finalValue)
} else if strings.HasPrefix(finalValue, current) {
block.Value.WriteString(finalValue[len(current):])
}
return s.flushBlock(block, estimatedInputTokens)
}
func (s *ResponsesToClaudeStreamState) flushBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
responses := s.startBlock(block, estimatedInputTokens)
if !block.Started {
return responses
}
value := block.Value.String()
if block.SentBytes >= len(value) {
return responses
}
delta := value[block.SentBytes:]
block.SentBytes = len(value)
s.usageText.WriteString(delta)
index := block.Index
media := &dto.ClaudeMediaMessage{}
switch block.Kind {
case "text":
media.Type = "text_delta"
media.Text = &delta
case "thinking":
media.Type = "thinking_delta"
media.Thinking = &delta
case "tool_use":
media.Type = "input_json_delta"
media.PartialJson = &delta
}
responses = append(responses, &dto.ClaudeResponse{Type: "content_block_delta", Index: &index, Delta: media})
return responses
}
func (s *ResponsesToClaudeStreamState) stopBlock(block *responsesClaudeStreamBlock, estimatedInputTokens int) []*dto.ClaudeResponse {
if block == nil || block.Stopped {
return nil
}
responses := s.flushBlock(block, estimatedInputTokens)
responses = append(responses, s.startBlock(block, estimatedInputTokens)...)
if !block.Started {
return responses
}
block.Stopped = true
index := block.Index
return append(responses, &dto.ClaudeResponse{Type: "content_block_stop", Index: &index})
}
func (s *ResponsesToClaudeStreamState) applyOutputItem(event *dto.ResponsesStreamResponse, estimatedInputTokens int, stop bool) ([]*dto.ClaudeResponse, error) {
if event == nil || event.Item == nil {
return nil, nil
}
item := event.Item
var kind string
switch item.Type {
case responsesOutputTypeReasoning:
kind = "thinking"
case responsesOutputTypeMessage:
if item.Role != "" && item.Role != "assistant" {
return nil, nil
}
kind = "text"
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
kind = "tool_use"
default:
return nil, nil
}
block, err := s.ensureBlock(event, kind)
if err != nil {
return nil, err
}
var responses []*dto.ClaudeResponse
switch kind {
case "thinking":
responses = append(responses, s.mergeFinalValue(block, reasoningOutputText(item), estimatedInputTokens)...)
case "text":
var text strings.Builder
var annotations []any
for _, content := range item.Content {
if content.Type != "output_text" {
continue
}
text.WriteString(content.Text)
annotations = append(annotations, content.Annotations...)
}
responses = append(responses, s.mergeFinalValue(block, text.String(), estimatedInputTokens)...)
responses = append(responses, s.appendAnnotations(block, annotations, estimatedInputTokens, true)...)
case "tool_use":
responses = append(responses, s.mergeFinalValue(block, item.ArgumentsString(), estimatedInputTokens)...)
}
if stop {
responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
}
return responses, nil
}
func (s *ResponsesToClaudeStreamState) appendAnnotations(block *responsesClaudeStreamBlock, annotations []any, estimatedInputTokens int, snapshot bool) []*dto.ClaudeResponse {
if block == nil || block.Kind != "text" || block.Stopped || len(annotations) == 0 {
return nil
}
remaining := annotations
if snapshot {
if len(annotations) <= block.AnnotationCount {
return nil
}
remaining = annotations[block.AnnotationCount:]
block.AnnotationCount = len(annotations)
} else {
block.AnnotationCount += len(annotations)
}
citations := responsesAnnotationsToClaude(remaining, block.Value.String())
if len(citations) == 0 {
return nil
}
responses := s.startBlock(block, estimatedInputTokens)
index := block.Index
for _, citation := range citations {
responses = append(responses, &dto.ClaudeResponse{
Type: "content_block_delta",
Index: &index,
Delta: &dto.ClaudeMediaMessage{Type: "citations_delta", Citation: citation},
})
}
return responses
}
func (s *ResponsesToClaudeStreamState) finish(response *dto.OpenAIResponsesResponse, estimatedInputTokens int) ([]*dto.ClaudeResponse, error) {
if s.done {
return nil, nil
}
s.applyResponseMetadata(response)
responses := make([]*dto.ClaudeResponse, 0)
if response != nil {
for outputIndex := range response.Output {
index := outputIndex
item := response.Output[outputIndex]
event := &dto.ResponsesStreamResponse{OutputIndex: &index, ItemID: item.ID, Item: &item}
itemResponses, err := s.applyOutputItem(event, estimatedInputTokens, true)
if err != nil {
return nil, err
}
responses = append(responses, itemResponses...)
}
}
for _, block := range s.blocks {
responses = append(responses, s.stopBlock(block, estimatedInputTokens)...)
}
responses = append(responses, s.ensureMessageStart(estimatedInputTokens)...)
stopReason := responsesClaudeStopReason(response, s.sawToolCall)
usage := sharedclaude.UsageFromOpenAI(s.Usage)
responses = append(responses,
&dto.ClaudeResponse{
Type: "message_delta",
Usage: usage,
Delta: &dto.ClaudeMediaMessage{StopReason: &stopReason},
},
&dto.ClaudeResponse{Type: "message_stop"},
)
s.done = true
return responses, nil
}
func separatedResponsesDelta(delta string) string {
if strings.HasPrefix(delta, "\n\n") {
return delta
}
if strings.HasPrefix(delta, "\n") {
return "\n" + delta
}
return "\n\n" + delta
}
@@ -0,0 +1,129 @@
package oairesponses
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestResponsesToClaudeStreamDoesNotRepeatBlocksFromDoneAndCompletedEvents(t *testing.T) {
state := NewResponsesToClaudeStreamState("", "")
arguments := `{"q":"x"}`
argumentRaw, err := kitutil.Marshal(arguments)
require.NoError(t, err)
statusRaw, err := kitutil.Marshal("completed")
require.NoError(t, err)
reasoningItem := dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
ID: "rs_1",
Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "plan"}},
}
messageItem := dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
ID: "msg_1",
Role: "assistant",
Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "hello"}},
}
toolItem := dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: "fc_1",
CallId: "call_1",
Name: "lookup",
Arguments: argumentRaw,
}
events := []*dto.ResponsesStreamResponse{
{Type: responsesEventCreated, Response: &dto.OpenAIResponsesResponse{ID: "resp_1", Model: "gpt-test"}},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &dto.ResponsesOutput{Type: reasoningItem.Type, ID: reasoningItem.ID}},
{Type: responsesEventReasoningSummaryDelta, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Delta: "plan"},
{Type: responsesEventReasoningSummaryDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Text: kitutil.GetPointer("plan")},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(0), ItemID: reasoningItem.ID, Item: &reasoningItem},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &dto.ResponsesOutput{Type: messageItem.Type, ID: messageItem.ID, Role: "assistant"}},
{Type: responsesEventOutputTextDelta, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Delta: "hello"},
{Type: responsesEventOutputTextDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Text: kitutil.GetPointer("hello")},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(1), ItemID: messageItem.ID, Item: &messageItem},
{Type: responsesEventOutputItemAdded, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &dto.ResponsesOutput{Type: toolItem.Type, ID: toolItem.ID, CallId: toolItem.CallId, Name: toolItem.Name}},
{Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `{"q":`},
{Type: responsesEventFunctionArgsDelta, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Delta: `"x"}`},
{Type: responsesEventFunctionArgsDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Arguments: &arguments},
{Type: responsesEventOutputItemDone, OutputIndex: kitutil.GetPointer(2), ItemID: toolItem.ID, Item: &toolItem},
{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: statusRaw,
Output: []dto.ResponsesOutput{reasoningItem, messageItem, toolItem},
Usage: &dto.Usage{InputTokens: 11, OutputTokens: 7, TotalTokens: 18},
},
},
}
var output []*dto.ClaudeResponse
for _, event := range events {
converted, _, err := state.ConvertChunk(event, 9)
require.NoError(t, err)
output = append(output, converted...)
}
starts := responsesOfType(output, "content_block_start")
stops := responsesOfType(output, "content_block_stop")
require.Len(t, responsesOfType(output, "message_start"), 1)
require.Len(t, starts, 3)
require.Len(t, stops, 3)
require.Len(t, responsesOfType(output, "message_delta"), 1)
require.Len(t, responsesOfType(output, "message_stop"), 1)
assert.Equal(t, []int{0, 1, 2}, []int{starts[0].GetIndex(), starts[1].GetIndex(), starts[2].GetIndex()})
assert.Equal(t, []string{"thinking", "text", "tool_use"}, []string{starts[0].ContentBlock.Type, starts[1].ContentBlock.Type, starts[2].ContentBlock.Type})
assert.Equal(t, "plan", joinedClaudeDeltas(output, "thinking_delta"))
assert.Equal(t, "hello", joinedClaudeDeltas(output, "text_delta"))
assert.Equal(t, arguments, joinedClaudeDeltas(output, "input_json_delta"))
messageDelta := responsesOfType(output, "message_delta")[0]
require.NotNil(t, messageDelta.Delta.StopReason)
assert.Equal(t, "tool_use", *messageDelta.Delta.StopReason)
finalized, err := state.Finalize(9)
require.NoError(t, err)
assert.Empty(t, finalized)
repeated, _, err := state.ConvertChunk(events[len(events)-1], 9)
require.NoError(t, err)
assert.Empty(t, repeated)
}
func responsesOfType(responses []*dto.ClaudeResponse, responseType string) []*dto.ClaudeResponse {
filtered := make([]*dto.ClaudeResponse, 0)
for _, response := range responses {
if response != nil && response.Type == responseType {
filtered = append(filtered, response)
}
}
return filtered
}
func joinedClaudeDeltas(responses []*dto.ClaudeResponse, deltaType string) string {
result := ""
for _, response := range responses {
if response == nil || response.Type != "content_block_delta" || response.Delta == nil || response.Delta.Type != deltaType {
continue
}
switch deltaType {
case "thinking_delta":
if response.Delta.Thinking != nil {
result += *response.Delta.Thinking
}
case "text_delta":
if response.Delta.Text != nil {
result += *response.Delta.Text
}
case "input_json_delta":
if response.Delta.PartialJson != nil {
result += *response.Delta.PartialJson
}
}
}
return result
}
@@ -10,6 +10,7 @@ import (
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func convertOpenAIResponsesRequestToGeminiChat(c context.Context, info convmeta.Meta, request any) (any, error) {
@@ -42,10 +43,10 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
Temperature: req.Temperature,
},
}
if req.TopP != nil && *req.TopP > 0 {
if req.TopP != nil {
geminiRequest.GenerationConfig.TopP = kitutil.GetPointer(*req.TopP)
}
if req.MaxOutputTokens != nil && *req.MaxOutputTokens > 0 {
if req.MaxOutputTokens != nil {
geminiRequest.GenerationConfig.MaxOutputTokens = kitutil.GetPointer(*req.MaxOutputTokens)
}
@@ -59,11 +60,19 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
if err := applyResponsesTextToGemini(req.Text, geminiRequest); err != nil {
return nil, err
}
sharedgemini.ApplyThinkingConfig(geminiRequest, info, dto.GeneralOpenAIRequest{
Model: req.Model,
MaxCompletionTokens: req.MaxOutputTokens,
ReasoningEffort: ReasoningEffort(req),
})
reasoningIntent, err := reasoning.FromOpenAIResponses(req)
if err != nil {
return nil, reasoning.AsClientError(err)
}
var reasoningPivot dto.GeneralOpenAIRequest
if err := reasoning.ApplyToOpenAIChat(&reasoningPivot, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
reasoningPivot.Model = req.Model
reasoningPivot.MaxCompletionTokens = req.MaxOutputTokens
if err := sharedgemini.ApplyThinkingConfig(geminiRequest, info, reasoningPivot); err != nil {
return nil, reasoning.AsClientError(err)
}
var safetySettings []dto.GeminiChatSafetySettings
for _, category := range sharedgemini.SafetySettingCategories {
@@ -137,7 +146,10 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
}
appendGeminiContentPart(geminiRequest, "model", part)
case ResponsesInputTypeFunctionCallOutput:
part := responsesFunctionOutputItemToGeminiPart(item, callNames)
part, err := responsesFunctionOutputItemToGeminiPart(item, callNames)
if err != nil {
return nil, err
}
appendGeminiContentPart(geminiRequest, "user", part)
default:
role := responsesGeminiRole(item)
@@ -252,24 +264,33 @@ func responsesFunctionCallItemToGeminiPart(item map[string]any) (dto.GeminiPart,
callID := CallID(item)
return dto.GeminiPart{
FunctionCall: &dto.FunctionCall{
ID: callID,
FunctionName: name,
Arguments: ObjectValue(item["arguments"], "arguments"),
},
}, callID, nil
}
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) dto.GeminiPart {
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) (dto.GeminiPart, error) {
callID := CallID(item)
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
if name == "" {
name = callNames[callID]
}
return dto.GeminiPart{
FunctionResponse: &dto.GeminiFunctionResponse{
Name: name,
Response: GeminiResponseMap(item["output"]),
},
response := &dto.GeminiFunctionResponse{
Name: name,
Response: GeminiResponseMap(item["output"]),
}
if callID != "" {
id, err := kitutil.Marshal(callID)
if err != nil {
return dto.GeminiPart{}, fmt.Errorf("failed to marshal function response ID: %w", err)
}
response.ID = id
}
return dto.GeminiPart{
FunctionResponse: response,
}, nil
}
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
@@ -8,6 +8,7 @@ import (
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
const (
@@ -85,8 +86,10 @@ func ResponsesRequestToChatCompletionsRequest(req *dto.OpenAIResponsesRequest) (
return nil, fmt.Errorf("invalid presence_penalty: %w", err)
}
if req.Reasoning != nil {
out.ReasoningEffort = req.Reasoning.Effort
if reasoningIntent, err := reasoning.FromOpenAIResponses(req); err != nil {
return nil, reasoning.AsClientError(err)
} else if err := reasoning.ApplyToOpenAIChat(out, reasoningIntent); err != nil {
return nil, reasoning.AsClientError(err)
}
if req.ServiceTier != "" {
out.ServiceTier, _ = kitutil.Marshal(req.ServiceTier)
@@ -10,29 +10,30 @@ import (
)
const (
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventDone = "response.done"
responsesEventIncomplete = "response.incomplete"
responsesEventFailed = "response.failed"
responsesEventError = "response.error"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
responsesEventReasoningTextDone = "response.reasoning_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeCustomToolCall = "custom_tool_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
responsesEventCreated = "response.created"
responsesEventCompleted = "response.completed"
responsesEventDone = "response.done"
responsesEventIncomplete = "response.incomplete"
responsesEventFailed = "response.failed"
responsesEventError = "response.error"
responsesEventOutputTextDelta = "response.output_text.delta"
responsesEventOutputTextAnnotationAdded = "response.output_text.annotation.added"
responsesEventOutputItemAdded = "response.output_item.added"
responsesEventOutputItemDone = "response.output_item.done"
responsesEventFunctionArgsDelta = "response.function_call_arguments.delta"
responsesEventFunctionArgsDone = "response.function_call_arguments.done"
responsesEventCustomToolInputDelta = "response.custom_tool_call_input.delta"
responsesEventCustomToolInputDone = "response.custom_tool_call_input.done"
responsesEventReasoningSummaryDelta = "response.reasoning_summary_text.delta"
responsesEventReasoningSummaryDone = "response.reasoning_summary_text.done"
responsesEventReasoningTextDelta = "response.reasoning_text.delta"
responsesEventReasoningTextDone = "response.reasoning_text.done"
responsesOutputTypeFunctionCall = "function_call"
responsesOutputTypeCustomToolCall = "custom_tool_call"
responsesOutputTypeMessage = "message"
responsesOutputTypeReasoning = "reasoning"
responsesIncompleteReasonContentFilter = "content_filter"
responsesIncompleteReasonMaxTokens = "max_output_tokens"
)
func ResponsesFinishReasonFromStatus(resp *dto.OpenAIResponsesResponse) (string, bool) {
@@ -103,6 +104,11 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons
Role: "assistant",
Content: text,
}
if annotations, err := responsesAnnotationsToChat(resp); err != nil {
return nil, nil, err
} else if len(annotations) > 0 {
msg.Annotations = annotations
}
if reasoning != "" {
msg.ReasoningContent = &reasoning
}
@@ -128,7 +134,65 @@ func ResponsesResponseToChatCompletionsResponse(resp *dto.OpenAIResponsesRespons
return out, usage, nil
}
func responsesAnnotationsToChat(resp *dto.OpenAIResponsesResponse) ([]byte, error) {
annotations := make([]any, 0)
for _, output := range resp.Output {
if output.Type != responsesOutputTypeMessage {
continue
}
for _, content := range output.Content {
for _, annotation := range content.Annotations {
converted, err := responseAnnotationToChat(annotation)
if err != nil {
return nil, err
}
annotations = append(annotations, converted)
}
}
}
if len(annotations) == 0 {
return nil, nil
}
return kitutil.Marshal(annotations)
}
func responseAnnotationToChat(annotation any) (map[string]any, error) {
value, ok := annotation.(map[string]any)
if !ok {
converted, err := kitutil.Any2Type[map[string]any](annotation)
if err != nil {
return nil, fmt.Errorf("invalid Responses annotation: %w", err)
}
value = converted
}
if strings.TrimSpace(kitutil.Interface2String(value["type"])) != "url_citation" {
return value, nil
}
citation := make(map[string]any, len(value)-1)
for key, item := range value {
if key != "type" {
citation[key] = item
}
}
return map[string]any{
"type": "url_citation",
"url_citation": citation,
}, nil
}
func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
return usageFromResponsesUsage(src, true)
}
// NormalizeResponsesUsage maps Responses usage into the shared accounting
// shape without creating a BillingUsage snapshot. Native Responses handlers
// use it so passthrough traffic preserves an existing snapshot but does not
// introduce a conversion sidecar solely for local settlement.
func NormalizeResponsesUsage(src *dto.Usage) *dto.Usage {
return usageFromResponsesUsage(src, false)
}
func usageFromResponsesUsage(src *dto.Usage, createBillingSnapshot bool) *dto.Usage {
usage := &dto.Usage{}
if src == nil {
return usage
@@ -136,7 +200,7 @@ func UsageFromResponsesUsage(src *dto.Usage) *dto.Usage {
usage.UsageSemantic = src.UsageSemantic
usage.UsageSource = src.UsageSource
usage.BillingUsage = dto.CloneBillingUsage(src.BillingUsage)
if usage.BillingUsage == nil {
if usage.BillingUsage == nil && createBillingSnapshot {
usage.BillingUsage = dto.NewOpenAIResponsesBillingUsage(src)
}
usage.Cost = src.Cost
@@ -190,21 +254,25 @@ func ExtractOutputTextFromResponses(resp *dto.OpenAIResponsesResponse) string {
if out.Role != "" && out.Role != "assistant" {
continue
}
var outputText strings.Builder
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
sb.WriteString(c.Text)
outputText.WriteString(c.Text)
}
}
appendSeparatedText(&sb, outputText.String())
}
if sb.Len() > 0 {
return sb.String()
}
for _, out := range resp.Output {
var outputText strings.Builder
for _, c := range out.Content {
if c.Text != "" {
sb.WriteString(c.Text)
outputText.WriteString(c.Text)
}
}
appendSeparatedText(&sb, outputText.String())
}
return sb.String()
}
@@ -219,15 +287,56 @@ func ExtractReasoningTextFromResponses(resp *dto.OpenAIResponsesResponse) string
if out.Type != responsesOutputTypeReasoning {
continue
}
for _, c := range out.Content {
if c.Text != "" {
sb.WriteString(c.Text)
}
}
appendSeparatedText(&sb, reasoningOutputText(&out))
}
return sb.String()
}
func reasoningOutputText(output *dto.ResponsesOutput) string {
if output == nil {
return ""
}
var text strings.Builder
hasContentText := false
for _, part := range output.Content {
if part.Text != "" {
hasContentText = true
break
}
}
if hasContentText {
for _, part := range output.Content {
appendSeparatedText(&text, part.Text)
}
return text.String()
}
for _, part := range output.Summary {
appendSeparatedText(&text, part.Text)
}
return text.String()
}
func appendSeparatedText(builder *strings.Builder, text string) {
if builder == nil || text == "" {
return
}
if builder.Len() > 0 {
current := builder.String()
trailingNewlines := 0
for index := len(current) - 1; index >= 0 && trailingNewlines < 2 && current[index] == '\n'; index-- {
trailingNewlines++
}
leadingNewlines := 0
for leadingNewlines < len(text) && leadingNewlines < 2 && text[leadingNewlines] == '\n' {
leadingNewlines++
}
for missing := 2 - trailingNewlines - leadingNewlines; missing > 0; missing-- {
builder.WriteByte('\n')
}
}
builder.WriteString(text)
}
func responseStatusString(resp *dto.OpenAIResponsesResponse) string {
if resp == nil || len(resp.Status) == 0 {
return ""
@@ -56,9 +56,9 @@ func TestResponsesResponseToChatCompletionsPreservesReasoningSummary(t *testing.
Output: []dto.ResponsesOutput{
{
Type: responsesOutputTypeReasoning,
Content: []dto.ResponsesOutputContent{
Summary: []dto.ResponsesReasoningSummaryPart{
{Type: "summary_text", Text: "first summary"},
{Type: "summary_text", Text: "\n\nsecond summary"},
{Type: "summary_text", Text: "second summary"},
},
},
{
@@ -77,6 +77,20 @@ func TestResponsesResponseToChatCompletionsPreservesReasoningSummary(t *testing.
assert.Equal(t, "final", chat.Choices[0].Message.StringContent())
}
func TestResponsesResponseToChatCompletionsSeparatesInterleavedOutputItems(t *testing.T) {
resp := &dto.OpenAIResponsesResponse{
ID: "resp_1",
Model: "gpt-test",
Status: []byte(`"completed"`),
Output: interleavedReasoningAndTextOutput(),
}
chat, _, err := ResponsesResponseToChatCompletionsResponse(resp, "chatcmpl_1")
require.NoError(t, err)
assert.Equal(t, "**Planning file inspection**\n\n**Clarifying environment task requirements**", chat.Choices[0].Message.GetReasoningContent())
assert.Equal(t, "Ill inspect the starter repository.\n\nWhat would you like me to build?", chat.Choices[0].Message.StringContent())
}
func TestResponsesFinishReasonFromIncompleteStatus(t *testing.T) {
tests := []struct {
name string
@@ -433,6 +447,90 @@ func TestResponsesBufferedAccumulatorDoesNotDuplicatePendingArgsWithOutputIndexA
assert.Empty(t, acc.pendingByItemID)
}
func TestResponsesBufferedAccumulatorPreservesInterleavedReasoningAndTextItems(t *testing.T) {
acc := NewResponsesBufferedAccumulator()
events := []dto.ResponsesStreamResponse{
bufferedOutputItemAdded(0, "rs_1", responsesOutputTypeReasoning),
{Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(0), ItemID: "rs_1", Delta: "**Planning file inspection**"},
bufferedOutputItemAdded(1, "msg_1", responsesOutputTypeMessage),
{Type: responsesEventOutputTextDelta, OutputIndex: intPointer(1), ItemID: "msg_1", Delta: "Ill inspect the starter repository."},
bufferedOutputItemAdded(2, "rs_2", responsesOutputTypeReasoning),
{Type: responsesEventReasoningSummaryDelta, OutputIndex: intPointer(2), ItemID: "rs_2", Delta: "**Clarifying environment task requirements**"},
bufferedOutputItemAdded(3, "msg_2", responsesOutputTypeMessage),
{Type: responsesEventOutputTextDelta, OutputIndex: intPointer(3), ItemID: "msg_2", Delta: "What would you like me to build?"},
}
for index := range events {
acc.ProcessEvent(&events[index])
}
output := acc.BuildOutput()
require.Len(t, output, 4)
assert.Equal(t, []string{
responsesOutputTypeReasoning,
responsesOutputTypeMessage,
responsesOutputTypeReasoning,
responsesOutputTypeMessage,
}, []string{output[0].Type, output[1].Type, output[2].Type, output[3].Type})
assert.Equal(t, "**Planning file inspection**", output[0].Summary[0].Text)
assert.Equal(t, "Ill inspect the starter repository.", output[1].Content[0].Text)
assert.Equal(t, "**Clarifying environment task requirements**", output[2].Summary[0].Text)
assert.Equal(t, "What would you like me to build?", output[3].Content[0].Text)
}
func TestResponsesStreamTerminalOutputPreservesInterleavedReasoningAndTextItems(t *testing.T) {
state := newTestResponsesStreamState()
chunks, err := ResponsesStreamEventToChatChunks(&dto.ResponsesStreamResponse{
Type: responsesEventCompleted,
Response: &dto.OpenAIResponsesResponse{
Status: []byte(`"completed"`),
Output: interleavedReasoningAndTextOutput(),
},
}, state)
require.NoError(t, err)
var deltas []string
for _, chunk := range chunks {
if len(chunk.Choices) == 0 {
continue
}
delta := chunk.Choices[0].Delta
if delta.ReasoningContent != nil {
deltas = append(deltas, "thinking:"+*delta.ReasoningContent)
}
if delta.Content != nil && *delta.Content != "" {
deltas = append(deltas, "text:"+*delta.Content)
}
}
assert.Equal(t, []string{
"thinking:**Planning file inspection**",
"text:Ill inspect the starter repository.",
"thinking:**Clarifying environment task requirements**",
"text:What would you like me to build?",
}, deltas)
}
func bufferedOutputItemAdded(outputIndex int, itemID string, itemType string) dto.ResponsesStreamResponse {
return dto.ResponsesStreamResponse{
Type: responsesEventOutputItemAdded,
OutputIndex: &outputIndex,
ItemID: itemID,
Item: &dto.ResponsesOutput{ID: itemID, Type: itemType},
}
}
func interleavedReasoningAndTextOutput() []dto.ResponsesOutput {
return []dto.ResponsesOutput{
{Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Planning file inspection**"}}},
{Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "Ill inspect the starter repository."}}},
{Type: responsesOutputTypeReasoning, Summary: []dto.ResponsesReasoningSummaryPart{{Type: "summary_text", Text: "**Clarifying environment task requirements**"}}},
{Type: responsesOutputTypeMessage, Role: "assistant", Content: []dto.ResponsesOutputContent{{Type: "output_text", Text: "What would you like me to build?"}}},
}
}
func intPointer(value int) *int {
return &value
}
func newTestResponsesStreamState() *ResponsesToChatStreamState {
state := NewResponsesToChatStreamState("gpt-test", false)
state.ID = "chatcmpl_test"
@@ -21,6 +21,7 @@ type ResponsesToChatStreamState struct {
sentStart bool
finalized bool
hasSentText bool
sentAnnotationCount int
sawToolCall bool
hasSentReasoning bool
needsReasoningSummaryBreak bool
@@ -61,6 +62,19 @@ func NewResponsesToChatStreamState(model string, includeUsage bool) *ResponsesTo
}
}
func (s *ResponsesToChatStreamState) StreamUsage() *dto.Usage {
if s == nil {
return nil
}
return s.Usage
}
func (s *ResponsesToChatStreamState) SetStreamUsage(usage *dto.Usage) {
if s != nil && usage != nil {
s.Usage = usage
}
}
func (s *ResponsesToChatStreamState) UsageText() string {
if s == nil {
return ""
@@ -86,6 +100,8 @@ func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state
return nil, nil
case responsesEventOutputTextDelta:
return state.textDelta(event.Delta), nil
case responsesEventOutputTextAnnotationAdded:
return state.annotationRawDelta(event.Annotation)
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
if event.Item == nil || !isResponsesToolOutputType(event.Item.Type) {
return nil, nil
@@ -101,7 +117,10 @@ func ResponsesStreamEventToChatChunks(event *dto.ResponsesStreamResponse, state
response = ensureIncompleteResponse(response)
}
state.applyResponseMetadata(response)
chunks := state.terminalOutputChunks(response)
chunks, err := state.terminalOutputChunks(response)
if err != nil {
return nil, err
}
chunks = append(chunks, state.finalize(response)...)
return chunks, nil
case responsesEventFailed, responsesEventError:
@@ -132,7 +151,7 @@ func (s *ResponsesToChatStreamState) applyResponseMetadata(response *dto.OpenAIR
s.Created = int64(response.CreatedAt)
}
if response.Usage != nil {
s.Usage = UsageFromResponsesUsage(response.Usage)
s.Usage = dto.MergeUsageNonZero(s.Usage, UsageFromResponsesUsage(response.Usage))
}
}
@@ -160,16 +179,19 @@ func (s *ResponsesToChatStreamState) textDelta(delta string) []dto.ChatCompletio
return chunks
}
func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIResponsesResponse) []dto.ChatCompletionsStreamResponse {
func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIResponsesResponse) ([]dto.ChatCompletionsStreamResponse, error) {
if s == nil || response == nil || len(response.Output) == 0 {
return nil
return nil, nil
}
var chunks []dto.ChatCompletionsStreamResponse
hadSentText := s.hasSentText
hadSentReasoning := s.hasSentReasoning
annotationOffset := 0
for i := range response.Output {
out := &response.Output[i]
switch {
case out.Type == responsesOutputTypeMessage && !s.hasSentText:
case out.Type == responsesOutputTypeMessage && !hadSentText:
var text strings.Builder
for _, c := range out.Content {
if c.Type == "output_text" && c.Text != "" {
@@ -177,19 +199,88 @@ func (s *ResponsesToChatStreamState) terminalOutputChunks(response *dto.OpenAIRe
}
}
chunks = append(chunks, s.textDelta(text.String())...)
case out.Type == responsesOutputTypeReasoning && !s.hasSentReasoning:
var reasoning strings.Builder
for _, c := range out.Content {
if c.Text != "" {
reasoning.WriteString(c.Text)
}
annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
if err != nil {
return nil, err
}
chunks = append(chunks, s.reasoningDelta(reasoning.String())...)
chunks = append(chunks, annotationChunks...)
annotationOffset += responsesOutputAnnotationCount(out)
case out.Type == responsesOutputTypeMessage:
annotationChunks, err := s.remainingAnnotationChunks(out, annotationOffset)
if err != nil {
return nil, err
}
chunks = append(chunks, annotationChunks...)
annotationOffset += responsesOutputAnnotationCount(out)
case out.Type == responsesOutputTypeReasoning && !hadSentReasoning:
chunks = append(chunks, s.reasoningDelta(reasoningOutputText(out))...)
case isResponsesToolOutputType(out.Type):
chunks = append(chunks, s.toolItem(&dto.ResponsesStreamResponse{Item: out})...)
}
}
return chunks
return chunks, nil
}
func (s *ResponsesToChatStreamState) annotationRawDelta(raw []byte) ([]dto.ChatCompletionsStreamResponse, error) {
if len(raw) == 0 {
return nil, nil
}
var annotation map[string]any
if err := kitutil.Unmarshal(raw, &annotation); err != nil {
return nil, fmt.Errorf("invalid Responses stream annotation: %w", err)
}
return s.annotationDelta(annotation)
}
func (s *ResponsesToChatStreamState) annotationDelta(annotation any) ([]dto.ChatCompletionsStreamResponse, error) {
converted, err := responseAnnotationToChat(annotation)
if err != nil {
return nil, err
}
raw, err := kitutil.Marshal([]any{converted})
if err != nil {
return nil, fmt.Errorf("marshal Chat annotation: %w", err)
}
s.sentAnnotationCount++
chunks := s.ensureStart()
chunks = append(chunks, s.makeChunk(dto.ChatCompletionsStreamResponseChoiceDelta{
Annotations: raw,
}, nil))
return chunks, nil
}
func (s *ResponsesToChatStreamState) remainingAnnotationChunks(output *dto.ResponsesOutput, offset int) ([]dto.ChatCompletionsStreamResponse, error) {
if output == nil {
return nil, nil
}
annotations := make([]any, 0)
for _, content := range output.Content {
annotations = append(annotations, content.Annotations...)
}
start := s.sentAnnotationCount - offset
if start < 0 {
start = 0
}
if start >= len(annotations) {
return nil, nil
}
var chunks []dto.ChatCompletionsStreamResponse
for _, annotation := range annotations[start:] {
converted, err := s.annotationDelta(annotation)
if err != nil {
return nil, err
}
chunks = append(chunks, converted...)
}
return chunks, nil
}
func responsesOutputAnnotationCount(output *dto.ResponsesOutput) int {
count := 0
for _, content := range output.Content {
count += len(content.Annotations)
}
return count
}
func (s *ResponsesToChatStreamState) reasoningDelta(delta string) []dto.ChatCompletionsStreamResponse {
@@ -554,8 +645,10 @@ func (s *ResponsesToChatStreamState) keyForEvent(event *dto.ResponsesStreamRespo
}
type ResponsesBufferedAccumulator struct {
text strings.Builder
reasoning strings.Builder
items []*responsesBufferedItem
outputIndexToItemIdx map[int]int
itemIDToItemIdx map[string]int
lastUnindexedItemIdx int
tools []*responsesBufferedTool
outputIndexToToolIdx map[int]int
itemIDToToolIdx map[string]int
@@ -563,6 +656,15 @@ type ResponsesBufferedAccumulator struct {
pendingByItemID map[string]string
}
type responsesBufferedItem struct {
Type string
ID string
Text strings.Builder
Annotations []interface{}
ToolIndex int
NeedsReasoningBreak bool
}
type responsesBufferedTool struct {
CallID string
ItemID string
@@ -572,6 +674,9 @@ type responsesBufferedTool struct {
func NewResponsesBufferedAccumulator() *ResponsesBufferedAccumulator {
return &ResponsesBufferedAccumulator{
outputIndexToItemIdx: make(map[int]int),
itemIDToItemIdx: make(map[string]int),
lastUnindexedItemIdx: -1,
outputIndexToToolIdx: make(map[int]int),
itemIDToToolIdx: make(map[string]int),
pendingByOutputIndex: make(map[int]string),
@@ -585,11 +690,55 @@ func (a *ResponsesBufferedAccumulator) ProcessEvent(event *dto.ResponsesStreamRe
}
switch event.Type {
case responsesEventOutputTextDelta:
a.text.WriteString(event.Delta)
item := a.ensureItem(event, responsesOutputTypeMessage)
item.Text.WriteString(event.Delta)
case responsesEventOutputTextAnnotationAdded:
item := a.ensureItem(event, responsesOutputTypeMessage)
var annotation interface{}
if err := kitutil.Unmarshal(event.Annotation, &annotation); err == nil && annotation != nil {
item.Annotations = append(item.Annotations, annotation)
}
case responsesEventReasoningSummaryDelta, responsesEventReasoningTextDelta:
a.reasoning.WriteString(event.Delta)
item := a.ensureItem(event, responsesOutputTypeReasoning)
if item.NeedsReasoningBreak {
appendSeparatedText(&item.Text, event.Delta)
item.NeedsReasoningBreak = false
} else {
item.Text.WriteString(event.Delta)
}
case responsesEventReasoningSummaryDone, responsesEventReasoningTextDone:
item := a.ensureItem(event, responsesOutputTypeReasoning)
if item.Text.Len() == 0 && event.Text != nil {
item.Text.WriteString(*event.Text)
}
if item.Text.Len() > 0 {
item.NeedsReasoningBreak = true
}
case responsesEventOutputItemAdded, responsesEventOutputItemDone:
if event.Item != nil && isResponsesToolOutputType(event.Item.Type) {
if event.Item == nil {
return
}
switch {
case event.Item.Type == responsesOutputTypeReasoning:
item := a.ensureItem(event, event.Item.Type)
if item.Text.Len() == 0 {
item.Text.WriteString(reasoningOutputText(event.Item))
}
case event.Item.Type == responsesOutputTypeMessage:
item := a.ensureItem(event, event.Item.Type)
seedText := item.Text.Len() == 0
seedAnnotations := len(item.Annotations) == 0
for _, content := range event.Item.Content {
if content.Type == "output_text" {
if seedText {
item.Text.WriteString(content.Text)
}
if seedAnnotations {
item.Annotations = append(item.Annotations, content.Annotations...)
}
}
}
case isResponsesToolOutputType(event.Item.Type):
tool := a.ensureTool(event)
if args := event.Item.ArgumentsString(); args != "" {
tool.Arguments.Reset()
@@ -620,50 +769,119 @@ func (a *ResponsesBufferedAccumulator) BuildOutput() []dto.ResponsesOutput {
if a == nil {
return nil
}
out := make([]dto.ResponsesOutput, 0, 2+len(a.tools))
if a.reasoning.Len() > 0 {
out = append(out, dto.ResponsesOutput{
Type: responsesOutputTypeReasoning,
Content: []dto.ResponsesOutputContent{
{Type: "summary_text", Text: a.reasoning.String()},
},
})
}
if a.text.Len() > 0 {
out = append(out, dto.ResponsesOutput{
Type: responsesOutputTypeMessage,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: a.text.String()},
},
})
}
for _, tool := range a.tools {
if tool == nil {
out := make([]dto.ResponsesOutput, 0, len(a.items))
for _, item := range a.items {
if item == nil {
continue
}
argsRaw, _ := kitutil.Marshal(tool.Arguments.String())
out = append(out, dto.ResponsesOutput{
Type: responsesOutputTypeFunctionCall,
ID: tool.ItemID,
CallId: tool.CallID,
Name: tool.Name,
Arguments: argsRaw,
})
switch item.Type {
case responsesOutputTypeReasoning:
if item.Text.Len() == 0 {
continue
}
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: item.ID,
Summary: []dto.ResponsesReasoningSummaryPart{
{Type: "summary_text", Text: item.Text.String()},
},
})
case responsesOutputTypeMessage:
if item.Text.Len() == 0 {
continue
}
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: item.ID,
Role: "assistant",
Content: []dto.ResponsesOutputContent{
{Type: "output_text", Text: item.Text.String(), Annotations: item.Annotations},
},
})
case responsesOutputTypeFunctionCall, responsesOutputTypeCustomToolCall:
if item.ToolIndex < 0 || item.ToolIndex >= len(a.tools) || a.tools[item.ToolIndex] == nil {
continue
}
tool := a.tools[item.ToolIndex]
argsRaw, _ := kitutil.Marshal(tool.Arguments.String())
out = append(out, dto.ResponsesOutput{
Type: item.Type,
ID: tool.ItemID,
CallId: tool.CallID,
Name: tool.Name,
Arguments: argsRaw,
})
}
}
return out
}
func (a *ResponsesBufferedAccumulator) ensureItem(event *dto.ResponsesStreamResponse, itemType string) *responsesBufferedItem {
if idx, ok := a.findItemIndex(event); ok {
item := a.items[idx]
if item.Type == "" {
item.Type = itemType
}
a.applyItemMetadata(idx, item, event)
return item
}
if event != nil && event.OutputIndex == nil && responseStreamEventItemID(event) == "" && a.lastUnindexedItemIdx >= 0 {
item := a.items[a.lastUnindexedItemIdx]
if item != nil && item.Type == itemType {
return item
}
}
item := &responsesBufferedItem{Type: itemType, ToolIndex: -1}
idx := len(a.items)
a.items = append(a.items, item)
a.lastUnindexedItemIdx = idx
a.applyItemMetadata(idx, item, event)
return item
}
func (a *ResponsesBufferedAccumulator) applyItemMetadata(idx int, item *responsesBufferedItem, event *dto.ResponsesStreamResponse) {
if item == nil || event == nil {
return
}
if event.OutputIndex != nil {
a.outputIndexToItemIdx[*event.OutputIndex] = idx
}
if itemID := responseStreamEventItemID(event); itemID != "" {
item.ID = itemID
a.itemIDToItemIdx[itemID] = idx
}
}
func (a *ResponsesBufferedAccumulator) findItemIndex(event *dto.ResponsesStreamResponse) (int, bool) {
if event == nil {
return 0, false
}
if event.OutputIndex != nil {
if idx, ok := a.outputIndexToItemIdx[*event.OutputIndex]; ok {
return idx, true
}
}
if itemID := responseStreamEventItemID(event); itemID != "" {
idx, ok := a.itemIDToItemIdx[itemID]
return idx, ok
}
return 0, false
}
func (a *ResponsesBufferedAccumulator) ensureTool(event *dto.ResponsesStreamResponse) *responsesBufferedTool {
if idx, ok := a.findToolIndex(event); ok {
tool := a.tools[idx]
a.applyToolMetadata(tool, event)
item := a.ensureItem(event, event.Item.Type)
item.ToolIndex = idx
return tool
}
tool := &responsesBufferedTool{}
a.applyToolMetadata(tool, event)
idx := len(a.tools)
a.tools = append(a.tools, tool)
item := a.ensureItem(event, event.Item.Type)
item.ToolIndex = idx
if event.OutputIndex != nil {
a.outputIndexToToolIdx[*event.OutputIndex] = idx
if pending := a.pendingByOutputIndex[*event.OutputIndex]; pending != "" {
@@ -0,0 +1,135 @@
package claude
import (
"fmt"
"math"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
func ApplyReasoning(req *dto.ClaudeRequest, info convmeta.Meta, source reasoning.Intent) error {
if req == nil {
return nil
}
native, err := reasoning.FromClaude(req)
if err != nil {
return err
}
explicit, err := reasoning.MergeExplicit(native, source, req.Model)
if err != nil {
return err
}
opts := convmeta.OptionsOf(info)
baseModel := req.Model
capabilityModel := baseModel
suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info))
preserveSuffix := opts.ShouldPreserveThinkingSuffix(req.Model)
if info != nil && opts.ShouldPreserveThinkingSuffix(info.GetOriginModelName()) {
preserveSuffix = true
}
if preserveSuffix {
suffix = reasoning.Intent{}
}
if info != nil && !reasoning.IsKnownClaudeModel(capabilityModel) && reasoning.IsKnownClaudeModel(info.GetOriginModelName()) {
capabilityModel = info.GetOriginModelName()
}
intent, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, req.Model)
if err != nil {
return err
}
knownClaudeModel := reasoning.IsKnownClaudeModel(capabilityModel)
if source.IsEmpty() && suffix.IsEmpty() && !knownClaudeModel {
// A native Messages request can target a non-Anthropic model through a
// Claude-compatible proxy. Its capability vocabulary belongs to that
// upstream, so preserve validated native controls instead of applying
// Anthropic model rules to an unknown model name.
if info != nil {
if effort := reasoning.EffectiveEffort(intent); effort != "" {
info.SetReasoningEffort(string(effort))
}
}
return nil
}
if !knownClaudeModel && intent.Mode == reasoning.ModeAdaptive {
// Cross-protocol pivots cannot safely assume that an unknown
// Claude-compatible model implements Anthropic's adaptive mode. Render
// the broadly supported manual form while retaining the requested
// strength. Native Claude requests took the passthrough path above.
intent.Mode = reasoning.ModeEnabled
if intent.Effort == "" {
intent.Effort = reasoning.EffortHigh
}
}
if req.MaxTokens == nil && intent.HasStrength() {
// Adapter-provided defaults may be raised to accommodate an exact
// cross-protocol budget. Explicit client max_tokens values are never
// expanded and remain subject to the renderer's strict validation.
minimum := uint(1280)
if configuredDefault, configured := opts.Claude.DefaultMaxTokensFor(capabilityModel); configured && configuredDefault > 0 {
minimum = uint(configuredDefault)
}
if reasoning.ClaudeUsesManualThinking(capabilityModel, intent) && *intent.BudgetTokens >= 0 {
if *intent.BudgetTokens == math.MaxInt {
return fmt.Errorf("thinking budget is too large to derive max_tokens")
}
required := uint(*intent.BudgetTokens) + 1
const maxDerivedTokens = uint(math.MaxInt32 / 2)
if required > maxDerivedTokens {
return fmt.Errorf("thinking budget %d exceeds the supported conversion limit", *intent.BudgetTokens)
}
if minimum < required {
minimum = required
}
}
req.MaxTokens = &minimum
}
rendered, err := reasoning.RenderClaude(capabilityModel, intent, req.MaxTokens, opts.Claude.ThinkingAdapterBudgetTokensPercentage)
if err != nil {
return err
}
req.Model = baseModel
if rendered.Thinking != nil {
req.Thinking = rendered.Thinking
}
if rendered.OutputEffort != "" {
outputConfig := make(map[string]any)
if len(req.OutputConfig) > 0 {
if kitutil.GetJsonType(req.OutputConfig) != "object" {
return fmt.Errorf("Claude output_config must be a JSON object")
}
if err := kitutil.Unmarshal(req.OutputConfig, &outputConfig); err != nil {
return fmt.Errorf("invalid Claude output_config: %w", err)
}
if outputConfig == nil {
outputConfig = make(map[string]any)
}
}
outputConfig["effort"] = string(rendered.OutputEffort)
encoded, err := kitutil.Marshal(outputConfig)
if err != nil {
return fmt.Errorf("failed to marshal Claude output_config: %w", err)
}
req.OutputConfig = encoded
}
if rendered.ClearSampling {
req.Temperature = nil
req.TopP = nil
req.TopK = nil
} else if rendered.ConstrainThinkingSampling {
req.Temperature = nil
req.TopK = nil
if req.TopP != nil && (*req.TopP < 0.95 || *req.TopP > 1) {
req.TopP = nil
}
}
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))
}
return nil
}
@@ -0,0 +1,52 @@
package claude
import "github.com/QuantumNous/new-api/relaykit/dto"
func UsageFromOpenAI(usage *dto.Usage) *dto.ClaudeUsage {
if usage == nil {
return nil
}
// An existing sidecar snapshots the original provider usage; carry it
// across this bridge unchanged regardless of its dialect. Only synthesize
// an OpenAI snapshot when no sidecar exists yet.
existingBillingUsage := dto.CloneBillingUsage(usage.BillingUsage)
if existingBillingUsage != nil && existingBillingUsage.ClaudeUsage != nil &&
(existingBillingUsage.Source == dto.BillingUsageSourceClaudeMessages || existingBillingUsage.Semantic == dto.BillingUsageSemanticAnthropic) {
result := existingBillingUsage.ClaudeUsage
result.BillingUsage = dto.CloneBillingUsage(usage.BillingUsage)
return result
}
billingUsage := existingBillingUsage
if billingUsage == nil {
billingUsage = dto.NewOpenAIChatBillingUsage(usage)
}
cacheCreation5m, cacheCreation1h := NormalizeCacheCreationSplit(
usage.PromptTokensDetails.CachedCreationTokens,
usage.ClaudeCacheCreation5mTokens,
usage.ClaudeCacheCreation1hTokens,
)
cacheCreationTokens := usage.PromptTokensDetails.CacheCreationTokensTotal()
inputTokens := usage.PromptTokens
if usage.UsageSemantic != dto.BillingUsageSemanticAnthropic {
// OpenAI-style prompt/input totals include cache reads and writes, while
// Claude reports both separately from input_tokens.
inputTokens = usage.PromptTokens - usage.PromptTokensDetails.CachedTokens - cacheCreationTokens
if inputTokens < 0 {
inputTokens = 0
}
}
result := &dto.ClaudeUsage{
InputTokens: inputTokens,
OutputTokens: usage.CompletionTokens,
CacheCreationInputTokens: cacheCreationTokens,
CacheReadInputTokens: usage.PromptTokensDetails.CachedTokens,
BillingUsage: billingUsage,
}
if cacheCreation5m > 0 || cacheCreation1h > 0 {
result.CacheCreation = &dto.ClaudeCacheCreationUsage{
Ephemeral5mInputTokens: cacheCreation5m,
Ephemeral1hInputTokens: cacheCreation1h,
}
}
return result
}
@@ -1,12 +1,12 @@
package gemini
import (
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
)
@@ -41,14 +41,6 @@ var SafetySettingCategories = []string{
const ThoughtSignatureBypassValue = "context_engineering_is_the_way_to_go"
const (
pro25MinBudget = 128
pro25MaxBudget = 32768
flash25MaxBudget = 24576
flash25LiteMinBudget = 512
flash25LiteMaxBudget = 24576
)
func ShouldAttachThoughtSignature(opts *convmeta.Options) bool {
return opts != nil && opts.Gemini.FunctionCallThoughtSignatureEnabled
}
@@ -81,70 +73,109 @@ func AttachFirstTextThoughtSignature(opts *convmeta.Options, parts []dto.GeminiP
return false
}
func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) {
func ApplyThinkingConfig(geminiRequest *dto.GeminiChatRequest, info convmeta.Meta, oaiRequest ...dto.GeneralOpenAIRequest) error {
opts := convmeta.OptionsOf(info)
if geminiRequest == nil || info == nil || !opts.Gemini.ThinkingAdapterEnabled {
return
if geminiRequest == nil {
return nil
}
modelName := convmeta.UpstreamModelName(info)
isNew25Pro := strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
if strings.Contains(modelName, "-thinking-") {
parts := strings.SplitN(modelName, "-thinking-", 2)
if len(parts) == 2 && parts[1] != "" {
if budgetTokens, err := strconv.Atoi(parts[1]); err == nil {
clampedBudget := clampThinkingBudget(modelName, budgetTokens)
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
ThinkingBudget: kitutil.GetPointer(clampedBudget),
IncludeThoughts: true,
}
}
var source reasoning.Intent
if len(oaiRequest) > 0 {
if modelName == "" {
modelName = oaiRequest[0].Model
}
} else if strings.HasSuffix(modelName, "-thinking") {
unsupportedModels := []string{
"gemini-2.5-pro-preview-05-06",
"gemini-2.5-pro-preview-03-25",
var err error
source, err = reasoning.FromOpenAIChat(&oaiRequest[0])
if err != nil {
return err
}
isUnsupported := false
for _, unsupportedModel := range unsupportedModels {
if strings.HasPrefix(modelName, unsupportedModel) {
isUnsupported = true
break
}
}
if isUnsupported {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
}
} else {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
}
if geminiRequest.GenerationConfig.MaxOutputTokens != nil && *geminiRequest.GenerationConfig.MaxOutputTokens > 0 {
budgetTokens := opts.Gemini.ThinkingAdapterBudgetTokensPercentage * float64(*geminiRequest.GenerationConfig.MaxOutputTokens)
clampedBudget := clampThinkingBudget(modelName, int(budgetTokens))
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = kitutil.GetPointer(clampedBudget)
} else if len(oaiRequest) > 0 {
geminiRequest.GenerationConfig.ThinkingConfig.ThinkingBudget = kitutil.GetPointer(clampThinkingBudgetByEffort(modelName, oaiRequest[0].ReasoningEffort))
}
}
} else if strings.HasSuffix(modelName, "-nothinking") {
if !isNew25Pro {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
ThinkingBudget: kitutil.GetPointer(0),
}
}
} else if _, level, ok := reasoning.TrimEffortSuffix(modelName); ok && level != "" {
geminiRequest.GenerationConfig.ThinkingConfig = &dto.GeminiThinkingConfig{
IncludeThoughts: true,
ThinkingLevel: level,
}
info.SetReasoningEffort(level)
}
baseModel := modelName
suffix := reasoning.IntentFromState(convmeta.ReasoningStateOf(info))
preserveSuffix := opts.ShouldPreserveThinkingSuffix(modelName)
if info != nil && opts.ShouldPreserveThinkingSuffix(info.GetOriginModelName()) {
preserveSuffix = true
}
if preserveSuffix {
suffix = reasoning.Intent{}
}
native, err := reasoning.FromGemini(geminiRequest)
if err != nil {
return err
}
source = reasoning.ResolveGeminiEnabledDefault(baseModel, source, geminiRequest.GenerationConfig.MaxOutputTokens)
if native.HasStrength() && source.HasStrength() {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, native, source)
if compareErr != nil {
return compareErr
}
if !equivalent {
nativeEffort := reasoning.EffectiveEffort(native)
sourceEffort := reasoning.EffectiveEffort(source)
return fmt.Errorf("%w for model %q: Gemini thinking_config effort %q differs from standard effort %q", reasoning.ErrEffortConflict, modelName, nativeEffort, sourceEffort)
}
// Native Gemini configuration is the lossless representation. Once the
// two controls are equivalent, retain only portable visibility metadata
// from the standard representation.
if native.IncludeThoughts == nil {
native.IncludeThoughts = source.IncludeThoughts
}
source = reasoning.Intent{}
}
explicit, err := reasoning.MergeExplicit(native, source, modelName)
if err != nil {
return err
}
if explicit.HasStrength() && suffix.HasStrength() {
equivalent, compareErr := reasoning.EquivalentGeminiStrength(baseModel, explicit, suffix)
if compareErr != nil {
return compareErr
}
if equivalent {
if explicit.IncludeThoughts == nil {
explicit.IncludeThoughts = suffix.IncludeThoughts
}
suffix = reasoning.Intent{}
}
}
requested, err := reasoning.MergeExplicitAndSuffix(explicit, suffix, modelName)
if err != nil {
return err
}
requested = reasoning.ResolveGeminiEnabledDefault(baseModel, requested, geminiRequest.GenerationConfig.MaxOutputTokens)
if native.HasStrength() && !suffix.HasStrength() {
if explicit.IncludeThoughts != nil {
geminiRequest.GenerationConfig.ThinkingConfig.IncludeThoughts = explicit.IncludeThoughts
}
effort, err := reasoning.ValidateGeminiThinkingConfig(baseModel, geminiRequest.GenerationConfig.ThinkingConfig)
if err != nil {
return err
}
if info != nil && effort != "" {
info.SetReasoningEffort(string(effort))
}
return nil
}
if requested.IsEmpty() {
return nil
}
rendered, err := reasoning.RenderGemini(
baseModel,
requested,
geminiRequest.GenerationConfig.MaxOutputTokens,
opts.Gemini.ThinkingAdapterBudgetTokensPercentage,
)
if err != nil {
return err
}
geminiRequest.GenerationConfig.ThinkingConfig = rendered.Config
if info != nil && rendered.EffectiveEffort != "" {
info.SetReasoningEffort(string(rendered.EffectiveEffort))
}
return nil
}
func ParseStopSequences(stop any) []string {
@@ -200,68 +231,3 @@ func SupportedMimeTypesList() []string {
}
return keys
}
func isNew25ProModel(modelName string) bool {
return strings.HasPrefix(modelName, "gemini-2.5-pro") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-05-06") &&
!strings.HasPrefix(modelName, "gemini-2.5-pro-preview-03-25")
}
func is25FlashLiteModel(modelName string) bool {
return strings.HasPrefix(modelName, "gemini-2.5-flash-lite")
}
func clampThinkingBudget(modelName string, budget int) int {
isNew25Pro := isNew25ProModel(modelName)
is25FlashLite := is25FlashLiteModel(modelName)
if is25FlashLite {
if budget < flash25LiteMinBudget {
return flash25LiteMinBudget
}
if budget > flash25LiteMaxBudget {
return flash25LiteMaxBudget
}
} else if isNew25Pro {
if budget < pro25MinBudget {
return pro25MinBudget
}
if budget > pro25MaxBudget {
return pro25MaxBudget
}
} else {
if budget < 0 {
return 0
}
if budget > flash25MaxBudget {
return flash25MaxBudget
}
}
return budget
}
func clampThinkingBudgetByEffort(modelName string, effort string) int {
isNew25Pro := isNew25ProModel(modelName)
is25FlashLite := is25FlashLiteModel(modelName)
maxBudget := 0
if is25FlashLite {
maxBudget = flash25LiteMaxBudget
}
if isNew25Pro {
maxBudget = pro25MaxBudget
} else {
maxBudget = flash25MaxBudget
}
switch effort {
case "high":
maxBudget = maxBudget * 80 / 100
case "medium":
maxBudget = maxBudget * 50 / 100
case "low":
maxBudget = maxBudget * 20 / 100
case "minimal":
maxBudget = maxBudget * 5 / 100
}
return clampThinkingBudget(modelName, maxBudget)
}
@@ -0,0 +1,960 @@
package toolconv
import (
"encoding/json"
"fmt"
"strconv"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
const maxClaudeWebSearchUses = 1000
func ExtractRequest(format types.RelayFormat, request any) (any, Set, error) {
switch format {
case types.RelayFormatOpenAI:
return extractOpenAIChatRequest(request)
case types.RelayFormatOpenAIResponses:
return extractOpenAIResponsesRequest(request)
case types.RelayFormatClaude:
return extractClaudeRequest(request)
case types.RelayFormatGemini:
return extractGeminiRequest(request)
default:
return request, Set{Source: format}, nil
}
}
func extractOpenAIChatRequest(request any) (any, Set, error) {
source, ok := request.(*dto.GeneralOpenAIRequest)
if !ok {
value, valueOK := request.(dto.GeneralOpenAIRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected OpenAI chat completions request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatOpenAI}
set.ParallelAllowed = source.ParallelTooCalls
if len(source.Functions) > 0 {
var functions []dto.FunctionRequest
if err := kitutil.Unmarshal(source.Functions, &functions); err != nil {
return nil, Set{}, fmt.Errorf("invalid legacy functions: %w", err)
}
for _, function := range functions {
function := function
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Function: &Function{Name: function.Name, Description: function.Description, Parameters: function.Parameters, Strict: function.Strict},
})
}
}
for index, tool := range source.Tools {
if tool.Type == "function" || tool.Type == "" {
set.Definitions = append(set.Definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Function: &Function{
Name: tool.Function.Name,
Description: tool.Function.Description,
Parameters: tool.Function.Parameters,
Strict: tool.Function.Strict,
},
})
continue
}
if len(tool.Custom) == 0 {
return nil, Set{}, fmt.Errorf("tools[%d] has unsupported type %q without a native payload", index, tool.Type)
}
definition, err := decodeOpenAIResponsesDefinition(tool.Custom)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
if source.WebSearchOptions != nil {
webSearch := &WebSearch{
SearchContextSize: source.WebSearchOptions.SearchContextSize,
}
location, err := decodeOpenAIChatLocation(source.WebSearchOptions.UserLocation)
if err != nil {
return nil, Set{}, err
}
webSearch.Location = location
set.Definitions = append(set.Definitions, Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: "web_search_options",
WebSearch: webSearch,
})
}
if choice, err := decodeOpenAIChatChoice(source.ToolChoice); err != nil {
return nil, Set{}, err
} else if choice != nil {
set.Choice = choice
}
if len(source.FunctionCall) > 0 {
legacyChoice, err := decodeLegacyOpenAIFunctionChoice(source.FunctionCall)
if err != nil {
return nil, Set{}, err
}
if set.Choice != nil && legacyChoice != nil {
return nil, Set{}, fmt.Errorf("tool_choice and legacy function_call cannot both be converted")
}
set.Choice = legacyChoice
}
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.WebSearchOptions = nil
clone.Functions = nil
clone.FunctionCall = nil
clone.ParallelTooCalls = nil
return &clone, set, nil
}
func extractOpenAIResponsesRequest(request any) (any, Set, error) {
source, ok := request.(*dto.OpenAIResponsesRequest)
if !ok {
value, valueOK := request.(dto.OpenAIResponsesRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected OpenAI Responses request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatOpenAIResponses}
set.ParallelAllowed = rawBoolPointer(source.ParallelToolCalls)
if len(source.Tools) > 0 {
var rawTools []json.RawMessage
if err := kitutil.Unmarshal(source.Tools, &rawTools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Responses tools: %w", err)
}
for index, rawTool := range rawTools {
definition, err := decodeOpenAIResponsesDefinition(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
}
choice, err := decodeOpenAIResponsesChoice(source.ToolChoice)
if err != nil {
return nil, Set{}, err
}
set.Choice = choice
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.ParallelToolCalls = nil
sanitizedInput, history, err := extractOpenAIResponsesHostedHistory(source.Input)
if err != nil {
return nil, Set{}, err
}
clone.Input = sanitizedInput
set.History = history
return &clone, set, nil
}
func extractClaudeRequest(request any) (any, Set, error) {
source, ok := request.(*dto.ClaudeRequest)
if !ok {
value, valueOK := request.(dto.ClaudeRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected Claude Messages request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatClaude}
if source.ToolChoice != nil {
rawChoice, rawErr := rawJSON(source.ToolChoice)
if rawErr == nil {
var choiceMap map[string]any
if kitutil.Unmarshal(rawChoice, &choiceMap) == nil {
if disabled, ok := choiceMap["disable_parallel_tool_use"].(bool); ok {
allowed := !disabled
set.ParallelAllowed = &allowed
}
}
}
}
if source.Tools != nil {
rawTools, err := rawJSON(source.Tools)
if err != nil {
return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
}
var tools []json.RawMessage
if err := kitutil.Unmarshal(rawTools, &tools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Claude tools: %w", err)
}
for index, rawTool := range tools {
definition, err := decodeClaudeDefinition(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
set.Definitions = append(set.Definitions, definition)
}
}
choice, err := decodeClaudeChoice(source.ToolChoice, set.Definitions)
if err != nil {
return nil, Set{}, err
}
set.Choice = choice
clone := *source
clone.Tools = nil
clone.ToolChoice = nil
clone.Messages, set.History, err = extractClaudeHostedHistory(source.Messages)
if err != nil {
return nil, Set{}, err
}
return &clone, set, nil
}
func extractGeminiRequest(request any) (any, Set, error) {
source, ok := request.(*dto.GeminiChatRequest)
if !ok {
value, valueOK := request.(dto.GeminiChatRequest)
if !valueOK {
return nil, Set{}, fmt.Errorf("expected Gemini generateContent request, got %T", request)
}
source = &value
}
set := Set{Source: types.RelayFormatGemini}
if source.ToolConfig != nil {
set.NativeToolConfig, _ = rawJSON(source.ToolConfig)
}
if len(source.Tools) > 0 {
var tools []json.RawMessage
if err := kitutil.Unmarshal(source.Tools, &tools); err != nil {
return nil, Set{}, fmt.Errorf("invalid Gemini tools: %w", err)
}
for index, rawTool := range tools {
definitions, err := decodeGeminiDefinitions(rawTool)
if err != nil {
return nil, Set{}, fmt.Errorf("tools[%d]: %w", index, err)
}
for definitionIndex := range definitions {
definitions[definitionIndex].Group = index
}
set.Definitions = append(set.Definitions, definitions...)
}
}
set.Choice = decodeGeminiChoice(source.ToolConfig)
clone := *source
clone.Tools = nil
clone.ToolConfig = nil
return &clone, set, nil
}
func decodeOpenAIResponsesDefinition(raw json.RawMessage) (Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return Definition{}, err
}
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if toolType == "function" {
return Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["parameters"],
Strict: boolPointer(tool, "strict"),
},
}, nil
}
if isOpenAIResponsesWebSearchType(toolType) {
webSearch := &WebSearch{
SearchContextSize: strings.TrimSpace(kitutil.Interface2String(tool["search_context_size"])),
ExternalWebAccess: boolPointer(tool, "external_web_access"),
}
if value, exists := tool["return_token_budget"]; exists {
encoded, err := rawJSON(value)
if err != nil {
return Definition{}, err
}
webSearch.ReturnTokenBudget = encoded
}
if filters, ok := tool["filters"].(map[string]any); ok {
webSearch.AllowedDomains = stringSlice(filters["allowed_domains"])
}
if location, ok := tool["user_location"].(map[string]any); ok {
webSearch.Location = locationFromMap(location)
}
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: toolType,
WebSearch: webSearch,
Raw: cloneRaw(raw),
}, nil
}
return Definition{
Kind: kindFromNativeType(toolType),
Execution: executionFromNativeType(toolType),
NativeType: toolType,
Raw: cloneRaw(raw),
}, nil
}
func decodeClaudeDefinition(raw json.RawMessage) (Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return Definition{}, err
}
toolType := strings.TrimSpace(kitutil.Interface2String(tool["type"]))
if strings.HasPrefix(toolType, "web_search") {
if !isVersionedClaudeWebSearchType(toolType) {
return Definition{}, fmt.Errorf("invalid Claude web-search tool version %q", toolType)
}
if !isKnownClaudeWebSearchType(toolType) {
return Definition{
Kind: KindNative,
Execution: ExecutionServer,
NativeType: toolType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
}, nil
}
toolName := strings.TrimSpace(kitutil.Interface2String(tool["name"]))
if toolName != "web_search" {
return Definition{}, fmt.Errorf("Claude web-search tool name must be %q", "web_search")
}
webSearch := &WebSearch{
AllowedDomains: stringSlice(tool["allowed_domains"]),
BlockedDomains: stringSlice(tool["blocked_domains"]),
AllowedCallers: stringSlice(tool["allowed_callers"]),
ResponseInclusion: strings.TrimSpace(kitutil.Interface2String(tool["response_inclusion"])),
}
if _, exists := tool["max_uses"]; exists {
var fields struct {
MaxUses *int `json:"max_uses"`
}
if err := kitutil.Unmarshal(raw, &fields); err != nil || fields.MaxUses == nil {
return Definition{}, fmt.Errorf("max_uses must be a JSON integer")
}
if *fields.MaxUses <= 0 || *fields.MaxUses > maxClaudeWebSearchUses {
return Definition{}, fmt.Errorf("max_uses must be between 1 and %d", maxClaudeWebSearchUses)
}
webSearch.MaxUses = fields.MaxUses
}
if len(webSearch.AllowedDomains) > 0 && len(webSearch.BlockedDomains) > 0 {
return Definition{}, fmt.Errorf("allowed_domains and blocked_domains are mutually exclusive")
}
if webSearch.ResponseInclusion != "" && !claudeWebSearchSupportsResponseInclusion(toolType) {
return Definition{}, fmt.Errorf("response_inclusion requires Claude web_search_20260318")
}
if location, ok := tool["user_location"].(map[string]any); ok {
webSearch.Location = locationFromMap(location)
}
return Definition{
Kind: KindWebSearch,
Execution: ExecutionServer,
NativeType: toolType,
Name: toolName,
WebSearch: webSearch,
Raw: cloneRaw(raw),
}, nil
}
if toolType == "" {
return Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Description: kitutil.Interface2String(tool["description"]),
Parameters: tool["input_schema"],
Strict: boolPointer(tool, "strict"),
},
}, nil
}
return Definition{
Kind: kindFromNativeType(toolType),
Execution: executionFromNativeType(toolType),
NativeType: toolType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: cloneRaw(raw),
}, nil
}
func decodeGeminiDefinitions(raw json.RawMessage) ([]Definition, error) {
var tool map[string]any
if err := kitutil.Unmarshal(raw, &tool); err != nil {
return nil, err
}
definitions := make([]Definition, 0)
if functions, ok := tool["functionDeclarations"].([]any); ok {
for _, value := range functions {
function, ok := value.(map[string]any)
if !ok {
continue
}
parameters := function["parameters"]
parametersJSONSchema, hasParametersJSONSchema := function["parametersJsonSchema"]
if parameters != nil && hasParametersJSONSchema && parametersJSONSchema != nil {
return nil, fmt.Errorf("function %q declares both parameters and parametersJsonSchema", strings.TrimSpace(kitutil.Interface2String(function["name"])))
}
if parameters == nil && hasParametersJSONSchema {
parameters = parametersJSONSchema
}
functionRaw, err := rawJSON(map[string]any{"functionDeclarations": []any{value}})
if err != nil {
return nil, err
}
definitions = append(definitions, Definition{
Kind: KindFunction,
Execution: ExecutionClient,
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
Raw: functionRaw,
Function: &Function{
Name: strings.TrimSpace(kitutil.Interface2String(function["name"])),
Description: kitutil.Interface2String(function["description"]),
Parameters: parameters,
},
})
}
}
for key := range tool {
var kind Kind
var nativeType string
switch key {
case "functionDeclarations":
continue
case "googleSearch":
kind, nativeType = KindWebSearch, "googleSearch"
case "googleSearchRetrieval":
kind, nativeType = KindWebSearch, "googleSearchRetrieval"
case "enterpriseWebSearch":
kind, nativeType = KindWebSearch, "enterpriseWebSearch"
case "googleMaps":
kind, nativeType = KindNative, "googleMaps"
case "codeExecution":
kind, nativeType = KindCodeExecution, "codeExecution"
case "urlContext":
kind, nativeType = KindURLContext, "urlContext"
case "fileSearch":
kind, nativeType = KindFileSearch, "fileSearch"
case "computerUse":
kind, nativeType = KindComputerUse, "computerUse"
case "retrieval":
kind, nativeType = KindFileSearch, "retrieval"
default:
kind, nativeType = KindNative, key
}
keyRaw, err := rawJSON(map[string]any{key: tool[key]})
if err != nil {
return nil, err
}
definition := Definition{
Kind: kind,
Execution: ExecutionServer,
NativeType: nativeType,
Name: strings.TrimSpace(kitutil.Interface2String(tool["name"])),
Raw: keyRaw,
}
if kind == KindWebSearch {
definition.WebSearch = &WebSearch{}
}
definitions = append(definitions, definition)
}
return definitions, nil
}
func decodeLegacyOpenAIFunctionChoice(raw json.RawMessage) (*Choice, error) {
if len(raw) == 0 {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, err
}
return choiceFromString(value), nil
}
var value map[string]any
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("invalid legacy function_call: %w", err)
}
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
if name == "" {
return nil, fmt.Errorf("legacy function_call requires name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
func rawBoolPointer(raw json.RawMessage) *bool {
if len(raw) == 0 || kitutil.GetJsonType(raw) != "boolean" {
return nil
}
var value bool
if kitutil.Unmarshal(raw, &value) != nil {
return nil
}
return &value
}
func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
if len(raw) == 0 {
return nil, nil
}
var wrapper map[string]any
if err := kitutil.Unmarshal(raw, &wrapper); err != nil {
return nil, fmt.Errorf("invalid web_search_options.user_location: %w", err)
}
location, ok := wrapper["approximate"].(map[string]any)
if !ok {
return nil, nil
}
return locationFromMap(location), nil
}
func decodeOpenAIChatChoice(value any) (*Choice, error) {
if value == nil {
return nil, nil
}
if text, ok := value.(string); ok {
return choiceFromString(text), nil
}
raw, err := rawJSON(value)
if err != nil {
return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid Chat tool_choice: %w", err)
}
if strings.TrimSpace(kitutil.Interface2String(choice["type"])) != "function" {
return &Choice{Mode: ChoiceOpaque, Raw: cloneRaw(raw)}, nil
}
function, _ := choice["function"].(map[string]any)
name := strings.TrimSpace(kitutil.Interface2String(function["name"]))
if name == "" {
return nil, fmt.Errorf("Chat function tool_choice requires function.name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
func decodeOpenAIResponsesChoice(raw json.RawMessage) (*Choice, error) {
if len(raw) == 0 {
return nil, nil
}
if kitutil.GetJsonType(raw) == "string" {
var text string
if err := kitutil.Unmarshal(raw, &text); err != nil {
return nil, err
}
return choiceFromString(text), nil
}
var value map[string]any
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, fmt.Errorf("invalid Responses tool_choice: %w", err)
}
toolType := strings.TrimSpace(kitutil.Interface2String(value["type"]))
if toolType == "function" {
name := strings.TrimSpace(kitutil.Interface2String(value["name"]))
if name == "" {
return nil, fmt.Errorf("Responses function tool_choice requires name")
}
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: name}, nil
}
if isOpenAIResponsesWebSearchType(toolType) {
return &Choice{Mode: ChoiceNamed, Kind: KindWebSearch, Name: "web_search", NativeType: toolType, Raw: cloneRaw(raw)}, nil
}
return &Choice{Mode: ChoiceOpaque, Kind: kindFromNativeType(toolType), NativeType: toolType, Raw: cloneRaw(raw)}, nil
}
func decodeClaudeChoice(value any, definitions []Definition) (*Choice, error) {
if value == nil {
return nil, nil
}
raw, err := rawJSON(value)
if err != nil {
return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
var choice map[string]any
if err := kitutil.Unmarshal(raw, &choice); err != nil {
return nil, fmt.Errorf("invalid Claude tool_choice: %w", err)
}
choiceType := strings.TrimSpace(kitutil.Interface2String(choice["type"]))
var decoded *Choice
switch choiceType {
case "auto":
decoded = &Choice{Mode: ChoiceAuto}
case "none":
decoded = &Choice{Mode: ChoiceNone}
case "any":
decoded = &Choice{Mode: ChoiceRequired}
case "tool":
name := strings.TrimSpace(kitutil.Interface2String(choice["name"]))
kind := KindNative
matches := 0
for _, definition := range definitions {
definitionName := definition.Name
if definition.Kind == KindFunction && definition.Function != nil {
definitionName = definition.Function.Name
}
if definitionName != name {
continue
}
matches++
kind = definition.Kind
}
if matches > 1 {
return nil, fmt.Errorf("Claude tool_choice name %q is ambiguous across %d definitions", name, matches)
}
decoded = &Choice{Mode: ChoiceNamed, Kind: kind, Name: name}
default:
decoded = &Choice{Mode: ChoiceOpaque}
}
if disabled, ok := choice["disable_parallel_tool_use"].(bool); ok {
decoded.DisableParallelToolUse = &disabled
}
decoded.Raw = cloneRaw(raw)
return decoded, nil
}
func decodeGeminiChoice(config *dto.ToolConfig) *Choice {
if config == nil || config.FunctionCallingConfig == nil {
return nil
}
functionConfig := config.FunctionCallingConfig
switch strings.ToUpper(strings.TrimSpace(string(functionConfig.Mode))) {
case "NONE":
return &Choice{Mode: ChoiceNone}
case "ANY":
if len(functionConfig.AllowedFunctionNames) == 1 {
return &Choice{Mode: ChoiceNamed, Kind: KindFunction, Name: functionConfig.AllowedFunctionNames[0]}
}
return &Choice{
Mode: ChoiceRequired,
Kind: KindFunction,
AllowedNames: append([]string(nil), functionConfig.AllowedFunctionNames...),
}
case "", "AUTO":
return &Choice{Mode: ChoiceAuto}
default:
raw, _ := rawJSON(functionConfig)
return &Choice{Mode: ChoiceOpaque, Raw: raw}
}
}
func choiceFromString(value string) *Choice {
switch strings.ToLower(strings.TrimSpace(value)) {
case "none":
return &Choice{Mode: ChoiceNone}
case "required", "any":
return &Choice{Mode: ChoiceRequired}
case "auto":
return &Choice{Mode: ChoiceAuto}
default:
raw, _ := rawJSON(value)
return &Choice{Mode: ChoiceOpaque, Raw: raw}
}
}
func isOpenAIResponsesWebSearchType(toolType string) bool {
switch toolType {
case "web_search", "web_search_2025_08_26", "web_search_preview", "web_search_preview_2025_03_11":
return true
default:
return false
}
}
func claudeWebSearchSupportsResponseInclusion(toolType string) bool {
return toolType == "web_search_20260318"
}
func isKnownClaudeWebSearchType(toolType string) bool {
switch toolType {
case "web_search_20250305", "web_search_20260209", "web_search_20260318":
return true
default:
return false
}
}
func isVersionedClaudeWebSearchType(toolType string) bool {
const prefix = "web_search_"
version := strings.TrimPrefix(toolType, prefix)
if !strings.HasPrefix(toolType, prefix) || len(version) != 8 {
return false
}
_, err := strconv.ParseUint(version, 10, 32)
return err == nil
}
func locationFromMap(value map[string]any) *ApproximateLocation {
if len(value) == 0 {
return nil
}
location := &ApproximateLocation{
City: strings.TrimSpace(kitutil.Interface2String(value["city"])),
Region: strings.TrimSpace(kitutil.Interface2String(value["region"])),
Country: strings.TrimSpace(kitutil.Interface2String(value["country"])),
Timezone: strings.TrimSpace(kitutil.Interface2String(value["timezone"])),
}
if location.City == "" && location.Region == "" && location.Country == "" && location.Timezone == "" {
return nil
}
return location
}
func boolPointer(value map[string]any, key string) *bool {
raw, exists := value[key]
if !exists {
return nil
}
parsed, ok := raw.(bool)
if !ok {
return nil
}
return &parsed
}
func stringSlice(value any) []string {
items, ok := value.([]any)
if !ok {
if strings, stringsOK := value.([]string); stringsOK {
return append([]string(nil), strings...)
}
return nil
}
result := make([]string, 0, len(items))
for _, item := range items {
if text, ok := item.(string); ok && strings.TrimSpace(text) != "" {
result = append(result, text)
}
}
return result
}
func rawJSON(value any) (json.RawMessage, error) {
switch raw := value.(type) {
case json.RawMessage:
return cloneRaw(raw), nil
case []byte:
return cloneRaw(raw), nil
default:
encoded, err := kitutil.Marshal(value)
return json.RawMessage(encoded), err
}
}
func cloneRaw(raw []byte) json.RawMessage {
return append(json.RawMessage(nil), raw...)
}
func kindFromNativeType(toolType string) Kind {
switch {
case toolType == "file_search":
return KindFileSearch
case strings.HasPrefix(toolType, "web_fetch"):
return KindWebFetch
case toolType == "code_interpreter", strings.HasPrefix(toolType, "code_execution"):
return KindCodeExecution
case strings.Contains(toolType, "computer"):
return KindComputerUse
case toolType == "url_context":
return KindURLContext
case toolType == "mcp", toolType == "mcp_toolset":
return KindMCP
case toolType == "image_generation":
return KindImage
default:
return KindNative
}
}
func executionFromNativeType(toolType string) Execution {
if strings.HasPrefix(toolType, "computer_") || strings.HasPrefix(toolType, "bash_") || strings.HasPrefix(toolType, "text_editor_") || strings.HasPrefix(toolType, "memory_") {
return ExecutionClient
}
return ExecutionServer
}
func extractOpenAIResponsesHostedHistory(input json.RawMessage) (json.RawMessage, []HostedHistoryItem, error) {
if len(input) == 0 || kitutil.GetJsonType(input) != "array" {
return input, nil, nil
}
var rawItems []json.RawMessage
if err := kitutil.Unmarshal(input, &rawItems); err != nil {
return nil, nil, fmt.Errorf("invalid Responses input: %w", err)
}
filtered := make([]json.RawMessage, 0, len(rawItems))
var history []HostedHistoryItem
for index, rawItem := range rawItems {
var item map[string]any
if err := kitutil.Unmarshal(rawItem, &item); err != nil {
return nil, nil, fmt.Errorf("input[%d]: %w", index, err)
}
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
if !isResponsesHostedHistoryType(itemType) {
filtered = append(filtered, rawItem)
continue
}
status := strings.TrimSpace(kitutil.Interface2String(item["status"]))
action := rawMapValue(item, "action")
results := firstRawMapValue(item, "results", "sources", "output")
if itemType == "mcp_call" {
action = rawMapValue(item, "arguments")
output := rawMapValue(item, "output")
itemError := rawMapValue(item, "error")
results = output
if rawJSONPresent(itemError) {
results = itemError
status = "failed"
}
}
history = append(history, HostedHistoryItem{
Kind: hostedKindFromResponsesType(itemType),
NativeType: itemType,
Role: strings.TrimSpace(kitutil.Interface2String(item["role"])),
MessageIndex: index,
Sequence: index,
ID: strings.TrimSpace(kitutil.Interface2String(item["id"])),
CallID: strings.TrimSpace(kitutil.Interface2String(item["call_id"])),
Name: strings.TrimSpace(kitutil.Interface2String(item["name"])),
ServerName: strings.TrimSpace(kitutil.Interface2String(item["server_label"])),
Status: status,
Action: action,
Results: results,
Caller: rawMapValue(item, "caller"),
Raw: cloneRaw(rawItem),
})
}
if len(history) == 0 {
return input, nil, nil
}
encoded, err := kitutil.Marshal(filtered)
if err != nil {
return nil, nil, err
}
return encoded, history, nil
}
func isResponsesHostedHistoryType(itemType string) bool {
switch strings.TrimSpace(itemType) {
case "web_search_call", "file_search_call", "code_interpreter_call", "computer_call", "computer_call_output", "image_generation_call", "local_shell_call", "local_shell_call_output", "apply_patch_call", "apply_patch_call_output", "mcp_call", "mcp_list_tools", "mcp_approval_request", "mcp_approval_response":
return true
default:
return false
}
}
func extractClaudeHostedHistory(messages []dto.ClaudeMessage) ([]dto.ClaudeMessage, []HostedHistoryItem, error) {
clonedMessages := make([]dto.ClaudeMessage, 0, len(messages))
var history []HostedHistoryItem
for messageIndex := range messages {
message := messages[messageIndex]
if message.IsStringContent() {
clonedMessages = append(clonedMessages, message)
continue
}
rawContent, err := rawJSON(message.Content)
if err != nil {
return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
var blocks []json.RawMessage
if err := kitutil.Unmarshal(rawContent, &blocks); err != nil {
return nil, nil, fmt.Errorf("messages[%d].content: %w", messageIndex, err)
}
filtered := make([]any, 0, len(blocks))
historyStart := len(history)
for blockIndex, rawBlock := range blocks {
var block map[string]any
if err := kitutil.Unmarshal(rawBlock, &block); err != nil {
return nil, nil, fmt.Errorf("messages[%d].content[%d]: %w", messageIndex, blockIndex, err)
}
blockType := strings.TrimSpace(kitutil.Interface2String(block["type"]))
if blockType != "server_tool_use" && blockType != "mcp_tool_use" && !isClaudeHostedToolBlock(blockType) {
filtered = append(filtered, block)
continue
}
name := strings.TrimSpace(kitutil.Interface2String(block["name"]))
kind := hostedKindFromClaudeCall(blockType, name)
if strings.HasSuffix(blockType, "_tool_result") {
kind = hostedKindFromClaudeResult(blockType)
}
results := rawMapValue(block, "content")
status := "in_progress"
if strings.HasSuffix(blockType, "_tool_result") {
status = "completed"
isError, _ := block["is_error"].(bool)
failed, _ := claudeHostedResultFailure(
blockType,
results,
&isError,
strings.TrimSpace(kitutil.Interface2String(block["error_code"])),
)
if failed {
status = "failed"
}
}
history = append(history, HostedHistoryItem{
Kind: kind,
NativeType: blockType,
Role: message.Role,
MessageIndex: messageIndex,
BlockIndex: blockIndex,
Sequence: len(history),
ID: strings.TrimSpace(kitutil.Interface2String(block["id"])),
CallID: strings.TrimSpace(kitutil.Interface2String(block["tool_use_id"])),
Name: name,
ServerName: strings.TrimSpace(kitutil.Interface2String(block["server_name"])),
Status: status,
Action: rawMapValue(block, "input"),
Results: results,
Caller: rawMapValue(block, "caller"),
Raw: cloneRaw(rawBlock),
})
}
if len(filtered) > 0 {
for index := historyStart; index < len(history); index++ {
history[index].MessageHasRegular = true
}
message.Content = filtered
clonedMessages = append(clonedMessages, message)
}
}
return clonedMessages, history, nil
}
func rawMapValue(value map[string]any, key string) json.RawMessage {
item, exists := value[key]
if !exists {
return nil
}
encoded, err := kitutil.Marshal(item)
if err != nil {
return nil
}
return encoded
}
func firstRawMapValue(value map[string]any, keys ...string) json.RawMessage {
for _, key := range keys {
if raw := rawMapValue(value, key); len(raw) > 0 {
return raw
}
}
return nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,171 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
)
// claudeWebSearchInputFromResponses narrows the richer Responses action union
// to the only operation exposed by Claude's web-search server tool: one query.
func claudeWebSearchInputFromResponses(raw json.RawMessage) (map[string]any, error) {
canonical, err := dto.NormalizeResponsesWebSearchAction(raw)
if err != nil {
return nil, err
}
var action struct {
Type string `json:"type"`
Query string `json:"query"`
Queries []string `json:"queries"`
}
if err := kitutil.Unmarshal(canonical, &action); err != nil {
return nil, fmt.Errorf("decode normalized Responses web-search action: %w", err)
}
if action.Type != "search" {
return nil, fmt.Errorf("Responses web-search action %q has no Claude equivalent", action.Type)
}
queries := make([]string, 0, len(action.Queries)+1)
for _, query := range action.Queries {
query = strings.TrimSpace(query)
if query != "" {
queries = append(queries, query)
}
}
deprecatedQuery := strings.TrimSpace(action.Query)
if len(queries) == 0 && deprecatedQuery != "" {
queries = append(queries, deprecatedQuery)
} else if deprecatedQuery != "" && (len(queries) != 1 || queries[0] != deprecatedQuery) {
return nil, fmt.Errorf("Responses web-search action contains conflicting query and queries fields")
}
if len(queries) != 1 {
return nil, fmt.Errorf("Claude web search requires exactly one query, got %d", len(queries))
}
return map[string]any{"query": queries[0]}, nil
}
// responsesMCPArgumentsFromClaude converts Claude's JSON-object input into the
// JSON string required by a Responses mcp_call.arguments field.
func responsesMCPArgumentsFromClaude(raw json.RawMessage) (json.RawMessage, error) {
trimmed := strings.TrimSpace(string(raw))
if trimmed == "" || kitutil.GetJsonType(raw) != "object" {
return nil, fmt.Errorf("Claude MCP input must be a JSON object")
}
var object map[string]json.RawMessage
if err := kitutil.Unmarshal(raw, &object); err != nil {
return nil, fmt.Errorf("decode Claude MCP input: %w", err)
}
encoded, err := kitutil.Marshal(trimmed)
if err != nil {
return nil, fmt.Errorf("encode Responses MCP arguments: %w", err)
}
return encoded, nil
}
// claudeMCPInputFromResponses decodes the outer Responses JSON string and
// validates that its contents satisfy Claude's JSON-object input contract.
func claudeMCPInputFromResponses(raw json.RawMessage) (any, error) {
var encoded string
if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
return nil, fmt.Errorf("Responses MCP arguments must be a JSON string")
}
if err := kitutil.Unmarshal(raw, &encoded); err != nil {
return nil, fmt.Errorf("decode Responses MCP arguments string: %w", err)
}
encoded = strings.TrimSpace(encoded)
if encoded == "" || kitutil.GetJsonType(json.RawMessage(encoded)) != "object" {
return nil, fmt.Errorf("Responses MCP arguments must contain a JSON object")
}
var input map[string]any
if err := kitutil.Unmarshal([]byte(encoded), &input); err != nil {
return nil, fmt.Errorf("decode Responses MCP arguments object: %w", err)
}
return input, nil
}
// responsesMCPStringFromClaudeContent maps the Claude result shapes that can
// be represented without changing their meaning. A single text block is the
// structured form of a plain MCP text result; other block arrays can contain
// media/resources that a Responses string cannot faithfully preserve.
func responsesMCPStringFromClaudeContent(raw json.RawMessage) (json.RawMessage, bool, error) {
switch kitutil.GetJsonType(raw) {
case "string":
var value string
if err := kitutil.Unmarshal(raw, &value); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP result string: %w", err)
}
return append(json.RawMessage(nil), raw...), false, nil
case "array":
var blocks []map[string]json.RawMessage
if err := kitutil.Unmarshal(raw, &blocks); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP result blocks: %w", err)
}
if len(blocks) == 0 {
encoded, err := kitutil.Marshal("")
return encoded, true, err
}
if len(blocks) != 1 {
return nil, false, fmt.Errorf("Responses MCP output cannot preserve %d Claude content blocks", len(blocks))
}
var blockType string
if err := kitutil.Unmarshal(blocks[0]["type"], &blockType); err != nil || blockType != "text" {
return nil, false, fmt.Errorf("Responses MCP output can only preserve a Claude text result block")
}
var text string
if err := kitutil.Unmarshal(blocks[0]["text"], &text); err != nil {
return nil, false, fmt.Errorf("decode Claude MCP text result: %w", err)
}
encoded, err := kitutil.Marshal(text)
if err != nil {
return nil, false, fmt.Errorf("encode Responses MCP output: %w", err)
}
return encoded, true, nil
default:
return nil, false, fmt.Errorf("Responses MCP output cannot preserve Claude result type %q", kitutil.GetJsonType(raw))
}
}
func claudeMCPContentFromResponsesString(raw json.RawMessage) (string, error) {
if len(raw) == 0 || kitutil.GetJsonType(raw) != "string" {
return "", fmt.Errorf("Responses MCP output/error must be a JSON string")
}
var content string
if err := kitutil.Unmarshal(raw, &content); err != nil {
return "", fmt.Errorf("decode Responses MCP output/error string: %w", err)
}
return content, nil
}
func claudeHostedResultFailure(blockType string, content json.RawMessage, explicitError *bool, explicitCode string) (bool, string) {
if explicitError != nil && *explicitError {
return true, strings.TrimSpace(explicitCode)
}
if strings.TrimSpace(explicitCode) != "" {
return true, strings.TrimSpace(explicitCode)
}
if !strings.HasSuffix(strings.TrimSpace(blockType), "_tool_result") || kitutil.GetJsonType(content) != "object" {
return false, ""
}
var resultError struct {
Type string `json:"type"`
ErrorCode string `json:"error_code"`
}
if kitutil.Unmarshal(content, &resultError) != nil {
return false, ""
}
if !strings.HasSuffix(strings.TrimSpace(resultError.Type), "_error") && strings.TrimSpace(resultError.ErrorCode) == "" {
return false, ""
}
return true, strings.TrimSpace(resultError.ErrorCode)
}
func responsesMCPErrorFromClaudeContent(raw json.RawMessage, errorCode string) (json.RawMessage, bool, error) {
if errorCode = strings.TrimSpace(errorCode); errorCode != "" {
encoded, err := kitutil.Marshal(errorCode)
return encoded, false, err
}
return responsesMCPStringFromClaudeContent(raw)
}
@@ -0,0 +1,118 @@
package toolconv
import (
"encoding/json"
"github.com/QuantumNous/new-api/relaykit/types"
)
type Kind string
const (
KindFunction Kind = "function"
KindWebSearch Kind = "web_search"
KindFileSearch Kind = "file_search"
KindWebFetch Kind = "web_fetch"
KindCodeExecution Kind = "code_execution"
KindComputerUse Kind = "computer_use"
KindURLContext Kind = "url_context"
KindMCP Kind = "mcp"
KindImage Kind = "image_generation"
KindNative Kind = "native"
)
type Execution string
const (
ExecutionClient Execution = "client"
ExecutionServer Execution = "server"
)
type Function struct {
Name string
Description string
Parameters any
Strict *bool
}
type ApproximateLocation struct {
City string
Region string
Country string
Timezone string
}
type WebSearch struct {
Location *ApproximateLocation
AllowedDomains []string
BlockedDomains []string
SearchContextSize string
MaxUses *int
AllowedCallers []string
ResponseInclusion string
ExternalWebAccess *bool
ReturnTokenBudget json.RawMessage
}
type Definition struct {
Kind Kind
Execution Execution
NativeType string
Name string
Function *Function
WebSearch *WebSearch
Raw json.RawMessage
Group int
}
type ChoiceMode string
const (
ChoiceAuto ChoiceMode = "auto"
ChoiceNone ChoiceMode = "none"
ChoiceRequired ChoiceMode = "required"
ChoiceNamed ChoiceMode = "named"
ChoiceOpaque ChoiceMode = "opaque"
)
type Choice struct {
Mode ChoiceMode
Kind Kind
Name string
AllowedNames []string
NativeType string
DisableParallelToolUse *bool
Raw json.RawMessage
}
type Set struct {
Source types.RelayFormat
Definitions []Definition
Choice *Choice
ParallelAllowed *bool
NativeToolConfig json.RawMessage
History []HostedHistoryItem
}
func (s Set) Empty() bool {
return len(s.Definitions) == 0 && s.Choice == nil && s.ParallelAllowed == nil && len(s.NativeToolConfig) == 0 && len(s.History) == 0
}
type HostedHistoryItem struct {
Kind Kind
NativeType string
Role string
MessageIndex int
BlockIndex int
MessageHasRegular bool
Sequence int
ID string
CallID string
Name string
ServerName string
Status string
Action json.RawMessage
Results json.RawMessage
Caller json.RawMessage
Raw json.RawMessage
}
@@ -0,0 +1,103 @@
package toolconv
import (
"testing"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func geminiCodeExecutionRequest(t *testing.T) *dto.GeminiChatRequest {
t.Helper()
tools, err := kitutil.Marshal([]map[string]any{{"codeExecution": map[string]any{}}})
require.NoError(t, err)
return &dto.GeminiChatRequest{
Contents: []dto.GeminiChatContent{
{Role: "user", Parts: []dto.GeminiPart{{Text: "run this"}}},
},
Tools: tools,
}
}
func hasDiagnosticCode(diagnostics []types.ConversionDiagnostic, code string) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Code == code {
return true
}
}
return false
}
func TestDefaultPolicyAllowsGeminiCodeExecutionToOpenAI(t *testing.T) {
t.Parallel()
_, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
require.NoError(t, err)
target := &dto.GeneralOpenAIRequest{
Model: "gpt-4o",
Messages: []dto.Message{{Role: "user", Content: "run this"}},
}
out, diagnostics, err := AttachRequest(types.RelayFormatOpenAI, target, set, &convmeta.Options{})
require.NoError(t, err)
require.NotNil(t, out)
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
assert.Equal(t, types.ConversionLossPolicyAllow, (&convmeta.Options{}).EffectiveToolLossPolicy())
}
func TestResponsePhaseNeverRejectsEvenUnderStrictPolicy(t *testing.T) {
t.Parallel()
text := "hello"
resp := &dto.ClaudeResponse{
Type: "message",
Role: "assistant",
StopReason: "pause_turn",
Content: []dto.ClaudeMediaMessage{
{Type: "redacted_thinking", Data: "secret"},
{Type: "text", Text: &text},
},
}
diagnostics := InspectResponse(types.RelayFormatClaude, types.RelayFormatOpenAI, resp)
require.True(t, hasDiagnosticCode(diagnostics, "continuation_state_lost"))
require.Error(t, types.RejectConversionLoss(types.ConversionLossPolicyStrict, diagnostics))
_, hosted, err := ExtractHostedResponse(types.RelayFormatClaude, resp)
require.NoError(t, err)
out, _, err := AttachHostedResponse(
types.RelayFormatOpenAI,
&dto.OpenAITextResponse{},
hosted,
&convmeta.Options{ToolLossPolicy: types.ConversionLossPolicyStrict},
)
require.NoError(t, err)
require.NotNil(t, out)
}
func TestSafePolicyRejectsRequestPhaseHostedToolLoss(t *testing.T) {
t.Parallel()
_, set, err := ExtractRequest(types.RelayFormatGemini, geminiCodeExecutionRequest(t))
require.NoError(t, err)
target := &dto.GeneralOpenAIRequest{
Model: "gpt-4o",
Messages: []dto.Message{{Role: "user", Content: "run this"}},
}
_, diagnostics, err := AttachRequest(
types.RelayFormatOpenAI,
target,
set,
&convmeta.Options{ToolLossPolicy: types.ConversionLossPolicySafe},
)
require.Error(t, err)
var loss *types.ConversionLossError
require.ErrorAs(t, err, &loss)
require.NotEmpty(t, loss.Diagnostics)
assert.True(t, hasDiagnosticCode(loss.Diagnostics, "unsupported_hosted_tool"))
assert.True(t, hasDiagnosticCode(diagnostics, "unsupported_hosted_tool"))
}
@@ -0,0 +1,515 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"unicode/utf8"
"github.com/QuantumNous/new-api/relaykit/dto"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
// InspectResponse reports protocol information that the current response
// converters cannot faithfully express. It keeps loss handling centralized so
// direct and multi-step routes behave consistently.
func InspectResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from == to {
return nil
}
var diagnostics []types.ConversionDiagnostic
switch value := response.(type) {
case *dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(value, to)
case dto.ClaudeResponse:
diagnostics = inspectClaudeResponse(&value, to)
case *dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(value, to)
case dto.OpenAIResponsesResponse:
diagnostics = inspectOpenAIResponsesResponse(&value, to)
case *dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(value)
case dto.ResponsesStreamResponse:
diagnostics = inspectOpenAIResponsesStreamResponse(&value)
case *dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(value, to)
case dto.GeminiChatResponse:
diagnostics = inspectGeminiResponse(&value, to)
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
// InspectStreamResponse avoids treating a single Gemini streaming chunk as a
// complete grounding document. Gemini grounding chunk indexes and segment
// offsets are cumulative across the stream; the stateful converter validates
// and resolves them after accumulating prior chunks.
func InspectStreamResponse(from types.RelayFormat, to types.RelayFormat, response any) []types.ConversionDiagnostic {
if from != types.RelayFormatGemini {
return InspectResponse(from, to, response)
}
var value *dto.GeminiChatResponse
switch response := response.(type) {
case *dto.GeminiChatResponse:
value = response
case dto.GeminiChatResponse:
value = &response
default:
return InspectResponse(from, to, response)
}
if value == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range value.Candidates {
metadata := value.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("candidates[%d].groundingMetadata.webSearchQueries", index),
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target stream converter cannot produce an OpenAI Responses web_search_call lifecycle",
))
}
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("candidates[%d].groundingMetadata", index),
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved across stream chunks, but provider-specific search metadata has no target-protocol equivalent",
))
}
for index := range diagnostics {
diagnostics[index].From = from
diagnostics[index].To = to
}
return diagnostics
}
func inspectClaudeResponse(response *dto.ClaudeResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
if to != types.RelayFormatOpenAIResponses && (response.StopReason == "pause_turn" || response.Delta != nil && response.Delta.StopReason != nil && *response.Delta.StopReason == "pause_turn") {
diagnostics = append(diagnostics, responseSemanticLoss(
"stop_reason",
"continuation_state_lost",
"Claude pause_turn requires protocol-native continuation state that the target response cannot preserve",
))
}
for index := range response.Content {
diagnostics = append(diagnostics, inspectClaudeContentBlock(&response.Content[index], fmt.Sprintf("content[%d]", index), to, false)...)
}
if response.ContentBlock != nil {
diagnostics = append(diagnostics, inspectClaudeContentBlock(response.ContentBlock, "content_block", to, true)...)
}
return diagnostics
}
func inspectClaudeContentBlock(block *dto.ClaudeMediaMessage, path string, to types.RelayFormat, stream bool) []types.ConversionDiagnostic {
if block == nil {
return nil
}
blockType := strings.TrimSpace(block.Type)
var diagnostics []types.ConversionDiagnostic
if isClaudeHostedToolBlock(blockType) {
kind := KindNative
if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
kind = hostedKindFromClaudeCall(blockType, block.Name)
} else {
kind = hostedKindFromClaudeResult(blockType)
}
if to != types.RelayFormatOpenAIResponses || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent Claude hosted-tool response block %q", to, blockType),
))
} else if blockType == "server_tool_use" || blockType == "mcp_tool_use" {
if block.Id == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".id",
"hosted_tool_id_missing",
"Claude hosted-tool call has no id for pairing it with its result",
))
}
if kind == KindMCP && (block.Name == "" || block.ServerName == "") {
diagnostics = append(diagnostics, responseSemanticLoss(
path,
"mcp_identity_missing",
"Claude MCP tool use must include both name and server_name for Responses MCP mapping",
))
}
if rawJSONPresent(block.Caller) {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".caller",
"hosted_tool_caller_unrepresentable",
"OpenAI Responses web_search_call and mcp_call items cannot preserve Claude's hosted-tool caller provenance",
))
}
if !stream {
input, err := kitutil.Marshal(block.Input)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"hosted_tool_input_invalid",
err.Error(),
))
} else if kind == KindMCP {
if _, err := responsesMCPArgumentsFromClaude(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"mcp_arguments_unrepresentable",
err.Error(),
))
}
} else if kind == KindWebSearch {
if _, err := dto.NormalizeResponsesWebSearchAction(input); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".input",
"web_search_action_unrepresentable",
err.Error(),
))
}
}
}
} else if block.ToolUseId == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".tool_use_id",
"hosted_tool_id_missing",
"Claude hosted-tool result has no tool_use_id for pairing it with its call",
))
}
if kind == KindMCP && blockType == "mcp_tool_result" {
content, err := kitutil.Marshal(block.Content)
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else {
failed, errorCode := claudeHostedResultFailure(blockType, content, block.IsError, block.ErrorCode)
var normalized bool
if failed {
_, normalized, err = responsesMCPErrorFromClaudeContent(content, errorCode)
} else {
_, normalized, err = responsesMCPStringFromClaudeContent(content)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(path+".content", "mcp_result_unrepresentable", err.Error()))
} else if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
path+".content",
"mcp_text_result_normalized",
"Claude's single MCP text block is normalized to a Responses output string",
))
}
}
}
}
if blockType == "redacted_thinking" && block.Data != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".data",
"continuation_state_lost",
"Claude encrypted thinking state cannot be represented by the target response",
))
}
return diagnostics
}
func isClaudeHostedToolBlock(blockType string) bool {
if blockType == "server_tool_use" || blockType == "mcp_tool_use" || blockType == "mcp_tool_result" {
return true
}
return strings.HasSuffix(blockType, "_tool_result")
}
func inspectOpenAIResponsesResponse(response *dto.OpenAIResponsesResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Output {
output := &response.Output[index]
if !isResponsesHostedOutput(output.Type) {
continue
}
kind := hostedKindFromResponsesType(output.Type)
if to != types.RelayFormatClaude || kind != KindWebSearch && kind != KindMCP {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("%s cannot losslessly represent OpenAI Responses hosted-tool output %q", to, output.Type),
))
continue
}
if output.ID == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].id", index),
"hosted_tool_id_missing",
"hosted-tool output has no id for pairing the call with its result",
))
}
if kind == KindMCP {
if output.Name == "" || output.ServerLabel == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_identity_missing",
"Responses MCP output must include both name and server_label for Claude MCP mapping",
))
}
if output.ApprovalRequestID != "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].approval_request_id", index),
"mcp_approval_state_unrepresentable",
"Claude MCP response blocks cannot preserve a Responses approval_request_id",
))
}
if _, err := claudeMCPInputFromResponses(output.Arguments); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].arguments", index),
"mcp_arguments_unrepresentable",
err.Error(),
))
}
if rawJSONPresent(output.Output) && rawJSONPresent(output.ItemError) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d]", index),
"mcp_result_ambiguous",
"Responses MCP output contains both output and error",
))
}
for _, field := range []struct {
name string
raw json.RawMessage
}{{name: "output", raw: output.Output}, {name: "error", raw: output.ItemError}} {
if !rawJSONPresent(field.raw) {
continue
}
if _, err := claudeMCPContentFromResponsesString(field.raw); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].%s", index, field.name),
"mcp_result_unrepresentable",
err.Error(),
))
}
}
} else if kind == KindWebSearch {
if _, err := claudeWebSearchInputFromResponses(output.Action); err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].action", index),
"web_search_action_unrepresentable",
err.Error(),
))
}
}
if output.Status != "" && output.Status != "in_progress" && output.Status != "completed" && output.Status != "failed" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_status_unrepresentable",
fmt.Sprintf("Claude cannot preserve hosted-tool status %q", output.Status),
))
}
if output.Status == "failed" && !rawJSONPresent(output.ItemError) && !rawJSONPresent(output.Output) && !rawJSONPresent(output.Results) {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("output[%d].status", index),
"hosted_tool_error_missing",
"failed hosted-tool output has no error or output that Claude can preserve",
))
}
}
return diagnostics
}
func inspectOpenAIResponsesStreamResponse(response *dto.ResponsesStreamResponse) []types.ConversionDiagnostic {
if response == nil {
return nil
}
if response.Item != nil && isResponsesHostedOutput(response.Item.Type) {
return []types.ConversionDiagnostic{responseSemanticLoss(
"item",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool output %q has no semantic target-protocol stream mapping", response.Item.Type),
)}
}
eventType := strings.TrimSpace(response.Type)
if strings.Contains(eventType, ".web_search_call.") ||
strings.Contains(eventType, ".file_search_call.") ||
strings.Contains(eventType, ".code_interpreter_call.") ||
strings.Contains(eventType, ".computer_tool_call.") ||
strings.Contains(eventType, ".image_generation_call.") ||
strings.Contains(eventType, ".mcp_call.") {
return []types.ConversionDiagnostic{responseSemanticLoss(
"type",
"hosted_tool_event_unrepresentable",
fmt.Sprintf("OpenAI Responses hosted-tool stream event %q has no semantic target-protocol mapping", eventType),
)}
}
return nil
}
func isResponsesHostedOutput(outputType string) bool {
switch strings.TrimSpace(outputType) {
case "", "message", "reasoning", "function_call", "custom_tool_call":
return false
default:
return true
}
}
func inspectGeminiResponse(response *dto.GeminiChatResponse, to types.RelayFormat) []types.ConversionDiagnostic {
if response == nil {
return nil
}
var diagnostics []types.ConversionDiagnostic
for index := range response.Candidates {
metadata := response.Candidates[index].GroundingMetadata
if metadata == nil {
continue
}
path := fmt.Sprintf("candidates[%d].groundingMetadata", index)
if len(metadata.WebSearchQueries) > 0 && to != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responseSemanticLoss(
path+".webSearchQueries",
"web_search_call_unrepresentable",
"Gemini grounding confirms a hosted web search, but the target converter cannot produce an OpenAI Responses web_search_call item",
))
}
diagnostics = append(diagnostics, inspectGeminiGroundingCitations(response.Candidates[index].Content, metadata, path)...)
if len(metadata.WebSearchQueries) == 0 && len(metadata.RetrievalQueries) == 0 && len(metadata.SearchEntryPoint) == 0 && len(metadata.RetrievalMetadata) == 0 && len(metadata.SourceFlaggingUris) == 0 && metadata.GoogleMapsWidgetContextToken == "" {
continue
}
diagnostics = append(diagnostics, responsePresentationLoss(
path,
"hosted_tool_metadata_reduced",
"Gemini grounding citations are preserved, but provider-specific search metadata has no target-protocol equivalent",
))
}
return diagnostics
}
type groundingSupportForInspection struct {
Segment struct {
PartIndex *int `json:"partIndex,omitempty"`
StartIndex int `json:"startIndex,omitempty"`
EndIndex int `json:"endIndex,omitempty"`
Text string `json:"text,omitempty"`
} `json:"segment"`
GroundingChunkIndices []int `json:"groundingChunkIndices"`
}
type groundingChunkForInspection struct {
Web *groundingSourceForInspection `json:"web,omitempty"`
RetrievedContext *groundingSourceForInspection `json:"retrievedContext,omitempty"`
}
type groundingSourceForInspection struct {
URI string `json:"uri,omitempty"`
}
func inspectGeminiGroundingCitations(content dto.GeminiChatContent, metadata *dto.GeminiGroundingMetadata, path string) []types.ConversionDiagnostic {
if len(metadata.GroundingSupports) == 0 {
return nil
}
var chunks []groundingChunkForInspection
if len(metadata.GroundingChunks) == 0 || kitutil.Unmarshal(metadata.GroundingChunks, &chunks) != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingChunks",
"grounding_source_invalid",
"Gemini grounding chunks are missing or cannot be decoded",
)}
}
var supports []groundingSupportForInspection
if err := kitutil.Unmarshal(metadata.GroundingSupports, &supports); err != nil {
return []types.ConversionDiagnostic{responseSemanticLoss(
path+".groundingSupports",
"grounding_citation_invalid",
fmt.Sprintf("Gemini grounding supports cannot be decoded: %v", err),
)}
}
textPartCount := 0
soleTextPart := -1
for index := range content.Parts {
if content.Parts[index].Text == "" || content.Parts[index].Thought {
continue
}
textPartCount++
soleTextPart = index
}
var diagnostics []types.ConversionDiagnostic
for index, support := range supports {
segmentPath := fmt.Sprintf("%s.groundingSupports[%d].segment", path, index)
hasSource := false
for _, chunkIndex := range support.GroundingChunkIndices {
if chunkIndex < 0 || chunkIndex >= len(chunks) {
continue
}
source := chunks[chunkIndex].Web
if source == nil {
source = chunks[chunkIndex].RetrievedContext
}
if source != nil && source.URI != "" {
hasSource = true
break
}
}
if !hasSource {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("%s.groundingSupports[%d].groundingChunkIndices", path, index),
"grounding_source_invalid",
"Gemini grounding support does not reference a valid source URI",
))
continue
}
partIndex := soleTextPart
if support.Segment.PartIndex != nil {
partIndex = *support.Segment.PartIndex
} else if textPartCount != 1 {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_ambiguous",
"Gemini grounding omitted partIndex while multiple text parts are present, so citation placement is ambiguous",
))
continue
}
if partIndex < 0 || partIndex >= len(content.Parts) || content.Parts[partIndex].Text == "" || content.Parts[partIndex].Thought {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".partIndex",
"grounding_part_invalid",
fmt.Sprintf("Gemini grounding references non-text part %d", partIndex),
))
continue
}
partText := content.Parts[partIndex].Text
start, end := support.Segment.StartIndex, support.Segment.EndIndex
if start < 0 || end <= start || end > len(partText) || !utf8.ValidString(partText[:start]) || !utf8.ValidString(partText[:end]) {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath,
"grounding_offset_invalid",
"Gemini grounding byte offsets do not identify valid UTF-8 boundaries in the referenced part",
))
continue
}
if support.Segment.Text != "" && partText[start:end] != support.Segment.Text {
diagnostics = append(diagnostics, responseSemanticLoss(
segmentPath+".text",
"grounding_text_mismatch",
"Gemini grounding segment text does not match the referenced part range",
))
}
}
return diagnostics
}
func responseSemanticLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticError}
}
func responsePresentationLoss(path string, code string, message string) types.ConversionDiagnostic {
return types.ConversionDiagnostic{Code: code, Path: path, Message: message, Severity: types.ConversionDiagnosticWarning}
}
@@ -0,0 +1,816 @@
package toolconv
import (
"encoding/json"
"fmt"
"strings"
"github.com/QuantumNous/new-api/relaykit/dto"
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
geminichat "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/gemini_chat"
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
"github.com/QuantumNous/new-api/relaykit/types"
)
type HostedResponseItem struct {
Kind Kind
NativeType string
ID string
CallID string
Name string
Status string
Position int
Action json.RawMessage
Results json.RawMessage
Sources json.RawMessage
Caller json.RawMessage
Arguments json.RawMessage
Output json.RawMessage
Error json.RawMessage
ServerName string
IsError *bool
ApprovalRequestID string
Tools json.RawMessage
ErrorCode string
Raw json.RawMessage
}
type HostedResponseSet struct {
Source types.RelayFormat
Items []HostedResponseItem
SourceLength int
RegularPositions []int
}
type positionedResponsesOutput struct {
position int
output dto.ResponsesOutput
}
type positionedClaudeBlocks struct {
position int
blocks []dto.ClaudeMediaMessage
}
func (s HostedResponseSet) Empty() bool {
return len(s.Items) == 0
}
// ExtractHostedResponse removes server-executed tool artifacts before a
// message converter sees them. The artifacts travel beside multi-step routes,
// just like request tool definitions, so a lossy Chat pivot cannot reclassify
// or discard them.
func ExtractHostedResponse(format types.RelayFormat, response any) (any, HostedResponseSet, error) {
switch format {
case types.RelayFormatClaude:
return extractClaudeHostedResponse(response)
case types.RelayFormatOpenAIResponses:
return extractOpenAIHostedResponse(response)
case types.RelayFormatGemini:
return extractGeminiHostedResponse(response)
default:
return response, HostedResponseSet{Source: format}, nil
}
}
func AttachHostedResponse(format types.RelayFormat, response any, set HostedResponseSet, options *convmeta.Options) (any, []types.ConversionDiagnostic, error) {
if set.Empty() {
return response, nil, nil
}
var (
value any
diagnostics []types.ConversionDiagnostic
err error
)
switch format {
case types.RelayFormatOpenAIResponses:
value, diagnostics, err = attachOpenAIHostedResponse(response, set)
case types.RelayFormatClaude:
value, diagnostics, err = attachClaudeHostedResponse(response, set)
default:
value = response
for index, item := range set.Items {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_event_omitted",
fmt.Sprintf("%s cannot represent hosted-tool response %q", format, item.NativeType),
))
}
}
if err != nil {
return nil, diagnostics, err
}
for index := range diagnostics {
diagnostics[index].From = set.Source
diagnostics[index].To = format
}
return value, diagnostics, nil
}
func extractClaudeHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.ClaudeResponse
switch value := response.(type) {
case *dto.ClaudeResponse:
source = value
case dto.ClaudeResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected Claude response, got %T", response)
}
clone := *source
clone.Content = make([]dto.ClaudeMediaMessage, 0, len(source.Content))
set := HostedResponseSet{Source: types.RelayFormatClaude, SourceLength: len(source.Content)}
for position := range source.Content {
block := source.Content[position]
blockType := strings.TrimSpace(block.Type)
switch {
case blockType == "server_tool_use" || blockType == "mcp_tool_use":
rawBlock, err := kitutil.Marshal(block)
if err != nil {
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
}
action, err := kitutil.Marshal(block.Input)
if err != nil {
return nil, set, fmt.Errorf("content[%d].input: %w", position, err)
}
item := HostedResponseItem{
Kind: hostedKindFromClaudeCall(blockType, block.Name),
NativeType: blockType,
ID: block.Id,
CallID: block.Id,
Name: block.Name,
Status: "in_progress",
Position: position,
Action: action,
Caller: append(json.RawMessage(nil), block.Caller...),
ServerName: block.ServerName,
Raw: rawBlock,
}
set.Items = append(set.Items, item)
case isClaudeHostedToolBlock(blockType):
rawBlock, err := kitutil.Marshal(block)
if err != nil {
return nil, set, fmt.Errorf("content[%d]: %w", position, err)
}
results, err := kitutil.Marshal(block.Content)
if err != nil {
return nil, set, fmt.Errorf("content[%d].content: %w", position, err)
}
failed, errorCode := claudeHostedResultFailure(blockType, results, block.IsError, block.ErrorCode)
status := "completed"
isError := block.IsError
if failed {
status = "failed"
if isError == nil {
value := true
isError = &value
}
}
set.Items = append(set.Items, HostedResponseItem{
Kind: hostedKindFromClaudeResult(blockType),
NativeType: blockType,
ID: block.ToolUseId,
CallID: block.ToolUseId,
Status: status,
Position: position,
Results: results,
ErrorCode: errorCode,
IsError: isError,
Raw: rawBlock,
})
default:
clone.Content = append(clone.Content, block)
set.RegularPositions = append(set.RegularPositions, position)
}
}
return &clone, set, nil
}
func extractOpenAIHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.OpenAIResponsesResponse
switch value := response.(type) {
case *dto.OpenAIResponsesResponse:
source = value
case dto.OpenAIResponsesResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected OpenAI Responses response, got %T", response)
}
clone := *source
clone.Output = make([]dto.ResponsesOutput, 0, len(source.Output))
set := HostedResponseSet{Source: types.RelayFormatOpenAIResponses, SourceLength: len(source.Output)}
for position := range source.Output {
output := source.Output[position]
if !isResponsesHostedOutput(output.Type) {
clone.Output = append(clone.Output, output)
set.RegularPositions = append(set.RegularPositions, position)
continue
}
rawOutput, err := kitutil.Marshal(output)
if err != nil {
return nil, set, fmt.Errorf("output[%d]: %w", position, err)
}
set.Items = append(set.Items, HostedResponseItem{
Kind: hostedKindFromResponsesType(output.Type),
NativeType: output.Type,
ID: output.ID,
CallID: output.CallId,
Name: output.Name,
Status: output.Status,
Position: position,
Action: append(json.RawMessage(nil), output.Action...),
Results: append(json.RawMessage(nil), output.Results...),
Sources: append(json.RawMessage(nil), output.Sources...),
Caller: append(json.RawMessage(nil), output.Caller...),
Arguments: append(json.RawMessage(nil), output.Arguments...),
Output: append(json.RawMessage(nil), output.Output...),
Error: append(json.RawMessage(nil), output.ItemError...),
ServerName: output.ServerLabel,
ApprovalRequestID: output.ApprovalRequestID,
Tools: append(json.RawMessage(nil), output.MCPTools...),
Raw: rawOutput,
})
}
return &clone, set, nil
}
func extractGeminiHostedResponse(response any) (any, HostedResponseSet, error) {
var source *dto.GeminiChatResponse
switch value := response.(type) {
case *dto.GeminiChatResponse:
source = value
case dto.GeminiChatResponse:
source = &value
default:
return nil, HostedResponseSet{}, fmt.Errorf("expected Gemini response, got %T", response)
}
queries := geminichat.GroundingWebSearchQueries(source)
if len(queries) == 0 {
return source, HostedResponseSet{Source: types.RelayFormatGemini}, nil
}
action, err := kitutil.Marshal(map[string]any{
"type": "search",
"queries": queries,
})
if err != nil {
return nil, HostedResponseSet{}, fmt.Errorf("marshal Gemini web-search action: %w", err)
}
// The Chat pivot emits the answer as the regular Responses output. Place
// the hosted call after that output, matching the stream bridge which only
// learns Gemini's queries once grounding metadata arrives near stream end.
set := HostedResponseSet{
Source: types.RelayFormatGemini,
SourceLength: 2,
RegularPositions: []int{0},
Items: []HostedResponseItem{{
Kind: KindWebSearch,
NativeType: "googleSearch",
ID: fmt.Sprintf("ws_%s", kitutil.GetUUID()),
Status: "completed",
Position: 1,
Action: action,
}},
}
return source, set, nil
}
func attachOpenAIHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
target, ok := response.(*dto.OpenAIResponsesResponse)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected OpenAI Responses response, got %T", response)
}
var diagnostics []types.ConversionDiagnostic
hostedOutput := make([]positionedResponsesOutput, 0, len(set.Items))
convertedByID := make(map[string]int, len(set.Items)*2)
for index, item := range set.Items {
if set.Source == types.RelayFormatOpenAIResponses && len(item.Raw) > 0 {
var output dto.ResponsesOutput
if err := kitutil.Unmarshal(item.Raw, &output); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
}
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
continue
}
outputType := responsesTypeFromHostedKind(item.Kind)
if outputType == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("OpenAI Responses has no lossless response mapping for %q", item.NativeType),
))
continue
}
if isClaudeHostedResult(item.NativeType) {
outputIndex, exists := convertedByID[item.CallID]
if !exists {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].tool_use_id", index),
"hosted_tool_result_orphaned",
fmt.Sprintf("hosted-tool result references unknown call %q", item.CallID),
))
continue
}
output := &hostedOutput[outputIndex].output
output.Status = hostedCompletionStatus(item)
if item.Kind == KindMCP {
failed := hostedItemFailed(item)
var (
encoded json.RawMessage
normalized bool
err error
)
if failed {
encoded, normalized, err = responsesMCPErrorFromClaudeContent(item.Results, item.ErrorCode)
} else {
encoded, normalized, err = responsesMCPStringFromClaudeContent(item.Results)
}
if err != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"mcp_result_unrepresentable",
err.Error(),
))
continue
}
if failed {
output.Output = nil
output.ItemError = encoded
} else {
output.Output = encoded
output.ItemError = nil
}
if normalized {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"mcp_text_result_normalized",
"Claude's single MCP text block was normalized to a Responses output string",
))
}
} else if item.Kind == KindWebSearch && rawJSONPresent(item.Results) {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].content", index),
"web_search_result_omitted",
"Claude web-search result content is provider-private and has no field on an OpenAI Responses web_search_call; completion status and citations remain available",
))
}
continue
}
output := dto.ResponsesOutput{
Type: outputType,
ID: firstNonEmpty(item.ID, item.CallID),
Status: hostedCompletionStatus(item),
}
switch item.Kind {
case KindWebSearch:
action, err := dto.NormalizeResponsesWebSearchAction(item.Action)
if err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
}
output.Action = action
case KindMCP:
if strings.TrimSpace(item.Name) == "" || strings.TrimSpace(item.ServerName) == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"mcp_identity_missing",
"Claude MCP output requires both name and server_name for Responses mapping",
))
continue
}
output.CallId = item.CallID
output.Name = item.Name
output.Caller = append(json.RawMessage(nil), item.Caller...)
output.ServerLabel = item.ServerName
output.ApprovalRequestID = item.ApprovalRequestID
output.MCPTools = append(json.RawMessage(nil), item.Tools...)
arguments := item.Arguments
if len(arguments) == 0 {
arguments = item.Action
}
encodedArguments, argumentErr := responsesMCPArgumentsFromClaude(arguments)
if argumentErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].input", index),
"mcp_arguments_unrepresentable",
argumentErr.Error(),
))
continue
}
output.Arguments = encodedArguments
}
outputIndex := len(hostedOutput)
for _, key := range []string{item.ID, item.CallID} {
if key != "" {
convertedByID[key] = outputIndex
}
}
hostedOutput = append(hostedOutput, positionedResponsesOutput{position: item.Position, output: output})
if set.Source != types.RelayFormatOpenAIResponses {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_result_approximated",
"hosted-tool execution is preserved, but provider-specific result fields may differ",
))
}
}
merged, orderingDiagnostics := mergeResponsesOutput(target.Output, hostedOutput, set)
diagnostics = append(diagnostics, orderingDiagnostics...)
target.Output = merged
return target, diagnostics, nil
}
func attachClaudeHostedResponse(response any, set HostedResponseSet) (any, []types.ConversionDiagnostic, error) {
target, ok := response.(*dto.ClaudeResponse)
if !ok || target == nil {
return nil, nil, fmt.Errorf("expected Claude response, got %T", response)
}
var diagnostics []types.ConversionDiagnostic
hostedContent := make([]positionedClaudeBlocks, 0, len(set.Items))
for index, item := range set.Items {
if set.Source == types.RelayFormatClaude && len(item.Raw) > 0 {
var block dto.ClaudeMediaMessage
if err := kitutil.Unmarshal(item.Raw, &block); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d]: %w", index, err)
}
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: []dto.ClaudeMediaMessage{block}})
continue
}
if set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"web_search_response_unrepresentable",
"Responses web-search execution cannot reconstruct Claude's required encrypted web_search_tool_result continuation state",
))
continue
}
name := claudeNameFromHostedKind(item.Kind)
if item.Kind == KindMCP {
name = item.Name
}
if name == "" {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_unrepresentable",
fmt.Sprintf("Claude has no lossless response mapping for %q", item.NativeType),
))
continue
}
var input any = map[string]any{}
if item.Kind == KindWebSearch {
webInput, inputErr := claudeWebSearchInputFromResponses(item.Action)
if inputErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].action", index),
"web_search_action_unrepresentable",
inputErr.Error(),
))
continue
}
input = webInput
} else if item.Kind == KindMCP {
mcpInput, inputErr := claudeMCPInputFromResponses(item.Arguments)
if inputErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].arguments", index),
"mcp_arguments_unrepresentable",
inputErr.Error(),
))
continue
}
input = mcpInput
} else if len(item.Action) > 0 {
if err := kitutil.Unmarshal(item.Action, &input); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].action: %w", index, err)
}
}
callType := "server_tool_use"
if item.Kind == KindMCP {
callType = "mcp_tool_use"
}
blocks := []dto.ClaudeMediaMessage{{
Type: callType,
Id: item.ID,
Name: name,
Input: input,
Caller: append(json.RawMessage(nil), item.Caller...),
ServerName: item.ServerName,
}}
result := item.Results
if item.Kind == KindMCP {
result = item.Output
if hostedItemFailed(item) && rawJSONPresent(item.Error) {
result = item.Error
}
} else if len(result) == 0 {
result = item.Sources
}
if len(result) > 0 && !(set.Source == types.RelayFormatOpenAIResponses && item.Kind == KindWebSearch) {
var content any
if item.Kind == KindMCP {
decoded, resultErr := claudeMCPContentFromResponsesString(result)
if resultErr != nil {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d].output", index),
"mcp_result_unrepresentable",
resultErr.Error(),
))
continue
}
content = decoded
} else if err := kitutil.Unmarshal(result, &content); err != nil {
return nil, diagnostics, fmt.Errorf("hosted_tools[%d].results: %w", index, err)
}
isError := hostedItemFailed(item)
blocks = append(blocks, dto.ClaudeMediaMessage{
Type: claudeResultTypeFromHostedKind(item.Kind),
ToolUseId: item.ID,
Content: content,
IsError: &isError,
ErrorCode: item.ErrorCode,
})
} else if len(result) > 0 && item.Kind == KindWebSearch {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d].results", index),
"web_search_result_omitted",
"Responses web-search source metadata cannot reconstruct Claude's encrypted web_search_tool_result",
))
} else if item.Kind == KindMCP && (item.Status == "completed" || item.Status == "failed") {
diagnostics = append(diagnostics, responseSemanticLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"mcp_result_missing",
fmt.Sprintf("Responses MCP output has status %q but no output or error", item.Status),
))
}
hostedContent = append(hostedContent, positionedClaudeBlocks{position: item.Position, blocks: blocks})
if set.Source != types.RelayFormatClaude {
diagnostics = append(diagnostics, responsePresentationLoss(
fmt.Sprintf("hosted_tools[%d]", index),
"hosted_tool_result_approximated",
"hosted-tool execution is preserved, but provider-specific result fields may differ",
))
}
}
merged, orderingDiagnostics := mergeClaudeContent(target.Content, hostedContent, set)
diagnostics = append(diagnostics, orderingDiagnostics...)
target.Content = merged
return target, diagnostics, nil
}
func mergeResponsesOutput(regular []dto.ResponsesOutput, hosted []positionedResponsesOutput, set HostedResponseSet) ([]dto.ResponsesOutput, []types.ConversionDiagnostic) {
if len(hosted) == 0 {
return regular, nil
}
if len(regular) == len(set.RegularPositions) {
byPosition := make(map[int][]dto.ResponsesOutput, len(hosted))
for _, item := range hosted {
byPosition[item.position] = append(byPosition[item.position], item.output)
}
regularByPosition := make(map[int]dto.ResponsesOutput, len(regular))
for index, position := range set.RegularPositions {
regularByPosition[position] = regular[index]
}
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for position := 0; position < set.SourceLength; position++ {
merged = append(merged, byPosition[position]...)
if output, exists := regularByPosition[position]; exists {
merged = append(merged, output)
}
}
return merged, nil
}
before, after, exact := hostedOutsideRegularRange(hostedPositions(hosted), set.RegularPositions)
if exact {
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for _, item := range before {
merged = append(merged, hosted[item].output)
}
merged = append(merged, regular...)
for _, item := range after {
merged = append(merged, hosted[item].output)
}
return merged, nil
}
merged := make([]dto.ResponsesOutput, 0, len(regular)+len(hosted))
for _, item := range hosted {
merged = append(merged, item.output)
}
merged = append(merged, regular...)
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
"output",
"hosted_tool_order_unrepresentable",
"hosted-tool items were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
)}
}
func mergeClaudeContent(regular []dto.ClaudeMediaMessage, hosted []positionedClaudeBlocks, set HostedResponseSet) ([]dto.ClaudeMediaMessage, []types.ConversionDiagnostic) {
if len(hosted) == 0 {
return regular, nil
}
if len(regular) == len(set.RegularPositions) {
byPosition := make(map[int][]dto.ClaudeMediaMessage, len(hosted))
for _, item := range hosted {
byPosition[item.position] = append(byPosition[item.position], item.blocks...)
}
regularByPosition := make(map[int]dto.ClaudeMediaMessage, len(regular))
for index, position := range set.RegularPositions {
regularByPosition[position] = regular[index]
}
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for position := 0; position < set.SourceLength; position++ {
merged = append(merged, byPosition[position]...)
if block, exists := regularByPosition[position]; exists {
merged = append(merged, block)
}
}
return merged, nil
}
before, after, exact := hostedOutsideRegularRange(claudeHostedPositions(hosted), set.RegularPositions)
if exact {
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for _, item := range before {
merged = append(merged, hosted[item].blocks...)
}
merged = append(merged, regular...)
for _, item := range after {
merged = append(merged, hosted[item].blocks...)
}
return merged, nil
}
merged := make([]dto.ClaudeMediaMessage, 0, len(regular)+len(hosted)*2)
for _, item := range hosted {
merged = append(merged, item.blocks...)
}
merged = append(merged, regular...)
return merged, []types.ConversionDiagnostic{responseSemanticLoss(
"content",
"hosted_tool_order_unrepresentable",
"hosted-tool blocks were interleaved with content that the target converter coalesced, so their original order cannot be reconstructed",
)}
}
func hostedPositions(items []positionedResponsesOutput) []int {
positions := make([]int, len(items))
for index := range items {
positions[index] = items[index].position
}
return positions
}
func claudeHostedPositions(items []positionedClaudeBlocks) []int {
positions := make([]int, len(items))
for index := range items {
positions[index] = items[index].position
}
return positions
}
func hostedOutsideRegularRange(hosted []int, regular []int) (before []int, after []int, exact bool) {
if len(regular) == 0 {
indices := make([]int, len(hosted))
for index := range hosted {
indices[index] = index
}
return indices, nil, true
}
firstRegular, lastRegular := regular[0], regular[len(regular)-1]
for index, position := range hosted {
switch {
case position < firstRegular:
before = append(before, index)
case position > lastRegular:
after = append(after, index)
default:
return nil, nil, false
}
}
return before, after, true
}
func hostedKindFromClaudeCall(blockType string, name string) Kind {
if blockType == "mcp_tool_use" {
return KindMCP
}
switch strings.TrimSpace(name) {
case "web_search":
return KindWebSearch
case "web_fetch":
return KindWebFetch
case "code_execution":
return KindCodeExecution
default:
return KindNative
}
}
func hostedKindFromClaudeResult(blockType string) Kind {
switch strings.TrimSuffix(blockType, "_tool_result") {
case "web_search":
return KindWebSearch
case "web_fetch":
return KindWebFetch
case "code_execution":
return KindCodeExecution
case "mcp":
return KindMCP
default:
return KindNative
}
}
func hostedKindFromResponsesType(outputType string) Kind {
normalized := strings.TrimSpace(outputType)
normalized = strings.TrimSuffix(normalized, "_output")
normalized = strings.TrimSuffix(normalized, "_call")
switch normalized {
case "web_search":
return KindWebSearch
case "file_search":
return KindFileSearch
case "code_interpreter", "local_shell":
return KindCodeExecution
case "computer":
return KindComputerUse
case "image_generation":
return KindImage
case "mcp":
return KindMCP
default:
return KindNative
}
}
func responsesTypeFromHostedKind(kind Kind) string {
switch kind {
case KindWebSearch:
return "web_search_call"
case KindMCP:
return "mcp_call"
default:
return ""
}
}
func claudeNameFromHostedKind(kind Kind) string {
switch kind {
case KindWebSearch:
return "web_search"
case KindWebFetch:
return "web_fetch"
case KindCodeExecution:
return ""
case KindMCP:
return "mcp"
default:
return ""
}
}
func isClaudeHostedResult(nativeType string) bool {
return nativeType == "mcp_tool_result" || strings.HasSuffix(nativeType, "_tool_result")
}
func hostedCompletionStatus(item HostedResponseItem) string {
if hostedItemFailed(item) {
return "failed"
}
if item.Status != "" && item.Status != "in_progress" || len(item.Results) > 0 || len(item.Output) > 0 {
return "completed"
}
return "in_progress"
}
func hostedItemFailed(item HostedResponseItem) bool {
return item.Status == "failed" || item.ErrorCode != "" || rawJSONPresent(item.Error) || item.IsError != nil && *item.IsError
}
func hostedErrorValue(item HostedResponseItem) json.RawMessage {
if len(item.Error) > 0 {
return append(json.RawMessage(nil), item.Error...)
}
if item.ErrorCode == "" {
return nil
}
encoded, _ := kitutil.Marshal(item.ErrorCode)
return encoded
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if value != "" {
return value
}
}
return ""
}
func rawJSONPresent(value json.RawMessage) bool {
normalized := strings.TrimSpace(string(value))
return normalized != "" && normalized != "null"
}
func claudeResultTypeFromHostedKind(kind Kind) string {
name := claudeNameFromHostedKind(kind)
if name == "mcp" {
return "mcp_tool_result"
}
return name + "_tool_result"
}

Some files were not shown because too many files have changed in this diff Show More