Compare commits

..

23 Commits

Author SHA1 Message Date
Luis Pater
9c6832cc22 Update LICENSE to reflect extended copyright ownership 2025-10-11 08:46:04 +08:00
Luis Pater
1ada33ab1d Merge pull request #104 from router-for-me/cloud
Add Cloud Deploy Mode
2025-10-10 20:23:11 +08:00
hkfires
78738ca3f0 fix(config): treat directory as absent for optional config in cloud deploy mode 2025-10-10 19:40:02 +08:00
hkfires
ac01c74c02 feat(server): Add cloud deploy mode 2025-10-10 18:52:43 +08:00
Luis Pater
02e28bbbe9 feat(watcher): add support for proxy_url in auth metadata
- Extracted and assigned `proxy_url` from metadata to `Auth.ProxyURL`.
2025-10-10 10:20:33 +08:00
Luis Pater
b9c7b9eea5 docs: add Homebrew installation instructions to README and README_CN
- Updated both English and Chinese documentation with steps to install and start `cliproxyapi` via Homebrew.
2025-10-10 04:38:01 +08:00
Luis Pater
57195fa0f5 feat(managementasset): enforce 3-hour rate limit on management asset update checks
- Introduced synchronization with `sync.Mutex` to ensure thread safety.
- Added logic to skip update checks if the last check was performed within the 3-hour interval.
2025-10-10 04:23:58 +08:00
Luis Pater
11f090c223 Fixed #102
feat(translator): add support for removing `strict` in Gemini request transformation

- Updated API and CLI translators to remove the `strict` path during request transformation, in addition to existing predefined JSON paths.
2025-10-10 02:59:21 +08:00
Luis Pater
829dd06b42 feat(cliproxy/auth): restructure auth candidate selection and ensure synchronization
- Refactored candidate selection logic in `auth/manager.go`.
- Ensured proper synchronization around `mu.RUnlock` to prevent racing conditions.
2025-10-10 02:35:15 +08:00
Luis Pater
20787cd107 feat(registry, executor, util): add support for gemini-2.5-flash-image-preview and improve aspect ratio handling
- Introduced `gemini-2.5-flash-image-preview` model to the registry with updated definitions.
- Enhanced Gemini CLI and API executors to handle image aspect ratio adjustments for the new model.
- Added utility function to create base64 white image placeholders based on aspect ratio configurations.
2025-10-10 01:49:58 +08:00
Luis Pater
1aa568ce45 docs: document api-keys usage in README and README_CN
- Added explanation and examples for `api-keys` configuration.
- Updated both English and Chinese documentation.
2025-10-09 23:36:11 +08:00
Luis Pater
b2cdbbdd47 feat(registry, executor): add support for glm-4.6 model and enhance Gemini CLI token handling
- Added `glm-4.6` model to registry and documentation.
- Updated Gemini CLI executor to pass configuration to `prepareGeminiCLITokenSource` for improved token management.
2025-10-09 20:57:18 +08:00
Luis Pater
8056af42a3 Merge pull request #99 from router-for-me/banana
feat(translator): Add support for openrouter image_config
2025-10-09 20:16:09 +08:00
hkfires
01be94a0de feat(translator): Map OpenAI modalities to Gemini responseModalities 2025-10-09 19:38:07 +08:00
hkfires
d1933075c3 Revert "feat(translator): Pass through imageConfig" 2025-10-09 16:35:08 +08:00
hkfires
a602ae859b feat(translator): Add support for openrouter image_config 2025-10-09 15:47:06 +08:00
hkfires
c5d7137d66 feat(translator): Pass through imageConfig 2025-10-09 13:50:43 +08:00
Luis Pater
d45ebff66b feat(registry, executor): add support for gemini-2.5-flash-image model
- Introduced `gemini-2.5-flash-image` model with updated definitions in registry.
- Enhanced model marker detection in Gemini CLI executor to include support for the new model.
2025-10-09 10:06:10 +08:00
Luis Pater
d6f671250e Fixed: #97
feat(translator): enhance request and response parsing for Gemini API and CLI

- Added support for removing predefined JSON paths (`additionalProperties`, `$schema`, `ref`) during request transformation for Gemini.
- Introduced `FunctionIndex` parameter to manage function call indexing in streaming responses for both API and CLI translators.
- Improved handling of tool call content and function call templates in response parsing logic.
2025-10-08 23:49:21 +08:00
Luis Pater
6d822cf309 fix(access): rebuild providers for specific AccessProviderTypeConfigAPIKey changes
- Added logic to force rebuild when provider type matches `AccessProviderTypeConfigAPIKey`.
2025-10-08 19:43:42 +08:00
Luis Pater
d03a75dba5 feat(middleware): add path exclusion for request logging in management routes
- Excluded `/v0/management` and `/keep-alive` paths from request logging middleware for optimized performance.
2025-10-08 03:08:01 +08:00
Luis Pater
9ff21b67a8 ci(homebrew): remove workflow for Homebrew formula bump 2025-10-07 23:17:08 +08:00
Luis Pater
5546c9d872 ci(homebrew): trigger workflow on tag push instead of release event 2025-10-07 23:06:47 +08:00
21 changed files with 438 additions and 41 deletions

View File

@@ -1,16 +0,0 @@
name: Bump Homebrew formula
on:
release:
types: [ released ]
jobs:
homebrew:
name: Bump Homebrew formula
runs-on: ubuntu-latest
steps:
- name: Update Homebrew Formula
uses: dawidd6/action-homebrew-bump-formula@v5
with:
token: ${{ secrets.HOMEBREW_TOKEN }}
formula: cliproxyapi

View File

@@ -1,6 +1,7 @@
MIT License
Copyright (c) 2025 Luis Pater
Copyright (c) 2025-2005.9 Luis Pater
Copyright (c) 2025.9-present Router-For.ME
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -62,6 +62,12 @@ Chinese providers have now been added: [Qwen Code](https://github.com/QwenLM/qwe
go build -o cli-proxy-api.exe ./cmd/server
```
### Installation via Homebrew
```bash
brew install cliproxyapi
brew services start cliproxyapi
```
## Usage
@@ -258,6 +264,7 @@ console.log(await claudeResponse.json());
- gemini-2.5-pro
- gemini-2.5-flash
- gemini-2.5-flash-lite
- gemini-2.5-flash-image
- gemini-2.5-flash-image-preview
- gpt-5
- gpt-5-codex
@@ -277,6 +284,7 @@ console.log(await claudeResponse.json());
- deepseek-v3
- kimi-k2
- glm-4.5
- glm-4.6
- tstars2.0
- And other iFlow-supported models
- Gemini models auto-switch to preview variants when needed
@@ -355,6 +363,11 @@ remote-management:
# Authentication directory (supports ~ for home directory). If you use Windows, please set the directory like this: `C:/cli-proxy-api/`
auth-dir: "~/.cli-proxy-api"
# API keys for authentication
api-keys:
- "your-api-key-1"
- "your-api-key-2"
# Enable debug logging
debug: false

View File

@@ -76,6 +76,13 @@
go build -o cli-proxy-api ./cmd/server
```
### 通过 Homebrew 安装
```bash
brew install cliproxyapi
brew services start cliproxyapi
```
## 使用方法
### 图形客户端与官方 WebUI
@@ -270,6 +277,7 @@ console.log(await claudeResponse.json());
- gemini-2.5-pro
- gemini-2.5-flash
- gemini-2.5-flash-lite
- gemini-2.5-flash-image
- gemini-2.5-flash-image-preview
- gpt-5
- gpt-5-codex
@@ -289,6 +297,7 @@ console.log(await claudeResponse.json());
- deepseek-v3
- kimi-k2
- glm-4.5
- glm-4.6
- tstars2.0
- 以及其他 iFlow 支持的模型
- Gemini 模型在需要时自动切换到对应的 preview 版本
@@ -366,6 +375,11 @@ remote-management:
# 身份验证目录(支持 ~ 表示主目录。如果你使用Windows建议设置成`C:/cli-proxy-api/`。
auth-dir: "~/.cli-proxy-api"
# 请求认证使用的API密钥
api-keys:
- "your-api-key-1"
- "your-api-key-2"
# 启用调试日志
debug: false

View File

@@ -96,6 +96,14 @@ func main() {
var err error
var cfg *config.Config
var wd string
var isCloudDeploy bool
// Check for cloud deploy mode only on first execution
// Read env var name in uppercase: DEPLOY
deployEnv := os.Getenv("DEPLOY")
if deployEnv == "cloud" {
isCloudDeploy = true
}
// Determine and load the configuration file.
// If a config path is provided via flags, it is used directly.
@@ -103,18 +111,34 @@ func main() {
var configFilePath string
if configPath != "" {
configFilePath = configPath
cfg, err = config.LoadConfig(configPath)
cfg, err = config.LoadConfigOptional(configPath, isCloudDeploy)
} else {
wd, err = os.Getwd()
if err != nil {
log.Fatalf("failed to get working directory: %v", err)
}
configFilePath = filepath.Join(wd, "config.yaml")
cfg, err = config.LoadConfig(configFilePath)
cfg, err = config.LoadConfigOptional(configFilePath, isCloudDeploy)
}
if err != nil {
log.Fatalf("failed to load config: %v", err)
}
// Log if we're running without a config file in cloud deploy mode
var configFileExists bool
if isCloudDeploy {
if info, errStat := os.Stat(configFilePath); errStat != nil {
// Don't mislead: API server will not start until configuration is provided.
log.Info("Cloud deploy mode: No configuration file detected; standing by for configuration (API server not started)")
configFileExists = false
} else if info.IsDir() {
log.Info("Cloud deploy mode: Config path is a directory; standing by for configuration (API server not started)")
configFileExists = false
} else {
log.Info("Cloud deploy mode: Configuration file detected; starting service")
configFileExists = true
}
}
usage.SetStatisticsEnabled(cfg.UsageStatisticsEnabled)
if err = logging.ConfigureLogOutput(cfg.LoggingToFile); err != nil {
@@ -161,6 +185,12 @@ func main() {
} else if geminiWebAuth {
cmd.DoGeminiWebAuth(cfg)
} else {
// In cloud deploy mode without config file, just wait for shutdown signals
if isCloudDeploy && !configFileExists {
// No config file available, just wait for shutdown
cmd.WaitForCloudDeploy()
return
}
// Start the main proxy service
cmd.StartService(cfg, configFilePath, password)
}

View File

@@ -10,6 +10,8 @@ services:
COMMIT: ${COMMIT:-none}
BUILD_DATE: ${BUILD_DATE:-unknown}
container_name: cli-proxy-api
environment:
DEPLOY: ${DEPLOY:-}
ports:
- "8317:8317"
- "8085:8085"

View File

@@ -51,9 +51,10 @@ func ReconcileProviders(oldCfg, newCfg *config.Config, existing []sdkaccess.Prov
continue
}
forceRebuild := strings.EqualFold(strings.TrimSpace(providerCfg.Type), sdkConfig.AccessProviderTypeConfigAPIKey)
if oldCfgProvider, ok := oldCfgMap[key]; ok {
isAliased := oldCfgProvider == providerCfg
if !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
if !forceRebuild && !isAliased && providerConfigEqual(oldCfgProvider, providerCfg) {
if existingProvider, okExisting := existingMap[key]; okExisting {
result = append(result, existingProvider)
finalIDs[key] = struct{}{}

View File

@@ -6,6 +6,7 @@ package middleware
import (
"bytes"
"io"
"strings"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
@@ -17,6 +18,12 @@ import (
// logger, the middleware has minimal overhead.
func RequestLoggingMiddleware(logger logging.RequestLogger) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
if strings.HasPrefix(path, "/v0/management") || path == "/keep-alive" {
c.Next()
return
}
// Early return if logging is disabled (zero overhead)
if !logger.IsEnabled() {
c.Next()

View File

@@ -53,3 +53,17 @@ func StartService(cfg *config.Config, configPath string, localPassword string) {
log.Fatalf("proxy service exited with error: %v", err)
}
}
// WaitForCloudDeploy waits indefinitely for shutdown signals in cloud deploy mode
// when no configuration file is available.
func WaitForCloudDeploy() {
// Clarify that we are intentionally idle for configuration and not running the API server.
log.Info("Cloud deploy mode: No config found; standing by for configuration. API server is not started. Press Ctrl+C to exit.")
ctxSignal, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer cancel()
// Block until shutdown signal is received
<-ctxSignal.Done()
log.Info("Cloud deploy mode: Shutdown signal received; exiting")
}

View File

@@ -5,8 +5,10 @@
package config
import (
"errors"
"fmt"
"os"
"syscall"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"golang.org/x/crypto/bcrypt"
@@ -187,9 +189,21 @@ type OpenAICompatibilityModel struct {
// - *Config: The loaded configuration
// - error: An error if the configuration could not be loaded
func LoadConfig(configFile string) (*Config, error) {
return LoadConfigOptional(configFile, false)
}
// LoadConfigOptional reads YAML from configFile.
// If optional is true and the file is missing, it returns an empty Config.
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
// Read the entire configuration file into memory.
data, err := os.ReadFile(configFile)
if err != nil {
if optional {
if os.IsNotExist(err) || errors.Is(err, syscall.EISDIR) {
// Missing and optional: return empty config (cloud deploy standby).
return &Config{}, nil
}
}
return nil, fmt.Errorf("failed to read config file: %w", err)
}

View File

@@ -12,6 +12,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
@@ -23,11 +24,17 @@ const (
managementReleaseURL = "https://api.github.com/repos/router-for-me/Cli-Proxy-API-Management-Center/releases/latest"
managementAssetName = "management.html"
httpUserAgent = "CLIProxyAPI-management-updater"
updateCheckInterval = 3 * time.Hour
)
// ManagementFileName exposes the control panel asset filename.
const ManagementFileName = managementAssetName
var (
lastUpdateCheckMu sync.Mutex
lastUpdateCheckTime time.Time
)
func newHTTPClient(proxyURL string) *http.Client {
client := &http.Client{Timeout: 15 * time.Second}
@@ -68,6 +75,7 @@ func FilePath(configFilePath string) string {
// EnsureLatestManagementHTML checks the latest management.html asset and updates the local copy when needed.
// The function is designed to run in a background goroutine and will never panic.
// It enforces a 3-hour rate limit to avoid frequent checks on config/auth file changes.
func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL string) {
if ctx == nil {
ctx = context.Background()
@@ -79,6 +87,18 @@ func EnsureLatestManagementHTML(ctx context.Context, staticDir string, proxyURL
return
}
// Rate limiting: check only once every 3 hours
lastUpdateCheckMu.Lock()
now := time.Now()
timeSinceLastCheck := now.Sub(lastUpdateCheckTime)
if timeSinceLastCheck < updateCheckInterval {
lastUpdateCheckMu.Unlock()
log.Debugf("management asset update check skipped: last check was %v ago (interval: %v)", timeSinceLastCheck.Round(time.Second), updateCheckInterval)
return
}
lastUpdateCheckTime = now
lastUpdateCheckMu.Unlock()
if err := os.MkdirAll(staticDir, 0o755); err != nil {
log.WithError(err).Warn("failed to prepare static directory for management asset")
return

View File

@@ -104,6 +104,34 @@ func GetGeminiModels() []*ModelInfo {
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
},
{
ID: "gemini-2.5-flash-image-preview",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "google",
Type: "gemini",
Name: "models/gemini-2.5-flash-image-preview",
Version: "2.5",
DisplayName: "Gemini 2.5 Flash Image Preview",
Description: "State-of-the-art image generation and editing model.",
InputTokenLimit: 1048576,
OutputTokenLimit: 8192,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
},
{
ID: "gemini-2.5-flash-image",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "google",
Type: "gemini",
Name: "models/gemini-2.5-flash-image",
Version: "2.5",
DisplayName: "Gemini 2.5 Flash Image",
Description: "State-of-the-art image generation and editing model.",
InputTokenLimit: 1048576,
OutputTokenLimit: 8192,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
},
}
}
@@ -166,6 +194,20 @@ func GetGeminiCLIModels() []*ModelInfo {
OutputTokenLimit: 8192,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
},
{
ID: "gemini-2.5-flash-image",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "google",
Type: "gemini",
Name: "models/gemini-2.5-flash-image",
Version: "2.5",
DisplayName: "Gemini 2.5 Flash Image",
Description: "State-of-the-art image generation and editing model.",
InputTokenLimit: 1048576,
OutputTokenLimit: 8192,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
},
}
}
@@ -354,6 +396,7 @@ func GetIFlowModels() []*ModelInfo {
{ID: "qwen3-max-preview", DisplayName: "Qwen3-Max-Preview", Description: "Qwen3 Max preview build"},
{ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905"},
{ID: "glm-4.5", DisplayName: "GLM-4.5", Description: "Zhipu GLM 4.5 general model"},
{ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model"},
{ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model"},
{ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental"},
{ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus"},

View File

@@ -14,6 +14,7 @@ import (
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/misc"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
@@ -51,7 +52,7 @@ func (e *GeminiCLIExecutor) Identifier() string { return "gemini-cli" }
func (e *GeminiCLIExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, auth)
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
if err != nil {
return cliproxyexecutor.Response{}, err
}
@@ -60,6 +61,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
from := opts.SourceFormat
to := sdktranslator.FromString("gemini-cli")
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
action := "generateContent"
if req.Metadata != nil {
@@ -139,7 +141,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
}
func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (<-chan cliproxyexecutor.StreamChunk, error) {
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, auth)
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
if err != nil {
return nil, err
}
@@ -148,6 +150,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
from := opts.SourceFormat
to := sdktranslator.FromString("gemini-cli")
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
projectID := strings.TrimSpace(stringValue(auth.Metadata, "project_id"))
@@ -270,7 +273,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
}
func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, auth)
tokenSource, baseTokenData, err := prepareGeminiCLITokenSource(ctx, e.cfg, auth)
if err != nil {
return cliproxyexecutor.Response{}, err
}
@@ -294,6 +297,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
payload = deleteJSONField(payload, "project")
payload = deleteJSONField(payload, "model")
payload = disableGeminiThinkingConfig(payload, attemptModel)
payload = fixGeminiCLIImageAspectRatio(attemptModel, payload)
tok, errTok := tokenSource.Token()
if errTok != nil {
@@ -351,7 +355,7 @@ func (e *GeminiCLIExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth
return auth, nil
}
func prepareGeminiCLITokenSource(ctx context.Context, auth *cliproxyauth.Auth) (oauth2.TokenSource, map[string]any, error) {
func prepareGeminiCLITokenSource(ctx context.Context, cfg *config.Config, auth *cliproxyauth.Auth) (oauth2.TokenSource, map[string]any, error) {
if auth == nil || auth.Metadata == nil {
return nil, nil, fmt.Errorf("gemini-cli auth metadata missing")
}
@@ -395,8 +399,8 @@ func prepareGeminiCLITokenSource(ctx context.Context, auth *cliproxyauth.Auth) (
}
ctxToken := ctx
if rt, ok := ctx.Value("cliproxy.roundtripper").(http.RoundTripper); ok && rt != nil {
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, &http.Client{Transport: rt})
if httpClient := newProxyAwareHTTPClient(ctx, cfg, auth, 0); httpClient != nil {
ctxToken = context.WithValue(ctxToken, oauth2.HTTPClient, httpClient)
}
src := conf.TokenSource(ctxToken, &token)
@@ -518,7 +522,7 @@ func geminiModelDisallowsThinking(model string) bool {
return false
}
lower := strings.ToLower(model)
for _, marker := range []string{"gemini-2.5-flash-image-preview"} {
for _, marker := range []string{"gemini-2.5-flash-image-preview", "gemini-2.5-flash-image"} {
if strings.Contains(lower, marker) {
return true
}
@@ -549,3 +553,45 @@ func deleteJSONField(body []byte, key string) []byte {
}
return updated
}
func fixGeminiCLIImageAspectRatio(modelName string, rawJSON []byte) []byte {
if modelName == "gemini-2.5-flash-image-preview" {
aspectRatioResult := gjson.GetBytes(rawJSON, "request.generationConfig.imageConfig.aspectRatio")
if aspectRatioResult.Exists() {
contents := gjson.GetBytes(rawJSON, "request.contents")
contentArray := contents.Array()
if len(contentArray) > 0 {
hasInlineData := false
loopContent:
for i := 0; i < len(contentArray); i++ {
parts := contentArray[i].Get("parts").Array()
for j := 0; j < len(parts); j++ {
if parts[j].Get("inlineData").Exists() {
hasInlineData = true
break loopContent
}
}
}
if !hasInlineData {
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
emptyImagePart := `{"inlineData":{"mime_type":"image/png","data":""}}`
emptyImagePart, _ = sjson.Set(emptyImagePart, "inlineData.data", emptyImageBase64ed)
newPartsJson := `[]`
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", `{"text": "Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear."}`)
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", emptyImagePart)
parts := contentArray[0].Get("parts").Array()
for j := 0; j < len(parts); j++ {
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", parts[j].Raw)
}
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.contents.0.parts", []byte(newPartsJson))
rawJSON, _ = sjson.SetRawBytes(rawJSON, "request.generationConfig.responseModalities", []byte(`["Image", "Text"]`))
}
}
rawJSON, _ = sjson.DeleteBytes(rawJSON, "request.generationConfig.imageConfig")
}
}
return rawJSON
}

View File

@@ -78,6 +78,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
to := sdktranslator.FromString("gemini")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
body = disableGeminiThinkingConfig(body, req.Model)
body = fixGeminiImageAspectRatio(req.Model, body)
action := "generateContent"
if req.Metadata != nil {
@@ -136,6 +137,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
to := sdktranslator.FromString("gemini")
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
body = disableGeminiThinkingConfig(body, req.Model)
body = fixGeminiImageAspectRatio(req.Model, body)
url := fmt.Sprintf("%s/%s/models/%s:%s", glEndpoint, glAPIVersion, req.Model, "streamGenerateContent")
if opts.Alt == "" {
@@ -207,6 +209,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
to := sdktranslator.FromString("gemini")
translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
translatedReq = disableGeminiThinkingConfig(translatedReq, req.Model)
translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq)
respCtx := context.WithValue(ctx, "alt", opts.Alt)
translatedReq, _ = sjson.DeleteBytes(translatedReq, "tools")
translatedReq, _ = sjson.DeleteBytes(translatedReq, "generationConfig")
@@ -374,3 +377,45 @@ func geminiCreds(a *cliproxyauth.Auth) (apiKey, bearer string) {
}
return
}
func fixGeminiImageAspectRatio(modelName string, rawJSON []byte) []byte {
if modelName == "gemini-2.5-flash-image-preview" {
aspectRatioResult := gjson.GetBytes(rawJSON, "generationConfig.imageConfig.aspectRatio")
if aspectRatioResult.Exists() {
contents := gjson.GetBytes(rawJSON, "contents")
contentArray := contents.Array()
if len(contentArray) > 0 {
hasInlineData := false
loopContent:
for i := 0; i < len(contentArray); i++ {
parts := contentArray[i].Get("parts").Array()
for j := 0; j < len(parts); j++ {
if parts[j].Get("inlineData").Exists() {
hasInlineData = true
break loopContent
}
}
}
if !hasInlineData {
emptyImageBase64ed, _ := util.CreateWhiteImageBase64(aspectRatioResult.String())
emptyImagePart := `{"inlineData":{"mime_type":"image/png","data":""}}`
emptyImagePart, _ = sjson.Set(emptyImagePart, "inlineData.data", emptyImageBase64ed)
newPartsJson := `[]`
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", `{"text": "Based on the following requirements, create an image within the uploaded picture. The new content *MUST* completely cover the entire area of the original picture, maintaining its exact proportions, and *NO* blank areas should appear."}`)
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", emptyImagePart)
parts := contentArray[0].Get("parts").Array()
for j := 0; j < len(parts); j++ {
newPartsJson, _ = sjson.SetRaw(newPartsJson, "-1", parts[j].Raw)
}
rawJSON, _ = sjson.SetRawBytes(rawJSON, "contents.0.parts", []byte(newPartsJson))
rawJSON, _ = sjson.SetRawBytes(rawJSON, "generationConfig.responseModalities", []byte(`["Image", "Text"]`))
}
}
rawJSON, _ = sjson.DeleteBytes(rawJSON, "generationConfig.imageConfig")
}
}
return rawJSON
}

View File

@@ -26,6 +26,21 @@ import (
// - []byte: The transformed request data in Gemini CLI API format
func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
var pathsToDelete []string
root := gjson.ParseBytes(rawJSON)
util.Walk(root, "", "additionalProperties", &pathsToDelete)
util.Walk(root, "", "$schema", &pathsToDelete)
util.Walk(root, "", "ref", &pathsToDelete)
util.Walk(root, "", "strict", &pathsToDelete)
var err error
for _, p := range pathsToDelete {
rawJSON, err = sjson.DeleteBytes(rawJSON, p)
if err != nil {
continue
}
}
// Base envelope
out := []byte(`{"project":"","request":{"contents":[],"generationConfig":{"thinkingConfig":{"include_thoughts":true}}},"model":"gemini-2.5-pro"}`)
@@ -65,6 +80,31 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
out, _ = sjson.SetBytes(out, "request.generationConfig.topK", tkr.Num)
}
// Map OpenAI modalities -> Gemini CLI request.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, "request.generationConfig.responseModalities", responseMods)
}
}
// OpenRouter-style image_config support
// If the input uses top-level image_config.aspect_ratio, map it into request.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, "request.generationConfig.imageConfig.aspectRatio", ar.Str)
}
}
// messages -> systemInstruction + contents
messages := gjson.GetBytes(rawJSON, "messages")
if messages.IsArray() {
@@ -232,7 +272,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
}
var pathsToType []string
root := gjson.ParseBytes(out)
root = gjson.ParseBytes(out)
util.Walk(root, "", "type", &pathsToType)
for _, p := range pathsToType {
typeResult := gjson.GetBytes(out, p)

View File

@@ -20,6 +20,7 @@ import (
// convertCliResponseToOpenAIChatParams holds parameters for response conversion.
type convertCliResponseToOpenAIChatParams struct {
UnixTimestamp int64
FunctionIndex int
}
// ConvertCliResponseToOpenAI translates a single chunk of a streaming response from the
@@ -40,6 +41,7 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
if *param == nil {
*param = &convertCliResponseToOpenAIChatParams{
UnixTimestamp: 0,
FunctionIndex: 0,
}
}
@@ -117,13 +119,18 @@ func ConvertCliResponseToOpenAI(_ context.Context, _ string, originalRequestRawJ
} else if functionCallResult.Exists() {
// Handle function call content.
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
functionCallIndex := (*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex
(*param).(*convertCliResponseToOpenAIChatParams).FunctionIndex++
if toolCallsResult.Exists() && toolCallsResult.IsArray() {
functionCallIndex = len(toolCallsResult.Array())
} else {
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
}
functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`
fcName := functionCallResult.Get("name").String()
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex)
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", fcArgsResult.Raw)

View File

@@ -26,6 +26,21 @@ import (
// - []byte: The transformed request data in Gemini API format
func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
var pathsToDelete []string
root := gjson.ParseBytes(rawJSON)
util.Walk(root, "", "additionalProperties", &pathsToDelete)
util.Walk(root, "", "$schema", &pathsToDelete)
util.Walk(root, "", "ref", &pathsToDelete)
util.Walk(root, "", "strict", &pathsToDelete)
var err error
for _, p := range pathsToDelete {
rawJSON, err = sjson.DeleteBytes(rawJSON, p)
if err != nil {
continue
}
}
// Base envelope
out := []byte(`{"contents":[],"generationConfig":{"thinkingConfig":{"include_thoughts":true}}}`)
@@ -65,6 +80,31 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
out, _ = sjson.SetBytes(out, "generationConfig.topK", tkr.Num)
}
// 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)
}
}
// messages -> systemInstruction + contents
messages := gjson.GetBytes(rawJSON, "messages")
if messages.IsArray() {
@@ -257,7 +297,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
}
var pathsToType []string
root := gjson.ParseBytes(out)
root = gjson.ParseBytes(out)
util.Walk(root, "", "type", &pathsToType)
for _, p := range pathsToType {
typeResult := gjson.GetBytes(out, p)

View File

@@ -19,6 +19,7 @@ import (
// convertGeminiResponseToOpenAIChatParams holds parameters for response conversion.
type convertGeminiResponseToOpenAIChatParams struct {
UnixTimestamp int64
FunctionIndex int
}
// ConvertGeminiResponseToOpenAI translates a single chunk of a streaming response from the
@@ -39,6 +40,7 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
if *param == nil {
*param = &convertGeminiResponseToOpenAIChatParams{
UnixTimestamp: 0,
FunctionIndex: 0,
}
}
@@ -120,13 +122,18 @@ func ConvertGeminiResponseToOpenAI(_ context.Context, _ string, originalRequestR
} else if functionCallResult.Exists() {
// Handle function call content.
toolCallsResult := gjson.Get(template, "choices.0.delta.tool_calls")
if !toolCallsResult.Exists() || !toolCallsResult.IsArray() {
functionCallIndex := (*param).(*convertGeminiResponseToOpenAIChatParams).FunctionIndex
(*param).(*convertGeminiResponseToOpenAIChatParams).FunctionIndex++
if toolCallsResult.Exists() && toolCallsResult.IsArray() {
functionCallIndex = len(toolCallsResult.Array())
} else {
template, _ = sjson.SetRaw(template, "choices.0.delta.tool_calls", `[]`)
}
functionCallTemplate := `{"id": "","type": "function","function": {"name": "","arguments": ""}}`
functionCallTemplate := `{"id": "","index": 0,"type": "function","function": {"name": "","arguments": ""}}`
fcName := functionCallResult.Get("name").String()
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "id", fmt.Sprintf("%s-%d", fcName, time.Now().UnixNano()))
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "index", functionCallIndex)
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.name", fcName)
if fcArgsResult := functionCallResult.Get("args"); fcArgsResult.Exists() {
functionCallTemplate, _ = sjson.Set(functionCallTemplate, "function.arguments", fcArgsResult.Raw)

59
internal/util/image.go Normal file
View File

@@ -0,0 +1,59 @@
package util
import (
"bytes"
"encoding/base64"
"image"
"image/draw"
"image/png"
)
func CreateWhiteImageBase64(aspectRatio string) (string, error) {
width := 1024
height := 1024
switch aspectRatio {
case "1:1":
width = 1024
height = 1024
case "2:3":
width = 832
height = 1248
case "3:2":
width = 1248
height = 832
case "3:4":
width = 864
height = 1184
case "4:3":
width = 1184
height = 864
case "4:5":
width = 896
height = 1152
case "5:4":
width = 1152
height = 896
case "9:16":
width = 768
height = 1344
case "16:9":
width = 1344
height = 768
case "21:9":
width = 1536
height = 672
}
img := image.NewRGBA(image.Rect(0, 0, width, height))
draw.Draw(img, img.Bounds(), image.White, image.Point{}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, img); err != nil {
return "", err
}
base64String := base64.StdEncoding.EncodeToString(buf.Bytes())
return base64String, nil
}

View File

@@ -951,6 +951,11 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
id = rel
}
proxyURL := ""
if p, ok := metadata["proxy_url"].(string); ok {
proxyURL = p
}
a := &coreauth.Auth{
ID: id,
Provider: provider,
@@ -960,6 +965,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
"source": full,
"path": full,
},
ProxyURL: proxyURL,
Metadata: metadata,
CreatedAt: now,
UpdatedAt: now,

View File

@@ -787,27 +787,31 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
return nil, nil, &Error{Code: "executor_not_found", Message: "executor not registered"}
}
candidates := make([]*Auth, 0, len(m.auths))
for _, auth := range m.auths {
if auth.Provider != provider || auth.Disabled {
for _, candidate := range m.auths {
if candidate.Provider != provider || candidate.Disabled {
continue
}
if _, used := tried[auth.ID]; used {
if _, used := tried[candidate.ID]; used {
continue
}
candidates = append(candidates, auth.Clone())
candidates = append(candidates, candidate)
}
m.mu.RUnlock()
if len(candidates) == 0 {
m.mu.RUnlock()
return nil, nil, &Error{Code: "auth_not_found", Message: "no auth available"}
}
auth, errPick := m.selector.Pick(ctx, provider, model, opts, candidates)
selected, errPick := m.selector.Pick(ctx, provider, model, opts, candidates)
if errPick != nil {
m.mu.RUnlock()
return nil, nil, errPick
}
if auth == nil {
if selected == nil {
m.mu.RUnlock()
return nil, nil, &Error{Code: "auth_not_found", Message: "selector returned no auth"}
}
return auth, executor, nil
authCopy := selected.Clone()
m.mu.RUnlock()
return authCopy, executor, nil
}
func (m *Manager) persist(ctx context.Context, auth *Auth) error {