mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-12 07:00:42 +00:00
fix(relay): follow-up billing integrity and conversion completions (#7170)
Deferred follow-ups from the relaykit-tools review cycle, verified by live end-to-end billing tests: - billing: normalize Gemini modality keys consistently between stream merge and settlement (case/whitespace variants no longer drop independent audio/image pricing) and sum duplicate modality entries on both paths - billing: sync legacy flat Claude cache-creation fields from the CacheCreation sub-object (including zeroing) and fall back to flat fields only when the snapshot never carried a sub-object, closing a stale 1h-cache overcharge path in cascaded deployments - relay: move Chat-to-Claude and Chat-to-Gemini stream conversion state from gin.Context onto RelayInfo and reset it with SendResponseCount in InitChannelMeta, so channel retries start clean while per-request state (stream error collection, conversion diagnostics, channel chain, billing accumulators) survives - relay: Claude channel now serves Gemini-format clients (request via registry conversion, response and stream composed through the Chat pivot), removing the last unimplemented conversion direction - relaykit: recognize legacy pseudo tool names (googleSearch, codeExecution, urlContext) in the toolconv decode stage and drop the string-matching bypass in the Chat-to-Gemini converter; native Gemini tool output is restored and non-Gemini targets follow standard loss diagnostics - relaykit: attach upstream Gemini usage (with billing_usage sidecar) to intermediate stream chunks so converted Claude streams report upstream truth from message_start, and preserve the sidecar through Claude stream usage merges; billing settlement unchanged - billing: clamp negative Total-Prompt completion derivation, OR the Estimated flag across cross-dialect snapshot replacement, and fill canonical OpenAI prompt details via field-wise merge
This commit is contained in:
@@ -21,9 +21,15 @@ import (
|
|||||||
type Adaptor struct {
|
type Adaptor struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertGeminiRequest(*gin.Context, *relaycommon.RelayInfo, *dto.GeminiChatRequest) (any, error) {
|
func (a *Adaptor) ConvertGeminiRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.GeminiChatRequest) (any, error) {
|
||||||
//TODO implement me
|
if request == nil {
|
||||||
return nil, errors.New("not implemented")
|
return nil, errors.New("request is nil")
|
||||||
|
}
|
||||||
|
result, err := service.ConvertRequest(c, info, types.RelayFormatClaude, request)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return result.Value, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
func (a *Adaptor) ConvertClaudeRequest(c *gin.Context, info *relaycommon.RelayInfo, request *dto.ClaudeRequest) (any, error) {
|
||||||
|
|||||||
@@ -88,3 +88,100 @@ func TestConvertClaudeRequestDoesNotOverwriteTrimmedUpstreamModelName(t *testing
|
|||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
|
assert.Equal(t, "claude-3-7-sonnet", info.UpstreamModelName)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func geminiToClaudeInfo() *relaycommon.RelayInfo {
|
||||||
|
return &relaycommon.RelayInfo{
|
||||||
|
OriginModelName: "claude-3-7-sonnet",
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "claude-3-7-sonnet",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertGeminiRequestMapsSystemInstructionToolsAndMultimodal(t *testing.T) {
|
||||||
|
req := &dto.GeminiChatRequest{
|
||||||
|
Contents: []dto.GeminiChatContent{
|
||||||
|
{
|
||||||
|
Role: "user",
|
||||||
|
Parts: []dto.GeminiPart{
|
||||||
|
{Text: "What is in this image?"},
|
||||||
|
{InlineData: &dto.GeminiInlineData{MimeType: "image/png", Data: "aGVsbG8="}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SystemInstructions: &dto.GeminiChatContent{
|
||||||
|
Parts: []dto.GeminiPart{{Text: "You are a helpful assistant."}},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req.SetTools([]dto.GeminiChatTool{
|
||||||
|
{
|
||||||
|
FunctionDeclarations: []dto.FunctionRequest{
|
||||||
|
{
|
||||||
|
Name: "lookup",
|
||||||
|
Description: "Lookup data",
|
||||||
|
Parameters: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{"q": map[string]any{"type": "string"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
out, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
converted, ok := out.(*dto.ClaudeRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
system := converted.ParseSystem()
|
||||||
|
require.NotEmpty(t, system)
|
||||||
|
assert.Contains(t, system[0].GetText(), "You are a helpful assistant.")
|
||||||
|
require.NotEmpty(t, converted.Messages)
|
||||||
|
assert.Equal(t, "user", converted.Messages[0].Role)
|
||||||
|
|
||||||
|
blocks, parseErr := converted.Messages[0].ParseContent()
|
||||||
|
require.NoError(t, parseErr)
|
||||||
|
var foundImage bool
|
||||||
|
for _, block := range blocks {
|
||||||
|
if block.Type == "image" || (block.Source != nil && block.Source.Type == "base64") {
|
||||||
|
foundImage = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.True(t, foundImage)
|
||||||
|
|
||||||
|
require.NotNil(t, converted.Tools)
|
||||||
|
tools, err := common.Marshal(converted.Tools)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(tools), `"lookup"`)
|
||||||
|
require.NotNil(t, converted.MaxTokens)
|
||||||
|
assert.Greater(t, *converted.MaxTokens, uint(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertGeminiRequestThinkingConfigUsesReasoningIntent(t *testing.T) {
|
||||||
|
budget := 1024
|
||||||
|
maxTokens := uint(4096)
|
||||||
|
req := &dto.GeminiChatRequest{
|
||||||
|
Contents: []dto.GeminiChatContent{
|
||||||
|
{Role: "user", Parts: []dto.GeminiPart{{Text: "think"}}},
|
||||||
|
},
|
||||||
|
GenerationConfig: dto.GeminiChatGenerationConfig{
|
||||||
|
MaxOutputTokens: &maxTokens,
|
||||||
|
ThinkingConfig: &dto.GeminiThinkingConfig{ThinkingBudget: &budget},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
out, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), req)
|
||||||
|
require.NoError(t, err)
|
||||||
|
converted, ok := out.(*dto.ClaudeRequest)
|
||||||
|
require.True(t, ok)
|
||||||
|
require.NotNil(t, converted.Thinking)
|
||||||
|
assert.Equal(t, "enabled", converted.Thinking.Type)
|
||||||
|
require.NotNil(t, converted.Thinking.BudgetTokens)
|
||||||
|
assert.Equal(t, 1024, *converted.Thinking.BudgetTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConvertGeminiRequestNilRequest(t *testing.T) {
|
||||||
|
_, err := (&Adaptor{}).ConvertGeminiRequest(nil, geminiToClaudeInfo(), nil)
|
||||||
|
require.Error(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
const claudeToChatStreamStateKey = "relaykit.claude_to_chat_stream_state"
|
|
||||||
|
|
||||||
func stopReasonClaude2OpenAI(reason string) string {
|
func stopReasonClaude2OpenAI(reason string) string {
|
||||||
return relayconvert.StopReasonClaudeToOpenAI(reason)
|
return relayconvert.StopReasonClaudeToOpenAI(reason)
|
||||||
}
|
}
|
||||||
@@ -120,7 +118,7 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
|||||||
countClaudeStreamBillableTools(c, info, &claudeResponse)
|
countClaudeStreamBillableTools(c, info, &claudeResponse)
|
||||||
helper.ClaudeChunkData(c, claudeResponse, data)
|
helper.ClaudeChunkData(c, claudeResponse, data)
|
||||||
} else if info.RelayFormat == types.RelayFormatOpenAI {
|
} else if info.RelayFormat == types.RelayFormatOpenAI {
|
||||||
state, err := claudeToChatStreamState(c)
|
state, err := claudeToChatStreamState(info)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
}
|
}
|
||||||
@@ -142,24 +140,80 @@ func HandleStreamResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.LogError(c, "send_stream_response_failed: "+err.Error())
|
logger.LogError(c, "send_stream_response_failed: "+err.Error())
|
||||||
}
|
}
|
||||||
|
} else if info.RelayFormat == types.RelayFormatGemini {
|
||||||
|
state, err := claudeToGeminiStreamState(info)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
results, err := service.ConvertStreamResponseChunk(c, info, state, &claudeResponse)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
if !FormatClaudeResponseInfo(&claudeResponse, nil, claudeInfo) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
countClaudeStreamBillableTools(c, info, &claudeResponse)
|
||||||
|
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
|
||||||
|
return sendErr
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func claudeToChatStreamState(c *gin.Context) (*relayconvert.ClaudeToChatStreamState, error) {
|
func claudeToChatStreamState(info *relaycommon.RelayInfo) (*relayconvert.ClaudeToChatStreamState, error) {
|
||||||
if value, ok := c.Get(claudeToChatStreamStateKey); ok {
|
if info != nil && info.ClaudeToChatStreamState != nil {
|
||||||
state, ok := value.(*relayconvert.ClaudeToChatStreamState)
|
state, ok := info.ClaudeToChatStreamState.(*relayconvert.ClaudeToChatStreamState)
|
||||||
if !ok || state == nil {
|
if !ok || state == nil {
|
||||||
return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", value)
|
return nil, fmt.Errorf("invalid Claude-to-Chat stream state %T", info.ClaudeToChatStreamState)
|
||||||
}
|
}
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
state := relayconvert.NewClaudeToChatStreamState()
|
state := relayconvert.NewClaudeToChatStreamState()
|
||||||
c.Set(claudeToChatStreamStateKey, state)
|
if info != nil {
|
||||||
|
info.ClaudeToChatStreamState = state
|
||||||
|
}
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func claudeToGeminiStreamState(info *relaycommon.RelayInfo) (*relayconvert.ResponseStreamState, error) {
|
||||||
|
if info != nil && info.ChatToGeminiStreamState != nil {
|
||||||
|
state, ok := info.ChatToGeminiStreamState.(*relayconvert.ResponseStreamState)
|
||||||
|
if !ok || state == nil {
|
||||||
|
return nil, fmt.Errorf("invalid Claude-to-Gemini stream state %T", info.ChatToGeminiStreamState)
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
state, err := relayconvert.NewResponseStreamState(types.RelayFormatClaude, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if info != nil {
|
||||||
|
info.ChatToGeminiStreamState = state
|
||||||
|
}
|
||||||
|
return state, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sendGeminiStreamResults(c *gin.Context, results []relayconvert.ResponseResult) *types.NewAPIError {
|
||||||
|
for _, result := range results {
|
||||||
|
geminiResponse, ok := result.Value.(*dto.GeminiChatResponse)
|
||||||
|
if !ok {
|
||||||
|
return types.NewError(fmt.Errorf("expected Gemini stream response, got %T", result.Value), types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
if geminiResponse == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
data, err := common.Marshal(geminiResponse)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
c.Render(-1, common.CustomEvent{Data: "data: " + string(data)})
|
||||||
|
_ = helper.FlushWriter(c)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
|
func countClaudeStreamBillableTools(c *gin.Context, info *relaycommon.RelayInfo, claudeResponse *dto.ClaudeResponse) {
|
||||||
if claudeResponse == nil {
|
if claudeResponse == nil {
|
||||||
return
|
return
|
||||||
@@ -213,6 +267,20 @@ func HandleStreamFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, clau
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
helper.Done(c)
|
helper.Done(c)
|
||||||
|
} else if info.RelayFormat == types.RelayFormatGemini {
|
||||||
|
state, err := claudeToGeminiStreamState(info)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog("error creating Gemini stream state: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
results, err := service.FinalizeStreamResponse(c, info, state)
|
||||||
|
if err != nil {
|
||||||
|
common.SysLog("error finalizing Gemini stream response: " + err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if sendErr := sendGeminiStreamResults(c, results); sendErr != nil {
|
||||||
|
common.SysLog("send final Gemini stream response failed: " + sendErr.Error())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +361,21 @@ func HandleClaudeResponseData(c *gin.Context, info *relaycommon.RelayInfo, claud
|
|||||||
}
|
}
|
||||||
case types.RelayFormatClaude:
|
case types.RelayFormatClaude:
|
||||||
responseData = data
|
responseData = data
|
||||||
|
case types.RelayFormatGemini:
|
||||||
|
{
|
||||||
|
convertResult, convertErr := service.ConvertResponse(c, info, types.RelayFormatGemini, &claudeResponse)
|
||||||
|
if convertErr != nil {
|
||||||
|
return types.NewError(convertErr, types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
geminiResponse, ok := convertResult.Value.(*dto.GeminiChatResponse)
|
||||||
|
if !ok {
|
||||||
|
return types.NewError(fmt.Errorf("expected Gemini generateContent response, got %T", convertResult.Value), types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
responseData, err = common.Marshal(geminiResponse)
|
||||||
|
if err != nil {
|
||||||
|
return types.NewError(err, types.ErrorCodeBadResponseBody)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
|
if claudeResponse.Usage != nil && claudeResponse.Usage.ServerToolUse != nil && claudeResponse.Usage.ServerToolUse.WebSearchRequests > 0 {
|
||||||
|
|||||||
@@ -299,6 +299,9 @@ func GeminiChatStreamHandler(c *gin.Context, info *relaycommon.RelayInfo, resp *
|
|||||||
if info.SendResponseCount == 0 {
|
if info.SendResponseCount == 0 {
|
||||||
// send first response
|
// send first response
|
||||||
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
emptyResponse := helper.GenerateStartEmptyResponse(id, createAt, info.UpstreamModelName, nil)
|
||||||
|
// Claude message_start is emitted from this first OpenAI chunk.
|
||||||
|
// Carry upstream usage when the current Gemini frame provided it.
|
||||||
|
emptyResponse.Usage = response.Usage
|
||||||
if response.IsToolCall() {
|
if response.IsToolCall() {
|
||||||
if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 {
|
if len(emptyResponse.Choices) > 0 && len(response.Choices) > 0 {
|
||||||
toolCalls := response.Choices[0].Delta.ToolCalls
|
toolCalls := response.Choices[0].Delta.ToolCalls
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/common"
|
"github.com/QuantumNous/new-api/common"
|
||||||
@@ -16,6 +17,130 @@ import (
|
|||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestStreamResponseGeminiChat2OpenAIAttachesUsageMetadata(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
withUsage, isStop := streamResponseGeminiChat2OpenAI(&dto.GeminiChatResponse{
|
||||||
|
Candidates: []dto.GeminiChatCandidate{{
|
||||||
|
Content: dto.GeminiChatContent{
|
||||||
|
Role: "model",
|
||||||
|
Parts: []dto.GeminiPart{{Text: "hello"}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 3868,
|
||||||
|
CandidatesTokenCount: 0,
|
||||||
|
TotalTokenCount: 3868,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.False(t, isStop)
|
||||||
|
require.NotNil(t, withUsage)
|
||||||
|
require.NotNil(t, withUsage.Usage)
|
||||||
|
require.Equal(t, 3868, withUsage.Usage.PromptTokens)
|
||||||
|
require.Equal(t, 3868, withUsage.Usage.TotalTokens)
|
||||||
|
require.NotNil(t, withUsage.Usage.BillingUsage)
|
||||||
|
require.Equal(t, dto.BillingUsageSourceGeminiChat, withUsage.Usage.BillingUsage.Source)
|
||||||
|
require.Equal(t, dto.BillingUsageSemanticGemini, withUsage.Usage.BillingUsage.Semantic)
|
||||||
|
require.NotNil(t, withUsage.Usage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
require.Equal(t, 3868, withUsage.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
require.False(t, withUsage.Usage.BillingUsage.Estimated)
|
||||||
|
|
||||||
|
withoutUsage, _ := streamResponseGeminiChat2OpenAI(&dto.GeminiChatResponse{
|
||||||
|
Candidates: []dto.GeminiChatCandidate{{
|
||||||
|
Content: dto.GeminiChatContent{
|
||||||
|
Role: "model",
|
||||||
|
Parts: []dto.GeminiPart{{Text: "hello"}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
require.NotNil(t, withoutUsage)
|
||||||
|
require.Nil(t, withoutUsage.Usage)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGeminiChatStreamHandlerClaudeFirstFrameUsesUpstreamUsage(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
recorder := httptest.NewRecorder()
|
||||||
|
c, _ := gin.CreateTestContext(recorder)
|
||||||
|
c.Request = httptest.NewRequest(http.MethodPost, "/v1/messages", nil)
|
||||||
|
|
||||||
|
oldStreamingTimeout := constant.StreamingTimeout
|
||||||
|
constant.StreamingTimeout = 300
|
||||||
|
t.Cleanup(func() {
|
||||||
|
constant.StreamingTimeout = oldStreamingTimeout
|
||||||
|
})
|
||||||
|
|
||||||
|
info := &relaycommon.RelayInfo{
|
||||||
|
RelayFormat: types.RelayFormatClaude,
|
||||||
|
OriginModelName: "gemini-2.5-flash",
|
||||||
|
ChannelMeta: &relaycommon.ChannelMeta{
|
||||||
|
UpstreamModelName: "gemini-2.5-flash",
|
||||||
|
},
|
||||||
|
ClaudeConvertInfo: &relaycommon.ClaudeConvertInfo{
|
||||||
|
LastMessagesType: relaycommon.LastMessageTypeNone,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
info.SetEstimatePromptTokens(4994)
|
||||||
|
|
||||||
|
chunkData, err := common.Marshal(dto.GeminiChatResponse{
|
||||||
|
Candidates: []dto.GeminiChatCandidate{{
|
||||||
|
Content: dto.GeminiChatContent{
|
||||||
|
Role: "model",
|
||||||
|
Parts: []dto.GeminiPart{{Text: "hello"}},
|
||||||
|
},
|
||||||
|
}},
|
||||||
|
UsageMetadata: dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 3868,
|
||||||
|
TotalTokenCount: 3868,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
resp := &http.Response{
|
||||||
|
Body: io.NopCloser(bytes.NewReader([]byte("data: " + string(chunkData) + "\n" + "data: [DONE]\n"))),
|
||||||
|
}
|
||||||
|
|
||||||
|
usage, newAPIError := GeminiChatStreamHandler(c, info, resp)
|
||||||
|
require.Nil(t, newAPIError)
|
||||||
|
require.NotNil(t, usage)
|
||||||
|
require.Equal(t, 3868, usage.PromptTokens)
|
||||||
|
|
||||||
|
var startUsage, deltaUsage *dto.ClaudeUsage
|
||||||
|
for _, line := range strings.Split(recorder.Body.String(), "\n") {
|
||||||
|
payload, ok := strings.CutPrefix(strings.TrimSpace(line), "data: ")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var event dto.ClaudeResponse
|
||||||
|
if err := common.UnmarshalJsonStr(payload, &event); err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch event.Type {
|
||||||
|
case "message_start":
|
||||||
|
if event.Message != nil {
|
||||||
|
startUsage = event.Message.Usage
|
||||||
|
}
|
||||||
|
case "message_delta":
|
||||||
|
deltaUsage = event.Usage
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NotNil(t, startUsage)
|
||||||
|
require.Equal(t, 3868, startUsage.InputTokens)
|
||||||
|
require.NotNil(t, startUsage.BillingUsage)
|
||||||
|
require.Equal(t, dto.BillingUsageSourceGeminiChat, startUsage.BillingUsage.Source)
|
||||||
|
require.Equal(t, dto.BillingUsageSemanticGemini, startUsage.BillingUsage.Semantic)
|
||||||
|
require.NotNil(t, startUsage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
require.Equal(t, 3868, startUsage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
require.False(t, startUsage.BillingUsage.Estimated)
|
||||||
|
|
||||||
|
require.NotNil(t, deltaUsage)
|
||||||
|
require.Equal(t, 3868, deltaUsage.InputTokens)
|
||||||
|
require.NotNil(t, deltaUsage.BillingUsage)
|
||||||
|
require.Equal(t, dto.BillingUsageSourceGeminiChat, deltaUsage.BillingUsage.Source)
|
||||||
|
require.Equal(t, dto.BillingUsageSemanticGemini, deltaUsage.BillingUsage.Semantic)
|
||||||
|
require.NotNil(t, deltaUsage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
require.Equal(t, 3868, deltaUsage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
}
|
||||||
|
|
||||||
func TestGeminiChatHandlerCompletionTokensExcludeToolUsePromptTokens(t *testing.T) {
|
func TestGeminiChatHandlerCompletionTokensExcludeToolUsePromptTokens(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"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 {
|
func HandleStreamFormat(c *gin.Context, info *relaycommon.RelayInfo, data string, forceFormat bool, thinkToContent bool) error {
|
||||||
switch info.RelayFormat {
|
switch info.RelayFormat {
|
||||||
@@ -68,7 +66,7 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := chatToGeminiStreamState(c, &streamResponse)
|
state, err := chatToGeminiStreamState(info, &streamResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -79,11 +77,11 @@ func handleGeminiFormat(c *gin.Context, data string, info *relaycommon.RelayInfo
|
|||||||
return sendGeminiStreamResults(c, results)
|
return sendGeminiStreamResults(c, results)
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
|
func chatToGeminiStreamState(info *relaycommon.RelayInfo, streamResponse *dto.ChatCompletionsStreamResponse) (*relayconvert.ResponseStreamState, error) {
|
||||||
if value, ok := c.Get(chatToGeminiStreamStateKey); ok {
|
if info != nil && info.ChatToGeminiStreamState != nil {
|
||||||
state, ok := value.(*relayconvert.ResponseStreamState)
|
state, ok := info.ChatToGeminiStreamState.(*relayconvert.ResponseStreamState)
|
||||||
if !ok || state == nil {
|
if !ok || state == nil {
|
||||||
return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", value)
|
return nil, fmt.Errorf("invalid Chat-to-Gemini stream state %T", info.ChatToGeminiStreamState)
|
||||||
}
|
}
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
@@ -96,7 +94,9 @@ func chatToGeminiStreamState(c *gin.Context, streamResponse *dto.ChatCompletions
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
c.Set(chatToGeminiStreamStateKey, state)
|
if info != nil {
|
||||||
|
info.ChatToGeminiStreamState = state
|
||||||
|
}
|
||||||
return state, nil
|
return state, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -233,7 +233,7 @@ func HandleFinalResponse(c *gin.Context, info *relaycommon.RelayInfo, lastStream
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state, err := chatToGeminiStreamState(c, &streamResponse)
|
state, err := chatToGeminiStreamState(info, &streamResponse)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
common.SysLog("error creating Gemini stream state: " + err.Error())
|
common.SysLog("error creating Gemini stream state: " + err.Error())
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -123,9 +123,14 @@ type RelayInfo struct {
|
|||||||
UserSetting dto.UserSetting
|
UserSetting dto.UserSetting
|
||||||
UserEmail string
|
UserEmail string
|
||||||
UserQuota int
|
UserQuota int
|
||||||
RelayFormat types.RelayFormat
|
RelayFormat types.RelayFormat
|
||||||
SendResponseCount int
|
SendResponseCount int
|
||||||
ReceivedResponseCount int
|
// ClaudeToChatStreamState / ChatToGeminiStreamState hold per-attempt
|
||||||
|
// stream converters. InitChannelMeta nils them so a retry cannot resume a
|
||||||
|
// dirty converter (advanced tool index / finalized).
|
||||||
|
ClaudeToChatStreamState any
|
||||||
|
ChatToGeminiStreamState any
|
||||||
|
ReceivedResponseCount int
|
||||||
FinalPreConsumedQuota int // 最终预消耗的配额
|
FinalPreConsumedQuota int // 最终预消耗的配额
|
||||||
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
|
// ForcePreConsume 为 true 时禁用 BillingSession 的信任额度旁路,
|
||||||
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
|
// 强制预扣全额。用于异步任务(视频/音乐生成等),因为请求返回后任务仍在运行,
|
||||||
@@ -203,6 +208,11 @@ func (info *RelayInfo) InitChannelMeta(c *gin.Context) {
|
|||||||
info.FinalRequestRelayFormat = ""
|
info.FinalRequestRelayFormat = ""
|
||||||
info.RequestConversionChain = nil
|
info.RequestConversionChain = nil
|
||||||
info.InitRequestConversionChain()
|
info.InitRequestConversionChain()
|
||||||
|
// Per-attempt only. Do not clear StreamStatus, conversion diagnostics,
|
||||||
|
// LastError, or billing accumulators — those are request-scoped.
|
||||||
|
info.SendResponseCount = 0
|
||||||
|
info.ClaudeToChatStreamState = nil
|
||||||
|
info.ChatToGeminiStreamState = nil
|
||||||
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
|
channelType := common.GetContextKeyInt(c, constant.ContextKeyChannelType)
|
||||||
paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
|
paramOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelParamOverride)
|
||||||
headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride)
|
headerOverride := common.GetContextKeyStringMap(c, constant.ContextKeyChannelHeaderOverride)
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
package common
|
package common
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||||
|
"github.com/QuantumNous/new-api/relaykit/relayconvert"
|
||||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||||
"github.com/QuantumNous/new-api/relaykit/types"
|
"github.com/QuantumNous/new-api/relaykit/types"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -178,3 +180,94 @@ func TestInitChannelMetaRestoresRequestReasoningEffortForRetry(t *testing.T) {
|
|||||||
info.InitChannelMeta(ctx)
|
info.InitChannelMeta(ctx)
|
||||||
assert.Equal(t, "max", info.ReasoningEffort)
|
assert.Equal(t, "max", info.ReasoningEffort)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestInitChannelMetaResetsPerAttemptStreamStateAndPreservesRequestState(t *testing.T) {
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
ctx, _ := gin.CreateTestContext(httptest.NewRecorder())
|
||||||
|
ctx.Request = httptest.NewRequest("POST", "/v1/chat/completions", nil)
|
||||||
|
|
||||||
|
info, err := GenRelayInfo(ctx, types.RelayFormatOpenAI, &dto.GeneralOpenAIRequest{Model: "gpt-test"}, nil)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
claudeState := relayconvert.NewClaudeToChatStreamState()
|
||||||
|
_, err = claudeState.ConvertChunk(&dto.ClaudeResponse{
|
||||||
|
Type: "content_block_start",
|
||||||
|
Index: ptr(7),
|
||||||
|
ContentBlock: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "tool_use",
|
||||||
|
Id: "toolu_1",
|
||||||
|
Name: "lookup",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = claudeState.ConvertChunk(&dto.ClaudeResponse{
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Index: ptr(7),
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "input_json_delta",
|
||||||
|
PartialJson: ptr(`{"q":"x"}`),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
geminiState, err := relayconvert.NewResponseStreamState(types.RelayFormatOpenAI, types.RelayFormatGemini, relayconvert.ResponseStreamOptions{
|
||||||
|
ID: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
})
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
info.SendResponseCount = 3
|
||||||
|
info.ClaudeToChatStreamState = claudeState
|
||||||
|
info.ChatToGeminiStreamState = geminiState
|
||||||
|
info.LastError = types.NewError(assert.AnError, types.ErrorCodeBadResponseBody)
|
||||||
|
info.StreamStatus = NewStreamStatus()
|
||||||
|
info.StreamStatus.RecordError("attempt 1 soft error")
|
||||||
|
info.RecordConversionDiagnostics(context.Background(), []types.ConversionDiagnostic{{
|
||||||
|
Code: "test.loss",
|
||||||
|
Message: "attempt 1 conversion loss",
|
||||||
|
Severity: types.ConversionDiagnosticWarning,
|
||||||
|
From: types.RelayFormatClaude,
|
||||||
|
To: types.RelayFormatOpenAI,
|
||||||
|
}})
|
||||||
|
|
||||||
|
info.InitChannelMeta(ctx)
|
||||||
|
|
||||||
|
assert.Zero(t, info.SendResponseCount)
|
||||||
|
assert.Nil(t, info.ClaudeToChatStreamState)
|
||||||
|
assert.Nil(t, info.ChatToGeminiStreamState)
|
||||||
|
|
||||||
|
require.NotNil(t, info.StreamStatus)
|
||||||
|
assert.True(t, info.StreamStatus.HasErrors())
|
||||||
|
assert.Equal(t, 1, info.StreamStatus.TotalErrorCount())
|
||||||
|
diagnostics := info.ConversionDiagnostics()
|
||||||
|
require.Len(t, diagnostics, 1)
|
||||||
|
assert.Equal(t, "test.loss", diagnostics[0].Code)
|
||||||
|
require.NotNil(t, info.LastError)
|
||||||
|
|
||||||
|
freshClaude := relayconvert.NewClaudeToChatStreamState()
|
||||||
|
_, err = freshClaude.ConvertChunk(&dto.ClaudeResponse{
|
||||||
|
Type: "content_block_delta",
|
||||||
|
Index: ptr(7),
|
||||||
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
|
Type: "input_json_delta",
|
||||||
|
PartialJson: ptr(`{"q":"x"}`),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "unknown content block index")
|
||||||
|
|
||||||
|
info.IncrSendResponseCount()
|
||||||
|
responses := relayconvert.StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_retry",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
|
||||||
|
}},
|
||||||
|
}, info)
|
||||||
|
require.NotEmpty(t, responses)
|
||||||
|
assert.Equal(t, "message_start", responses[0].Type)
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](value T) *T {
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|||||||
@@ -270,25 +270,12 @@ func (usage *BillingUsage) CanonicalUsage() (*Usage, bool) {
|
|||||||
|
|
||||||
func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
|
func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
|
||||||
canonical := cloneOpenAIUsage(usage.OpenAIUsage)
|
canonical := cloneOpenAIUsage(usage.OpenAIUsage)
|
||||||
if inputDetails := canonical.InputTokensDetails; inputDetails != nil {
|
if canonical.InputTokensDetails != nil {
|
||||||
if canonical.PromptTokensDetails.CachedTokens == 0 && inputDetails.CachedTokens > 0 {
|
// InputTokensDetails fills fields that PromptTokensDetails omitted;
|
||||||
canonical.PromptTokensDetails.CachedTokens = inputDetails.CachedTokens
|
// existing PromptTokensDetails values stay canonical on overlap.
|
||||||
}
|
filled := *canonical.InputTokensDetails
|
||||||
if canonical.PromptTokensDetails.CachedCreationTokens == 0 && inputDetails.CachedCreationTokens > 0 {
|
mergeInputTokenDetails(&filled, canonical.PromptTokensDetails)
|
||||||
canonical.PromptTokensDetails.CachedCreationTokens = inputDetails.CachedCreationTokens
|
canonical.PromptTokensDetails = filled
|
||||||
}
|
|
||||||
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 {
|
if canonical.PromptTokensDetails.CachedTokens == 0 && canonical.PromptCacheHitTokens > 0 {
|
||||||
canonical.PromptTokensDetails.CachedTokens = canonical.PromptCacheHitTokens
|
canonical.PromptTokensDetails.CachedTokens = canonical.PromptCacheHitTokens
|
||||||
@@ -316,12 +303,15 @@ func (usage *BillingUsage) canonicalOpenAIUsage() *Usage {
|
|||||||
|
|
||||||
func (usage *BillingUsage) canonicalClaudeUsage() *Usage {
|
func (usage *BillingUsage) canonicalClaudeUsage() *Usage {
|
||||||
claudeUsage := usage.ClaudeUsage
|
claudeUsage := usage.ClaudeUsage
|
||||||
cacheCreation5m := claudeUsage.GetCacheCreation5mTokens()
|
// Flat legacy fields are a fallback only when this snapshot never carried
|
||||||
if cacheCreation5m == 0 {
|
// a CacheCreation sub-object. Presence (non-nil), not zero vs non-zero,
|
||||||
|
// is the discriminator — a later sub-object that zeros 1h must win.
|
||||||
|
var cacheCreation5m, cacheCreation1h int
|
||||||
|
if claudeUsage.CacheCreation != nil {
|
||||||
|
cacheCreation5m = claudeUsage.GetCacheCreation5mTokens()
|
||||||
|
cacheCreation1h = claudeUsage.GetCacheCreation1hTokens()
|
||||||
|
} else {
|
||||||
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
|
cacheCreation5m = claudeUsage.ClaudeCacheCreation5mTokens
|
||||||
}
|
|
||||||
cacheCreation1h := claudeUsage.GetCacheCreation1hTokens()
|
|
||||||
if cacheCreation1h == 0 {
|
|
||||||
cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
|
cacheCreation1h = claudeUsage.ClaudeCacheCreation1hTokens
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -363,7 +353,7 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
|
|||||||
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
|
addGeminiInputTokenDetail(&canonical.PromptTokensDetails, detail)
|
||||||
}
|
}
|
||||||
for _, detail := range metadata.CandidatesTokensDetails {
|
for _, detail := range metadata.CandidatesTokensDetails {
|
||||||
switch detail.Modality {
|
switch normalizeGeminiModality(detail.Modality) {
|
||||||
case "IMAGE":
|
case "IMAGE":
|
||||||
canonical.CompletionTokenDetails.ImageTokens += detail.TokenCount
|
canonical.CompletionTokenDetails.ImageTokens += detail.TokenCount
|
||||||
case "AUDIO":
|
case "AUDIO":
|
||||||
@@ -377,6 +367,9 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
|
|||||||
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
|
canonical.TotalTokens = canonical.PromptTokens + canonical.CompletionTokens
|
||||||
} else if canonical.CompletionTokens <= 0 {
|
} else if canonical.CompletionTokens <= 0 {
|
||||||
canonical.CompletionTokens = canonical.TotalTokens - canonical.PromptTokens
|
canonical.CompletionTokens = canonical.TotalTokens - canonical.PromptTokens
|
||||||
|
if canonical.CompletionTokens < 0 {
|
||||||
|
canonical.CompletionTokens = 0
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if canonical.PromptTokens > 0 && canonical.PromptTokensDetails.TextTokens == 0 && canonical.PromptTokensDetails.AudioTokens == 0 {
|
if canonical.PromptTokens > 0 && canonical.PromptTokensDetails.TextTokens == 0 && canonical.PromptTokensDetails.AudioTokens == 0 {
|
||||||
canonical.PromptTokensDetails.TextTokens = canonical.PromptTokens
|
canonical.PromptTokensDetails.TextTokens = canonical.PromptTokens
|
||||||
@@ -385,7 +378,7 @@ func (usage *BillingUsage) canonicalGeminiUsage() *Usage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
|
func addGeminiInputTokenDetail(details *InputTokenDetails, detail GeminiPromptTokensDetails) {
|
||||||
switch detail.Modality {
|
switch normalizeGeminiModality(detail.Modality) {
|
||||||
case "AUDIO":
|
case "AUDIO":
|
||||||
details.AudioTokens += detail.TokenCount
|
details.AudioTokens += detail.TokenCount
|
||||||
case "IMAGE":
|
case "IMAGE":
|
||||||
|
|||||||
@@ -63,6 +63,38 @@ func TestNewEstimatedGeminiChatBillingUsage(t *testing.T) {
|
|||||||
assert.Equal(t, 18, billingUsage.GeminiUsageMetadata.TotalTokenCount)
|
assert.Equal(t, 18, billingUsage.GeminiUsageMetadata.TotalTokenCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestCanonicalGeminiUsageClampsNegativeCompletionFromTotalMinusPrompt(t *testing.T) {
|
||||||
|
usage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 50,
|
||||||
|
TotalTokenCount: 30,
|
||||||
|
}).CanonicalUsage()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 0, usage.CompletionTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCanonicalOpenAIUsageMergesInputTokenDetailsFieldwise(t *testing.T) {
|
||||||
|
usage, ok := NewOpenAIResponsesBillingUsage(&Usage{
|
||||||
|
PromptTokens: 10,
|
||||||
|
PromptTokensDetails: InputTokenDetails{
|
||||||
|
CachedTokens: 8,
|
||||||
|
TextTokens: 12,
|
||||||
|
ImageTokens: 4,
|
||||||
|
AudioTokens: 3,
|
||||||
|
},
|
||||||
|
InputTokensDetails: &InputTokenDetails{
|
||||||
|
CachedTokens: 5,
|
||||||
|
CachedCreationTokens: 7,
|
||||||
|
TextTokens: 2,
|
||||||
|
},
|
||||||
|
}).CanonicalUsage()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 8, usage.PromptTokensDetails.CachedTokens)
|
||||||
|
assert.Equal(t, 12, usage.PromptTokensDetails.TextTokens)
|
||||||
|
assert.Equal(t, 4, usage.PromptTokensDetails.ImageTokens)
|
||||||
|
assert.Equal(t, 3, usage.PromptTokensDetails.AudioTokens)
|
||||||
|
assert.Equal(t, 7, usage.PromptTokensDetails.CachedCreationTokens)
|
||||||
|
}
|
||||||
|
|
||||||
func TestBillingUsageJSONUsesProtocolNamedFields(t *testing.T) {
|
func TestBillingUsageJSONUsesProtocolNamedFields(t *testing.T) {
|
||||||
billingUsage := &BillingUsage{
|
billingUsage := &BillingUsage{
|
||||||
OpenAIUsage: &Usage{PromptTokens: 1, BillingUsage: NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 9})},
|
OpenAIUsage: &Usage{PromptTokens: 1, BillingUsage: NewClaudeMessagesBillingUsage(&ClaudeUsage{InputTokens: 9})},
|
||||||
|
|||||||
@@ -101,7 +101,13 @@ func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *Bi
|
|||||||
return CloneBillingUsage(current)
|
return CloneBillingUsage(current)
|
||||||
}
|
}
|
||||||
if current == nil || !sameBillingUsageDialect(current, incoming) {
|
if current == nil || !sameBillingUsageDialect(current, incoming) {
|
||||||
return CloneBillingUsage(incoming)
|
replaced := CloneBillingUsage(incoming)
|
||||||
|
if current != nil && replaced != nil {
|
||||||
|
// Replacement carries the incoming payload; Estimated carries the
|
||||||
|
// history of any local synthesis on either side.
|
||||||
|
replaced.Estimated = current.Estimated || incoming.Estimated
|
||||||
|
}
|
||||||
|
return replaced
|
||||||
}
|
}
|
||||||
|
|
||||||
merged := CloneBillingUsage(current)
|
merged := CloneBillingUsage(current)
|
||||||
@@ -120,7 +126,7 @@ func MergeBillingUsageNonZero(current *BillingUsage, incoming *BillingUsage) *Bi
|
|||||||
cloneOpenAIUsage(incoming.OpenAIUsage),
|
cloneOpenAIUsage(incoming.OpenAIUsage),
|
||||||
)
|
)
|
||||||
case current.ClaudeUsage != nil && incoming.ClaudeUsage != nil:
|
case current.ClaudeUsage != nil && incoming.ClaudeUsage != nil:
|
||||||
merged.ClaudeUsage = mergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
|
merged.ClaudeUsage = MergeClaudeUsageNonZero(current.ClaudeUsage, incoming.ClaudeUsage)
|
||||||
case current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil:
|
case current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil:
|
||||||
merged.GeminiUsageMetadata = MergeGeminiUsageMetadataNonZero(current.GeminiUsageMetadata, incoming.GeminiUsageMetadata)
|
merged.GeminiUsageMetadata = MergeGeminiUsageMetadataNonZero(current.GeminiUsageMetadata, incoming.GeminiUsageMetadata)
|
||||||
}
|
}
|
||||||
@@ -140,12 +146,15 @@ func sameBillingUsageDialect(current *BillingUsage, incoming *BillingUsage) bool
|
|||||||
current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil
|
current.GeminiUsageMetadata != nil && incoming.GeminiUsageMetadata != nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
|
func MergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *ClaudeUsage {
|
||||||
merged := cloneClaudeUsage(current)
|
merged := cloneClaudeUsage(current)
|
||||||
if merged == nil {
|
if merged == nil {
|
||||||
merged = &ClaudeUsage{}
|
merged = &ClaudeUsage{}
|
||||||
}
|
}
|
||||||
if incoming == nil {
|
if incoming == nil {
|
||||||
|
if current != nil {
|
||||||
|
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
|
||||||
|
}
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
if incoming.InputTokens > 0 {
|
if incoming.InputTokens > 0 {
|
||||||
@@ -169,6 +178,11 @@ func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *Claud
|
|||||||
if incoming.CacheCreation != nil {
|
if incoming.CacheCreation != nil {
|
||||||
cacheCreation := *incoming.CacheCreation
|
cacheCreation := *incoming.CacheCreation
|
||||||
merged.CacheCreation = &cacheCreation
|
merged.CacheCreation = &cacheCreation
|
||||||
|
// Flat legacy fields are the same information as the sub-object.
|
||||||
|
// Sync them as a whole overwrite, including explicit zeros, so a
|
||||||
|
// later correction cannot leave a stale high-watermark behind.
|
||||||
|
merged.ClaudeCacheCreation5mTokens = cacheCreation.Ephemeral5mInputTokens
|
||||||
|
merged.ClaudeCacheCreation1hTokens = cacheCreation.Ephemeral1hInputTokens
|
||||||
}
|
}
|
||||||
if incoming.ServerToolUse != nil {
|
if incoming.ServerToolUse != nil {
|
||||||
if merged.ServerToolUse == nil {
|
if merged.ServerToolUse == nil {
|
||||||
@@ -187,6 +201,14 @@ func mergeClaudeUsageNonZero(current *ClaudeUsage, incoming *ClaudeUsage) *Claud
|
|||||||
merged.ServerToolUse.ToolSearchRequests = incoming.ServerToolUse.ToolSearchRequests
|
merged.ServerToolUse.ToolSearchRequests = incoming.ServerToolUse.ToolSearchRequests
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// cloneClaudeUsage strips BillingUsage so a nested Claude snapshot cannot
|
||||||
|
// recurse. Restore the client-visible sidecar here: incoming wins when
|
||||||
|
// present (authoritative/upstream), otherwise keep current's.
|
||||||
|
if incoming.BillingUsage != nil {
|
||||||
|
merged.BillingUsage = CloneBillingUsage(incoming.BillingUsage)
|
||||||
|
} else if current != nil {
|
||||||
|
merged.BillingUsage = CloneBillingUsage(current.BillingUsage)
|
||||||
|
}
|
||||||
return merged
|
return merged
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,19 +260,23 @@ func MergeGeminiUsageMetadataNonZero(current *GeminiUsageMetadata, incoming *Gem
|
|||||||
return &merged
|
return &merged
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func normalizeGeminiModality(modality string) string {
|
||||||
|
return strings.ToUpper(strings.TrimSpace(modality))
|
||||||
|
}
|
||||||
|
|
||||||
func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
|
func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
|
||||||
merged := append([]GeminiPromptTokensDetails{}, current...)
|
merged := append([]GeminiPromptTokensDetails{}, current...)
|
||||||
indexes := make(map[string]int, len(merged))
|
indexes := make(map[string]int, len(merged))
|
||||||
for index, detail := range merged {
|
for index, detail := range merged {
|
||||||
indexes[strings.ToUpper(strings.TrimSpace(detail.Modality))] = index
|
indexes[normalizeGeminiModality(detail.Modality)] = index
|
||||||
}
|
}
|
||||||
for _, detail := range incoming {
|
for _, detail := range incoming {
|
||||||
if detail.TokenCount <= 0 {
|
if detail.TokenCount <= 0 {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
key := strings.ToUpper(strings.TrimSpace(detail.Modality))
|
key := normalizeGeminiModality(detail.Modality)
|
||||||
if index, ok := indexes[key]; ok {
|
if index, ok := indexes[key]; ok {
|
||||||
merged[index] = detail
|
merged[index].TokenCount += detail.TokenCount
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
indexes[key] = len(merged)
|
indexes[key] = len(merged)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
|
func TestMergeClaudeUsageCacheCreationReplacesWholeObject(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
merged := mergeClaudeUsageNonZero(
|
merged := MergeClaudeUsageNonZero(
|
||||||
&ClaudeUsage{
|
&ClaudeUsage{
|
||||||
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
|
CacheCreation: &ClaudeCacheCreationUsage{Ephemeral1hInputTokens: 1000},
|
||||||
},
|
},
|
||||||
@@ -52,6 +52,173 @@ func TestMergeGeminiUsageMetadataCandidatesAndThoughtsReplacedAsPair(t *testing.
|
|||||||
assert.Equal(t, 150, usage.CompletionTokens)
|
assert.Equal(t, 150, usage.CompletionTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestGeminiModalityKeysSettleConsistentlyAndDuplicateEntriesSum(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
for _, modality := range []string{"audio", " AUDIO ", "AUDIO"} {
|
||||||
|
t.Run("settle_"+modality, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
billing := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 100,
|
||||||
|
PromptTokensDetails: []GeminiPromptTokensDetails{
|
||||||
|
{Modality: modality, TokenCount: 40},
|
||||||
|
{Modality: "TEXT", TokenCount: 60},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
usage, ok := billing.CanonicalUsage()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 40, usage.PromptTokensDetails.AudioTokens)
|
||||||
|
assert.Equal(t, 60, usage.PromptTokensDetails.TextTokens)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
mergedDetails := mergeGeminiTokenDetails(
|
||||||
|
[]GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
|
||||||
|
[]GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
|
||||||
|
)
|
||||||
|
require.Len(t, mergedDetails, 1)
|
||||||
|
assert.Equal(t, 25, mergedDetails[0].TokenCount)
|
||||||
|
|
||||||
|
streamMerged := MergeGeminiUsageMetadataNonZero(
|
||||||
|
&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 10,
|
||||||
|
PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
|
||||||
|
},
|
||||||
|
&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 25,
|
||||||
|
PromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NotNil(t, streamMerged)
|
||||||
|
streamUsage, ok := NewGeminiChatBillingUsage(streamMerged).CanonicalUsage()
|
||||||
|
require.True(t, ok)
|
||||||
|
|
||||||
|
decodedUsage, ok := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 25,
|
||||||
|
PromptTokensDetails: []GeminiPromptTokensDetails{
|
||||||
|
{Modality: "AUDIO", TokenCount: 10},
|
||||||
|
{Modality: "audio", TokenCount: 15},
|
||||||
|
},
|
||||||
|
}).CanonicalUsage()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, decodedUsage.PromptTokensDetails.AudioTokens, streamUsage.PromptTokensDetails.AudioTokens)
|
||||||
|
assert.Equal(t, 25, decodedUsage.PromptTokensDetails.AudioTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeCacheCreationSubObjectZeroDoesNotReviveFlatLegacyFields(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
merged := MergeClaudeUsageNonZero(
|
||||||
|
&ClaudeUsage{ClaudeCacheCreation1hTokens: 1000},
|
||||||
|
&ClaudeUsage{
|
||||||
|
InputTokens: 10,
|
||||||
|
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)
|
||||||
|
assert.Equal(t, 1000, merged.ClaudeCacheCreation5mTokens)
|
||||||
|
assert.Equal(t, 0, merged.ClaudeCacheCreation1hTokens)
|
||||||
|
|
||||||
|
usage, ok := NewClaudeMessagesBillingUsage(merged).CanonicalUsage()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 1000, usage.ClaudeCacheCreation5mTokens)
|
||||||
|
assert.Equal(t, 0, usage.ClaudeCacheCreation1hTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClaudeCacheCreationFlatFieldsStillSettleWhenSnapshotNeverHadSubObject(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
usage, ok := NewClaudeMessagesBillingUsage(&ClaudeUsage{
|
||||||
|
InputTokens: 10,
|
||||||
|
ClaudeCacheCreation1hTokens: 1000,
|
||||||
|
}).CanonicalUsage()
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Equal(t, 0, usage.ClaudeCacheCreation5mTokens)
|
||||||
|
assert.Equal(t, 1000, usage.ClaudeCacheCreation1hTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeBillingUsageORsEstimatedOnSameAndCrossDialect(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
estimated := NewEstimatedGeminiChatBillingUsage(&Usage{PromptTokens: 10, CompletionTokens: 2})
|
||||||
|
require.NotNil(t, estimated)
|
||||||
|
require.True(t, estimated.Estimated)
|
||||||
|
|
||||||
|
sameDialect := MergeBillingUsageNonZero(estimated, NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 11,
|
||||||
|
CandidatesTokenCount: 3,
|
||||||
|
TotalTokenCount: 14,
|
||||||
|
}))
|
||||||
|
require.NotNil(t, sameDialect)
|
||||||
|
assert.True(t, sameDialect.Estimated)
|
||||||
|
|
||||||
|
crossDialect := MergeBillingUsageNonZero(estimated, NewOpenAIChatBillingUsage(&Usage{
|
||||||
|
PromptTokens: 12,
|
||||||
|
CompletionTokens: 4,
|
||||||
|
TotalTokens: 16,
|
||||||
|
}))
|
||||||
|
require.NotNil(t, crossDialect)
|
||||||
|
assert.True(t, crossDialect.Estimated)
|
||||||
|
require.NotNil(t, crossDialect.OpenAIUsage)
|
||||||
|
assert.Equal(t, 12, crossDialect.OpenAIUsage.PromptTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMergeClaudeUsageNonZeroPreservesBillingUsage(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
currentSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 3868,
|
||||||
|
TotalTokenCount: 3868,
|
||||||
|
CachedContentTokenCount: 20,
|
||||||
|
})
|
||||||
|
incomingSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 3868,
|
||||||
|
CandidatesTokenCount: 12,
|
||||||
|
TotalTokenCount: 3880,
|
||||||
|
})
|
||||||
|
require.NotNil(t, currentSidecar)
|
||||||
|
require.NotNil(t, incomingSidecar)
|
||||||
|
|
||||||
|
withIncoming := MergeClaudeUsageNonZero(
|
||||||
|
&ClaudeUsage{
|
||||||
|
InputTokens: 3868,
|
||||||
|
CacheReadInputTokens: 20,
|
||||||
|
BillingUsage: currentSidecar,
|
||||||
|
},
|
||||||
|
&ClaudeUsage{
|
||||||
|
InputTokens: 3868,
|
||||||
|
OutputTokens: 12,
|
||||||
|
BillingUsage: incomingSidecar,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
require.NotNil(t, withIncoming.BillingUsage)
|
||||||
|
assert.Equal(t, BillingUsageSourceGeminiChat, withIncoming.BillingUsage.Source)
|
||||||
|
assert.Equal(t, BillingUsageSemanticGemini, withIncoming.BillingUsage.Semantic)
|
||||||
|
require.NotNil(t, withIncoming.BillingUsage.GeminiUsageMetadata)
|
||||||
|
assert.Equal(t, 12, withIncoming.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||||
|
assert.Equal(t, 20, withIncoming.CacheReadInputTokens)
|
||||||
|
assert.NotSame(t, incomingSidecar, withIncoming.BillingUsage)
|
||||||
|
|
||||||
|
keepCurrent := MergeClaudeUsageNonZero(
|
||||||
|
&ClaudeUsage{
|
||||||
|
InputTokens: 3868,
|
||||||
|
CacheReadInputTokens: 20,
|
||||||
|
BillingUsage: currentSidecar,
|
||||||
|
},
|
||||||
|
&ClaudeUsage{InputTokens: 3868, OutputTokens: 12},
|
||||||
|
)
|
||||||
|
require.NotNil(t, keepCurrent.BillingUsage)
|
||||||
|
require.NotNil(t, keepCurrent.BillingUsage.GeminiUsageMetadata)
|
||||||
|
assert.Equal(t, 20, keepCurrent.BillingUsage.GeminiUsageMetadata.CachedContentTokenCount)
|
||||||
|
assert.Equal(t, 0, keepCurrent.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||||
|
assert.Equal(t, 20, keepCurrent.CacheReadInputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) {
|
func TestMergeUsageNonZeroKeepsPositiveValuesAndTakesMaxTotal(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -283,6 +283,11 @@ func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
|||||||
Object: "chat.completion.chunk",
|
Object: "chat.completion.chunk",
|
||||||
Choices: choices,
|
Choices: choices,
|
||||||
}
|
}
|
||||||
|
// Only attach usage the chunk actually reported. Do not fall back to a
|
||||||
|
// local prompt estimate — converters treat this as first-frame truth.
|
||||||
|
if metadata := geminiResponse.GetUsageMetadata(); dto.HasGeminiUsageMetadataTokens(metadata) {
|
||||||
|
response.Usage = UsageFromGeminiMetadata(metadata, 0)
|
||||||
|
}
|
||||||
return &response, isStop
|
return &response, isStop
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,21 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
|||||||
return sharedclaude.UsageFromOpenAI(oaiUsage)
|
return sharedclaude.UsageFromOpenAI(oaiUsage)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func clientVisibleClaudeStreamUsage(state *convmeta.ClaudeConvertInfo, incoming *dto.Usage) *dto.ClaudeUsage {
|
||||||
|
prior := buildClaudeUsageFromOpenAIUsage(state.Usage)
|
||||||
|
converted := buildClaudeUsageFromOpenAIUsage(incoming)
|
||||||
|
if incoming != nil {
|
||||||
|
state.Usage = dto.MergeUsageNonZero(state.Usage, incoming)
|
||||||
|
}
|
||||||
|
if prior == nil {
|
||||||
|
return converted
|
||||||
|
}
|
||||||
|
if converted == nil {
|
||||||
|
return prior
|
||||||
|
}
|
||||||
|
return dto.MergeClaudeUsageNonZero(prior, converted)
|
||||||
|
}
|
||||||
|
|
||||||
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
func NormalizeCacheCreationSplit(totalTokens int, tokens5m int, tokens1h int) (int, int) {
|
||||||
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
|
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
|
||||||
}
|
}
|
||||||
@@ -168,15 +183,27 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if info.GetSendResponseCount() == 1 {
|
if info.GetSendResponseCount() == 1 {
|
||||||
|
// Client-visible Claude stream usage matches billing merge: first
|
||||||
|
// frame records first, a later non-zero field overrides, and a later
|
||||||
|
// zero/missing field never erases a first-frame positive. Anthropic
|
||||||
|
// clients treat message_delta as authoritative for the fields it
|
||||||
|
// carries, including a corrected input_tokens.
|
||||||
|
startUsage := &dto.ClaudeUsage{
|
||||||
|
InputTokens: info.GetEstimatePromptTokens(),
|
||||||
|
OutputTokens: 0,
|
||||||
|
}
|
||||||
|
if openAIResponse.Usage != nil && dto.HasOpenAIUsageTokens(openAIResponse.Usage) {
|
||||||
|
if real := buildClaudeUsageFromOpenAIUsage(openAIResponse.Usage); real != nil {
|
||||||
|
startUsage = real
|
||||||
|
}
|
||||||
|
state.Usage = dto.MergeUsageNonZero(state.Usage, openAIResponse.Usage)
|
||||||
|
}
|
||||||
msg := &dto.ClaudeMediaMessage{
|
msg := &dto.ClaudeMediaMessage{
|
||||||
Id: openAIResponse.Id,
|
Id: openAIResponse.Id,
|
||||||
Model: openAIResponse.Model,
|
Model: openAIResponse.Model,
|
||||||
Type: "message",
|
Type: "message",
|
||||||
Role: "assistant",
|
Role: "assistant",
|
||||||
Usage: &dto.ClaudeUsage{
|
Usage: startUsage,
|
||||||
InputTokens: info.GetEstimatePromptTokens(),
|
|
||||||
OutputTokens: 0,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
msg.SetContent(make([]any, 0))
|
msg.SetContent(make([]any, 0))
|
||||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
@@ -187,10 +214,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
|||||||
|
|
||||||
if len(openAIResponse.Choices) == 0 {
|
if len(openAIResponse.Choices) == 0 {
|
||||||
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
||||||
oaiUsage := openAIResponse.Usage
|
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
|
||||||
if oaiUsage == nil {
|
|
||||||
oaiUsage = state.Usage
|
|
||||||
}
|
|
||||||
if oaiUsage != nil {
|
if oaiUsage != nil {
|
||||||
appendStopOpenBlocks()
|
appendStopOpenBlocks()
|
||||||
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
|
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
|
||||||
@@ -199,7 +223,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
|||||||
}
|
}
|
||||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
Type: "message_delta",
|
Type: "message_delta",
|
||||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
Usage: oaiUsage,
|
||||||
Delta: &dto.ClaudeMediaMessage{
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
StopReason: kitutil.GetPointer[string](stopReason),
|
StopReason: kitutil.GetPointer[string](stopReason),
|
||||||
},
|
},
|
||||||
@@ -367,10 +391,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
|||||||
appendCitationDeltas(chosenChoice.Delta.Annotations)
|
appendCitationDeltas(chosenChoice.Delta.Annotations)
|
||||||
|
|
||||||
if doneChunk || state.Done {
|
if doneChunk || state.Done {
|
||||||
oaiUsage := openAIResponse.Usage
|
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
|
||||||
if oaiUsage == nil {
|
|
||||||
oaiUsage = state.Usage
|
|
||||||
}
|
|
||||||
if oaiUsage == nil {
|
if oaiUsage == nil {
|
||||||
// Some upstreams emit finish_reason first, then send a final usage-only chunk.
|
// 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
|
// Keep content blocks open until usage is available so the terminal message_delta
|
||||||
@@ -380,7 +401,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
|||||||
appendStopOpenBlocks()
|
appendStopOpenBlocks()
|
||||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||||
Type: "message_delta",
|
Type: "message_delta",
|
||||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
Usage: oaiUsage,
|
||||||
Delta: &dto.ClaudeMediaMessage{
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
|
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
|
||||||
},
|
},
|
||||||
@@ -414,7 +435,7 @@ func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeRespon
|
|||||||
responses = append(responses,
|
responses = append(responses,
|
||||||
&dto.ClaudeResponse{
|
&dto.ClaudeResponse{
|
||||||
Type: "message_delta",
|
Type: "message_delta",
|
||||||
Usage: buildClaudeUsageFromOpenAIUsage(state.Usage),
|
Usage: clientVisibleClaudeStreamUsage(state, nil),
|
||||||
Delta: &dto.ClaudeMediaMessage{
|
Delta: &dto.ClaudeMediaMessage{
|
||||||
StopReason: kitutil.GetPointer[string](stopReason),
|
StopReason: kitutil.GetPointer[string](stopReason),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -223,6 +223,192 @@ func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T
|
|||||||
assert.Equal(t, "message_stop", finishResponses[2].Type)
|
assert.Equal(t, "message_stop", finishResponses[2].Type)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestStreamResponseOpenAI2ClaudeFirstFrameUsesUpstreamUsageWhenPresent(t *testing.T) {
|
||||||
|
info := &convmeta.Values{
|
||||||
|
EstimatePromptTokens: 32,
|
||||||
|
SendResponseCount: 1,
|
||||||
|
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
|
||||||
|
}
|
||||||
|
|
||||||
|
responses := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
|
||||||
|
}},
|
||||||
|
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 0, TotalTokens: 29},
|
||||||
|
}, info)
|
||||||
|
require.NotEmpty(t, responses)
|
||||||
|
require.Equal(t, "message_start", responses[0].Type)
|
||||||
|
require.NotNil(t, responses[0].Message)
|
||||||
|
require.NotNil(t, responses[0].Message.Usage)
|
||||||
|
assert.Equal(t, 29, responses[0].Message.Usage.InputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponseOpenAI2ClaudeMessageDeltaCorrectsEstimatedFirstFrame(t *testing.T) {
|
||||||
|
info := &convmeta.Values{
|
||||||
|
EstimatePromptTokens: 32,
|
||||||
|
SendResponseCount: 1,
|
||||||
|
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
|
||||||
|
}
|
||||||
|
|
||||||
|
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
|
||||||
|
}},
|
||||||
|
}, info)
|
||||||
|
require.NotEmpty(t, first)
|
||||||
|
require.Equal(t, "message_start", first[0].Type)
|
||||||
|
require.NotNil(t, first[0].Message.Usage)
|
||||||
|
assert.Equal(t, 32, first[0].Message.Usage.InputTokens)
|
||||||
|
|
||||||
|
info.SendResponseCount = 2
|
||||||
|
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
|
FinishReason: ptr("stop"),
|
||||||
|
}},
|
||||||
|
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
|
||||||
|
}, info)
|
||||||
|
var delta *dto.ClaudeResponse
|
||||||
|
for _, resp := range finish {
|
||||||
|
if resp.Type == "message_delta" {
|
||||||
|
delta = resp
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, delta)
|
||||||
|
require.NotNil(t, delta.Usage)
|
||||||
|
assert.Equal(t, 29, delta.Usage.InputTokens)
|
||||||
|
assert.Equal(t, 4, delta.Usage.OutputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponseOpenAI2ClaudeMessageDeltaDoesNotZeroFirstFrameCache(t *testing.T) {
|
||||||
|
info := &convmeta.Values{
|
||||||
|
EstimatePromptTokens: 8,
|
||||||
|
SendResponseCount: 1,
|
||||||
|
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
|
||||||
|
}
|
||||||
|
|
||||||
|
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
|
||||||
|
}},
|
||||||
|
Usage: &dto.Usage{
|
||||||
|
PromptTokens: 40,
|
||||||
|
CompletionTokens: 0,
|
||||||
|
TotalTokens: 40,
|
||||||
|
PromptTokensDetails: dto.InputTokenDetails{
|
||||||
|
CachedTokens: 20,
|
||||||
|
CachedCreationTokens: 10,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, info)
|
||||||
|
require.Equal(t, "message_start", first[0].Type)
|
||||||
|
require.NotNil(t, first[0].Message.Usage)
|
||||||
|
assert.Equal(t, 20, first[0].Message.Usage.CacheReadInputTokens)
|
||||||
|
assert.Equal(t, 10, first[0].Message.Usage.CacheCreationInputTokens)
|
||||||
|
|
||||||
|
info.SendResponseCount = 2
|
||||||
|
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
|
FinishReason: ptr("stop"),
|
||||||
|
}},
|
||||||
|
Usage: &dto.Usage{PromptTokens: 29, CompletionTokens: 4, TotalTokens: 33},
|
||||||
|
}, info)
|
||||||
|
var delta *dto.ClaudeResponse
|
||||||
|
for _, resp := range finish {
|
||||||
|
if resp.Type == "message_delta" {
|
||||||
|
delta = resp
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, delta)
|
||||||
|
require.NotNil(t, delta.Usage)
|
||||||
|
assert.Equal(t, 29, delta.Usage.InputTokens)
|
||||||
|
assert.Equal(t, 20, delta.Usage.CacheReadInputTokens)
|
||||||
|
assert.Equal(t, 10, delta.Usage.CacheCreationInputTokens)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestStreamResponseOpenAI2ClaudeGeminiBillingUsageOnStartAndDelta(t *testing.T) {
|
||||||
|
info := &convmeta.Values{
|
||||||
|
EstimatePromptTokens: 4994,
|
||||||
|
SendResponseCount: 1,
|
||||||
|
ClaudeConvertInfo: &convmeta.ClaudeConvertInfo{LastMessagesType: convmeta.LastMessageTypeNone},
|
||||||
|
}
|
||||||
|
|
||||||
|
firstUsage := &dto.Usage{
|
||||||
|
PromptTokens: 3868,
|
||||||
|
CompletionTokens: 0,
|
||||||
|
TotalTokens: 3868,
|
||||||
|
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 3868,
|
||||||
|
TotalTokenCount: 3868,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
first := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
|
Delta: dto.ChatCompletionsStreamResponseChoiceDelta{Content: ptr("hello")},
|
||||||
|
}},
|
||||||
|
Usage: firstUsage,
|
||||||
|
}, info)
|
||||||
|
require.NotEmpty(t, first)
|
||||||
|
require.Equal(t, "message_start", first[0].Type)
|
||||||
|
require.NotNil(t, first[0].Message)
|
||||||
|
require.NotNil(t, first[0].Message.Usage)
|
||||||
|
assert.Equal(t, 3868, first[0].Message.Usage.InputTokens)
|
||||||
|
require.NotNil(t, first[0].Message.Usage.BillingUsage)
|
||||||
|
assert.Equal(t, dto.BillingUsageSourceGeminiChat, first[0].Message.Usage.BillingUsage.Source)
|
||||||
|
assert.Equal(t, dto.BillingUsageSemanticGemini, first[0].Message.Usage.BillingUsage.Semantic)
|
||||||
|
require.NotNil(t, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
assert.Equal(t, 3868, first[0].Message.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
|
||||||
|
info.SendResponseCount = 2
|
||||||
|
finish := StreamResponseOpenAI2Claude(&dto.ChatCompletionsStreamResponse{
|
||||||
|
Id: "chatcmpl_1",
|
||||||
|
Model: "gpt-test",
|
||||||
|
Choices: []dto.ChatCompletionsStreamResponseChoice{{
|
||||||
|
FinishReason: ptr("stop"),
|
||||||
|
}},
|
||||||
|
Usage: &dto.Usage{
|
||||||
|
PromptTokens: 3868,
|
||||||
|
CompletionTokens: 12,
|
||||||
|
TotalTokens: 3880,
|
||||||
|
BillingUsage: dto.NewGeminiChatBillingUsage(&dto.GeminiUsageMetadata{
|
||||||
|
PromptTokenCount: 3868,
|
||||||
|
CandidatesTokenCount: 12,
|
||||||
|
TotalTokenCount: 3880,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
}, info)
|
||||||
|
var delta *dto.ClaudeResponse
|
||||||
|
for _, resp := range finish {
|
||||||
|
if resp.Type == "message_delta" {
|
||||||
|
delta = resp
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
require.NotNil(t, delta)
|
||||||
|
require.NotNil(t, delta.Usage)
|
||||||
|
assert.Equal(t, 3868, delta.Usage.InputTokens)
|
||||||
|
assert.Equal(t, 12, delta.Usage.OutputTokens)
|
||||||
|
require.NotNil(t, delta.Usage.BillingUsage)
|
||||||
|
assert.Equal(t, dto.BillingUsageSourceGeminiChat, delta.Usage.BillingUsage.Source)
|
||||||
|
assert.Equal(t, dto.BillingUsageSemanticGemini, delta.Usage.BillingUsage.Semantic)
|
||||||
|
require.NotNil(t, delta.Usage.BillingUsage.GeminiUsageMetadata)
|
||||||
|
assert.Equal(t, 3868, delta.Usage.BillingUsage.GeminiUsageMetadata.PromptTokenCount)
|
||||||
|
assert.Equal(t, 12, delta.Usage.BillingUsage.GeminiUsageMetadata.CandidatesTokenCount)
|
||||||
|
}
|
||||||
|
|
||||||
func TestNormalizeCacheCreationSplit(t *testing.T) {
|
func TestNormalizeCacheCreationSplit(t *testing.T) {
|
||||||
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
|
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
|
||||||
assert.Equal(t, 8, cache5m)
|
assert.Equal(t, 8, cache5m)
|
||||||
|
|||||||
@@ -157,22 +157,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
|||||||
|
|
||||||
if textRequest.Tools != nil {
|
if textRequest.Tools != nil {
|
||||||
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
|
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
|
||||||
googleSearch := false
|
|
||||||
codeExecution := false
|
|
||||||
urlContext := false
|
|
||||||
for _, tool := range textRequest.Tools {
|
for _, tool := range textRequest.Tools {
|
||||||
if tool.Function.Name == "googleSearch" {
|
|
||||||
googleSearch = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if tool.Function.Name == "codeExecution" {
|
|
||||||
codeExecution = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if tool.Function.Name == "urlContext" {
|
|
||||||
urlContext = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if tool.Function.Parameters != nil {
|
if tool.Function.Parameters != nil {
|
||||||
if params, ok := tool.Function.Parameters.(map[string]interface{}); ok {
|
if params, ok := tool.Function.Parameters.(map[string]interface{}); ok {
|
||||||
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
|
if props, hasProps := params["properties"].(map[string]interface{}); hasProps && len(props) == 0 {
|
||||||
@@ -184,21 +169,6 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
|||||||
functions = append(functions, tool.Function)
|
functions = append(functions, tool.Function)
|
||||||
}
|
}
|
||||||
geminiTools := geminiRequest.GetTools()
|
geminiTools := geminiRequest.GetTools()
|
||||||
if codeExecution {
|
|
||||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
|
||||||
CodeExecution: make(map[string]string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if googleSearch {
|
|
||||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
|
||||||
GoogleSearch: make(map[string]string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if urlContext {
|
|
||||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
|
||||||
URLContext: make(map[string]string),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if len(functions) > 0 {
|
if len(functions) > 0 {
|
||||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||||
FunctionDeclarations: functions,
|
FunctionDeclarations: functions,
|
||||||
|
|||||||
@@ -56,6 +56,10 @@ func extractOpenAIChatRequest(request any) (any, Set, error) {
|
|||||||
}
|
}
|
||||||
for index, tool := range source.Tools {
|
for index, tool := range source.Tools {
|
||||||
if tool.Type == "function" || tool.Type == "" {
|
if tool.Type == "function" || tool.Type == "" {
|
||||||
|
if definition, ok := decodeOpenAIChatPseudoHostedTool(tool.Function.Name); ok {
|
||||||
|
set.Definitions = append(set.Definitions, definition)
|
||||||
|
continue
|
||||||
|
}
|
||||||
set.Definitions = append(set.Definitions, Definition{
|
set.Definitions = append(set.Definitions, Definition{
|
||||||
Kind: KindFunction,
|
Kind: KindFunction,
|
||||||
Execution: ExecutionClient,
|
Execution: ExecutionClient,
|
||||||
@@ -511,6 +515,40 @@ func rawBoolPointer(raw json.RawMessage) *bool {
|
|||||||
return &value
|
return &value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// decodeOpenAIChatPseudoHostedTool recognizes the OpenAI Chat dialect that
|
||||||
|
// declares Gemini hosted tools as function definitions named googleSearch,
|
||||||
|
// codeExecution, or urlContext. The names are the historical public contract;
|
||||||
|
// recognition lives here so every target format goes through the same hosted
|
||||||
|
// ToolDefinition pipeline.
|
||||||
|
func decodeOpenAIChatPseudoHostedTool(name string) (Definition, bool) {
|
||||||
|
switch name {
|
||||||
|
case "googleSearch":
|
||||||
|
return Definition{
|
||||||
|
Kind: KindWebSearch,
|
||||||
|
Execution: ExecutionServer,
|
||||||
|
NativeType: "googleSearch",
|
||||||
|
Name: "googleSearch",
|
||||||
|
WebSearch: &WebSearch{},
|
||||||
|
}, true
|
||||||
|
case "codeExecution":
|
||||||
|
return Definition{
|
||||||
|
Kind: KindCodeExecution,
|
||||||
|
Execution: ExecutionServer,
|
||||||
|
NativeType: "codeExecution",
|
||||||
|
Name: "codeExecution",
|
||||||
|
}, true
|
||||||
|
case "urlContext":
|
||||||
|
return Definition{
|
||||||
|
Kind: KindURLContext,
|
||||||
|
Execution: ExecutionServer,
|
||||||
|
NativeType: "urlContext",
|
||||||
|
Name: "urlContext",
|
||||||
|
}, true
|
||||||
|
default:
|
||||||
|
return Definition{}, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
|
func decodeOpenAIChatLocation(raw json.RawMessage) (*ApproximateLocation, error) {
|
||||||
if len(raw) == 0 {
|
if len(raw) == 0 {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
|
|||||||
@@ -381,13 +381,13 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
|||||||
diagnostics = append(diagnostics, geminiWebSearchDiagnostics(index, definition.WebSearch)...)
|
diagnostics = append(diagnostics, geminiWebSearchDiagnostics(index, definition.WebSearch)...)
|
||||||
}
|
}
|
||||||
case KindCodeExecution:
|
case KindCodeExecution:
|
||||||
if set.Source == types.RelayFormatGemini {
|
if set.Source == types.RelayFormatGemini || definition.NativeType == "codeExecution" {
|
||||||
tools = append(tools, map[string]any{"codeExecution": map[string]any{}})
|
tools = append(tools, map[string]any{"codeExecution": map[string]any{}})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "code execution semantics differ across providers"))
|
diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "code execution semantics differ across providers"))
|
||||||
case KindURLContext:
|
case KindURLContext:
|
||||||
if set.Source == types.RelayFormatGemini {
|
if set.Source == types.RelayFormatGemini || definition.NativeType == "urlContext" {
|
||||||
tools = append(tools, map[string]any{"urlContext": map[string]any{}})
|
tools = append(tools, map[string]any{"urlContext": map[string]any{}})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user