Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0449fefa60 | ||
|
|
156e3b017d | ||
|
|
d4dc7b0a34 | ||
|
|
ebf2a26e72 | ||
|
|
545dff8b64 | ||
|
|
7353bc0b2b | ||
|
|
99c9f3069c | ||
|
|
f9f2333997 | ||
|
|
179b8aa88f | ||
|
|
040d66f0bb |
1
.gitignore
vendored
1
.gitignore
vendored
@@ -3,3 +3,4 @@ docs/*
|
|||||||
logs/*
|
logs/*
|
||||||
auths/*
|
auths/*
|
||||||
!auths/.gitkeep
|
!auths/.gitkeep
|
||||||
|
AGENTS.md
|
||||||
@@ -514,6 +514,56 @@ Manage JSON token files under `auth-dir`: list, download, upload, delete.
|
|||||||
{ "status": "ok", "deleted": 3 }
|
{ "status": "ok", "deleted": 3 }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Login/OAuth URLs
|
||||||
|
|
||||||
|
These endpoints initiate provider login flows and return a URL to open in a browser. Tokens are saved under `auths/` once the flow completes.
|
||||||
|
|
||||||
|
- GET `/anthropic-auth-url` — Start Anthropic (Claude) login
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/anthropic-auth-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/codex-auth-url` — Start Codex login
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/codex-auth-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/gemini-cli-auth-url` — Start Google (Gemini CLI) login
|
||||||
|
- Query params:
|
||||||
|
- `project_id` (optional): Google Cloud project ID.
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=<PROJECT_ID>'
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/qwen-auth-url` — Start Qwen login (device flow)
|
||||||
|
- Request:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/qwen-auth-url
|
||||||
|
```
|
||||||
|
- Response:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
## Error Responses
|
## Error Responses
|
||||||
|
|
||||||
Generic error format:
|
Generic error format:
|
||||||
@@ -527,4 +577,3 @@ Generic error format:
|
|||||||
|
|
||||||
- Changes are written back to the YAML config file and hot‑reloaded by the file watcher and clients.
|
- Changes are written back to the YAML config file and hot‑reloaded by the file watcher and clients.
|
||||||
- `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file.
|
- `allow-remote-management` and `remote-management-key` cannot be changed via the API; configure them in the config file.
|
||||||
|
|
||||||
|
|||||||
@@ -514,6 +514,56 @@
|
|||||||
{ "status": "ok", "deleted": 3 }
|
{ "status": "ok", "deleted": 3 }
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### 登录/授权 URL
|
||||||
|
|
||||||
|
以下端点用于发起各提供商的登录流程,并返回需要在浏览器中打开的 URL。流程完成后,令牌会保存到 `auths/` 目录。
|
||||||
|
|
||||||
|
- GET `/anthropic-auth-url` — 开始 Anthropic(Claude)登录
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/anthropic-auth-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/codex-auth-url` — 开始 Codex 登录
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/codex-auth-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/gemini-cli-auth-url` — 开始 Google(Gemini CLI)登录
|
||||||
|
- 查询参数:
|
||||||
|
- `project_id`(可选):Google Cloud 项目 ID。
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
'http://localhost:8317/v0/management/gemini-cli-auth-url?project_id=<PROJECT_ID>'
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
|
- GET `/qwen-auth-url` — 开始 Qwen 登录(设备授权流程)
|
||||||
|
- 请求:
|
||||||
|
```bash
|
||||||
|
curl -H 'Authorization: Bearer <MANAGEMENT_KEY>' \
|
||||||
|
http://localhost:8317/v0/management/qwen-auth-url
|
||||||
|
```
|
||||||
|
- 响应:
|
||||||
|
```json
|
||||||
|
{ "status": "ok", "url": "https://..." }
|
||||||
|
```
|
||||||
|
|
||||||
## 错误响应
|
## 错误响应
|
||||||
|
|
||||||
通用错误格式:
|
通用错误格式:
|
||||||
@@ -527,4 +577,3 @@
|
|||||||
|
|
||||||
- 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。
|
- 变更会写回 YAML 配置文件,并由文件监控器热重载配置与客户端。
|
||||||
- `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。
|
- `allow-remote-management` 与 `remote-management-key` 不能通过 API 修改,需在配置文件中设置。
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,33 @@
|
|||||||
package management
|
package management
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/auth/claude"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
||||||
|
geminiAuth "github.com/luispater/CLIProxyAPI/internal/auth/gemini"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/auth/qwen"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/misc"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
|
"golang.org/x/oauth2"
|
||||||
|
"golang.org/x/oauth2/google"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
oauthStatus = make(map[string]string)
|
||||||
)
|
)
|
||||||
|
|
||||||
// List auth files
|
// List auth files
|
||||||
@@ -147,3 +166,579 @@ func (h *Handler) DeleteAuthFile(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
c.JSON(200, gin.H{"status": "ok"})
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestAnthropicToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
log.Info("Initializing Claude authentication...")
|
||||||
|
|
||||||
|
// Generate PKCE codes
|
||||||
|
pkceCodes, err := claude.GeneratePKCECodes()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate PKCE codes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random state parameter
|
||||||
|
state, err := misc.GenerateRandomState()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate state parameter: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Claude auth service
|
||||||
|
anthropicAuth := claude.NewClaudeAuth(h.cfg)
|
||||||
|
|
||||||
|
// Generate authorization URL (then override redirect_uri to reuse server port)
|
||||||
|
authURL, state, err := anthropicAuth.GenerateAuthURL(state, pkceCodes)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate authorization URL: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Override redirect_uri in authorization URL to current server port
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Helper: wait for callback file
|
||||||
|
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-anthropic-%s.oauth", state))
|
||||||
|
waitForFile := func(path string, timeout time.Duration) (map[string]string, error) {
|
||||||
|
deadline := time.Now().Add(timeout)
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
oauthStatus[state] = "Timeout waiting for OAuth callback"
|
||||||
|
return nil, fmt.Errorf("timeout waiting for OAuth callback")
|
||||||
|
}
|
||||||
|
data, errRead := os.ReadFile(path)
|
||||||
|
if errRead == nil {
|
||||||
|
var m map[string]string
|
||||||
|
_ = json.Unmarshal(data, &m)
|
||||||
|
_ = os.Remove(path)
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Waiting for authentication callback...")
|
||||||
|
// Wait up to 5 minutes
|
||||||
|
resultMap, errWait := waitForFile(waitFile, 5*time.Minute)
|
||||||
|
if errWait != nil {
|
||||||
|
authErr := claude.NewAuthenticationError(claude.ErrCallbackTimeout, errWait)
|
||||||
|
log.Error(claude.GetUserFriendlyMessage(authErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errStr := resultMap["error"]; errStr != "" {
|
||||||
|
oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest)
|
||||||
|
log.Error(claude.GetUserFriendlyMessage(oauthErr))
|
||||||
|
oauthStatus[state] = "Bad request"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if resultMap["state"] != state {
|
||||||
|
authErr := claude.NewAuthenticationError(claude.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, resultMap["state"]))
|
||||||
|
log.Error(claude.GetUserFriendlyMessage(authErr))
|
||||||
|
oauthStatus[state] = "State code error"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse code (Claude may append state after '#')
|
||||||
|
rawCode := resultMap["code"]
|
||||||
|
code := strings.Split(rawCode, "#")[0]
|
||||||
|
|
||||||
|
// Exchange code for tokens (replicate logic using updated redirect_uri)
|
||||||
|
// Extract client_id from the modified auth URL
|
||||||
|
clientID := ""
|
||||||
|
if u2, errP := url.Parse(authURL); errP == nil {
|
||||||
|
clientID = u2.Query().Get("client_id")
|
||||||
|
}
|
||||||
|
// Build request
|
||||||
|
bodyMap := map[string]any{
|
||||||
|
"code": code,
|
||||||
|
"state": state,
|
||||||
|
"grant_type": "authorization_code",
|
||||||
|
"client_id": clientID,
|
||||||
|
"redirect_uri": "http://localhost:54545/callback",
|
||||||
|
"code_verifier": pkceCodes.CodeVerifier,
|
||||||
|
}
|
||||||
|
bodyJSON, _ := json.Marshal(bodyMap)
|
||||||
|
|
||||||
|
httpClient := util.SetProxy(h.cfg, &http.Client{})
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", "https://console.anthropic.com/v1/oauth/token", strings.NewReader(string(bodyJSON)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errDo)
|
||||||
|
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
||||||
|
oauthStatus[state] = "Failed to exchange authorization code for tokens"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Errorf("failed to close response body: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
oauthStatus[state] = fmt.Sprintf("token exchange failed with status %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
Account struct {
|
||||||
|
EmailAddress string `json:"email_address"`
|
||||||
|
} `json:"account"`
|
||||||
|
}
|
||||||
|
if errU := json.Unmarshal(respBody, &tResp); errU != nil {
|
||||||
|
log.Errorf("failed to parse token response: %v", errU)
|
||||||
|
oauthStatus[state] = "Failed to parse token response"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
bundle := &claude.ClaudeAuthBundle{
|
||||||
|
TokenData: claude.ClaudeTokenData{
|
||||||
|
AccessToken: tResp.AccessToken,
|
||||||
|
RefreshToken: tResp.RefreshToken,
|
||||||
|
Email: tResp.Account.EmailAddress,
|
||||||
|
Expire: time.Now().Add(time.Duration(tResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
LastRefresh: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage
|
||||||
|
tokenStorage := anthropicAuth.CreateTokenStorage(bundle)
|
||||||
|
// Initialize Claude client
|
||||||
|
anthropicClient := client.NewClaudeClient(h.cfg, tokenStorage)
|
||||||
|
// Save token storage
|
||||||
|
if errSave := anthropicClient.SaveTokenToFile(); errSave != nil {
|
||||||
|
log.Fatalf("Failed to save authentication tokens: %v", errSave)
|
||||||
|
oauthStatus[state] = "Failed to save authentication tokens"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Authentication successful!")
|
||||||
|
if bundle.APIKey != "" {
|
||||||
|
log.Info("API key obtained and saved")
|
||||||
|
}
|
||||||
|
log.Info("You can now use Claude services through this CLI")
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
}()
|
||||||
|
|
||||||
|
oauthStatus[state] = ""
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Optional project ID from query
|
||||||
|
projectID := c.Query("project_id")
|
||||||
|
|
||||||
|
log.Info("Initializing Google authentication...")
|
||||||
|
|
||||||
|
// OAuth2 configuration (mirrors internal/auth/gemini)
|
||||||
|
conf := &oauth2.Config{
|
||||||
|
ClientID: "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com",
|
||||||
|
ClientSecret: "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",
|
||||||
|
RedirectURL: "http://localhost:8085/oauth2callback",
|
||||||
|
Scopes: []string{
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
},
|
||||||
|
Endpoint: google.Endpoint,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build authorization URL and return it immediately
|
||||||
|
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
||||||
|
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Wait for callback file written by server route
|
||||||
|
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-gemini-%s.oauth", state))
|
||||||
|
log.Info("Waiting for authentication callback...")
|
||||||
|
deadline := time.Now().Add(5 * time.Minute)
|
||||||
|
var authCode string
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
log.Error("oauth flow timed out")
|
||||||
|
oauthStatus[state] = "OAuth flow timed out"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data, errR := os.ReadFile(waitFile); errR == nil {
|
||||||
|
var m map[string]string
|
||||||
|
_ = json.Unmarshal(data, &m)
|
||||||
|
_ = os.Remove(waitFile)
|
||||||
|
if errStr := m["error"]; errStr != "" {
|
||||||
|
log.Errorf("Authentication failed: %s", errStr)
|
||||||
|
oauthStatus[state] = "Authentication failed"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authCode = m["code"]
|
||||||
|
if authCode == "" {
|
||||||
|
log.Errorf("Authentication failed: code not found")
|
||||||
|
oauthStatus[state] = "Authentication failed: code not found"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exchange authorization code for token
|
||||||
|
token, err := conf.Exchange(ctx, authCode)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("Failed to exchange token: %v", err)
|
||||||
|
oauthStatus[state] = "Failed to exchange token"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage (mirrors internal/auth/gemini createTokenStorage)
|
||||||
|
httpClient := conf.Client(ctx, token)
|
||||||
|
req, errNewRequest := http.NewRequestWithContext(ctx, "GET", "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
|
||||||
|
if errNewRequest != nil {
|
||||||
|
log.Errorf("Could not get user info: %v", errNewRequest)
|
||||||
|
oauthStatus[state] = "Could not get user info"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token.AccessToken))
|
||||||
|
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
log.Errorf("Failed to execute request: %v", errDo)
|
||||||
|
oauthStatus[state] = "Failed to execute request"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if errClose := resp.Body.Close(); errClose != nil {
|
||||||
|
log.Printf("warn: failed to close response body: %v", errClose)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
log.Errorf("Get user info request failed with status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||||
|
oauthStatus[state] = fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
email := gjson.GetBytes(bodyBytes, "email").String()
|
||||||
|
if email != "" {
|
||||||
|
log.Infof("Authenticated user email: %s", email)
|
||||||
|
} else {
|
||||||
|
log.Info("Failed to get user email from token")
|
||||||
|
oauthStatus[state] = "Failed to get user email from token"
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marshal/unmarshal oauth2.Token to generic map and enrich fields
|
||||||
|
var ifToken map[string]any
|
||||||
|
jsonData, _ := json.Marshal(token)
|
||||||
|
if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {
|
||||||
|
log.Errorf("Failed to unmarshal token: %v", errUnmarshal)
|
||||||
|
oauthStatus[state] = "Failed to unmarshal token"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ifToken["token_uri"] = "https://oauth2.googleapis.com/token"
|
||||||
|
ifToken["client_id"] = "681255809395-oo8ft2oprdrnp9e3aqf6av3hmdib135j.apps.googleusercontent.com"
|
||||||
|
ifToken["client_secret"] = "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"
|
||||||
|
ifToken["scopes"] = []string{
|
||||||
|
"https://www.googleapis.com/auth/cloud-platform",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.email",
|
||||||
|
"https://www.googleapis.com/auth/userinfo.profile",
|
||||||
|
}
|
||||||
|
ifToken["universe_domain"] = "googleapis.com"
|
||||||
|
|
||||||
|
ts := geminiAuth.GeminiTokenStorage{
|
||||||
|
Token: ifToken,
|
||||||
|
ProjectID: projectID,
|
||||||
|
Email: email,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize authenticated HTTP client via GeminiAuth to honor proxy settings
|
||||||
|
gemAuth := geminiAuth.NewGeminiAuth()
|
||||||
|
httpClient2, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true)
|
||||||
|
if errGetClient != nil {
|
||||||
|
log.Fatalf("failed to get authenticated client: %v", errGetClient)
|
||||||
|
oauthStatus[state] = "Failed to get authenticated client"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("Authentication successful.")
|
||||||
|
|
||||||
|
// Initialize the API client
|
||||||
|
cliClient := client.NewGeminiCLIClient(httpClient2, &ts, h.cfg)
|
||||||
|
|
||||||
|
// Perform the user setup process (migrated from DoLogin)
|
||||||
|
if err = cliClient.SetupUser(ctx, ts.Email, projectID); err != nil {
|
||||||
|
if err.Error() == "failed to start user onboarding, need define a project id" {
|
||||||
|
log.Error("Failed to start user onboarding: A project ID is required.")
|
||||||
|
oauthStatus[state] = "Failed to start user onboarding: A project ID is required"
|
||||||
|
project, errGetProjectList := cliClient.GetProjectList(ctx)
|
||||||
|
if errGetProjectList != nil {
|
||||||
|
log.Fatalf("Failed to get project list: %v", err)
|
||||||
|
oauthStatus[state] = "Failed to get project list"
|
||||||
|
} else {
|
||||||
|
log.Infof("Your account %s needs to specify a project ID.", ts.Email)
|
||||||
|
log.Info("========================================================================")
|
||||||
|
for _, p := range project.Projects {
|
||||||
|
log.Infof("Project ID: %s", p.ProjectID)
|
||||||
|
log.Infof("Project Name: %s", p.Name)
|
||||||
|
log.Info("------------------------------------------------------------------------")
|
||||||
|
}
|
||||||
|
log.Infof("Please run this command to login again with a specific project:\n\n%s --login --project_id <project_id>\n", os.Args[0])
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
log.Fatalf("Failed to complete user setup: %v", err)
|
||||||
|
oauthStatus[state] = "Failed to complete user setup"
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Post-setup checks and token persistence
|
||||||
|
auto := projectID == ""
|
||||||
|
cliClient.SetIsAuto(auto)
|
||||||
|
if !cliClient.IsChecked() && !cliClient.IsAuto() {
|
||||||
|
isChecked, checkErr := cliClient.CheckCloudAPIIsEnabled()
|
||||||
|
if checkErr != nil {
|
||||||
|
log.Fatalf("Failed to check if Cloud AI API is enabled: %v", checkErr)
|
||||||
|
oauthStatus[state] = "Failed to check if Cloud AI API is enabled"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cliClient.SetIsChecked(isChecked)
|
||||||
|
if !isChecked {
|
||||||
|
log.Fatal("Failed to check if Cloud AI API is enabled. If you encounter an error message, please create an issue.")
|
||||||
|
oauthStatus[state] = "Failed to check if Cloud AI API is enabled"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = cliClient.SaveTokenToFile(); err != nil {
|
||||||
|
log.Fatalf("Failed to save token to file: %v", err)
|
||||||
|
oauthStatus[state] = "Failed to save token to file"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
log.Info("You can now use Gemini CLI services through this CLI")
|
||||||
|
}()
|
||||||
|
|
||||||
|
oauthStatus[state] = ""
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestCodexToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
log.Info("Initializing Codex authentication...")
|
||||||
|
|
||||||
|
// Generate PKCE codes
|
||||||
|
pkceCodes, err := codex.GeneratePKCECodes()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate PKCE codes: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate random state parameter
|
||||||
|
state, err := misc.GenerateRandomState()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate state parameter: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize Codex auth service
|
||||||
|
openaiAuth := codex.NewCodexAuth(h.cfg)
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
authURL, err := openaiAuth.GenerateAuthURL(state, pkceCodes)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate authorization URL: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
// Wait for callback file
|
||||||
|
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-codex-%s.oauth", state))
|
||||||
|
deadline := time.Now().Add(5 * time.Minute)
|
||||||
|
var code string
|
||||||
|
for {
|
||||||
|
if time.Now().After(deadline) {
|
||||||
|
authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback"))
|
||||||
|
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
oauthStatus[state] = "Timeout waiting for OAuth callback"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if data, errR := os.ReadFile(waitFile); errR == nil {
|
||||||
|
var m map[string]string
|
||||||
|
_ = json.Unmarshal(data, &m)
|
||||||
|
_ = os.Remove(waitFile)
|
||||||
|
if errStr := m["error"]; errStr != "" {
|
||||||
|
oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest)
|
||||||
|
log.Error(codex.GetUserFriendlyMessage(oauthErr))
|
||||||
|
oauthStatus[state] = "Bad Request"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if m["state"] != state {
|
||||||
|
authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"]))
|
||||||
|
oauthStatus[state] = "State code error"
|
||||||
|
log.Error(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
code = m["code"]
|
||||||
|
break
|
||||||
|
}
|
||||||
|
time.Sleep(500 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug("Authorization code received, exchanging for tokens...")
|
||||||
|
// Extract client_id from authURL
|
||||||
|
clientID := ""
|
||||||
|
if u2, errP := url.Parse(authURL); errP == nil {
|
||||||
|
clientID = u2.Query().Get("client_id")
|
||||||
|
}
|
||||||
|
// Exchange code for tokens with redirect equal to mgmtRedirect
|
||||||
|
form := url.Values{
|
||||||
|
"grant_type": {"authorization_code"},
|
||||||
|
"client_id": {clientID},
|
||||||
|
"code": {code},
|
||||||
|
"redirect_uri": {"http://localhost:1455/auth/callback"},
|
||||||
|
"code_verifier": {pkceCodes.CodeVerifier},
|
||||||
|
}
|
||||||
|
httpClient := util.SetProxy(h.cfg, &http.Client{})
|
||||||
|
req, _ := http.NewRequestWithContext(ctx, "POST", "https://auth.openai.com/oauth/token", strings.NewReader(form.Encode()))
|
||||||
|
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||||
|
req.Header.Set("Accept", "application/json")
|
||||||
|
resp, errDo := httpClient.Do(req)
|
||||||
|
if errDo != nil {
|
||||||
|
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errDo)
|
||||||
|
oauthStatus[state] = "Failed to exchange authorization code for tokens"
|
||||||
|
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
respBody, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
oauthStatus[state] = fmt.Sprintf("Token exchange failed with status %d", resp.StatusCode)
|
||||||
|
log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var tokenResp struct {
|
||||||
|
AccessToken string `json:"access_token"`
|
||||||
|
RefreshToken string `json:"refresh_token"`
|
||||||
|
IDToken string `json:"id_token"`
|
||||||
|
ExpiresIn int `json:"expires_in"`
|
||||||
|
}
|
||||||
|
if errU := json.Unmarshal(respBody, &tokenResp); errU != nil {
|
||||||
|
oauthStatus[state] = "Failed to parse token response"
|
||||||
|
log.Errorf("failed to parse token response: %v", errU)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
claims, _ := codex.ParseJWTToken(tokenResp.IDToken)
|
||||||
|
email := ""
|
||||||
|
accountID := ""
|
||||||
|
if claims != nil {
|
||||||
|
email = claims.GetUserEmail()
|
||||||
|
accountID = claims.GetAccountID()
|
||||||
|
}
|
||||||
|
// Build bundle compatible with existing storage
|
||||||
|
bundle := &codex.CodexAuthBundle{
|
||||||
|
TokenData: codex.CodexTokenData{
|
||||||
|
IDToken: tokenResp.IDToken,
|
||||||
|
AccessToken: tokenResp.AccessToken,
|
||||||
|
RefreshToken: tokenResp.RefreshToken,
|
||||||
|
AccountID: accountID,
|
||||||
|
Email: email,
|
||||||
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
||||||
|
},
|
||||||
|
LastRefresh: time.Now().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage and persist
|
||||||
|
tokenStorage := openaiAuth.CreateTokenStorage(bundle)
|
||||||
|
openaiClient, errInit := client.NewCodexClient(h.cfg, tokenStorage)
|
||||||
|
if errInit != nil {
|
||||||
|
oauthStatus[state] = "Failed to initialize Codex client"
|
||||||
|
log.Fatalf("Failed to initialize Codex client: %v", errInit)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if errSave := openaiClient.SaveTokenToFile(); errSave != nil {
|
||||||
|
oauthStatus[state] = "Failed to save authentication tokens"
|
||||||
|
log.Fatalf("Failed to save authentication tokens: %v", errSave)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info("Authentication successful!")
|
||||||
|
if bundle.APIKey != "" {
|
||||||
|
log.Info("API key obtained and saved")
|
||||||
|
}
|
||||||
|
log.Info("You can now use Codex services through this CLI")
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
}()
|
||||||
|
|
||||||
|
oauthStatus[state] = ""
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) RequestQwenToken(c *gin.Context) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
log.Info("Initializing Qwen authentication...")
|
||||||
|
|
||||||
|
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
|
||||||
|
// Initialize Qwen auth service
|
||||||
|
qwenAuth := qwen.NewQwenAuth(h.cfg)
|
||||||
|
|
||||||
|
// Generate authorization URL
|
||||||
|
deviceFlow, err := qwenAuth.InitiateDeviceFlow(ctx)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("Failed to generate authorization URL: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authURL := deviceFlow.VerificationURIComplete
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
log.Info("Waiting for authentication...")
|
||||||
|
tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
|
||||||
|
if errPollForToken != nil {
|
||||||
|
oauthStatus[state] = "Authentication failed"
|
||||||
|
fmt.Printf("Authentication failed: %v\n", errPollForToken)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create token storage
|
||||||
|
tokenStorage := qwenAuth.CreateTokenStorage(tokenData)
|
||||||
|
|
||||||
|
// Initialize Qwen client
|
||||||
|
qwenClient := client.NewQwenClient(h.cfg, tokenStorage)
|
||||||
|
|
||||||
|
tokenStorage.Email = fmt.Sprintf("qwen-%d", time.Now().UnixMilli())
|
||||||
|
|
||||||
|
// Save token storage
|
||||||
|
if err = qwenClient.SaveTokenToFile(); err != nil {
|
||||||
|
log.Fatalf("Failed to save authentication tokens: %v", err)
|
||||||
|
oauthStatus[state] = "Failed to save authentication tokens"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Authentication successful!")
|
||||||
|
log.Info("You can now use Qwen services through this CLI")
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
}()
|
||||||
|
|
||||||
|
oauthStatus[state] = ""
|
||||||
|
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) GetAuthStatus(c *gin.Context) {
|
||||||
|
state := c.Query("state")
|
||||||
|
if err, ok := oauthStatus[state]; ok {
|
||||||
|
if err != "" {
|
||||||
|
c.JSON(200, gin.H{"status": "error", "error": err})
|
||||||
|
} else {
|
||||||
|
c.JSON(200, gin.H{"status": "wait"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
c.JSON(200, gin.H{"status": "ok"})
|
||||||
|
}
|
||||||
|
delete(oauthStatus, state)
|
||||||
|
}
|
||||||
|
|||||||
@@ -65,14 +65,17 @@ func (h *Handler) Middleware() gin.HandlerFunc {
|
|||||||
if provided == "" {
|
if provided == "" {
|
||||||
provided = c.GetHeader("X-Management-Key")
|
provided = c.GetHeader("X-Management-Key")
|
||||||
}
|
}
|
||||||
if provided == "" {
|
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil {
|
if !(clientIP == "127.0.0.1" || clientIP == "::1") {
|
||||||
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
|
if provided == "" {
|
||||||
return
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "missing management key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := bcrypt.CompareHashAndPassword([]byte(secret), []byte(provided)); err != nil {
|
||||||
|
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "invalid management key"})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
c.Next()
|
c.Next()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
@@ -145,6 +146,46 @@ func (s *Server) setupRoutes() {
|
|||||||
})
|
})
|
||||||
s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
|
s.engine.POST("/v1internal:method", geminiCLIHandlers.CLIHandler)
|
||||||
|
|
||||||
|
// OAuth callback endpoints (reuse main server port)
|
||||||
|
// These endpoints receive provider redirects and persist
|
||||||
|
// the short-lived code/state for the waiting goroutine.
|
||||||
|
s.engine.GET("/anthropic/callback", func(c *gin.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
state := c.Query("state")
|
||||||
|
errStr := c.Query("error")
|
||||||
|
// Persist to a temporary file keyed by state
|
||||||
|
if state != "" {
|
||||||
|
file := fmt.Sprintf("%s/.oauth-anthropic-%s.oauth", s.cfg.AuthDir, state)
|
||||||
|
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||||
|
})
|
||||||
|
|
||||||
|
s.engine.GET("/codex/callback", func(c *gin.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
state := c.Query("state")
|
||||||
|
errStr := c.Query("error")
|
||||||
|
if state != "" {
|
||||||
|
file := fmt.Sprintf("%s/.oauth-codex-%s.oauth", s.cfg.AuthDir, state)
|
||||||
|
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||||
|
})
|
||||||
|
|
||||||
|
s.engine.GET("/google/callback", func(c *gin.Context) {
|
||||||
|
code := c.Query("code")
|
||||||
|
state := c.Query("state")
|
||||||
|
errStr := c.Query("error")
|
||||||
|
if state != "" {
|
||||||
|
file := fmt.Sprintf("%s/.oauth-gemini-%s.oauth", s.cfg.AuthDir, state)
|
||||||
|
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
|
||||||
|
}
|
||||||
|
c.Header("Content-Type", "text/html; charset=utf-8")
|
||||||
|
c.String(http.StatusOK, "<html><body><h1>Authentication successful!</h1><p>You can close this window.</p></body></html>")
|
||||||
|
})
|
||||||
|
|
||||||
// Management API routes (delegated to management handlers)
|
// Management API routes (delegated to management handlers)
|
||||||
// New logic: if remote-management-key is empty, do not expose any management endpoint (404).
|
// New logic: if remote-management-key is empty, do not expose any management endpoint (404).
|
||||||
if s.cfg.RemoteManagement.SecretKey != "" {
|
if s.cfg.RemoteManagement.SecretKey != "" {
|
||||||
@@ -211,6 +252,12 @@ func (s *Server) setupRoutes() {
|
|||||||
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
||||||
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
||||||
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
mgmt.DELETE("/auth-files", s.mgmt.DeleteAuthFile)
|
||||||
|
|
||||||
|
mgmt.GET("/anthropic-auth-url", s.mgmt.RequestAnthropicToken)
|
||||||
|
mgmt.GET("/codex-auth-url", s.mgmt.RequestCodexToken)
|
||||||
|
mgmt.GET("/gemini-cli-auth-url", s.mgmt.RequestGeminiCLIToken)
|
||||||
|
mgmt.GET("/qwen-auth-url", s.mgmt.RequestQwenToken)
|
||||||
|
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -279,7 +326,7 @@ func corsMiddleware() gin.HandlerFunc {
|
|||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
c.Header("Access-Control-Allow-Origin", "*")
|
c.Header("Access-Control-Allow-Origin", "*")
|
||||||
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
|
||||||
c.Header("Access-Control-Allow-Headers", "Origin, Content-Type, Content-Length, Accept-Encoding, X-CSRF-Token, Authorization")
|
c.Header("Access-Control-Allow-Headers", "*")
|
||||||
|
|
||||||
if c.Request.Method == "OPTIONS" {
|
if c.Request.Method == "OPTIONS" {
|
||||||
c.AbortWithStatus(http.StatusNoContent)
|
c.AbortWithStatus(http.StatusNoContent)
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
"github.com/luispater/CLIProxyAPI/internal/auth/codex"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/browser"
|
"github.com/luispater/CLIProxyAPI/internal/browser"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"golang.org/x/net/proxy"
|
"golang.org/x/net/proxy"
|
||||||
@@ -250,11 +251,13 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
|||||||
// Check if browser is available
|
// Check if browser is available
|
||||||
if !browser.IsAvailable() {
|
if !browser.IsAvailable() {
|
||||||
log.Warn("No browser available on this system")
|
log.Warn("No browser available on this system")
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
} else {
|
} else {
|
||||||
if err := browser.OpenURL(authURL); err != nil {
|
if err := browser.OpenURL(authURL); err != nil {
|
||||||
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
||||||
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
|
||||||
// Log platform info for debugging
|
// Log platform info for debugging
|
||||||
@@ -265,6 +268,7 @@ func (g *GeminiAuth) getTokenFromWeb(ctx context.Context, config *oauth2.Config,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
util.PrintSSHTunnelInstructions(8085)
|
||||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -417,6 +417,7 @@ func (c *GeminiCLIClient) SendRawTokenCount(ctx context.Context, modelName strin
|
|||||||
if newModelName != "" {
|
if newModelName != "" {
|
||||||
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", modelName, newModelName)
|
log.Debugf("Model %s is quota exceeded. Switch to preview model %s", modelName, newModelName)
|
||||||
rawJSON, _ = sjson.SetBytes(rawJSON, "model", newModelName)
|
rawJSON, _ = sjson.SetBytes(rawJSON, "model", newModelName)
|
||||||
|
modelName = newModelName
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,7 +44,7 @@ type OpenAICompatibilityClient struct {
|
|||||||
// Returns:
|
// Returns:
|
||||||
// - *OpenAICompatibilityClient: A new OpenAI compatibility client instance.
|
// - *OpenAICompatibilityClient: A new OpenAI compatibility client instance.
|
||||||
// - error: An error if the client creation fails.
|
// - error: An error if the client creation fails.
|
||||||
func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenAICompatibility) (*OpenAICompatibilityClient, error) {
|
func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenAICompatibility, apiKeyIndex int) (*OpenAICompatibilityClient, error) {
|
||||||
if compatConfig == nil {
|
if compatConfig == nil {
|
||||||
return nil, fmt.Errorf("compatibility configuration is required")
|
return nil, fmt.Errorf("compatibility configuration is required")
|
||||||
}
|
}
|
||||||
@@ -53,10 +53,14 @@ func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenA
|
|||||||
return nil, fmt.Errorf("at least one API key is required for OpenAI compatibility provider: %s", compatConfig.Name)
|
return nil, fmt.Errorf("at least one API key is required for OpenAI compatibility provider: %s", compatConfig.Name)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(compatConfig.APIKeys) <= apiKeyIndex {
|
||||||
|
return nil, fmt.Errorf("invalid API key index for OpenAI compatibility provider: %s", compatConfig.Name)
|
||||||
|
}
|
||||||
|
|
||||||
httpClient := util.SetProxy(cfg, &http.Client{})
|
httpClient := util.SetProxy(cfg, &http.Client{})
|
||||||
|
|
||||||
// Generate unique client ID
|
// Generate unique client ID
|
||||||
clientID := fmt.Sprintf("openai-compatibility-%s-%d", compatConfig.Name, time.Now().UnixNano())
|
clientID := fmt.Sprintf("openai-compatibility-%s-%d-%d", compatConfig.Name, apiKeyIndex, time.Now().UnixNano())
|
||||||
|
|
||||||
client := &OpenAICompatibilityClient{
|
client := &OpenAICompatibilityClient{
|
||||||
ClientBase: ClientBase{
|
ClientBase: ClientBase{
|
||||||
@@ -66,7 +70,7 @@ func NewOpenAICompatibilityClient(cfg *config.Config, compatConfig *config.OpenA
|
|||||||
modelQuotaExceeded: make(map[string]*time.Time),
|
modelQuotaExceeded: make(map[string]*time.Time),
|
||||||
},
|
},
|
||||||
compatConfig: compatConfig,
|
compatConfig: compatConfig,
|
||||||
currentAPIKeyIndex: 0,
|
currentAPIKeyIndex: apiKeyIndex,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize model registry
|
// Initialize model registry
|
||||||
@@ -134,8 +138,6 @@ func (c *OpenAICompatibilityClient) GetCurrentAPIKey() string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
key := c.compatConfig.APIKeys[c.currentAPIKeyIndex]
|
key := c.compatConfig.APIKeys[c.currentAPIKeyIndex]
|
||||||
// Rotate to next key for load balancing
|
|
||||||
c.currentAPIKeyIndex = (c.currentAPIKeyIndex + 1) % len(c.compatConfig.APIKeys)
|
|
||||||
return key
|
return key
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ import (
|
|||||||
"github.com/luispater/CLIProxyAPI/internal/browser"
|
"github.com/luispater/CLIProxyAPI/internal/browser"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/misc"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -43,7 +45,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate random state parameter
|
// Generate random state parameter
|
||||||
state, err := generateRandomState()
|
state, err := misc.GenerateRandomState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to generate state parameter: %v", err)
|
log.Fatalf("Failed to generate state parameter: %v", err)
|
||||||
return
|
return
|
||||||
@@ -86,11 +88,13 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
// Check if browser is available
|
// Check if browser is available
|
||||||
if !browser.IsAvailable() {
|
if !browser.IsAvailable() {
|
||||||
log.Warn("No browser available on this system")
|
log.Warn("No browser available on this system")
|
||||||
|
util.PrintSSHTunnelInstructions(54545)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
} else {
|
} else {
|
||||||
if err = browser.OpenURL(authURL); err != nil {
|
if err = browser.OpenURL(authURL); err != nil {
|
||||||
authErr := claude.NewAuthenticationError(claude.ErrBrowserOpenFailed, err)
|
authErr := claude.NewAuthenticationError(claude.ErrBrowserOpenFailed, err)
|
||||||
log.Warn(claude.GetUserFriendlyMessage(authErr))
|
log.Warn(claude.GetUserFriendlyMessage(authErr))
|
||||||
|
util.PrintSSHTunnelInstructions(54545)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
|
||||||
// Log platform info for debugging
|
// Log platform info for debugging
|
||||||
@@ -101,6 +105,7 @@ func DoClaudeLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
util.PrintSSHTunnelInstructions(54545)
|
||||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ package cmd
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/rand"
|
|
||||||
"encoding/hex"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
@@ -17,6 +15,8 @@ import (
|
|||||||
"github.com/luispater/CLIProxyAPI/internal/browser"
|
"github.com/luispater/CLIProxyAPI/internal/browser"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/client"
|
"github.com/luispater/CLIProxyAPI/internal/client"
|
||||||
"github.com/luispater/CLIProxyAPI/internal/config"
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/misc"
|
||||||
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Generate random state parameter
|
// Generate random state parameter
|
||||||
state, err := generateRandomState()
|
state, err := misc.GenerateRandomState()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("Failed to generate state parameter: %v", err)
|
log.Fatalf("Failed to generate state parameter: %v", err)
|
||||||
return
|
return
|
||||||
@@ -94,11 +94,13 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
// Check if browser is available
|
// Check if browser is available
|
||||||
if !browser.IsAvailable() {
|
if !browser.IsAvailable() {
|
||||||
log.Warn("No browser available on this system")
|
log.Warn("No browser available on this system")
|
||||||
|
util.PrintSSHTunnelInstructions(1455)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
} else {
|
} else {
|
||||||
if err = browser.OpenURL(authURL); err != nil {
|
if err = browser.OpenURL(authURL); err != nil {
|
||||||
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
authErr := codex.NewAuthenticationError(codex.ErrBrowserOpenFailed, err)
|
||||||
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
log.Warn(codex.GetUserFriendlyMessage(authErr))
|
||||||
|
util.PrintSSHTunnelInstructions(1455)
|
||||||
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please manually open this URL in your browser:\n\n%s\n", authURL)
|
||||||
|
|
||||||
// Log platform info for debugging
|
// Log platform info for debugging
|
||||||
@@ -109,6 +111,7 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
util.PrintSSHTunnelInstructions(1455)
|
||||||
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
log.Infof("Please open this URL in your browser:\n\n%s\n", authURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,17 +176,3 @@ func DoCodexLogin(cfg *config.Config, options *LoginOptions) {
|
|||||||
|
|
||||||
log.Info("You can now use Codex services through this CLI")
|
log.Info("You can now use Codex services through this CLI")
|
||||||
}
|
}
|
||||||
|
|
||||||
// generateRandomState generates a cryptographically secure random state parameter
|
|
||||||
// for OAuth2 flows to prevent CSRF attacks.
|
|
||||||
//
|
|
||||||
// Returns:
|
|
||||||
// - string: A hexadecimal encoded random state string
|
|
||||||
// - error: An error if the random generation fails, nil otherwise
|
|
||||||
func generateRandomState() (string, error) {
|
|
||||||
bytes := make([]byte, 16)
|
|
||||||
if _, err := rand.Read(bytes); err != nil {
|
|
||||||
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
|
||||||
}
|
|
||||||
return hex.EncodeToString(bytes), nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -335,14 +335,16 @@ func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int,
|
|||||||
|
|
||||||
if len(cfg.OpenAICompatibility) > 0 {
|
if len(cfg.OpenAICompatibility) > 0 {
|
||||||
for _, compatConfig := range cfg.OpenAICompatibility {
|
for _, compatConfig := range cfg.OpenAICompatibility {
|
||||||
log.Debugf("Initializing OpenAI compatibility client for provider: %s", compatConfig.Name)
|
for i := 0; i < len(compatConfig.APIKeys); i++ {
|
||||||
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig)
|
log.Debugf("Initializing OpenAI compatibility client for provider: %s", compatConfig.Name)
|
||||||
if errClient != nil {
|
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig, i)
|
||||||
log.Errorf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
|
if errClient != nil {
|
||||||
continue
|
log.Errorf("failed to create OpenAI compatibility client for %s: %v", compatConfig.Name, errClient)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apiKeyClients[compatClient.GetClientID()] = compatClient
|
||||||
|
openAICompatCount++
|
||||||
}
|
}
|
||||||
apiKeyClients[compatClient.GetClientID()] = compatClient
|
|
||||||
openAICompatCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
21
internal/misc/oauth.go
Normal file
21
internal/misc/oauth.go
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
package misc
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
// GenerateRandomState generates a cryptographically secure random state parameter
|
||||||
|
// for OAuth2 flows to prevent CSRF attacks.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: A hexadecimal encoded random state string
|
||||||
|
// - error: An error if the random generation fails, nil otherwise
|
||||||
|
func GenerateRandomState() (string, error) {
|
||||||
|
bytes := make([]byte, 16)
|
||||||
|
if _, err := rand.Read(bytes); err != nil {
|
||||||
|
return "", fmt.Errorf("failed to generate random bytes: %w", err)
|
||||||
|
}
|
||||||
|
return hex.EncodeToString(bytes), nil
|
||||||
|
}
|
||||||
135
internal/util/ssh_helper.go
Normal file
135
internal/util/ssh_helper.go
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
// Package util provides helper functions for SSH tunnel instructions and network-related tasks.
|
||||||
|
// This includes detecting the appropriate IP address and printing commands
|
||||||
|
// to help users connect to the local server from a remote machine.
|
||||||
|
package util
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
log "github.com/sirupsen/logrus"
|
||||||
|
)
|
||||||
|
|
||||||
|
var ipServices = []string{
|
||||||
|
"https://api.ipify.org",
|
||||||
|
"https://ifconfig.me/ip",
|
||||||
|
"https://icanhazip.com",
|
||||||
|
"https://ipinfo.io/ip",
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPublicIP attempts to retrieve the public IP address from a list of external services.
|
||||||
|
// It iterates through the ipServices and returns the first successful response.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The public IP address as a string
|
||||||
|
// - error: An error if all services fail, nil otherwise
|
||||||
|
func getPublicIP() (string, error) {
|
||||||
|
for _, service := range ipServices {
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
req, err := http.NewRequestWithContext(ctx, "GET", service, nil)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to create request to %s: %v", service, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to get public IP from %s: %v", service, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := resp.Body.Close(); closeErr != nil {
|
||||||
|
log.Warnf("Failed to close response body from %s: %v", service, closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
log.Debugf("bad status code from %s: %d", service, resp.StatusCode)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
ip, err := io.ReadAll(resp.Body)
|
||||||
|
if err != nil {
|
||||||
|
log.Debugf("Failed to read response body from %s: %v", service, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(string(ip)), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("all IP services failed")
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOutboundIP retrieves the preferred outbound IP address of this machine.
|
||||||
|
// It uses a UDP connection to a public DNS server to determine the local IP
|
||||||
|
// address that would be used for outbound traffic.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The outbound IP address as a string
|
||||||
|
// - error: An error if the IP address cannot be determined, nil otherwise
|
||||||
|
func getOutboundIP() (string, error) {
|
||||||
|
conn, err := net.Dial("udp", "8.8.8.8:80")
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
if closeErr := conn.Close(); closeErr != nil {
|
||||||
|
log.Warnf("Failed to close UDP connection: %v", closeErr)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
localAddr, ok := conn.LocalAddr().(*net.UDPAddr)
|
||||||
|
if !ok {
|
||||||
|
return "", fmt.Errorf("could not assert UDP address type")
|
||||||
|
}
|
||||||
|
|
||||||
|
return localAddr.IP.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIPAddress attempts to find the best-available IP address.
|
||||||
|
// It first tries to get the public IP address, and if that fails,
|
||||||
|
// it falls back to getting the local outbound IP address.
|
||||||
|
//
|
||||||
|
// Returns:
|
||||||
|
// - string: The determined IP address (preferring public IPv4)
|
||||||
|
func GetIPAddress() string {
|
||||||
|
publicIP, err := getPublicIP()
|
||||||
|
if err == nil {
|
||||||
|
log.Debugf("Public IP detected: %s", publicIP)
|
||||||
|
return publicIP
|
||||||
|
}
|
||||||
|
log.Warnf("Failed to get public IP, falling back to outbound IP: %v", err)
|
||||||
|
outboundIP, err := getOutboundIP()
|
||||||
|
if err == nil {
|
||||||
|
log.Debugf("Outbound IP detected: %s", outboundIP)
|
||||||
|
return outboundIP
|
||||||
|
}
|
||||||
|
log.Errorf("Failed to get any IP address: %v", err)
|
||||||
|
return "127.0.0.1" // Fallback
|
||||||
|
}
|
||||||
|
|
||||||
|
// PrintSSHTunnelInstructions detects the IP address and prints SSH tunnel instructions
|
||||||
|
// for the user to connect to the local OAuth callback server from a remote machine.
|
||||||
|
//
|
||||||
|
// Parameters:
|
||||||
|
// - port: The local port number for the SSH tunnel
|
||||||
|
func PrintSSHTunnelInstructions(port int) {
|
||||||
|
ipAddress := GetIPAddress()
|
||||||
|
border := "================================================================================"
|
||||||
|
log.Infof("To authenticate from a remote machine, an SSH tunnel may be required.")
|
||||||
|
fmt.Println(border)
|
||||||
|
fmt.Println(" Run one of the following commands on your local machine (NOT the server):")
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" # Standard SSH command (assumes SSH port 22):\n")
|
||||||
|
fmt.Printf(" ssh -L %d:127.0.0.1:%d root@%s -p 22\n", port, port, ipAddress)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf(" # If using an SSH key (assumes SSH port 22):\n")
|
||||||
|
fmt.Printf(" ssh -i <path_to_your_key> -L %d:127.0.0.1:%d root@%s -p 22\n", port, port, ipAddress)
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Println(" NOTE: If your server's SSH port is not 22, please modify the '-p 22' part accordingly.")
|
||||||
|
fmt.Println(border)
|
||||||
|
}
|
||||||
@@ -41,6 +41,7 @@ type Watcher struct {
|
|||||||
reloadCallback func(map[string]interfaces.Client, *config.Config)
|
reloadCallback func(map[string]interfaces.Client, *config.Config)
|
||||||
watcher *fsnotify.Watcher
|
watcher *fsnotify.Watcher
|
||||||
lastAuthHashes map[string]string
|
lastAuthHashes map[string]string
|
||||||
|
lastConfigHash string
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewWatcher creates a new file watcher instance
|
// NewWatcher creates a new file watcher instance
|
||||||
@@ -136,9 +137,33 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
|||||||
|
|
||||||
// Handle config file changes
|
// Handle config file changes
|
||||||
if event.Name == w.configPath && (event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create) {
|
if event.Name == w.configPath && (event.Op&fsnotify.Write == fsnotify.Write || event.Op&fsnotify.Create == fsnotify.Create) {
|
||||||
log.Infof("config file changed, reloading: %s", w.configPath)
|
|
||||||
log.Debugf("config file change details - operation: %s, timestamp: %s", event.Op.String(), now.Format("2006-01-02 15:04:05.000"))
|
log.Debugf("config file change details - operation: %s, timestamp: %s", event.Op.String(), now.Format("2006-01-02 15:04:05.000"))
|
||||||
w.reloadConfig()
|
data, err := os.ReadFile(w.configPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Errorf("failed to read config file for hash check: %v", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(data) == 0 {
|
||||||
|
log.Debugf("ignoring empty config file write event")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
newHash := hex.EncodeToString(sum[:])
|
||||||
|
|
||||||
|
w.clientsMutex.RLock()
|
||||||
|
currentHash := w.lastConfigHash
|
||||||
|
w.clientsMutex.RUnlock()
|
||||||
|
|
||||||
|
if currentHash != "" && currentHash == newHash {
|
||||||
|
log.Debugf("config file content unchanged (hash match), skipping reload")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Infof("config file changed, reloading: %s", w.configPath)
|
||||||
|
if w.reloadConfig() {
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
w.lastConfigHash = newHash
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -154,13 +179,13 @@ func (w *Watcher) handleEvent(event fsnotify.Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// reloadConfig reloads the configuration and triggers a full reload
|
// reloadConfig reloads the configuration and triggers a full reload
|
||||||
func (w *Watcher) reloadConfig() {
|
func (w *Watcher) reloadConfig() bool {
|
||||||
log.Debugf("starting config reload from: %s", w.configPath)
|
log.Debugf("starting config reload from: %s", w.configPath)
|
||||||
|
|
||||||
newConfig, errLoadConfig := config.LoadConfig(w.configPath)
|
newConfig, errLoadConfig := config.LoadConfig(w.configPath)
|
||||||
if errLoadConfig != nil {
|
if errLoadConfig != nil {
|
||||||
log.Errorf("failed to reload config: %v", errLoadConfig)
|
log.Errorf("failed to reload config: %v", errLoadConfig)
|
||||||
return
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
w.clientsMutex.Lock()
|
w.clientsMutex.Lock()
|
||||||
@@ -220,6 +245,7 @@ func (w *Watcher) reloadConfig() {
|
|||||||
log.Infof("config successfully reloaded, triggering client reload")
|
log.Infof("config successfully reloaded, triggering client reload")
|
||||||
// Reload clients with new config
|
// Reload clients with new config
|
||||||
w.reloadClients()
|
w.reloadClients()
|
||||||
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// reloadClients performs a full scan and reload of all clients.
|
// reloadClients performs a full scan and reload of all clients.
|
||||||
@@ -549,13 +575,15 @@ func buildAPIKeyClients(cfg *config.Config) (map[string]interfaces.Client, int,
|
|||||||
}
|
}
|
||||||
if len(cfg.OpenAICompatibility) > 0 {
|
if len(cfg.OpenAICompatibility) > 0 {
|
||||||
for _, compatConfig := range cfg.OpenAICompatibility {
|
for _, compatConfig := range cfg.OpenAICompatibility {
|
||||||
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig)
|
for i := 0; i < len(compatConfig.APIKeys); i++ {
|
||||||
if errClient != nil {
|
compatClient, errClient := client.NewOpenAICompatibilityClient(cfg, &compatConfig, i)
|
||||||
log.Errorf("failed to create OpenAI-compatibility client for %s: %v", compatConfig.Name, errClient)
|
if errClient != nil {
|
||||||
continue
|
log.Errorf("failed to create OpenAI-compatibility client for %s: %v", compatConfig.Name, errClient)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
apiKeyClients[compatClient.GetClientID()] = compatClient
|
||||||
|
openAICompatCount++
|
||||||
}
|
}
|
||||||
apiKeyClients[compatClient.GetClientID()] = compatClient
|
|
||||||
openAICompatCount++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
|
return apiKeyClients, glAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
|
||||||
|
|||||||
Reference in New Issue
Block a user