Compare commits

...

7 Commits

Author SHA1 Message Date
Luis Pater
cef2aeeb08 Merge pull request #448 from router-for-me/plus
v6.8.54
2026-03-16 00:37:06 +08:00
Luis Pater
bcd1e8cc34 Merge branch 'main' into plus 2026-03-16 00:36:19 +08:00
Luis Pater
198b3f4a40 chore(ci): update build metadata to use GITHUB_REF_NAME in workflows 2026-03-16 00:30:44 +08:00
Luis Pater
9fee7f488e chore(ci): update GoReleaser config and release workflow to skip validation step 2026-03-16 00:16:25 +08:00
Luis Pater
1b46d39b8b Merge branch 'router-for-me:main' into main 2026-03-15 23:57:47 +08:00
Luis Pater
b5701f416b Fixed: #2102
fix(auth): ensure unique auth index for shared API keys across providers and credential identities
2026-03-15 02:48:54 +08:00
Luis Pater
4b1a404fcb Fixed: #1936
feat(translator): add image type handling in ConvertClaudeRequestToGemini
2026-03-15 02:18:28 +08:00
8 changed files with 234 additions and 20 deletions

View File

@@ -29,7 +29,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate Build Metadata
run: |
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
- name: Build and push (amd64)
@@ -64,7 +64,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate Build Metadata
run: |
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
- name: Build and push (arm64)
@@ -98,7 +98,7 @@ jobs:
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Generate Build Metadata
run: |
echo VERSION=`git describe --tags --always --dirty` >> $GITHUB_ENV
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
- name: Create and push multi-arch manifests

View File

@@ -27,15 +27,14 @@ jobs:
cache: true
- name: Generate Build Metadata
run: |
VERSION=$(git describe --tags --always --dirty)
echo "VERSION=${VERSION}" >> $GITHUB_ENV
echo "VERSION=${GITHUB_REF_NAME}" >> $GITHUB_ENV
echo COMMIT=`git rev-parse --short HEAD` >> $GITHUB_ENV
echo BUILD_DATE=`date -u +%Y-%m-%dT%H:%M:%SZ` >> $GITHUB_ENV
- uses: goreleaser/goreleaser-action@v4
with:
distribution: goreleaser
version: latest
args: release --clean
args: release --clean --skip=validate
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
VERSION: ${{ env.VERSION }}

View File

@@ -1,3 +1,5 @@
version: 2
builds:
- id: "cli-proxy-api-plus"
env:

View File

@@ -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)
}
}

View File

@@ -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
})

View File

@@ -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)
}
}

View File

@@ -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)

View File

@@ -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)
}
}