mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-14 02:10:52 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7a8e00fcea | ||
|
|
89771216a1 | ||
|
|
14ddfd4b79 | ||
|
|
567227f35f | ||
|
|
17016ae6a5 | ||
|
|
01b7b60901 | ||
|
|
b52a5cc066 | ||
|
|
1ba057112a | ||
|
|
23a7633e6d | ||
|
|
e5e985978d | ||
|
|
db2d22c978 | ||
|
|
1c815c58a6 | ||
|
|
4eab141410 | ||
|
|
5937b8e429 | ||
|
|
9875565339 |
@@ -59,6 +59,7 @@ func main() {
|
||||
var claudeLogin bool
|
||||
var qwenLogin bool
|
||||
var iflowLogin bool
|
||||
var iflowCookie bool
|
||||
var noBrowser bool
|
||||
var projectID string
|
||||
var vertexImport string
|
||||
@@ -71,6 +72,7 @@ func main() {
|
||||
flag.BoolVar(&claudeLogin, "claude-login", false, "Login to Claude 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(&iflowCookie, "iflow-cookie", false, "Login to iFlow using Cookie")
|
||||
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(&configPath, "config", DefaultConfigPath, "Configure File Path")
|
||||
@@ -439,6 +441,8 @@ func main() {
|
||||
cmd.DoQwenLogin(cfg, options)
|
||||
} else if iflowLogin {
|
||||
cmd.DoIFlowLogin(cfg, options)
|
||||
} else if iflowCookie {
|
||||
cmd.DoIFlowCookieAuth(cfg, options)
|
||||
} else {
|
||||
// In cloud deploy mode without config file, just wait for shutdown signals
|
||||
if isCloudDeploy && !configFileExists {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package iflow
|
||||
|
||||
import (
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/base64"
|
||||
"encoding/json"
|
||||
@@ -23,6 +24,9 @@ const (
|
||||
iFlowUserInfoEndpoint = "https://iflow.cn/api/oauth/getUserInfo"
|
||||
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.
|
||||
iFlowOAuthClientID = "10009311001"
|
||||
iFlowOAuthClientSecret = "4Z3YjXycVsQvyGF1etiNlIBB4RsqSDtW"
|
||||
@@ -261,6 +265,7 @@ type IFlowTokenData struct {
|
||||
Expire string
|
||||
APIKey string
|
||||
Email string
|
||||
Cookie string
|
||||
}
|
||||
|
||||
// userInfoResponse represents the structure returned by the user info endpoint.
|
||||
@@ -274,3 +279,232 @@ type userInfoData struct {
|
||||
Email string `json:"email"`
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ type IFlowTokenStorage struct {
|
||||
Email string `json:"email"`
|
||||
TokenType string `json:"token_type"`
|
||||
Scope string `json:"scope"`
|
||||
Cookie string `json:"cookie"`
|
||||
Type string `json:"type"`
|
||||
}
|
||||
|
||||
|
||||
111
internal/cmd/iflow_cookie.go
Normal file
111
internal/cmd/iflow_cookie.go
Normal file
@@ -0,0 +1,111 @@
|
||||
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, "@", "_at_")
|
||||
cleanEmail = strings.ReplaceAll(cleanEmail, ".", "_")
|
||||
cleanEmail = strings.ReplaceAll(cleanEmail, "-", "_")
|
||||
|
||||
// Remove any remaining special characters
|
||||
var result strings.Builder
|
||||
for _, r := range cleanEmail {
|
||||
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
|
||||
result.WriteRune(r)
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s/%s-%s.json", cfg.AuthDir, provider, result.String())
|
||||
}
|
||||
@@ -62,6 +62,9 @@ type Part struct {
|
||||
// InlineData contains base64-encoded data with its MIME type (e.g., images).
|
||||
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 *FunctionCall `json:"functionCall,omitempty"`
|
||||
|
||||
|
||||
@@ -114,7 +114,7 @@ func GeminiModels() []*ModelInfo {
|
||||
InputTokenLimit: 1048576,
|
||||
OutputTokenLimit: 65536,
|
||||
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},
|
||||
},
|
||||
{
|
||||
ID: "gemini-3-pro-preview-11-2025",
|
||||
ID: "gemini-2.5-flash-lite",
|
||||
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",
|
||||
Name: "models/gemini-2.5-flash-lite",
|
||||
Version: "2.5",
|
||||
DisplayName: "Gemini 2.5 Flash Lite",
|
||||
Description: "Our smallest and most cost effective model, built for at scale usage.",
|
||||
InputTokenLimit: 1048576,
|
||||
OutputTokenLimit: 65536,
|
||||
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,
|
||||
[]*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",
|
||||
Object: "model",
|
||||
|
||||
@@ -219,8 +219,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
// If from == to (Claude → Claude), directly forward the SSE stream without translation
|
||||
if from == to {
|
||||
scanner := bufio.NewScanner(decodedBody)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
appendAPIResponseChunk(ctx, e.cfg, line)
|
||||
@@ -243,8 +242,7 @@ func (e *ClaudeExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
|
||||
// For other formats, use translation
|
||||
scanner := bufio.NewScanner(decodedBody)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -205,8 +205,7 @@ func (e *CodexExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
}
|
||||
}()
|
||||
scanner := bufio.NewScanner(httpResp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -319,8 +319,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
||||
}()
|
||||
if opts.Alt == "" {
|
||||
scanner := bufio.NewScanner(resp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -251,8 +251,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
||||
}
|
||||
}()
|
||||
scanner := bufio.NewScanner(httpResp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -240,8 +240,7 @@ func (e *GeminiVertexExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
||||
}
|
||||
}()
|
||||
scanner := bufio.NewScanner(httpResp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -199,8 +199,7 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
|
||||
}()
|
||||
|
||||
scanner := bufio.NewScanner(httpResp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
@@ -243,13 +242,87 @@ func (e *IFlowExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth
|
||||
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) {
|
||||
log.Debugf("iflow executor: refresh called")
|
||||
if auth == 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 := ""
|
||||
oldAccessToken := ""
|
||||
if auth.Metadata != nil {
|
||||
|
||||
@@ -205,8 +205,7 @@ func (e *OpenAICompatExecutor) ExecuteStream(ctx context.Context, auth *cliproxy
|
||||
}
|
||||
}()
|
||||
scanner := bufio.NewScanner(httpResp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -181,8 +181,7 @@ func (e *QwenExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Aut
|
||||
}
|
||||
}()
|
||||
scanner := bufio.NewScanner(httpResp.Body)
|
||||
buf := make([]byte, 20_971_520)
|
||||
scanner.Buffer(buf, 20_971_520)
|
||||
scanner.Buffer(nil, 20_971_520)
|
||||
var param any
|
||||
for scanner.Scan() {
|
||||
line := scanner.Bytes()
|
||||
|
||||
@@ -289,7 +289,7 @@ func buildShortNameMap(names []string) map[string]string {
|
||||
}
|
||||
base := cand
|
||||
for i := 1; ; i++ {
|
||||
suffix := "~" + strconv.Itoa(i)
|
||||
suffix := "_" + strconv.Itoa(i)
|
||||
allowed := limit - len(suffix)
|
||||
if allowed < 0 {
|
||||
allowed = 0
|
||||
|
||||
@@ -310,7 +310,7 @@ func buildShortNameMap(names []string) map[string]string {
|
||||
}
|
||||
base := cand
|
||||
for i := 1; ; i++ {
|
||||
suffix := "~" + strconv.Itoa(i)
|
||||
suffix := "_" + strconv.Itoa(i)
|
||||
allowed := limit - len(suffix)
|
||||
if allowed < 0 {
|
||||
allowed = 0
|
||||
|
||||
@@ -361,7 +361,7 @@ func buildShortNameMap(names []string) map[string]string {
|
||||
}
|
||||
base := cand
|
||||
for i := 1; ; i++ {
|
||||
suffix := "~" + strconv.Itoa(i)
|
||||
suffix := "_" + strconv.Itoa(i)
|
||||
allowed := limit - len(suffix)
|
||||
if allowed < 0 {
|
||||
allowed = 0
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiCLIClaudeThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// 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
|
||||
// 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()
|
||||
var args map[string]any
|
||||
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" {
|
||||
toolCallID := contentResult.Get("tool_use_id").String()
|
||||
@@ -128,6 +133,7 @@ func ConvertClaudeRequestToCLI(modelName string, inputRawJSON []byte, _ bool) []
|
||||
tool, _ := sjson.Delete(toolResult.Raw, "input_schema")
|
||||
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
||||
tool, _ = sjson.Delete(tool, "strict")
|
||||
tool, _ = sjson.Delete(tool, "input_examples")
|
||||
var toolDeclaration any
|
||||
if err := json.Unmarshal([]byte(tool), &toolDeclaration); err == nil {
|
||||
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, toolDeclaration)
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiCLIFunctionThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// 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.
|
||||
//
|
||||
@@ -239,6 +241,7 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
|
||||
fargs := tc.Get("function.arguments").String()
|
||||
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
|
||||
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
|
||||
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiCLIFunctionThoughtSignature)
|
||||
p++
|
||||
if fid != "" {
|
||||
fIDs = append(fIDs, fid)
|
||||
@@ -283,6 +286,17 @@ func ConvertOpenAIRequestToGeminiCLI(modelName string, inputRawJSON []byte, _ bo
|
||||
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
|
||||
if errRename != nil {
|
||||
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
|
||||
var errSet error
|
||||
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 {
|
||||
fnRaw = renamed
|
||||
}
|
||||
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiClaudeThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// ConvertClaudeRequestToGemini parses a Claude API request and returns a complete
|
||||
// Gemini CLI request body (as JSON bytes) ready to be sent via SendRawMessageStream.
|
||||
// All JSON transformations are performed using gjson/sjson.
|
||||
@@ -82,7 +84,10 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
functionArgs := contentResult.Get("input").String()
|
||||
var args map[string]any
|
||||
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" {
|
||||
toolCallID := contentResult.Get("tool_use_id").String()
|
||||
@@ -121,6 +126,7 @@ func ConvertClaudeRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
tool, _ := sjson.Delete(toolResult.Raw, "input_schema")
|
||||
tool, _ = sjson.SetRaw(tool, "parametersJsonSchema", inputSchema)
|
||||
tool, _ = sjson.Delete(tool, "strict")
|
||||
tool, _ = sjson.Delete(tool, "input_examples")
|
||||
var toolDeclaration any
|
||||
if err := json.Unmarshal([]byte(tool), &toolDeclaration); err == nil {
|
||||
tools[0].FunctionDeclarations = append(tools[0].FunctionDeclarations, toolDeclaration)
|
||||
|
||||
@@ -15,6 +15,8 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiFunctionThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
// ConvertOpenAIRequestToGemini converts an OpenAI Chat Completions request (raw JSON)
|
||||
// into a complete Gemini request JSON. All JSON construction uses sjson and lookups use gjson.
|
||||
//
|
||||
@@ -264,6 +266,7 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
fargs := tc.Get("function.arguments").String()
|
||||
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".functionCall.name", fname)
|
||||
node, _ = sjson.SetRawBytes(node, "parts."+itoa(p)+".functionCall.args", []byte(fargs))
|
||||
node, _ = sjson.SetBytes(node, "parts."+itoa(p)+".thoughtSignature", geminiFunctionThoughtSignature)
|
||||
p++
|
||||
if fid != "" {
|
||||
fIDs = append(fIDs, fid)
|
||||
@@ -303,8 +306,34 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
|
||||
if t.Get("type").String() == "function" {
|
||||
fn := t.Get("function")
|
||||
if fn.Exists() && fn.IsObject() {
|
||||
parametersJsonSchema, _ := util.RenameKey(fn.Raw, "parameters", "parametersJsonSchema")
|
||||
out, _ = sjson.SetRawBytes(out, fdPath+".-1", []byte(parametersJsonSchema))
|
||||
fnRaw := fn.Raw
|
||||
if fn.Get("parameters").Exists() {
|
||||
renamed, errRename := util.RenameKey(fnRaw, "parameters", "parametersJsonSchema")
|
||||
if errRename != nil {
|
||||
log.Warnf("Failed to rename parameters for tool '%s': %v", fn.Get("name").String(), errRename)
|
||||
} 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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,8 @@ import (
|
||||
"github.com/tidwall/sjson"
|
||||
)
|
||||
|
||||
const geminiResponsesThoughtSignature = "skip_thought_signature_validator"
|
||||
|
||||
func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte, stream bool) []byte {
|
||||
rawJSON := bytes.Clone(inputRawJSON)
|
||||
|
||||
@@ -108,6 +110,7 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
||||
modelContent := `{"role":"model","parts":[]}`
|
||||
functionCall := `{"functionCall":{"name":"","args":{}}}`
|
||||
functionCall, _ = sjson.Set(functionCall, "functionCall.name", name)
|
||||
functionCall, _ = sjson.Set(functionCall, "thoughtSignature", geminiResponsesThoughtSignature)
|
||||
|
||||
// Parse arguments JSON string and set as args object
|
||||
if arguments != "" {
|
||||
@@ -156,6 +159,11 @@ func ConvertOpenAIResponsesRequestToGemini(modelName string, inputRawJSON []byte
|
||||
|
||||
return true
|
||||
})
|
||||
} else if input.Exists() && input.Type == gjson.String {
|
||||
// Simple string input conversion to user message
|
||||
userContent := `{"role":"user","parts":[{"text":""}]}`
|
||||
userContent, _ = sjson.Set(userContent, "parts.0.text", input.String())
|
||||
out, _ = sjson.SetRaw(out, "contents.-1", userContent)
|
||||
}
|
||||
|
||||
// Convert tools to Gemini functionDeclarations format
|
||||
|
||||
@@ -32,6 +32,8 @@ type ConvertOpenAIResponseToAnthropicParams struct {
|
||||
ToolCallsAccumulator map[int]*ToolCallAccumulator
|
||||
// Track if text content block has been started
|
||||
TextContentBlockStarted bool
|
||||
// Track if thinking content block has been started
|
||||
ThinkingContentBlockStarted bool
|
||||
// Track finish reason for later use
|
||||
FinishReason string
|
||||
// Track if content blocks have been stopped
|
||||
@@ -40,6 +42,16 @@ type ConvertOpenAIResponseToAnthropicParams struct {
|
||||
MessageDeltaSent bool
|
||||
// Track if message_start has been sent
|
||||
MessageStarted bool
|
||||
// Track if message_stop has been sent
|
||||
MessageStopSent bool
|
||||
// Tool call content block index mapping
|
||||
ToolCallBlockIndexes map[int]int
|
||||
// Index assigned to text content block
|
||||
TextContentBlockIndex int
|
||||
// Index assigned to thinking content block
|
||||
ThinkingContentBlockIndex int
|
||||
// Next available content block index
|
||||
NextContentBlockIndex int
|
||||
}
|
||||
|
||||
// ToolCallAccumulator holds the state for accumulating tool call data
|
||||
@@ -64,15 +76,20 @@ type ToolCallAccumulator struct {
|
||||
func ConvertOpenAIResponseToClaude(_ context.Context, _ string, originalRequestRawJSON, requestRawJSON, rawJSON []byte, param *any) []string {
|
||||
if *param == nil {
|
||||
*param = &ConvertOpenAIResponseToAnthropicParams{
|
||||
MessageID: "",
|
||||
Model: "",
|
||||
CreatedAt: 0,
|
||||
ContentAccumulator: strings.Builder{},
|
||||
ToolCallsAccumulator: nil,
|
||||
TextContentBlockStarted: false,
|
||||
FinishReason: "",
|
||||
ContentBlocksStopped: false,
|
||||
MessageDeltaSent: false,
|
||||
MessageID: "",
|
||||
Model: "",
|
||||
CreatedAt: 0,
|
||||
ContentAccumulator: strings.Builder{},
|
||||
ToolCallsAccumulator: nil,
|
||||
TextContentBlockStarted: false,
|
||||
ThinkingContentBlockStarted: false,
|
||||
FinishReason: "",
|
||||
ContentBlocksStopped: false,
|
||||
MessageDeltaSent: false,
|
||||
ToolCallBlockIndexes: make(map[int]int),
|
||||
TextContentBlockIndex: -1,
|
||||
ThinkingContentBlockIndex: -1,
|
||||
NextContentBlockIndex: 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -138,13 +155,56 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
// Don't send content_block_start for text here - wait for actual content
|
||||
}
|
||||
|
||||
// Handle reasoning content delta
|
||||
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
|
||||
for _, reasoningText := range collectOpenAIReasoningTexts(reasoning) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
stopTextContentBlock(param, &results)
|
||||
if !param.ThinkingContentBlockStarted {
|
||||
if param.ThinkingContentBlockIndex == -1 {
|
||||
param.ThinkingContentBlockIndex = param.NextContentBlockIndex
|
||||
param.NextContentBlockIndex++
|
||||
}
|
||||
contentBlockStart := map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": param.ThinkingContentBlockIndex,
|
||||
"content_block": map[string]interface{}{
|
||||
"type": "thinking",
|
||||
"thinking": "",
|
||||
},
|
||||
}
|
||||
contentBlockStartJSON, _ := json.Marshal(contentBlockStart)
|
||||
results = append(results, "event: content_block_start\ndata: "+string(contentBlockStartJSON)+"\n\n")
|
||||
param.ThinkingContentBlockStarted = true
|
||||
}
|
||||
|
||||
thinkingDelta := map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": param.ThinkingContentBlockIndex,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "thinking_delta",
|
||||
"thinking": reasoningText,
|
||||
},
|
||||
}
|
||||
thinkingDeltaJSON, _ := json.Marshal(thinkingDelta)
|
||||
results = append(results, "event: content_block_delta\ndata: "+string(thinkingDeltaJSON)+"\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content delta
|
||||
if content := delta.Get("content"); content.Exists() && content.String() != "" {
|
||||
// Send content_block_start for text if not already sent
|
||||
if !param.TextContentBlockStarted {
|
||||
stopThinkingContentBlock(param, &results)
|
||||
if param.TextContentBlockIndex == -1 {
|
||||
param.TextContentBlockIndex = param.NextContentBlockIndex
|
||||
param.NextContentBlockIndex++
|
||||
}
|
||||
contentBlockStart := map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": 0,
|
||||
"index": param.TextContentBlockIndex,
|
||||
"content_block": map[string]interface{}{
|
||||
"type": "text",
|
||||
"text": "",
|
||||
@@ -157,7 +217,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
|
||||
contentDelta := map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": 0,
|
||||
"index": param.TextContentBlockIndex,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "text_delta",
|
||||
"text": content.String(),
|
||||
@@ -178,6 +238,7 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
|
||||
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
|
||||
index := int(toolCall.Get("index").Int())
|
||||
blockIndex := param.toolContentBlockIndex(index)
|
||||
|
||||
// Initialize accumulator if needed
|
||||
if _, exists := param.ToolCallsAccumulator[index]; !exists {
|
||||
@@ -196,20 +257,14 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
if name := function.Get("name"); name.Exists() {
|
||||
accumulator.Name = name.String()
|
||||
|
||||
if param.TextContentBlockStarted {
|
||||
param.TextContentBlockStarted = false
|
||||
contentBlockStop := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": index,
|
||||
}
|
||||
contentBlockStopJSON, _ := json.Marshal(contentBlockStop)
|
||||
results = append(results, "event: content_block_stop\ndata: "+string(contentBlockStopJSON)+"\n\n")
|
||||
}
|
||||
stopThinkingContentBlock(param, &results)
|
||||
|
||||
stopTextContentBlock(param, &results)
|
||||
|
||||
// Send content_block_start for tool_use
|
||||
contentBlockStart := map[string]interface{}{
|
||||
"type": "content_block_start",
|
||||
"index": index + 1, // Offset by 1 since text is at index 0
|
||||
"index": blockIndex,
|
||||
"content_block": map[string]interface{}{
|
||||
"type": "tool_use",
|
||||
"id": accumulator.ID,
|
||||
@@ -240,26 +295,32 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
reason := finishReason.String()
|
||||
param.FinishReason = reason
|
||||
|
||||
// Send content_block_stop for text if text content block was started
|
||||
if param.TextContentBlockStarted && !param.ContentBlocksStopped {
|
||||
// Send content_block_stop for thinking content if needed
|
||||
if param.ThinkingContentBlockStarted {
|
||||
contentBlockStop := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": 0,
|
||||
"index": param.ThinkingContentBlockIndex,
|
||||
}
|
||||
contentBlockStopJSON, _ := json.Marshal(contentBlockStop)
|
||||
results = append(results, "event: content_block_stop\ndata: "+string(contentBlockStopJSON)+"\n\n")
|
||||
param.ThinkingContentBlockStarted = false
|
||||
param.ThinkingContentBlockIndex = -1
|
||||
}
|
||||
|
||||
// Send content_block_stop for text if text content block was started
|
||||
stopTextContentBlock(param, &results)
|
||||
|
||||
// Send content_block_stop for any tool calls
|
||||
if !param.ContentBlocksStopped {
|
||||
for index := range param.ToolCallsAccumulator {
|
||||
accumulator := param.ToolCallsAccumulator[index]
|
||||
blockIndex := param.toolContentBlockIndex(index)
|
||||
|
||||
// Send complete input_json_delta with all accumulated arguments
|
||||
if accumulator.Arguments.Len() > 0 {
|
||||
inputDelta := map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": index + 1,
|
||||
"index": blockIndex,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "input_json_delta",
|
||||
"partial_json": util.FixJSON(accumulator.Arguments.String()),
|
||||
@@ -271,10 +332,11 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
|
||||
contentBlockStop := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": index + 1,
|
||||
"index": blockIndex,
|
||||
}
|
||||
contentBlockStopJSON, _ := json.Marshal(contentBlockStop)
|
||||
results = append(results, "event: content_block_stop\ndata: "+string(contentBlockStopJSON)+"\n\n")
|
||||
delete(param.ToolCallBlockIndexes, index)
|
||||
}
|
||||
param.ContentBlocksStopped = true
|
||||
}
|
||||
@@ -284,29 +346,38 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
|
||||
// Handle usage information separately (this comes in a later chunk)
|
||||
// Only process if usage has actual values (not null)
|
||||
if usage := root.Get("usage"); usage.Exists() && usage.Type != gjson.Null && param.FinishReason != "" {
|
||||
// Check if usage has actual token counts
|
||||
promptTokens := usage.Get("prompt_tokens")
|
||||
completionTokens := usage.Get("completion_tokens")
|
||||
if param.FinishReason != "" {
|
||||
usage := root.Get("usage")
|
||||
var inputTokens, outputTokens int64
|
||||
if usage.Exists() && usage.Type != gjson.Null {
|
||||
// Check if usage has actual token counts
|
||||
promptTokens := usage.Get("prompt_tokens")
|
||||
completionTokens := usage.Get("completion_tokens")
|
||||
|
||||
if promptTokens.Exists() && completionTokens.Exists() {
|
||||
// Send message_delta with usage
|
||||
messageDelta := map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{
|
||||
"stop_reason": mapOpenAIFinishReasonToAnthropic(param.FinishReason),
|
||||
"stop_sequence": nil,
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": promptTokens.Int(),
|
||||
"output_tokens": completionTokens.Int(),
|
||||
},
|
||||
if promptTokens.Exists() && completionTokens.Exists() {
|
||||
inputTokens = promptTokens.Int()
|
||||
outputTokens = completionTokens.Int()
|
||||
}
|
||||
|
||||
messageDeltaJSON, _ := json.Marshal(messageDelta)
|
||||
results = append(results, "event: message_delta\ndata: "+string(messageDeltaJSON)+"\n\n")
|
||||
param.MessageDeltaSent = true
|
||||
}
|
||||
// Send message_delta with usage
|
||||
messageDelta := map[string]interface{}{
|
||||
"type": "message_delta",
|
||||
"delta": map[string]interface{}{
|
||||
"stop_reason": mapOpenAIFinishReasonToAnthropic(param.FinishReason),
|
||||
"stop_sequence": nil,
|
||||
},
|
||||
"usage": map[string]interface{}{
|
||||
"input_tokens": inputTokens,
|
||||
"output_tokens": outputTokens,
|
||||
},
|
||||
}
|
||||
|
||||
messageDeltaJSON, _ := json.Marshal(messageDelta)
|
||||
results = append(results, "event: message_delta\ndata: "+string(messageDeltaJSON)+"\n\n")
|
||||
param.MessageDeltaSent = true
|
||||
|
||||
emitMessageStopIfNeeded(param, &results)
|
||||
|
||||
}
|
||||
|
||||
return results
|
||||
@@ -316,6 +387,49 @@ func convertOpenAIStreamingChunkToAnthropic(rawJSON []byte, param *ConvertOpenAI
|
||||
func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams) []string {
|
||||
var results []string
|
||||
|
||||
// Ensure all content blocks are stopped before final events
|
||||
if param.ThinkingContentBlockStarted {
|
||||
contentBlockStop := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": param.ThinkingContentBlockIndex,
|
||||
}
|
||||
contentBlockStopJSON, _ := json.Marshal(contentBlockStop)
|
||||
results = append(results, "event: content_block_stop\ndata: "+string(contentBlockStopJSON)+"\n\n")
|
||||
param.ThinkingContentBlockStarted = false
|
||||
param.ThinkingContentBlockIndex = -1
|
||||
}
|
||||
|
||||
stopTextContentBlock(param, &results)
|
||||
|
||||
if !param.ContentBlocksStopped {
|
||||
for index := range param.ToolCallsAccumulator {
|
||||
accumulator := param.ToolCallsAccumulator[index]
|
||||
blockIndex := param.toolContentBlockIndex(index)
|
||||
|
||||
if accumulator.Arguments.Len() > 0 {
|
||||
inputDelta := map[string]interface{}{
|
||||
"type": "content_block_delta",
|
||||
"index": blockIndex,
|
||||
"delta": map[string]interface{}{
|
||||
"type": "input_json_delta",
|
||||
"partial_json": util.FixJSON(accumulator.Arguments.String()),
|
||||
},
|
||||
}
|
||||
inputDeltaJSON, _ := json.Marshal(inputDelta)
|
||||
results = append(results, "event: content_block_delta\ndata: "+string(inputDeltaJSON)+"\n\n")
|
||||
}
|
||||
|
||||
contentBlockStop := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": blockIndex,
|
||||
}
|
||||
contentBlockStopJSON, _ := json.Marshal(contentBlockStop)
|
||||
results = append(results, "event: content_block_stop\ndata: "+string(contentBlockStopJSON)+"\n\n")
|
||||
delete(param.ToolCallBlockIndexes, index)
|
||||
}
|
||||
param.ContentBlocksStopped = true
|
||||
}
|
||||
|
||||
// If we haven't sent message_delta yet (no usage info was received), send it now
|
||||
if param.FinishReason != "" && !param.MessageDeltaSent {
|
||||
messageDelta := map[string]interface{}{
|
||||
@@ -331,8 +445,7 @@ func convertOpenAIDoneToAnthropic(param *ConvertOpenAIResponseToAnthropicParams)
|
||||
param.MessageDeltaSent = true
|
||||
}
|
||||
|
||||
// Send message_stop
|
||||
results = append(results, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||
emitMessageStopIfNeeded(param, &results)
|
||||
|
||||
return results
|
||||
}
|
||||
@@ -361,6 +474,18 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) []string {
|
||||
|
||||
if choices := root.Get("choices"); choices.Exists() && choices.IsArray() {
|
||||
choice := choices.Array()[0] // Take first choice
|
||||
reasoningNode := choice.Get("message.reasoning_content")
|
||||
allReasoning := collectOpenAIReasoningTexts(reasoningNode)
|
||||
|
||||
for _, reasoningText := range allReasoning {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "thinking",
|
||||
"thinking": reasoningText,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle text content
|
||||
if content := choice.Get("message.content"); content.Exists() && content.String() != "" {
|
||||
@@ -412,6 +537,17 @@ func convertOpenAINonStreamingToAnthropic(rawJSON []byte) []string {
|
||||
response["usage"] = map[string]interface{}{
|
||||
"input_tokens": usage.Get("prompt_tokens").Int(),
|
||||
"output_tokens": usage.Get("completion_tokens").Int(),
|
||||
"reasoning_tokens": func() int64 {
|
||||
if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
|
||||
return v.Int()
|
||||
}
|
||||
return 0
|
||||
}(),
|
||||
}
|
||||
} else {
|
||||
response["usage"] = map[string]interface{}{
|
||||
"input_tokens": 0,
|
||||
"output_tokens": 0,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -437,6 +573,84 @@ func mapOpenAIFinishReasonToAnthropic(openAIReason string) string {
|
||||
}
|
||||
}
|
||||
|
||||
func (p *ConvertOpenAIResponseToAnthropicParams) toolContentBlockIndex(openAIToolIndex int) int {
|
||||
if idx, ok := p.ToolCallBlockIndexes[openAIToolIndex]; ok {
|
||||
return idx
|
||||
}
|
||||
idx := p.NextContentBlockIndex
|
||||
p.NextContentBlockIndex++
|
||||
p.ToolCallBlockIndexes[openAIToolIndex] = idx
|
||||
return idx
|
||||
}
|
||||
|
||||
func collectOpenAIReasoningTexts(node gjson.Result) []string {
|
||||
var texts []string
|
||||
if !node.Exists() {
|
||||
return texts
|
||||
}
|
||||
|
||||
if node.IsArray() {
|
||||
node.ForEach(func(_, value gjson.Result) bool {
|
||||
texts = append(texts, collectOpenAIReasoningTexts(value)...)
|
||||
return true
|
||||
})
|
||||
return texts
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case gjson.String:
|
||||
if text := strings.TrimSpace(node.String()); text != "" {
|
||||
texts = append(texts, text)
|
||||
}
|
||||
case gjson.JSON:
|
||||
if text := node.Get("text"); text.Exists() {
|
||||
if trimmed := strings.TrimSpace(text.String()); trimmed != "" {
|
||||
texts = append(texts, trimmed)
|
||||
}
|
||||
} else if raw := strings.TrimSpace(node.Raw); raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
|
||||
texts = append(texts, raw)
|
||||
}
|
||||
}
|
||||
|
||||
return texts
|
||||
}
|
||||
|
||||
func stopThinkingContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results *[]string) {
|
||||
if !param.ThinkingContentBlockStarted {
|
||||
return
|
||||
}
|
||||
contentBlockStop := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": param.ThinkingContentBlockIndex,
|
||||
}
|
||||
contentBlockStopJSON, _ := json.Marshal(contentBlockStop)
|
||||
*results = append(*results, "event: content_block_stop\ndata: "+string(contentBlockStopJSON)+"\n\n")
|
||||
param.ThinkingContentBlockStarted = false
|
||||
param.ThinkingContentBlockIndex = -1
|
||||
}
|
||||
|
||||
func emitMessageStopIfNeeded(param *ConvertOpenAIResponseToAnthropicParams, results *[]string) {
|
||||
if param.MessageStopSent {
|
||||
return
|
||||
}
|
||||
*results = append(*results, "event: message_stop\ndata: {\"type\":\"message_stop\"}\n\n")
|
||||
param.MessageStopSent = true
|
||||
}
|
||||
|
||||
func stopTextContentBlock(param *ConvertOpenAIResponseToAnthropicParams, results *[]string) {
|
||||
if !param.TextContentBlockStarted {
|
||||
return
|
||||
}
|
||||
contentBlockStop := map[string]interface{}{
|
||||
"type": "content_block_stop",
|
||||
"index": param.TextContentBlockIndex,
|
||||
}
|
||||
contentBlockStopJSON, _ := json.Marshal(contentBlockStop)
|
||||
*results = append(*results, "event: content_block_stop\ndata: "+string(contentBlockStopJSON)+"\n\n")
|
||||
param.TextContentBlockStarted = false
|
||||
param.TextContentBlockIndex = -1
|
||||
}
|
||||
|
||||
// ConvertOpenAIResponseToClaudeNonStream converts a non-streaming OpenAI response to a non-streaming Anthropic response.
|
||||
//
|
||||
// Parameters:
|
||||
@@ -564,6 +778,18 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina
|
||||
}
|
||||
}
|
||||
|
||||
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
|
||||
for _, reasoningText := range collectOpenAIReasoningTexts(reasoning) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
contentBlocks = append(contentBlocks, map[string]interface{}{
|
||||
"type": "thinking",
|
||||
"thinking": reasoningText,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if toolCalls := message.Get("tool_calls"); toolCalls.Exists() && toolCalls.IsArray() {
|
||||
toolCalls.ForEach(func(_, toolCall gjson.Result) bool {
|
||||
hasToolCall = true
|
||||
@@ -601,6 +827,8 @@ func ConvertOpenAIResponseToClaudeNonStream(_ context.Context, _ string, origina
|
||||
usageJSON, _ = sjson.Set(usageJSON, "output_tokens", respUsage.Get("completion_tokens").Int())
|
||||
parsedUsage := gjson.Parse(usageJSON).Value().(map[string]interface{})
|
||||
response["usage"] = parsedUsage
|
||||
} else {
|
||||
response["usage"] = `{"input_tokens":0,"output_tokens":0}`
|
||||
}
|
||||
|
||||
if response["stop_reason"] == nil {
|
||||
|
||||
@@ -89,6 +89,9 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
|
||||
"candidatesTokenCount": usage.Get("completion_tokens").Int(),
|
||||
"totalTokenCount": usage.Get("total_tokens").Int(),
|
||||
}
|
||||
if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {
|
||||
usageObj["thoughtsTokenCount"] = reasoningTokens
|
||||
}
|
||||
template, _ = sjson.Set(template, "usageMetadata", usageObj)
|
||||
return []string{template}
|
||||
}
|
||||
@@ -108,6 +111,7 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
|
||||
|
||||
_ = int(choice.Get("index").Int()) // choiceIdx not used in streaming
|
||||
delta := choice.Get("delta")
|
||||
baseTemplate := template
|
||||
|
||||
// Handle role (only in first chunk)
|
||||
if role := delta.Get("role"); role.Exists() && (*param).(*ConvertOpenAIResponseToGeminiParams).IsFirstChunk {
|
||||
@@ -120,6 +124,26 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
|
||||
return true
|
||||
}
|
||||
|
||||
var chunkOutputs []string
|
||||
|
||||
// Handle reasoning/thinking delta
|
||||
if reasoning := delta.Get("reasoning_content"); reasoning.Exists() {
|
||||
for _, reasoningText := range extractReasoningTexts(reasoning) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
reasoningTemplate := baseTemplate
|
||||
parts := []interface{}{
|
||||
map[string]interface{}{
|
||||
"thought": true,
|
||||
"text": reasoningText,
|
||||
},
|
||||
}
|
||||
reasoningTemplate, _ = sjson.Set(reasoningTemplate, "candidates.0.content.parts", parts)
|
||||
chunkOutputs = append(chunkOutputs, reasoningTemplate)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content delta
|
||||
if content := delta.Get("content"); content.Exists() && content.String() != "" {
|
||||
contentText := content.String()
|
||||
@@ -131,8 +155,13 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
|
||||
"text": contentText,
|
||||
},
|
||||
}
|
||||
template, _ = sjson.Set(template, "candidates.0.content.parts", parts)
|
||||
results = append(results, template)
|
||||
contentTemplate := baseTemplate
|
||||
contentTemplate, _ = sjson.Set(contentTemplate, "candidates.0.content.parts", parts)
|
||||
chunkOutputs = append(chunkOutputs, contentTemplate)
|
||||
}
|
||||
|
||||
if len(chunkOutputs) > 0 {
|
||||
results = append(results, chunkOutputs...)
|
||||
return true
|
||||
}
|
||||
|
||||
@@ -231,6 +260,9 @@ func ConvertOpenAIResponseToGemini(_ context.Context, _ string, originalRequestR
|
||||
"candidatesTokenCount": usage.Get("completion_tokens").Int(),
|
||||
"totalTokenCount": usage.Get("total_tokens").Int(),
|
||||
}
|
||||
if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {
|
||||
usageObj["thoughtsTokenCount"] = reasoningTokens
|
||||
}
|
||||
template, _ = sjson.Set(template, "usageMetadata", usageObj)
|
||||
results = append(results, template)
|
||||
return true
|
||||
@@ -549,6 +581,19 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina
|
||||
|
||||
var parts []interface{}
|
||||
|
||||
// Handle reasoning content before visible text
|
||||
if reasoning := message.Get("reasoning_content"); reasoning.Exists() {
|
||||
for _, reasoningText := range extractReasoningTexts(reasoning) {
|
||||
if reasoningText == "" {
|
||||
continue
|
||||
}
|
||||
parts = append(parts, map[string]interface{}{
|
||||
"thought": true,
|
||||
"text": reasoningText,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Handle content first
|
||||
if content := message.Get("content"); content.Exists() && content.String() != "" {
|
||||
parts = append(parts, map[string]interface{}{
|
||||
@@ -605,6 +650,9 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina
|
||||
"candidatesTokenCount": usage.Get("completion_tokens").Int(),
|
||||
"totalTokenCount": usage.Get("total_tokens").Int(),
|
||||
}
|
||||
if reasoningTokens := reasoningTokensFromUsage(usage); reasoningTokens > 0 {
|
||||
usageObj["thoughtsTokenCount"] = reasoningTokens
|
||||
}
|
||||
out, _ = sjson.Set(out, "usageMetadata", usageObj)
|
||||
}
|
||||
|
||||
@@ -614,3 +662,43 @@ func ConvertOpenAIResponseToGeminiNonStream(_ context.Context, _ string, origina
|
||||
func GeminiTokenCount(ctx context.Context, count int64) string {
|
||||
return fmt.Sprintf(`{"totalTokens":%d,"promptTokensDetails":[{"modality":"TEXT","tokenCount":%d}]}`, count, count)
|
||||
}
|
||||
|
||||
func reasoningTokensFromUsage(usage gjson.Result) int64 {
|
||||
if usage.Exists() {
|
||||
if v := usage.Get("completion_tokens_details.reasoning_tokens"); v.Exists() {
|
||||
return v.Int()
|
||||
}
|
||||
if v := usage.Get("output_tokens_details.reasoning_tokens"); v.Exists() {
|
||||
return v.Int()
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func extractReasoningTexts(node gjson.Result) []string {
|
||||
var texts []string
|
||||
if !node.Exists() {
|
||||
return texts
|
||||
}
|
||||
|
||||
if node.IsArray() {
|
||||
node.ForEach(func(_, value gjson.Result) bool {
|
||||
texts = append(texts, extractReasoningTexts(value)...)
|
||||
return true
|
||||
})
|
||||
return texts
|
||||
}
|
||||
|
||||
switch node.Type {
|
||||
case gjson.String:
|
||||
texts = append(texts, node.String())
|
||||
case gjson.JSON:
|
||||
if text := node.Get("text"); text.Exists() {
|
||||
texts = append(texts, text.String())
|
||||
} else if raw := strings.TrimSpace(node.Raw); raw != "" && !strings.HasPrefix(raw, "{") && !strings.HasPrefix(raw, "[") {
|
||||
texts = append(texts, raw)
|
||||
}
|
||||
}
|
||||
|
||||
return texts
|
||||
}
|
||||
|
||||
@@ -152,11 +152,29 @@ func (a *Auth) AccountInfo() (string, string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
if a.Metadata != nil {
|
||||
if v, ok := a.Metadata["email"].(string); ok {
|
||||
return "oauth", v
|
||||
|
||||
// For iFlow provider, prioritize OAuth type if email is present
|
||||
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 v := a.Attributes["api_key"]; v != "" {
|
||||
return "api_key", v
|
||||
@@ -259,6 +277,7 @@ func parseTimeValue(v any) (time.Time, bool) {
|
||||
time.RFC3339,
|
||||
time.RFC3339Nano,
|
||||
"2006-01-02 15:04:05",
|
||||
"2006-01-02 15:04",
|
||||
"2006-01-02T15:04:05Z07:00",
|
||||
}
|
||||
for _, layout := range layouts {
|
||||
|
||||
Reference in New Issue
Block a user