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:
Calcium-Ion
2026-10-07 15:45:08 +08:00
committed by GitHub
parent 973cf8ef46
commit 78bd5b1cbf
10 changed files with 742 additions and 92 deletions
+18 -13
View File
@@ -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
}
+79 -21
View File
@@ -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/*")
}
+34 -11
View File
@@ -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)))
})
}
}
}
+48 -5
View File
@@ -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{