mirror of
https://github.com/QuantumNous/new-api.git
synced 2026-09-11 14:41:21 +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:
@@ -283,6 +283,11 @@ func StreamResponseGeminiChat2OpenAI(geminiResponse *dto.GeminiChatResponse) (*d
|
||||
Object: "chat.completion.chunk",
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@@ -89,6 +89,21 @@ func buildClaudeUsageFromOpenAIUsage(oaiUsage *dto.Usage) *dto.ClaudeUsage {
|
||||
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) {
|
||||
return sharedclaude.NormalizeCacheCreationSplit(totalTokens, tokens5m, tokens1h)
|
||||
}
|
||||
@@ -168,15 +183,27 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
}
|
||||
}
|
||||
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{
|
||||
Id: openAIResponse.Id,
|
||||
Model: openAIResponse.Model,
|
||||
Type: "message",
|
||||
Role: "assistant",
|
||||
Usage: &dto.ClaudeUsage{
|
||||
InputTokens: info.GetEstimatePromptTokens(),
|
||||
OutputTokens: 0,
|
||||
},
|
||||
Usage: startUsage,
|
||||
}
|
||||
msg.SetContent(make([]any, 0))
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
@@ -187,10 +214,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
|
||||
if len(openAIResponse.Choices) == 0 {
|
||||
// Some OpenAI-compatible upstreams end with a usage-only SSE chunk.
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = state.Usage
|
||||
}
|
||||
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
|
||||
if oaiUsage != nil {
|
||||
appendStopOpenBlocks()
|
||||
stopReason := stopReasonOpenAI2Claude(state.FinishReason)
|
||||
@@ -199,7 +223,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
}
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Usage: oaiUsage,
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: kitutil.GetPointer[string](stopReason),
|
||||
},
|
||||
@@ -367,10 +391,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
appendCitationDeltas(chosenChoice.Delta.Annotations)
|
||||
|
||||
if doneChunk || state.Done {
|
||||
oaiUsage := openAIResponse.Usage
|
||||
if oaiUsage == nil {
|
||||
oaiUsage = state.Usage
|
||||
}
|
||||
oaiUsage := clientVisibleClaudeStreamUsage(state, openAIResponse.Usage)
|
||||
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
|
||||
@@ -380,7 +401,7 @@ func StreamResponseOpenAI2Claude(openAIResponse *dto.ChatCompletionsStreamRespon
|
||||
appendStopOpenBlocks()
|
||||
claudeResponses = append(claudeResponses, &dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(oaiUsage),
|
||||
Usage: oaiUsage,
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: kitutil.GetPointer[string](stopReasonOpenAI2Claude(state.FinishReason)),
|
||||
},
|
||||
@@ -414,7 +435,7 @@ func FinalizeStreamResponseOpenAI2Claude(info convmeta.Meta) []*dto.ClaudeRespon
|
||||
responses = append(responses,
|
||||
&dto.ClaudeResponse{
|
||||
Type: "message_delta",
|
||||
Usage: buildClaudeUsageFromOpenAIUsage(state.Usage),
|
||||
Usage: clientVisibleClaudeStreamUsage(state, nil),
|
||||
Delta: &dto.ClaudeMediaMessage{
|
||||
StopReason: kitutil.GetPointer[string](stopReason),
|
||||
},
|
||||
|
||||
@@ -223,6 +223,192 @@ func TestStreamResponseOpenAI2ClaudeClosesTextThinkingAndToolBlocks(t *testing.T
|
||||
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) {
|
||||
cache5m, cache1h := NormalizeCacheCreationSplit(10, 3, 2)
|
||||
assert.Equal(t, 8, cache5m)
|
||||
|
||||
@@ -157,22 +157,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
||||
|
||||
if textRequest.Tools != nil {
|
||||
functions := make([]dto.FunctionRequest, 0, len(textRequest.Tools))
|
||||
googleSearch := false
|
||||
codeExecution := false
|
||||
urlContext := false
|
||||
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 params, ok := tool.Function.Parameters.(map[string]interface{}); ok {
|
||||
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)
|
||||
}
|
||||
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 {
|
||||
geminiTools = append(geminiTools, dto.GeminiChatTool{
|
||||
FunctionDeclarations: functions,
|
||||
|
||||
@@ -56,6 +56,10 @@ func extractOpenAIChatRequest(request any) (any, Set, error) {
|
||||
}
|
||||
for index, tool := range source.Tools {
|
||||
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{
|
||||
Kind: KindFunction,
|
||||
Execution: ExecutionClient,
|
||||
@@ -511,6 +515,40 @@ func rawBoolPointer(raw json.RawMessage) *bool {
|
||||
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) {
|
||||
if len(raw) == 0 {
|
||||
return nil, nil
|
||||
|
||||
@@ -381,13 +381,13 @@ func attachGeminiRequest(request any, set Set) (any, []types.ConversionDiagnosti
|
||||
diagnostics = append(diagnostics, geminiWebSearchDiagnostics(index, definition.WebSearch)...)
|
||||
}
|
||||
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{}})
|
||||
continue
|
||||
}
|
||||
diagnostics = append(diagnostics, semanticLoss(fmt.Sprintf("tools[%d]", index), "unverified_tool_mapping", "code execution semantics differ across providers"))
|
||||
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{}})
|
||||
continue
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user