Add projects
This commit is contained in:
parent
2d3a9ad623
commit
8b607dd700
1802 changed files with 503346 additions and 2 deletions
|
|
@ -0,0 +1,66 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const capturedGeminiThinkingSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
|
||||
func TestConvertClaudeRequestToGeminiWithCompat_SignatureCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
signature string
|
||||
wantSignature string
|
||||
}{
|
||||
{
|
||||
name: "preserves valid gemini signature",
|
||||
signature: "gemini#" + capturedGeminiThinkingSignature,
|
||||
wantSignature: capturedGeminiThinkingSignature,
|
||||
},
|
||||
{
|
||||
name: "foreign claude signature maps to bypass sentinel",
|
||||
signature: "claude#opaque-signature-12345",
|
||||
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
|
||||
},
|
||||
{
|
||||
name: "empty signature maps to bypass sentinel",
|
||||
signature: "",
|
||||
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":"` + tt.signature + `"}]}]}`)
|
||||
withCompat := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false)
|
||||
part := gjson.GetBytes(withCompat, "contents.0.parts.0")
|
||||
if !part.Get("thought").Bool() || part.Get("text").String() != "reason" {
|
||||
t.Fatalf("compat translation missing thought part: %s", withCompat)
|
||||
}
|
||||
if got := part.Get("thoughtSignature").String(); got != tt.wantSignature {
|
||||
t.Fatalf("thoughtSignature = %q, want %q; output: %s", got, tt.wantSignature, withCompat)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGeminiWithCompatPreservesEmptyThinking(t *testing.T) {
|
||||
payload := []byte(`{"messages":[{"role":"assistant","content":[{"type":"thinking","thinking":"reason","signature":""}]}]}`)
|
||||
|
||||
withoutCompat := ConvertClaudeRequestToGemini("deepseek-v4", payload, false)
|
||||
if gjson.GetBytes(withoutCompat, "contents.0.parts.#").Int() != 0 {
|
||||
t.Fatalf("default translation preserved thinking: %s", withoutCompat)
|
||||
}
|
||||
|
||||
withCompat := ConvertClaudeRequestToGeminiWithCompat("deepseek-v4", payload, false)
|
||||
part := gjson.GetBytes(withCompat, "contents.0.parts.0")
|
||||
if !part.Get("thought").Bool() || part.Get("text").String() != "reason" {
|
||||
t.Fatalf("compat translation missing thought part: %s", withCompat)
|
||||
}
|
||||
if !part.Get("thoughtSignature").Exists() || part.Get("thoughtSignature").String() != signature.GeminiSkipThoughtSignatureValidator {
|
||||
t.Fatalf("compat translation did not preserve bypass signature: %s", withCompat)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,348 @@
|
|||
// Package claude provides request translation functionality for Claude API.
|
||||
// It handles parsing and transforming Claude API requests into the internal client format,
|
||||
// extracting model information, system instructions, message contents, and tool declarations.
|
||||
// The package also performs JSON data cleaning and transformation to ensure compatibility
|
||||
// between Claude API format and the internal client's expected format.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/registry"
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiClaudeThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// ConvertClaudeRequestToGemini parses a Claude API request and returns a complete
|
||||
// Gemini request body (as JSON bytes) ready to be sent via SendRawMessageStream.
|
||||
// All JSON transformations are performed using gjson/sjson.
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON request from the Claude API.
|
||||
// - stream: A boolean indicating if the request is for a streaming response.
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The transformed request in Gemini format.
|
||||
func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, false)
|
||||
}
|
||||
|
||||
// ConvertClaudeRequestToGeminiWithCompat preserves assistant thinking blocks
|
||||
// with empty signatures for configured compatibility endpoints.
|
||||
func ConvertClaudeRequestToGeminiWithCompat(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
return convertClaudeRequestToGemini(modelName, inputRawJSON, stream, true)
|
||||
}
|
||||
|
||||
func convertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool, preserveEmptyThinkingBlocks bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
// Build output Gemini request JSON
|
||||
out := []byte(`{"contents":[]}`)
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
|
||||
// system instruction
|
||||
if systemResult := gjson.GetBytes(rawJSON, "system"); systemResult.IsArray() {
|
||||
systemParts := make([][]byte, 0, 2)
|
||||
systemResult.ForEach(func(_, systemPromptResult gjson.Result) bool {
|
||||
if systemPromptResult.Get("type").String() == "text" {
|
||||
textResult := systemPromptResult.Get("text")
|
||||
if textResult.Type == gjson.String {
|
||||
if util.IsClaudeCodeAttributionSystemText(textResult.String()) {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", textResult.String())
|
||||
systemParts = append(systemParts, part)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(systemParts) > 0 {
|
||||
systemInstruction := []byte(`{"role":"user","parts":[]}`)
|
||||
systemInstruction, _ = sjson.SetRawBytes(systemInstruction, "parts", translatorcommon.JoinRawArray(systemParts))
|
||||
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
|
||||
}
|
||||
} else if systemResult.Type == gjson.String && !util.IsClaudeCodeAttributionSystemText(systemResult.String()) {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", systemResult.String())
|
||||
systemInstruction := []byte(`{"parts":[]}`)
|
||||
systemInstruction = translatorcommon.SetRawArrayItems(systemInstruction, "parts", [][]byte{part})
|
||||
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
|
||||
}
|
||||
|
||||
// contents
|
||||
if messagesResult := gjson.GetBytes(rawJSON, "messages"); messagesResult.IsArray() {
|
||||
contentItems := translatorcommon.NewRawArrayItems(messagesResult.Get("#").Int())
|
||||
messagesResult.ForEach(func(_, messageResult gjson.Result) bool {
|
||||
roleResult := messageResult.Get("role")
|
||||
if roleResult.Type != gjson.String {
|
||||
return true
|
||||
}
|
||||
role := roleResult.String()
|
||||
if role == "assistant" {
|
||||
role = "model"
|
||||
} else if role == "system" {
|
||||
role = "user"
|
||||
}
|
||||
|
||||
partItems := make([][]byte, 0, 4)
|
||||
contentsResult := messageResult.Get("content")
|
||||
if roleResult.String() == "system" {
|
||||
if reminderText, ok := translatorcommon.ClaudeMessageSystemReminderText(contentsResult); ok {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", reminderText)
|
||||
partItems = append(partItems, part)
|
||||
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
|
||||
}
|
||||
return true
|
||||
}
|
||||
if contentsResult.IsArray() {
|
||||
contentsResult.ForEach(func(_, contentResult gjson.Result) bool {
|
||||
switch contentResult.Get("type").String() {
|
||||
case "text":
|
||||
text := contentResult.Get("text").String()
|
||||
if text == "" {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", text)
|
||||
partItems = append(partItems, part)
|
||||
|
||||
case "thinking":
|
||||
if !preserveEmptyThinkingBlocks {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"text":"","thought":true,"thoughtSignature":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", contentResult.Get("thinking").String())
|
||||
signature := sigcompat.GeminiReplaySignatureOrBypass(contentResult.Get("signature").String(), sigcompat.SignatureBlockKindGeminiModelPart)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
|
||||
partItems = append(partItems, part)
|
||||
|
||||
case "tool_use":
|
||||
functionName := contentResult.Get("name").String()
|
||||
if toolUseID := contentResult.Get("id").String(); toolUseID != "" {
|
||||
if derived := toolNameFromClaudeToolUseID(toolUseID); derived != "" {
|
||||
functionName = derived
|
||||
}
|
||||
}
|
||||
functionName = util.SanitizeFunctionName(functionName)
|
||||
functionArgs := contentResult.Get("input").String()
|
||||
argsResult := gjson.Parse(functionArgs)
|
||||
if argsResult.IsObject() && gjson.Valid(functionArgs) {
|
||||
part := []byte(`{"thoughtSignature":"","functionCall":{"name":"","args":{}}}`)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", geminiClaudeThoughtSignature)
|
||||
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
|
||||
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(functionArgs))
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
|
||||
case "tool_result":
|
||||
toolCallID := contentResult.Get("tool_use_id").String()
|
||||
if toolCallID == "" {
|
||||
return true
|
||||
}
|
||||
funcName := toolNameFromClaudeToolUseID(toolCallID)
|
||||
if funcName == "" {
|
||||
funcName = toolCallID
|
||||
}
|
||||
funcName = util.SanitizeFunctionName(funcName)
|
||||
toolResult := util.ConvertClaudeToolResultContent(contentResult.Get("content"))
|
||||
part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.name", funcName)
|
||||
if toolResult.ResultIsRaw {
|
||||
part, _ = sjson.SetRawBytes(part, "functionResponse.response.result", []byte(toolResult.Result))
|
||||
} else {
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.response.result", toolResult.Result)
|
||||
}
|
||||
partItems = append(partItems, part)
|
||||
for _, img := range toolResult.Images {
|
||||
imagePart := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
|
||||
imagePart, _ = sjson.SetBytes(imagePart, "inline_data.mime_type", img.MimeType)
|
||||
imagePart, _ = sjson.SetBytes(imagePart, "inline_data.data", img.Data)
|
||||
partItems = append(partItems, imagePart)
|
||||
}
|
||||
|
||||
case "image":
|
||||
source := contentResult.Get("source")
|
||||
if source.Get("type").String() != "base64" {
|
||||
return true
|
||||
}
|
||||
mimeType := source.Get("media_type").String()
|
||||
data := source.Get("data").String()
|
||||
if mimeType == "" || data == "" {
|
||||
return true
|
||||
}
|
||||
part := []byte(`{"inline_data":{"mime_type":"","data":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "inline_data.mime_type", mimeType)
|
||||
part, _ = sjson.SetBytes(part, "inline_data.data", data)
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
return true
|
||||
})
|
||||
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
|
||||
} else if contentsResult.Type == gjson.String {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", contentsResult.String())
|
||||
partItems = append(partItems, part)
|
||||
contentItems = append(contentItems, geminiContentWithParts(role, partItems))
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
// Strip a trailing model turn with unanswered function calls.
|
||||
if len(contentItems) > 0 {
|
||||
last := gjson.ParseBytes(contentItems[len(contentItems)-1])
|
||||
if last.Get("role").String() == "model" {
|
||||
hasFunctionCall := false
|
||||
last.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionCall").Exists() {
|
||||
hasFunctionCall = true
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if hasFunctionCall {
|
||||
contentItems = contentItems[:len(contentItems)-1]
|
||||
}
|
||||
}
|
||||
}
|
||||
out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
|
||||
}
|
||||
|
||||
// tools
|
||||
if toolsResult := gjson.GetBytes(rawJSON, "tools"); toolsResult.IsArray() {
|
||||
var toolItems [][]byte
|
||||
toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
|
||||
inputSchemaResult := toolResult.Get("input_schema")
|
||||
if inputSchemaResult.Exists() && inputSchemaResult.IsObject() {
|
||||
inputSchema := util.CleanJSONSchemaForGemini(inputSchemaResult.Raw)
|
||||
tool := []byte(toolResult.Raw)
|
||||
var err error
|
||||
tool, err = sjson.DeleteBytes(tool, "input_schema")
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
tool, err = sjson.SetRawBytes(tool, "parametersJsonSchema", []byte(inputSchema))
|
||||
if err != nil {
|
||||
return true
|
||||
}
|
||||
for _, path := range []string{"strict", "input_examples", "type", "cache_control", "defer_loading", "eager_input_streaming"} {
|
||||
if toolResult.Get(path).Exists() {
|
||||
tool, _ = sjson.DeleteBytes(tool, path)
|
||||
}
|
||||
}
|
||||
nameResult := toolResult.Get("name")
|
||||
originalName := nameResult.String()
|
||||
sanitizedName := util.SanitizeFunctionName(originalName)
|
||||
if nameResult.Type != gjson.String || sanitizedName != originalName {
|
||||
tool, _ = sjson.SetBytes(tool, "name", sanitizedName)
|
||||
}
|
||||
if gjson.ValidBytes(tool) && gjson.ParseBytes(tool).IsObject() {
|
||||
toolItems = append(toolItems, tool)
|
||||
}
|
||||
}
|
||||
return true
|
||||
})
|
||||
if len(toolItems) > 0 {
|
||||
tools := []byte(`[{"functionDeclarations":[]}]`)
|
||||
tools, _ = sjson.SetRawBytes(tools, "0.functionDeclarations", translatorcommon.JoinRawArray(toolItems))
|
||||
out, _ = sjson.SetRawBytes(out, "tools", tools)
|
||||
}
|
||||
}
|
||||
|
||||
// tool_choice
|
||||
toolChoiceResult := gjson.GetBytes(rawJSON, "tool_choice")
|
||||
if toolChoiceResult.Exists() {
|
||||
toolChoiceType := ""
|
||||
toolChoiceName := ""
|
||||
if toolChoiceResult.IsObject() {
|
||||
toolChoiceType = toolChoiceResult.Get("type").String()
|
||||
toolChoiceName = toolChoiceResult.Get("name").String()
|
||||
} else if toolChoiceResult.Type == gjson.String {
|
||||
toolChoiceType = toolChoiceResult.String()
|
||||
}
|
||||
|
||||
switch toolChoiceType {
|
||||
case "auto":
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "AUTO")
|
||||
case "none":
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "NONE")
|
||||
case "any":
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
|
||||
case "tool":
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.mode", "ANY")
|
||||
if toolChoiceName != "" {
|
||||
out, _ = sjson.SetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames", []string{util.SanitizeFunctionName(toolChoiceName)})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Map Anthropic thinking -> Gemini thinking config when enabled
|
||||
// Translator only does format conversion, ApplyThinking handles model capability validation.
|
||||
if t := gjson.GetBytes(rawJSON, "thinking"); t.Exists() && t.IsObject() {
|
||||
switch t.Get("type").String() {
|
||||
case "enabled":
|
||||
if b := t.Get("budget_tokens"); b.Exists() && b.Type == gjson.Number {
|
||||
budget := int(b.Int())
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", budget)
|
||||
}
|
||||
case "adaptive", "auto":
|
||||
// For adaptive thinking:
|
||||
// - If output_config.effort is explicitly present, pass through as thinkingLevel.
|
||||
// - Otherwise, treat it as "enabled with target-model maximum" and emit thinkingBudget=max.
|
||||
// ApplyThinking handles clamping to target model's supported levels.
|
||||
effort := ""
|
||||
if v := gjson.GetBytes(rawJSON, "output_config.effort"); v.Exists() && v.Type == gjson.String {
|
||||
effort = strings.ToLower(strings.TrimSpace(v.String()))
|
||||
}
|
||||
if effort != "" {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", effort)
|
||||
} else {
|
||||
maxBudget := 0
|
||||
if mi := registry.LookupModelInfo(modelName, "gemini"); mi != nil && mi.Thinking != nil {
|
||||
maxBudget = mi.Thinking.Max
|
||||
}
|
||||
if maxBudget > 0 {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingBudget", maxBudget)
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.thinkingConfig.thinkingLevel", "high")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if v := gjson.GetBytes(rawJSON, "temperature"); v.Exists() && v.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.temperature", v.Num)
|
||||
}
|
||||
if v := gjson.GetBytes(rawJSON, "top_p"); v.Exists() && v.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topP", v.Num)
|
||||
}
|
||||
if v := gjson.GetBytes(rawJSON, "top_k"); v.Exists() && v.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topK", v.Num)
|
||||
}
|
||||
|
||||
result := out
|
||||
result = common.AttachDefaultSafetySettings(result, "safetySettings")
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
func geminiContentWithParts(role string, parts [][]byte) []byte {
|
||||
content := []byte(`{"role":"","parts":[]}`)
|
||||
content, _ = sjson.SetBytes(content, "role", role)
|
||||
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
|
||||
return content
|
||||
}
|
||||
|
||||
func toolNameFromClaudeToolUseID(toolUseID string) string {
|
||||
parts := strings.Split(toolUseID, "-")
|
||||
if len(parts) <= 1 {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(parts[0:len(parts)-1], "-")
|
||||
}
|
||||
|
|
@ -0,0 +1,277 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "hi"}
|
||||
]
|
||||
}
|
||||
],
|
||||
"tools": [
|
||||
{
|
||||
"name": "json",
|
||||
"description": "A JSON tool",
|
||||
"input_schema": {
|
||||
"type": "object",
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
],
|
||||
"tool_choice": {"type": "tool", "name": "json"}
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
if got := gjson.GetBytes(output, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
|
||||
t.Fatalf("Expected toolConfig.functionCallingConfig.mode 'ANY', got '%s'", got)
|
||||
}
|
||||
allowed := gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Array()
|
||||
if len(allowed) != 1 || allowed[0].String() != "json" {
|
||||
t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_StringSystemInstruction(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"system": "Be concise",
|
||||
"messages": [{"role": "user", "content": "Hello"}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
if got := gjson.GetBytes(output, "systemInstruction.parts.0.text").String(); got != "Be concise" {
|
||||
t.Fatalf("Expected systemInstruction text %q, got %q", "Be concise", got)
|
||||
}
|
||||
if gjson.GetBytes(output, "systemInstruction.role").Exists() {
|
||||
t.Fatalf("Expected systemInstruction.role to not exist, got %q", gjson.GetBytes(output, "systemInstruction.role").String())
|
||||
}
|
||||
if gjson.GetBytes(output, "system_instruction").Exists() {
|
||||
t.Fatalf("Legacy system_instruction field should not be emitted: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_ImageContent(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "describe this image"},
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": "aGVsbG8="
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
parts := gjson.GetBytes(output, "contents.0.parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("Expected 2 parts, got %d", len(parts))
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "describe this image" {
|
||||
t.Fatalf("Expected first part text 'describe this image', got '%s'", got)
|
||||
}
|
||||
if got := parts[1].Get("inline_data.mime_type").String(); got != "image/png" {
|
||||
t.Fatalf("Expected image mime type 'image/png', got '%s'", got)
|
||||
}
|
||||
if got := parts[1].Get("inline_data.data").String(); got != "aGVsbG8=" {
|
||||
t.Fatalf("Expected image data 'aGVsbG8=', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_StripsClaudeCodeAttribution(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-sonnet-4-5",
|
||||
"system": [
|
||||
{"type": "text", "text": "x-anthropic-billing-header: cc_version=2.1.63.abc; cc_entrypoint=cli; cch=12345;"},
|
||||
{"type": "text", "text": "You are a Claude agent, built on Anthropic's Claude Agent SDK."},
|
||||
{"type": "text", "text": "User system prompt"}
|
||||
],
|
||||
"messages": [{"role": "user", "content": [{"type": "text", "text": "hi"}]}]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
parts := gjson.GetBytes(output, "systemInstruction.parts").Array()
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("Expected 2 system parts after attribution strip, got %d: %s", len(parts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "You are a Claude agent, built on Anthropic's Claude Agent SDK." {
|
||||
t.Fatalf("Unexpected first system part: %q", got)
|
||||
}
|
||||
if got := parts[1].Get("text").String(); got != "User system prompt" {
|
||||
t.Fatalf("Unexpected second system part: %q", got)
|
||||
}
|
||||
if gjson.GetBytes(output, `systemInstruction.parts.#(text%"x-anthropic-billing-header:*")`).Exists() {
|
||||
t.Fatalf("Claude Code attribution block was forwarded: %s", gjson.GetBytes(output, "systemInstruction.parts").Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_ConvertsMessageSystemRoleToUserContent(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"system": [{"type": "text", "text": "Top-level rules"}],
|
||||
"messages": [
|
||||
{"role": "user", "content": [{"type": "text", "text": "Hello"}]},
|
||||
{"role": "system", "content": "String mid-conversation rule"},
|
||||
{"role": "system", "content": [{"type": "text", "text": "Array mid-conversation rule"}]}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
if systemContent := gjson.GetBytes(output, `contents.#(role=="system")`); systemContent.Exists() {
|
||||
t.Fatalf("system role should not be emitted in contents: %s", systemContent.Raw)
|
||||
}
|
||||
|
||||
contents := gjson.GetBytes(output, "contents").Array()
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("Expected the user and message-level system turns in contents, got %d: %s", len(contents), gjson.GetBytes(output, "contents").Raw)
|
||||
}
|
||||
if got := contents[0].Get("role").String(); got != "user" {
|
||||
t.Fatalf("Expected first content role user, got %q", got)
|
||||
}
|
||||
if got := contents[1].Get("role").String(); got != "user" {
|
||||
t.Fatalf("Expected message-level string system content to be downgraded to user role, got %q", got)
|
||||
}
|
||||
if got := contents[1].Get("parts.0.text").String(); got != "<system-reminder>\nString mid-conversation rule\n</system-reminder>" {
|
||||
t.Fatalf("Unexpected string message-level system content text: %q", got)
|
||||
}
|
||||
if got := contents[2].Get("role").String(); got != "user" {
|
||||
t.Fatalf("Expected message-level array system content to be downgraded to user role, got %q", got)
|
||||
}
|
||||
if got := contents[2].Get("parts.0.text").String(); got != "<system-reminder>\nArray mid-conversation rule\n</system-reminder>" {
|
||||
t.Fatalf("Unexpected array message-level system content text: %q", got)
|
||||
}
|
||||
|
||||
parts := gjson.GetBytes(output, "systemInstruction.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("Expected only top-level system parts, got %d: %s", len(parts), gjson.GetBytes(output, "systemInstruction.parts").Raw)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "Top-level rules" {
|
||||
t.Fatalf("Unexpected first system part: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_SkipsEmptyTextParts(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "claude-3-5-sonnet",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "text", "text": "hello"},
|
||||
{"type": "text", "text": ""}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
parts := gjson.GetBytes(output, "contents.0.parts").Array()
|
||||
if len(parts) != 1 {
|
||||
t.Fatalf("Expected 1 part after skipping empty text, got %d: %s", len(parts), output)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "hello" {
|
||||
t.Fatalf("Expected part text 'hello', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_StructuredToolResult(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "json-call-1", "name": "json", "input": {"ok": true}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{
|
||||
"type": "tool_result",
|
||||
"tool_use_id": "json-call-1",
|
||||
"content": [
|
||||
{"type": "text", "text": "alpha"},
|
||||
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "aGVsbG8="}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
|
||||
if !fr.Exists() {
|
||||
t.Fatalf("expected functionResponse part, contents=%s", gjson.GetBytes(output, "contents").Raw)
|
||||
}
|
||||
// The text block must remain structured JSON, not a double-encoded string blob.
|
||||
if got := fr.Get("response.result.text").String(); got != "alpha" {
|
||||
t.Fatalf("expected structured result text 'alpha', got result=%s", fr.Get("response.result").Raw)
|
||||
}
|
||||
// The image block must be emitted as a separate inline_data part, not embedded in result.
|
||||
img := gjson.GetBytes(output, "contents.1.parts.1.inline_data")
|
||||
if got := img.Get("mime_type").String(); got != "image/png" {
|
||||
t.Fatalf("expected image mime type 'image/png', got '%s'", got)
|
||||
}
|
||||
if got := img.Get("data").String(); got != "aGVsbG8=" {
|
||||
t.Fatalf("expected image data 'aGVsbG8=', got '%s'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertClaudeRequestToGemini_StringToolResult(t *testing.T) {
|
||||
inputJSON := []byte(`{
|
||||
"model": "gemini-3-flash-preview",
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [
|
||||
{"type": "tool_use", "id": "json-call-1", "name": "json", "input": {"ok": true}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "tool_result", "tool_use_id": "json-call-1", "content": "alpha"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
output := ConvertClaudeRequestToGemini("gemini-3-flash-preview", inputJSON, false)
|
||||
|
||||
fr := gjson.GetBytes(output, "contents.1.parts.0.functionResponse")
|
||||
if !fr.Exists() {
|
||||
t.Fatalf("expected functionResponse part, contents=%s", gjson.GetBytes(output, "contents").Raw)
|
||||
}
|
||||
// String content must not be double-encoded: result should be exactly "alpha".
|
||||
if got := fr.Get("response.result").String(); got != "alpha" {
|
||||
t.Fatalf("expected result 'alpha', got '%s' (raw=%s)", got, fr.Get("response.result").Raw)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,421 @@
|
|||
// Package claude provides response translation functionality for Claude API.
|
||||
// This package handles the conversion of backend client responses into Claude-compatible
|
||||
// Server-Sent Events (SSE) format, implementing a sophisticated state machine that manages
|
||||
// different response types including text content, thinking processes, and function calls.
|
||||
// The translation ensures proper sequencing of SSE events and maintains state across
|
||||
// multiple response chunks to provide a seamless streaming experience.
|
||||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// Params holds parameters for response conversion.
|
||||
type Params struct {
|
||||
IsGlAPIKey bool
|
||||
HasFirstResponse bool
|
||||
ResponseType int
|
||||
ResponseIndex int
|
||||
HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
|
||||
ToolNameMap map[string]string
|
||||
SanitizedNameMap map[string]string
|
||||
SawToolCall bool
|
||||
HasFinalEvents bool
|
||||
}
|
||||
|
||||
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.
|
||||
var toolUseIDCounter uint64
|
||||
|
||||
// ConvertGeminiResponseToClaude performs sophisticated streaming response format conversion.
|
||||
// This function implements a complex state machine that translates backend client responses
|
||||
// into Claude-compatible Server-Sent Events (SSE) format. It manages different response types
|
||||
// and handles state transitions between content blocks, thinking processes, and function calls.
|
||||
//
|
||||
// Response type states: 0=none, 1=content, 2=thinking, 3=function
|
||||
// The function maintains state across multiple calls to ensure proper SSE event sequencing.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request.
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON response from the Gemini API.
|
||||
// - param: A pointer to a parameter object for the conversion.
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of bytes, each containing a Claude-compatible SSE payload.
|
||||
func ConvertGeminiResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
if *param == nil {
|
||||
*param = &Params{
|
||||
IsGlAPIKey: false,
|
||||
HasFirstResponse: false,
|
||||
ResponseType: 0,
|
||||
ResponseIndex: 0,
|
||||
ToolNameMap: util.ToolNameMapFromClaudeRequest(originalRequestRawJSON),
|
||||
SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
|
||||
SawToolCall: false,
|
||||
}
|
||||
}
|
||||
|
||||
if bytes.Equal(rawJSON, []byte("[DONE]")) {
|
||||
// Only send message_stop if we have actually output content
|
||||
if (*param).(*Params).HasContent {
|
||||
return [][]byte{translatorcommon.AppendSSEEventString(nil, "message_stop", `{"type":"message_stop"}`, 3)}
|
||||
}
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
output := make([]byte, 0, 1024)
|
||||
appendEvent := func(event, payload string) {
|
||||
output = translatorcommon.AppendSSEEventString(output, event, payload, 3)
|
||||
}
|
||||
appendSignatureDelta := func(signature string) {
|
||||
if signature == "" || (*param).(*Params).ResponseType != 2 {
|
||||
return
|
||||
}
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":""}}`, (*param).(*Params).ResponseIndex)), "delta.signature", signature)
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).HasContent = true
|
||||
}
|
||||
|
||||
// Initialize the streaming session with a message_start event
|
||||
// This is only sent for the very first response chunk
|
||||
if !(*param).(*Params).HasFirstResponse {
|
||||
// Create the initial message structure with default values
|
||||
// This follows the Claude API specification for streaming message initialization
|
||||
messageStartTemplate := []byte(`{"type":"message_start","message":{"id":"msg_1nZdL29xx5MUA1yADyHTEsnR8uuvGzszyY","type":"message","role":"assistant","content":[],"model":"claude-3-5-sonnet-20241022","stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}}`)
|
||||
|
||||
// Override default values with actual response metadata if available
|
||||
if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
|
||||
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.model", modelVersionResult.String())
|
||||
}
|
||||
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
|
||||
messageStartTemplate, _ = sjson.SetBytes(messageStartTemplate, "message.id", responseIDResult.String())
|
||||
}
|
||||
appendEvent("message_start", string(messageStartTemplate))
|
||||
|
||||
(*param).(*Params).HasFirstResponse = true
|
||||
}
|
||||
|
||||
// Process the response parts array from the backend client
|
||||
// Each part can contain text content, thinking content, or function calls
|
||||
partsResult := gjson.GetBytes(rawJSON, "candidates.0.content.parts")
|
||||
if partsResult.IsArray() {
|
||||
partResults := partsResult.Array()
|
||||
for i := 0; i < len(partResults); i++ {
|
||||
partResult := partResults[i]
|
||||
|
||||
// Extract the different types of content from each part
|
||||
partTextResult := partResult.Get("text")
|
||||
functionCallResult := partResult.Get("functionCall")
|
||||
thoughtSignatureResult := partResult.Get("thoughtSignature")
|
||||
if !thoughtSignatureResult.Exists() {
|
||||
thoughtSignatureResult = partResult.Get("thought_signature")
|
||||
}
|
||||
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
|
||||
|
||||
if hasThoughtSignature && !partTextResult.Exists() && !functionCallResult.Exists() {
|
||||
appendSignatureDelta(thoughtSignatureResult.String())
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle text content (both regular content and thinking)
|
||||
if partTextResult.Exists() {
|
||||
// Process thinking content (internal reasoning)
|
||||
if partResult.Get("thought").Bool() || hasThoughtSignature {
|
||||
if hasThoughtSignature && partTextResult.String() == "" {
|
||||
appendSignatureDelta(thoughtSignatureResult.String())
|
||||
continue
|
||||
}
|
||||
// Continue existing thinking block
|
||||
if (*param).(*Params).ResponseType == 2 {
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String())
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).HasContent = true
|
||||
} else {
|
||||
// Transition from another state to thinking
|
||||
// First, close any existing content block
|
||||
if (*param).(*Params).ResponseType != 0 {
|
||||
if (*param).(*Params).ResponseType == 2 {
|
||||
// output = output + "event: content_block_delta\n"
|
||||
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
|
||||
// output = output + "\n\n\n"
|
||||
}
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseIndex++
|
||||
}
|
||||
|
||||
// Start a new thinking content block
|
||||
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"thinking","thinking":""}}`, (*param).(*Params).ResponseIndex))
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"thinking_delta","thinking":""}}`, (*param).(*Params).ResponseIndex)), "delta.thinking", partTextResult.String())
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).ResponseType = 2 // Set state to thinking
|
||||
(*param).(*Params).HasContent = true
|
||||
}
|
||||
appendSignatureDelta(thoughtSignatureResult.String())
|
||||
} else {
|
||||
// Process regular text content (user-visible output)
|
||||
// Continue existing text block
|
||||
if (*param).(*Params).ResponseType == 1 {
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String())
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).HasContent = true
|
||||
} else {
|
||||
// Transition from another state to text content
|
||||
// First, close any existing content block
|
||||
if (*param).(*Params).ResponseType != 0 {
|
||||
if (*param).(*Params).ResponseType == 2 {
|
||||
// output = output + "event: content_block_delta\n"
|
||||
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
|
||||
// output = output + "\n\n\n"
|
||||
}
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseIndex++
|
||||
}
|
||||
|
||||
// Start a new text content block
|
||||
appendEvent("content_block_start", fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"text","text":""}}`, (*param).(*Params).ResponseIndex))
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"text_delta","text":""}}`, (*param).(*Params).ResponseIndex)), "delta.text", partTextResult.String())
|
||||
appendEvent("content_block_delta", string(data))
|
||||
(*param).(*Params).ResponseType = 1 // Set state to content
|
||||
(*param).(*Params).HasContent = true
|
||||
}
|
||||
}
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function/tool calls from the AI model
|
||||
// This processes tool usage requests and formats them for Claude API compatibility
|
||||
(*param).(*Params).SawToolCall = true
|
||||
upstreamToolName := functionCallResult.Get("name").String()
|
||||
upstreamToolName = util.RestoreSanitizedToolName((*param).(*Params).SanitizedNameMap, upstreamToolName)
|
||||
clientToolName := util.MapToolName((*param).(*Params).ToolNameMap, upstreamToolName)
|
||||
|
||||
// FIX: Handle streaming split/delta where name might be empty in subsequent chunks.
|
||||
// If we are already in tool use mode and name is empty, treat as continuation (delta).
|
||||
if (*param).(*Params).ResponseType == 3 && upstreamToolName == "" {
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
data, _ := sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
|
||||
appendEvent("content_block_delta", string(data))
|
||||
}
|
||||
// Continue to next part without closing/opening logic
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle state transitions when switching to function calls
|
||||
// Close any existing function call block first
|
||||
if (*param).(*Params).ResponseType == 3 {
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseIndex++
|
||||
(*param).(*Params).ResponseType = 0
|
||||
}
|
||||
|
||||
// Special handling for thinking state transition
|
||||
if (*param).(*Params).ResponseType == 2 {
|
||||
// output = output + "event: content_block_delta\n"
|
||||
// output = output + fmt.Sprintf(`data: {"type":"content_block_delta","index":%d,"delta":{"type":"signature_delta","signature":null}}`, (*param).(*Params).ResponseIndex)
|
||||
// output = output + "\n\n\n"
|
||||
}
|
||||
|
||||
// Close any other existing content block
|
||||
if (*param).(*Params).ResponseType != 0 {
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseIndex++
|
||||
}
|
||||
|
||||
// Start a new tool use content block
|
||||
// This creates the structure for a function call in Claude format
|
||||
// Create the tool use block with unique ID and function details
|
||||
data := []byte(fmt.Sprintf(`{"type":"content_block_start","index":%d,"content_block":{"type":"tool_use","id":"","name":"","input":{}}}`, (*param).(*Params).ResponseIndex))
|
||||
data, _ = sjson.SetBytes(data, "content_block.id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, atomic.AddUint64(&toolUseIDCounter, 1))))
|
||||
data, _ = sjson.SetBytes(data, "content_block.name", clientToolName)
|
||||
appendEvent("content_block_start", string(data))
|
||||
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
data, _ = sjson.SetBytes([]byte(fmt.Sprintf(`{"type":"content_block_delta","index":%d,"delta":{"type":"input_json_delta","partial_json":""}}`, (*param).(*Params).ResponseIndex)), "delta.partial_json", fcArgsResult.Raw)
|
||||
appendEvent("content_block_delta", string(data))
|
||||
}
|
||||
(*param).(*Params).ResponseType = 3
|
||||
(*param).(*Params).HasContent = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
usageResult := gjson.GetBytes(rawJSON, "usageMetadata")
|
||||
if usageResult.Exists() && bytes.Contains(rawJSON, []byte(`"finishReason"`)) && !(*param).(*Params).HasFinalEvents {
|
||||
// Only send final events if we have actually output content
|
||||
if (*param).(*Params).HasContent {
|
||||
if (*param).(*Params).ResponseType != 0 {
|
||||
appendEvent("content_block_stop", fmt.Sprintf(`{"type":"content_block_stop","index":%d}`, (*param).(*Params).ResponseIndex))
|
||||
(*param).(*Params).ResponseType = 0
|
||||
}
|
||||
|
||||
template := []byte(`{"type":"message_delta","delta":{"stop_reason":"end_turn","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
if (*param).(*Params).SawToolCall {
|
||||
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"tool_use","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
} else if finish := gjson.GetBytes(rawJSON, "candidates.0.finishReason"); finish.Exists() && finish.String() == "MAX_TOKENS" {
|
||||
template = []byte(`{"type":"message_delta","delta":{"stop_reason":"max_tokens","stop_sequence":null},"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
}
|
||||
|
||||
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
||||
candidatesTokenCount := usageResult.Get("candidatesTokenCount").Int()
|
||||
template, _ = sjson.SetBytes(template, "usage.output_tokens", candidatesTokenCount+thoughtsTokenCount)
|
||||
template, _ = sjson.SetBytes(template, "usage.input_tokens", usageResult.Get("promptTokenCount").Int())
|
||||
|
||||
appendEvent("message_delta", string(template))
|
||||
(*param).(*Params).HasFinalEvents = true
|
||||
}
|
||||
}
|
||||
|
||||
return [][]byte{output}
|
||||
}
|
||||
|
||||
// ConvertGeminiResponseToClaudeNonStream converts a non-streaming Gemini response to a non-streaming Claude response.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request.
|
||||
// - modelName: The name of the model.
|
||||
// - rawJSON: The raw JSON response from the Gemini API.
|
||||
// - param: A pointer to a parameter object for the conversion.
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: A Claude-compatible JSON response.
|
||||
func ConvertGeminiResponseToClaudeNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
_ = requestRawJSON
|
||||
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
toolNameMap := util.ToolNameMapFromClaudeRequest(originalRequestRawJSON)
|
||||
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
|
||||
|
||||
out := []byte(`{"id":"","type":"message","role":"assistant","model":"","content":[],"stop_reason":null,"stop_sequence":null,"usage":{"input_tokens":0,"output_tokens":0}}`)
|
||||
out, _ = sjson.SetBytes(out, "id", root.Get("responseId").String())
|
||||
out, _ = sjson.SetBytes(out, "model", root.Get("modelVersion").String())
|
||||
|
||||
inputTokens := root.Get("usageMetadata.promptTokenCount").Int()
|
||||
outputTokens := root.Get("usageMetadata.candidatesTokenCount").Int() + root.Get("usageMetadata.thoughtsTokenCount").Int()
|
||||
out, _ = sjson.SetBytes(out, "usage.input_tokens", inputTokens)
|
||||
out, _ = sjson.SetBytes(out, "usage.output_tokens", outputTokens)
|
||||
|
||||
parts := root.Get("candidates.0.content.parts")
|
||||
textBuilder := strings.Builder{}
|
||||
thinkingBuilder := strings.Builder{}
|
||||
var thinkingSignature string
|
||||
toolIDCounter := 0
|
||||
hasToolCall := false
|
||||
var blocks [][]byte
|
||||
|
||||
flushText := func() {
|
||||
if textBuilder.Len() == 0 {
|
||||
return
|
||||
}
|
||||
block := []byte(`{"type":"text","text":""}`)
|
||||
block, _ = sjson.SetBytes(block, "text", textBuilder.String())
|
||||
blocks = append(blocks, block)
|
||||
textBuilder.Reset()
|
||||
}
|
||||
|
||||
flushThinking := func() {
|
||||
if thinkingBuilder.Len() == 0 && thinkingSignature == "" {
|
||||
return
|
||||
}
|
||||
block := []byte(`{"type":"thinking","thinking":""}`)
|
||||
block, _ = sjson.SetBytes(block, "thinking", thinkingBuilder.String())
|
||||
if thinkingSignature != "" {
|
||||
block, _ = sjson.SetBytes(block, "signature", thinkingSignature)
|
||||
}
|
||||
blocks = append(blocks, block)
|
||||
thinkingBuilder.Reset()
|
||||
thinkingSignature = ""
|
||||
}
|
||||
|
||||
if parts.IsArray() {
|
||||
for _, part := range parts.Array() {
|
||||
thoughtSignatureResult := part.Get("thoughtSignature")
|
||||
if !thoughtSignatureResult.Exists() {
|
||||
thoughtSignatureResult = part.Get("thought_signature")
|
||||
}
|
||||
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
|
||||
if hasThoughtSignature {
|
||||
thinkingSignature = thoughtSignatureResult.String()
|
||||
}
|
||||
|
||||
text := part.Get("text")
|
||||
functionCall := part.Get("functionCall")
|
||||
|
||||
if hasThoughtSignature && (!text.Exists() || text.String() == "") && !functionCall.Exists() {
|
||||
continue
|
||||
}
|
||||
|
||||
if text.Exists() && text.String() != "" {
|
||||
if part.Get("thought").Bool() || hasThoughtSignature {
|
||||
flushText()
|
||||
thinkingBuilder.WriteString(text.String())
|
||||
continue
|
||||
}
|
||||
flushThinking()
|
||||
textBuilder.WriteString(text.String())
|
||||
continue
|
||||
}
|
||||
|
||||
if functionCall.Exists() {
|
||||
flushThinking()
|
||||
flushText()
|
||||
hasToolCall = true
|
||||
|
||||
upstreamToolName := functionCall.Get("name").String()
|
||||
upstreamToolName = util.RestoreSanitizedToolName(sanitizedNameMap, upstreamToolName)
|
||||
clientToolName := util.MapToolName(toolNameMap, upstreamToolName)
|
||||
toolIDCounter++
|
||||
toolBlock := []byte(`{"type":"tool_use","id":"","name":"","input":{}}`)
|
||||
toolBlock, _ = sjson.SetBytes(toolBlock, "id", util.SanitizeClaudeToolID(fmt.Sprintf("%s-%d", upstreamToolName, toolIDCounter)))
|
||||
toolBlock, _ = sjson.SetBytes(toolBlock, "name", clientToolName)
|
||||
inputRaw := "{}"
|
||||
if args := functionCall.Get("args"); args.Exists() && gjson.Valid(args.Raw) && args.IsObject() {
|
||||
inputRaw = args.Raw
|
||||
}
|
||||
toolBlock, _ = sjson.SetRawBytes(toolBlock, "input", []byte(inputRaw))
|
||||
blocks = append(blocks, toolBlock)
|
||||
continue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flushThinking()
|
||||
flushText()
|
||||
|
||||
if len(blocks) > 0 {
|
||||
out, _ = sjson.SetRawBytes(out, "content", translatorcommon.JoinRawArray(blocks))
|
||||
}
|
||||
|
||||
stopReason := "end_turn"
|
||||
if hasToolCall {
|
||||
stopReason = "tool_use"
|
||||
} else {
|
||||
if finish := root.Get("candidates.0.finishReason"); finish.Exists() {
|
||||
switch finish.String() {
|
||||
case "MAX_TOKENS":
|
||||
stopReason = "max_tokens"
|
||||
case "STOP", "FINISH_REASON_UNSPECIFIED", "UNKNOWN":
|
||||
stopReason = "end_turn"
|
||||
default:
|
||||
stopReason = "end_turn"
|
||||
}
|
||||
}
|
||||
}
|
||||
out, _ = sjson.SetBytes(out, "stop_reason", stopReason)
|
||||
|
||||
if inputTokens == int64(0) && outputTokens == int64(0) && !root.Get("usageMetadata").Exists() {
|
||||
out, _ = sjson.DeleteBytes(out, "usage")
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func ClaudeTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.ClaudeInputTokensJSON(count)
|
||||
}
|
||||
|
|
@ -0,0 +1,204 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiResponseToClaude_SignatureOnlyPartDoesNotOpenEmptyTextBlock(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-test","messages":[{"role":"user","content":[{"type":"text","text":"hi"}]}]}`)
|
||||
thinkingChunk := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{"text": "thinking text", "thought": true}]
|
||||
}
|
||||
}],
|
||||
"modelVersion": "gemini-test",
|
||||
"responseId": "resp-test"
|
||||
}`)
|
||||
signatureChunk := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [{"text": "", "thoughtSignature": "sig-test"}]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"thoughtsTokenCount": 2,
|
||||
"totalTokenCount": 12
|
||||
},
|
||||
"modelVersion": "gemini-test",
|
||||
"responseId": "resp-test"
|
||||
}`)
|
||||
|
||||
var param any
|
||||
ctx := context.Background()
|
||||
output := bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, thinkingChunk, ¶m), nil)
|
||||
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, signatureChunk, ¶m), nil)...)
|
||||
output = append(output, bytes.Join(ConvertGeminiResponseToClaude(ctx, "gemini-test", requestJSON, requestJSON, []byte("[DONE]"), ¶m), nil)...)
|
||||
outputText := string(output)
|
||||
|
||||
if strings.Contains(outputText, `"content_block":{"type":"text"`) {
|
||||
t.Fatalf("signature-only part must not open an empty text block: %s", outputText)
|
||||
}
|
||||
if strings.Contains(outputText, `"type":"content_block_stop","index":1`) {
|
||||
t.Fatalf("signature-only part must not produce a stop for unopened index 1: %s", outputText)
|
||||
}
|
||||
if !strings.Contains(outputText, `"type":"signature_delta"`) || !strings.Contains(outputText, `"signature":"sig-test"`) {
|
||||
t.Fatalf("signature-only part must be emitted as a thinking signature delta: %s", outputText)
|
||||
}
|
||||
if got := strings.Count(outputText, `"type":"content_block_stop","index":0`); got != 1 {
|
||||
t.Fatalf("expected exactly one stop for thinking index 0, got %d: %s", got, outputText)
|
||||
}
|
||||
if !strings.Contains(outputText, `"type":"message_delta"`) || !strings.Contains(outputText, `"output_tokens":2`) {
|
||||
t.Fatalf("finish chunk without candidatesTokenCount must still emit final message_delta: %s", outputText)
|
||||
}
|
||||
if !strings.Contains(outputText, `"type":"message_stop"`) {
|
||||
t.Fatalf("DONE chunk must still emit message_stop after final events: %s", outputText)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_PreservesThoughtSignature(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "thinking step 1\n", "thought": true},
|
||||
{"text": "thinking step 2", "thought": true, "thoughtSignature": "sig-xyz-123"},
|
||||
{"text": "visible answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "thinking step 1\nthinking step 2" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-xyz-123" {
|
||||
t.Fatalf("expected signature 'sig-xyz-123', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_PartWithThoughtSignatureWithoutThoughtBool(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "inferred reasoning", "thought_signature": "sig-snake-case"},
|
||||
{"text": "final answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream-2"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "inferred reasoning" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-snake-case" {
|
||||
t.Fatalf("expected signature 'sig-snake-case', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "final answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToClaudeNonStream_TrailingSignatureOnlyPart(t *testing.T) {
|
||||
requestJSON := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":"hi"}]}`)
|
||||
geminiResponse := []byte(`{
|
||||
"candidates": [{
|
||||
"content": {
|
||||
"parts": [
|
||||
{"text": "thinking step 1\n", "thought": true},
|
||||
{"text": "", "thoughtSignature": "sig-trailing"},
|
||||
{"text": "visible answer"}
|
||||
]
|
||||
},
|
||||
"finishReason": "STOP"
|
||||
}],
|
||||
"usageMetadata": {
|
||||
"promptTokenCount": 10,
|
||||
"candidatesTokenCount": 5
|
||||
},
|
||||
"modelVersion": "gemini-2.5-pro",
|
||||
"responseId": "resp-non-stream-trailing"
|
||||
}`)
|
||||
|
||||
ctx := context.Background()
|
||||
output := ConvertGeminiResponseToClaudeNonStream(ctx, "gemini-2.5-pro", requestJSON, requestJSON, geminiResponse, nil)
|
||||
outputJSON := gjson.ParseBytes(output)
|
||||
|
||||
blocks := outputJSON.Get("content").Array()
|
||||
if len(blocks) != 2 {
|
||||
t.Fatalf("expected 2 content blocks (thinking + text), got %d: %s", len(blocks), string(output))
|
||||
}
|
||||
|
||||
thinkingBlock := blocks[0]
|
||||
if thinkingBlock.Get("type").String() != "thinking" {
|
||||
t.Fatalf("expected first block to be thinking, got %s", thinkingBlock.Get("type").String())
|
||||
}
|
||||
if thinkingBlock.Get("thinking").String() != "thinking step 1\n" {
|
||||
t.Fatalf("unexpected thinking content: %s", thinkingBlock.Get("thinking").String())
|
||||
}
|
||||
if thinkingBlock.Get("signature").String() != "sig-trailing" {
|
||||
t.Fatalf("expected signature 'sig-trailing', got %q. Output: %s", thinkingBlock.Get("signature").String(), string(output))
|
||||
}
|
||||
|
||||
textBlock := blocks[1]
|
||||
if textBlock.Get("type").String() != "text" || textBlock.Get("text").String() != "visible answer" {
|
||||
t.Fatalf("unexpected text block: %s", textBlock.Raw)
|
||||
}
|
||||
}
|
||||
20
backend/internal/translator/gemini/claude/init.go
Normal file
20
backend/internal/translator/gemini/claude/init.go
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
package claude
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
Claude,
|
||||
Gemini,
|
||||
ConvertClaudeRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertGeminiResponseToClaude,
|
||||
NonStream: ConvertGeminiResponseToClaudeNonStream,
|
||||
TokenCount: ClaudeTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
47
backend/internal/translator/gemini/common/safety.go
Normal file
47
backend/internal/translator/gemini/common/safety.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package common
|
||||
|
||||
import (
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// DefaultSafetySettings returns the default Gemini safety configuration we attach to requests.
|
||||
func DefaultSafetySettings() []map[string]string {
|
||||
return []map[string]string{
|
||||
{
|
||||
"category": "HARM_CATEGORY_HARASSMENT",
|
||||
"threshold": "OFF",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_HATE_SPEECH",
|
||||
"threshold": "OFF",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_SEXUALLY_EXPLICIT",
|
||||
"threshold": "OFF",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_DANGEROUS_CONTENT",
|
||||
"threshold": "OFF",
|
||||
},
|
||||
{
|
||||
"category": "HARM_CATEGORY_CIVIC_INTEGRITY",
|
||||
"threshold": "BLOCK_NONE",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// AttachDefaultSafetySettings ensures the default safety settings are present when absent.
|
||||
// The caller must provide the target JSON path (e.g. "safetySettings" or "request.safetySettings").
|
||||
func AttachDefaultSafetySettings(rawJSON []byte, path string) []byte {
|
||||
if gjson.GetBytes(rawJSON, path).Exists() {
|
||||
return rawJSON
|
||||
}
|
||||
|
||||
out, err := sjson.SetBytes(rawJSON, path, DefaultSafetySettings())
|
||||
if err != nil {
|
||||
return rawJSON
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,310 @@
|
|||
// Package gemini provides in-provider request normalization for Gemini API.
|
||||
// It ensures incoming v1beta requests meet minimal schema requirements
|
||||
// expected by Google's Generative Language API.
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// ConvertGeminiRequestToGemini normalizes Gemini v1beta requests.
|
||||
// - Adds a default role for each content if missing or invalid.
|
||||
// The first message defaults to "user", then alternates user/model when needed.
|
||||
//
|
||||
// It keeps the payload otherwise unchanged.
|
||||
func ConvertGeminiRequestToGemini(_ string, inputRawJSON []byte, _ bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
// Fast path: if no contents field, only attach safety settings
|
||||
contents := util.GetGJSONBytesNoCopy(rawJSON, "contents")
|
||||
if !contents.Exists() {
|
||||
return common.AttachDefaultSafetySettings(rawJSON, "safetySettings")
|
||||
}
|
||||
|
||||
toolsResult := gjson.GetBytes(rawJSON, "tools")
|
||||
if toolsResult.Exists() && toolsResult.IsArray() {
|
||||
var toolItems [][]byte
|
||||
toolsChanged := false
|
||||
toolsResult.ForEach(func(_, toolResult gjson.Result) bool {
|
||||
tool := []byte(toolResult.Raw)
|
||||
toolChanged := false
|
||||
if declarations := toolResult.Get("functionDeclarations"); declarations.Exists() {
|
||||
tool, _ = sjson.SetRawBytes(tool, "function_declarations", []byte(declarations.Raw))
|
||||
tool, _ = sjson.DeleteBytes(tool, "functionDeclarations")
|
||||
toolChanged = true
|
||||
}
|
||||
|
||||
declarations := gjson.GetBytes(tool, "function_declarations")
|
||||
if declarations.IsArray() {
|
||||
var declarationItems [][]byte
|
||||
declarationsChanged := false
|
||||
declarations.ForEach(func(_, declarationResult gjson.Result) bool {
|
||||
declaration := []byte(declarationResult.Raw)
|
||||
if parameters := declarationResult.Get("parameters"); parameters.Exists() {
|
||||
declaration, _ = sjson.SetRawBytes(declaration, "parametersJsonSchema", []byte(parameters.Raw))
|
||||
declaration, _ = sjson.DeleteBytes(declaration, "parameters")
|
||||
declarationsChanged = true
|
||||
}
|
||||
declarationItems = append(declarationItems, declaration)
|
||||
return true
|
||||
})
|
||||
if declarationsChanged {
|
||||
tool, _ = sjson.SetRawBytes(tool, "function_declarations", translatorcommon.JoinRawArray(declarationItems))
|
||||
toolChanged = true
|
||||
}
|
||||
}
|
||||
toolsChanged = toolsChanged || toolChanged
|
||||
toolItems = append(toolItems, tool)
|
||||
return true
|
||||
})
|
||||
if toolsChanged {
|
||||
rawJSON, _ = sjson.SetRawBytes(rawJSON, "tools", translatorcommon.JoinRawArray(toolItems))
|
||||
}
|
||||
}
|
||||
|
||||
// Walk contents and fix roles
|
||||
out := rawJSON
|
||||
prevRole := ""
|
||||
if contents.IsArray() {
|
||||
rolesChanged := false
|
||||
contents.ForEach(func(_, value gjson.Result) bool {
|
||||
role := value.Get("role").String()
|
||||
if role != "user" && role != "model" {
|
||||
role = nextGeminiRole(prevRole)
|
||||
rolesChanged = true
|
||||
}
|
||||
prevRole = role
|
||||
return true
|
||||
})
|
||||
if rolesChanged {
|
||||
prevRole = ""
|
||||
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
|
||||
contents.ForEach(func(_, value gjson.Result) bool {
|
||||
role := value.Get("role").String()
|
||||
item := []byte(value.Raw)
|
||||
if role != "user" && role != "model" {
|
||||
role = nextGeminiRole(prevRole)
|
||||
item, _ = sjson.SetBytes(item, "role", role)
|
||||
}
|
||||
prevRole = role
|
||||
contentItems = append(contentItems, item)
|
||||
return true
|
||||
})
|
||||
out, _ = sjson.SetRawBytes(out, "contents", translatorcommon.JoinRawArray(contentItems))
|
||||
}
|
||||
} else {
|
||||
idx := 0
|
||||
contents.ForEach(func(_ gjson.Result, value gjson.Result) bool {
|
||||
role := value.Get("role").String()
|
||||
if role != "user" && role != "model" {
|
||||
role = nextGeminiRole(prevRole)
|
||||
out, _ = sjson.SetBytes(out, fmt.Sprintf("contents.%d.role", idx), role)
|
||||
}
|
||||
prevRole = role
|
||||
idx++
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
out = signature.SanitizeGeminiRequestThoughtSignatures(out, "contents")
|
||||
|
||||
if gjson.GetBytes(rawJSON, "generationConfig.responseSchema").Exists() {
|
||||
strJson, _ := util.RenameKey(string(out), "generationConfig.responseSchema", "generationConfig.responseJsonSchema")
|
||||
out = []byte(strJson)
|
||||
}
|
||||
|
||||
// Backfill empty functionResponse.name from the preceding functionCall.name.
|
||||
// Some clients send function responses with empty names; the Gemini API rejects these.
|
||||
out = backfillEmptyFunctionResponseNames(out)
|
||||
|
||||
out = common.AttachDefaultSafetySettings(out, "safetySettings")
|
||||
return out
|
||||
}
|
||||
|
||||
// backfillEmptyFunctionResponseNames walks the contents array and for each
|
||||
// model turn containing functionCall parts, records the call names in order.
|
||||
// For the immediately following user/function turn containing functionResponse
|
||||
// parts, any empty name is replaced with the corresponding call name.
|
||||
func backfillEmptyFunctionResponseNames(data []byte) []byte {
|
||||
contents := util.GetGJSONBytesNoCopy(data, "contents")
|
||||
if !contents.Exists() {
|
||||
return data
|
||||
}
|
||||
canBatch := contents.IsArray()
|
||||
if canBatch {
|
||||
contents.ForEach(func(_, content gjson.Result) bool {
|
||||
parts := content.Get("parts")
|
||||
if parts.Exists() && !parts.IsArray() {
|
||||
canBatch = false
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
if !canBatch {
|
||||
return backfillEmptyFunctionResponseNamesLegacy(data, contents)
|
||||
}
|
||||
needsBackfill, excessResponseIndexes := geminiFunctionResponseNamesNeedBackfill(contents)
|
||||
if !needsBackfill {
|
||||
for _, contentIndex := range excessResponseIndexes {
|
||||
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIndex)
|
||||
}
|
||||
return data
|
||||
}
|
||||
|
||||
changed := false
|
||||
contentItems := translatorcommon.NewRawArrayItems(contents.Get("#").Int())
|
||||
var pendingCallNames []string
|
||||
|
||||
contents.ForEach(func(contentIdx, content gjson.Result) bool {
|
||||
role := content.Get("role").String()
|
||||
contentRaw := []byte(content.Raw)
|
||||
|
||||
// Collect functionCall names from model turns.
|
||||
if role == "model" {
|
||||
var names []string
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionCall").Exists() {
|
||||
names = append(names, part.Get("functionCall.name").String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
pendingCallNames = names
|
||||
contentItems = append(contentItems, contentRaw)
|
||||
return true
|
||||
}
|
||||
|
||||
// Backfill empty functionResponse names from pending call names.
|
||||
if len(pendingCallNames) > 0 {
|
||||
responseIndex := 0
|
||||
partsChanged := false
|
||||
partItems := make([][]byte, 0, 4)
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
partRaw := []byte(part.Raw)
|
||||
if part.Get("functionResponse").Exists() {
|
||||
name := part.Get("functionResponse.name").String()
|
||||
if strings.TrimSpace(name) == "" {
|
||||
if responseIndex < len(pendingCallNames) {
|
||||
partRaw, _ = sjson.SetBytes(partRaw, "functionResponse.name", pendingCallNames[responseIndex])
|
||||
partsChanged = true
|
||||
} else {
|
||||
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
|
||||
}
|
||||
}
|
||||
responseIndex++
|
||||
}
|
||||
partItems = append(partItems, partRaw)
|
||||
return true
|
||||
})
|
||||
if partsChanged {
|
||||
contentRaw, _ = sjson.SetRawBytes(contentRaw, "parts", translatorcommon.JoinRawArray(partItems))
|
||||
changed = true
|
||||
}
|
||||
pendingCallNames = nil
|
||||
}
|
||||
|
||||
contentItems = append(contentItems, contentRaw)
|
||||
return true
|
||||
})
|
||||
|
||||
if !changed {
|
||||
return data
|
||||
}
|
||||
out, errSetContents := sjson.SetRawBytes(data, "contents", translatorcommon.JoinRawArray(contentItems))
|
||||
if errSetContents != nil {
|
||||
return data
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func geminiFunctionResponseNamesNeedBackfill(contents gjson.Result) (bool, []int64) {
|
||||
var pendingCallNames []string
|
||||
var excessResponseIndexes []int64
|
||||
needsBackfill := false
|
||||
contents.ForEach(func(contentIdx, content gjson.Result) bool {
|
||||
if content.Get("role").String() == "model" {
|
||||
var names []string
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionCall").Exists() {
|
||||
names = append(names, part.Get("functionCall.name").String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
pendingCallNames = names
|
||||
return true
|
||||
}
|
||||
if len(pendingCallNames) == 0 {
|
||||
return true
|
||||
}
|
||||
responseIndex := 0
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionResponse").Exists() {
|
||||
if strings.TrimSpace(part.Get("functionResponse.name").String()) == "" {
|
||||
if responseIndex < len(pendingCallNames) {
|
||||
needsBackfill = true
|
||||
return false
|
||||
}
|
||||
excessResponseIndexes = append(excessResponseIndexes, contentIdx.Int())
|
||||
}
|
||||
responseIndex++
|
||||
}
|
||||
return true
|
||||
})
|
||||
pendingCallNames = nil
|
||||
return !needsBackfill
|
||||
})
|
||||
return needsBackfill, excessResponseIndexes
|
||||
}
|
||||
|
||||
func backfillEmptyFunctionResponseNamesLegacy(data []byte, contents gjson.Result) []byte {
|
||||
out := data
|
||||
var pendingCallNames []string
|
||||
contents.ForEach(func(contentIdx, content gjson.Result) bool {
|
||||
if content.Get("role").String() == "model" {
|
||||
var names []string
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionCall").Exists() {
|
||||
names = append(names, part.Get("functionCall.name").String())
|
||||
}
|
||||
return true
|
||||
})
|
||||
pendingCallNames = names
|
||||
return true
|
||||
}
|
||||
if len(pendingCallNames) > 0 {
|
||||
responseIndex := 0
|
||||
content.Get("parts").ForEach(func(partIdx, part gjson.Result) bool {
|
||||
if part.Get("functionResponse").Exists() {
|
||||
if strings.TrimSpace(part.Get("functionResponse.name").String()) == "" {
|
||||
if responseIndex < len(pendingCallNames) {
|
||||
path := fmt.Sprintf("contents.%d.parts.%d.functionResponse.name", contentIdx.Int(), partIdx.Int())
|
||||
out, _ = sjson.SetBytes(out, path, pendingCallNames[responseIndex])
|
||||
} else {
|
||||
log.Debugf("more function responses than calls at contents[%d], skipping name backfill", contentIdx.Int())
|
||||
}
|
||||
}
|
||||
responseIndex++
|
||||
}
|
||||
return true
|
||||
})
|
||||
pendingCallNames = nil
|
||||
}
|
||||
return true
|
||||
})
|
||||
return out
|
||||
}
|
||||
|
||||
func nextGeminiRole(previousRole string) string {
|
||||
if previousRole == "" || previousRole == "model" {
|
||||
return "user"
|
||||
}
|
||||
return "model"
|
||||
}
|
||||
|
|
@ -0,0 +1,255 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const largeInlineDataSize = 20 << 20
|
||||
|
||||
var largeInlineDataBenchmarkOutput []byte
|
||||
|
||||
func TestConvertGeminiRequestToGeminiReusesLargeNormalizedPayload(t *testing.T) {
|
||||
input := largeInlineDataGeminiRequest(true)
|
||||
|
||||
// Assert the reuse invariant with t.Fatal rather than inside testing.Benchmark:
|
||||
// a failing benchmark aborts before any iteration completes and yields a zero
|
||||
// BenchmarkResult, so AllocedBytesPerOp would report 0 and silently satisfy the
|
||||
// allocation check below exactly when the payload is being copied.
|
||||
output := ConvertGeminiRequestToGemini("gemini-test", input, false)
|
||||
if &output[0] != &input[0] {
|
||||
t.Fatal("normalized request should reuse the input payload")
|
||||
}
|
||||
largeInlineDataBenchmarkOutput = output
|
||||
|
||||
result := testing.Benchmark(func(b *testing.B) {
|
||||
b.ReportAllocs()
|
||||
for b.Loop() {
|
||||
largeInlineDataBenchmarkOutput = ConvertGeminiRequestToGemini("gemini-test", input, false)
|
||||
}
|
||||
})
|
||||
|
||||
if result.N == 0 {
|
||||
t.Fatal("allocation benchmark did not complete an iteration")
|
||||
}
|
||||
if allocated := result.AllocedBytesPerOp(); allocated >= 1<<20 {
|
||||
t.Fatalf("normalized 20 MiB inlineData request allocated %d bytes/op, want less than 1 MiB", allocated)
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkConvertGeminiRequestToGeminiLargeInlineData(b *testing.B) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
includeSafetySettings bool
|
||||
}{
|
||||
{name: "normalized_passthrough", includeSafetySettings: true},
|
||||
{name: "attach_default_safety", includeSafetySettings: false},
|
||||
} {
|
||||
b.Run(test.name, func(b *testing.B) {
|
||||
input := largeInlineDataGeminiRequest(test.includeSafetySettings)
|
||||
b.ReportAllocs()
|
||||
b.SetBytes(int64(len(input)))
|
||||
b.ResetTimer()
|
||||
for b.Loop() {
|
||||
largeInlineDataBenchmarkOutput = ConvertGeminiRequestToGemini("gemini-test", input, false)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func largeInlineDataGeminiRequest(includeSafetySettings bool) []byte {
|
||||
prefix := `{"contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"video/mp4","data":"`
|
||||
suffix := `"}}]}]`
|
||||
if includeSafetySettings {
|
||||
suffix += `,"safetySettings":[]`
|
||||
}
|
||||
return []byte(prefix + strings.Repeat("A", largeInlineDataSize) + suffix + `}`)
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_Single(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"output": "file1.txt"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
if name != "Bash" {
|
||||
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_Parallel(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Read", "args": {"path": "/a"}}},
|
||||
{"functionCall": {"name": "Grep", "args": {"pattern": "x"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "content a"}}},
|
||||
{"functionResponse": {"name": "", "response": {"result": "match x"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String()
|
||||
if name0 != "Read" {
|
||||
t.Errorf("Expected first name 'Read', got '%s'", name0)
|
||||
}
|
||||
if name1 != "Grep" {
|
||||
t.Errorf("Expected second name 'Grep', got '%s'", name1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_PreservesExisting(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "Bash", "response": {"result": "ok"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
if name != "Bash" {
|
||||
t.Errorf("Expected preserved name 'Bash', got '%s'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToGemini_BackfillsEmptyName(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {"cmd": "ls"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"output": "file1.txt"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := ConvertGeminiRequestToGemini("", input, false)
|
||||
|
||||
name := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
if name != "Bash" {
|
||||
t.Errorf("Expected backfilled name 'Bash', got '%s'", name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_MoreResponsesThanCalls(t *testing.T) {
|
||||
// Extra responses beyond the call count should not panic and should be left unchanged.
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Bash", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "ok"}}},
|
||||
{"functionResponse": {"name": "", "response": {"result": "extra"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
if name0 != "Bash" {
|
||||
t.Errorf("Expected first name 'Bash', got '%s'", name0)
|
||||
}
|
||||
// Second response has no matching call, should remain empty
|
||||
name1 := gjson.GetBytes(out, "contents.1.parts.1.functionResponse.name").String()
|
||||
if name1 != "" {
|
||||
t.Errorf("Expected second name to remain empty, got '%s'", name1)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBackfillEmptyFunctionResponseNames_MultipleGroups(t *testing.T) {
|
||||
// Two sequential call/response groups should each get correct names.
|
||||
input := []byte(`{
|
||||
"contents": [
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Read", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "content"}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "model",
|
||||
"parts": [
|
||||
{"functionCall": {"name": "Grep", "args": {}}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"parts": [
|
||||
{"functionResponse": {"name": "", "response": {"result": "match"}}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`)
|
||||
|
||||
out := backfillEmptyFunctionResponseNames(input)
|
||||
|
||||
name0 := gjson.GetBytes(out, "contents.1.parts.0.functionResponse.name").String()
|
||||
name1 := gjson.GetBytes(out, "contents.3.parts.0.functionResponse.name").String()
|
||||
if name0 != "Read" {
|
||||
t.Errorf("Expected first group name 'Read', got '%s'", name0)
|
||||
}
|
||||
if name1 != "Grep" {
|
||||
t.Errorf("Expected second group name 'Grep', got '%s'", name1)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
)
|
||||
|
||||
// PassthroughGeminiResponseStream forwards Gemini responses unchanged.
|
||||
func PassthroughGeminiResponseStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) [][]byte {
|
||||
if bytes.HasPrefix(rawJSON, []byte("data:")) {
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
}
|
||||
|
||||
if bytes.Equal(rawJSON, []byte("[DONE]")) {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
return [][]byte{rawJSON}
|
||||
}
|
||||
|
||||
// PassthroughGeminiResponseNonStream forwards Gemini responses unchanged.
|
||||
func PassthroughGeminiResponseNonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
return rawJSON
|
||||
}
|
||||
|
||||
func GeminiTokenCount(ctx context.Context, count int64) []byte {
|
||||
return translatorcommon.GeminiTokenCountJSON(count)
|
||||
}
|
||||
22
backend/internal/translator/gemini/gemini/init.go
Normal file
22
backend/internal/translator/gemini/gemini/init.go
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
package gemini
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
// Register a no-op response translator and a request normalizer for Gemini→Gemini.
|
||||
// The request converter ensures missing or invalid roles are normalized to valid values.
|
||||
func init() {
|
||||
translator.Register(
|
||||
Gemini,
|
||||
Gemini,
|
||||
ConvertGeminiRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: PassthroughGeminiResponseStream,
|
||||
NonStream: PassthroughGeminiResponseNonStream,
|
||||
TokenCount: GeminiTokenCount,
|
||||
},
|
||||
)
|
||||
}
|
||||
37
backend/internal/translator/gemini/interactions/init.go
Normal file
37
backend/internal/translator/gemini/interactions/init.go
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
package interactions
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
Interactions,
|
||||
Interactions,
|
||||
ConvertInteractionsRequestToInteractions,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertInteractionsResponsePassthrough,
|
||||
NonStream: ConvertInteractionsResponsePassthroughNonStream,
|
||||
},
|
||||
)
|
||||
translator.Register(
|
||||
Interactions,
|
||||
Gemini,
|
||||
ConvertInteractionsRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertGeminiResponseToInteractions,
|
||||
NonStream: ConvertGeminiResponseToInteractionsNonStream,
|
||||
},
|
||||
)
|
||||
translator.Register(
|
||||
Gemini,
|
||||
Interactions,
|
||||
ConvertGeminiRequestToInteractions,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertInteractionsResponseToGemini,
|
||||
NonStream: ConvertInteractionsResponseToGeminiNonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,756 @@
|
|||
package interactions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiStringInput(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":"hello"}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
|
||||
t.Fatalf("role = %q, want user", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hello" {
|
||||
t.Fatalf("text = %q, want hello", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiSystemAndGenerationConfig(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","system_instruction":{"text":"be brief"},"generation_config":{"max_output_tokens":32,"top_p":0.8},"input":"hi"}`), false)
|
||||
if got := gjson.GetBytes(out, "systemInstruction.parts.0.text").String(); got != "be brief" {
|
||||
t.Fatalf("systemInstruction = %q, want be brief", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
|
||||
t.Fatalf("maxOutputTokens = %d, want 32", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.topP").Float(); got != 0.8 {
|
||||
t.Fatalf("topP = %v, want 0.8", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiStringSystemInstruction(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","system_instruction":"be brief","input":"hi"}`), false)
|
||||
if got := gjson.GetBytes(out, "systemInstruction.parts.0.text").String(); got != "be brief" {
|
||||
t.Fatalf("systemInstruction.parts.0.text = %q, want be brief. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToInteractionsStringSystemInstruction(t *testing.T) {
|
||||
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","systemInstruction":{"parts":[{"text":"be brief"},{"text":"answer directly"}]},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
|
||||
sys := gjson.GetBytes(out, "system_instruction")
|
||||
if sys.Type != gjson.String {
|
||||
t.Fatalf("system_instruction type = %v, want string. Output: %s", sys.Type, string(out))
|
||||
}
|
||||
if got := sys.String(); got != "be brief\nanswer directly" {
|
||||
t.Fatalf("system_instruction = %q, want merged text. Output: %s", got, string(out))
|
||||
}
|
||||
if gjson.GetBytes(out, "system_instruction.parts").Exists() {
|
||||
t.Fatalf("system_instruction.parts should not be forwarded. Output: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsNonStream(t *testing.T) {
|
||||
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}}`))
|
||||
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "model_output" {
|
||||
t.Fatalf("step type = %q, want model_output", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "steps.0.content.0.text").String(); got != "ok" {
|
||||
t.Fatalf("text = %q, want ok", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "usage.total_tokens").Int(); got != 3 {
|
||||
t.Fatalf("total tokens = %d, want 3", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsNonStreamSnakeCaseUsage(t *testing.T) {
|
||||
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_snake","candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usage_metadata":{"prompt_token_count":11,"candidates_token_count":22,"total_token_count":33,"thoughts_token_count":44,"cached_content_token_count":55}}`))
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
want int64
|
||||
}{
|
||||
{"usage.input_tokens", 11},
|
||||
{"usage.output_tokens", 22},
|
||||
{"usage.reasoning_tokens", 44},
|
||||
{"usage.total_tokens", 33},
|
||||
{"usage.cached_tokens", 55},
|
||||
} {
|
||||
if got := gjson.GetBytes(out, test.path).Int(); got != test.want {
|
||||
t.Fatalf("%s = %d, want %d. Output: %s", test.path, got, test.want, string(out))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsResponseToGeminiStreamFunctionCall(t *testing.T) {
|
||||
var param any
|
||||
created := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"interaction":{"id":"i1","model":"gemini-3.1-flash-lite"},"event_type":"interaction.created"}`), ¶m)
|
||||
if len(created) != 0 {
|
||||
t.Fatalf("created output count = %d, want 0", len(created))
|
||||
}
|
||||
start := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"index":0,"step":{"type":"function_call","id":"call_1","signature":"sig_1","name":"get_weather","arguments":{}},"event_type":"step.start"}`), ¶m)
|
||||
if len(start) != 0 {
|
||||
t.Fatalf("start output count = %d, want 0", len(start))
|
||||
}
|
||||
delta := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"index":0,"delta":{"type":"arguments_delta","arguments":"{\"location\":\"北京\"}"},"event_type":"step.delta"}`), ¶m)
|
||||
if len(delta) != 1 {
|
||||
t.Fatalf("delta output count = %d, want 1", len(delta))
|
||||
}
|
||||
if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
|
||||
t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(delta[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
|
||||
t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(delta[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.functionCall.id").String(); got != "call_1" {
|
||||
t.Fatalf("functionCall.id = %q, want call_1. Payload: %s", got, string(delta[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(delta[0], "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
|
||||
t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(delta[0]))
|
||||
}
|
||||
completed := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`data: {"interaction":{"id":"i1","status":"requires_action","usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5,"total_thought_tokens":1,"total_cached_tokens":4},"service_tier":"standard","model":"gemini-3.1-flash-lite"},"event_type":"interaction.completed"}`), ¶m)
|
||||
if len(completed) != 1 {
|
||||
t.Fatalf("completed output count = %d, want 1", len(completed))
|
||||
}
|
||||
if got := gjson.GetBytes(completed[0], "candidates.0.finishReason").String(); got != "STOP" {
|
||||
t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(completed[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(completed[0], "usageMetadata.promptTokenCount").Int(); got != 2 {
|
||||
t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(completed[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(completed[0], "usageMetadata.candidatesTokenCount").Int(); got != 3 {
|
||||
t.Fatalf("candidatesTokenCount = %d, want 3. Payload: %s", got, string(completed[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(completed[0], "usageMetadata.totalTokenCount").Int(); got != 5 {
|
||||
t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(completed[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(completed[0], "usageMetadata.promptTokensDetails.0.tokenCount").Int(); got != 2 {
|
||||
t.Fatalf("promptTokensDetails.0.tokenCount = %d, want 2. Payload: %s", got, string(completed[0]))
|
||||
}
|
||||
done := ConvertInteractionsResponseToGemini(context.Background(), "gemini-3.1-flash-lite", nil, nil, []byte(`event: done
|
||||
data: [DONE]`), ¶m)
|
||||
if len(done) != 0 {
|
||||
t.Fatalf("done output count = %d, want 0", len(done))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsResponseToGeminiStreamFinishMetadataUsage(t *testing.T) {
|
||||
var param any
|
||||
out := ConvertInteractionsResponseToGemini(context.Background(), "gemini-test", nil, nil, []byte(`data: {"event_type":"finish","metadata":{"total_usage":{"total_input_tokens":2,"total_output_tokens":6,"total_thought_tokens":3,"total_cached_tokens":1,"total_tokens":11}}}`), ¶m)
|
||||
if len(out) != 1 {
|
||||
t.Fatalf("output count = %d, want 1", len(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "candidates.0.finishReason").String(); got != "STOP" {
|
||||
t.Fatalf("finishReason = %q, want STOP. Payload: %s", got, string(out[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "usageMetadata.promptTokenCount").Int(); got != 2 {
|
||||
t.Fatalf("promptTokenCount = %d, want 2. Payload: %s", got, string(out[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "usageMetadata.candidatesTokenCount").Int(); got != 6 {
|
||||
t.Fatalf("candidatesTokenCount = %d, want 6. Payload: %s", got, string(out[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "usageMetadata.thoughtsTokenCount").Int(); got != 3 {
|
||||
t.Fatalf("thoughtsTokenCount = %d, want 3. Payload: %s", got, string(out[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "usageMetadata.cachedContentTokenCount").Int(); got != 1 {
|
||||
t.Fatalf("cachedContentTokenCount = %d, want 1. Payload: %s", got, string(out[0]))
|
||||
}
|
||||
if got := gjson.GetBytes(out[0], "usageMetadata.totalTokenCount").Int(); got != 11 {
|
||||
t.Fatalf("totalTokenCount = %d, want 11. Payload: %s", got, string(out[0]))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsResponseToGeminiNonStreamFunctionCall(t *testing.T) {
|
||||
raw := []byte(`{"id":"i1","model":"gemini-3.1-flash-lite","steps":[{"type":"function_call","call_id":"call_1","signature":"sig_1","name":"get_weather","arguments":{"location":"北京"}}],"usage":{"total_input_tokens":2,"total_output_tokens":3,"total_tokens":5}}`)
|
||||
out := ConvertInteractionsResponseToGeminiNonStream(context.Background(), "gemini-3.1-flash-lite", nil, nil, raw, nil)
|
||||
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.name").String(); got != "get_weather" {
|
||||
t.Fatalf("functionCall.name = %q, want get_weather. Payload: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.functionCall.args.location").String(); got != "北京" {
|
||||
t.Fatalf("functionCall.args.location = %q, want 北京. Payload: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "candidates.0.content.parts.0.thoughtSignature").String(); got != "sig_1" {
|
||||
t.Fatalf("thoughtSignature = %q, want sig_1. Payload: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "usageMetadata.totalTokenCount").Int(); got != 5 {
|
||||
t.Fatalf("totalTokenCount = %d, want 5. Payload: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiTurnInput(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"user","steps":[{"type":"user_input","content":[{"text":"hi"}]}]}}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
|
||||
t.Fatalf("text = %q, want hi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiTurnArrayInput(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"role":"user","steps":[{"type":"user_input","content":[{"text":"hi"}]}]},{"role":"assistant","steps":[{"type":"model_output","content":[{"text":"ok"}]}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
|
||||
t.Fatalf("contents.0.role = %q, want user. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
|
||||
t.Fatalf("contents.0.parts.0.text = %q, want hi. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
|
||||
t.Fatalf("contents.1.role = %q, want model. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.1.parts.0.text").String(); got != "ok" {
|
||||
t.Fatalf("contents.1.parts.0.text = %q, want ok. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiPreservesExpressibleTopLevelFields(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","tool_choice":{"type":"function","function":{"name":"lookup"}},"response_modalities":["text","image"],"service_tier":"priority","input":"hi"}`), false)
|
||||
if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); got != "ANY" {
|
||||
t.Fatalf("toolConfig.functionCallingConfig.mode = %q, want ANY. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.allowedFunctionNames.0").String(); got != "lookup" {
|
||||
t.Fatalf("allowedFunctionNames.0 = %q, want lookup. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.responseModalities.0").String(); got != "TEXT" {
|
||||
t.Fatalf("responseModalities.0 = %q, want TEXT. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.responseModalities.1").String(); got != "IMAGE" {
|
||||
t.Fatalf("responseModalities.1 = %q, want IMAGE. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "service_tier").String(); got != "priority" {
|
||||
t.Fatalf("service_tier = %q, want priority. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiContentInput(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"user","parts":[{"text":"hi"}]}}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
|
||||
t.Fatalf("contents.0.role = %q, want user", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
|
||||
t.Fatalf("contents.0.parts.0.text = %q, want hi", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiContentArrayInput(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"role":"user","parts":[{"text":"hi"}]},{"role":"assistant","parts":[{"text":"ok"}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "user" {
|
||||
t.Fatalf("contents.0.role = %q, want user", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "hi" {
|
||||
t.Fatalf("contents.0.parts.0.text = %q, want hi", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
|
||||
t.Fatalf("contents.1.role = %q, want model", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.1.parts.0.text").String(); got != "ok" {
|
||||
t.Fatalf("contents.1.parts.0.text = %q, want ok", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsNonStreamFunctionCall(t *testing.T) {
|
||||
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"q":"x"}}}]}}],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3,"cachedContentTokenCount":4}}`))
|
||||
if got := gjson.GetBytes(out, "steps.0.type").String(); got != "function_call" {
|
||||
t.Fatalf("step type = %q, want function_call", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "steps.0.name").String(); got != "lookup" {
|
||||
t.Fatalf("name = %q, want lookup", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "usage.cached_tokens").Int(); got != 4 {
|
||||
t.Fatalf("cached tokens = %d, want 4", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsNonStreamFunctionCallPreservesCallID(t *testing.T) {
|
||||
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_response_1","args":{"q":"x"}}}]}}]}`))
|
||||
if got := gjson.GetBytes(out, "steps.0.call_id").String(); got != "call_response_1" {
|
||||
t.Fatalf("steps.0.call_id = %q, want call_response_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsStreamFunctionCallCallID(t *testing.T) {
|
||||
var param any
|
||||
out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_stream_1","args":{"q":"x"}}}]}}]}`), ¶m)
|
||||
payload := findStepDeltaPayload(out)
|
||||
if len(payload) == 0 {
|
||||
t.Fatalf("step.delta payload not found")
|
||||
}
|
||||
startPayload := findEventPayload(out, "step.start")
|
||||
if got := gjson.GetBytes(startPayload, "step.id").String(); got != "call_stream_1" {
|
||||
t.Fatalf("step.id = %q, want call_stream_1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(payload, "delta.arguments").String(); got != `{"q":"x"}` {
|
||||
t.Fatalf("delta.arguments = %q, want JSON string", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsStreamFunctionCallThoughtSignature(t *testing.T) {
|
||||
var param any
|
||||
thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"thinking","thought":true}]}}]}`), ¶m)
|
||||
textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"I will call the tool."}]}}]}`), ¶m)
|
||||
callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"sig-call","functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]}}]}`), ¶m)
|
||||
|
||||
out := append(append(thoughtOut, textOut...), callOut...)
|
||||
signaturePayload := findStepDeltaPayloadByType(out, "thought_signature")
|
||||
if len(signaturePayload) == 0 {
|
||||
t.Fatalf("thought_signature step.delta payload not found. Events: %s", eventTypes(out))
|
||||
}
|
||||
if got := gjson.GetBytes(signaturePayload, "delta.signature").String(); got != "sig-call" {
|
||||
t.Fatalf("delta.signature = %q, want sig-call. Payload: %s", got, string(signaturePayload))
|
||||
}
|
||||
if got := gjson.GetBytes(signaturePayload, "index").Int(); got != 2 {
|
||||
t.Fatalf("signature index = %d, want 2. Events: %s", got, eventTypes(out))
|
||||
}
|
||||
functionStartPayload := findNthEventPayload(out, "step.start", 3)
|
||||
if got := gjson.GetBytes(functionStartPayload, "step.type").String(); got != "function_call" {
|
||||
t.Fatalf("fourth step type = %q, want function_call. Events: %s", got, eventTypes(out))
|
||||
}
|
||||
if got := gjson.GetBytes(functionStartPayload, "step.id").String(); got != "call_1" {
|
||||
t.Fatalf("function call id = %q, want call_1. Payload: %s", got, string(functionStartPayload))
|
||||
}
|
||||
argumentsPayload := findStepDeltaPayloadByType(out, "arguments_delta")
|
||||
if got := gjson.GetBytes(argumentsPayload, "delta.arguments").String(); got != `{"q":"x"}` {
|
||||
t.Fatalf("delta.arguments = %q, want JSON string. Payload: %s", got, string(argumentsPayload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsStreamStepLifecycle(t *testing.T) {
|
||||
var param any
|
||||
thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"thinking","thought":true}]}}]}`), ¶m)
|
||||
textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"answer"}]}}]}`), ¶m)
|
||||
callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":3,"candidatesTokenCount":4,"totalTokenCount":7,"thoughtsTokenCount":2}}`), ¶m)
|
||||
|
||||
out := append(append(thoughtOut, textOut...), callOut...)
|
||||
if got := eventTypes(out); !bytes.Equal(got, []byte("interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed")) {
|
||||
t.Fatalf("event sequence = %s", got)
|
||||
}
|
||||
if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 0), "step.type").String(); got != "thought" {
|
||||
t.Fatalf("first step type = %q, want thought", got)
|
||||
}
|
||||
if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 1), "step.type").String(); got != "model_output" {
|
||||
t.Fatalf("second step type = %q, want model_output", got)
|
||||
}
|
||||
if got := gjson.GetBytes(findNthEventPayload(out, "step.start", 2), "step.type").String(); got != "function_call" {
|
||||
t.Fatalf("third step type = %q, want function_call", got)
|
||||
}
|
||||
if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 0), "delta.type").String(); got != "thought_summary" {
|
||||
t.Fatalf("thought delta type = %q, want thought_summary", got)
|
||||
}
|
||||
if got := gjson.GetBytes(findNthEventPayload(out, "step.delta", 2), "delta.type").String(); got != "arguments_delta" {
|
||||
t.Fatalf("function delta type = %q, want arguments_delta", got)
|
||||
}
|
||||
completed := findCompletedPayload(out)
|
||||
if got := gjson.GetBytes(completed, "interaction.usage.total_input_tokens").Int(); got != 3 {
|
||||
t.Fatalf("total_input_tokens = %d, want 3. Payload: %s", got, string(completed))
|
||||
}
|
||||
if got := gjson.GetBytes(completed, "interaction.usage.total_output_tokens").Int(); got != 4 {
|
||||
t.Fatalf("total_output_tokens = %d, want 4. Payload: %s", got, string(completed))
|
||||
}
|
||||
if got := gjson.GetBytes(completed, "interaction.usage.total_thought_tokens").Int(); got != 2 {
|
||||
t.Fatalf("total_thought_tokens = %d, want 2. Payload: %s", got, string(completed))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsStreamSnakeCaseUsage(t *testing.T) {
|
||||
var param any
|
||||
out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"ok"}]},"finishReason":"STOP"}],"usage_metadata":{"prompt_token_count":11,"candidates_token_count":22,"total_token_count":33,"thoughts_token_count":44,"cached_content_token_count":55}}`), ¶m)
|
||||
if got := countEventType(out, "interaction.completed"); got != 1 {
|
||||
t.Fatalf("interaction.completed count = %d, want 1. Events: %s", got, eventTypes(out))
|
||||
}
|
||||
completed := findCompletedPayload(out)
|
||||
for _, test := range []struct {
|
||||
path string
|
||||
want int64
|
||||
}{
|
||||
{"interaction.usage.total_input_tokens", 11},
|
||||
{"interaction.usage.total_output_tokens", 22},
|
||||
{"interaction.usage.total_thought_tokens", 44},
|
||||
{"interaction.usage.total_tokens", 33},
|
||||
{"interaction.usage.total_cached_tokens", 55},
|
||||
} {
|
||||
if got := gjson.GetBytes(completed, test.path).Int(); got != test.want {
|
||||
t.Fatalf("%s = %d, want %d. Payload: %s", test.path, got, test.want, string(completed))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsStreamEmitsTerminalOnce(t *testing.T) {
|
||||
var param any
|
||||
finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"finishReason":"STOP"}]}`), ¶m)
|
||||
usageOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[],"usageMetadata":{"promptTokenCount":1,"candidatesTokenCount":2,"totalTokenCount":3}}`), ¶m)
|
||||
doneOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`[DONE]`), ¶m)
|
||||
|
||||
if got := countEventType(finishOut, "step.stop"); got != 0 {
|
||||
t.Fatalf("finish step.stop count = %d, want 0", got)
|
||||
}
|
||||
if got := countEventType(finishOut, "interaction.completed"); got != 0 {
|
||||
t.Fatalf("finish interaction.completed count = %d, want 0", got)
|
||||
}
|
||||
if got := countEventType(usageOut, "step.stop"); got != 0 {
|
||||
t.Fatalf("usage step.stop count = %d, want 0", got)
|
||||
}
|
||||
if got := countEventType(usageOut, "interaction.completed"); got != 1 {
|
||||
t.Fatalf("usage interaction.completed count = %d, want 1", got)
|
||||
}
|
||||
if got := countEventType(doneOut, "interaction.completed"); got != 0 {
|
||||
t.Fatalf("done interaction.completed count = %d, want 0", got)
|
||||
}
|
||||
if got := countEventType(doneOut, "done"); got != 1 {
|
||||
t.Fatalf("done event count = %d, want 1", got)
|
||||
}
|
||||
if payload := findEventPayload(doneOut, "done"); string(payload) != "[DONE]" {
|
||||
t.Fatalf("done payload = %q, want [DONE]", string(payload))
|
||||
}
|
||||
payload := findCompletedPayload(usageOut)
|
||||
if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 3 {
|
||||
t.Fatalf("completed total_tokens = %d, want 3. Payload: %s", got, string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsStreamDoesNotCompleteOnNonTerminalUsage(t *testing.T) {
|
||||
var param any
|
||||
thoughtOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"thought":true,"text":"thinking"}]}}],"usageMetadata":{"promptTokenCount":124,"totalTokenCount":124}}`), ¶m)
|
||||
if got := countEventType(thoughtOut, "interaction.completed"); got != 0 {
|
||||
t.Fatalf("thought interaction.completed count = %d, want 0. Events: %s", got, eventTypes(thoughtOut))
|
||||
}
|
||||
if got := countEventType(thoughtOut, "step.stop"); got != 0 {
|
||||
t.Fatalf("thought step.stop count = %d, want 0. Events: %s", got, eventTypes(thoughtOut))
|
||||
}
|
||||
|
||||
textOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":"好的,我将为您调用天气查询工具。"}]}}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":17,"totalTokenCount":452,"thoughtsTokenCount":311}}`), ¶m)
|
||||
callOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"functionCall":{"name":"get_weather","args":{"location":"北京"},"id":"nriii75p"}}]}}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":33,"totalTokenCount":468,"thoughtsTokenCount":311}}`), ¶m)
|
||||
finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash-low", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":124,"candidatesTokenCount":33,"totalTokenCount":468,"thoughtsTokenCount":311}}`), ¶m)
|
||||
|
||||
out := append(append(append(thoughtOut, textOut...), callOut...), finishOut...)
|
||||
if got := countEventType(out, "interaction.completed"); got != 1 {
|
||||
t.Fatalf("interaction.completed count = %d, want 1. Events: %s", got, eventTypes(out))
|
||||
}
|
||||
if got := eventTypes(out); !bytes.Equal(got, []byte("interaction.created,interaction.status_update,step.start,step.delta,step.stop,step.start,step.delta,step.stop,step.start,step.delta,step.stop,interaction.completed")) {
|
||||
t.Fatalf("event sequence = %s", got)
|
||||
}
|
||||
payload := findCompletedPayload(out)
|
||||
if got := gjson.GetBytes(payload, "interaction.usage.total_tokens").Int(); got != 468 {
|
||||
t.Fatalf("completed total_tokens = %d, want 468. Payload: %s", got, string(payload))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsStreamIgnoresTrafficOnlyUsageMetadata(t *testing.T) {
|
||||
var param any
|
||||
out := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"content":{"role":"model","parts":[]}}],"usageMetadata":{"trafficType":"PROVISIONED_THROUGHPUT"}}`), ¶m)
|
||||
if got := countEventType(out, "interaction.completed"); got != 0 {
|
||||
t.Fatalf("interaction.completed count = %d, want 0. Events: %q", got, out)
|
||||
}
|
||||
if got := countEventType(out, "done"); got != 0 {
|
||||
t.Fatalf("done count = %d, want 0. Events: %q", got, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsStreamCompletesOnDoneWithoutUsage(t *testing.T) {
|
||||
var param any
|
||||
finishOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`{"candidates":[{"finishReason":"STOP"}]}`), ¶m)
|
||||
doneOut := ConvertGeminiResponseToInteractionsStream(context.Background(), "gemini-3.5-flash", nil, nil, []byte(`[DONE]`), ¶m)
|
||||
|
||||
if got := countEventType(finishOut, "interaction.completed"); got != 0 {
|
||||
t.Fatalf("finish interaction.completed count = %d, want 0", got)
|
||||
}
|
||||
if got := countEventType(doneOut, "interaction.completed"); got != 1 {
|
||||
t.Fatalf("done interaction.completed count = %d, want 1", got)
|
||||
}
|
||||
if got := countEventType(doneOut, "done"); got != 1 {
|
||||
t.Fatalf("done event count = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiImageContent(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" {
|
||||
t.Fatalf("mimeType = %q, want image/png", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" {
|
||||
t.Fatalf("data = %q, want aGVsbG8=", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiModelOutputTypedContent(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"model_output","content":[{"type":"image","mime_type":"image/png","data":"aGVsbG8="},{"type":"document","mime_type":"application/pdf","file_uri":"gs://bucket/doc.pdf"}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "model" {
|
||||
t.Fatalf("contents.0.role = %q, want model. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.mimeType").String(); got != "image/png" {
|
||||
t.Fatalf("image mimeType = %q, want image/png. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.inlineData.data").String(); got != "aGVsbG8=" {
|
||||
t.Fatalf("image data = %q, want aGVsbG8=. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.1.fileData.mimeType").String(); got != "application/pdf" {
|
||||
t.Fatalf("document mimeType = %q, want application/pdf. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.1.fileData.fileUri").String(); got != "gs://bucket/doc.pdf" {
|
||||
t.Fatalf("document fileUri = %q, want gs://bucket/doc.pdf. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiThoughtTypedContent(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"thought","content":[{"type":"text","text":"thinking"},{"type":"audio","mime_type":"audio/wav","data":"UklGRg=="}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.text").String(); got != "thinking" {
|
||||
t.Fatalf("thought text = %q, want thinking. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.thought").Bool(); !got {
|
||||
t.Fatalf("thought flag = false, want true. Output: %s", string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.1.inlineData.mimeType").String(); got != "audio/wav" {
|
||||
t.Fatalf("audio mimeType = %q, want audio/wav. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToInteractionsNonStreamImage(t *testing.T) {
|
||||
out := convertGeminiResponseToInteractionsNonStreamDirect("gemini-3.5-flash", nil, nil, []byte(`{"responseId":"resp_1","candidates":[{"content":{"role":"model","parts":[{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`))
|
||||
if got := gjson.GetBytes(out, "steps.0.content.0.type").String(); got != "image" {
|
||||
t.Fatalf("content type = %q, want image", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiGenerationConfigAllFields(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"max_output_tokens":32,"response_schema":{"type":"object"},"seed":42,"thinking_config":{"thinking_budget":1024,"include_thoughts":true},"context_window_compression":{"trigger_tokens":1000}},"input":"hi"}`), false)
|
||||
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
|
||||
t.Fatalf("maxOutputTokens = %d, want 32", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.responseSchema.type").String(); got != "object" {
|
||||
t.Fatalf("responseSchema.type = %q, want object", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.seed").Int(); got != 42 {
|
||||
t.Fatalf("seed = %d, want 42", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.thinkingBudget").Int(); got != 1024 {
|
||||
t.Fatalf("thinkingBudget = %d, want 1024", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
|
||||
t.Fatalf("includeThoughts = false, want true")
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.contextWindowCompression.triggerTokens").Int(); got != 1000 {
|
||||
t.Fatalf("triggerTokens = %d, want 1000", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiGenerationConfigProtocolFields(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"tool_choice":"auto","thinking_level":"high","thinking_summaries":"auto"},"stream":true,"input":"hi"}`), true)
|
||||
for _, path := range []string{
|
||||
"stream",
|
||||
"generationConfig.toolChoice",
|
||||
"generationConfig.thinkingLevel",
|
||||
"generationConfig.thinkingSummaries",
|
||||
} {
|
||||
if gjson.GetBytes(out, path).Exists() {
|
||||
t.Fatalf("%s exists, want omitted. Output: %s", path, string(out))
|
||||
}
|
||||
}
|
||||
if got := gjson.GetBytes(out, "toolConfig.functionCallingConfig.mode").String(); got != "AUTO" {
|
||||
t.Fatalf("toolConfig.functionCallingConfig.mode = %q, want AUTO. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.thinkingLevel").String(); got != "high" {
|
||||
t.Fatalf("thinkingLevel = %q, want high. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.thinkingConfig.includeThoughts").Bool(); !got {
|
||||
t.Fatalf("includeThoughts = false, want true. Output: %s", string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToInteractionsFunctionCall(t *testing.T) {
|
||||
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","response":{"ok":true}}}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "input.0.type").String(); got != "function_call" {
|
||||
t.Fatalf("input.0.type = %q, want function_call", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.0.name").String(); got != "lookup" {
|
||||
t.Fatalf("input.0.name = %q, want lookup", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.1.type").String(); got != "function_result" {
|
||||
t.Fatalf("input.1.type = %q, want function_result", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToInteractionsTextContentType(t *testing.T) {
|
||||
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "text" {
|
||||
t.Fatalf("content.0.type = %q, want text. Output: %s", got, string(out))
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.0.content.0.text").String(); got != "hi" {
|
||||
t.Fatalf("content.0.text = %q, want hi. Output: %s", got, string(out))
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToInteractionsMultimodal(t *testing.T) {
|
||||
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"user","parts":[{"inlineData":{"mimeType":"audio/wav","data":"aGVsbG8="}}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "input.0.type").String(); got != "user_input" {
|
||||
t.Fatalf("input.0.type = %q, want user_input", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.0.content.0.type").String(); got != "audio" {
|
||||
t.Fatalf("content.0.type = %q, want audio", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.0.content.0.mime_type").String(); got != "audio/wav" {
|
||||
t.Fatalf("mime_type = %q, want audio/wav", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToInteractionsThought(t *testing.T) {
|
||||
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"text":"thinking","thought":true}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "input.0.type").String(); got != "thought" {
|
||||
t.Fatalf("input.0.type = %q, want thought", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiTurnWithModelRole(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":{"role":"model","steps":[{"type":"user_input","content":[{"text":"hi"}]},{"type":"model_output","content":[{"text":"ok"}]}]}}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.role").String(); got != "model" {
|
||||
t.Fatalf("contents.0.role = %q, want model", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.1.role").String(); got != "model" {
|
||||
t.Fatalf("contents.1.role = %q, want model", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiGenerationConfigPreservesLargeIntegers(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generation_config":{"max_output_tokens":32,"large_identity":9223372036854775807},"input":"hi"}`), false)
|
||||
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != 32 {
|
||||
t.Fatalf("maxOutputTokens = %d, want 32", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generationConfig.largeIdentity").String(); got != "9223372036854775807" {
|
||||
t.Fatalf("largeIdentity = %q, want 9223372036854775807", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiFunctionCallPreservesCallID(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"function_call","name":"lookup","call_id":"call_1","arguments":{"q":"x"}}]}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.id").String(); got != "call_1" {
|
||||
t.Fatalf("functionCall.id = %q, want call_1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.name").String(); got != "lookup" {
|
||||
t.Fatalf("functionCall.name = %q, want lookup", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.functionCall.args.q").String(); got != "x" {
|
||||
t.Fatalf("functionCall.args.q = %q, want x", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiFunctionResultPreservesCallID(t *testing.T) {
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","input":[{"type":"function_result","name":"lookup","call_id":"call_1","result":{"ok":true}}]}`), false)
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.id").String(); got != "call_1" {
|
||||
t.Fatalf("functionResponse.id = %q, want call_1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "contents.0.parts.0.functionResponse.name").String(); got != "lookup" {
|
||||
t.Fatalf("functionResponse.name = %q, want lookup", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToInteractionsFunctionCallPreservesID(t *testing.T) {
|
||||
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","id":"call_1","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","id":"call_1","response":{"ok":true}}}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_1" {
|
||||
t.Fatalf("input.0.call_id = %q, want call_1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_1" {
|
||||
t.Fatalf("input.1.call_id = %q, want call_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToInteractionsFunctionCallPreservesCallID(t *testing.T) {
|
||||
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","contents":[{"role":"model","parts":[{"functionCall":{"name":"lookup","call_id":"call_request_1","args":{"q":"x"}}}]},{"role":"user","parts":[{"functionResponse":{"name":"lookup","call_id":"call_request_1","response":{"ok":true}}}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "input.0.call_id").String(); got != "call_request_1" {
|
||||
t.Fatalf("input.0.call_id = %q, want call_request_1", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "input.1.call_id").String(); got != "call_request_1" {
|
||||
t.Fatalf("input.1.call_id = %q, want call_request_1", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiRequestToInteractionsGenerationConfig(t *testing.T) {
|
||||
out := ConvertGeminiRequestToInteractions("gemini-3.5-flash", []byte(`{"model":"gemini-3.5-flash","generationConfig":{"maxOutputTokens":32,"topP":0.8,"thinkingConfig":{"thinkingBudget":1024}},"contents":[{"role":"user","parts":[{"text":"hi"}]}]}`), false)
|
||||
if got := gjson.GetBytes(out, "generation_config.max_output_tokens").Int(); got != 32 {
|
||||
t.Fatalf("max_output_tokens = %d, want 32", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generation_config.top_p").Float(); got != 0.8 {
|
||||
t.Fatalf("top_p = %v, want 0.8", got)
|
||||
}
|
||||
if got := gjson.GetBytes(out, "generation_config.thinking_config.thinking_budget").Int(); got != 1024 {
|
||||
t.Fatalf("thinking_budget = %d, want 1024", got)
|
||||
}
|
||||
}
|
||||
|
||||
func findStepDeltaPayload(events [][]byte) []byte {
|
||||
return findEventPayload(events, "step.delta")
|
||||
}
|
||||
|
||||
func findStepDeltaPayloadByType(events [][]byte, deltaType string) []byte {
|
||||
for _, event := range events {
|
||||
payload := ssePayload(event)
|
||||
if eventName(event, payload) == "step.delta" && gjson.GetBytes(payload, "delta.type").String() == deltaType {
|
||||
return payload
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func findCompletedPayload(events [][]byte) []byte {
|
||||
return findEventPayload(events, "interaction.completed")
|
||||
}
|
||||
|
||||
func findEventPayload(events [][]byte, eventType string) []byte {
|
||||
return findNthEventPayload(events, eventType, 0)
|
||||
}
|
||||
|
||||
func findNthEventPayload(events [][]byte, eventType string, n int) []byte {
|
||||
for _, event := range events {
|
||||
payload := ssePayload(event)
|
||||
if eventName(event, payload) == eventType {
|
||||
if n == 0 {
|
||||
return payload
|
||||
}
|
||||
n--
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func eventTypes(events [][]byte) []byte {
|
||||
var out []byte
|
||||
for _, event := range events {
|
||||
payload := ssePayload(event)
|
||||
eventType := eventName(event, payload)
|
||||
if eventType == "" {
|
||||
continue
|
||||
}
|
||||
if len(out) > 0 {
|
||||
out = append(out, ',')
|
||||
}
|
||||
out = append(out, eventType...)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countEventType(events [][]byte, eventType string) int {
|
||||
count := 0
|
||||
for _, event := range events {
|
||||
payload := ssePayload(event)
|
||||
if eventName(event, payload) == eventType {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func eventName(event, payload []byte) string {
|
||||
if eventType := gjson.GetBytes(payload, "event_type").String(); eventType != "" {
|
||||
return eventType
|
||||
}
|
||||
const prefix = "event: "
|
||||
lineEnd := bytes.IndexByte(event, '\n')
|
||||
if lineEnd < 0 || !bytes.HasPrefix(event, []byte(prefix)) {
|
||||
return ""
|
||||
}
|
||||
return string(event[len(prefix):lineEnd])
|
||||
}
|
||||
|
||||
func ssePayload(event []byte) []byte {
|
||||
const prefix = "\ndata: "
|
||||
idx := bytes.Index(event, []byte(prefix))
|
||||
if idx < 0 {
|
||||
return nil
|
||||
}
|
||||
return event[idx+len(prefix):]
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package interactions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertInteractionsRequestToGeminiNormalizesOpenAIFileDataURL(t *testing.T) {
|
||||
input := []byte(`{"model":"gemini-3.5-flash","input":[{"type":"user_input","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
|
||||
|
||||
out := ConvertInteractionsRequestToGemini("gemini-3.5-flash", input, false)
|
||||
inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
|
||||
if got := inlineData.Get("mimeType").String(); got != "application/pdf" {
|
||||
t.Fatalf("inlineData.mimeType = %q, want application/pdf. Output: %s", got, out)
|
||||
}
|
||||
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
|
||||
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,367 @@
|
|||
package interactions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
type interactionsToGeminiStreamState struct {
|
||||
ID string
|
||||
Model string
|
||||
ServiceTier string
|
||||
StepNames map[int]string
|
||||
StepIDs map[int]string
|
||||
StepSignatures map[int]string
|
||||
}
|
||||
|
||||
func ConvertGeminiResponseToInteractions(ctx context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
return ConvertGeminiResponseToInteractionsStream(ctx, modelName, originalRequestRawJSON, requestRawJSON, rawJSON, param)
|
||||
}
|
||||
|
||||
func ConvertGeminiResponseToInteractionsNonStream(_ context.Context, modelName string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
return convertGeminiResponseToInteractionsNonStreamDirect(modelName, originalRequestRawJSON, requestRawJSON, rawJSON)
|
||||
}
|
||||
|
||||
func ConvertInteractionsResponseToGemini(_ context.Context, modelName string, _, _, rawJSON []byte, param *any) [][]byte {
|
||||
if param == nil {
|
||||
var local any
|
||||
param = &local
|
||||
}
|
||||
if *param == nil {
|
||||
*param = &interactionsToGeminiStreamState{Model: modelName}
|
||||
}
|
||||
st := (*param).(*interactionsToGeminiStreamState)
|
||||
st.ensureMaps()
|
||||
return convertInteractionsEventToGemini(modelName, rawJSON, st)
|
||||
}
|
||||
|
||||
func ConvertInteractionsResponseToGeminiNonStream(_ context.Context, modelName string, _, _, rawJSON []byte, _ *any) []byte {
|
||||
root := gjson.ParseBytes(rawJSON)
|
||||
interaction := root
|
||||
if nested := root.Get("interaction"); nested.Exists() {
|
||||
interaction = nested
|
||||
}
|
||||
st := &interactionsToGeminiStreamState{
|
||||
ID: firstNonEmptyInteractionString(interaction.Get("id").String(), root.Get("id").String(), fmt.Sprintf("response_%d", time.Now().UnixNano())),
|
||||
Model: firstNonEmptyInteractionString(interaction.Get("model").String(), root.Get("model").String(), modelName),
|
||||
ServiceTier: firstNonEmptyInteractionString(interaction.Get("service_tier").String(), root.Get("service_tier").String()),
|
||||
}
|
||||
var parts [][]byte
|
||||
steps := interaction.Get("steps")
|
||||
if !steps.Exists() {
|
||||
steps = root.Get("steps")
|
||||
}
|
||||
steps.ForEach(func(_, step gjson.Result) bool {
|
||||
parts = append(parts, interactionsStepToGeminiParts(step)...)
|
||||
return true
|
||||
})
|
||||
out := buildInteractionsGeminiChunk(st, modelName, parts, "STOP", translatorcommon.InteractionsUsage(root), true)
|
||||
return out
|
||||
}
|
||||
|
||||
func ConvertInteractionsRequestToInteractions(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
_ = modelName
|
||||
_ = stream
|
||||
return inputRawJSON
|
||||
}
|
||||
|
||||
func ConvertInteractionsResponsePassthrough(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) [][]byte {
|
||||
if len(rawJSON) == 0 {
|
||||
return nil
|
||||
}
|
||||
return [][]byte{rawJSON}
|
||||
}
|
||||
|
||||
func ConvertInteractionsResponsePassthroughNonStream(_ context.Context, _ string, _, _, rawJSON []byte, _ *any) []byte {
|
||||
return rawJSON
|
||||
}
|
||||
|
||||
func convertInteractionsEventToGemini(modelName string, rawJSON []byte, st *interactionsToGeminiStreamState) [][]byte {
|
||||
payload := interactionsGeminiSSEPayload(rawJSON)
|
||||
if len(payload) == 0 {
|
||||
return nil
|
||||
}
|
||||
root := gjson.ParseBytes(payload)
|
||||
if !root.Exists() {
|
||||
return nil
|
||||
}
|
||||
switch root.Get("event_type").String() {
|
||||
case "interaction.created":
|
||||
interaction := root.Get("interaction")
|
||||
st.ID = firstNonEmptyInteractionString(st.ID, interaction.Get("id").String())
|
||||
st.Model = firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName)
|
||||
case "step.start":
|
||||
rememberInteractionsGeminiStep(root, st)
|
||||
case "step.delta":
|
||||
if chunk := interactionsStepDeltaToGeminiChunk(modelName, root, st); len(chunk) > 0 {
|
||||
return [][]byte{chunk}
|
||||
}
|
||||
case "interaction.completed", "finish":
|
||||
interaction := root.Get("interaction")
|
||||
st.ID = firstNonEmptyInteractionString(st.ID, interaction.Get("id").String())
|
||||
st.Model = firstNonEmptyInteractionString(st.Model, interaction.Get("model").String(), modelName)
|
||||
st.ServiceTier = firstNonEmptyInteractionString(st.ServiceTier, interaction.Get("service_tier").String())
|
||||
chunk := buildInteractionsGeminiChunk(st, modelName, nil, "STOP", translatorcommon.InteractionsUsage(root), true)
|
||||
return [][]byte{chunk}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func rememberInteractionsGeminiStep(root gjson.Result, st *interactionsToGeminiStreamState) {
|
||||
index := int(root.Get("index").Int())
|
||||
step := root.Get("step")
|
||||
st.StepNames[index] = step.Get("name").String()
|
||||
st.StepIDs[index] = firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String())
|
||||
st.StepSignatures[index] = firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String())
|
||||
}
|
||||
|
||||
func interactionsStepDeltaToGeminiChunk(modelName string, root gjson.Result, st *interactionsToGeminiStreamState) []byte {
|
||||
index := int(root.Get("index").Int())
|
||||
delta := root.Get("delta")
|
||||
switch delta.Get("type").String() {
|
||||
case "arguments_delta":
|
||||
part := []byte(`{"functionCall":{"name":"","args":{}}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionCall.name", firstNonEmptyInteractionString(st.StepNames[index], root.Get("step.name").String()))
|
||||
if id := st.StepIDs[index]; id != "" {
|
||||
part, _ = sjson.SetBytes(part, "functionCall.id", id)
|
||||
}
|
||||
if signature := st.StepSignatures[index]; signature != "" {
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
|
||||
}
|
||||
arguments := strings.TrimSpace(delta.Get("arguments").String())
|
||||
if arguments != "" && gjson.Valid(arguments) {
|
||||
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(arguments))
|
||||
}
|
||||
return buildInteractionsGeminiChunk(st, modelName, [][]byte{part}, "", gjson.Result{}, false)
|
||||
case "text":
|
||||
text := firstNonEmptyInteractionString(delta.Get("text").String(), delta.Get("content.text").String())
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, false)}, "", gjson.Result{}, false)
|
||||
case "thought_summary":
|
||||
text := firstNonEmptyInteractionString(delta.Get("content.text").String(), delta.Get("text").String())
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return buildInteractionsGeminiChunk(st, modelName, [][]byte{geminiTextPartJSON(text, true)}, "", gjson.Result{}, false)
|
||||
case "thought_signature":
|
||||
signature := firstNonEmptyInteractionString(delta.Get("signature").String(), delta.Get("thought_signature").String(), delta.Get("thoughtSignature").String())
|
||||
if signature == "" {
|
||||
return nil
|
||||
}
|
||||
st.StepSignatures[index] = signature
|
||||
part := geminiTextPartJSON("", true)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
|
||||
return buildInteractionsGeminiChunk(st, modelName, [][]byte{part}, "", gjson.Result{}, false)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func interactionsStepToGeminiParts(step gjson.Result) [][]byte {
|
||||
switch step.Get("type").String() {
|
||||
case "function_call":
|
||||
return [][]byte{interactionsFunctionCallStepToGeminiPart(step)}
|
||||
case "function_result":
|
||||
return [][]byte{interactionsFunctionResponseStepToGeminiPart(step)}
|
||||
case "thought":
|
||||
return interactionsContentToGeminiParts(step.Get("content"), true)
|
||||
default:
|
||||
return interactionsContentToGeminiParts(step.Get("content"), false)
|
||||
}
|
||||
}
|
||||
|
||||
func interactionsContentToGeminiParts(content gjson.Result, thought bool) [][]byte {
|
||||
var parts [][]byte
|
||||
if !content.Exists() {
|
||||
return parts
|
||||
}
|
||||
if content.Type == gjson.String {
|
||||
return [][]byte{geminiTextPartJSON(content.String(), thought)}
|
||||
}
|
||||
if content.IsObject() {
|
||||
if part := interactionsContentPartToGeminiPart(content, thought); len(part) > 0 {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
if content.IsArray() {
|
||||
content.ForEach(func(_, item gjson.Result) bool {
|
||||
if part := interactionsContentPartToGeminiPart(item, thought); len(part) > 0 {
|
||||
parts = append(parts, part)
|
||||
}
|
||||
return true
|
||||
})
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func interactionsFunctionCallStepToGeminiPart(step gjson.Result) []byte {
|
||||
part := []byte(`{"functionCall":{"name":"","args":{}}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionCall.name", step.Get("name").String())
|
||||
if id := firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()); id != "" {
|
||||
part, _ = sjson.SetBytes(part, "functionCall.id", id)
|
||||
}
|
||||
if signature := firstNonEmptyInteractionString(step.Get("signature").String(), step.Get("thoughtSignature").String(), step.Get("thought_signature").String()); signature != "" {
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", signature)
|
||||
}
|
||||
part = setInteractionsGeminiRawObject(part, "functionCall.args", firstExistingInteractionResult(step, "arguments", "args"))
|
||||
return part
|
||||
}
|
||||
|
||||
func interactionsFunctionResponseStepToGeminiPart(step gjson.Result) []byte {
|
||||
part := []byte(`{"functionResponse":{"name":"","response":{}}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.name", step.Get("name").String())
|
||||
if id := firstNonEmptyInteractionString(step.Get("call_id").String(), step.Get("id").String()); id != "" {
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.id", id)
|
||||
}
|
||||
part = setInteractionsGeminiRawObject(part, "functionResponse.response", firstExistingInteractionResult(step, "result", "response"))
|
||||
return part
|
||||
}
|
||||
|
||||
func buildInteractionsGeminiChunk(st *interactionsToGeminiStreamState, modelName string, parts [][]byte, finishReason string, usage gjson.Result, includeEmptyPart bool) []byte {
|
||||
out := []byte(`{"candidates":[{"content":{"parts":[],"role":"model"},"index":0}]}`)
|
||||
if len(parts) == 0 && includeEmptyPart {
|
||||
parts = append(parts, geminiTextPartJSON("", false))
|
||||
}
|
||||
validParts := make([][]byte, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
if len(part) > 0 {
|
||||
validParts = append(validParts, part)
|
||||
}
|
||||
}
|
||||
if len(validParts) > 0 {
|
||||
out = translatorcommon.SetRawArrayItems(out, "candidates.0.content.parts", validParts)
|
||||
}
|
||||
if finishReason != "" {
|
||||
out, _ = sjson.SetBytes(out, "candidates.0.finishReason", finishReason)
|
||||
}
|
||||
if model := firstNonEmptyInteractionString(st.Model, modelName); model != "" {
|
||||
out, _ = sjson.SetBytes(out, "modelVersion", model)
|
||||
}
|
||||
if id := st.ID; id != "" {
|
||||
out, _ = sjson.SetBytes(out, "responseId", id)
|
||||
}
|
||||
if st.ServiceTier != "" {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.serviceTier", st.ServiceTier)
|
||||
}
|
||||
return setGeminiUsageMetadataFromInteractionsUsage(out, usage)
|
||||
}
|
||||
|
||||
func setGeminiUsageMetadataFromInteractionsUsage(out []byte, usage gjson.Result) []byte {
|
||||
if !usage.Exists() {
|
||||
return out
|
||||
}
|
||||
inputTokens, hasInputTokens := interactionsUsageInt(usage, "input_tokens", "total_input_tokens")
|
||||
outputTokens, hasOutputTokens := interactionsUsageInt(usage, "output_tokens", "total_output_tokens")
|
||||
totalTokens, hasTotalTokens := interactionsUsageInt(usage, "total_tokens")
|
||||
if hasInputTokens {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.promptTokenCount", inputTokens)
|
||||
out, _ = sjson.SetRawBytes(out, "usageMetadata.promptTokensDetails", []byte(fmt.Sprintf(`[{"modality":"TEXT","tokenCount":%d}]`, inputTokens)))
|
||||
}
|
||||
if hasOutputTokens {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.candidatesTokenCount", outputTokens)
|
||||
}
|
||||
if hasTotalTokens {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", totalTokens)
|
||||
} else if hasInputTokens || hasOutputTokens {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.totalTokenCount", inputTokens+outputTokens)
|
||||
}
|
||||
if thoughtTokens, ok := interactionsUsageInt(usage, "reasoning_tokens", "total_thought_tokens"); ok {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.thoughtsTokenCount", thoughtTokens)
|
||||
}
|
||||
if cachedTokens, ok := interactionsUsageInt(usage, "cached_tokens", "total_cached_tokens"); ok {
|
||||
out, _ = sjson.SetBytes(out, "usageMetadata.cachedContentTokenCount", cachedTokens)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func interactionsGeminiSSEPayload(rawJSON []byte) []byte {
|
||||
trimmed := bytes.TrimSpace(rawJSON)
|
||||
if len(trimmed) == 0 || bytes.Equal(trimmed, []byte("[DONE]")) {
|
||||
return nil
|
||||
}
|
||||
if bytes.HasPrefix(trimmed, []byte("{")) {
|
||||
return trimmed
|
||||
}
|
||||
var payload []byte
|
||||
for _, line := range bytes.Split(trimmed, []byte{'\n'}) {
|
||||
line = bytes.TrimSpace(bytes.TrimRight(line, "\r"))
|
||||
if !bytes.HasPrefix(line, []byte("data:")) {
|
||||
continue
|
||||
}
|
||||
data := bytes.TrimSpace(line[len("data:"):])
|
||||
if len(data) == 0 || bytes.Equal(data, []byte("[DONE]")) {
|
||||
continue
|
||||
}
|
||||
if len(payload) > 0 {
|
||||
payload = append(payload, '\n')
|
||||
}
|
||||
payload = append(payload, data...)
|
||||
}
|
||||
return payload
|
||||
}
|
||||
|
||||
func interactionsUsageInt(usage gjson.Result, paths ...string) (int64, bool) {
|
||||
for _, path := range paths {
|
||||
if value := usage.Get(path); value.Exists() {
|
||||
return value.Int(), true
|
||||
}
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func firstExistingInteractionResult(root gjson.Result, paths ...string) gjson.Result {
|
||||
for _, path := range paths {
|
||||
if value := root.Get(path); value.Exists() {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return gjson.Result{}
|
||||
}
|
||||
|
||||
func setInteractionsGeminiRawObject(out []byte, path string, value gjson.Result) []byte {
|
||||
if !value.Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, path, []byte(`{}`))
|
||||
return out
|
||||
}
|
||||
if value.Type == gjson.String {
|
||||
raw := strings.TrimSpace(value.String())
|
||||
if raw != "" && gjson.Valid(raw) {
|
||||
out, _ = sjson.SetRawBytes(out, path, []byte(raw))
|
||||
return out
|
||||
}
|
||||
}
|
||||
if value.Raw != "" {
|
||||
out, _ = sjson.SetRawBytes(out, path, []byte(value.Raw))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func firstNonEmptyInteractionString(values ...string) string {
|
||||
for _, value := range values {
|
||||
if strings.TrimSpace(value) != "" {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func (st *interactionsToGeminiStreamState) ensureMaps() {
|
||||
if st.StepNames == nil {
|
||||
st.StepNames = make(map[int]string)
|
||||
}
|
||||
if st.StepIDs == nil {
|
||||
st.StepIDs = make(map[int]string)
|
||||
}
|
||||
if st.StepSignatures == nil {
|
||||
st.StepSignatures = make(map[int]string)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiNormalizesFileDataURL(t *testing.T) {
|
||||
input := []byte(`{"model":"gemini-2.5-pro","messages":[{"role":"user","content":[{"type":"file","file":{"filename":"test.pdf","file_data":"data:application/pdf;base64,JVBERi0xLjQK"}}]}]}`)
|
||||
|
||||
out := ConvertOpenAIRequestToGemini("gemini-2.5-pro", input, false)
|
||||
inlineData := gjson.GetBytes(out, "contents.0.parts.0.inlineData")
|
||||
if got := inlineData.Get("mime_type").String(); got != "application/pdf" {
|
||||
t.Fatalf("inlineData.mime_type = %q, want application/pdf. Output: %s", got, out)
|
||||
}
|
||||
if got := inlineData.Get("data").String(); got != "JVBERi0xLjQK" {
|
||||
t.Fatalf("inlineData.data = %q, want raw base64 payload. Output: %s", got, out)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,502 @@
|
|||
// Package openai provides request translation functionality for OpenAI to Gemini API compatibility.
|
||||
// It converts OpenAI Chat Completions requests into Gemini compatible JSON using gjson/sjson only.
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/gemini/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiFunctionThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// ConvertOpenAIRequestToGemini converts an OpenAI Chat Completions request (raw JSON)
|
||||
// into a complete Gemini request JSON. All JSON construction uses sjson and lookups use gjson.
|
||||
//
|
||||
// Parameters:
|
||||
// - modelName: The name of the model to use for the request
|
||||
// - rawJSON: The raw JSON request data from the OpenAI API
|
||||
// - stream: A boolean indicating if the request is for a streaming response (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: The transformed request data in Gemini API format
|
||||
func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {
|
||||
rawJSON := inputRawJSON
|
||||
// Base envelope (no default thinkingConfig)
|
||||
out := []byte(`{"contents":[]}`)
|
||||
|
||||
// Model
|
||||
out, _ = sjson.SetBytes(out, "model", modelName)
|
||||
|
||||
// Let user-provided generationConfig pass through
|
||||
if genConfig := gjson.GetBytes(rawJSON, "generationConfig"); genConfig.Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "generationConfig", []byte(genConfig.Raw))
|
||||
}
|
||||
|
||||
// Apply thinking configuration: convert OpenAI reasoning_effort to Gemini thinkingConfig.
|
||||
// Inline translation-only mapping; capability checks happen later in ApplyThinking.
|
||||
re := gjson.GetBytes(rawJSON, "reasoning_effort")
|
||||
if re.Exists() {
|
||||
effort := strings.ToLower(strings.TrimSpace(re.String()))
|
||||
if effort != "" {
|
||||
thinkingPath := "generationConfig.thinkingConfig"
|
||||
if effort == "auto" {
|
||||
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingBudget", -1)
|
||||
} else {
|
||||
out, _ = sjson.SetBytes(out, thinkingPath+".thinkingLevel", effort)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Temperature/top_p/top_k
|
||||
if tr := gjson.GetBytes(rawJSON, "temperature"); tr.Exists() && tr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.temperature", tr.Num)
|
||||
}
|
||||
if tpr := gjson.GetBytes(rawJSON, "top_p"); tpr.Exists() && tpr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topP", tpr.Num)
|
||||
}
|
||||
if tkr := gjson.GetBytes(rawJSON, "top_k"); tkr.Exists() && tkr.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.topK", tkr.Num)
|
||||
}
|
||||
|
||||
// OpenAI max_tokens / max_completion_tokens -> Gemini generationConfig.maxOutputTokens
|
||||
if mt := gjson.GetBytes(rawJSON, "max_tokens"); mt.Exists() && mt.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.maxOutputTokens", mt.Num)
|
||||
} else if mct := gjson.GetBytes(rawJSON, "max_completion_tokens"); mct.Exists() && mct.Type == gjson.Number {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.maxOutputTokens", mct.Num)
|
||||
}
|
||||
|
||||
// Candidate count (OpenAI 'n' parameter)
|
||||
if n := gjson.GetBytes(rawJSON, "n"); n.Exists() && n.Type == gjson.Number {
|
||||
if val := n.Int(); val > 1 {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.candidateCount", val)
|
||||
}
|
||||
}
|
||||
|
||||
// Map OpenAI response_format to Gemini structured output settings.
|
||||
out = applyOpenAIResponseFormatToGemini(out, rawJSON)
|
||||
|
||||
// Map OpenAI modalities -> Gemini generationConfig.responseModalities
|
||||
// e.g. "modalities": ["image", "text"] -> ["IMAGE", "TEXT"]
|
||||
if mods := gjson.GetBytes(rawJSON, "modalities"); mods.Exists() && mods.IsArray() {
|
||||
var responseMods []string
|
||||
for _, m := range mods.Array() {
|
||||
switch strings.ToLower(m.String()) {
|
||||
case "text":
|
||||
responseMods = append(responseMods, "TEXT")
|
||||
case "image":
|
||||
responseMods = append(responseMods, "IMAGE")
|
||||
}
|
||||
}
|
||||
if len(responseMods) > 0 {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.responseModalities", responseMods)
|
||||
}
|
||||
}
|
||||
|
||||
// OpenRouter-style image_config support
|
||||
// If the input uses top-level image_config.aspect_ratio, map it into generationConfig.imageConfig.aspectRatio.
|
||||
if imgCfg := gjson.GetBytes(rawJSON, "image_config"); imgCfg.Exists() && imgCfg.IsObject() {
|
||||
if ar := imgCfg.Get("aspect_ratio"); ar.Exists() && ar.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.imageConfig.aspectRatio", ar.Str)
|
||||
}
|
||||
if size := imgCfg.Get("image_size"); size.Exists() && size.Type == gjson.String {
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.imageConfig.imageSize", size.Str)
|
||||
}
|
||||
}
|
||||
|
||||
// messages -> systemInstruction + contents
|
||||
messages := gjson.GetBytes(rawJSON, "messages")
|
||||
if messages.IsArray() {
|
||||
arr := messages.Array()
|
||||
systemParts := make([][]byte, 0, 2)
|
||||
contentItems := make([][]byte, 0, len(arr))
|
||||
// First pass: assistant tool_calls id->name map
|
||||
tcID2Name := map[string]string{}
|
||||
for i := 0; i < len(arr); i++ {
|
||||
m := arr[i]
|
||||
if m.Get("role").String() == "assistant" {
|
||||
tcs := m.Get("tool_calls")
|
||||
if tcs.IsArray() {
|
||||
for _, tc := range tcs.Array() {
|
||||
if tc.Get("type").String() == "function" {
|
||||
id := tc.Get("id").String()
|
||||
name := tc.Get("function.name").String()
|
||||
if id != "" && name != "" {
|
||||
tcID2Name[id] = name
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Second pass build systemInstruction/tool responses cache
|
||||
toolResponses := map[string]string{} // tool_call_id -> response text
|
||||
for i := 0; i < len(arr); i++ {
|
||||
m := arr[i]
|
||||
role := m.Get("role").String()
|
||||
if role == "tool" {
|
||||
toolCallID := m.Get("tool_call_id").String()
|
||||
if toolCallID != "" {
|
||||
c := m.Get("content")
|
||||
toolResponses[toolCallID] = c.Raw
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(arr); i++ {
|
||||
m := arr[i]
|
||||
role := m.Get("role").String()
|
||||
content := m.Get("content")
|
||||
|
||||
if (role == "system" || role == "developer") && len(arr) > 1 {
|
||||
// system -> systemInstruction as a user message style
|
||||
if content.Type == gjson.String {
|
||||
systemParts = append(systemParts, geminiTextPart(content.String()))
|
||||
} else if content.IsObject() && content.Get("type").String() == "text" {
|
||||
systemParts = append(systemParts, geminiTextPart(content.Get("text").String()))
|
||||
} else if content.IsArray() {
|
||||
contents := content.Array()
|
||||
for j := 0; j < len(contents); j++ {
|
||||
systemParts = append(systemParts, geminiTextPart(contents[j].Get("text").String()))
|
||||
}
|
||||
}
|
||||
} else if role == "user" || ((role == "system" || role == "developer") && len(arr) == 1) {
|
||||
// Build single user content node to avoid splitting into multiple contents.
|
||||
partItems := make([][]byte, 0, 4)
|
||||
if content.Type == gjson.String {
|
||||
partItems = append(partItems, geminiTextPart(content.String()))
|
||||
} else if content.IsArray() {
|
||||
for _, item := range content.Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "text":
|
||||
if text := item.Get("text").String(); text != "" {
|
||||
partItems = append(partItems, geminiTextPart(text))
|
||||
}
|
||||
case "image_url":
|
||||
imageURL := item.Get("image_url.url").String()
|
||||
if len(imageURL) > 5 {
|
||||
pieces := strings.SplitN(imageURL[5:], ";", 2)
|
||||
if len(pieces) == 2 && len(pieces[1]) > 7 {
|
||||
partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], geminiFunctionThoughtSignature))
|
||||
}
|
||||
}
|
||||
case "video_url":
|
||||
videoURL := item.Get("video_url.url").String()
|
||||
if len(videoURL) > 5 {
|
||||
pieces := strings.SplitN(videoURL[5:], ";", 2)
|
||||
if len(pieces) == 2 && len(pieces[1]) > 7 {
|
||||
partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], ""))
|
||||
}
|
||||
}
|
||||
case "file":
|
||||
filename := item.Get("file.filename").String()
|
||||
fileData := item.Get("file.file_data").String()
|
||||
if mimeType, data, ok := translatorcommon.NormalizeOpenAIFileData(filename, "", fileData); ok {
|
||||
partItems = append(partItems, geminiInlineDataPart(mimeType, data, ""))
|
||||
} else {
|
||||
log.Warn("Invalid file data or unknown file name extension in user message, skip")
|
||||
}
|
||||
case "input_audio":
|
||||
audioData := item.Get("input_audio.data").String()
|
||||
if audioData != "" {
|
||||
mimeType := openAIInputAudioMimeType(item.Get("input_audio.format").String())
|
||||
partItems = append(partItems, geminiInlineDataPart(mimeType, audioData, ""))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
contentItems = append(contentItems, geminiContentNode("user", partItems))
|
||||
} else if role == "assistant" {
|
||||
partItems := make([][]byte, 0, 4)
|
||||
if reasoningContent := m.Get("reasoning_content"); reasoningContent.Type == gjson.String && reasoningContent.String() != "" {
|
||||
part := geminiTextPart(reasoningContent.String())
|
||||
part, _ = sjson.SetBytes(part, "thought", true)
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", geminiFunctionThoughtSignature)
|
||||
partItems = append(partItems, part)
|
||||
}
|
||||
if content.Type == gjson.String && content.String() != "" {
|
||||
partItems = append(partItems, geminiTextPart(content.String()))
|
||||
} else if content.IsArray() {
|
||||
// Assistant multimodal content (e.g. text + image) -> single model content with parts.
|
||||
for _, item := range content.Array() {
|
||||
switch item.Get("type").String() {
|
||||
case "text":
|
||||
if text := item.Get("text").String(); text != "" {
|
||||
partItems = append(partItems, geminiTextPart(text))
|
||||
}
|
||||
case "image_url":
|
||||
imageURL := item.Get("image_url.url").String()
|
||||
if len(imageURL) > 5 {
|
||||
pieces := strings.SplitN(imageURL[5:], ";", 2)
|
||||
if len(pieces) == 2 && len(pieces[1]) > 7 {
|
||||
partItems = append(partItems, geminiInlineDataPart(pieces[0], pieces[1][7:], geminiFunctionThoughtSignature))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Tool calls -> single model content with functionCall parts.
|
||||
tcs := m.Get("tool_calls")
|
||||
if tcs.IsArray() {
|
||||
functionIDs := make([]string, 0)
|
||||
for _, tc := range tcs.Array() {
|
||||
if tc.Get("type").String() != "function" {
|
||||
continue
|
||||
}
|
||||
functionID := tc.Get("id").String()
|
||||
functionName := util.SanitizeFunctionName(tc.Get("function.name").String())
|
||||
if functionName == "" {
|
||||
continue
|
||||
}
|
||||
part := []byte(`{"functionCall":{"name":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionCall.name", functionName)
|
||||
part, _ = sjson.SetRawBytes(part, "functionCall.args", []byte(tc.Get("function.arguments").String()))
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", openAIToolCallGeminiThoughtSignature(tc))
|
||||
partItems = append(partItems, part)
|
||||
if functionID != "" {
|
||||
functionIDs = append(functionIDs, functionID)
|
||||
}
|
||||
}
|
||||
if len(partItems) > 0 {
|
||||
contentItems = append(contentItems, geminiContentNode("model", partItems))
|
||||
}
|
||||
|
||||
// Append a single tool content combining name + response per function.
|
||||
responseParts := make([][]byte, 0, len(functionIDs))
|
||||
for _, functionID := range functionIDs {
|
||||
if name, ok := tcID2Name[functionID]; ok {
|
||||
part := []byte(`{"functionResponse":{"name":"","response":{"result":""}}}`)
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.name", util.SanitizeFunctionName(name))
|
||||
response := toolResponses[functionID]
|
||||
if response == "" {
|
||||
response = "{}"
|
||||
}
|
||||
part, _ = sjson.SetBytes(part, "functionResponse.response.result", []byte(response))
|
||||
responseParts = append(responseParts, part)
|
||||
}
|
||||
}
|
||||
if len(responseParts) > 0 {
|
||||
contentItems = append(contentItems, geminiContentNode("user", responseParts))
|
||||
}
|
||||
} else if len(partItems) > 0 {
|
||||
contentItems = append(contentItems, geminiContentNode("model", partItems))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if len(systemParts) > 0 {
|
||||
systemInstruction := geminiContentNode("user", systemParts)
|
||||
out, _ = sjson.SetRawBytes(out, "systemInstruction", systemInstruction)
|
||||
}
|
||||
if len(contentItems) > 0 && gjson.GetBytes(contentItems[len(contentItems)-1], "role").String() == "model" {
|
||||
contentItems = contentItems[:len(contentItems)-1]
|
||||
}
|
||||
out = translatorcommon.SetRawArrayItems(out, "contents", contentItems)
|
||||
}
|
||||
|
||||
// tools -> tools[].functionDeclarations + tools[].googleSearch/codeExecution/urlContext passthrough
|
||||
tools := gjson.GetBytes(rawJSON, "tools")
|
||||
toolResults := tools.Array()
|
||||
if tools.IsArray() && len(toolResults) > 0 {
|
||||
functionDeclarations := make([][]byte, 0, len(toolResults))
|
||||
googleSearchNodes := make([][]byte, 0)
|
||||
codeExecutionNodes := make([][]byte, 0)
|
||||
urlContextNodes := make([][]byte, 0)
|
||||
for _, t := range toolResults {
|
||||
if t.Get("type").String() == "function" {
|
||||
fn := t.Get("function")
|
||||
if fn.Exists() && fn.IsObject() {
|
||||
fnRaw := fn.Raw
|
||||
if fn.Get("parameters").Exists() {
|
||||
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
|
||||
if errRename != nil {
|
||||
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
|
||||
var errSet error
|
||||
fnRawBytes := []byte(fnRaw)
|
||||
fnRawBytes, errSet = sjson.SetBytes(fnRawBytes, "parametersJsonSchema.type", "object")
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRawBytes, errSet = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema.properties", []byte(`{}`))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRaw = string(fnRawBytes)
|
||||
} else {
|
||||
fnRaw = renamed
|
||||
}
|
||||
} else {
|
||||
var errSet error
|
||||
fnRawBytes := []byte(fnRaw)
|
||||
fnRawBytes, errSet = sjson.SetBytes(fnRawBytes, "parametersJsonSchema.type", "object")
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRawBytes, errSet = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema.properties", []byte(`{}`))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
|
||||
continue
|
||||
}
|
||||
fnRaw = string(fnRawBytes)
|
||||
}
|
||||
fnRawBytes := []byte(fnRaw)
|
||||
nameResult := fn.Get("name")
|
||||
originalName := nameResult.String()
|
||||
sanitizedName := util.SanitizeFunctionName(originalName)
|
||||
if nameResult.Type != gjson.String || sanitizedName != originalName {
|
||||
fnRawBytes, _ = sjson.SetBytes(fnRawBytes, "name", sanitizedName)
|
||||
}
|
||||
if parameters := gjson.GetBytes(fnRawBytes, "parametersJsonSchema"); parameters.Exists() {
|
||||
cleanedParameters := util.CleanJSONSchemaForGemini(parameters.Raw)
|
||||
if cleanedParameters != parameters.Raw {
|
||||
fnRawBytes, _ = sjson.SetRawBytes(fnRawBytes, "parametersJsonSchema", []byte(cleanedParameters))
|
||||
}
|
||||
}
|
||||
if gjson.GetBytes(fnRawBytes, "strict").Exists() {
|
||||
fnRawBytes, _ = sjson.DeleteBytes(fnRawBytes, "strict")
|
||||
}
|
||||
functionDeclarations = append(functionDeclarations, fnRawBytes)
|
||||
}
|
||||
}
|
||||
if gs := t.Get("google_search"); gs.Exists() {
|
||||
googleToolNode := []byte(`{}`)
|
||||
var errSet error
|
||||
googleToolNode, errSet = sjson.SetRawBytes(googleToolNode, "googleSearch", []byte(gs.Raw))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set googleSearch tool: %v", errSet)
|
||||
continue
|
||||
}
|
||||
googleSearchNodes = append(googleSearchNodes, googleToolNode)
|
||||
}
|
||||
if ce := t.Get("code_execution"); ce.Exists() {
|
||||
codeToolNode := []byte(`{}`)
|
||||
var errSet error
|
||||
codeToolNode, errSet = sjson.SetRawBytes(codeToolNode, "codeExecution", []byte(ce.Raw))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set codeExecution tool: %v", errSet)
|
||||
continue
|
||||
}
|
||||
codeExecutionNodes = append(codeExecutionNodes, codeToolNode)
|
||||
}
|
||||
if uc := t.Get("url_context"); uc.Exists() {
|
||||
urlToolNode := []byte(`{}`)
|
||||
var errSet error
|
||||
urlToolNode, errSet = sjson.SetRawBytes(urlToolNode, "urlContext", []byte(uc.Raw))
|
||||
if errSet != nil {
|
||||
log.Warnf("Failed to set urlContext tool: %v", errSet)
|
||||
continue
|
||||
}
|
||||
urlContextNodes = append(urlContextNodes, urlToolNode)
|
||||
}
|
||||
}
|
||||
if len(functionDeclarations) > 0 || len(googleSearchNodes) > 0 || len(codeExecutionNodes) > 0 || len(urlContextNodes) > 0 {
|
||||
toolItems := make([][]byte, 0, 1+len(googleSearchNodes)+len(codeExecutionNodes)+len(urlContextNodes))
|
||||
if len(functionDeclarations) > 0 {
|
||||
functionToolNode := []byte(`{"functionDeclarations":[]}`)
|
||||
functionToolNode, _ = sjson.SetRawBytes(functionToolNode, "functionDeclarations", translatorcommon.JoinRawArray(functionDeclarations))
|
||||
toolItems = append(toolItems, functionToolNode)
|
||||
}
|
||||
toolItems = append(toolItems, googleSearchNodes...)
|
||||
toolItems = append(toolItems, codeExecutionNodes...)
|
||||
toolItems = append(toolItems, urlContextNodes...)
|
||||
out, _ = sjson.SetRawBytes(out, "tools", translatorcommon.JoinRawArray(toolItems))
|
||||
}
|
||||
}
|
||||
|
||||
out = common.AttachDefaultSafetySettings(out, "safetySettings")
|
||||
|
||||
return out
|
||||
}
|
||||
|
||||
func geminiTextPart(text string) []byte {
|
||||
part := []byte(`{"text":""}`)
|
||||
part, _ = sjson.SetBytes(part, "text", text)
|
||||
return part
|
||||
}
|
||||
|
||||
func geminiInlineDataPart(mimeType, data, thoughtSignature string) []byte {
|
||||
part := []byte(`{"inlineData":{"mime_type":"","data":""}}`)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.mime_type", mimeType)
|
||||
part, _ = sjson.SetBytes(part, "inlineData.data", data)
|
||||
if thoughtSignature != "" {
|
||||
part, _ = sjson.SetBytes(part, "thoughtSignature", thoughtSignature)
|
||||
}
|
||||
return part
|
||||
}
|
||||
|
||||
func geminiContentNode(role string, parts [][]byte) []byte {
|
||||
content := []byte(`{"role":"","parts":[]}`)
|
||||
content, _ = sjson.SetBytes(content, "role", role)
|
||||
content, _ = sjson.SetRawBytes(content, "parts", translatorcommon.JoinRawArray(parts))
|
||||
return content
|
||||
}
|
||||
|
||||
func openAIToolCallGeminiThoughtSignature(toolCall gjson.Result) string {
|
||||
for _, path := range []string{
|
||||
"extra_content.google.thought_signature",
|
||||
"function.extra_content.google.thought_signature",
|
||||
"thoughtSignature",
|
||||
"thought_signature",
|
||||
} {
|
||||
if signatureResult := toolCall.Get(path); signatureResult.Exists() {
|
||||
return sigcompat.GeminiReplaySignatureOrBypass(signatureResult.String(), sigcompat.SignatureBlockKindGeminiFunctionCall)
|
||||
}
|
||||
}
|
||||
return geminiFunctionThoughtSignature
|
||||
}
|
||||
|
||||
func openAIInputAudioMimeType(audioFormat string) string {
|
||||
switch audioFormat {
|
||||
case "", "wav":
|
||||
return "audio/wav"
|
||||
case "mp3":
|
||||
return "audio/mpeg"
|
||||
case "ogg":
|
||||
return "audio/ogg"
|
||||
case "flac":
|
||||
return "audio/flac"
|
||||
case "aac":
|
||||
return "audio/aac"
|
||||
case "webm":
|
||||
return "audio/webm"
|
||||
case "pcm16":
|
||||
return "audio/pcm"
|
||||
case "g711_ulaw", "g711_alaw":
|
||||
return "audio/basic"
|
||||
default:
|
||||
return "audio/" + audioFormat
|
||||
}
|
||||
}
|
||||
|
||||
// applyOpenAIResponseFormatToGemini maps OpenAI Chat Completions structured output settings to Gemini.
|
||||
// Response schemas pass through unchanged because the tool schema cleaner removes supported response fields.
|
||||
func applyOpenAIResponseFormatToGemini(out []byte, rawJSON []byte) []byte {
|
||||
responseFormat := gjson.GetBytes(rawJSON, "response_format")
|
||||
if !responseFormat.Exists() {
|
||||
return out
|
||||
}
|
||||
|
||||
switch strings.ToLower(strings.TrimSpace(responseFormat.Get("type").String())) {
|
||||
case "json_object":
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
|
||||
case "json_schema":
|
||||
out, _ = sjson.SetBytes(out, "generationConfig.responseMimeType", "application/json")
|
||||
out, _ = sjson.DeleteBytes(out, "generationConfig.responseSchema")
|
||||
if schema := responseFormat.Get("json_schema.schema"); schema.Exists() {
|
||||
out, _ = sjson.SetRawBytes(out, "generationConfig.responseJsonSchema", []byte(schema.Raw))
|
||||
}
|
||||
}
|
||||
|
||||
return out
|
||||
}
|
||||
|
|
@ -0,0 +1,417 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestToGemini_StripsTrailingAssistantPrefill(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-5.4",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hello"},
|
||||
{"role": "assistant", "content": "previous answer"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
contents := resultJSON.Get("contents").Array()
|
||||
|
||||
if len(contents) != 1 {
|
||||
t.Fatalf("contents length = %d, want 1. contents=%s", len(contents), resultJSON.Get("contents").Raw)
|
||||
}
|
||||
if got := contents[0].Get("role").String(); got != "user" {
|
||||
t.Fatalf("final remaining role = %q, want %q", got, "user")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiPreservesInputAudio(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gpt-5.5",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": "Transcribe this audio verbatim."},
|
||||
{"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3.1-pro-high", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
parts := resultJSON.Get("contents.0.parts").Array()
|
||||
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts length = %d, want 2. parts=%s", len(parts), resultJSON.Get("contents.0.parts").Raw)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "Transcribe this audio verbatim." {
|
||||
t.Fatalf("text part = %q, want prompt text", got)
|
||||
}
|
||||
if got := parts[1].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
|
||||
t.Fatalf("audio mime_type = %q, want %q", got, "audio/mpeg")
|
||||
}
|
||||
if got := parts[1].Get("inlineData.data").String(); got != "SUQzBA==" {
|
||||
t.Fatalf("audio data = %q, want %q", got, "SUQzBA==")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiPreservesVideoURL(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "video_url", "video_url": {"url": "data:video/mp4;base64,AAAAIGZ0eXBtcDQy"}},
|
||||
{"type": "text", "text": "Describe the video"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
|
||||
resultJSON := gjson.ParseBytes(result)
|
||||
parts := resultJSON.Get("contents.0.parts").Array()
|
||||
|
||||
if len(parts) != 2 {
|
||||
t.Fatalf("parts length = %d, want 2. parts=%s", len(parts), resultJSON.Get("contents.0.parts").Raw)
|
||||
}
|
||||
if got := parts[0].Get("inlineData.mime_type").String(); got != "video/mp4" {
|
||||
t.Fatalf("video mime_type = %q, want %q", got, "video/mp4")
|
||||
}
|
||||
if got := parts[0].Get("inlineData.data").String(); got != "AAAAIGZ0eXBtcDQy" {
|
||||
t.Fatalf("video data = %q, want %q", got, "AAAAIGZ0eXBtcDQy")
|
||||
}
|
||||
if got := parts[1].Get("text").String(); got != "Describe the video" {
|
||||
t.Fatalf("text part = %q, want prompt text", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiSkipsEmptyTextPartsWithoutNulls(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": [
|
||||
{"type": "text", "text": ""},
|
||||
{"type": "input_audio", "input_audio": {"data": "SUQzBA==", "format": "mp3"}}
|
||||
]
|
||||
},
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": [{"type": "text", "text": ""}],
|
||||
"tool_calls": [{
|
||||
"id": "call_1",
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "arguments": "{\"path\":\"a.txt\"}"}
|
||||
}]
|
||||
},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"},
|
||||
{"role": "user", "content": "done"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), false)
|
||||
userParts := gjson.GetBytes(result, "contents.0.parts").Array()
|
||||
if len(userParts) != 1 {
|
||||
t.Fatalf("user parts length = %d, want 1. Output: %s", len(userParts), result)
|
||||
}
|
||||
if userParts[0].Type == gjson.Null {
|
||||
t.Fatalf("user parts.0 is null. Output: %s", result)
|
||||
}
|
||||
if got := userParts[0].Get("inlineData.mime_type").String(); got != "audio/mpeg" {
|
||||
t.Fatalf("audio mime_type = %q, want audio/mpeg. Output: %s", got, result)
|
||||
}
|
||||
|
||||
assistantParts := gjson.GetBytes(result, "contents.1.parts").Array()
|
||||
if len(assistantParts) != 1 {
|
||||
t.Fatalf("assistant parts length = %d, want 1. Output: %s", len(assistantParts), result)
|
||||
}
|
||||
if assistantParts[0].Type == gjson.Null {
|
||||
t.Fatalf("assistant parts.0 is null. Output: %s", result)
|
||||
}
|
||||
if !assistantParts[0].Get("functionCall").Exists() {
|
||||
t.Fatalf("functionCall missing. Output: %s", result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiPreservesReasoningContent(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "", "reasoning_content": "thinking only"},
|
||||
{"role": "user", "content": "say ok"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "contents").Array()
|
||||
if len(contents) != 3 {
|
||||
t.Fatalf("contents length = %d, want 3. Output: %s", len(contents), result)
|
||||
}
|
||||
part := contents[1].Get("parts.0")
|
||||
if got := contents[1].Get("role").String(); got != "model" {
|
||||
t.Fatalf("contents.1.role = %q, want model. Output: %s", got, result)
|
||||
}
|
||||
if got := part.Get("text").String(); got != "thinking only" {
|
||||
t.Fatalf("reasoning text = %q, want thinking only. Output: %s", got, result)
|
||||
}
|
||||
if !part.Get("thought").Bool() {
|
||||
t.Fatalf("reasoning part should be marked as thought. Output: %s", result)
|
||||
}
|
||||
if got := part.Get("thoughtSignature").String(); got != geminiFunctionThoughtSignature {
|
||||
t.Fatalf("thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiPreservesReasoningBeforeVisibleContentAndToolCall(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "visible answer", "reasoning_content": "thinking only", "tool_calls": [{"id": "call_1", "type": "function", "function": {"name": "read_file", "arguments": "{}"}}]},
|
||||
{"role": "tool", "tool_call_id": "call_1", "content": "{\"output\":\"ok\"}"},
|
||||
{"role": "user", "content": "say ok"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "contents").Array()
|
||||
if len(contents) != 4 {
|
||||
t.Fatalf("contents length = %d, want 4. Output: %s", len(contents), result)
|
||||
}
|
||||
parts := contents[1].Get("parts").Array()
|
||||
if len(parts) != 3 {
|
||||
t.Fatalf("model parts length = %d, want 3. Output: %s", len(parts), result)
|
||||
}
|
||||
if got := parts[0].Get("text").String(); got != "thinking only" || !parts[0].Get("thought").Bool() {
|
||||
t.Fatalf("first part should be the reasoning thought. Output: %s", result)
|
||||
}
|
||||
if got := parts[1].Get("text").String(); got != "visible answer" || parts[1].Get("thought").Bool() {
|
||||
t.Fatalf("second part should be visible assistant content. Output: %s", result)
|
||||
}
|
||||
if got := parts[2].Get("functionCall.name").String(); got != "read_file" {
|
||||
t.Fatalf("functionCall.name = %q, want read_file. Output: %s", got, result)
|
||||
}
|
||||
if got := parts[2].Get("thoughtSignature").String(); got != geminiFunctionThoughtSignature {
|
||||
t.Fatalf("functionCall thoughtSignature = %q, want bypass sentinel. Output: %s", got, result)
|
||||
}
|
||||
if got := contents[2].Get("parts.0.functionResponse.name").String(); got != "read_file" {
|
||||
t.Fatalf("functionResponse.name = %q, want read_file. Output: %s", got, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiSkipsEmptyAssistantMessages(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3-flash",
|
||||
"messages": [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "", "tool_calls": [{"type": "function", "function": {"name": "", "arguments": "{}"}}, {"type": "custom"}]},
|
||||
{"role": "user", "content": "say ok"}
|
||||
]
|
||||
}`
|
||||
|
||||
result := ConvertOpenAIRequestToGemini("gemini-3-flash", []byte(inputJSON), true)
|
||||
contents := gjson.GetBytes(result, "contents").Array()
|
||||
if len(contents) != 2 {
|
||||
t.Fatalf("contents length = %d, want 2. Output: %s", len(contents), result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiMapsMaxTokens(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
want int64
|
||||
}{
|
||||
{
|
||||
name: "max_tokens",
|
||||
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":30}`,
|
||||
want: 30,
|
||||
},
|
||||
{
|
||||
name: "max_completion_tokens",
|
||||
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_completion_tokens":40}`,
|
||||
want: 40,
|
||||
},
|
||||
{
|
||||
name: "max_tokens preferred over max_completion_tokens",
|
||||
body: `{"model":"gemini-2.0-flash","messages":[{"role":"user","content":"hi"}],"max_tokens":30,"max_completion_tokens":40}`,
|
||||
want: 30,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
out := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(tt.body), false)
|
||||
if got := gjson.GetBytes(out, "generationConfig.maxOutputTokens").Int(); got != tt.want {
|
||||
t.Fatalf("generationConfig.maxOutputTokens = %d, want %d. Output: %s", got, tt.want, out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiCleansToolSchemaRequiredFields(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-2.0-flash",
|
||||
"messages": [{"role": "user", "content": "hi"}],
|
||||
"tools": [{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "search_company",
|
||||
"description": "Search",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"title": "SearchCompany",
|
||||
"properties": {
|
||||
"country": {"type": "string"},
|
||||
"industry": {"type": "string"}
|
||||
},
|
||||
"required": ["country", "industry", "stale_field", "another_stale"]
|
||||
}
|
||||
}
|
||||
}]
|
||||
}`
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-2.0-flash", []byte(inputJSON), false)
|
||||
schema := gjson.GetBytes(output, "tools.0.functionDeclarations.0.parametersJsonSchema")
|
||||
|
||||
if !schema.Exists() {
|
||||
t.Fatalf("parametersJsonSchema missing. Output: %s", output)
|
||||
}
|
||||
if schema.Get("title").Exists() {
|
||||
t.Fatalf("schema title should be removed. Output: %s", output)
|
||||
}
|
||||
required := schema.Get("required").Array()
|
||||
if len(required) != 2 {
|
||||
t.Fatalf("required length = %d, want 2. Schema: %s", len(required), schema.Raw)
|
||||
}
|
||||
if got := required[0].String(); got != "country" {
|
||||
t.Fatalf("required[0] = %q, want country. Schema: %s", got, schema.Raw)
|
||||
}
|
||||
if got := required[1].String(); got != "industry" {
|
||||
t.Fatalf("required[1] = %q, want industry. Schema: %s", got, schema.Raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchema(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.1-flash-lite",
|
||||
"generationConfig": {
|
||||
"temperature": 0.2,
|
||||
"responseSchema": {"type": "string"}
|
||||
},
|
||||
"messages": [{"role": "user", "content": "Return structured JSON."}],
|
||||
"response_format": {
|
||||
"type": "json_schema",
|
||||
"json_schema": {
|
||||
"name": "response",
|
||||
"strict": true,
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {"cleanedContent": {"type": "string"}},
|
||||
"required": ["cleanedContent"],
|
||||
"additionalProperties": false
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
|
||||
generationConfig := gjson.GetBytes(output, "generationConfig")
|
||||
|
||||
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
|
||||
}
|
||||
schema := generationConfig.Get("responseJsonSchema")
|
||||
if !schema.Exists() {
|
||||
t.Fatalf("responseJsonSchema missing. Output: %s", output)
|
||||
}
|
||||
if generationConfig.Get("responseSchema").Exists() {
|
||||
t.Fatalf("responseSchema should be removed. Output: %s", output)
|
||||
}
|
||||
if additionalProperties := schema.Get("additionalProperties"); !additionalProperties.Exists() || additionalProperties.Bool() {
|
||||
t.Fatalf("additionalProperties = %s, want false. Output: %s", additionalProperties.Raw, output)
|
||||
}
|
||||
if got := generationConfig.Get("temperature").Float(); got != 0.2 {
|
||||
t.Fatalf("temperature = %v, want 0.2. Output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiResponseFormatJSONObject(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.1-flash-lite",
|
||||
"generationConfig": {"temperature": 0.6},
|
||||
"messages": [{"role": "user", "content": "Return a JSON object."}],
|
||||
"response_format": {"type": "json_object"}
|
||||
}`
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
|
||||
generationConfig := gjson.GetBytes(output, "generationConfig")
|
||||
|
||||
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
|
||||
}
|
||||
if generationConfig.Get("responseJsonSchema").Exists() {
|
||||
t.Fatalf("responseJsonSchema should not be set for json_object. Output: %s", output)
|
||||
}
|
||||
if got := generationConfig.Get("temperature").Float(); got != 0.6 {
|
||||
t.Fatalf("temperature = %v, want 0.6. Output: %s", got, output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiResponseFormatJSONSchemaWithoutSchema(t *testing.T) {
|
||||
inputJSON := `{
|
||||
"model": "gemini-3.1-flash-lite",
|
||||
"messages": [{"role": "user", "content": "Return structured JSON."}],
|
||||
"response_format": {"type": "json_schema", "json_schema": {"name": "response"}}
|
||||
}`
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(inputJSON), false)
|
||||
generationConfig := gjson.GetBytes(output, "generationConfig")
|
||||
|
||||
if got := generationConfig.Get("responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json. Output: %s", got, output)
|
||||
}
|
||||
if generationConfig.Get("responseJsonSchema").Exists() {
|
||||
t.Fatalf("responseJsonSchema should not be set without a schema. Output: %s", output)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiResponseFormatNoOp(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{
|
||||
name: "absent",
|
||||
body: `{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"plain text"}],"temperature":0.5}`,
|
||||
},
|
||||
{
|
||||
name: "unknown type",
|
||||
body: `{"model":"gemini-3.1-flash-lite","messages":[{"role":"user","content":"plain text"}],"temperature":0.5,"response_format":{"type":"text"}}`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.1-flash-lite", []byte(tt.body), false)
|
||||
generationConfig := gjson.GetBytes(output, "generationConfig")
|
||||
if generationConfig.Get("responseMimeType").Exists() {
|
||||
t.Fatalf("responseMimeType should not be set. Output: %s", output)
|
||||
}
|
||||
if generationConfig.Get("responseJsonSchema").Exists() {
|
||||
t.Fatalf("responseJsonSchema should not be set. Output: %s", output)
|
||||
}
|
||||
if got := generationConfig.Get("temperature").Float(); got != 0.5 {
|
||||
t.Fatalf("temperature = %v, want 0.5. Output: %s", got, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,444 @@
|
|||
// Package openai provides response translation functionality for Gemini to OpenAI API compatibility.
|
||||
// This package handles the conversion of Gemini API responses into OpenAI Chat Completions-compatible
|
||||
// JSON format, transforming streaming events and non-streaming responses into the format
|
||||
// expected by OpenAI API clients. It supports both streaming and non-streaming modes,
|
||||
// handling text content, tool calls, reasoning content, and usage metadata appropriately.
|
||||
package chat_completions
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
translatorcommon "github.com/router-for-me/CLIProxyAPI/v7/internal/translator/common"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/util"
|
||||
log "github.com/sirupsen/logrus"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
// convertGeminiResponseToOpenAIChatParams holds parameters for response conversion.
|
||||
type convertGeminiResponseToOpenAIChatParams struct {
|
||||
UnixTimestamp int64
|
||||
// FunctionIndex tracks tool call indices per candidate index to support multiple candidates.
|
||||
FunctionIndex map[int]int
|
||||
SawToolCall map[int]bool
|
||||
UpstreamFinishReason map[int]string
|
||||
SanitizedNameMap map[string]string
|
||||
}
|
||||
|
||||
// functionCallIDCounter provides a process-wide unique counter for function call identifiers.
|
||||
var functionCallIDCounter uint64
|
||||
|
||||
// ConvertGeminiResponseToOpenAI translates a single chunk of a streaming response from the
|
||||
// Gemini API format to the OpenAI Chat Completions streaming format.
|
||||
// It processes various Gemini event types and transforms them into OpenAI-compatible JSON responses.
|
||||
// The function handles text content, tool calls, reasoning content, and usage metadata, outputting
|
||||
// responses that match the OpenAI API format. It supports incremental updates for streaming responses.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response (unused in current implementation)
|
||||
// - rawJSON: The raw JSON response from the Gemini API
|
||||
// - param: A pointer to a parameter object for maintaining state between calls
|
||||
//
|
||||
// Returns:
|
||||
// - [][]byte: A slice of OpenAI-compatible JSON responses
|
||||
func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) [][]byte {
|
||||
// Initialize parameters if nil.
|
||||
if *param == nil {
|
||||
*param = &convertGeminiResponseToOpenAIChatParams{
|
||||
UnixTimestamp: 0,
|
||||
FunctionIndex: make(map[int]int),
|
||||
SawToolCall: make(map[int]bool),
|
||||
UpstreamFinishReason: make(map[int]string),
|
||||
SanitizedNameMap: util.SanitizedToolNameMap(originalRequestRawJSON),
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure the Map is initialized (handling cases where param might be reused from older context).
|
||||
p := (*param).(*convertGeminiResponseToOpenAIChatParams)
|
||||
if p.FunctionIndex == nil {
|
||||
p.FunctionIndex = make(map[int]int)
|
||||
}
|
||||
if p.SawToolCall == nil {
|
||||
p.SawToolCall = make(map[int]bool)
|
||||
}
|
||||
if p.UpstreamFinishReason == nil {
|
||||
p.UpstreamFinishReason = make(map[int]string)
|
||||
}
|
||||
if p.SanitizedNameMap == nil {
|
||||
p.SanitizedNameMap = util.SanitizedToolNameMap(originalRequestRawJSON)
|
||||
}
|
||||
|
||||
if bytes.HasPrefix(rawJSON, []byte("data:")) {
|
||||
rawJSON = bytes.TrimSpace(rawJSON[5:])
|
||||
}
|
||||
|
||||
if bytes.Equal(rawJSON, []byte("[DONE]")) {
|
||||
return [][]byte{}
|
||||
}
|
||||
|
||||
// Initialize the OpenAI SSE base template.
|
||||
// We use a base template and clone it for each candidate to support multiple candidates.
|
||||
baseTemplate := []byte(`{"id":"","object":"chat.completion.chunk","created":12345,"model":"model","choices":[{"index":0,"delta":{"role":null,"content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}]}`)
|
||||
|
||||
// Extract and set the model version.
|
||||
if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "model", modelVersionResult.String())
|
||||
}
|
||||
|
||||
// Extract and set the creation timestamp.
|
||||
if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
|
||||
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
||||
if err == nil {
|
||||
p.UnixTimestamp = t.Unix()
|
||||
}
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "created", p.UnixTimestamp)
|
||||
} else {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "created", p.UnixTimestamp)
|
||||
}
|
||||
|
||||
// Extract and set the response ID.
|
||||
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "id", responseIDResult.String())
|
||||
}
|
||||
|
||||
// Extract and set usage metadata (token counts).
|
||||
// Usage is applied to the base template so it appears in the chunks.
|
||||
if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
|
||||
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int())
|
||||
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.total_tokens", totalTokenCountResult.Int())
|
||||
}
|
||||
promptTokenCount := usageResult.Get("promptTokenCount").Int()
|
||||
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.prompt_tokens", promptTokenCount)
|
||||
if thoughtsTokenCount > 0 {
|
||||
baseTemplate, _ = sjson.SetBytes(baseTemplate, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
|
||||
}
|
||||
// Include cached token count if present (indicates prompt caching is working)
|
||||
if cachedTokenCount > 0 {
|
||||
var err error
|
||||
baseTemplate, err = sjson.SetBytes(baseTemplate, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
|
||||
if err != nil {
|
||||
log.Warnf("gemini openai response: failed to set cached_tokens in streaming: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var responseStrings [][]byte
|
||||
candidates := gjson.GetBytes(rawJSON, "candidates")
|
||||
|
||||
// Iterate over all candidates to support candidate_count > 1.
|
||||
if candidates.IsArray() {
|
||||
candidates.ForEach(func(_, candidate gjson.Result) bool {
|
||||
// Clone the template for the current candidate.
|
||||
template := append([]byte(nil), baseTemplate...)
|
||||
|
||||
// Set the specific index for this candidate.
|
||||
candidateIndex := int(candidate.Get("index").Int())
|
||||
template, _ = sjson.SetBytes(template, "choices.0.index", candidateIndex)
|
||||
|
||||
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
|
||||
p.UpstreamFinishReason[candidateIndex] = strings.ToUpper(finishReasonResult.String())
|
||||
}
|
||||
|
||||
partsResult := candidate.Get("content.parts")
|
||||
assistantRoleSet := false
|
||||
setAssistantRole := func() {
|
||||
if assistantRoleSet {
|
||||
return
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.role", "assistant")
|
||||
assistantRoleSet = true
|
||||
}
|
||||
|
||||
if partsResult.IsArray() {
|
||||
partResults := partsResult.Array()
|
||||
for i := 0; i < len(partResults); i++ {
|
||||
partResult := partResults[i]
|
||||
partTextResult := partResult.Get("text")
|
||||
functionCallResult := partResult.Get("functionCall")
|
||||
inlineDataResult := partResult.Get("inlineData")
|
||||
if !inlineDataResult.Exists() {
|
||||
inlineDataResult = partResult.Get("inline_data")
|
||||
}
|
||||
thoughtSignatureResult := partResult.Get("thoughtSignature")
|
||||
if !thoughtSignatureResult.Exists() {
|
||||
thoughtSignatureResult = partResult.Get("thought_signature")
|
||||
}
|
||||
|
||||
hasThoughtSignature := thoughtSignatureResult.Exists() && thoughtSignatureResult.String() != ""
|
||||
hasContentPayload := partTextResult.Exists() || functionCallResult.Exists() || inlineDataResult.Exists()
|
||||
|
||||
// Skip pure thoughtSignature parts but keep any actual payload in the same part.
|
||||
if hasThoughtSignature && !hasContentPayload {
|
||||
continue
|
||||
}
|
||||
|
||||
if partTextResult.Exists() {
|
||||
text := partTextResult.String()
|
||||
setAssistantRole()
|
||||
// Handle text content, distinguishing between regular content and reasoning/thoughts.
|
||||
if partResult.Get("thought").Bool() {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.reasoning_content", text)
|
||||
} else {
|
||||
template, _ = sjson.SetBytes(template, "choices.0.delta.content", text)
|
||||
}
|
||||
} else if functionCallResult.Exists() {
|
||||
// Handle function call content.
|
||||
p.SawToolCall[candidateIndex] = true
|
||||
toolCallsResult := gjson.GetBytes(template, "choices.0.delta.tool_calls")
|
||||
|
||||
// Retrieve the function index for this specific candidate.
|
||||
functionCallIndex := p.FunctionIndex[candidateIndex]
|
||||
p.FunctionIndex[candidateIndex]++
|
||||
|
||||
if toolCallsResult.Exists() && toolCallsResult.IsArray() {
|
||||
functionCallIndex = len(toolCallsResult.Array())
|
||||
} else {
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls", []byte(`[]`))
|
||||
}
|
||||
|
||||
functionCallTemplate := []byte(`{"id":"","index":0,"type":"function","function":{"name":"","arguments":""}}`)
|
||||
fcName := util.RestoreSanitizedToolName(p.SanitizedNameMap, functionCallResult.Get("name").String())
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "index", functionCallIndex)
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.name", fcName)
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
functionCallTemplate, _ = sjson.SetBytes(functionCallTemplate, "function.arguments", fcArgsResult.Raw)
|
||||
}
|
||||
setAssistantRole()
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.tool_calls.-1", functionCallTemplate)
|
||||
} else if inlineDataResult.Exists() {
|
||||
data := inlineDataResult.Get("data").String()
|
||||
if data == "" {
|
||||
continue
|
||||
}
|
||||
mimeType := inlineDataResult.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = inlineDataResult.Get("mime_type").String()
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||
imagesResult := gjson.GetBytes(template, "choices.0.delta.images")
|
||||
if !imagesResult.Exists() || !imagesResult.IsArray() {
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images", []byte(`[]`))
|
||||
}
|
||||
imageIndex := len(gjson.GetBytes(template, "choices.0.delta.images").Array())
|
||||
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "index", imageIndex)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
|
||||
setAssistantRole()
|
||||
template, _ = sjson.SetRawBytes(template, "choices.0.delta.images.-1", imagePayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
upstreamFinishReason := p.UpstreamFinishReason[candidateIndex]
|
||||
sawToolCall := p.SawToolCall[candidateIndex]
|
||||
usageExists := gjson.GetBytes(rawJSON, "usageMetadata").Exists()
|
||||
isFinalChunk := upstreamFinishReason != "" && usageExists
|
||||
|
||||
if isFinalChunk {
|
||||
var finishReason string
|
||||
if sawToolCall {
|
||||
finishReason = "tool_calls"
|
||||
} else if upstreamFinishReason == "MAX_TOKENS" {
|
||||
finishReason = "max_tokens"
|
||||
} else {
|
||||
finishReason = "stop"
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "choices.0.finish_reason", finishReason)
|
||||
template, _ = sjson.SetBytes(template, "choices.0.native_finish_reason", strings.ToLower(upstreamFinishReason))
|
||||
}
|
||||
|
||||
responseStrings = append(responseStrings, template)
|
||||
return true // continue loop
|
||||
})
|
||||
} else {
|
||||
// If there are no candidates (e.g., a pure usageMetadata chunk), return the usage chunk if present.
|
||||
if gjson.GetBytes(rawJSON, "usageMetadata").Exists() && len(responseStrings) == 0 {
|
||||
responseStrings = append(responseStrings, append([]byte(nil), baseTemplate...))
|
||||
}
|
||||
}
|
||||
|
||||
return responseStrings
|
||||
}
|
||||
|
||||
// ConvertGeminiResponseToOpenAINonStream converts a non-streaming Gemini response to a non-streaming OpenAI response.
|
||||
// This function processes the complete Gemini response and transforms it into a single OpenAI-compatible
|
||||
// JSON response. It handles message content, tool calls, reasoning content, and usage metadata, combining all
|
||||
// the information into a single response that matches the OpenAI API format.
|
||||
//
|
||||
// Parameters:
|
||||
// - ctx: The context for the request, used for cancellation and timeout handling
|
||||
// - modelName: The name of the model being used for the response (unused in current implementation)
|
||||
// - rawJSON: The raw JSON response from the Gemini API
|
||||
// - param: A pointer to a parameter object for the conversion (unused in current implementation)
|
||||
//
|
||||
// Returns:
|
||||
// - []byte: An OpenAI-compatible JSON response containing all message content and metadata
|
||||
func ConvertGeminiResponseToOpenAINonStream(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, _ *any) []byte {
|
||||
sanitizedNameMap := util.SanitizedToolNameMap(originalRequestRawJSON)
|
||||
var unixTimestamp int64
|
||||
// Initialize template with an empty choices array to support multiple candidates.
|
||||
template := []byte(`{"id":"","object":"chat.completion","created":123456,"model":"model","choices":[]}`)
|
||||
|
||||
if modelVersionResult := gjson.GetBytes(rawJSON, "modelVersion"); modelVersionResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "model", modelVersionResult.String())
|
||||
}
|
||||
|
||||
if createTimeResult := gjson.GetBytes(rawJSON, "createTime"); createTimeResult.Exists() {
|
||||
t, err := time.Parse(time.RFC3339Nano, createTimeResult.String())
|
||||
if err == nil {
|
||||
unixTimestamp = t.Unix()
|
||||
}
|
||||
template, _ = sjson.SetBytes(template, "created", unixTimestamp)
|
||||
} else {
|
||||
template, _ = sjson.SetBytes(template, "created", unixTimestamp)
|
||||
}
|
||||
|
||||
if responseIDResult := gjson.GetBytes(rawJSON, "responseId"); responseIDResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "id", responseIDResult.String())
|
||||
}
|
||||
|
||||
if usageResult := gjson.GetBytes(rawJSON, "usageMetadata"); usageResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens", usageResult.Get("candidatesTokenCount").Int())
|
||||
if totalTokenCountResult := usageResult.Get("totalTokenCount"); totalTokenCountResult.Exists() {
|
||||
template, _ = sjson.SetBytes(template, "usage.total_tokens", totalTokenCountResult.Int())
|
||||
}
|
||||
promptTokenCount := usageResult.Get("promptTokenCount").Int()
|
||||
thoughtsTokenCount := usageResult.Get("thoughtsTokenCount").Int()
|
||||
cachedTokenCount := usageResult.Get("cachedContentTokenCount").Int()
|
||||
template, _ = sjson.SetBytes(template, "usage.prompt_tokens", promptTokenCount)
|
||||
if thoughtsTokenCount > 0 {
|
||||
template, _ = sjson.SetBytes(template, "usage.completion_tokens_details.reasoning_tokens", thoughtsTokenCount)
|
||||
}
|
||||
// Include cached token count if present (indicates prompt caching is working)
|
||||
if cachedTokenCount > 0 {
|
||||
var err error
|
||||
template, err = sjson.SetBytes(template, "usage.prompt_tokens_details.cached_tokens", cachedTokenCount)
|
||||
if err != nil {
|
||||
log.Warnf("gemini openai response: failed to set cached_tokens in non-streaming: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Process the main content part of the response for all candidates.
|
||||
candidates := gjson.GetBytes(rawJSON, "candidates")
|
||||
if candidates.IsArray() {
|
||||
var choicesList [][]byte
|
||||
candidates.ForEach(func(_, candidate gjson.Result) bool {
|
||||
// Construct a single Choice object.
|
||||
choiceTemplate := []byte(`{"index":0,"message":{"role":"assistant","content":null,"reasoning_content":null,"tool_calls":null},"finish_reason":null,"native_finish_reason":null}`)
|
||||
|
||||
// Set the index for this choice.
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "index", candidate.Get("index").Int())
|
||||
|
||||
// Set finish reason.
|
||||
if finishReasonResult := candidate.Get("finishReason"); finishReasonResult.Exists() {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", strings.ToLower(finishReasonResult.String()))
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", strings.ToLower(finishReasonResult.String()))
|
||||
}
|
||||
|
||||
partsResult := candidate.Get("content.parts")
|
||||
hasFunctionCall := false
|
||||
if partsResult.IsArray() {
|
||||
partsResults := partsResult.Array()
|
||||
var toolCalls [][]byte
|
||||
var images [][]byte
|
||||
var textContent strings.Builder
|
||||
var reasoningContent strings.Builder
|
||||
hasTextContent := false
|
||||
hasReasoningContent := false
|
||||
|
||||
for i := 0; i < len(partsResults); i++ {
|
||||
partResult := partsResults[i]
|
||||
partTextResult := partResult.Get("text")
|
||||
functionCallResult := partResult.Get("functionCall")
|
||||
inlineDataResult := partResult.Get("inlineData")
|
||||
if !inlineDataResult.Exists() {
|
||||
inlineDataResult = partResult.Get("inline_data")
|
||||
}
|
||||
|
||||
if partTextResult.Exists() {
|
||||
// Append text content, distinguishing between regular content and reasoning.
|
||||
if partResult.Get("thought").Bool() {
|
||||
hasReasoningContent = true
|
||||
reasoningContent.WriteString(partTextResult.String())
|
||||
} else {
|
||||
hasTextContent = true
|
||||
textContent.WriteString(partTextResult.String())
|
||||
}
|
||||
} else if functionCallResult.Exists() {
|
||||
// Append function call content to the tool_calls array.
|
||||
hasFunctionCall = true
|
||||
functionCallItemTemplate := []byte(`{"id":"","type":"function","function":{"name":"","arguments":""}}`)
|
||||
fcName := util.RestoreSanitizedToolName(sanitizedNameMap, functionCallResult.Get("name").String())
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "id", fmt.Sprintf("%s-%d-%d", fcName, time.Now().UnixNano(), atomic.AddUint64(&functionCallIDCounter, 1)))
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.name", fcName)
|
||||
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
|
||||
functionCallItemTemplate, _ = sjson.SetBytes(functionCallItemTemplate, "function.arguments", fcArgsResult.Raw)
|
||||
}
|
||||
toolCalls = append(toolCalls, functionCallItemTemplate)
|
||||
} else if inlineDataResult.Exists() {
|
||||
data := inlineDataResult.Get("data").String()
|
||||
if data != "" {
|
||||
mimeType := inlineDataResult.Get("mimeType").String()
|
||||
if mimeType == "" {
|
||||
mimeType = inlineDataResult.Get("mime_type").String()
|
||||
}
|
||||
if mimeType == "" {
|
||||
mimeType = "image/png"
|
||||
}
|
||||
imageURL := fmt.Sprintf("data:%s;base64,%s", mimeType, data)
|
||||
imagePayload := []byte(`{"type":"image_url","image_url":{"url":""}}`)
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "index", len(images))
|
||||
imagePayload, _ = sjson.SetBytes(imagePayload, "image_url.url", imageURL)
|
||||
images = append(images, imagePayload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if hasTextContent {
|
||||
if !hasReasoningContent && len(partsResults) == 1 && len(toolCalls) == 0 && len(images) == 0 {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.content", partsResults[0].Get("text").String())
|
||||
} else {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.content", textContent.String())
|
||||
}
|
||||
}
|
||||
if hasReasoningContent {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "message.reasoning_content", reasoningContent.String())
|
||||
}
|
||||
if len(toolCalls) > 0 {
|
||||
choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.tool_calls", translatorcommon.JoinRawArray(toolCalls))
|
||||
}
|
||||
if len(images) > 0 {
|
||||
choiceTemplate, _ = sjson.SetRawBytes(choiceTemplate, "message.images", translatorcommon.JoinRawArray(images))
|
||||
}
|
||||
}
|
||||
|
||||
if hasFunctionCall {
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "finish_reason", "tool_calls")
|
||||
choiceTemplate, _ = sjson.SetBytes(choiceTemplate, "native_finish_reason", "tool_calls")
|
||||
}
|
||||
|
||||
// Append the constructed choice to the main choices array.
|
||||
choicesList = append(choicesList, choiceTemplate)
|
||||
return true
|
||||
})
|
||||
if len(choicesList) > 0 {
|
||||
template = translatorcommon.SetRawArrayItems(template, "choices", choicesList)
|
||||
}
|
||||
}
|
||||
|
||||
return template
|
||||
}
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertGeminiResponseToOpenAIIncludesZeroCompletionTokensWhenMissing(t *testing.T) {
|
||||
var param any
|
||||
chunk := []byte(`{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}`)
|
||||
|
||||
result := ConvertGeminiResponseToOpenAI(context.Background(), "model", nil, nil, chunk, ¶m)
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("expected 1 result, got %d", len(result))
|
||||
}
|
||||
completionTokens := gjson.GetBytes(result[0], "usage.completion_tokens")
|
||||
if !completionTokens.Exists() || completionTokens.Int() != 0 {
|
||||
t.Fatalf("completion_tokens = %s, want present with value 0. Output: %s", completionTokens.Raw, result[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToOpenAINonStreamIncludesZeroCompletionTokensWhenMissing(t *testing.T) {
|
||||
response := []byte(`{"usageMetadata":{"promptTokenCount":16,"thoughtsTokenCount":42,"totalTokenCount":58}}`)
|
||||
|
||||
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
|
||||
completionTokens := gjson.GetBytes(result, "usage.completion_tokens")
|
||||
if !completionTokens.Exists() || completionTokens.Int() != 0 {
|
||||
t.Fatalf("completion_tokens = %s, want present with value 0. Output: %s", completionTokens.Raw, result)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiFinishReasonOnlyOnFinalChunk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
var param any
|
||||
|
||||
chunk1 := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"C:/"}}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"}}`)
|
||||
result1 := ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk1, ¶m)
|
||||
if len(result1) != 1 {
|
||||
t.Fatalf("expected 1 result from chunk1, got %d", len(result1))
|
||||
}
|
||||
fr1 := gjson.GetBytes(result1[0], "choices.0.finish_reason")
|
||||
if fr1.Exists() && fr1.String() != "" && fr1.Type.String() != "Null" {
|
||||
t.Fatalf("expected null finish_reason on tool chunk, got %v", fr1.String())
|
||||
}
|
||||
|
||||
chunk2 := []byte(`{"candidates":[{"content":{"parts":[{"functionCall":{"name":"list_dir","args":{"path":"D:/"}}}]}}],"usageMetadata":{"trafficType":"ON_DEMAND"}}`)
|
||||
ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk2, ¶m)
|
||||
|
||||
chunk3 := []byte(`{"candidates":[{"content":{"parts":[{"text":""}]},"finishReason":"STOP"}],"usageMetadata":{"promptTokenCount":10,"candidatesTokenCount":5,"totalTokenCount":15}}`)
|
||||
result3 := ConvertGeminiResponseToOpenAI(ctx, "model", nil, nil, chunk3, ¶m)
|
||||
if len(result3) != 1 {
|
||||
t.Fatalf("expected 1 result from chunk3, got %d", len(result3))
|
||||
}
|
||||
fr3 := gjson.GetBytes(result3[0], "choices.0.finish_reason").String()
|
||||
if fr3 != "tool_calls" {
|
||||
t.Fatalf("expected finish_reason tool_calls, got %s", fr3)
|
||||
}
|
||||
nfr3 := gjson.GetBytes(result3[0], "choices.0.native_finish_reason").String()
|
||||
if nfr3 != "stop" {
|
||||
t.Fatalf("expected native_finish_reason stop, got %s", nfr3)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToOpenAINonStream_EmptyTextProducesEmptyString(t *testing.T) {
|
||||
response := []byte(`{"candidates":[{"content":{"parts":[{"text":""},{"text":"","thought":true}]},"finishReason":"STOP"}]}`)
|
||||
result := ConvertGeminiResponseToOpenAINonStream(context.Background(), "model", nil, nil, response, nil)
|
||||
|
||||
content := gjson.GetBytes(result, "choices.0.message.content")
|
||||
if !content.Exists() || content.String() != "" || content.Type == gjson.Null {
|
||||
t.Fatalf("expected content to be empty string \"\", got %v (type %v)", content.Value(), content.Type)
|
||||
}
|
||||
|
||||
reasoning := gjson.GetBytes(result, "choices.0.message.reasoning_content")
|
||||
if !reasoning.Exists() || reasoning.String() != "" || reasoning.Type == gjson.Null {
|
||||
t.Fatalf("expected reasoning_content to be empty string \"\", got %v (type %v)", reasoning.Value(), reasoning.Type)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
const capturedGeminiToolCallThoughtSignature = "EjQKMgEMOdbHO0Gd+c9Mxk4ELwPGbpCEcp2mFfYYLix2UVtBH3fL8GECc4+JITVnHF4qZDsA"
|
||||
|
||||
func TestConvertOpenAIRequestToGemini_ToolCallSignatureCompatibility(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
rawSignature string
|
||||
wantSignature string
|
||||
}{
|
||||
{
|
||||
name: "Gemini signature is preserved",
|
||||
rawSignature: "gemini#" + capturedGeminiToolCallThoughtSignature,
|
||||
wantSignature: capturedGeminiToolCallThoughtSignature,
|
||||
},
|
||||
{
|
||||
name: "unknown signature uses bypass",
|
||||
rawSignature: "not-a-provider-signature",
|
||||
wantSignature: signature.GeminiSkipThoughtSignatureValidator,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
input := []byte(`{
|
||||
"model": "gemini-3.5-flash",
|
||||
"messages": [{
|
||||
"role": "assistant",
|
||||
"tool_calls": [{
|
||||
"id": "call_123",
|
||||
"type": "function",
|
||||
"function": {"name": "lookup", "arguments": "{\"q\":\"Paris\"}"},
|
||||
"extra_content": {"google": {"thought_signature": "` + tt.rawSignature + `"}}
|
||||
}]
|
||||
}]
|
||||
}`)
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-3.5-flash", input, false)
|
||||
if got := gjson.GetBytes(output, "contents.0.parts.0.thoughtSignature").String(); got != tt.wantSignature {
|
||||
t.Fatalf("thoughtSignature = %q, want %q. Output: %s", got, tt.wantSignature, output)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenAI,
|
||||
Gemini,
|
||||
ConvertOpenAIRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertGeminiResponseToOpenAI,
|
||||
NonStream: ConvertGeminiResponseToOpenAINonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
package chat_completions
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIRequestToGeminiNormalizesToolNameAndStrict(t *testing.T) {
|
||||
input := []byte(`{"messages":[],"tools":[{"type":"function","function":{"name":true,"strict":true,"parameters":{"type":"object"}}}]}`)
|
||||
|
||||
output := ConvertOpenAIRequestToGemini("gemini-test", input, false)
|
||||
|
||||
name := gjson.GetBytes(output, "tools.0.functionDeclarations.0.name")
|
||||
if name.Type != gjson.String || name.String() != "true" {
|
||||
t.Fatalf("tool name = %s, want string true", name.Raw)
|
||||
}
|
||||
if gjson.GetBytes(output, "tools.0.functionDeclarations.0.strict").Exists() {
|
||||
t.Fatal("strict should be removed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToOpenAINonStreamKeepsAssistantRole(t *testing.T) {
|
||||
input := []byte(`{"candidates":[{"index":0,"content":{"parts":[{"text":"hello"}]},"finishReason":"STOP"}]}`)
|
||||
|
||||
output := ConvertGeminiResponseToOpenAINonStream(context.Background(), "", nil, nil, input, nil)
|
||||
|
||||
if role := gjson.GetBytes(output, "choices.0.message.role").String(); role != "assistant" {
|
||||
t.Fatalf("role = %q, want assistant", role)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertGeminiResponseToOpenAIStreamingSetsAssistantRoleOnce(t *testing.T) {
|
||||
input := []byte(`{"candidates":[{"index":0,"content":{"parts":[{"text":"hello"},{"functionCall":{"name":"lookup","args":{}}},{"inlineData":{"mimeType":"image/png","data":"aGVsbG8="}}]}}]}`)
|
||||
var param any
|
||||
|
||||
outputs := ConvertGeminiResponseToOpenAI(context.Background(), "", nil, nil, input, ¶m)
|
||||
|
||||
if len(outputs) != 1 {
|
||||
t.Fatalf("output count = %d, want 1", len(outputs))
|
||||
}
|
||||
if role := gjson.GetBytes(outputs[0], "choices.0.delta.role").String(); role != "assistant" {
|
||||
t.Fatalf("role = %q, want assistant", role)
|
||||
}
|
||||
if got := gjson.GetBytes(outputs[0], "choices.0.delta.content").String(); got != "hello" {
|
||||
t.Fatalf("content = %q, want hello", got)
|
||||
}
|
||||
if !gjson.GetBytes(outputs[0], "choices.0.delta.tool_calls.0").Exists() {
|
||||
t.Fatal("tool call should be present")
|
||||
}
|
||||
if !gjson.GetBytes(outputs[0], "choices.0.delta.images.0").Exists() {
|
||||
t.Fatal("image should be present")
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
19
backend/internal/translator/gemini/openai/responses/init.go
Normal file
19
backend/internal/translator/gemini/openai/responses/init.go
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
. "github.com/router-for-me/CLIProxyAPI/v7/internal/constant"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/interfaces"
|
||||
"github.com/router-for-me/CLIProxyAPI/v7/internal/translator/translator"
|
||||
)
|
||||
|
||||
func init() {
|
||||
translator.Register(
|
||||
OpenaiResponse,
|
||||
Gemini,
|
||||
ConvertOpenAIResponsesRequestToGemini,
|
||||
interfaces.TranslateResponse{
|
||||
Stream: ConvertGeminiResponseToOpenAIResponses,
|
||||
NonStream: ConvertGeminiResponseToOpenAIResponsesNonStream,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
)
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGeminiBuildsGenerationConfigWithoutIntermediateObject(t *testing.T) {
|
||||
input := []byte(`{"input":"hello","temperature":0.5,"top_p":0.9,"stop_sequences":["done"],"text":{"format":{"type":"json_schema","schema":{"type":"object"}}}}`)
|
||||
|
||||
output := ConvertOpenAIResponsesRequestToGemini("gemini-test", input, false)
|
||||
|
||||
if got := gjson.GetBytes(output, "generationConfig.temperature").Float(); got != 0.5 {
|
||||
t.Fatalf("temperature = %v, want 0.5", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "generationConfig.topP").Float(); got != 0.9 {
|
||||
t.Fatalf("topP = %v, want 0.9", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "generationConfig.stopSequences.0").String(); got != "done" {
|
||||
t.Fatalf("stop sequence = %q, want done", got)
|
||||
}
|
||||
if got := gjson.GetBytes(output, "generationConfig.responseMimeType").String(); got != "application/json" {
|
||||
t.Fatalf("responseMimeType = %q, want application/json", got)
|
||||
}
|
||||
if !gjson.GetBytes(output, "generationConfig.responseJsonSchema").Exists() {
|
||||
t.Fatal("responseJsonSchema should be present")
|
||||
}
|
||||
if gjson.GetBytes(output, "generationConfig.responseSchema").Exists() {
|
||||
t.Fatal("responseSchema should not be present")
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,199 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
|
||||
sigcompat "github.com/router-for-me/CLIProxyAPI/v7/internal/signature"
|
||||
"github.com/tidwall/gjson"
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const (
|
||||
geminiResponsesCarrierPrefix = "cpa-gemini-responses-carrier-v1:"
|
||||
geminiResponsesCarrierNext = "next"
|
||||
geminiResponsesCarrierPrevious = "previous"
|
||||
geminiResponsesCarrierStandalone = "standalone"
|
||||
geminiResponsesCarrierText = "text"
|
||||
geminiResponsesCarrierFunction = "function"
|
||||
geminiResponsesCarrierAny = "any"
|
||||
|
||||
geminiResponsesCarrierDirectionField = "_cpa_reasoning_direction"
|
||||
geminiResponsesCarrierTargetField = "_cpa_reasoning_target"
|
||||
geminiResponsesCarrierSignatureField = "_cpa_reasoning_signature"
|
||||
geminiResponsesCarrierSummaryField = "_cpa_reasoning_summary"
|
||||
)
|
||||
|
||||
func encodeGeminiResponsesCarrier(rawSignature, direction, targetKind string) string {
|
||||
rawSignature = strings.TrimSpace(rawSignature)
|
||||
if rawSignature == "" {
|
||||
return ""
|
||||
}
|
||||
return geminiResponsesCarrierPrefix + direction + ":" + targetKind + ":" + base64.RawStdEncoding.EncodeToString([]byte(rawSignature))
|
||||
}
|
||||
|
||||
func decodeGeminiResponsesCarrier(rawSignature string) (signatureValue, direction, targetKind string, marked, ok bool) {
|
||||
rawSignature = strings.TrimSpace(rawSignature)
|
||||
if !strings.HasPrefix(rawSignature, geminiResponsesCarrierPrefix) {
|
||||
return rawSignature, "", "", false, true
|
||||
}
|
||||
marked = true
|
||||
if len(rawSignature) > (sigcompat.MaxGeminiThoughtSignatureLen*4/3)+1024 {
|
||||
return "", "", "", true, false
|
||||
}
|
||||
fields := strings.SplitN(strings.TrimPrefix(rawSignature, geminiResponsesCarrierPrefix), ":", 3)
|
||||
if len(fields) != 3 {
|
||||
return "", "", "", true, false
|
||||
}
|
||||
direction, targetKind = fields[0], fields[1]
|
||||
switch direction {
|
||||
case geminiResponsesCarrierNext, geminiResponsesCarrierPrevious, geminiResponsesCarrierStandalone:
|
||||
default:
|
||||
return "", "", "", true, false
|
||||
}
|
||||
switch targetKind {
|
||||
case geminiResponsesCarrierText, geminiResponsesCarrierFunction, geminiResponsesCarrierAny:
|
||||
default:
|
||||
return "", "", "", true, false
|
||||
}
|
||||
decoded, errDecode := base64.RawStdEncoding.DecodeString(fields[2])
|
||||
if errDecode != nil || len(decoded) == 0 || strings.HasPrefix(string(decoded), geminiResponsesCarrierPrefix) {
|
||||
return "", "", "", true, false
|
||||
}
|
||||
return string(decoded), direction, targetKind, true, true
|
||||
}
|
||||
|
||||
func compatibleGeminiResponsesCarrierSignature(rawSignature, targetKind string) (string, bool) {
|
||||
blockKind := sigcompat.SignatureBlockKindGeminiModelPart
|
||||
if targetKind == geminiResponsesCarrierFunction {
|
||||
blockKind = sigcompat.SignatureBlockKindGeminiFunctionCall
|
||||
}
|
||||
normalized, compatible := sigcompat.CompatibleSignatureForProviderBlock(sigcompat.SignatureProviderGemini, rawSignature, blockKind)
|
||||
if !compatible || sigcompat.IsGeminiThoughtSignatureBypass(sigcompat.SignaturePayloadWithoutProviderPrefix(normalized)) {
|
||||
return "", false
|
||||
}
|
||||
return normalized, true
|
||||
}
|
||||
|
||||
func geminiResponsesCarrierSemanticTarget(item gjson.Result) string {
|
||||
switch item.Get("type").String() {
|
||||
case "function_call", "custom_tool_call":
|
||||
return geminiResponsesCarrierFunction
|
||||
case "reasoning":
|
||||
if strings.TrimSpace(item.Get("summary.0.text").String()) != "" {
|
||||
return geminiResponsesCarrierText
|
||||
}
|
||||
}
|
||||
if _, ok := openAIResponsesAssistantVisibleText(item); ok {
|
||||
return geminiResponsesCarrierText
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func geminiResponsesCarrierMatchesAdjacent(items []gjson.Result, index int, direction, targetKind string) bool {
|
||||
step := 1
|
||||
if direction == geminiResponsesCarrierPrevious {
|
||||
step = -1
|
||||
}
|
||||
for adjacent := index + step; adjacent >= 0 && adjacent < len(items); adjacent += step {
|
||||
if kind := geminiResponsesCarrierSemanticTarget(items[adjacent]); kind != "" {
|
||||
return targetKind == geminiResponsesCarrierAny || targetKind == kind
|
||||
}
|
||||
if !isOpenAIResponsesDetachedCarrier(items[adjacent]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func hasInternalCarrierFields(item gjson.Result) bool {
|
||||
return item.Get(geminiResponsesCarrierDirectionField).Exists() ||
|
||||
item.Get(geminiResponsesCarrierTargetField).Exists() ||
|
||||
item.Get(geminiResponsesCarrierSignatureField).Exists() ||
|
||||
item.Get(geminiResponsesCarrierSummaryField).Exists()
|
||||
}
|
||||
|
||||
func stripGeminiResponsesCarrierMetadata(rawJSON string) ([]byte, bool) {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(rawJSON), &fields); err != nil {
|
||||
return []byte(rawJSON), false
|
||||
}
|
||||
delete(fields, geminiResponsesCarrierDirectionField)
|
||||
delete(fields, geminiResponsesCarrierTargetField)
|
||||
delete(fields, geminiResponsesCarrierSignatureField)
|
||||
delete(fields, geminiResponsesCarrierSummaryField)
|
||||
stripped, errMarshal := json.Marshal(fields)
|
||||
if errMarshal != nil {
|
||||
return []byte(rawJSON), false
|
||||
}
|
||||
return stripped, true
|
||||
}
|
||||
|
||||
func normalizeGeminiResponsesCarriers(items []gjson.Result) ([]gjson.Result, bool) {
|
||||
normalized := make([]gjson.Result, 0, len(items))
|
||||
hasValidCarrier := false
|
||||
for itemIndex, originalItem := range items {
|
||||
item := originalItem
|
||||
var itemJSON []byte
|
||||
if hasInternalCarrierFields(originalItem) {
|
||||
stripped, ok := stripGeminiResponsesCarrierMetadata(originalItem.Raw)
|
||||
if ok {
|
||||
itemJSON = stripped
|
||||
item = gjson.ParseBytes(itemJSON)
|
||||
}
|
||||
}
|
||||
if item.Get("type").String() != "reasoning" {
|
||||
normalized = append(normalized, item)
|
||||
continue
|
||||
}
|
||||
if len(itemJSON) == 0 {
|
||||
itemJSON = []byte(item.Raw)
|
||||
}
|
||||
rawSignature := strings.TrimSpace(item.Get("encrypted_content").String())
|
||||
signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(rawSignature)
|
||||
if !marked {
|
||||
if rawSignature != "" {
|
||||
_, hasCompatibleRawCarrier := compatibleGeminiResponsesCarrierSignature(rawSignature, geminiResponsesCarrierAny)
|
||||
hasValidCarrier = hasValidCarrier || hasCompatibleRawCarrier
|
||||
}
|
||||
normalized = append(normalized, item)
|
||||
continue
|
||||
}
|
||||
if ok {
|
||||
signature, ok = compatibleGeminiResponsesCarrierSignature(signature, targetKind)
|
||||
}
|
||||
if ok && direction != geminiResponsesCarrierStandalone {
|
||||
ok = geminiResponsesCarrierMatchesAdjacent(items, itemIndex, direction, targetKind)
|
||||
}
|
||||
isDetached := isOpenAIResponsesDetachedCarrier(item)
|
||||
hasSummary := strings.TrimSpace(item.Get("summary.0.text").String()) != ""
|
||||
validSummaryCarrier := hasSummary && ((direction == geminiResponsesCarrierStandalone && (targetKind == geminiResponsesCarrierText || targetKind == geminiResponsesCarrierAny)) || direction == geminiResponsesCarrierNext)
|
||||
if !ok || (!isDetached && !validSummaryCarrier) {
|
||||
if strings.TrimSpace(item.Get("summary.0.text").String()) == "" {
|
||||
continue
|
||||
}
|
||||
itemJSON, _ = sjson.DeleteBytes(itemJSON, "encrypted_content")
|
||||
normalized = append(normalized, gjson.ParseBytes(itemJSON))
|
||||
continue
|
||||
}
|
||||
hasValidCarrier = true
|
||||
itemJSON, _ = sjson.SetBytes(itemJSON, "encrypted_content", signature)
|
||||
itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierDirectionField, direction)
|
||||
itemJSON, _ = sjson.SetBytes(itemJSON, geminiResponsesCarrierTargetField, targetKind)
|
||||
normalized = append(normalized, gjson.ParseBytes(itemJSON))
|
||||
}
|
||||
return normalized, hasValidCarrier
|
||||
}
|
||||
|
||||
func geminiResponsesCarrierDirection(item gjson.Result) string {
|
||||
return item.Get(geminiResponsesCarrierDirectionField).String()
|
||||
}
|
||||
|
||||
func geminiResponsesCarrierTarget(item gjson.Result) string {
|
||||
return item.Get(geminiResponsesCarrierTargetField).String()
|
||||
}
|
||||
|
||||
func isOpenAIResponsesDetachedCarrier(item gjson.Result) bool {
|
||||
return item.Get("type").String() == "reasoning" && strings.TrimSpace(item.Get("encrypted_content").String()) != "" && strings.TrimSpace(item.Get("summary.0.text").String()) == ""
|
||||
}
|
||||
|
|
@ -0,0 +1,167 @@
|
|||
package responses
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/tidwall/gjson"
|
||||
"google.golang.org/protobuf/encoding/protowire"
|
||||
)
|
||||
|
||||
func TestGeminiResponsesCarrierRoundTrip(t *testing.T) {
|
||||
for _, testCase := range []struct {
|
||||
direction string
|
||||
targetKind string
|
||||
}{
|
||||
{geminiResponsesCarrierNext, geminiResponsesCarrierText},
|
||||
{geminiResponsesCarrierPrevious, geminiResponsesCarrierFunction},
|
||||
{geminiResponsesCarrierStandalone, geminiResponsesCarrierAny},
|
||||
} {
|
||||
encoded := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, testCase.direction, testCase.targetKind)
|
||||
signature, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(encoded)
|
||||
if !marked || !ok || signature != testResponsesGeminiThoughtSignature || direction != testCase.direction || targetKind != testCase.targetKind {
|
||||
t.Fatalf("carrier round-trip = %q/%q/%q marked=%v ok=%v", signature, direction, targetKind, marked, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeGeminiResponsesCarriersDropsMalformedEnvelope(t *testing.T) {
|
||||
items := gjson.Parse(`[{"type":"reasoning","encrypted_content":"` + geminiResponsesCarrierPrefix + `previous:text:not-base64!","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"safe"}]}]`).Array()
|
||||
normalized, hasCarrier := normalizeGeminiResponsesCarriers(items)
|
||||
if hasCarrier || len(normalized) != 1 || normalized[0].Get("type").String() != "message" || strings.Contains(normalized[0].Raw, geminiResponsesCarrierPrefix) {
|
||||
t.Fatalf("malformed carrier was preserved: %v", normalized)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_DecodesCarrierForAliasModel(t *testing.T) {
|
||||
carrier := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText)
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + carrier + `","summary":[]},{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
part := gjson.GetBytes(translated, "contents.0.parts.0")
|
||||
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierPrefix) {
|
||||
t.Fatalf("alias model did not decode carrier: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGeminiResponsesWrappedUUIDFunctionSignatureRoundTrip(t *testing.T) {
|
||||
const providerUUID = "e24830a7-5cd6-42fe-998b-ee539e72b9c3"
|
||||
inner := protowire.AppendTag(nil, 1, protowire.BytesType)
|
||||
inner = protowire.AppendBytes(inner, []byte(providerUUID))
|
||||
outer := protowire.AppendTag(nil, 2, protowire.BytesType)
|
||||
outer = protowire.AppendBytes(outer, inner)
|
||||
signature := base64.StdEncoding.EncodeToString(outer)
|
||||
|
||||
providerResponse := `data: {"response":{"candidates":[{"content":{"role":"model","parts":[{"thoughtSignature":"` + signature + `","functionCall":{"id":"native-call","name":"run","args":{"command":"true"}}}]},"finishReason":"STOP"}],"modelVersion":"gemini-3.6-flash","responseId":"wrapped-uuid"}}`
|
||||
var state any
|
||||
chunks := ConvertGeminiResponseToOpenAIResponses(context.Background(), "gemini-3.6-flash", []byte(`{"model":"alias-without-provider-name"}`), nil, []byte(providerResponse), &state)
|
||||
clientItems := make([]string, 0, 2)
|
||||
callID := ""
|
||||
for _, chunk := range chunks {
|
||||
event, data := parseSSEEvent(t, chunk)
|
||||
if event != "response.output_item.done" {
|
||||
continue
|
||||
}
|
||||
item := data.Get("item")
|
||||
switch item.Get("type").String() {
|
||||
case "reasoning":
|
||||
decoded, direction, targetKind, marked, ok := decodeGeminiResponsesCarrier(item.Get("encrypted_content").String())
|
||||
if !marked || !ok || decoded != signature || direction != geminiResponsesCarrierNext || targetKind != geminiResponsesCarrierFunction {
|
||||
t.Fatalf("provider signature carrier = marked:%v ok:%v direction:%q target:%q", marked, ok, direction, targetKind)
|
||||
}
|
||||
clientItems = append(clientItems, item.Raw)
|
||||
case "function_call":
|
||||
callID = item.Get("call_id").String()
|
||||
clientItems = append(clientItems, item.Raw)
|
||||
}
|
||||
}
|
||||
if len(clientItems) != 2 || callID == "" {
|
||||
t.Fatalf("Responses client items = %v, call ID present=%v", clientItems, callID != "")
|
||||
}
|
||||
clientItems = append(clientItems, `{"type":"function_call_output","call_id":`+strconv.Quote(callID)+`,"output":"ok"}`)
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[` + strings.Join(clientItems, ",") + `]}`)
|
||||
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
var functionPart gjson.Result
|
||||
gjson.GetBytes(translated, "contents").ForEach(func(_, content gjson.Result) bool {
|
||||
content.Get("parts").ForEach(func(_, part gjson.Result) bool {
|
||||
if part.Get("functionCall").Exists() {
|
||||
functionPart = part
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return !functionPart.Exists()
|
||||
})
|
||||
if !functionPart.Exists() || functionPart.Get("functionCall.name").String() != "run" || functionPart.Get("functionCall.args.command").String() != "true" {
|
||||
t.Fatalf("function carrier did not bind to the native call: %s", translated)
|
||||
}
|
||||
if got := functionPart.Get("thoughtSignature").String(); got != signature || got == geminiResponsesThoughtSignature {
|
||||
t.Fatalf("function signature = %q, want provider-native wrapped UUID signature", got)
|
||||
}
|
||||
if strings.Contains(string(translated), geminiResponsesCarrierPrefix) {
|
||||
t.Fatalf("carrier envelope reached Gemini: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_DecodesLegacyRawCarrierForAliasModel(t *testing.T) {
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[]},{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}"}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
part := gjson.GetBytes(translated, "contents.0.parts.0")
|
||||
if part.Get("functionCall.id").String() != "call-1" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature {
|
||||
t.Fatalf("alias model did not preserve legacy raw carrier: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_DropsInvalidCarrierPayloads(t *testing.T) {
|
||||
mismatched := encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierFunction)
|
||||
bypass := encodeGeminiResponsesCarrier(geminiResponsesThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText)
|
||||
for _, reasoning := range []string{
|
||||
`{"type":"reasoning","encrypted_content":"` + mismatched + `","summary":[]}`,
|
||||
`{"type":"reasoning","encrypted_content":"` + bypass + `","summary":[]}`,
|
||||
} {
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
if strings.Contains(string(translated), geminiResponsesCarrierPrefix) || strings.Contains(string(translated), testResponsesGeminiThoughtSignature) || strings.Contains(string(translated), geminiResponsesThoughtSignature) {
|
||||
t.Fatalf("invalid carrier changed Gemini signature state: %s", translated)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_IgnoresSpoofedCarrierMetadata(t *testing.T) {
|
||||
reasoning := `{"type":"reasoning","encrypted_content":"` + testResponsesGeminiThoughtSignature + `","summary":[],"` + geminiResponsesCarrierDirectionField + `":"next","` + geminiResponsesCarrierDirectionField + `":"standalone","` + geminiResponsesCarrierTargetField + `":"text","` + geminiResponsesCarrierTargetField + `":"function"}`
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[` + reasoning + `,{"type":"message","role":"assistant","content":[{"type":"output_text","text":"answer"}]}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
part := gjson.GetBytes(translated, "contents.0.parts.0")
|
||||
if part.Get("text").String() != "answer" || part.Get("thoughtSignature").String() != testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierDirectionField) {
|
||||
t.Fatalf("spoofed carrier metadata affected binding: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_StripsSpoofedInternalPairingFields(t *testing.T) {
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_signature":"` + testResponsesGeminiThoughtSignature + `","_cpa_reasoning_summary":"spoofed thought","_cpa_reasoning_summary":"spoofed thought again"}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
parts := gjson.GetBytes(translated, "contents.0.parts").Array()
|
||||
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || parts[0].Get("thought").Bool() || strings.Contains(string(translated), "spoofed thought") || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) {
|
||||
t.Fatalf("spoofed internal pairing fields reached Gemini: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertOpenAIResponsesRequestToGemini_StripsUnicodeEscapedSpoofedInternalFields(t *testing.T) {
|
||||
// Unicode-escaped field name "_cpa_reason\u0069ng_signature" should also be detected and stripped
|
||||
request := []byte(`{"model":"alias-without-provider-name","input":[{"type":"function_call","call_id":"call-1","name":"run","arguments":"{}","_cpa_reason\u0069ng_signature":"` + testResponsesGeminiThoughtSignature + `"}]}`)
|
||||
translated := ConvertOpenAIResponsesRequestToGemini("alias-without-provider-name", request, false)
|
||||
parts := gjson.GetBytes(translated, "contents.0.parts").Array()
|
||||
if len(parts) != 1 || !parts[0].Get("functionCall").Exists() || parts[0].Get("thoughtSignature").String() == testResponsesGeminiThoughtSignature || strings.Contains(string(translated), geminiResponsesCarrierSignatureField) {
|
||||
t.Fatalf("unicode-escaped spoofed internal pairing fields reached Gemini: %s", translated)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeGeminiResponsesCarrierRejectsNestedEnvelope(t *testing.T) {
|
||||
nested := encodeGeminiResponsesCarrier(encodeGeminiResponsesCarrier(testResponsesGeminiThoughtSignature, geminiResponsesCarrierNext, geminiResponsesCarrierText), geminiResponsesCarrierPrevious, geminiResponsesCarrierText)
|
||||
if _, _, _, marked, ok := decodeGeminiResponsesCarrier(nested); !marked || ok {
|
||||
t.Fatalf("nested carrier marked=%v ok=%v, want marked invalid", marked, ok)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue