mirror of
https://gh-proxy.org/https://github.com/klinsmaya/new-api.git
synced 2026-11-08 01:17:09 +08:00
fix(relaykit): Gemini stream token details, Responses tool-output media on Gemini, Opus 5.5 default effort (#7687)
* fix(relaykit): stop summing cumulative Gemini stream token details Gemini usageMetadata on every stream frame is a cumulative snapshot, but mergeGeminiTokenDetails added each frame's per-modality counts to the previous ones. Prompt, tool-use prompt and candidates details were multiplied by the number of frames carrying usage, and converted streams multiplied them again because the same sidecar is remembered at several pipeline steps. A streamed image prompt was billed as 3 x 1089 image tokens. A modality reported by the incoming frame now replaces all earlier entries for it, matching how the scalar counts are merged; modalities the frame omits keep their earlier value, and duplicate entries inside one snapshot are still summed at settlement. * fix(relayconvert): send Responses tool-output media to Gemini as inline data A function_call_output or custom_tool_call_output holding a Responses content-part array was stringified into functionResponse.response, so Gemini tokenized the base64 media as text (an image tool output cost 24,366 prompt tokens and the model could not see it). Text parts now stay in response and images and documents become inlineData resolved through the SSRF-protected media resolver. Gemini 3 documents multimodal function responses, so the media go into functionResponse.parts there and for any model id not known to be older. Gemini 1.x and 2.x get them as ordinary inlineData parts after every functionResponse of the same turn, which keeps parallel calls grouped. Media a target cannot carry (audio or video inside functionResponse.parts, unsupported MIME types, failed loads) is dropped with an unsupported_media_type diagnostic for the tool-loss policy. The inline-data MIME allowlist now follows the Blob.mimeType list in the Gemini API reference, adding image/gif, image/avif, audio/*, video/* and the listed text and application types, so ordinary Chat and Responses messages with those files are no longer rejected locally. * fix(relayconvert): use medium as the Opus 5.5 default effort Claude Opus 5.5 runs at medium effort when a request sets none; every other model, including Opus 5, Sonnet 5.5 and Fable 5.1, runs at high (platform.claude.com/docs/en/build-with-claude/effort). The converter assumed high everywhere, so Opus 5.5 requests without an effort were logged as high, and a Claude client on claude-opus-5-5 routed to an OpenAI upstream sent reasoning effort high instead of the model's default. Claude capabilities now carry a per-model default effort, used when the client gave none: the no-intent and include_thoughts-only paths, adaptive or enabled thinking without a strength, the native passthrough label, and ResolveClaudeDefault for Claude-to-OpenAI conversions. Explicit efforts and budget mappings are unchanged. The value feeds the consume-log reasoning_effort field, OpenAI parameter filtering and the RelayInfo debug string; billing does not read it.
This commit is contained in:
+18
-13
@@ -264,23 +264,28 @@ func normalizeGeminiModality(modality string) string {
|
||||
return strings.ToUpper(strings.TrimSpace(modality))
|
||||
}
|
||||
|
||||
// mergeGeminiTokenDetails overlays per-modality counts the same way as the
|
||||
// scalar counts: every stream frame carries a cumulative snapshot, so a
|
||||
// modality the incoming frame reports replaces all earlier entries for it,
|
||||
// and a modality it omits keeps its earlier value. Duplicate entries inside
|
||||
// one snapshot are kept as-is; settlement sums them.
|
||||
func mergeGeminiTokenDetails(current []GeminiPromptTokensDetails, incoming []GeminiPromptTokensDetails) []GeminiPromptTokensDetails {
|
||||
merged := append([]GeminiPromptTokensDetails{}, current...)
|
||||
indexes := make(map[string]int, len(merged))
|
||||
for index, detail := range merged {
|
||||
indexes[normalizeGeminiModality(detail.Modality)] = index
|
||||
reported := make(map[string]bool, len(incoming))
|
||||
for _, detail := range incoming {
|
||||
if detail.TokenCount > 0 {
|
||||
reported[normalizeGeminiModality(detail.Modality)] = true
|
||||
}
|
||||
}
|
||||
merged := make([]GeminiPromptTokensDetails, 0, len(current)+len(incoming))
|
||||
for _, detail := range current {
|
||||
if !reported[normalizeGeminiModality(detail.Modality)] {
|
||||
merged = append(merged, detail)
|
||||
}
|
||||
}
|
||||
for _, detail := range incoming {
|
||||
if detail.TokenCount <= 0 {
|
||||
continue
|
||||
if detail.TokenCount > 0 {
|
||||
merged = append(merged, detail)
|
||||
}
|
||||
key := normalizeGeminiModality(detail.Modality)
|
||||
if index, ok := indexes[key]; ok {
|
||||
merged[index].TokenCount += detail.TokenCount
|
||||
continue
|
||||
}
|
||||
indexes[key] = len(merged)
|
||||
merged = append(merged, detail)
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
@@ -72,37 +72,95 @@ func TestGeminiModalityKeysSettleConsistentlyAndDuplicateEntriesSum(t *testing.T
|
||||
})
|
||||
}
|
||||
|
||||
// A case variant in a later cumulative frame is the same modality and
|
||||
// replaces the earlier value.
|
||||
mergedDetails := mergeGeminiTokenDetails(
|
||||
[]GeminiPromptTokensDetails{{Modality: "AUDIO", TokenCount: 10}},
|
||||
[]GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}},
|
||||
)
|
||||
require.Len(t, mergedDetails, 1)
|
||||
assert.Equal(t, 25, mergedDetails[0].TokenCount)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{{Modality: "audio", TokenCount: 15}}, mergedDetails)
|
||||
|
||||
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{
|
||||
// Duplicate entries inside one snapshot settle as their sum, and merging
|
||||
// that snapshot again (every frame repeats it) does not add it twice.
|
||||
snapshot := &GeminiUsageMetadata{
|
||||
PromptTokenCount: 25,
|
||||
PromptTokensDetails: []GeminiPromptTokensDetails{
|
||||
{Modality: "AUDIO", TokenCount: 10},
|
||||
{Modality: "audio", TokenCount: 15},
|
||||
},
|
||||
}).CanonicalUsage()
|
||||
}
|
||||
streamMerged := MergeGeminiUsageMetadataNonZero(MergeGeminiUsageMetadataNonZero(nil, snapshot), snapshot)
|
||||
require.NotNil(t, streamMerged)
|
||||
streamUsage, ok := NewGeminiChatBillingUsage(streamMerged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
|
||||
decodedUsage, ok := NewGeminiChatBillingUsage(snapshot).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, decodedUsage.PromptTokensDetails.AudioTokens, streamUsage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 25, decodedUsage.PromptTokensDetails.AudioTokens)
|
||||
assert.Equal(t, 25, streamUsage.PromptTokensDetails.AudioTokens)
|
||||
}
|
||||
|
||||
func TestMergeGeminiUsageMetadataStreamDetailsAreCumulativeSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
prompt := []GeminiPromptTokensDetails{
|
||||
{Modality: "IMAGE", TokenCount: 1089},
|
||||
{Modality: "TEXT", TokenCount: 42},
|
||||
}
|
||||
frames := []*GeminiUsageMetadata{
|
||||
{PromptTokenCount: 1131, TotalTokenCount: 1131, PromptTokensDetails: prompt},
|
||||
{
|
||||
PromptTokenCount: 1131,
|
||||
ToolUsePromptTokenCount: 7,
|
||||
CandidatesTokenCount: 5,
|
||||
TotalTokenCount: 1143,
|
||||
PromptTokensDetails: prompt,
|
||||
ToolUsePromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}},
|
||||
CandidatesTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 5}},
|
||||
},
|
||||
{
|
||||
PromptTokenCount: 1131,
|
||||
ToolUsePromptTokenCount: 7,
|
||||
CandidatesTokenCount: 1310,
|
||||
ThoughtsTokenCount: 76,
|
||||
TotalTokenCount: 2524,
|
||||
PromptTokensDetails: prompt,
|
||||
ToolUsePromptTokensDetails: []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}},
|
||||
CandidatesTokensDetails: []GeminiPromptTokensDetails{
|
||||
{Modality: "TEXT", TokenCount: 20},
|
||||
{Modality: "IMAGE", TokenCount: 1290},
|
||||
},
|
||||
},
|
||||
// A trailing frame without details keeps the last reported ones.
|
||||
{PromptTokenCount: 1131, CandidatesTokenCount: 1310, ThoughtsTokenCount: 76, TotalTokenCount: 2524},
|
||||
}
|
||||
var merged *GeminiUsageMetadata
|
||||
for _, frame := range frames {
|
||||
merged = MergeGeminiUsageMetadataNonZero(merged, frame)
|
||||
}
|
||||
require.NotNil(t, merged)
|
||||
assert.Equal(t, prompt, merged.PromptTokensDetails)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{{Modality: "TEXT", TokenCount: 7}}, merged.ToolUsePromptTokensDetails)
|
||||
assert.Equal(t, []GeminiPromptTokensDetails{
|
||||
{Modality: "TEXT", TokenCount: 20},
|
||||
{Modality: "IMAGE", TokenCount: 1290},
|
||||
}, merged.CandidatesTokensDetails)
|
||||
|
||||
streamUsage, ok := NewGeminiChatBillingUsage(merged).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
nonStreamUsage, ok := NewGeminiChatBillingUsage(frames[2]).CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, nonStreamUsage, streamUsage)
|
||||
assert.Equal(t, 1089, streamUsage.PromptTokensDetails.ImageTokens)
|
||||
assert.Equal(t, 1290, streamUsage.CompletionTokenDetails.ImageTokens)
|
||||
|
||||
// Converted streams remember the same cumulative sidecar at several
|
||||
// pipeline steps; repeated merges must not change the result.
|
||||
sidecar := NewGeminiChatBillingUsage(merged)
|
||||
again := MergeBillingUsageNonZero(MergeBillingUsageNonZero(sidecar, sidecar), sidecar)
|
||||
repeatedUsage, ok := again.CanonicalUsage()
|
||||
require.True(t, ok)
|
||||
assert.Equal(t, nonStreamUsage, repeatedUsage)
|
||||
}
|
||||
|
||||
func TestClaudeCacheCreationSubObjectZeroDoesNotReviveFlatLegacyFields(t *testing.T) {
|
||||
@@ -172,8 +230,8 @@ func TestMergeClaudeUsageNonZeroPreservesBillingUsage(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
currentSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
PromptTokenCount: 3868,
|
||||
TotalTokenCount: 3868,
|
||||
PromptTokenCount: 3868,
|
||||
TotalTokenCount: 3868,
|
||||
CachedContentTokenCount: 20,
|
||||
})
|
||||
incomingSidecar := NewGeminiChatBillingUsage(&GeminiUsageMetadata{
|
||||
|
||||
@@ -339,7 +339,7 @@ func OpenAIChatRequestToGeminiGenerateContent(c context.Context, textRequest dto
|
||||
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
|
||||
}
|
||||
|
||||
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
|
||||
if !sharedgemini.IsSupportedMimeType(mimeType) {
|
||||
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
|
||||
}
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ package oairesponses
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"context"
|
||||
@@ -13,6 +14,7 @@ import (
|
||||
sharedgemini "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/shared/gemini"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/reasoning"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIResponsesRequest, info convmeta.Meta) (*dto.GeminiChatRequest, error) {
|
||||
@@ -121,6 +123,11 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Multimodal function responses (media in functionResponse.parts) are
|
||||
// documented for Gemini 3. Gemini 1.x and 2.x get tool media as ordinary
|
||||
// inlineData parts after the function responses of the same turn.
|
||||
modelID := strings.TrimPrefix(strings.ToLower(upstreamModelName), "models/")
|
||||
toolMediaInFunctionResponse := !strings.HasPrefix(modelID, "gemini-1.") && !strings.HasPrefix(modelID, "gemini-2.")
|
||||
callNames := make(map[string]string)
|
||||
for _, item := range inputItems {
|
||||
itemType := strings.TrimSpace(kitutil.Interface2String(item["type"]))
|
||||
@@ -136,11 +143,13 @@ func OpenAIResponsesRequestToGeminiChat(c context.Context, req *dto.OpenAIRespon
|
||||
}
|
||||
appendGeminiContentPart(geminiRequest, "model", part)
|
||||
case ResponsesInputTypeFunctionCallOutput, ResponsesInputTypeCustomToolOutput:
|
||||
part, err := responsesFunctionOutputItemToGeminiPart(item, callNames)
|
||||
part, media, err := responsesFunctionOutputItemToGeminiPart(c, item, callNames, toolMediaInFunctionResponse)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
appendGeminiContentPart(geminiRequest, "user", part)
|
||||
turn := &geminiRequest.Contents[len(geminiRequest.Contents)-1]
|
||||
turn.Parts = append(turn.Parts, media...)
|
||||
default:
|
||||
role := responsesGeminiRole(item)
|
||||
parts, err := responsesInputContentToGeminiParts(c, item["content"])
|
||||
@@ -230,7 +239,7 @@ func responsesContentPartToGeminiParts(c context.Context, part map[string]any) (
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("get file data from '%s' failed: %w", source.GetIdentifier(), err)
|
||||
}
|
||||
if _, ok := sharedgemini.SupportedMimeTypes[strings.ToLower(mimeType)]; !ok {
|
||||
if !sharedgemini.IsSupportedMimeType(mimeType) {
|
||||
return nil, fmt.Errorf("mime type is not supported by Gemini: '%s', url: '%s', supported types are: %v", mimeType, source.GetIdentifier(), sharedgemini.SupportedMimeTypesList())
|
||||
}
|
||||
return []dto.GeminiPart{
|
||||
@@ -269,43 +278,145 @@ func responsesFunctionCallItemToGeminiPart(item map[string]any, itemType string)
|
||||
}, callID, nil
|
||||
}
|
||||
|
||||
func responsesFunctionOutputItemToGeminiPart(item map[string]any, callNames map[string]string) (dto.GeminiPart, error) {
|
||||
// responsesFunctionOutputItemToGeminiPart returns the functionResponse part
|
||||
// and, unless mediaInParts puts them into functionResponse.parts, the tool
|
||||
// media the caller sends as separate parts after the turn's function
|
||||
// responses.
|
||||
func responsesFunctionOutputItemToGeminiPart(c context.Context, item map[string]any, callNames map[string]string, mediaInParts bool) (dto.GeminiPart, []dto.GeminiPart, error) {
|
||||
callID := CallID(item)
|
||||
name := strings.TrimSpace(kitutil.Interface2String(item["name"]))
|
||||
if name == "" {
|
||||
name = callNames[callID]
|
||||
}
|
||||
output, media := responsesToolOutputToGemini(c, item["output"], mediaInParts)
|
||||
response := &dto.GeminiFunctionResponse{
|
||||
Name: name,
|
||||
Response: GeminiResponseMap(item["output"]),
|
||||
Response: output,
|
||||
}
|
||||
if mediaInParts && len(media) > 0 {
|
||||
parts, err := kitutil.Marshal(media)
|
||||
if err != nil {
|
||||
return dto.GeminiPart{}, nil, fmt.Errorf("failed to marshal function response parts: %w", err)
|
||||
}
|
||||
response.Parts = parts
|
||||
media = nil
|
||||
}
|
||||
if callID != "" {
|
||||
id, err := kitutil.Marshal(callID)
|
||||
if err != nil {
|
||||
return dto.GeminiPart{}, fmt.Errorf("failed to marshal function response ID: %w", err)
|
||||
return dto.GeminiPart{}, nil, fmt.Errorf("failed to marshal function response ID: %w", err)
|
||||
}
|
||||
response.ID = id
|
||||
}
|
||||
return dto.GeminiPart{
|
||||
FunctionResponse: response,
|
||||
}, nil
|
||||
}, media, nil
|
||||
}
|
||||
|
||||
// responsesToolOutputToGemini maps a function_call_output payload onto a Gemini
|
||||
// functionResponse. A Responses content-part array keeps its text in response
|
||||
// and returns its media as inlineData parts; stringifying them instead would
|
||||
// hand base64 media to the upstream text tokenizer. Multimodal function
|
||||
// responses (mediaInParts) carry images and documents only; separate parts
|
||||
// carry every type Gemini accepts. Media that cannot be loaded or carried is
|
||||
// omitted with a diagnostic. An array holding any other part type, and every
|
||||
// other payload shape, keeps the historical response map.
|
||||
func responsesToolOutputToGemini(c context.Context, value any, mediaInParts bool) (map[string]any, []dto.GeminiPart) {
|
||||
rawParts, ok := value.([]any)
|
||||
if !ok || len(rawParts) == 0 {
|
||||
return GeminiResponseMap(value), nil
|
||||
}
|
||||
parts := make([]map[string]any, 0, len(rawParts))
|
||||
for _, rawPart := range rawParts {
|
||||
part, isMap := rawPart.(map[string]any)
|
||||
if !isMap {
|
||||
return GeminiResponseMap(value), nil
|
||||
}
|
||||
switch strings.TrimSpace(kitutil.Interface2String(part["type"])) {
|
||||
case "input_text", "output_text", "text", "input_image", "input_file", "input_audio", "input_video":
|
||||
default:
|
||||
return GeminiResponseMap(value), nil
|
||||
}
|
||||
parts = append(parts, part)
|
||||
}
|
||||
|
||||
texts := make([]string, 0, len(parts))
|
||||
labels := make([]string, 0, len(parts))
|
||||
media := make([]dto.GeminiPart, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
partType := strings.TrimSpace(kitutil.Interface2String(part["type"]))
|
||||
switch partType {
|
||||
case "input_text", "output_text", "text":
|
||||
if text := kitutil.Interface2String(part["text"]); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
continue
|
||||
}
|
||||
kind := strings.TrimPrefix(partType, "input_")
|
||||
if label := "[" + kind + "]"; !slices.Contains(labels, label) {
|
||||
labels = append(labels, label)
|
||||
}
|
||||
source := ContentPartToFileSource(part)
|
||||
if source == nil {
|
||||
continue
|
||||
}
|
||||
// The documented MIME list for multimodal function responses has no
|
||||
// audio or video, and Gemini 3 ignores audio sent there.
|
||||
reason := fmt.Sprintf("Gemini function responses cannot carry %s content", kind)
|
||||
if !mediaInParts || kind == "image" || kind == "file" {
|
||||
base64Data, mimeType, err := relaymedia.ResolveBase64Data(c, source, "formatting Responses tool output for Gemini")
|
||||
topLevelType, _, _ := strings.Cut(strings.ToLower(mimeType), "/")
|
||||
switch {
|
||||
case err != nil:
|
||||
reason = fmt.Sprintf("the %s could not be loaded", kind)
|
||||
case sharedgemini.IsSupportedMimeType(mimeType) && (!mediaInParts || topLevelType != "audio" && topLevelType != "video"):
|
||||
media = append(media, dto.GeminiPart{InlineData: &dto.GeminiInlineData{MimeType: mimeType, Data: base64Data}})
|
||||
continue
|
||||
default:
|
||||
reason = fmt.Sprintf("Gemini function responses cannot carry %s content", mimeType)
|
||||
}
|
||||
}
|
||||
convdiag.Add(c, types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: "input.output",
|
||||
Message: reason + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
})
|
||||
}
|
||||
|
||||
switch {
|
||||
case len(texts) > 0:
|
||||
return GeminiResponseMap(strings.Join(texts, "\n")), media
|
||||
case len(media) > 0:
|
||||
return map[string]any{}, media
|
||||
default:
|
||||
// Every media part was omitted; name what was there instead of
|
||||
// sending the payload as text.
|
||||
return GeminiResponseMap(strings.Join(labels, " ")), nil
|
||||
}
|
||||
}
|
||||
|
||||
func appendGeminiContentPart(req *dto.GeminiChatRequest, role string, part dto.GeminiPart) {
|
||||
if len(req.Contents) > 0 && req.Contents[len(req.Contents)-1].Role == role {
|
||||
if role == "model" && part.FunctionCall != nil {
|
||||
parts := req.Contents[len(req.Contents)-1].Parts
|
||||
insertAt := 0
|
||||
parts := req.Contents[len(req.Contents)-1].Parts
|
||||
insertAt := len(parts)
|
||||
switch {
|
||||
case role == "model" && part.FunctionCall != nil:
|
||||
insertAt = 0
|
||||
for insertAt < len(parts) && parts[insertAt].FunctionCall != nil {
|
||||
insertAt++
|
||||
}
|
||||
parts = append(parts, dto.GeminiPart{})
|
||||
copy(parts[insertAt+1:], parts[insertAt:])
|
||||
parts[insertAt] = part
|
||||
req.Contents[len(req.Contents)-1].Parts = parts
|
||||
return
|
||||
case part.FunctionResponse != nil:
|
||||
// Tool media sent as separate parts stay after every function
|
||||
// response of the turn, so a later response goes before them.
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
if parts[i].FunctionResponse != nil {
|
||||
insertAt = i + 1
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
req.Contents[len(req.Contents)-1].Parts = append(req.Contents[len(req.Contents)-1].Parts, part)
|
||||
req.Contents[len(req.Contents)-1].Parts = slices.Insert(parts, insertAt, part)
|
||||
return
|
||||
}
|
||||
req.Contents = append(req.Contents, dto.GeminiChatContent{
|
||||
|
||||
@@ -3,10 +3,16 @@ package oairesponses
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/QuantumNous/new-api/relaykit/dto"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/convmeta"
|
||||
"github.com/QuantumNous/new-api/relaykit/relayconvert/internal/convdiag"
|
||||
relaymedia "github.com/QuantumNous/new-api/relaykit/relayconvert/internal/media"
|
||||
kitutil "github.com/QuantumNous/new-api/relaykit/relayconvert/kitutil"
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
"github.com/samber/lo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
@@ -538,6 +544,340 @@ func TestResponsesRequestToChatCompletionsRequestHoistsToolOutputMediaAfterToolB
|
||||
})
|
||||
}
|
||||
|
||||
// useDataURLMediaResolver installs a media resolver that decodes data URLs,
|
||||
// returns raw base64 with its declared MIME type, and refuses every remote
|
||||
// URL, as the host resolver does for a blocked download.
|
||||
func useDataURLMediaResolver(t *testing.T) {
|
||||
t.Helper()
|
||||
relaymedia.SetMediaResolver(relaymedia.MediaResolver{
|
||||
GetBase64Data: func(_ context.Context, source types.FileSource, _ ...string) (string, string, error) {
|
||||
if source.IsURL() {
|
||||
return "", "", errors.New("download blocked")
|
||||
}
|
||||
header, data, isDataURL := strings.Cut(source.GetRawData(), ",")
|
||||
if !isDataURL {
|
||||
return source.GetRawData(), source.(*types.Base64Source).MimeType, nil
|
||||
}
|
||||
return data, strings.TrimSuffix(strings.TrimPrefix(header, "data:"), ";base64"), nil
|
||||
},
|
||||
})
|
||||
t.Cleanup(func() { relaymedia.SetMediaResolver(relaymedia.MediaResolver{}) })
|
||||
}
|
||||
|
||||
func omittedToolMedia(message string) types.ConversionDiagnostic {
|
||||
return types.ConversionDiagnostic{
|
||||
Code: "unsupported_media_type",
|
||||
Path: "input.output",
|
||||
Message: message + "; the part was omitted",
|
||||
Severity: types.ConversionDiagnosticError,
|
||||
}
|
||||
}
|
||||
|
||||
// toolOutputGeminiTurn converts one call and its output and returns the user
|
||||
// turn that carries the function response.
|
||||
func toolOutputGeminiTurn(t *testing.T, ctx context.Context, model string, outputType string, output any) dto.GeminiChatContent {
|
||||
t.Helper()
|
||||
call := map[string]any{"type": "function_call", "call_id": "call_1", "name": "view", "arguments": "{}"}
|
||||
if outputType == "custom_tool_call_output" {
|
||||
call = map[string]any{"type": "custom_tool_call", "call_id": "call_1", "name": "view", "input": "screen"}
|
||||
}
|
||||
got, err := OpenAIResponsesRequestToGeminiChat(ctx, &dto.OpenAIResponsesRequest{
|
||||
Model: model,
|
||||
Input: mustRawMessage(t, []map[string]any{
|
||||
call,
|
||||
{"type": outputType, "call_id": "call_1", "output": output},
|
||||
}),
|
||||
}, &convmeta.Values{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got.Contents, 2)
|
||||
assert.Equal(t, "user", got.Contents[1].Role)
|
||||
require.NotEmpty(t, got.Contents[1].Parts)
|
||||
response := got.Contents[1].Parts[0].FunctionResponse
|
||||
require.NotNil(t, response)
|
||||
assert.Equal(t, "view", response.Name)
|
||||
assert.JSONEq(t, `"call_1"`, string(response.ID))
|
||||
return got.Contents[1]
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesRequestToGeminiChatToolOutputContentParts(t *testing.T) {
|
||||
useDataURLMediaResolver(t)
|
||||
imagePart := map[string]any{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
output any
|
||||
wantResponse string
|
||||
wantParts string
|
||||
wantDiagnostics []types.ConversionDiagnostic
|
||||
}{
|
||||
{
|
||||
name: "text and image",
|
||||
output: []any{map[string]any{"type": "input_text", "text": "screenshot captured"}, imagePart},
|
||||
wantResponse: `{"content":"screenshot captured"}`,
|
||||
wantParts: `[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgo="}}]`,
|
||||
},
|
||||
{
|
||||
name: "text and pdf file",
|
||||
output: []any{
|
||||
map[string]any{"type": "input_text", "text": "report attached"},
|
||||
map[string]any{"type": "input_file", "filename": "report.pdf", "file_data": "data:application/pdf;base64,JVBERi0xLjQ="},
|
||||
},
|
||||
wantResponse: `{"content":"report attached"}`,
|
||||
wantParts: `[{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0xLjQ="}}]`,
|
||||
},
|
||||
{
|
||||
name: "image only leaves an empty response object",
|
||||
output: []any{imagePart},
|
||||
wantResponse: `{}`,
|
||||
wantParts: `[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgo="}}]`,
|
||||
},
|
||||
{
|
||||
name: "unsupported and unloadable media are omitted",
|
||||
output: []any{
|
||||
map[string]any{"type": "input_text", "text": "two files"},
|
||||
map[string]any{"type": "input_file", "file_data": "data:application/zip;base64,UEsDBA=="},
|
||||
map[string]any{"type": "input_image", "image_url": "https://example.com/screen.png"},
|
||||
imagePart,
|
||||
},
|
||||
wantResponse: `{"content":"two files"}`,
|
||||
wantParts: `[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgo="}}]`,
|
||||
wantDiagnostics: []types.ConversionDiagnostic{
|
||||
omittedToolMedia("Gemini function responses cannot carry application/zip content"),
|
||||
omittedToolMedia("the image could not be loaded"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "all media omitted names the parts",
|
||||
output: []any{
|
||||
map[string]any{"type": "input_audio", "input_audio": map[string]any{"data": "UklGRg==", "format": "wav"}},
|
||||
map[string]any{"type": "input_file", "file_data": "data:video/mp4;base64,AAAA"},
|
||||
},
|
||||
wantResponse: `{"content":"[audio] [file]"}`,
|
||||
wantDiagnostics: []types.ConversionDiagnostic{
|
||||
omittedToolMedia("Gemini function responses cannot carry audio content"),
|
||||
omittedToolMedia("Gemini function responses cannot carry video/mp4 content"),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "text parts join with newline",
|
||||
output: []any{
|
||||
map[string]any{"type": "input_text", "text": "first"},
|
||||
map[string]any{"type": "output_text", "text": ""},
|
||||
map[string]any{"type": "text", "text": "second"},
|
||||
},
|
||||
wantResponse: `{"content":"first\nsecond"}`,
|
||||
},
|
||||
{
|
||||
name: "string is unchanged",
|
||||
output: "15 degrees",
|
||||
wantResponse: `{"content":"15 degrees"}`,
|
||||
},
|
||||
{
|
||||
name: "object is unchanged",
|
||||
output: map[string]any{"ok": true},
|
||||
wantResponse: `{"ok":true}`,
|
||||
},
|
||||
{
|
||||
name: "plain array is unchanged",
|
||||
output: []any{1, 2},
|
||||
wantResponse: `{"result":[1,2]}`,
|
||||
},
|
||||
{
|
||||
name: "unknown part keeps the whole array",
|
||||
output: []any{imagePart, map[string]any{"type": "refusal", "refusal": "no"}},
|
||||
wantResponse: `{"result":[{"type":"input_image","image_url":"data:image/png;base64,iVBORw0KGgo="},{"type":"refusal","refusal":"no"}]}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, outputType := range []string{"function_call_output", "custom_tool_call_output"} {
|
||||
t.Run(tt.name+"/"+outputType, func(t *testing.T) {
|
||||
ctx, collector := convdiag.WithCollector(context.Background())
|
||||
turn := toolOutputGeminiTurn(t, ctx, "gemini-test", outputType, tt.output)
|
||||
|
||||
require.Len(t, turn.Parts, 1)
|
||||
response := turn.Parts[0].FunctionResponse
|
||||
gotResponse, err := kitutil.Marshal(response.Response)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, tt.wantResponse, string(gotResponse))
|
||||
if tt.wantParts == "" {
|
||||
assert.Empty(t, response.Parts)
|
||||
} else {
|
||||
assert.JSONEq(t, tt.wantParts, string(response.Parts))
|
||||
}
|
||||
assert.Equal(t, tt.wantDiagnostics, collector.Diagnostics())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Gemini 1.x and 2.x do not document multimodal function responses; their
|
||||
// tool media go into the same user turn as ordinary parts.
|
||||
func TestOpenAIResponsesRequestToGeminiChatToolOutputMediaOnPreGemini3(t *testing.T) {
|
||||
useDataURLMediaResolver(t)
|
||||
imagePart := map[string]any{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
output any
|
||||
wantTurn string
|
||||
wantDiagnostics []types.ConversionDiagnostic
|
||||
}{
|
||||
{
|
||||
name: "text and image",
|
||||
model: "gemini-2.5-flash",
|
||||
output: []any{map[string]any{"type": "input_text", "text": "screenshot captured"}, imagePart},
|
||||
wantTurn: `[
|
||||
{"functionResponse":{"name":"view","response":{"content":"screenshot captured"},"id":"call_1"}},
|
||||
{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgo="}}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "pdf only on a models/ prefixed 1.5 id",
|
||||
model: "models/gemini-1.5-pro",
|
||||
output: []any{
|
||||
map[string]any{"type": "input_file", "file_data": "data:application/pdf;base64,JVBERi0xLjQ="},
|
||||
},
|
||||
wantTurn: `[
|
||||
{"functionResponse":{"name":"view","response":{},"id":"call_1"}},
|
||||
{"inlineData":{"mimeType":"application/pdf","data":"JVBERi0xLjQ="}}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "audio and video are carried as ordinary parts",
|
||||
model: "gemini-2.0-flash",
|
||||
output: []any{
|
||||
map[string]any{"type": "input_audio", "input_audio": map[string]any{"data": "UklGRg==", "format": "wav"}},
|
||||
map[string]any{"type": "input_file", "file_data": "data:video/mp4;base64,AAAA"},
|
||||
map[string]any{"type": "input_file", "file_data": "data:application/zip;base64,UEsDBA=="},
|
||||
},
|
||||
wantTurn: `[
|
||||
{"functionResponse":{"name":"view","response":{},"id":"call_1"}},
|
||||
{"inlineData":{"mimeType":"audio/wav","data":"UklGRg=="}},
|
||||
{"inlineData":{"mimeType":"video/mp4","data":"AAAA"}}
|
||||
]`,
|
||||
wantDiagnostics: []types.ConversionDiagnostic{
|
||||
omittedToolMedia("Gemini function responses cannot carry application/zip content"),
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for _, outputType := range []string{"function_call_output", "custom_tool_call_output"} {
|
||||
t.Run(tt.name+"/"+outputType, func(t *testing.T) {
|
||||
ctx, collector := convdiag.WithCollector(context.Background())
|
||||
turn := toolOutputGeminiTurn(t, ctx, tt.model, outputType, tt.output)
|
||||
|
||||
gotTurn, err := kitutil.Marshal(turn.Parts)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, tt.wantTurn, string(gotTurn))
|
||||
assert.Equal(t, tt.wantDiagnostics, collector.Diagnostics())
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The Gemini Blob reference lists GIF, AVIF, audio/*, video/* and text
|
||||
// documents as inline data; ordinary messages carrying them must convert.
|
||||
func TestOpenAIResponsesRequestToGeminiChatInlineMimeTypes(t *testing.T) {
|
||||
useDataURLMediaResolver(t)
|
||||
|
||||
for _, mimeType := range []string{"image/gif", "image/avif", "audio/ogg", "video/webm", "text/markdown"} {
|
||||
t.Run(mimeType, func(t *testing.T) {
|
||||
got, err := OpenAIResponsesRequestToGeminiChat(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustRawMessage(t, []map[string]any{{"role": "user", "content": []any{
|
||||
map[string]any{"type": "input_file", "file_data": "data:" + mimeType + ";base64,AAAA"},
|
||||
}}}),
|
||||
}, &convmeta.Values{})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, got.Contents, 1)
|
||||
assert.Equal(t, []dto.GeminiPart{{InlineData: &dto.GeminiInlineData{MimeType: mimeType, Data: "AAAA"}}}, got.Contents[0].Parts)
|
||||
})
|
||||
}
|
||||
|
||||
_, err := OpenAIResponsesRequestToGeminiChat(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-test",
|
||||
Input: mustRawMessage(t, []map[string]any{{"role": "user", "content": []any{
|
||||
map[string]any{"type": "input_file", "file_data": "data:application/zip;base64,UEsDBA=="},
|
||||
}}}),
|
||||
}, &convmeta.Values{})
|
||||
require.ErrorContains(t, err, "mime type is not supported by Gemini: 'application/zip'")
|
||||
}
|
||||
|
||||
func TestOpenAIResponsesRequestToGeminiChatKeepsParallelToolOutputsInOrder(t *testing.T) {
|
||||
useDataURLMediaResolver(t)
|
||||
input := []map[string]any{
|
||||
{"type": "function_call", "call_id": "call_1", "name": "screenshot", "arguments": "{}"},
|
||||
{"type": "function_call", "call_id": "call_2", "name": "read_file", "arguments": `{"path":"note.txt"}`},
|
||||
{"type": "function_call", "call_id": "call_3", "name": "logo", "arguments": "{}"},
|
||||
{"type": "function_call_output", "call_id": "call_1", "output": []any{
|
||||
map[string]any{"type": "input_text", "text": "screenshot captured"},
|
||||
map[string]any{"type": "input_image", "image_url": "data:image/png;base64,iVBORw0KGgo="},
|
||||
}},
|
||||
{"type": "function_call_output", "call_id": "call_2", "output": "file contents"},
|
||||
{"type": "function_call_output", "call_id": "call_3", "output": []any{
|
||||
map[string]any{"type": "input_image", "image_url": "data:image/webp;base64,UklGRg=="},
|
||||
}},
|
||||
{"role": "user", "content": "what now?"},
|
||||
}
|
||||
calls := `{"role":"model","parts":[
|
||||
{"functionCall":{"id":"call_1","name":"screenshot","args":{}}},
|
||||
{"functionCall":{"id":"call_2","name":"read_file","args":{"path":"note.txt"}}},
|
||||
{"functionCall":{"id":"call_3","name":"logo","args":{}}}
|
||||
]}`
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
info *convmeta.Values
|
||||
wantContents string
|
||||
}{
|
||||
{
|
||||
name: "media inside function responses",
|
||||
info: &convmeta.Values{},
|
||||
wantContents: `[` + calls + `,
|
||||
{"role":"user","parts":[
|
||||
{"functionResponse":{"name":"screenshot","response":{"content":"screenshot captured"},
|
||||
"parts":[{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgo="}}],"id":"call_1"}},
|
||||
{"functionResponse":{"name":"read_file","response":{"content":"file contents"},"id":"call_2"}},
|
||||
{"functionResponse":{"name":"logo","response":{},
|
||||
"parts":[{"inlineData":{"mimeType":"image/webp","data":"UklGRg=="}}],"id":"call_3"}}
|
||||
]},
|
||||
{"role":"user","parts":[{"text":"what now?"}]}
|
||||
]`,
|
||||
},
|
||||
{
|
||||
name: "media after all function responses on the mapped 2.5 upstream",
|
||||
info: &convmeta.Values{ChannelMetaAttached: true, UpstreamModelName: "gemini-2.5-flash"},
|
||||
wantContents: `[` + calls + `,
|
||||
{"role":"user","parts":[
|
||||
{"functionResponse":{"name":"screenshot","response":{"content":"screenshot captured"},"id":"call_1"}},
|
||||
{"functionResponse":{"name":"read_file","response":{"content":"file contents"},"id":"call_2"}},
|
||||
{"functionResponse":{"name":"logo","response":{},"id":"call_3"}},
|
||||
{"inlineData":{"mimeType":"image/png","data":"iVBORw0KGgo="}},
|
||||
{"inlineData":{"mimeType":"image/webp","data":"UklGRg=="}}
|
||||
]},
|
||||
{"role":"user","parts":[{"text":"what now?"}]}
|
||||
]`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := OpenAIResponsesRequestToGeminiChat(context.Background(), &dto.OpenAIResponsesRequest{
|
||||
Model: "gemini-client-alias",
|
||||
Input: mustRawMessage(t, input),
|
||||
}, tt.info)
|
||||
require.NoError(t, err)
|
||||
|
||||
gotContents, err := kitutil.Marshal(got.Contents)
|
||||
require.NoError(t, err)
|
||||
assert.JSONEq(t, tt.wantContents, string(gotContents))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func mustRawMessage(t *testing.T, value any) []byte {
|
||||
t.Helper()
|
||||
raw, err := kitutil.Marshal(value)
|
||||
|
||||
@@ -44,7 +44,7 @@ func ApplyReasoning(ctx context.Context, req *dto.ClaudeRequest, info convmeta.M
|
||||
case req.Thinking.BudgetTokens != nil:
|
||||
effort = string(reasoning.EffortFromBudget(*req.Thinking.BudgetTokens))
|
||||
case req.Thinking.Type == "enabled" || req.Thinking.Type == "adaptive":
|
||||
effort = string(reasoning.EffortHigh)
|
||||
effort = string(reasoning.ClaudeDefaultEffort(req.Model))
|
||||
}
|
||||
}
|
||||
info.SetReasoningEffort(effort)
|
||||
|
||||
@@ -3,6 +3,8 @@ package gemini
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"maps"
|
||||
"slices"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -13,26 +15,43 @@ import (
|
||||
"github.com/QuantumNous/new-api/relaykit/types"
|
||||
)
|
||||
|
||||
var SupportedMimeTypes = map[string]bool{
|
||||
"application/pdf": true,
|
||||
"audio/mpeg": true,
|
||||
"audio/mp3": true,
|
||||
"audio/wav": true,
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/jpg": true,
|
||||
"image/webp": true,
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
"text/plain": true,
|
||||
"video/mov": true,
|
||||
"video/mpeg": true,
|
||||
"video/mp4": true,
|
||||
"video/mpg": true,
|
||||
"video/avi": true,
|
||||
"video/wmv": true,
|
||||
"video/mpegps": true,
|
||||
"video/flv": true,
|
||||
// supportedMimeTypes follows the Blob.mimeType list in the Gemini API
|
||||
// reference (https://ai.google.dev/api/generate-content#Blob). Audio and
|
||||
// video are listed there as audio/* and video/*; IsSupportedMimeType accepts
|
||||
// them by prefix.
|
||||
var supportedMimeTypes = map[string]bool{
|
||||
"image/png": true,
|
||||
"image/jpeg": true,
|
||||
"image/jpg": true,
|
||||
"image/webp": true,
|
||||
"image/heic": true,
|
||||
"image/heif": true,
|
||||
"image/gif": true,
|
||||
"image/avif": true,
|
||||
"text/plain": true,
|
||||
"text/html": true,
|
||||
"text/css": true,
|
||||
"text/javascript": true,
|
||||
"text/x-typescript": true,
|
||||
"text/csv": true,
|
||||
"text/markdown": true,
|
||||
"text/x-python": true,
|
||||
"text/xml": true,
|
||||
"text/rtf": true,
|
||||
"application/x-javascript": true,
|
||||
"application/x-typescript": true,
|
||||
"application/x-python-code": true,
|
||||
"application/json": true,
|
||||
"application/x-ipynb+json": true,
|
||||
"application/rtf": true,
|
||||
"application/pdf": true,
|
||||
}
|
||||
|
||||
// IsSupportedMimeType reports whether Gemini accepts inline data of the MIME
|
||||
// type.
|
||||
func IsSupportedMimeType(mimeType string) bool {
|
||||
mimeType = strings.ToLower(mimeType)
|
||||
return supportedMimeTypes[mimeType] || strings.HasPrefix(mimeType, "audio/") || strings.HasPrefix(mimeType, "video/")
|
||||
}
|
||||
|
||||
var SafetySettingCategories = []string{
|
||||
@@ -272,9 +291,5 @@ func HasFunctionCallContent(call *dto.FunctionCall) bool {
|
||||
}
|
||||
|
||||
func SupportedMimeTypesList() []string {
|
||||
keys := make([]string, 0, len(SupportedMimeTypes))
|
||||
for key := range SupportedMimeTypes {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
return keys
|
||||
return append(slices.Sorted(maps.Keys(supportedMimeTypes)), "audio/*", "video/*")
|
||||
}
|
||||
|
||||
@@ -30,11 +30,15 @@ type claudeCapabilities struct {
|
||||
supportsXHigh bool
|
||||
supportsMax bool
|
||||
strictSampling bool
|
||||
// defaultEffort is the effort the model runs at when a request sets none:
|
||||
// medium on Claude Opus 5.5 and high on every other model
|
||||
// (https://platform.claude.com/docs/en/build-with-claude/effort).
|
||||
defaultEffort Effort
|
||||
}
|
||||
|
||||
func claudeCapabilitiesFor(model string) claudeCapabilities {
|
||||
model = strings.ToLower(model)
|
||||
capabilities := claudeCapabilities{supportsManual: true, supportsDisable: true}
|
||||
capabilities := claudeCapabilities{supportsManual: true, supportsDisable: true, defaultEffort: EffortHigh}
|
||||
|
||||
switch {
|
||||
case strings.HasPrefix(model, "claude-fable-5"),
|
||||
@@ -61,6 +65,9 @@ func claudeCapabilitiesFor(model string) claudeCapabilities {
|
||||
if strings.HasPrefix(model, "claude-opus-5") || strings.HasPrefix(model, "claude-sonnet-5") {
|
||||
capabilities.defaultThinking = true
|
||||
}
|
||||
if strings.HasPrefix(model, "claude-opus-5-5") {
|
||||
capabilities.defaultEffort = EffortMedium
|
||||
}
|
||||
capabilities.supportsEffort = true
|
||||
capabilities.supportsXHigh = true
|
||||
capabilities.supportsMax = true
|
||||
@@ -113,12 +120,12 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
}
|
||||
return ClaudeRender{
|
||||
Thinking: thinking,
|
||||
EffectiveEffort: EffortHigh,
|
||||
EffectiveEffort: capabilities.defaultEffort,
|
||||
ClearSampling: capabilities.strictSampling,
|
||||
}, nil
|
||||
}
|
||||
if capabilities.defaultThinking {
|
||||
return ClaudeRender{EffectiveEffort: EffortHigh, ClearSampling: capabilities.strictSampling}, nil
|
||||
return ClaudeRender{EffectiveEffort: capabilities.defaultEffort, ClearSampling: capabilities.strictSampling}, nil
|
||||
}
|
||||
return ClaudeRender{ClearSampling: capabilities.strictSampling}, nil
|
||||
}
|
||||
@@ -139,7 +146,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
}
|
||||
}
|
||||
outputEffort := Effort("")
|
||||
effectiveEffort := EffortHigh
|
||||
effectiveEffort := capabilities.defaultEffort
|
||||
if capabilities.supportsEffort {
|
||||
outputEffort = EffortLow
|
||||
effectiveEffort = EffortLow
|
||||
@@ -175,7 +182,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
effort = EffortFromBudget(*intent.BudgetTokens)
|
||||
}
|
||||
if effort == "" && intent.Mode == ModeEnabled {
|
||||
effort = EffortHigh
|
||||
effort = capabilities.defaultEffort
|
||||
}
|
||||
normalizedEffort := normalizeClaudeEffort(effort, capabilities)
|
||||
if effort != "" && normalizedEffort != effort {
|
||||
@@ -187,7 +194,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
effort = normalizedEffort
|
||||
effectiveEffort := effort
|
||||
if effectiveEffort == "" && intent.Mode == ModeAdaptive {
|
||||
effectiveEffort = EffortHigh
|
||||
effectiveEffort = capabilities.defaultEffort
|
||||
}
|
||||
|
||||
// Claude effort can be used without enabling thinking. Preserve that
|
||||
@@ -227,7 +234,7 @@ func RenderClaude(model string, intent Intent, maxTokens *uint, adapterBudgetPer
|
||||
))
|
||||
intent.Mode = ModeEnabled
|
||||
if intent.Effort == "" {
|
||||
intent.Effort = EffortHigh
|
||||
intent.Effort = capabilities.defaultEffort
|
||||
}
|
||||
}
|
||||
if intent.Mode == ModeUnset {
|
||||
@@ -333,12 +340,28 @@ func IsKnownClaudeModel(model string) bool {
|
||||
return isKnownClaudeModel(model)
|
||||
}
|
||||
|
||||
// ClaudeDefaultEffort is the effort the model runs at when a request sets
|
||||
// none.
|
||||
func ClaudeDefaultEffort(model string) Effort {
|
||||
return claudeCapabilitiesFor(model).defaultEffort
|
||||
}
|
||||
|
||||
// ResolveClaudeDefault makes the Claude model's default thinking explicit for
|
||||
// a non-Claude target: no intent becomes adaptive thinking on models that
|
||||
// think by default, and thinking without a strength takes the model's
|
||||
// default effort.
|
||||
func ResolveClaudeDefault(model string, intent Intent) Intent {
|
||||
if intent.HasStrength() || !claudeCapabilitiesFor(model).defaultThinking {
|
||||
return intent
|
||||
capabilities := claudeCapabilitiesFor(model)
|
||||
switch {
|
||||
case !intent.HasStrength():
|
||||
if !capabilities.defaultThinking {
|
||||
return intent
|
||||
}
|
||||
intent.Mode = ModeAdaptive
|
||||
intent.Effort = capabilities.defaultEffort
|
||||
case intent.Effort == "" && intent.BudgetTokens == nil && (intent.Mode == ModeAdaptive || intent.Mode == ModeEnabled):
|
||||
intent.Effort = capabilities.defaultEffort
|
||||
}
|
||||
intent.Mode = ModeAdaptive
|
||||
intent.Effort = EffortHigh
|
||||
return intent
|
||||
}
|
||||
|
||||
|
||||
@@ -273,3 +273,58 @@ func TestOpenAIPivotExplicitEffortOverridesPivotWithDiagnostic(t *testing.T) {
|
||||
assert.Equal(t, EffortXHigh, got.Effort)
|
||||
assert.Equal(t, []string{"explicit_fields_conflict"}, diagnosticCodes(diagnostics))
|
||||
}
|
||||
|
||||
// Claude Opus 5.5 runs at medium effort when a request sets none; every other
|
||||
// model runs at high (https://platform.claude.com/docs/en/build-with-claude/effort).
|
||||
func TestClaudeModelDefaultEffortWhenClientSetsNone(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
modelDefaults := map[string]Effort{"claude-opus-5-5": EffortMedium, "claude-opus-5": EffortHigh, "claude-sonnet-5-5": EffortHigh}
|
||||
budget := 2048
|
||||
maxTokens := uint(8192)
|
||||
tests := []struct {
|
||||
name string
|
||||
intent Intent
|
||||
wantThinking string
|
||||
sendsEffort bool
|
||||
// wantEffort is empty when the model default applies.
|
||||
wantEffort Effort
|
||||
}{
|
||||
{name: "no intent", intent: Intent{}},
|
||||
{name: "include_thoughts only", intent: Intent{IncludeThoughts: boolPtr(true)}, wantThinking: "adaptive"},
|
||||
{name: "adaptive without effort", intent: Intent{Mode: ModeAdaptive}, wantThinking: "adaptive"},
|
||||
{name: "enabled without strength", intent: Intent{Mode: ModeEnabled}, wantThinking: "adaptive", sendsEffort: true},
|
||||
{name: "explicit effort is kept", intent: Intent{Mode: ModeEnabled, Effort: EffortHigh}, wantThinking: "adaptive", sendsEffort: true, wantEffort: EffortHigh},
|
||||
{name: "budget mapping is kept", intent: Intent{Mode: ModeEnabled, BudgetTokens: &budget}, wantThinking: "adaptive", sendsEffort: true, wantEffort: EffortMedium},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
for model, modelDefault := range modelDefaults {
|
||||
t.Run(tt.name+"/"+model, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
wantEffort := tt.wantEffort
|
||||
if wantEffort == "" {
|
||||
wantEffort = modelDefault
|
||||
}
|
||||
wantOutput := Effort("")
|
||||
if tt.sendsEffort {
|
||||
wantOutput = wantEffort
|
||||
}
|
||||
|
||||
rendered, err := RenderClaude(model, tt.intent, &maxTokens, 0)
|
||||
require.NoError(t, err)
|
||||
if tt.wantThinking == "" {
|
||||
assert.Nil(t, rendered.Thinking)
|
||||
} else {
|
||||
require.NotNil(t, rendered.Thinking)
|
||||
assert.Equal(t, tt.wantThinking, rendered.Thinking.Type)
|
||||
}
|
||||
assert.Equal(t, wantOutput, rendered.OutputEffort)
|
||||
assert.Equal(t, wantEffort, rendered.EffectiveEffort)
|
||||
|
||||
// A Claude client routed to an OpenAI upstream sends this effort.
|
||||
assert.Equal(t, wantEffort, EffectiveEffort(ResolveClaudeDefault(model, tt.intent)))
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -286,26 +286,36 @@ func TestConvertRequestClaudeToResponsesDropsIncompatibleContextManagement(t *te
|
||||
}
|
||||
|
||||
func TestConvertRequestClaudeAdaptiveThinkingPreservesEffort(t *testing.T) {
|
||||
adaptive := &dto.Thinking{Type: "adaptive", Display: "summarized"}
|
||||
tests := []struct {
|
||||
name string
|
||||
originModel string
|
||||
thinking *dto.Thinking
|
||||
outputConfig []byte
|
||||
wantEffort string
|
||||
}{
|
||||
{name: "adaptive default", wantEffort: "high"},
|
||||
{name: "explicit low", outputConfig: mustRawMessage(t, map[string]any{"effort": "low"}), wantEffort: "low"},
|
||||
{name: "explicit xhigh", outputConfig: mustRawMessage(t, map[string]any{"effort": "xhigh"}), wantEffort: "xhigh"},
|
||||
{name: "adaptive default", originModel: "gpt-5.6-sol", thinking: adaptive, wantEffort: "high"},
|
||||
{name: "explicit low", originModel: "gpt-5.6-sol", thinking: adaptive, outputConfig: mustRawMessage(t, map[string]any{"effort": "low"}), wantEffort: "low"},
|
||||
{name: "explicit xhigh", originModel: "gpt-5.6-sol", thinking: adaptive, outputConfig: mustRawMessage(t, map[string]any{"effort": "xhigh"}), wantEffort: "xhigh"},
|
||||
// Claude models think by default at their own default effort: medium on
|
||||
// Opus 5.5, high on the others.
|
||||
{name: "opus 5.5 adaptive default", originModel: "claude-opus-5-5", thinking: adaptive, wantEffort: "medium"},
|
||||
{name: "opus 5.5 without thinking", originModel: "claude-opus-5-5", wantEffort: "medium"},
|
||||
{name: "opus 5.5 explicit high", originModel: "claude-opus-5-5", thinking: adaptive, outputConfig: mustRawMessage(t, map[string]any{"effort": "high"}), wantEffort: "high"},
|
||||
{name: "opus 5 adaptive default", originModel: "claude-opus-5", thinking: adaptive, wantEffort: "high"},
|
||||
{name: "opus 5 without thinking", originModel: "claude-opus-5", wantEffort: "high"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := &convmeta.Values{
|
||||
OriginModelName: "gpt-5.6-sol",
|
||||
OriginModelName: tt.originModel,
|
||||
ConversionChain: []types.RelayFormat{types.RelayFormatClaude},
|
||||
}
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: "gpt-5.6-sol",
|
||||
OutputConfig: tt.outputConfig,
|
||||
Thinking: &dto.Thinking{Type: "adaptive", Display: "summarized"},
|
||||
Thinking: tt.thinking,
|
||||
Messages: []dto.ClaudeMessage{
|
||||
{Role: "user", Content: "hello"},
|
||||
},
|
||||
@@ -324,6 +334,39 @@ func TestConvertRequestClaudeAdaptiveThinkingPreservesEffort(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyClaudeThinkingModelLabelsNativeThinkingWithModelDefaultEffort(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
model string
|
||||
thinkingType string
|
||||
outputConfig []byte
|
||||
wantEffort string
|
||||
}{
|
||||
{name: "opus 5.5 adaptive", model: "claude-opus-5-5", thinkingType: "adaptive", wantEffort: "medium"},
|
||||
{name: "opus 5.5 enabled", model: "claude-opus-5-5", thinkingType: "enabled", wantEffort: "medium"},
|
||||
{name: "opus 5.5 explicit high", model: "claude-opus-5-5", thinkingType: "adaptive", outputConfig: mustRawMessage(t, map[string]any{"effort": "high"}), wantEffort: "high"},
|
||||
{name: "opus 5 adaptive", model: "claude-opus-5", thinkingType: "adaptive", wantEffort: "high"},
|
||||
{name: "sonnet 5.5 adaptive", model: "claude-sonnet-5-5", thinkingType: "adaptive", wantEffort: "high"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
info := &convmeta.Values{OriginModelName: tt.model, UpstreamModelName: tt.model}
|
||||
req := &dto.ClaudeRequest{
|
||||
Model: tt.model,
|
||||
Thinking: &dto.Thinking{Type: tt.thinkingType},
|
||||
OutputConfig: tt.outputConfig,
|
||||
Messages: []dto.ClaudeMessage{{Role: "user", Content: "hello"}},
|
||||
}
|
||||
|
||||
require.NoError(t, ApplyClaudeThinkingModel(req, info))
|
||||
assert.Equal(t, tt.wantEffort, info.GetReasoningEffort())
|
||||
assert.Equal(t, tt.thinkingType, req.Thinking.Type)
|
||||
assert.Equal(t, tt.outputConfig, []byte(req.OutputConfig))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiThinkingLevelCaseInsensitiveAcrossPaths(t *testing.T) {
|
||||
newRequest := func(level string) *dto.GeminiChatRequest {
|
||||
return &dto.GeminiChatRequest{
|
||||
|
||||
Reference in New Issue
Block a user