Compare commits

...

13 Commits

Author SHA1 Message Date
Luis Pater
9acfbcc2a0 Merge pull request #275 from router-for-me/iflow
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
Iflow
2025-11-19 20:44:54 +08:00
hkfires
b285b07986 fix(iflow): adjust auth filename email sanitization 2025-11-19 19:50:06 +08:00
Luis Pater
c40e00526b Merge pull request #274 from router-for-me/log
fix: detect HTML error bodies without text/html content type
2025-11-19 17:40:06 +08:00
hkfires
8a33f3ef69 fix: detect HTML error bodies without text/html content type 2025-11-19 14:45:33 +08:00
Luis Pater
7a8e00fcea **fix(translator): handle missing parameters in Gemini tool schema gracefully**
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-11-19 13:19:46 +08:00
Luis Pater
89771216a1 **feat(translator): add ThoughtSignature handling in Gemini request transformations**
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-11-19 11:34:13 +08:00
Luis Pater
14ddfd4b79 Merge pull request #270 from router-for-me/iflow
feat(auth): add iFlow cookie-based authentication support
2025-11-19 01:54:34 +08:00
Luis Pater
567227f35f Merge pull request #268 from router-for-me/tools
fix: use underscore suffix in short name mapping
2025-11-19 01:43:41 +08:00
Luis Pater
17016ae6a5 **feat(registry): add Gemini 3 Pro Preview model definition**
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-11-18 23:48:21 +08:00
Luis Pater
01b7b60901 **feat(registry): add Gemini 3 Pro Preview model definition** 2025-11-18 23:46:58 +08:00
hkfires
b52a5cc066 feat(auth): add iFlow cookie-based authentication support 2025-11-18 22:35:35 +08:00
hkfires
1ba057112a fix: use underscore suffix in short name mapping
Replace the "~<n>" suffix with "_<n>" when generating unique short names in codex translators (Claude, Gemini, OpenAI chat).
This avoids using a special character in identifiers, improving compatibility with downstream APIs while preserving length constraints.
2025-11-18 16:59:25 +08:00
Luis Pater
23a7633e6d **fix(registry): update Thinking parameters and replace Gemini-3 Preview with Gemini-2.5 Flash Lite**
Some checks failed
docker-image / docker (push) Has been cancelled
goreleaser / goreleaser (push) Has been cancelled
2025-11-18 11:51:52 +08:00
17 changed files with 557 additions and 19 deletions

View File

@@ -59,6 +59,7 @@ func main() {
var claudeLogin bool var claudeLogin bool
var qwenLogin bool var qwenLogin bool
var iflowLogin bool var iflowLogin bool
var iflowCookie bool
var noBrowser bool var noBrowser bool
var projectID string var projectID string
var vertexImport string var vertexImport string
@@ -71,6 +72,7 @@ func main() {
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth") flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude using OAuth")
flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth") flag.BoolVar(&qwenLogin, "qwen-login", false, "Login to Qwen using OAuth")
flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth") flag.BoolVar(&iflowLogin, "iflow-login", false, "Login to iFlow using OAuth")
flag.BoolVar(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth") flag.BoolVar(&noBrowser, "no-browser", false, "Don't open browser automatically for OAuth")
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)") flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path") flag.StringVar(&configPath, "config", DefaultConfigPath, "Configure File Path")
@@ -439,6 +441,8 @@ func main() {
cmd.DoQwenLogin(cfg, options) cmd.DoQwenLogin(cfg, options)
} else if iflowLogin { } else if iflowLogin {
cmd.DoIFlowLogin(cfg, options) cmd.DoIFlowLogin(cfg, options)
} else if iflowCookie {
cmd.DoIFlowCookieAuth(cfg, options)
} else { } else {
// In cloud deploy mode without config file, just wait for shutdown signals // In cloud deploy mode without config file, just wait for shutdown signals
if isCloudDeploy && !configFileExists { if isCloudDeploy && !configFileExists {

View File

@@ -1,6 +1,7 @@
package iflow package iflow
import ( import (
"compress/gzip"
"context" "context"
"encoding/base64" "encoding/base64"
"encoding/json" "encoding/json"
@@ -23,6 +24,9 @@ const (
iFlowUserInfoEndpoint = "https://iflow.cn/api/oauth/getUserInfo" iFlowUserInfoEndpoint = "https://iflow.cn/api/oauth/getUserInfo"
iFlowSuccessRedirectURL = "https://iflow.cn/oauth/success" iFlowSuccessRedirectURL = "https://iflow.cn/oauth/success"
// Cookie authentication endpoints
iFlowAPIKeyEndpoint = "https://platform.iflow.cn/api/openapi/apikey"
// Client credentials provided by iFlow for the Code Assist integration. // Client credentials provided by iFlow for the Code Assist integration.
iFlowOAuthClientID = "10009311001" iFlowOAuthClientID = "10009311001"
iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW" iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
@@ -261,6 +265,7 @@ type IFlowTokenData struct {
Expire string Expire string
APIKey string APIKey string
Email string Email string
Cookie string
} }
// userInfoResponse represents the structure returned by the user info endpoint. // userInfoResponse represents the structure returned by the user info endpoint.
@@ -274,3 +279,232 @@ type userInfoData struct {
Email string `json:"email"` Email string `json:"email"`
Phone string `json:"phone"` Phone string `json:"phone"`
} }
// iFlowAPIKeyResponse represents the response from the API key endpoint
type iFlowAPIKeyResponse struct {
Success bool `json:"success"`
Code string `json:"code"`
Message string `json:"message"`
Data iFlowKeyData `json:"data"`
Extra interface{} `json:"extra"`
}
// iFlowKeyData contains the API key information
type iFlowKeyData struct {
HasExpired bool `json:"hasExpired"`
ExpireTime string `json:"expireTime"`
Name string `json:"name"`
APIKey string `json:"apiKey"`
APIKeyMask string `json:"apiKeyMask"`
}
// iFlowRefreshRequest represents the request body for refreshing API key
type iFlowRefreshRequest struct {
Name string `json:"name"`
}
// AuthenticateWithCookie performs authentication using browser cookies
func (ia *IFlowAuth) AuthenticateWithCookie(ctx context.Context, cookie string) (*IFlowTokenData, error) {
if strings.TrimSpace(cookie) == "" {
return nil, fmt.Errorf("iflow cookie authentication: cookie is empty")
}
// First, get initial API key information using GET request
keyInfo, err := ia.fetchAPIKeyInfo(ctx, cookie)
if err != nil {
return nil, fmt.Errorf("iflow cookie authentication: fetch initial API key info failed: %w", err)
}
// Convert to token data format
data := &IFlowTokenData{
APIKey: keyInfo.APIKey,
Expire: keyInfo.ExpireTime,
Email: keyInfo.Name,
Cookie: cookie,
}
return data, nil
}
// fetchAPIKeyInfo retrieves API key information using GET request with cookie
func (ia *IFlowAuth) fetchAPIKeyInfo(ctx context.Context, cookie string) (*iFlowKeyData, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, iFlowAPIKeyEndpoint, nil)
if err != nil {
return nil, fmt.Errorf("iflow cookie: create GET request failed: %w", err)
}
// Set cookie and other headers to mimic browser
req.Header.Set("Cookie", cookie)
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Sec-Fetch-Dest", "empty")
req.Header.Set("Sec-Fetch-Mode", "cors")
req.Header.Set("Sec-Fetch-Site", "same-origin")
resp, err := ia.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("iflow cookie: GET request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// Handle gzip compression
var reader io.Reader = resp.Body
if resp.Header.Get("Content-Encoding") == "gzip" {
gzipReader, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("iflow cookie: create gzip reader failed: %w", err)
}
defer func() { _ = gzipReader.Close() }()
reader = gzipReader
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("iflow cookie: read GET response failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
log.Debugf("iflow cookie GET request failed: status=%d body=%s", resp.StatusCode, string(body))
return nil, fmt.Errorf("iflow cookie: GET request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var keyResp iFlowAPIKeyResponse
if err = json.Unmarshal(body, &keyResp); err != nil {
return nil, fmt.Errorf("iflow cookie: decode GET response failed: %w", err)
}
if !keyResp.Success {
return nil, fmt.Errorf("iflow cookie: GET request not successful: %s", keyResp.Message)
}
// Handle initial response where apiKey field might be apiKeyMask
if keyResp.Data.APIKey == "" && keyResp.Data.APIKeyMask != "" {
keyResp.Data.APIKey = keyResp.Data.APIKeyMask
}
return &keyResp.Data, nil
}
// RefreshAPIKey refreshes the API key using POST request
func (ia *IFlowAuth) RefreshAPIKey(ctx context.Context, cookie, name string) (*iFlowKeyData, error) {
if strings.TrimSpace(cookie) == "" {
return nil, fmt.Errorf("iflow cookie refresh: cookie is empty")
}
if strings.TrimSpace(name) == "" {
return nil, fmt.Errorf("iflow cookie refresh: name is empty")
}
// Prepare request body
refreshReq := iFlowRefreshRequest{
Name: name,
}
bodyBytes, err := json.Marshal(refreshReq)
if err != nil {
return nil, fmt.Errorf("iflow cookie refresh: marshal request failed: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, iFlowAPIKeyEndpoint, strings.NewReader(string(bodyBytes)))
if err != nil {
return nil, fmt.Errorf("iflow cookie refresh: create POST request failed: %w", err)
}
// Set cookie and other headers to mimic browser
req.Header.Set("Cookie", cookie)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json, text/plain, */*")
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36")
req.Header.Set("Accept-Language", "zh-CN,zh;q=0.9,en;q=0.8")
req.Header.Set("Accept-Encoding", "gzip, deflate, br")
req.Header.Set("Connection", "keep-alive")
req.Header.Set("Origin", "https://platform.iflow.cn")
req.Header.Set("Referer", "https://platform.iflow.cn/")
resp, err := ia.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("iflow cookie refresh: POST request failed: %w", err)
}
defer func() { _ = resp.Body.Close() }()
// Handle gzip compression
var reader io.Reader = resp.Body
if resp.Header.Get("Content-Encoding") == "gzip" {
gzipReader, err := gzip.NewReader(resp.Body)
if err != nil {
return nil, fmt.Errorf("iflow cookie refresh: create gzip reader failed: %w", err)
}
defer func() { _ = gzipReader.Close() }()
reader = gzipReader
}
body, err := io.ReadAll(reader)
if err != nil {
return nil, fmt.Errorf("iflow cookie refresh: read POST response failed: %w", err)
}
if resp.StatusCode != http.StatusOK {
log.Debugf("iflow cookie POST request failed: status=%d body=%s", resp.StatusCode, string(body))
return nil, fmt.Errorf("iflow cookie refresh: POST request failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))
}
var keyResp iFlowAPIKeyResponse
if err = json.Unmarshal(body, &keyResp); err != nil {
return nil, fmt.Errorf("iflow cookie refresh: decode POST response failed: %w", err)
}
if !keyResp.Success {
return nil, fmt.Errorf("iflow cookie refresh: POST request not successful: %s", keyResp.Message)
}
return &keyResp.Data, nil
}
// ShouldRefreshAPIKey checks if the API key needs to be refreshed (within 2 days of expiry)
func ShouldRefreshAPIKey(expireTime string) (bool, time.Duration, error) {
if strings.TrimSpace(expireTime) == "" {
return false, 0, fmt.Errorf("iflow cookie: expire time is empty")
}
expire, err := time.Parse("2006-01-02 15:04", expireTime)
if err != nil {
return false, 0, fmt.Errorf("iflow cookie: parse expire time failed: %w", err)
}
now := time.Now()
twoDaysFromNow := now.Add(48 * time.Hour)
needsRefresh := expire.Before(twoDaysFromNow)
timeUntilExpiry := expire.Sub(now)
return needsRefresh, timeUntilExpiry, nil
}
// CreateCookieTokenStorage converts cookie-based token data into persistence storage
func (ia *IFlowAuth) CreateCookieTokenStorage(data *IFlowTokenData) *IFlowTokenStorage {
if data == nil {
return nil
}
return &IFlowTokenStorage{
APIKey: data.APIKey,
Email: data.Email,
Expire: data.Expire,
Cookie: data.Cookie,
LastRefresh: time.Now().Format(time.RFC3339),
Type: "iflow",
}
}
// UpdateCookieTokenStorage updates the persisted token storage with refreshed API key data
func (ia *IFlowAuth) UpdateCookieTokenStorage(storage *IFlowTokenStorage, keyData *iFlowKeyData) {
if storage == nil || keyData == nil {
return
}
storage.APIKey = keyData.APIKey
storage.Expire = keyData.ExpireTime
storage.LastRefresh = time.Now().Format(time.RFC3339)
}

View File

@@ -19,6 +19,7 @@ type IFlowTokenStorage struct {
Email string `json:"email"` Email string `json:"email"`
TokenType string `json:"token_type"` TokenType string `json:"token_type"`
Scope string `json:"scope"` Scope string `json:"scope"`
Cookie string `json:"cookie"`
Type string `json:"type"` Type string `json:"type"`
} }

View File

@@ -0,0 +1,110 @@
package cmd
import (
"bufio"
"context"
"fmt"
"os"
"strings"
"github.com/router-for-me/CLIProxyAPI/v6/internal/auth/iflow"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
)
// DoIFlowCookieAuth performs the iFlow cookie-based authentication.
func DoIFlowCookieAuth(cfg *config.Config, options *LoginOptions) {
if options == nil {
options = &LoginOptions{}
}
promptFn := options.Prompt
if promptFn == nil {
reader := bufio.NewReader(os.Stdin)
promptFn = func(prompt string) (string, error) {
fmt.Print(prompt)
value, err := reader.ReadString('\n')
if err != nil {
return "", err
}
return strings.TrimSpace(value), nil
}
}
// Prompt user for cookie
cookie, err := promptForCookie(promptFn)
if err != nil {
fmt.Printf("Failed to get cookie: %v\n", err)
return
}
// Authenticate with cookie
auth := iflow.NewIFlowAuth(cfg)
ctx := context.Background()
tokenData, err := auth.AuthenticateWithCookie(ctx, cookie)
if err != nil {
fmt.Printf("iFlow cookie authentication failed: %v\n", err)
return
}
// Create token storage
tokenStorage := auth.CreateCookieTokenStorage(tokenData)
// Get auth file path using email in filename
authFilePath := getAuthFilePath(cfg, "iflow", tokenData.Email)
// Save token to file
if err := tokenStorage.SaveTokenToFile(authFilePath); err != nil {
fmt.Printf("Failed to save authentication: %v\n", err)
return
}
fmt.Printf("Authentication successful! API key: %s\n", tokenData.APIKey)
fmt.Printf("Expires at: %s\n", tokenData.Expire)
fmt.Printf("Authentication saved to: %s\n", authFilePath)
}
// promptForCookie prompts the user to enter their iFlow cookie
func promptForCookie(promptFn func(string) (string, error)) (string, error) {
line, err := promptFn("Enter iFlow Cookie (from browser cookies): ")
if err != nil {
return "", fmt.Errorf("failed to read cookie: %w", err)
}
line = strings.TrimSpace(line)
if line == "" {
return "", fmt.Errorf("cookie cannot be empty")
}
// Clean up any extra whitespace and join multiple spaces
cookie := strings.Join(strings.Fields(line), " ")
// Ensure it ends properly
if !strings.HasSuffix(cookie, ";") {
cookie = cookie + ";"
}
// Ensure BXAuth is present in the cookie
if !strings.Contains(cookie, "BXAuth=") {
return "", fmt.Errorf("BXAuth field not found in cookie")
}
return cookie, nil
}
// getAuthFilePath returns the auth file path for the given provider and email
func getAuthFilePath(cfg *config.Config, provider, email string) string {
// Clean email to make it filename-safe
cleanEmail := strings.ReplaceAll(email, "*", "x")
// Remove any unsafe characters, but allow standard email chars (@, ., -)
var result strings.Builder
for _, r := range cleanEmail {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') ||
r == '_' || r == '@' || r == '.' || r == '-' {
result.WriteRune(r)
}
}
return fmt.Sprintf("%s/%s-%s.json", cfg.AuthDir, provider, result.String())
}

View File

@@ -62,6 +62,9 @@ type Part struct {
// InlineData contains base64-encoded data with its MIME type (e.g., images). // InlineData contains base64-encoded data with its MIME type (e.g., images).
InlineData *InlineData `json:"inlineData,omitempty"` InlineData *InlineData `json:"inlineData,omitempty"`
// ThoughtSignature is a provider-required signature that accompanies certain parts.
ThoughtSignature string `json:"thoughtSignature,omitempty"`
// FunctionCall represents a tool call requested by the model. // FunctionCall represents a tool call requested by the model.
FunctionCall *FunctionCall `json:"functionCall,omitempty"` FunctionCall *FunctionCall `json:"functionCall,omitempty"`

View File

@@ -114,7 +114,7 @@ func GeminiModels() []*ModelInfo {
InputTokenLimit: 1048576, InputTokenLimit: 1048576,
OutputTokenLimit: 65536, OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 512, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true},
}, },
} }
} }
@@ -156,20 +156,35 @@ func GetGeminiCLIModels() []*ModelInfo {
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
}, },
{ {
ID: "gemini-3-pro-preview-11-2025", ID: "gemini-2.5-flash-lite",
Object: "model", Object: "model",
Created: time.Now().Unix(), Created: time.Now().Unix(),
OwnedBy: "google", OwnedBy: "google",
Type: "gemini", Type: "gemini",
Name: "models/gemini-3-pro-preview-11-2025", Name: "models/gemini-2.5-flash-lite",
Version: "3", Version: "2.5",
DisplayName: "Gemini 3 Pro Preview 11-2025", DisplayName: "Gemini 2.5 Flash Lite",
Description: "Latest preview of Gemini Pro", Description: "Our smallest and most cost effective model, built for at scale usage.",
InputTokenLimit: 1048576, InputTokenLimit: 1048576,
OutputTokenLimit: 65536, OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"}, SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true},
}, },
// {
// ID: "gemini-3-pro-preview-11-2025",
// Object: "model",
// Created: time.Now().Unix(),
// OwnedBy: "google",
// Type: "gemini",
// Name: "models/gemini-3-pro-preview-11-2025",
// Version: "3",
// DisplayName: "Gemini 3 Pro Preview 11-2025",
// Description: "Latest preview of Gemini Pro",
// InputTokenLimit: 1048576,
// OutputTokenLimit: 65536,
// SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
// Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
// },
} }
} }
@@ -179,6 +194,21 @@ func GetAIStudioModels() []*ModelInfo {
return append(base, return append(base,
[]*ModelInfo{ []*ModelInfo{
{
ID: "gemini-3-pro-preview",
Object: "model",
Created: time.Now().Unix(),
OwnedBy: "google",
Type: "gemini",
Name: "models/gemini-3-pro-preview",
Version: "3.0",
DisplayName: "Gemini 3 Pro Preview",
Description: "Gemini 3 Pro Preview",
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
},
{ {
ID: "gemini-pro-latest", ID: "gemini-pro-latest",
Object: "model", Object: "model",

View File

@@ -242,13 +242,87 @@ func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
return cliproxyexecutor.Response{Payload: []byte(translated)}, nil return cliproxyexecutor.Response{Payload: []byte(translated)}, nil
} }
// Refresh refreshes OAuth tokens and updates the stored API key. // Refresh refreshes OAuth tokens or cookie-based API keys and updates the stored API key.
func (e *IFlowExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { func (e *IFlowExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
log.Debugf("iflow executor: refresh called") log.Debugf("iflow executor: refresh called")
if auth == nil { if auth == nil {
return nil, fmt.Errorf("iflow executor: auth is nil") return nil, fmt.Errorf("iflow executor: auth is nil")
} }
// Check if this is cookie-based authentication
var cookie string
var email string
if auth.Metadata != nil {
if v, ok := auth.Metadata["cookie"].(string); ok {
cookie = strings.TrimSpace(v)
}
if v, ok := auth.Metadata["email"].(string); ok {
email = strings.TrimSpace(v)
}
}
// If cookie is present, use cookie-based refresh
if cookie != "" && email != "" {
return e.refreshCookieBased(ctx, auth, cookie, email)
}
// Otherwise, use OAuth-based refresh
return e.refreshOAuthBased(ctx, auth)
}
// refreshCookieBased refreshes API key using browser cookie
func (e *IFlowExecutor) refreshCookieBased(ctx context.Context, auth *cliproxyauth.Auth, cookie, email string) (*cliproxyauth.Auth, error) {
log.Debugf("iflow executor: checking refresh need for cookie-based API key for user: %s", email)
// Get current expiry time from metadata
var currentExpire string
if auth.Metadata != nil {
if v, ok := auth.Metadata["expired"].(string); ok {
currentExpire = strings.TrimSpace(v)
}
}
// Check if refresh is needed
needsRefresh, _, err := iflowauth.ShouldRefreshAPIKey(currentExpire)
if err != nil {
log.Warnf("iflow executor: failed to check refresh need: %v", err)
// If we can't check, continue with refresh anyway as a safety measure
} else if !needsRefresh {
log.Debugf("iflow executor: no refresh needed for user: %s", email)
return auth, nil
}
log.Infof("iflow executor: refreshing cookie-based API key for user: %s", email)
svc := iflowauth.NewIFlowAuth(e.cfg)
keyData, err := svc.RefreshAPIKey(ctx, cookie, email)
if err != nil {
log.Errorf("iflow executor: cookie-based API key refresh failed: %v", err)
return nil, err
}
if auth.Metadata == nil {
auth.Metadata = make(map[string]any)
}
auth.Metadata["api_key"] = keyData.APIKey
auth.Metadata["expired"] = keyData.ExpireTime
auth.Metadata["type"] = "iflow"
auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339)
auth.Metadata["cookie"] = cookie
auth.Metadata["email"] = email
log.Infof("iflow executor: cookie-based API key refreshed successfully, new expiry: %s", keyData.ExpireTime)
if auth.Attributes == nil {
auth.Attributes = make(map[string]string)
}
auth.Attributes["api_key"] = keyData.APIKey
return auth, nil
}
// refreshOAuthBased refreshes tokens using OAuth refresh token
func (e *IFlowExecutor) refreshOAuthBased(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
refreshToken := "" refreshToken := ""
oldAccessToken := "" oldAccessToken := ""
if auth.Metadata != nil { if auth.Metadata != nil {

View File

@@ -323,7 +323,14 @@ func formatAuthInfo(info upstreamRequestLog) string {
} }
func summarizeErrorBody(contentType string, body []byte) string { func summarizeErrorBody(contentType string, body []byte) string {
if strings.Contains(strings.ToLower(contentType), "text/html") { isHTML := strings.Contains(strings.ToLower(contentType), "text/html")
if !isHTML {
trimmed := bytes.TrimSpace(bytes.ToLower(body))
if bytes.HasPrefix(trimmed, []byte("<!doctype html")) || bytes.HasPrefix(trimmed, []byte("<html")) {
isHTML = true
}
}
if isHTML {
if title := extractHTMLTitle(body); title != "" { if title := extractHTMLTitle(body); title != "" {
return title return title
} }

View File

@@ -289,7 +289,7 @@ func buildShortNameMap(names []string) map[string]string {
} }
base := cand base := cand
for i := 1; ; i++ { for i := 1; ; i++ {
suffix := "~" + strconv.Itoa(i) suffix := "_" + strconv.Itoa(i)
allowed := limit - len(suffix) allowed := limit - len(suffix)
if allowed < 0 { if allowed < 0 {
allowed = 0 allowed = 0

View File

@@ -310,7 +310,7 @@ func buildShortNameMap(names []string) map[string]string {
} }
base := cand base := cand
for i := 1; ; i++ { for i := 1; ; i++ {
suffix := "~" + strconv.Itoa(i) suffix := "_" + strconv.Itoa(i)
allowed := limit - len(suffix) allowed := limit - len(suffix)
if allowed < 0 { if allowed < 0 {
allowed = 0 allowed = 0

View File

@@ -361,7 +361,7 @@ func buildShortNameMap(names []string) map[string]string {
} }
base := cand base := cand
for i := 1; ; i++ { for i := 1; ; i++ {
suffix := "~" + strconv.Itoa(i) suffix := "_" + strconv.Itoa(i)
allowed := limit - len(suffix) allowed := limit - len(suffix)
if allowed < 0 { if allowed < 0 {
allowed = 0 allowed = 0

View File

@@ -17,6 +17,8 @@ import (
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
) )
const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
// ConvertClaudeRequestToCLI parses and transforms a Claude Code API request into Gemini CLI API format. // ConvertClaudeRequestToCLI parses and transforms a Claude Code API request into Gemini CLI API format.
// It extracts the model name, system instruction, message contents, and tool declarations // It extracts the model name, system instruction, message contents, and tool declarations
// from the raw JSON request and returns them in the format expected by the Gemini CLI API. // from the raw JSON request and returns them in the format expected by the Gemini CLI API.
@@ -89,7 +91,10 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
functionArgs := contentResult.Get("input").String() functionArgs := contentResult.Get("input").String()
var args map[string]any var args map[string]any
if err := json.Unmarshal([]byte(functionArgs), &args); err == nil { if err := json.Unmarshal([]byte(functionArgs), &args); err == nil {
clientContent.Parts = append(clientContent.Parts, client.Part{FunctionCall: &client.FunctionCall{Name: functionName, Args: args}}) clientContent.Parts = append(clientContent.Parts, client.Part{
FunctionCall: &client.FunctionCall{Name: functionName, Args: args},
ThoughtSignature: geminiCLIClaudeThoughtSignature,
})
} }
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
toolCallID := contentResult.Get("tool_use_id").String() toolCallID := contentResult.Get("tool_use_id").String()

View File

@@ -15,6 +15,8 @@ import (
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
) )
const geminiCLIFunctionThoughtSignature = "skip_thought_signature_validator"
// ConvertOpenAIRequestToGeminiCLI converts an OpenAI Chat Completions request (raw JSON) // ConvertOpenAIRequestToGeminiCLI converts an OpenAI Chat Completions request (raw JSON)
// into a complete Gemini CLI request JSON. All JSON construction uses sjson and lookups use gjson. // into a complete Gemini CLI request JSON. All JSON construction uses sjson and lookups use gjson.
// //
@@ -239,6 +241,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
fargs := tc.Get("function.arguments").String() fargs := tc.Get("function.arguments").String()
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
p++ p++
if fid != "" { if fid != "" {
fIDs = append(fIDs, fid) fIDs = append(fIDs, fid)
@@ -283,6 +286,17 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema") renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
if errRename != nil { if errRename != nil {
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename) log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
var errSet error
fnRaw, errSet = sjson.Set(fnRaw, "parametersJsonSchema.type", "object")
if errSet != nil {
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRaw, errSet = sjson.Set(fnRaw, "parametersJsonSchema.properties", map[string]interface{}{})
if errSet != nil {
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
} else { } else {
fnRaw = renamed fnRaw = renamed
} }

View File

@@ -17,6 +17,8 @@ import (
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
) )
const geminiClaudeThoughtSignature = "skip_thought_signature_validator"
// ConvertClaudeRequestToGemini parses a Claude API request and returns a complete // ConvertClaudeRequestToGemini parses a Claude API request and returns a complete
// Gemini CLI request body (as JSON bytes) ready to be sent via SendRawMessageStream. // Gemini CLI request body (as JSON bytes) ready to be sent via SendRawMessageStream.
// All JSON transformations are performed using gjson/sjson. // All JSON transformations are performed using gjson/sjson.
@@ -82,7 +84,10 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
functionArgs := contentResult.Get("input").String() functionArgs := contentResult.Get("input").String()
var args map[string]any var args map[string]any
if err := json.Unmarshal([]byte(functionArgs), &args); err == nil { if err := json.Unmarshal([]byte(functionArgs), &args); err == nil {
clientContent.Parts = append(clientContent.Parts, client.Part{FunctionCall: &client.FunctionCall{Name: functionName, Args: args}}) clientContent.Parts = append(clientContent.Parts, client.Part{
FunctionCall: &client.FunctionCall{Name: functionName, Args: args},
ThoughtSignature: geminiClaudeThoughtSignature,
})
} }
} else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" { } else if contentTypeResult.Type == gjson.String && contentTypeResult.String() == "tool_result" {
toolCallID := contentResult.Get("tool_use_id").String() toolCallID := contentResult.Get("tool_use_id").String()

View File

@@ -15,6 +15,8 @@ import (
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
) )
const geminiFunctionThoughtSignature = "skip_thought_signature_validator"
// ConvertOpenAIRequestToGemini converts an OpenAI Chat Completions request (raw JSON) // ConvertOpenAIRequestToGemini converts an OpenAI Chat Completions request (raw JSON)
// into a complete Gemini request JSON. All JSON construction uses sjson and lookups use gjson. // into a complete Gemini request JSON. All JSON construction uses sjson and lookups use gjson.
// //
@@ -264,6 +266,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
fargs := tc.Get("function.arguments").String() fargs := tc.Get("function.arguments").String()
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname) node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs)) node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature)
p++ p++
if fid != "" { if fid != "" {
fIDs = append(fIDs, fid) fIDs = append(fIDs, fid)
@@ -303,8 +306,34 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
if t.Get("type").String() == "function" { if t.Get("type").String() == "function" {
fn := t.Get("function") fn := t.Get("function")
if fn.Exists() && fn.IsObject() { if fn.Exists() && fn.IsObject() {
parametersJsonSchema, _ := util.RenameKey(fn.Raw, "parameters", "parametersJsonSchema") fnRaw := fn.Raw
out, _ = sjson.SetRawBytes(out, fdPath+".-1", []byte(parametersJsonSchema)) 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)
} else {
fnRaw = renamed
}
} else {
var errSet error
fnRaw, errSet = sjson.Set(fnRaw, "parametersJsonSchema.type", "object")
if errSet != nil {
log.Warnf("Failed to set default schema type for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
fnRaw, errSet = sjson.Set(fnRaw, "parametersJsonSchema.properties", map[string]interface{}{})
if errSet != nil {
log.Warnf("Failed to set default schema properties for tool '%s': %v", fn.Get("name").String(), errSet)
continue
}
}
fnRaw, _ = sjson.Delete(fnRaw, "strict")
tmp, errSet := sjson.SetRawBytes(out, fdPath+".-1", []byte(fnRaw))
if errSet != nil {
log.Warnf("Failed to append tool declaration for '%s': %v", fn.Get("name").String(), errSet)
continue
}
out = tmp
} }
} }
} }

View File

@@ -10,6 +10,8 @@ import (
"github.com/tidwall/sjson" "github.com/tidwall/sjson"
) )
const geminiResponsesThoughtSignature = "skip_thought_signature_validator"
func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte { func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := bytes.Clone(inputRawJSON) rawJSON := bytes.Clone(inputRawJSON)
@@ -108,6 +110,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
modelContent := `{"role":"model","parts":[]}` modelContent := `{"role":"model","parts":[]}`
functionCall := `{"functionCall":{"name":"","args":{}}}` functionCall := `{"functionCall":{"name":"","args":{}}}`
functionCall, _ = sjson.Set(functionCall, "functionCall.name", name) functionCall, _ = sjson.Set(functionCall, "functionCall.name", name)
functionCall, _ = sjson.Set(functionCall, "thoughtSignature", geminiResponsesThoughtSignature)
// Parse arguments JSON string and set as args object // Parse arguments JSON string and set as args object
if arguments != "" { if arguments != "" {

View File

@@ -152,11 +152,29 @@ func (a *Auth) AccountInfo() (string, string) {
} }
} }
} }
if a.Metadata != nil {
if v, ok := a.Metadata["email"].(string); ok { // For iFlow provider, prioritize OAuth type if email is present
return "oauth", v if strings.ToLower(a.Provider) == "iflow" {
if a.Metadata != nil {
if email, ok := a.Metadata["email"].(string); ok {
email = strings.TrimSpace(email)
if email != "" {
return "oauth", email
}
}
} }
} }
// Check metadata for email first (OAuth-style auth)
if a.Metadata != nil {
if v, ok := a.Metadata["email"].(string); ok {
email := strings.TrimSpace(v)
if email != "" {
return "oauth", email
}
}
}
// Fall back to API key (API-key auth)
if a.Attributes != nil { if a.Attributes != nil {
if v := a.Attributes["api_key"]; v != "" { if v := a.Attributes["api_key"]; v != "" {
return "api_key", v return "api_key", v
@@ -259,6 +277,7 @@ func parseTimeValue(v any) (time.Time, bool) {
time.RFC3339, time.RFC3339,
time.RFC3339Nano, time.RFC3339Nano,
"2006-01-02 15:04:05", "2006-01-02 15:04:05",
"2006-01-02 15:04",
"2006-01-02T15:04:05Z07:00", "2006-01-02T15:04:05Z07:00",
} }
for _, layout := range layouts { for _, layout := range layouts {