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