Compare commits

...

20 Commits

Author SHA1 Message Date
Luis Pater
40d78908ed Merge branch 'router-for-me:main' into main 2025-12-20 04:50:39 +08:00
Luis Pater
8a5db02165 Fixed: #607
refactor(config): re-export internal configuration types for SDK consumers
2025-12-20 04:49:02 +08:00
Luis Pater
56fa81f3c6 Merge branch 'router-for-me:main' into main 2025-12-20 03:12:35 +08:00
Luis Pater
d7afb6eb0c fix(gemini): improve reasoning effort conversion for Gemini 3 models
Refactors the reasoning effort conversion logic for Gemini models.
The update specifically addresses how `reasoning_effort` is translated into Gemini 3 specific thinking configurations (`thinkingLevel`, `includeThoughts`) and ensures that numeric budgets are not incorrectly applied to level-based models.

Changes include:
- Differentiating conversion logic for Gemini 3 models versus other models.
- Handling `none`, `auto`, and validated thinking levels for Gemini 3.
- Maintaining existing conversion for models not using discrete thinking levels.
2025-12-20 03:11:28 +08:00
Luis Pater
03209b35c0 Merge branch 'router-for-me:main' into main 2025-12-20 00:02:07 +08:00
Luis Pater
bbd1fe890a Merge pull request #598 from BigUncle/fix/token-refresh-loop
fix(auth): prevent token refresh loop by ignoring timestamp fields
2025-12-19 23:59:40 +08:00
Luis Pater
843316ea7a Merge branch 'router-for-me:main' into main 2025-12-19 22:24:26 +08:00
Luis Pater
f607231efa Merge pull request #627 from router-for-me/gemini
fix(gemini): add optional skip for gemini3 thinking conversion
2025-12-19 22:20:51 +08:00
hkfires
2039062845 fix(gemini): add optional skip for gemini3 thinking conversion 2025-12-19 22:07:43 +08:00
Luis Pater
44f66d2257 Merge PR #55 2025-12-19 18:59:12 +08:00
Luis Pater
99478d13a8 Merge pull request #623 from router-for-me/remote-OAuth
Remote OAuth
2025-12-19 18:29:09 +08:00
Luis Pater
3b51a0fe12 Merge pull request #54 from StriveMario/fix/kiro-config-synthesis
add missing Kiro config synthesis
2025-12-19 18:25:02 +08:00
Mario
2d91c2a3f5 add missing Kiro config synthesis 2025-12-19 18:13:15 +08:00
Luis Pater
69d3a80fc3 Merge pull request #618 from router-for-me/amp
fix(amp): add management auth skipper
2025-12-19 17:37:51 +08:00
Luis Pater
9e268ad103 Merge pull request #619 from router-for-me/gemini
fix(util): disable default thinking for gemini 3 flash
2025-12-19 17:36:52 +08:00
hkfires
9d9b9e7a0d fix(amp): add management auth skipper 2025-12-19 13:57:47 +08:00
hkfires
13aa82f3f3 fix(util): disable default thinking for gemini 3 flash 2025-12-19 13:11:15 +08:00
Supra4E8C
1b358c931c fix: restore get-auth-status ok fallback and document it 2025-12-19 12:15:22 +08:00
Supra4E8C
cfa8ddb59f feat(oauth): add remote OAuth callback support with session management
Introduce a centralized OAuth session store with TTL-based expiration
  to replace the previous simple map-based status tracking. Add a new
  /api/oauth/callback endpoint that allows remote clients to relay OAuth
  callback data back to the CLI proxy, enabling OAuth flows when the
  callback cannot reach the local machine directly.

  - Add oauth_sessions.go with thread-safe session store and validation
  - Add oauth_callback.go with POST handler for remote callback relay
  - Refactor auth_files.go to use new session management APIs
  - Register new callback route in server.go
2025-12-19 00:38:29 +08:00
BigUncle
39597267ae fix(auth): prevent token refresh loop by ignoring timestamp fields
Add metadataEqualIgnoringTimestamps() function to compare metadata JSON
without timestamp/expired/expires_in/last_refresh/access_token fields.
This prevents unnecessary file writes when only these fields change
during refresh, breaking the fsnotify event → Watcher callback → refresh loop.

Key insight: Google OAuth returns a new access_token on each refresh,
which was causing file writes and triggering the refresh loop.

Fixes antigravity channel excessive log generation issue.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-18 21:37:05 +08:00
23 changed files with 973 additions and 311 deletions

View File

@@ -23,13 +23,13 @@ import (
"time"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
clipexec "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
sdktr "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
)

View File

@@ -40,33 +40,6 @@ import (
"golang.org/x/oauth2/google"
)
var (
oauthStatus = make(map[string]string)
oauthStatusMutex sync.RWMutex
)
// getOAuthStatus safely retrieves an OAuth status
func getOAuthStatus(key string) (string, bool) {
oauthStatusMutex.RLock()
defer oauthStatusMutex.RUnlock()
status, ok := oauthStatus[key]
return status, ok
}
// setOAuthStatus safely sets an OAuth status
func setOAuthStatus(key string, status string) {
oauthStatusMutex.Lock()
defer oauthStatusMutex.Unlock()
oauthStatus[key] = status
}
// deleteOAuthStatus safely deletes an OAuth status
func deleteOAuthStatus(key string) {
oauthStatusMutex.Lock()
defer oauthStatusMutex.Unlock()
delete(oauthStatus, key)
}
var lastRefreshKeys = []string{"last_refresh", "lastRefresh", "last_refreshed_at", "lastRefreshedAt"}
const (
@@ -813,6 +786,8 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
return
}
RegisterOAuthSession(state, "anthropic")
isWebUI := isWebUIRequest(c)
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/anthropic/callback")
@@ -839,7 +814,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
deadline := time.Now().Add(timeout)
for {
if time.Now().After(deadline) {
setOAuthStatus(state, "Timeout waiting for OAuth callback")
SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
return nil, fmt.Errorf("timeout waiting for OAuth callback")
}
data, errRead := os.ReadFile(path)
@@ -864,13 +839,13 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
if errStr := resultMap["error"]; errStr != "" {
oauthErr := claude.NewOAuthError(errStr, "", http.StatusBadRequest)
log.Error(claude.GetUserFriendlyMessage(oauthErr))
setOAuthStatus(state, "Bad request")
SetOAuthSessionError(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))
setOAuthStatus(state, "State code error")
SetOAuthSessionError(state, "State code error")
return
}
@@ -903,7 +878,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
if errDo != nil {
authErr := claude.NewAuthenticationError(claude.ErrCodeExchangeFailed, errDo)
log.Errorf("Failed to exchange authorization code for tokens: %v", authErr)
setOAuthStatus(state, "Failed to exchange authorization code for tokens")
SetOAuthSessionError(state, "Failed to exchange authorization code for tokens")
return
}
defer func() {
@@ -914,7 +889,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
respBody, _ := io.ReadAll(resp.Body)
if resp.StatusCode != http.StatusOK {
log.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(respBody))
setOAuthStatus(state, fmt.Sprintf("token exchange failed with status %d", resp.StatusCode))
SetOAuthSessionError(state, fmt.Sprintf("token exchange failed with status %d", resp.StatusCode))
return
}
var tResp struct {
@@ -927,7 +902,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
}
if errU := json.Unmarshal(respBody, &tResp); errU != nil {
log.Errorf("failed to parse token response: %v", errU)
setOAuthStatus(state, "Failed to parse token response")
SetOAuthSessionError(state, "Failed to parse token response")
return
}
bundle := &claude.ClaudeAuthBundle{
@@ -952,7 +927,7 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save authentication tokens: %v", errSave)
setOAuthStatus(state, "Failed to save authentication tokens")
SetOAuthSessionError(state, "Failed to save authentication tokens")
return
}
@@ -961,10 +936,9 @@ func (h *Handler) RequestAnthropicToken(c *gin.Context) {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use Claude services through this CLI")
deleteOAuthStatus(state)
CompleteOAuthSession(state)
}()
setOAuthStatus(state, "")
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
@@ -995,6 +969,8 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
state := fmt.Sprintf("gem-%d", time.Now().UnixNano())
authURL := conf.AuthCodeURL(state, oauth2.AccessTypeOffline, oauth2.SetAuthURLParam("prompt", "consent"))
RegisterOAuthSession(state, "gemini")
isWebUI := isWebUIRequest(c)
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/google/callback")
@@ -1023,7 +999,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
for {
if time.Now().After(deadline) {
log.Error("oauth flow timed out")
setOAuthStatus(state, "OAuth flow timed out")
SetOAuthSessionError(state, "OAuth flow timed out")
return
}
if data, errR := os.ReadFile(waitFile); errR == nil {
@@ -1032,13 +1008,13 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
_ = os.Remove(waitFile)
if errStr := m["error"]; errStr != "" {
log.Errorf("Authentication failed: %s", errStr)
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
return
}
authCode = m["code"]
if authCode == "" {
log.Errorf("Authentication failed: code not found")
setOAuthStatus(state, "Authentication failed: code not found")
SetOAuthSessionError(state, "Authentication failed: code not found")
return
}
break
@@ -1050,7 +1026,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
token, err := conf.Exchange(ctx, authCode)
if err != nil {
log.Errorf("Failed to exchange token: %v", err)
setOAuthStatus(state, "Failed to exchange token")
SetOAuthSessionError(state, "Failed to exchange token")
return
}
@@ -1061,7 +1037,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
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)
setOAuthStatus(state, "Could not get user info")
SetOAuthSessionError(state, "Could not get user info")
return
}
req.Header.Set("Content-Type", "application/json")
@@ -1070,7 +1046,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
resp, errDo := authHTTPClient.Do(req)
if errDo != nil {
log.Errorf("Failed to execute request: %v", errDo)
setOAuthStatus(state, "Failed to execute request")
SetOAuthSessionError(state, "Failed to execute request")
return
}
defer func() {
@@ -1082,7 +1058,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
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))
setOAuthStatus(state, fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode))
SetOAuthSessionError(state, fmt.Sprintf("Get user info request failed with status %d", resp.StatusCode))
return
}
@@ -1091,7 +1067,6 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
fmt.Printf("Authenticated user email: %s\n", email)
} else {
fmt.Println("Failed to get user email from token")
setOAuthStatus(state, "Failed to get user email from token")
}
// Marshal/unmarshal oauth2.Token to generic map and enrich fields
@@ -1099,7 +1074,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
jsonData, _ := json.Marshal(token)
if errUnmarshal := json.Unmarshal(jsonData, &ifToken); errUnmarshal != nil {
log.Errorf("Failed to unmarshal token: %v", errUnmarshal)
setOAuthStatus(state, "Failed to unmarshal token")
SetOAuthSessionError(state, "Failed to unmarshal token")
return
}
@@ -1125,7 +1100,7 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
gemClient, errGetClient := gemAuth.GetAuthenticatedClient(ctx, &ts, h.cfg, true)
if errGetClient != nil {
log.Errorf("failed to get authenticated client: %v", errGetClient)
setOAuthStatus(state, "Failed to get authenticated client")
SetOAuthSessionError(state, "Failed to get authenticated client")
return
}
fmt.Println("Authentication successful.")
@@ -1135,12 +1110,12 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
projects, errAll := onboardAllGeminiProjects(ctx, gemClient, &ts)
if errAll != nil {
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errAll)
setOAuthStatus(state, "Failed to complete Gemini CLI onboarding")
SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding")
return
}
if errVerify := ensureGeminiProjectsEnabled(ctx, gemClient, projects); errVerify != nil {
log.Errorf("Failed to verify Cloud AI API status: %v", errVerify)
setOAuthStatus(state, "Failed to verify Cloud AI API status")
SetOAuthSessionError(state, "Failed to verify Cloud AI API status")
return
}
ts.ProjectID = strings.Join(projects, ",")
@@ -1148,26 +1123,26 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
} else {
if errEnsure := ensureGeminiProjectAndOnboard(ctx, gemClient, &ts, requestedProjectID); errEnsure != nil {
log.Errorf("Failed to complete Gemini CLI onboarding: %v", errEnsure)
setOAuthStatus(state, "Failed to complete Gemini CLI onboarding")
SetOAuthSessionError(state, "Failed to complete Gemini CLI onboarding")
return
}
if strings.TrimSpace(ts.ProjectID) == "" {
log.Error("Onboarding did not return a project ID")
setOAuthStatus(state, "Failed to resolve project ID")
SetOAuthSessionError(state, "Failed to resolve project ID")
return
}
isChecked, errCheck := checkCloudAPIIsEnabled(ctx, gemClient, ts.ProjectID)
if errCheck != nil {
log.Errorf("Failed to verify Cloud AI API status: %v", errCheck)
setOAuthStatus(state, "Failed to verify Cloud AI API status")
SetOAuthSessionError(state, "Failed to verify Cloud AI API status")
return
}
ts.Checked = isChecked
if !isChecked {
log.Error("Cloud AI API is not enabled for the selected project")
setOAuthStatus(state, "Cloud AI API not enabled")
SetOAuthSessionError(state, "Cloud AI API not enabled")
return
}
}
@@ -1190,15 +1165,14 @@ func (h *Handler) RequestGeminiCLIToken(c *gin.Context) {
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save token to file: %v", errSave)
setOAuthStatus(state, "Failed to save token to file")
SetOAuthSessionError(state, "Failed to save token to file")
return
}
deleteOAuthStatus(state)
CompleteOAuthSession(state)
fmt.Printf("You can now use Gemini CLI services through this CLI; token saved to %s\n", savedPath)
}()
setOAuthStatus(state, "")
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
@@ -1234,6 +1208,8 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
return
}
RegisterOAuthSession(state, "codex")
isWebUI := isWebUIRequest(c)
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/codex/callback")
@@ -1262,7 +1238,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
if time.Now().After(deadline) {
authErr := codex.NewAuthenticationError(codex.ErrCallbackTimeout, fmt.Errorf("timeout waiting for OAuth callback"))
log.Error(codex.GetUserFriendlyMessage(authErr))
setOAuthStatus(state, "Timeout waiting for OAuth callback")
SetOAuthSessionError(state, "Timeout waiting for OAuth callback")
return
}
if data, errR := os.ReadFile(waitFile); errR == nil {
@@ -1272,12 +1248,12 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
if errStr := m["error"]; errStr != "" {
oauthErr := codex.NewOAuthError(errStr, "", http.StatusBadRequest)
log.Error(codex.GetUserFriendlyMessage(oauthErr))
setOAuthStatus(state, "Bad Request")
SetOAuthSessionError(state, "Bad Request")
return
}
if m["state"] != state {
authErr := codex.NewAuthenticationError(codex.ErrInvalidState, fmt.Errorf("expected %s, got %s", state, m["state"]))
setOAuthStatus(state, "State code error")
SetOAuthSessionError(state, "State code error")
log.Error(codex.GetUserFriendlyMessage(authErr))
return
}
@@ -1308,14 +1284,14 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
resp, errDo := httpClient.Do(req)
if errDo != nil {
authErr := codex.NewAuthenticationError(codex.ErrCodeExchangeFailed, errDo)
setOAuthStatus(state, "Failed to exchange authorization code for tokens")
SetOAuthSessionError(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 {
setOAuthStatus(state, fmt.Sprintf("Token exchange failed with status %d", resp.StatusCode))
SetOAuthSessionError(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
}
@@ -1326,7 +1302,7 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
ExpiresIn int `json:"expires_in"`
}
if errU := json.Unmarshal(respBody, &tokenResp); errU != nil {
setOAuthStatus(state, "Failed to parse token response")
SetOAuthSessionError(state, "Failed to parse token response")
log.Errorf("failed to parse token response: %v", errU)
return
}
@@ -1364,8 +1340,8 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
}
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
SetOAuthSessionError(state, "Failed to save authentication tokens")
log.Errorf("Failed to save authentication tokens: %v", errSave)
setOAuthStatus(state, "Failed to save authentication tokens")
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
@@ -1373,10 +1349,9 @@ func (h *Handler) RequestCodexToken(c *gin.Context) {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use Codex services through this CLI")
deleteOAuthStatus(state)
CompleteOAuthSession(state)
}()
setOAuthStatus(state, "")
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
@@ -1417,6 +1392,8 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
params.Set("state", state)
authURL := "https://accounts.google.com/o/oauth2/v2/auth?" + params.Encode()
RegisterOAuthSession(state, "antigravity")
isWebUI := isWebUIRequest(c)
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/antigravity/callback")
@@ -1443,7 +1420,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
for {
if time.Now().After(deadline) {
log.Error("oauth flow timed out")
setOAuthStatus(state, "OAuth flow timed out")
SetOAuthSessionError(state, "OAuth flow timed out")
return
}
if data, errReadFile := os.ReadFile(waitFile); errReadFile == nil {
@@ -1452,18 +1429,18 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
_ = os.Remove(waitFile)
if errStr := strings.TrimSpace(payload["error"]); errStr != "" {
log.Errorf("Authentication failed: %s", errStr)
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
return
}
if payloadState := strings.TrimSpace(payload["state"]); payloadState != "" && payloadState != state {
log.Errorf("Authentication failed: state mismatch")
setOAuthStatus(state, "Authentication failed: state mismatch")
SetOAuthSessionError(state, "Authentication failed: state mismatch")
return
}
authCode = strings.TrimSpace(payload["code"])
if authCode == "" {
log.Error("Authentication failed: code not found")
setOAuthStatus(state, "Authentication failed: code not found")
SetOAuthSessionError(state, "Authentication failed: code not found")
return
}
break
@@ -1482,7 +1459,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
req, errNewRequest := http.NewRequestWithContext(ctx, http.MethodPost, "https://oauth2.googleapis.com/token", strings.NewReader(form.Encode()))
if errNewRequest != nil {
log.Errorf("Failed to build token request: %v", errNewRequest)
setOAuthStatus(state, "Failed to build token request")
SetOAuthSessionError(state, "Failed to build token request")
return
}
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
@@ -1490,7 +1467,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
resp, errDo := httpClient.Do(req)
if errDo != nil {
log.Errorf("Failed to execute token request: %v", errDo)
setOAuthStatus(state, "Failed to exchange token")
SetOAuthSessionError(state, "Failed to exchange token")
return
}
defer func() {
@@ -1502,7 +1479,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
bodyBytes, _ := io.ReadAll(resp.Body)
log.Errorf("Antigravity token exchange failed with status %d: %s", resp.StatusCode, string(bodyBytes))
setOAuthStatus(state, fmt.Sprintf("Token exchange failed: %d", resp.StatusCode))
SetOAuthSessionError(state, fmt.Sprintf("Token exchange failed: %d", resp.StatusCode))
return
}
@@ -1514,7 +1491,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
}
if errDecode := json.NewDecoder(resp.Body).Decode(&tokenResp); errDecode != nil {
log.Errorf("Failed to parse token response: %v", errDecode)
setOAuthStatus(state, "Failed to parse token response")
SetOAuthSessionError(state, "Failed to parse token response")
return
}
@@ -1523,7 +1500,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
infoReq, errInfoReq := http.NewRequestWithContext(ctx, http.MethodGet, "https://www.googleapis.com/oauth2/v1/userinfo?alt=json", nil)
if errInfoReq != nil {
log.Errorf("Failed to build user info request: %v", errInfoReq)
setOAuthStatus(state, "Failed to build user info request")
SetOAuthSessionError(state, "Failed to build user info request")
return
}
infoReq.Header.Set("Authorization", "Bearer "+tokenResp.AccessToken)
@@ -1531,7 +1508,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
infoResp, errInfo := httpClient.Do(infoReq)
if errInfo != nil {
log.Errorf("Failed to execute user info request: %v", errInfo)
setOAuthStatus(state, "Failed to execute user info request")
SetOAuthSessionError(state, "Failed to execute user info request")
return
}
defer func() {
@@ -1550,7 +1527,7 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
} else {
bodyBytes, _ := io.ReadAll(infoResp.Body)
log.Errorf("User info request failed with status %d: %s", infoResp.StatusCode, string(bodyBytes))
setOAuthStatus(state, fmt.Sprintf("User info request failed: %d", infoResp.StatusCode))
SetOAuthSessionError(state, fmt.Sprintf("User info request failed: %d", infoResp.StatusCode))
return
}
}
@@ -1598,11 +1575,11 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save token to file: %v", errSave)
setOAuthStatus(state, "Failed to save token to file")
SetOAuthSessionError(state, "Failed to save token to file")
return
}
deleteOAuthStatus(state)
CompleteOAuthSession(state)
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
if projectID != "" {
fmt.Printf("Using GCP project: %s\n", projectID)
@@ -1610,7 +1587,6 @@ func (h *Handler) RequestAntigravityToken(c *gin.Context) {
fmt.Println("You can now use Antigravity services through this CLI")
}()
setOAuthStatus(state, "")
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
@@ -1632,11 +1608,13 @@ func (h *Handler) RequestQwenToken(c *gin.Context) {
}
authURL := deviceFlow.VerificationURIComplete
RegisterOAuthSession(state, "qwen")
go func() {
fmt.Println("Waiting for authentication...")
tokenData, errPollForToken := qwenAuth.PollForToken(deviceFlow.DeviceCode, deviceFlow.CodeVerifier)
if errPollForToken != nil {
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
fmt.Printf("Authentication failed: %v\n", errPollForToken)
return
}
@@ -1655,16 +1633,15 @@ func (h *Handler) RequestQwenToken(c *gin.Context) {
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save authentication tokens: %v", errSave)
setOAuthStatus(state, "Failed to save authentication tokens")
SetOAuthSessionError(state, "Failed to save authentication tokens")
return
}
fmt.Printf("Authentication successful! Token saved to %s\n", savedPath)
fmt.Println("You can now use Qwen services through this CLI")
deleteOAuthStatus(state)
CompleteOAuthSession(state)
}()
setOAuthStatus(state, "")
c.JSON(200, gin.H{"status": "ok", "url": authURL, "state": state})
}
@@ -1677,6 +1654,8 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) {
authSvc := iflowauth.NewIFlowAuth(h.cfg)
authURL, redirectURI := authSvc.AuthorizationURL(state, iflowauth.CallbackPort)
RegisterOAuthSession(state, "iflow")
isWebUI := isWebUIRequest(c)
if isWebUI {
targetURL, errTarget := h.managementCallbackURL("/iflow/callback")
@@ -1703,7 +1682,7 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) {
var resultMap map[string]string
for {
if time.Now().After(deadline) {
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
fmt.Println("Authentication failed: timeout waiting for callback")
return
}
@@ -1716,26 +1695,26 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) {
}
if errStr := strings.TrimSpace(resultMap["error"]); errStr != "" {
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
fmt.Printf("Authentication failed: %s\n", errStr)
return
}
if resultState := strings.TrimSpace(resultMap["state"]); resultState != state {
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
fmt.Println("Authentication failed: state mismatch")
return
}
code := strings.TrimSpace(resultMap["code"])
if code == "" {
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
fmt.Println("Authentication failed: code missing")
return
}
tokenData, errExchange := authSvc.ExchangeCodeForTokens(ctx, code, redirectURI)
if errExchange != nil {
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
fmt.Printf("Authentication failed: %v\n", errExchange)
return
}
@@ -1757,8 +1736,8 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) {
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
SetOAuthSessionError(state, "Failed to save authentication tokens")
log.Errorf("Failed to save authentication tokens: %v", errSave)
setOAuthStatus(state, "Failed to save authentication tokens")
return
}
@@ -1767,10 +1746,9 @@ func (h *Handler) RequestIFlowToken(c *gin.Context) {
fmt.Println("API key obtained and saved")
}
fmt.Println("You can now use iFlow services through this CLI")
deleteOAuthStatus(state)
CompleteOAuthSession(state)
}()
setOAuthStatus(state, "")
c.JSON(http.StatusOK, gin.H{"status": "ok", "url": authURL, "state": state})
}
@@ -2206,44 +2184,45 @@ func checkCloudAPIIsEnabled(ctx context.Context, httpClient *http.Client, projec
}
func (h *Handler) GetAuthStatus(c *gin.Context) {
state := c.Query("state")
if statusValue, ok := getOAuthStatus(state); ok {
if statusValue != "" {
// Check for device_code prefix (Kiro AWS Builder ID flow)
// Format: "device_code|verification_url|user_code"
// Using "|" as separator because URLs contain ":"
if strings.HasPrefix(statusValue, "device_code|") {
parts := strings.SplitN(statusValue, "|", 3)
if len(parts) == 3 {
c.JSON(200, gin.H{
"status": "device_code",
"verification_url": parts[1],
"user_code": parts[2],
})
return
}
}
// Check for auth_url prefix (Kiro social auth flow)
// Format: "auth_url|url"
// Using "|" as separator because URLs contain ":"
if strings.HasPrefix(statusValue, "auth_url|") {
authURL := strings.TrimPrefix(statusValue, "auth_url|")
c.JSON(200, gin.H{
"status": "auth_url",
"url": authURL,
state := strings.TrimSpace(c.Query("state"))
if state == "" {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
}
if err := ValidateOAuthState(state); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
return
}
_, status, ok := GetOAuthSession(state)
if !ok {
c.JSON(http.StatusOK, gin.H{"status": "ok"})
return
}
if status != "" {
if strings.HasPrefix(status, "device_code|") {
parts := strings.SplitN(status, "|", 3)
if len(parts) == 3 {
c.JSON(http.StatusOK, gin.H{
"status": "device_code",
"verification_url": parts[1],
"user_code": parts[2],
})
return
}
// Otherwise treat as error
c.JSON(200, gin.H{"status": "error", "error": statusValue})
} else {
c.JSON(200, gin.H{"status": "wait"})
}
if strings.HasPrefix(status, "auth_url|") {
authURL := strings.TrimPrefix(status, "auth_url|")
c.JSON(http.StatusOK, gin.H{
"status": "auth_url",
"url": authURL,
})
return
}
} else {
c.JSON(200, gin.H{"status": "ok"})
c.JSON(http.StatusOK, gin.H{"status": "error", "error": status})
return
}
deleteOAuthStatus(state)
c.JSON(http.StatusOK, gin.H{"status": "wait"})
}
const kiroCallbackPort = 9876
@@ -2263,31 +2242,33 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
switch method {
case "aws", "builder-id":
RegisterOAuthSession(state, "kiro")
// AWS Builder ID uses device code flow (no callback needed)
go func() {
ssoClient := kiroauth.NewSSOOIDCClient(h.cfg)
// Step 1: Register client
fmt.Println("Registering client...")
regResp, err := ssoClient.RegisterClient(ctx)
if err != nil {
log.Errorf("Failed to register client: %v", err)
setOAuthStatus(state, "Failed to register client")
regResp, errRegister := ssoClient.RegisterClient(ctx)
if errRegister != nil {
log.Errorf("Failed to register client: %v", errRegister)
SetOAuthSessionError(state, "Failed to register client")
return
}
// Step 2: Start device authorization
fmt.Println("Starting device authorization...")
authResp, err := ssoClient.StartDeviceAuthorization(ctx, regResp.ClientID, regResp.ClientSecret)
if err != nil {
log.Errorf("Failed to start device auth: %v", err)
setOAuthStatus(state, "Failed to start device authorization")
authResp, errAuth := ssoClient.StartDeviceAuthorization(ctx, regResp.ClientID, regResp.ClientSecret)
if errAuth != nil {
log.Errorf("Failed to start device auth: %v", errAuth)
SetOAuthSessionError(state, "Failed to start device authorization")
return
}
// Store the verification URL for the frontend to display
// Using "|" as separator because URLs contain ":"
setOAuthStatus(state, "device_code|"+authResp.VerificationURIComplete+"|"+authResp.UserCode)
// Store the verification URL for the frontend to display.
// Using "|" as separator because URLs contain ":".
SetOAuthSessionError(state, "device_code|"+authResp.VerificationURIComplete+"|"+authResp.UserCode)
// Step 3: Poll for token
fmt.Println("Waiting for authorization...")
@@ -2300,12 +2281,12 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
setOAuthStatus(state, "Authorization cancelled")
SetOAuthSessionError(state, "Authorization cancelled")
return
case <-time.After(interval):
tokenResp, err := ssoClient.CreateToken(ctx, regResp.ClientID, regResp.ClientSecret, authResp.DeviceCode)
if err != nil {
errStr := err.Error()
tokenResp, errToken := ssoClient.CreateToken(ctx, regResp.ClientID, regResp.ClientSecret, authResp.DeviceCode)
if errToken != nil {
errStr := errToken.Error()
if strings.Contains(errStr, "authorization_pending") {
continue
}
@@ -2313,8 +2294,8 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
interval += 5 * time.Second
continue
}
log.Errorf("Token creation failed: %v", err)
setOAuthStatus(state, "Token creation failed")
log.Errorf("Token creation failed: %v", errToken)
SetOAuthSessionError(state, "Token creation failed")
return
}
@@ -2351,7 +2332,7 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save authentication tokens: %v", errSave)
setOAuthStatus(state, "Failed to save authentication tokens")
SetOAuthSessionError(state, "Failed to save authentication tokens")
return
}
@@ -2359,18 +2340,20 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
if email != "" {
fmt.Printf("Authenticated as: %s\n", email)
}
deleteOAuthStatus(state)
CompleteOAuthSession(state)
return
}
}
setOAuthStatus(state, "Authorization timed out")
SetOAuthSessionError(state, "Authorization timed out")
}()
// Return immediately with the state for polling
c.JSON(200, gin.H{"status": "ok", "state": state, "method": "device_code"})
c.JSON(http.StatusOK, gin.H{"status": "ok", "state": state, "method": "device_code"})
case "google", "github":
RegisterOAuthSession(state, "kiro")
// Social auth uses protocol handler - for WEB UI we use a callback forwarder
provider := "Google"
if method == "github" {
@@ -2400,10 +2383,10 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
socialClient := kiroauth.NewSocialAuthClient(h.cfg)
// Generate PKCE codes
codeVerifier, codeChallenge, err := generateKiroPKCE()
if err != nil {
log.Errorf("Failed to generate PKCE: %v", err)
setOAuthStatus(state, "Failed to generate PKCE")
codeVerifier, codeChallenge, errPKCE := generateKiroPKCE()
if errPKCE != nil {
log.Errorf("Failed to generate PKCE: %v", errPKCE)
SetOAuthSessionError(state, "Failed to generate PKCE")
return
}
@@ -2416,9 +2399,9 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
state,
)
// Store auth URL for frontend
// Using "|" as separator because URLs contain ":"
setOAuthStatus(state, "auth_url|"+authURL)
// Store auth URL for frontend.
// Using "|" as separator because URLs contain ":".
SetOAuthSessionError(state, "auth_url|"+authURL)
// Wait for callback file
waitFile := filepath.Join(h.cfg.AuthDir, fmt.Sprintf(".oauth-kiro-%s.oauth", state))
@@ -2427,27 +2410,27 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
for {
if time.Now().After(deadline) {
log.Error("oauth flow timed out")
setOAuthStatus(state, "OAuth flow timed out")
SetOAuthSessionError(state, "OAuth flow timed out")
return
}
if data, errR := os.ReadFile(waitFile); errR == nil {
if data, errRead := os.ReadFile(waitFile); errRead == nil {
var m map[string]string
_ = json.Unmarshal(data, &m)
_ = os.Remove(waitFile)
if errStr := m["error"]; errStr != "" {
log.Errorf("Authentication failed: %s", errStr)
setOAuthStatus(state, "Authentication failed")
SetOAuthSessionError(state, "Authentication failed")
return
}
if m["state"] != state {
log.Errorf("State mismatch")
setOAuthStatus(state, "State mismatch")
SetOAuthSessionError(state, "State mismatch")
return
}
code := m["code"]
if code == "" {
log.Error("No authorization code received")
setOAuthStatus(state, "No authorization code received")
SetOAuthSessionError(state, "No authorization code received")
return
}
@@ -2461,7 +2444,7 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
tokenResp, errToken := socialClient.CreateToken(ctx, tokenReq)
if errToken != nil {
log.Errorf("Failed to exchange code for tokens: %v", errToken)
setOAuthStatus(state, "Failed to exchange code for tokens")
SetOAuthSessionError(state, "Failed to exchange code for tokens")
return
}
@@ -2501,7 +2484,7 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
savedPath, errSave := h.saveTokenRecord(ctx, record)
if errSave != nil {
log.Errorf("Failed to save authentication tokens: %v", errSave)
setOAuthStatus(state, "Failed to save authentication tokens")
SetOAuthSessionError(state, "Failed to save authentication tokens")
return
}
@@ -2509,15 +2492,14 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
if email != "" {
fmt.Printf("Authenticated as: %s\n", email)
}
deleteOAuthStatus(state)
CompleteOAuthSession(state)
return
}
time.Sleep(500 * time.Millisecond)
}
}()
setOAuthStatus(state, "")
c.JSON(200, gin.H{"status": "ok", "state": state, "method": "social"})
c.JSON(http.StatusOK, gin.H{"status": "ok", "state": state, "method": "social"})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid method, use 'aws', 'google', or 'github'"})
@@ -2527,8 +2509,8 @@ func (h *Handler) RequestKiroToken(c *gin.Context) {
// generateKiroPKCE generates PKCE code verifier and challenge for Kiro OAuth.
func generateKiroPKCE() (verifier, challenge string, err error) {
b := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, b); err != nil {
return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
if _, errRead := io.ReadFull(rand.Reader, b); errRead != nil {
return "", "", fmt.Errorf("failed to generate random bytes: %w", errRead)
}
verifier = base64.RawURLEncoding.EncodeToString(b)

View File

@@ -0,0 +1,100 @@
package management
import (
"errors"
"net/http"
"net/url"
"strings"
"github.com/gin-gonic/gin"
)
type oauthCallbackRequest struct {
Provider string `json:"provider"`
RedirectURL string `json:"redirect_url"`
Code string `json:"code"`
State string `json:"state"`
Error string `json:"error"`
}
func (h *Handler) PostOAuthCallback(c *gin.Context) {
if h == nil || h.cfg == nil {
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "handler not initialized"})
return
}
var req oauthCallbackRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid body"})
return
}
canonicalProvider, err := NormalizeOAuthProvider(req.Provider)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "unsupported provider"})
return
}
state := strings.TrimSpace(req.State)
code := strings.TrimSpace(req.Code)
errMsg := strings.TrimSpace(req.Error)
if rawRedirect := strings.TrimSpace(req.RedirectURL); rawRedirect != "" {
u, errParse := url.Parse(rawRedirect)
if errParse != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid redirect_url"})
return
}
q := u.Query()
if state == "" {
state = strings.TrimSpace(q.Get("state"))
}
if code == "" {
code = strings.TrimSpace(q.Get("code"))
}
if errMsg == "" {
errMsg = strings.TrimSpace(q.Get("error"))
if errMsg == "" {
errMsg = strings.TrimSpace(q.Get("error_description"))
}
}
}
if state == "" {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "state is required"})
return
}
if err := ValidateOAuthState(state); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "invalid state"})
return
}
if code == "" && errMsg == "" {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "code or error is required"})
return
}
sessionProvider, sessionStatus, ok := GetOAuthSession(state)
if !ok {
c.JSON(http.StatusNotFound, gin.H{"status": "error", "error": "unknown or expired state"})
return
}
if sessionStatus != "" {
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
return
}
if !strings.EqualFold(sessionProvider, canonicalProvider) {
c.JSON(http.StatusBadRequest, gin.H{"status": "error", "error": "provider does not match state"})
return
}
if _, errWrite := WriteOAuthCallbackFileForPendingSession(h.cfg.AuthDir, canonicalProvider, state, code, errMsg); errWrite != nil {
if errors.Is(errWrite, errOAuthSessionNotPending) {
c.JSON(http.StatusConflict, gin.H{"status": "error", "error": "oauth flow is not pending"})
return
}
c.JSON(http.StatusInternalServerError, gin.H{"status": "error", "error": "failed to persist oauth callback"})
return
}
c.JSON(http.StatusOK, gin.H{"status": "ok"})
}

View File

@@ -0,0 +1,265 @@
package management
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
const (
oauthSessionTTL = 10 * time.Minute
maxOAuthStateLength = 128
)
var (
errInvalidOAuthState = errors.New("invalid oauth state")
errUnsupportedOAuthFlow = errors.New("unsupported oauth provider")
errOAuthSessionNotPending = errors.New("oauth session is not pending")
)
type oauthSession struct {
Provider string
Status string
CreatedAt time.Time
ExpiresAt time.Time
}
type oauthSessionStore struct {
mu sync.RWMutex
ttl time.Duration
sessions map[string]oauthSession
}
func newOAuthSessionStore(ttl time.Duration) *oauthSessionStore {
if ttl <= 0 {
ttl = oauthSessionTTL
}
return &oauthSessionStore{
ttl: ttl,
sessions: make(map[string]oauthSession),
}
}
func (s *oauthSessionStore) purgeExpiredLocked(now time.Time) {
for state, session := range s.sessions {
if !session.ExpiresAt.IsZero() && now.After(session.ExpiresAt) {
delete(s.sessions, state)
}
}
}
func (s *oauthSessionStore) Register(state, provider string) {
state = strings.TrimSpace(state)
provider = strings.ToLower(strings.TrimSpace(provider))
if state == "" || provider == "" {
return
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.purgeExpiredLocked(now)
s.sessions[state] = oauthSession{
Provider: provider,
Status: "",
CreatedAt: now,
ExpiresAt: now.Add(s.ttl),
}
}
func (s *oauthSessionStore) SetError(state, message string) {
state = strings.TrimSpace(state)
message = strings.TrimSpace(message)
if state == "" {
return
}
if message == "" {
message = "Authentication failed"
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.purgeExpiredLocked(now)
session, ok := s.sessions[state]
if !ok {
return
}
session.Status = message
session.ExpiresAt = now.Add(s.ttl)
s.sessions[state] = session
}
func (s *oauthSessionStore) Complete(state string) {
state = strings.TrimSpace(state)
if state == "" {
return
}
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.purgeExpiredLocked(now)
delete(s.sessions, state)
}
func (s *oauthSessionStore) Get(state string) (oauthSession, bool) {
state = strings.TrimSpace(state)
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.purgeExpiredLocked(now)
session, ok := s.sessions[state]
return session, ok
}
func (s *oauthSessionStore) IsPending(state, provider string) bool {
state = strings.TrimSpace(state)
provider = strings.ToLower(strings.TrimSpace(provider))
now := time.Now()
s.mu.Lock()
defer s.mu.Unlock()
s.purgeExpiredLocked(now)
session, ok := s.sessions[state]
if !ok {
return false
}
if session.Status != "" {
if !strings.EqualFold(session.Provider, "kiro") {
return false
}
if !strings.HasPrefix(session.Status, "device_code|") && !strings.HasPrefix(session.Status, "auth_url|") {
return false
}
}
if provider == "" {
return true
}
return strings.EqualFold(session.Provider, provider)
}
var oauthSessions = newOAuthSessionStore(oauthSessionTTL)
func RegisterOAuthSession(state, provider string) { oauthSessions.Register(state, provider) }
func SetOAuthSessionError(state, message string) { oauthSessions.SetError(state, message) }
func CompleteOAuthSession(state string) { oauthSessions.Complete(state) }
func GetOAuthSession(state string) (provider string, status string, ok bool) {
session, ok := oauthSessions.Get(state)
if !ok {
return "", "", false
}
return session.Provider, session.Status, true
}
func IsOAuthSessionPending(state, provider string) bool {
return oauthSessions.IsPending(state, provider)
}
func ValidateOAuthState(state string) error {
trimmed := strings.TrimSpace(state)
if trimmed == "" {
return fmt.Errorf("%w: empty", errInvalidOAuthState)
}
if len(trimmed) > maxOAuthStateLength {
return fmt.Errorf("%w: too long", errInvalidOAuthState)
}
if strings.Contains(trimmed, "/") || strings.Contains(trimmed, "\\") {
return fmt.Errorf("%w: contains path separator", errInvalidOAuthState)
}
if strings.Contains(trimmed, "..") {
return fmt.Errorf("%w: contains '..'", errInvalidOAuthState)
}
for _, r := range trimmed {
switch {
case r >= 'a' && r <= 'z':
case r >= 'A' && r <= 'Z':
case r >= '0' && r <= '9':
case r == '-' || r == '_' || r == '.':
default:
return fmt.Errorf("%w: invalid character", errInvalidOAuthState)
}
}
return nil
}
func NormalizeOAuthProvider(provider string) (string, error) {
switch strings.ToLower(strings.TrimSpace(provider)) {
case "anthropic", "claude":
return "anthropic", nil
case "codex", "openai":
return "codex", nil
case "gemini", "google":
return "gemini", nil
case "iflow", "i-flow":
return "iflow", nil
case "antigravity", "anti-gravity":
return "antigravity", nil
case "qwen":
return "qwen", nil
case "kiro":
return "kiro", nil
default:
return "", errUnsupportedOAuthFlow
}
}
type oauthCallbackFilePayload struct {
Code string `json:"code"`
State string `json:"state"`
Error string `json:"error"`
}
func WriteOAuthCallbackFile(authDir, provider, state, code, errorMessage string) (string, error) {
if strings.TrimSpace(authDir) == "" {
return "", fmt.Errorf("auth dir is empty")
}
canonicalProvider, err := NormalizeOAuthProvider(provider)
if err != nil {
return "", err
}
if err := ValidateOAuthState(state); err != nil {
return "", err
}
fileName := fmt.Sprintf(".oauth-%s-%s.oauth", canonicalProvider, state)
filePath := filepath.Join(authDir, fileName)
payload := oauthCallbackFilePayload{
Code: strings.TrimSpace(code),
State: strings.TrimSpace(state),
Error: strings.TrimSpace(errorMessage),
}
data, err := json.Marshal(payload)
if err != nil {
return "", fmt.Errorf("marshal oauth callback payload: %w", err)
}
if err := os.WriteFile(filePath, data, 0o600); err != nil {
return "", fmt.Errorf("write oauth callback file: %w", err)
}
return filePath, nil
}
func WriteOAuthCallbackFileForPendingSession(authDir, provider, state, code, errorMessage string) (string, error) {
canonicalProvider, err := NormalizeOAuthProvider(provider)
if err != nil {
return "", err
}
if !IsOAuthSessionPending(state, canonicalProvider) {
return "", errOAuthSessionNotPending
}
return WriteOAuthCallbackFile(authDir, canonicalProvider, state, code, errorMessage)
}

View File

@@ -95,6 +95,20 @@ func (m *AmpModule) managementAvailabilityMiddleware() gin.HandlerFunc {
}
}
// wrapManagementAuth skips auth for selected management paths while keeping authentication elsewhere.
func wrapManagementAuth(auth gin.HandlerFunc, prefixes ...string) gin.HandlerFunc {
return func(c *gin.Context) {
path := c.Request.URL.Path
for _, prefix := range prefixes {
if strings.HasPrefix(path, prefix) && (len(path) == len(prefix) || path[len(prefix)] == '/') {
c.Next()
return
}
}
auth(c)
}
}
// registerManagementRoutes registers Amp management proxy routes
// These routes proxy through to the Amp control plane for OAuth, user management, etc.
// Uses dynamic middleware and proxy getter for hot-reload support.
@@ -109,8 +123,10 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
ampAPI.Use(m.localhostOnlyMiddleware())
// Apply authentication middleware - requires valid API key in Authorization header
var authWithBypass gin.HandlerFunc
if auth != nil {
ampAPI.Use(auth)
authWithBypass = wrapManagementAuth(auth, "/threads", "/auth")
}
// Dynamic proxy handler that uses m.getProxy() for hot-reload support
@@ -156,8 +172,8 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
// Root-level routes that AMP CLI expects without /api prefix
// These need the same security middleware as the /api/* routes (dynamic for hot-reload)
rootMiddleware := []gin.HandlerFunc{m.managementAvailabilityMiddleware(), noCORSMiddleware(), m.localhostOnlyMiddleware()}
if auth != nil {
rootMiddleware = append(rootMiddleware, auth)
if authWithBypass != nil {
rootMiddleware = append(rootMiddleware, authWithBypass)
}
engine.GET("/threads/*path", append(rootMiddleware, proxyHandler)...)
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)

View File

@@ -360,10 +360,11 @@ func (s *Server) setupRoutes() {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
// Persist to a temporary file keyed by state
if errStr == "" {
errStr = c.Query("error_description")
}
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)
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "anthropic", state, code, errStr)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -373,9 +374,11 @@ func (s *Server) setupRoutes() {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
if errStr == "" {
errStr = c.Query("error_description")
}
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)
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "codex", state, code, errStr)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -385,9 +388,11 @@ func (s *Server) setupRoutes() {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
if errStr == "" {
errStr = c.Query("error_description")
}
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)
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "gemini", state, code, errStr)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -397,9 +402,11 @@ func (s *Server) setupRoutes() {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
if errStr == "" {
errStr = c.Query("error_description")
}
if state != "" {
file := fmt.Sprintf("%s/.oauth-iflow-%s.oauth", s.cfg.AuthDir, state)
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "iflow", state, code, errStr)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -409,9 +416,11 @@ func (s *Server) setupRoutes() {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
if errStr == "" {
errStr = c.Query("error_description")
}
if state != "" {
file := fmt.Sprintf("%s/.oauth-antigravity-%s.oauth", s.cfg.AuthDir, state)
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "antigravity", state, code, errStr)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -421,9 +430,11 @@ func (s *Server) setupRoutes() {
code := c.Query("code")
state := c.Query("state")
errStr := c.Query("error")
if errStr == "" {
errStr = c.Query("error_description")
}
if state != "" {
file := fmt.Sprintf("%s/.oauth-kiro-%s.oauth", s.cfg.AuthDir, state)
_ = os.WriteFile(file, []byte(fmt.Sprintf(`{"code":"%s","state":"%s","error":"%s"}`, code, state, errStr)), 0o600)
_, _ = managementHandlers.WriteOAuthCallbackFileForPendingSession(s.cfg.AuthDir, "kiro", state, code, errStr)
}
c.Header("Content-Type", "text/html; charset=utf-8")
c.String(http.StatusOK, oauthCallbackSuccessHTML)
@@ -596,6 +607,7 @@ func (s *Server) registerManagementRoutes() {
mgmt.GET("/iflow-auth-url", s.mgmt.RequestIFlowToken)
mgmt.POST("/iflow-auth-url", s.mgmt.RequestIFlowCookieToken)
mgmt.GET("/kiro-auth-url", s.mgmt.RequestKiroToken)
mgmt.POST("/oauth-callback", s.mgmt.PostOAuthCallback)
mgmt.GET("/get-auth-status", s.mgmt.GetAuthStatus)
}
}

View File

@@ -12,7 +12,6 @@ import (
"strings"
"syscall"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"golang.org/x/crypto/bcrypt"
"gopkg.in/yaml.v3"
)
@@ -21,7 +20,7 @@ const DefaultPanelGitHubRepository = "https://github.com/router-for-me/Cli-Proxy
// Config represents the application's configuration, loaded from a YAML file.
type Config struct {
config.SDKConfig `yaml:",inline"`
SDKConfig `yaml:",inline"`
// Host is the network host/interface on which the API server will bind.
// Default is empty ("") to bind all interfaces (IPv4 + IPv6). Use "127.0.0.1" or "localhost" for local-only access.
Host string `yaml:"host" json:"-"`
@@ -754,7 +753,7 @@ func sanitizeConfigForPersist(cfg *Config) *Config {
}
clone := *cfg
clone.SDKConfig = cfg.SDKConfig
clone.SDKConfig.Access = config.AccessConfig{}
clone.SDKConfig.Access = AccessConfig{}
return &clone
}

View File

@@ -0,0 +1,87 @@
// Package config provides configuration management for the CLI Proxy API server.
// It handles loading and parsing YAML configuration files, and provides structured
// access to application settings including server port, authentication directory,
// debug settings, proxy configuration, and API keys.
package config
// SDKConfig represents the application's configuration, loaded from a YAML file.
type SDKConfig struct {
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
// ForceModelPrefix requires explicit model prefixes (e.g., "teamA/gemini-3-pro-preview")
// to target prefixed credentials. When false, unprefixed model requests may use prefixed
// credentials as well.
ForceModelPrefix bool `yaml:"force-model-prefix" json:"force-model-prefix"`
// RequestLog enables or disables detailed request logging functionality.
RequestLog bool `yaml:"request-log" json:"request-log"`
// APIKeys is a list of keys for authenticating clients to this proxy server.
APIKeys []string `yaml:"api-keys" json:"api-keys"`
// Access holds request authentication provider configuration.
Access AccessConfig `yaml:"auth,omitempty" json:"auth,omitempty"`
}
// AccessConfig groups request authentication providers.
type AccessConfig struct {
// Providers lists configured authentication providers.
Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
}
// AccessProvider describes a request authentication provider entry.
type AccessProvider struct {
// Name is the instance identifier for the provider.
Name string `yaml:"name" json:"name"`
// Type selects the provider implementation registered via the SDK.
Type string `yaml:"type" json:"type"`
// SDK optionally names a third-party SDK module providing this provider.
SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"`
// APIKeys lists inline keys for providers that require them.
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
// Config passes provider-specific options to the implementation.
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"`
}
const (
// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.
AccessProviderTypeConfigAPIKey = "config-api-key"
// DefaultAccessProviderName is applied when no provider name is supplied.
DefaultAccessProviderName = "config-inline"
)
// ConfigAPIKeyProvider returns the first inline API key provider if present.
func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider {
if c == nil {
return nil
}
for i := range c.Access.Providers {
if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey {
if c.Access.Providers[i].Name == "" {
c.Access.Providers[i].Name = DefaultAccessProviderName
}
return &c.Access.Providers[i]
}
}
return nil
}
// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.
// It returns nil when no keys are supplied.
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
if len(keys) == 0 {
return nil
}
provider := &AccessProvider{
Name: DefaultAccessProviderName,
Type: AccessProviderTypeConfigAPIKey,
APIKeys: append([]string(nil), keys...),
}
return provider
}

View File

@@ -325,8 +325,8 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
payload = ApplyThinkingMetadata(payload, req.Metadata, req.Model)
payload = util.ApplyGemini3ThinkingLevelFromMetadata(req.Model, req.Metadata, payload)
payload = util.ApplyDefaultThinkingIfNeeded(req.Model, payload)
payload = util.ConvertThinkingLevelToBudget(payload, req.Model)
payload = util.NormalizeGeminiThinkingBudget(req.Model, payload)
payload = util.ConvertThinkingLevelToBudget(payload, req.Model, true)
payload = util.NormalizeGeminiThinkingBudget(req.Model, payload, true)
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
payload = fixGeminiImageAspectRatio(req.Model, payload)
payload = applyPayloadConfig(e.cfg, req.Model, payload)

View File

@@ -39,8 +39,23 @@ func ConvertOpenAIRequestToAntigravity(modelName string, inputRawJSON []byte, _
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
re := gjson.GetBytes(rawJSON, "reasoning_effort")
hasOfficialThinking := re.Exists()
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
out = util.ApplyReasoningEffortToGeminiCLI(out, re.String())
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
effort := strings.ToLower(strings.TrimSpace(re.String()))
if util.IsGemini3Model(modelName) {
switch effort {
case "none":
out, _ = sjson.DeleteBytes(out, "request.generationConfig.thinkingConfig")
case "auto":
includeThoughts := true
out = util.ApplyGeminiCLIThinkingLevel(out, "", &includeThoughts)
default:
if level, ok := util.ValidateGemini3ThinkingLevel(modelName, effort); ok {
out = util.ApplyGeminiCLIThinkingLevel(out, level, nil)
}
}
} else if !util.ModelUsesThinkingLevels(modelName) {
out = util.ApplyReasoningEffortToGeminiCLI(out, effort)
}
}
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)

View File

@@ -37,12 +37,28 @@ func ConvertOpenAIRequestToGemini(modelName string, inputRawJSON []byte, _ bool)
// Reasoning effort -> thinkingBudget/include_thoughts
// Note: OpenAI official fields take precedence over extra_body.google.thinking_config
// Only convert for models that use numeric budgets (not discrete levels) to avoid
// incorrectly applying thinkingBudget for level-based models like gpt-5.
// Only apply numeric budgets for models that use budgets (not discrete levels) to avoid
// incorrectly applying thinkingBudget for level-based models like gpt-5. Gemini 3 models
// use thinkingLevel/includeThoughts instead.
re := gjson.GetBytes(rawJSON, "reasoning_effort")
hasOfficialThinking := re.Exists()
if hasOfficialThinking && util.ModelSupportsThinking(modelName) && !util.ModelUsesThinkingLevels(modelName) {
out = util.ApplyReasoningEffortToGemini(out, re.String())
if hasOfficialThinking && util.ModelSupportsThinking(modelName) {
effort := strings.ToLower(strings.TrimSpace(re.String()))
if util.IsGemini3Model(modelName) {
switch effort {
case "none":
out, _ = sjson.DeleteBytes(out, "generationConfig.thinkingConfig")
case "auto":
includeThoughts := true
out = util.ApplyGeminiThinkingLevel(out, "", &includeThoughts)
default:
if level, ok := util.ValidateGemini3ThinkingLevel(modelName, effort); ok {
out = util.ApplyGeminiThinkingLevel(out, level, nil)
}
}
} else if !util.ModelUsesThinkingLevels(modelName) {
out = util.ApplyReasoningEffortToGemini(out, effort)
}
}
// Cherry Studio extension extra_body.google.thinking_config (effective only when official fields are absent)

View File

@@ -242,7 +242,7 @@ func ThinkingBudgetToGemini3Level(model string, budget int) (string, bool) {
var modelsWithDefaultThinking = map[string]bool{
"gemini-3-pro-preview": true,
"gemini-3-pro-image-preview": true,
"gemini-3-flash-preview": true,
// "gemini-3-flash-preview": true,
}
// ModelHasDefaultThinking returns true if the model should have thinking enabled by default.
@@ -352,8 +352,9 @@ func StripThinkingConfigIfUnsupported(model string, body []byte) []byte {
// NormalizeGeminiThinkingBudget normalizes the thinkingBudget value in a standard Gemini
// request body (generationConfig.thinkingConfig.thinkingBudget path).
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation.
func NormalizeGeminiThinkingBudget(model string, body []byte) []byte {
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation,
// unless skipGemini3Check is provided and true.
func NormalizeGeminiThinkingBudget(model string, body []byte, skipGemini3Check ...bool) []byte {
const budgetPath = "generationConfig.thinkingConfig.thinkingBudget"
const levelPath = "generationConfig.thinkingConfig.thinkingLevel"
@@ -363,7 +364,8 @@ func NormalizeGeminiThinkingBudget(model string, body []byte) []byte {
}
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
if IsGemini3Model(model) {
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
if IsGemini3Model(model) && !skipGemini3 {
if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok {
updated, _ := sjson.SetBytes(body, levelPath, level)
updated, _ = sjson.DeleteBytes(updated, budgetPath)
@@ -382,8 +384,9 @@ func NormalizeGeminiThinkingBudget(model string, body []byte) []byte {
// NormalizeGeminiCLIThinkingBudget normalizes the thinkingBudget value in a Gemini CLI
// request body (request.generationConfig.thinkingConfig.thinkingBudget path).
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation.
func NormalizeGeminiCLIThinkingBudget(model string, body []byte) []byte {
// For Gemini 3 models, converts thinkingBudget to thinkingLevel per Google's documentation,
// unless skipGemini3Check is provided and true.
func NormalizeGeminiCLIThinkingBudget(model string, body []byte, skipGemini3Check ...bool) []byte {
const budgetPath = "request.generationConfig.thinkingConfig.thinkingBudget"
const levelPath = "request.generationConfig.thinkingConfig.thinkingLevel"
@@ -393,7 +396,8 @@ func NormalizeGeminiCLIThinkingBudget(model string, body []byte) []byte {
}
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
if IsGemini3Model(model) {
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
if IsGemini3Model(model) && !skipGemini3 {
if level, ok := ThinkingBudgetToGemini3Level(model, int(budget.Int())); ok {
updated, _ := sjson.SetBytes(body, levelPath, level)
updated, _ = sjson.DeleteBytes(updated, budgetPath)
@@ -477,7 +481,7 @@ func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte {
// ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel"
// and converts it to "thinkingBudget" for Gemini 2.5 models.
// For Gemini 3 models, preserves thinkingLevel as-is (does not convert).
// For Gemini 3 models, preserves thinkingLevel unless skipGemini3Check is provided and true.
// Mappings for Gemini 2.5:
// - "high" -> 32768
// - "medium" -> 8192
@@ -485,43 +489,31 @@ func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte {
// - "minimal" -> 512
//
// It removes "thinkingLevel" after conversion (for Gemini 2.5 only).
func ConvertThinkingLevelToBudget(body []byte, model string) []byte {
func ConvertThinkingLevelToBudget(body []byte, model string, skipGemini3Check ...bool) []byte {
levelPath := "generationConfig.thinkingConfig.thinkingLevel"
res := gjson.GetBytes(body, levelPath)
if !res.Exists() {
return body
}
// For Gemini 3 models, preserve thinkingLevel - don't convert to budget
if IsGemini3Model(model) {
// For Gemini 3 models, preserve thinkingLevel unless explicitly skipped
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
if IsGemini3Model(model) && !skipGemini3 {
return body
}
level := strings.ToLower(res.String())
var budget int
switch level {
case "high":
budget = 32768
case "medium":
budget = 8192
case "low":
budget = 1024
case "minimal":
budget = 512
default:
// Unknown level - remove it and let the API use defaults
budget, ok := ThinkingLevelToBudget(res.String())
if !ok {
updated, _ := sjson.DeleteBytes(body, levelPath)
return updated
}
// Set budget
budgetPath := "generationConfig.thinkingConfig.thinkingBudget"
updated, err := sjson.SetBytes(body, budgetPath, budget)
if err != nil {
return body
}
// Remove level
updated, err = sjson.DeleteBytes(updated, levelPath)
if err != nil {
return body
@@ -544,31 +536,18 @@ func ConvertThinkingLevelToBudgetCLI(body []byte, model string) []byte {
return body
}
level := strings.ToLower(res.String())
var budget int
switch level {
case "high":
budget = 32768
case "medium":
budget = 8192
case "low":
budget = 1024
case "minimal":
budget = 512
default:
// Unknown level - remove it and let the API use defaults
budget, ok := ThinkingLevelToBudget(res.String())
if !ok {
updated, _ := sjson.DeleteBytes(body, levelPath)
return updated
}
// Set budget
budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget"
updated, err := sjson.SetBytes(body, budgetPath, budget)
if err != nil {
return body
}
// Remove level
updated, err = sjson.DeleteBytes(updated, levelPath)
if err != nil {
return body

View File

@@ -160,6 +160,34 @@ func ThinkingEffortToBudget(model, effort string) (int, bool) {
}
}
// ThinkingLevelToBudget maps a Gemini thinkingLevel to a numeric thinking budget (tokens).
//
// Mappings:
// - "minimal" -> 512
// - "low" -> 1024
// - "medium" -> 8192
// - "high" -> 32768
//
// Returns false when the level is empty or unsupported.
func ThinkingLevelToBudget(level string) (int, bool) {
if level == "" {
return 0, false
}
normalized := strings.ToLower(strings.TrimSpace(level))
switch normalized {
case "minimal":
return 512, true
case "low":
return 1024, true
case "medium":
return 8192, true
case "high":
return 32768, true
default:
return 0, false
}
}
// ThinkingBudgetToEffort maps a numeric thinking budget (tokens)
// to a reasoning effort level for level-based models.
//

View File

@@ -4,8 +4,10 @@ import (
"fmt"
"strings"
kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
)
// ConfigSynthesizer generates Auth entries from configuration API keys.
@@ -30,6 +32,8 @@ func (s *ConfigSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth,
out = append(out, s.synthesizeClaudeKeys(ctx)...)
// Codex API Keys
out = append(out, s.synthesizeCodexKeys(ctx)...)
// Kiro (AWS CodeWhisperer)
out = append(out, s.synthesizeKiroKeys(ctx)...)
// OpenAI-compat
out = append(out, s.synthesizeOpenAICompat(ctx)...)
// Vertex-compat
@@ -292,3 +296,96 @@ func (s *ConfigSynthesizer) synthesizeVertexCompat(ctx *SynthesisContext) []*cor
}
return out
}
// synthesizeKiroKeys creates Auth entries for Kiro (AWS CodeWhisperer) tokens.
func (s *ConfigSynthesizer) synthesizeKiroKeys(ctx *SynthesisContext) []*coreauth.Auth {
cfg := ctx.Config
now := ctx.Now
idGen := ctx.IDGenerator
if len(cfg.KiroKey) == 0 {
return nil
}
out := make([]*coreauth.Auth, 0, len(cfg.KiroKey))
kAuth := kiroauth.NewKiroAuth(cfg)
for i := range cfg.KiroKey {
kk := cfg.KiroKey[i]
var accessToken, profileArn, refreshToken string
// Try to load from token file first
if kk.TokenFile != "" && kAuth != nil {
tokenData, err := kAuth.LoadTokenFromFile(kk.TokenFile)
if err != nil {
log.Warnf("failed to load kiro token file %s: %v", kk.TokenFile, err)
} else {
accessToken = tokenData.AccessToken
profileArn = tokenData.ProfileArn
refreshToken = tokenData.RefreshToken
}
}
// Override with direct config values if provided
if kk.AccessToken != "" {
accessToken = kk.AccessToken
}
if kk.ProfileArn != "" {
profileArn = kk.ProfileArn
}
if kk.RefreshToken != "" {
refreshToken = kk.RefreshToken
}
if accessToken == "" {
log.Warnf("kiro config[%d] missing access_token, skipping", i)
continue
}
// profileArn is optional for AWS Builder ID users
id, token := idGen.Next("kiro:token", accessToken, profileArn)
attrs := map[string]string{
"source": fmt.Sprintf("config:kiro[%s]", token),
"access_token": accessToken,
}
if profileArn != "" {
attrs["profile_arn"] = profileArn
}
if kk.Region != "" {
attrs["region"] = kk.Region
}
if kk.AgentTaskType != "" {
attrs["agent_task_type"] = kk.AgentTaskType
}
if kk.PreferredEndpoint != "" {
attrs["preferred_endpoint"] = kk.PreferredEndpoint
} else if cfg.KiroPreferredEndpoint != "" {
// Apply global default if not overridden by specific key
attrs["preferred_endpoint"] = cfg.KiroPreferredEndpoint
}
if refreshToken != "" {
attrs["refresh_token"] = refreshToken
}
proxyURL := strings.TrimSpace(kk.ProxyURL)
a := &coreauth.Auth{
ID: id,
Provider: "kiro",
Label: "kiro-token",
Status: coreauth.StatusActive,
ProxyURL: proxyURL,
Attributes: attrs,
CreatedAt: now,
UpdatedAt: now,
}
if refreshToken != "" {
if a.Metadata == nil {
a.Metadata = make(map[string]any)
}
a.Metadata["refresh_token"] = refreshToken
}
out = append(out, a)
}
return out
}

46
sdk/api/options.go Normal file
View File

@@ -0,0 +1,46 @@
// Package api exposes server option helpers for embedding CLIProxyAPI.
//
// It wraps internal server option types so external projects can configure the embedded
// HTTP server without importing internal packages.
package api
import (
"time"
"github.com/gin-gonic/gin"
internalapi "github.com/router-for-me/CLIProxyAPI/v6/internal/api"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/api/handlers"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/logging"
)
// ServerOption customises HTTP server construction.
type ServerOption = internalapi.ServerOption
// WithMiddleware appends additional Gin middleware during server construction.
func WithMiddleware(mw ...gin.HandlerFunc) ServerOption { return internalapi.WithMiddleware(mw...) }
// WithEngineConfigurator allows callers to mutate the Gin engine prior to middleware setup.
func WithEngineConfigurator(fn func(*gin.Engine)) ServerOption {
return internalapi.WithEngineConfigurator(fn)
}
// WithRouterConfigurator appends a callback after default routes are registered.
func WithRouterConfigurator(fn func(*gin.Engine, *handlers.BaseAPIHandler, *config.Config)) ServerOption {
return internalapi.WithRouterConfigurator(fn)
}
// WithLocalManagementPassword stores a runtime-only management password accepted for localhost requests.
func WithLocalManagementPassword(password string) ServerOption {
return internalapi.WithLocalManagementPassword(password)
}
// WithKeepAliveEndpoint enables a keep-alive endpoint with the provided timeout and callback.
func WithKeepAliveEndpoint(timeout time.Duration, onTimeout func()) ServerOption {
return internalapi.WithKeepAliveEndpoint(timeout, onTimeout)
}
// WithRequestLoggerFactory customises request logger creation.
func WithRequestLoggerFactory(factory func(*config.Config, string) logging.RequestLogger) ServerOption {
return internalapi.WithRequestLoggerFactory(factory)
}

View File

@@ -72,7 +72,9 @@ func (s *FileTokenStore) Save(ctx context.Context, auth *cliproxyauth.Auth) (str
return "", fmt.Errorf("auth filestore: marshal metadata failed: %w", errMarshal)
}
if existing, errRead := os.ReadFile(path); errRead == nil {
if jsonEqual(existing, raw) {
// Use metadataEqualIgnoringTimestamps to skip writes when only timestamp fields change.
// This prevents the token refresh loop caused by timestamp/expired/expires_in changes.
if metadataEqualIgnoringTimestamps(existing, raw) {
return path, nil
}
} else if errRead != nil && !os.IsNotExist(errRead) {
@@ -264,6 +266,8 @@ func (s *FileTokenStore) baseDirSnapshot() string {
return s.baseDir
}
// DEPRECATED: Use metadataEqualIgnoringTimestamps for comparing auth metadata.
// This function is kept for backward compatibility but can cause refresh loops.
func jsonEqual(a, b []byte) bool {
var objA any
var objB any
@@ -276,6 +280,32 @@ func jsonEqual(a, b []byte) bool {
return deepEqualJSON(objA, objB)
}
// metadataEqualIgnoringTimestamps compares two metadata JSON blobs,
// ignoring fields that change on every refresh but don't affect functionality.
// This prevents unnecessary file writes that would trigger watcher events and
// create refresh loops.
func metadataEqualIgnoringTimestamps(a, b []byte) bool {
var objA, objB map[string]any
if err := json.Unmarshal(a, &objA); err != nil {
return false
}
if err := json.Unmarshal(b, &objB); err != nil {
return false
}
// Fields to ignore: these change on every refresh but don't affect authentication logic.
// - timestamp, expired, expires_in, last_refresh: time-related fields that change on refresh
// - access_token: Google OAuth returns a new access_token on each refresh, this is expected
// and shouldn't trigger file writes (the new token will be fetched again when needed)
ignoredFields := []string{"timestamp", "expired", "expires_in", "last_refresh", "access_token"}
for _, field := range ignoredFields {
delete(objA, field)
delete(objB, field)
}
return deepEqualJSON(objA, objB)
}
func deepEqualJSON(a, b any) bool {
switch valA := a.(type) {
case map[string]any:

View File

@@ -7,10 +7,10 @@ import (
"fmt"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access"
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
)
// Builder constructs a Service instance with customizable providers.

View File

@@ -3,8 +3,8 @@ package cliproxy
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
)
// NewFileTokenClientProvider returns the default token-backed client loader.

View File

@@ -13,7 +13,6 @@ import (
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/api"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor"
_ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage"
@@ -23,6 +22,7 @@ import (
sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
log "github.com/sirupsen/logrus"
)

View File

@@ -6,9 +6,9 @@ package cliproxy
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
)
// TokenClientProvider loads clients backed by stored authentication tokens.

View File

@@ -3,9 +3,9 @@ package cliproxy
import (
"context"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/watcher"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
"github.com/router-for-me/CLIProxyAPI/v6/sdk/config"
)
func defaultWatcherFactory(configPath, authDir string, reload func(*config.Config)) (*WatcherWrapper, error) {

View File

@@ -1,87 +1,59 @@
// Package config provides configuration management for the CLI Proxy API server.
// It handles loading and parsing YAML configuration files, and provides structured
// access to application settings including server port, authentication directory,
// debug settings, proxy configuration, and API keys.
// Package config provides the public SDK configuration API.
//
// It re-exports the server configuration types and helpers so external projects can
// embed CLIProxyAPI without importing internal packages.
package config
// SDKConfig represents the application's configuration, loaded from a YAML file.
type SDKConfig struct {
// ProxyURL is the URL of an optional proxy server to use for outbound requests.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
import internalconfig "github.com/router-for-me/CLIProxyAPI/v6/internal/config"
// ForceModelPrefix requires explicit model prefixes (e.g., "teamA/gemini-3-pro-preview")
// to target prefixed credentials. When false, unprefixed model requests may use prefixed
// credentials as well.
ForceModelPrefix bool `yaml:"force-model-prefix" json:"force-model-prefix"`
type SDKConfig = internalconfig.SDKConfig
type AccessConfig = internalconfig.AccessConfig
type AccessProvider = internalconfig.AccessProvider
// RequestLog enables or disables detailed request logging functionality.
RequestLog bool `yaml:"request-log" json:"request-log"`
type Config = internalconfig.Config
// APIKeys is a list of keys for authenticating clients to this proxy server.
APIKeys []string `yaml:"api-keys" json:"api-keys"`
type TLSConfig = internalconfig.TLSConfig
type RemoteManagement = internalconfig.RemoteManagement
type AmpCode = internalconfig.AmpCode
type PayloadConfig = internalconfig.PayloadConfig
type PayloadRule = internalconfig.PayloadRule
type PayloadModelRule = internalconfig.PayloadModelRule
// Access holds request authentication provider configuration.
Access AccessConfig `yaml:"auth,omitempty" json:"auth,omitempty"`
}
type GeminiKey = internalconfig.GeminiKey
type CodexKey = internalconfig.CodexKey
type ClaudeKey = internalconfig.ClaudeKey
type VertexCompatKey = internalconfig.VertexCompatKey
type VertexCompatModel = internalconfig.VertexCompatModel
type OpenAICompatibility = internalconfig.OpenAICompatibility
type OpenAICompatibilityAPIKey = internalconfig.OpenAICompatibilityAPIKey
type OpenAICompatibilityModel = internalconfig.OpenAICompatibilityModel
// AccessConfig groups request authentication providers.
type AccessConfig struct {
// Providers lists configured authentication providers.
Providers []AccessProvider `yaml:"providers,omitempty" json:"providers,omitempty"`
}
// AccessProvider describes a request authentication provider entry.
type AccessProvider struct {
// Name is the instance identifier for the provider.
Name string `yaml:"name" json:"name"`
// Type selects the provider implementation registered via the SDK.
Type string `yaml:"type" json:"type"`
// SDK optionally names a third-party SDK module providing this provider.
SDK string `yaml:"sdk,omitempty" json:"sdk,omitempty"`
// APIKeys lists inline keys for providers that require them.
APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"`
// Config passes provider-specific options to the implementation.
Config map[string]any `yaml:"config,omitempty" json:"config,omitempty"`
}
type TLS = internalconfig.TLSConfig
const (
// AccessProviderTypeConfigAPIKey is the built-in provider validating inline API keys.
AccessProviderTypeConfigAPIKey = "config-api-key"
// DefaultAccessProviderName is applied when no provider name is supplied.
DefaultAccessProviderName = "config-inline"
AccessProviderTypeConfigAPIKey = internalconfig.AccessProviderTypeConfigAPIKey
DefaultAccessProviderName = internalconfig.DefaultAccessProviderName
DefaultPanelGitHubRepository = internalconfig.DefaultPanelGitHubRepository
)
// ConfigAPIKeyProvider returns the first inline API key provider if present.
func (c *SDKConfig) ConfigAPIKeyProvider() *AccessProvider {
if c == nil {
return nil
}
for i := range c.Access.Providers {
if c.Access.Providers[i].Type == AccessProviderTypeConfigAPIKey {
if c.Access.Providers[i].Name == "" {
c.Access.Providers[i].Name = DefaultAccessProviderName
}
return &c.Access.Providers[i]
}
}
return nil
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
return internalconfig.MakeInlineAPIKeyProvider(keys)
}
// MakeInlineAPIKeyProvider constructs an inline API key provider configuration.
// It returns nil when no keys are supplied.
func MakeInlineAPIKeyProvider(keys []string) *AccessProvider {
if len(keys) == 0 {
return nil
}
provider := &AccessProvider{
Name: DefaultAccessProviderName,
Type: AccessProviderTypeConfigAPIKey,
APIKeys: append([]string(nil), keys...),
}
return provider
func LoadConfig(configFile string) (*Config, error) { return internalconfig.LoadConfig(configFile) }
func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
return internalconfig.LoadConfigOptional(configFile, optional)
}
func SaveConfigPreserveComments(configFile string, cfg *Config) error {
return internalconfig.SaveConfigPreserveComments(configFile, cfg)
}
func SaveConfigPreserveCommentsUpdateNestedScalar(configFile string, path []string, value string) error {
return internalconfig.SaveConfigPreserveCommentsUpdateNestedScalar(configFile, path, value)
}
func NormalizeCommentIndentation(data []byte) []byte {
return internalconfig.NormalizeCommentIndentation(data)
}

View File

@@ -0,0 +1,18 @@
// Package logging re-exports request logging primitives for SDK consumers.
package logging
import internallogging "github.com/router-for-me/CLIProxyAPI/v6/internal/logging"
// RequestLogger defines the interface for logging HTTP requests and responses.
type RequestLogger = internallogging.RequestLogger
// StreamingLogWriter handles real-time logging of streaming response chunks.
type StreamingLogWriter = internallogging.StreamingLogWriter
// FileRequestLogger implements RequestLogger using file-based storage.
type FileRequestLogger = internallogging.FileRequestLogger
// NewFileRequestLogger creates a new file-based request logger.
func NewFileRequestLogger(enabled bool, logsDir string, configDir string) *FileRequestLogger {
return internallogging.NewFileRequestLogger(enabled, logsDir, configDir)
}