From 4b1a404fcb2cc91e98300cd8243e9d311b509b19 Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 15 Mar 2026 02:18:28 +0800 Subject: [PATCH 1/2] Fixed: #1936 feat(translator): add image type handling in ConvertClaudeRequestToGemini --- .../gemini/claude/gemini_claude_request.go | 15 ++++++++ .../claude/gemini_claude_request_test.go | 38 +++++++++++++++++++ 2 files changed, 53 insertions(+) diff --git a/internal/translator/gemini/claude/gemini_claude_request.go b/internal/translator/gemini/claude/gemini_claude_request.go index b13955bb..137008b0 100644 --- a/internal/translator/gemini/claude/gemini_claude_request.go +++ b/internal/translator/gemini/claude/gemini_claude_request.go @@ -114,6 +114,21 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool) part, _ = sjson.Set(part, "functionResponse.name", funcName) part, _ = sjson.Set(part, "functionResponse.response.result", responseData) contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) + + 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 := `{"inline_data":{"mime_type":"","data":""}}` + part, _ = sjson.Set(part, "inline_data.mime_type", mimeType) + part, _ = sjson.Set(part, "inline_data.data", data) + contentJSON, _ = sjson.SetRaw(contentJSON, "parts.-1", part) } return true }) diff --git a/internal/translator/gemini/claude/gemini_claude_request_test.go b/internal/translator/gemini/claude/gemini_claude_request_test.go index e242c42c..10ad2d3a 100644 --- a/internal/translator/gemini/claude/gemini_claude_request_test.go +++ b/internal/translator/gemini/claude/gemini_claude_request_test.go @@ -40,3 +40,41 @@ func TestConvertClaudeRequestToGemini_ToolChoice_SpecificTool(t *testing.T) { t.Fatalf("Expected allowedFunctionNames ['json'], got %s", gjson.GetBytes(output, "toolConfig.functionCallingConfig.allowedFunctionNames").Raw) } } + +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) + } +} From b5701f416b64d9c2af6ad3d36c3ed68d8bfa746d Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sun, 15 Mar 2026 02:48:54 +0800 Subject: [PATCH 2/2] Fixed: #2102 fix(auth): ensure unique auth index for shared API keys across providers and credential identities --- .../api/handlers/management/api_tools_test.go | 55 +++++++++++++++ sdk/cliproxy/auth/types.go | 70 +++++++++++++++---- sdk/cliproxy/auth/types_test.go | 63 +++++++++++++++++ 3 files changed, 174 insertions(+), 14 deletions(-) diff --git a/internal/api/handlers/management/api_tools_test.go b/internal/api/handlers/management/api_tools_test.go index 5b0c6369..6ed98c6e 100644 --- a/internal/api/handlers/management/api_tools_test.go +++ b/internal/api/handlers/management/api_tools_test.go @@ -1,6 +1,7 @@ package management import ( + "context" "net/http" "testing" @@ -56,3 +57,57 @@ func TestAPICallTransportInvalidAuthFallsBackToGlobalProxy(t *testing.T) { t.Fatalf("proxy URL = %v, want http://global-proxy.example.com:8080", proxyURL) } } + +func TestAuthByIndexDistinguishesSharedAPIKeysAcrossProviders(t *testing.T) { + t.Parallel() + + manager := coreauth.NewManager(nil, nil, nil) + geminiAuth := &coreauth.Auth{ + ID: "gemini:apikey:123", + Provider: "gemini", + Attributes: map[string]string{ + "api_key": "shared-key", + }, + } + compatAuth := &coreauth.Auth{ + ID: "openai-compatibility:bohe:456", + Provider: "bohe", + Label: "bohe", + Attributes: map[string]string{ + "api_key": "shared-key", + "compat_name": "bohe", + "provider_key": "bohe", + }, + } + + if _, errRegister := manager.Register(context.Background(), geminiAuth); errRegister != nil { + t.Fatalf("register gemini auth: %v", errRegister) + } + if _, errRegister := manager.Register(context.Background(), compatAuth); errRegister != nil { + t.Fatalf("register compat auth: %v", errRegister) + } + + geminiIndex := geminiAuth.EnsureIndex() + compatIndex := compatAuth.EnsureIndex() + if geminiIndex == compatIndex { + t.Fatalf("shared api key produced duplicate auth_index %q", geminiIndex) + } + + h := &Handler{authManager: manager} + + gotGemini := h.authByIndex(geminiIndex) + if gotGemini == nil { + t.Fatal("expected gemini auth by index") + } + if gotGemini.ID != geminiAuth.ID { + t.Fatalf("authByIndex(gemini) returned %q, want %q", gotGemini.ID, geminiAuth.ID) + } + + gotCompat := h.authByIndex(compatIndex) + if gotCompat == nil { + t.Fatal("expected compat auth by index") + } + if gotCompat.ID != compatAuth.ID { + t.Fatalf("authByIndex(compat) returned %q, want %q", gotCompat.ID, compatAuth.ID) + } +} diff --git a/sdk/cliproxy/auth/types.go b/sdk/cliproxy/auth/types.go index 0bfaf11a..8390b051 100644 --- a/sdk/cliproxy/auth/types.go +++ b/sdk/cliproxy/auth/types.go @@ -162,7 +162,60 @@ func stableAuthIndex(seed string) string { return hex.EncodeToString(sum[:8]) } -// EnsureIndex returns a stable index derived from the auth file name or API key. +func (a *Auth) indexSeed() string { + if a == nil { + return "" + } + + if fileName := strings.TrimSpace(a.FileName); fileName != "" { + return "file:" + fileName + } + + providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) + compatName := "" + baseURL := "" + apiKey := "" + source := "" + if a.Attributes != nil { + if value := strings.TrimSpace(a.Attributes["provider_key"]); value != "" { + providerKey = strings.ToLower(value) + } + compatName = strings.ToLower(strings.TrimSpace(a.Attributes["compat_name"])) + baseURL = strings.TrimSpace(a.Attributes["base_url"]) + apiKey = strings.TrimSpace(a.Attributes["api_key"]) + source = strings.TrimSpace(a.Attributes["source"]) + } + + proxyURL := strings.TrimSpace(a.ProxyURL) + hasCredentialIdentity := compatName != "" || baseURL != "" || proxyURL != "" || apiKey != "" || source != "" + if providerKey != "" && hasCredentialIdentity { + parts := []string{"provider=" + providerKey} + if compatName != "" { + parts = append(parts, "compat="+compatName) + } + if baseURL != "" { + parts = append(parts, "base="+baseURL) + } + if proxyURL != "" { + parts = append(parts, "proxy="+proxyURL) + } + if apiKey != "" { + parts = append(parts, "api_key="+apiKey) + } + if source != "" { + parts = append(parts, "source="+source) + } + return "config:" + strings.Join(parts, "\x00") + } + + if id := strings.TrimSpace(a.ID); id != "" { + return "id:" + id + } + + return "" +} + +// EnsureIndex returns a stable index derived from the auth file name or credential identity. func (a *Auth) EnsureIndex() string { if a == nil { return "" @@ -171,20 +224,9 @@ func (a *Auth) EnsureIndex() string { return a.Index } - seed := strings.TrimSpace(a.FileName) - if seed != "" { - seed = "file:" + seed - } else if a.Attributes != nil { - if apiKey := strings.TrimSpace(a.Attributes["api_key"]); apiKey != "" { - seed = "api_key:" + apiKey - } - } + seed := a.indexSeed() if seed == "" { - if id := strings.TrimSpace(a.ID); id != "" { - seed = "id:" + id - } else { - return "" - } + return "" } idx := stableAuthIndex(seed) diff --git a/sdk/cliproxy/auth/types_test.go b/sdk/cliproxy/auth/types_test.go index 8249b063..e7029385 100644 --- a/sdk/cliproxy/auth/types_test.go +++ b/sdk/cliproxy/auth/types_test.go @@ -33,3 +33,66 @@ func TestToolPrefixDisabled(t *testing.T) { t.Error("should return false when set to false") } } + +func TestEnsureIndexUsesCredentialIdentity(t *testing.T) { + t.Parallel() + + geminiAuth := &Auth{ + Provider: "gemini", + Attributes: map[string]string{ + "api_key": "shared-key", + "source": "config:gemini[abc123]", + }, + } + compatAuth := &Auth{ + Provider: "bohe", + Attributes: map[string]string{ + "api_key": "shared-key", + "compat_name": "bohe", + "provider_key": "bohe", + "source": "config:bohe[def456]", + }, + } + geminiAltBase := &Auth{ + Provider: "gemini", + Attributes: map[string]string{ + "api_key": "shared-key", + "base_url": "https://alt.example.com", + "source": "config:gemini[ghi789]", + }, + } + geminiDuplicate := &Auth{ + Provider: "gemini", + Attributes: map[string]string{ + "api_key": "shared-key", + "source": "config:gemini[abc123-1]", + }, + } + + geminiIndex := geminiAuth.EnsureIndex() + compatIndex := compatAuth.EnsureIndex() + altBaseIndex := geminiAltBase.EnsureIndex() + duplicateIndex := geminiDuplicate.EnsureIndex() + + if geminiIndex == "" { + t.Fatal("gemini index should not be empty") + } + if compatIndex == "" { + t.Fatal("compat index should not be empty") + } + if altBaseIndex == "" { + t.Fatal("alt base index should not be empty") + } + if duplicateIndex == "" { + t.Fatal("duplicate index should not be empty") + } + if geminiIndex == compatIndex { + t.Fatalf("shared api key produced duplicate auth_index %q", geminiIndex) + } + if geminiIndex == altBaseIndex { + t.Fatalf("same provider/key with different base_url produced duplicate auth_index %q", geminiIndex) + } + if geminiIndex == duplicateIndex { + t.Fatalf("duplicate config entries should be separated by source-derived seed, got %q", geminiIndex) + } +}