Compare commits

...

36 Commits

Author SHA1 Message Date
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
Luis Pater
4ea5586b6f Merge branch 'router-for-me:main' into main 2025-12-19 12:40:41 +08:00
Luis Pater
05e55d7dc5 feat(codex): update gpt-5.2 codex prompt instructions
The prompt for the gpt-5.2 codex model has been updated with more comprehensive instructions. This includes detailed guidelines on general usage, editing constraints, the plan tool, sandboxing configurations, handling special user requests, frontend task considerations, and final message presentation. The updates aim to improve the model's understanding and execution of complex coding tasks by providing clearer directives and constraints.
2025-12-19 12:38:28 +08:00
Supra4E8C
1b358c931c fix: restore get-auth-status ok fallback and document it 2025-12-19 12:15:22 +08:00
Luis Pater
e3af8783b9 Merge branch 'router-for-me:main' into main 2025-12-19 11:40:52 +08:00
Luis Pater
ca09db21ff feat(codex): add gpt-5.2 codex prompt handling
This change introduces specific logic to load and use instructions for the 'gpt-5.2-codex' model variant by recognizing the 'gpt-5.2-codex_prompt.md' filename. This ensures the correct prompts are used when the '5.2-codex' model is identified, complementing the recent addition of its definition.
2025-12-19 11:39:51 +08:00
Chén Mù
718ff7a73f Merge pull request #609 from router-for-me/codex
feat(registry): add gpt 5.2 codex model definition
2025-12-19 09:54:34 +08:00
hkfires
fa70b220e9 feat(registry): add gpt 5.2 codex model definition 2025-12-19 09:53:03 +08:00
Luis Pater
774f1fbc17 Merge pull request #586 from router-for-me/chore
chore: ignore gemini metadata files
2025-12-19 01:00:30 +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
hkfires
393e38f2c0 chore: ignore gemini metadata files 2025-12-18 13:18:15 +08:00
Luis Pater
0f646800f6 Merge branch 'router-for-me:main' into main 2025-12-18 08:36:59 +08:00
Luis Pater
ca993238f3 Merge pull request #42 from Ravens2121/master
feat(kiro): 新增授权码登录流程,优化邮箱获取与官方 Thinking 模式解析 预支持
2025-12-18 08:36:27 +08:00
Luis Pater
d1220de02d chore(docs): remove legacy documentation and unused PR workflow file 2025-12-18 08:21:58 +08:00
Ravens2121
cf9a246d53 feat(kiro): 新增 AWS Builder ID 授权码流程认证及用户邮箱获取增强
Amp-Thread-ID: https://ampcode.com/threads/T-019b2ecc-fb2d-713f-b30d-1196c7dce3e2
Co-authored-by: Amp <amp@ampcode.com>
2025-12-18 08:16:52 +08:00
Luis Pater
13eb5268de Merge pull request #582 from ben-vargas/fix-gemini-3-thinking-level
feat: use thinkingLevel for Gemini 3 models per Google documentation
2025-12-18 07:19:37 +08:00
Ben Vargas
88798816f2 fix: require dot in gemini25Pattern regex for precise matching 2025-12-17 16:09:50 -07:00
Ben Vargas
598f0af19b fix: apply thinkingLevel from model suffix metadata for Gemini 3
The previous commit added thinkingLevel support but didn't apply it
when the reasoning effort came from model name suffix (e.g., model(minimal)).

This was because ResolveThinkingConfigFromMetadata returns nil for
level-based models, bypassing the metadata application.

Changes:
- Add ApplyGemini3ThinkingLevelFromMetadata for standard Gemini API
- Add ApplyGemini3ThinkingLevelFromMetadataCLI for CLI API format
- Update gemini_cli_executor to apply Gemini 3 thinkingLevel from metadata
- Update antigravity_executor to apply Gemini 3 thinkingLevel from metadata
- Update aistudio_executor to apply Gemini 3 thinkingLevel from metadata
- Add comprehensive test coverage for Gemini 3 thinkingLevel functions
2025-12-17 16:08:38 -07:00
Ben Vargas
a33f5d31fc feat: use thinkingLevel for Gemini 3 models per Google documentation
Per Google's official documentation, Gemini 3 models should use
thinkingLevel (string) instead of thinkingBudget (number) for
optimal performance.

From Google's Gemini Thinking docs:
> Use the thinkingLevel parameter with Gemini 3 models. While
> thinkingBudget is accepted for backwards compatibility, using
> it with Gemini 3 Pro may result in suboptimal performance.

Changes:
- Add model family detection functions (IsGemini3Model, IsGemini25Model,
  IsGemini3ProModel, IsGemini3FlashModel)
- Add ApplyGeminiThinkingLevel and ApplyGeminiCLIThinkingLevel functions
  for applying thinkingLevel config
- Add ValidateGemini3ThinkingLevel for model-specific level validation
- Add ThinkingBudgetToGemini3Level for backward compatibility conversion
- Update NormalizeGeminiThinkingBudget to convert budget to level for
  Gemini 3 models
- Update ApplyDefaultThinkingIfNeeded to not set a default level for
  Gemini 3 (lets API use its dynamic default "high")
- Update ConvertThinkingLevelToBudget to preserve thinkingLevel for
  Gemini 3 models
- Add Levels field to all Gemini 3 model definitions:
  - Gemini 3 Pro: ["low", "high"]
  - Gemini 3 Flash: ["minimal", "low", "medium", "high"]

Backward compatibility:
- Gemini 2.5 models continue to use thinkingBudget as before
- If thinkingBudget is provided for Gemini 3, it's converted to the
  appropriate thinkingLevel
- Existing configurations continue to work
2025-12-17 15:28:20 -07:00
Ravens2121
54acd69e9d Merge branch 'router-for-me:main' into master 2025-12-18 04:39:17 +08:00
Ravens2121
d687ee2777 feat(kiro): implement official reasoningContentEvent and improve metadat 2025-12-18 04:38:22 +08:00
Luis Pater
54c2fefbad Merge branch 'router-for-me:main' into main 2025-12-18 03:29:00 +08:00
Luis Pater
506699fba1 ci(workflows): update pr-test-build workflow 2025-12-18 03:28:23 +08:00
Luis Pater
f7b17ee6ec Merge pull request #36 from rezhajulio/feat/gpt-5.2
Add GPT-5.2 model support for GitHub Copilot
2025-12-18 03:16:25 +08:00
Ben Vargas
de87fb622b docs: add redirect info and disable Pull app auto-sync 2025-12-17 12:06:39 -07:00
Rezha Julio
92c62bb2fb Add GPT-5.2 model support for GitHub Copilot 2025-12-17 02:15:10 +07:00
29 changed files with 2743 additions and 704 deletions

View File

@@ -27,6 +27,7 @@ config.yaml
bin/*
.claude/*
.vscode/*
.gemini/*
.serena/*
.agent/*
.bmad/*

23
.github/workflows/pr-test-build.yml vendored Normal file
View File

@@ -0,0 +1,23 @@
name: pr-test-build
on:
pull_request:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
cache: true
- name: Build
run: |
go build -o test-output ./cmd/server
rm -f test-output

1
.gitignore vendored
View File

@@ -31,6 +31,7 @@ GEMINI.md
# Tooling metadata
.vscode/*
.claude/*
.gemini/*
.serena/*
.agent/*
.bmad/*

View File

@@ -78,6 +78,7 @@ func main() {
var kiroLogin bool
var kiroGoogleLogin bool
var kiroAWSLogin bool
var kiroAWSAuthCode bool
var kiroImport bool
var githubCopilotLogin bool
var projectID string
@@ -101,6 +102,7 @@ func main() {
flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
flag.BoolVar(&kiroGoogleLogin, "kiro-google-login", false, "Login to Kiro using Google OAuth (same as --kiro-login)")
flag.BoolVar(&kiroAWSLogin, "kiro-aws-login", false, "Login to Kiro using AWS Builder ID (device code flow)")
flag.BoolVar(&kiroAWSAuthCode, "kiro-aws-authcode", false, "Login to Kiro using AWS Builder ID (authorization code flow, better UX)")
flag.BoolVar(&kiroImport, "kiro-import", false, "Import Kiro token from Kiro IDE (~/.aws/sso/cache/kiro-auth-token.json)")
flag.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow")
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
@@ -513,6 +515,10 @@ func main() {
// Users can explicitly override with --no-incognito
setKiroIncognitoMode(cfg, useIncognito, noIncognito)
cmd.DoKiroAWSLogin(cfg, options)
} else if kiroAWSAuthCode {
// For Kiro auth with authorization code flow (better UX)
setKiroIncognitoMode(cfg, useIncognito, noIncognito)
cmd.DoKiroAWSAuthCodeLogin(cfg, options)
} else if kiroImport {
cmd.DoKiroImport(cfg, options)
} else {

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

@@ -0,0 +1,166 @@
// Package kiro provides CodeWhisperer API client for fetching user info.
package kiro
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"time"
"github.com/google/uuid"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
log "github.com/sirupsen/logrus"
)
const (
codeWhispererAPI = "https://codewhisperer.us-east-1.amazonaws.com"
kiroVersion = "0.6.18"
)
// CodeWhispererClient handles CodeWhisperer API calls.
type CodeWhispererClient struct {
httpClient *http.Client
machineID string
}
// UsageLimitsResponse represents the getUsageLimits API response.
type UsageLimitsResponse struct {
DaysUntilReset *int `json:"daysUntilReset,omitempty"`
NextDateReset *float64 `json:"nextDateReset,omitempty"`
UserInfo *UserInfo `json:"userInfo,omitempty"`
SubscriptionInfo *SubscriptionInfo `json:"subscriptionInfo,omitempty"`
UsageBreakdownList []UsageBreakdown `json:"usageBreakdownList,omitempty"`
}
// UserInfo contains user information from the API.
type UserInfo struct {
Email string `json:"email,omitempty"`
UserID string `json:"userId,omitempty"`
}
// SubscriptionInfo contains subscription details.
type SubscriptionInfo struct {
SubscriptionTitle string `json:"subscriptionTitle,omitempty"`
Type string `json:"type,omitempty"`
}
// UsageBreakdown contains usage details.
type UsageBreakdown struct {
UsageLimit *int `json:"usageLimit,omitempty"`
CurrentUsage *int `json:"currentUsage,omitempty"`
UsageLimitWithPrecision *float64 `json:"usageLimitWithPrecision,omitempty"`
CurrentUsageWithPrecision *float64 `json:"currentUsageWithPrecision,omitempty"`
NextDateReset *float64 `json:"nextDateReset,omitempty"`
DisplayName string `json:"displayName,omitempty"`
ResourceType string `json:"resourceType,omitempty"`
}
// NewCodeWhispererClient creates a new CodeWhisperer client.
func NewCodeWhispererClient(cfg *config.Config, machineID string) *CodeWhispererClient {
client := &http.Client{Timeout: 30 * time.Second}
if cfg != nil {
client = util.SetProxy(&cfg.SDKConfig, client)
}
if machineID == "" {
machineID = uuid.New().String()
}
return &CodeWhispererClient{
httpClient: client,
machineID: machineID,
}
}
// generateInvocationID generates a unique invocation ID.
func generateInvocationID() string {
return uuid.New().String()
}
// GetUsageLimits fetches usage limits and user info from CodeWhisperer API.
// This is the recommended way to get user email after login.
func (c *CodeWhispererClient) GetUsageLimits(ctx context.Context, accessToken string) (*UsageLimitsResponse, error) {
url := fmt.Sprintf("%s/getUsageLimits?isEmailRequired=true&origin=AI_EDITOR&resourceType=AGENTIC_REQUEST", codeWhispererAPI)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
// Set headers to match Kiro IDE
xAmzUserAgent := fmt.Sprintf("aws-sdk-js/1.0.0 KiroIDE-%s-%s", kiroVersion, c.machineID)
userAgent := fmt.Sprintf("aws-sdk-js/1.0.0 ua/2.1 os/windows lang/js md/nodejs#20.16.0 api/codewhispererruntime#1.0.0 m/E KiroIDE-%s-%s", kiroVersion, c.machineID)
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("x-amz-user-agent", xAmzUserAgent)
req.Header.Set("User-Agent", userAgent)
req.Header.Set("amz-sdk-invocation-id", generateInvocationID())
req.Header.Set("amz-sdk-request", "attempt=1; max=1")
req.Header.Set("Connection", "close")
log.Debugf("codewhisperer: GET %s", url)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("request failed: %w", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read response: %w", err)
}
log.Debugf("codewhisperer: status=%d, body=%s", resp.StatusCode, string(body))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API returned status %d: %s", resp.StatusCode, string(body))
}
var result UsageLimitsResponse
if err := json.Unmarshal(body, &result); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &result, nil
}
// FetchUserEmailFromAPI fetches user email using CodeWhisperer getUsageLimits API.
// This is more reliable than JWT parsing as it uses the official API.
func (c *CodeWhispererClient) FetchUserEmailFromAPI(ctx context.Context, accessToken string) string {
resp, err := c.GetUsageLimits(ctx, accessToken)
if err != nil {
log.Debugf("codewhisperer: failed to get usage limits: %v", err)
return ""
}
if resp.UserInfo != nil && resp.UserInfo.Email != "" {
log.Debugf("codewhisperer: got email from API: %s", resp.UserInfo.Email)
return resp.UserInfo.Email
}
log.Debugf("codewhisperer: no email in response")
return ""
}
// FetchUserEmailWithFallback fetches user email with multiple fallback methods.
// Priority: 1. CodeWhisperer API 2. userinfo endpoint 3. JWT parsing
func FetchUserEmailWithFallback(ctx context.Context, cfg *config.Config, accessToken string) string {
// Method 1: Try CodeWhisperer API (most reliable)
cwClient := NewCodeWhispererClient(cfg, "")
email := cwClient.FetchUserEmailFromAPI(ctx, accessToken)
if email != "" {
return email
}
// Method 2: Try SSO OIDC userinfo endpoint
ssoClient := NewSSOOIDCClient(cfg)
email = ssoClient.FetchUserEmail(ctx, accessToken)
if email != "" {
return email
}
// Method 3: Fallback to JWT parsing
return ExtractEmailFromJWT(accessToken)
}

View File

@@ -163,6 +163,13 @@ func (o *KiroOAuth) LoginWithBuilderID(ctx context.Context) (*KiroTokenData, err
return ssoClient.LoginWithBuilderID(ctx)
}
// LoginWithBuilderIDAuthCode performs OAuth login with AWS Builder ID using authorization code flow.
// This provides a better UX than device code flow as it uses automatic browser callback.
func (o *KiroOAuth) LoginWithBuilderIDAuthCode(ctx context.Context) (*KiroTokenData, error) {
ssoClient := NewSSOOIDCClient(o.cfg)
return ssoClient.LoginWithBuilderIDAuthCode(ctx)
}
// exchangeCodeForToken exchanges the authorization code for tokens.
func (o *KiroOAuth) exchangeCodeForToken(ctx context.Context, code, codeVerifier, redirectURI string) (*KiroTokenData, error) {
payload := map[string]string{

View File

@@ -3,9 +3,14 @@ package kiro
import (
"context"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"fmt"
"html"
"io"
"net"
"net/http"
"strings"
"time"
@@ -25,6 +30,13 @@ const (
// Polling interval
pollInterval = 5 * time.Second
// Authorization code flow callback
authCodeCallbackPath = "/oauth/callback"
authCodeCallbackPort = 19877
// User-Agent to match official Kiro IDE
kiroUserAgent = "KiroIDE"
)
// SSOOIDCClient handles AWS SSO OIDC authentication.
@@ -73,13 +85,11 @@ type CreateTokenResponse struct {
// RegisterClient registers a new OIDC client with AWS.
func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResponse, error) {
// Generate unique client name for each registration to support multiple accounts
clientName := fmt.Sprintf("CLI-Proxy-API-%d", time.Now().UnixNano())
payload := map[string]interface{}{
"clientName": clientName,
"clientName": "Kiro IDE",
"clientType": "public",
"scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations"},
"scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"},
"grantTypes": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
}
body, err := json.Marshal(payload)
@@ -92,6 +102,7 @@ func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResp
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -135,6 +146,7 @@ func (c *SSOOIDCClient) StartDeviceAuthorization(ctx context.Context, clientID,
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -179,6 +191,7 @@ func (c *SSOOIDCClient) CreateToken(ctx context.Context, clientID, clientSecret,
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -240,6 +253,7 @@ func (c *SSOOIDCClient) RefreshToken(ctx context.Context, clientID, clientSecret
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
@@ -370,8 +384,8 @@ func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData,
fmt.Println("Fetching profile information...")
profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
// Extract email from JWT access token
email := ExtractEmailFromJWT(tokenResp.AccessToken)
// Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing)
email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
if email != "" {
fmt.Printf(" Logged in as: %s\n", email)
}
@@ -399,6 +413,68 @@ func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData,
return nil, fmt.Errorf("authorization timed out")
}
// FetchUserEmail retrieves the user's email from AWS SSO OIDC userinfo endpoint.
// Falls back to JWT parsing if userinfo fails.
func (c *SSOOIDCClient) FetchUserEmail(ctx context.Context, accessToken string) string {
// Method 1: Try userinfo endpoint (standard OIDC)
email := c.tryUserInfoEndpoint(ctx, accessToken)
if email != "" {
return email
}
// Method 2: Fallback to JWT parsing
return ExtractEmailFromJWT(accessToken)
}
// tryUserInfoEndpoint attempts to get user info from AWS SSO OIDC userinfo endpoint.
func (c *SSOOIDCClient) tryUserInfoEndpoint(ctx context.Context, accessToken string) string {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, ssoOIDCEndpoint+"/userinfo", nil)
if err != nil {
return ""
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
log.Debugf("userinfo request failed: %v", err)
return ""
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
respBody, _ := io.ReadAll(resp.Body)
log.Debugf("userinfo endpoint returned status %d: %s", resp.StatusCode, string(respBody))
return ""
}
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return ""
}
log.Debugf("userinfo response: %s", string(respBody))
var userInfo struct {
Email string `json:"email"`
Sub string `json:"sub"`
PreferredUsername string `json:"preferred_username"`
Name string `json:"name"`
}
if err := json.Unmarshal(respBody, &userInfo); err != nil {
return ""
}
if userInfo.Email != "" {
return userInfo.Email
}
if userInfo.PreferredUsername != "" && strings.Contains(userInfo.PreferredUsername, "@") {
return userInfo.PreferredUsername
}
return ""
}
// fetchProfileArn retrieves the profile ARN from CodeWhisperer API.
// This is needed for file naming since AWS SSO OIDC doesn't return profile info.
func (c *SSOOIDCClient) fetchProfileArn(ctx context.Context, accessToken string) string {
@@ -525,3 +601,323 @@ func (c *SSOOIDCClient) tryListCustomizations(ctx context.Context, accessToken s
return ""
}
// RegisterClientForAuthCode registers a new OIDC client for authorization code flow.
func (c *SSOOIDCClient) RegisterClientForAuthCode(ctx context.Context, redirectURI string) (*RegisterClientResponse, error) {
payload := map[string]interface{}{
"clientName": "Kiro IDE",
"clientType": "public",
"scopes": []string{"codewhisperer:completions", "codewhisperer:analysis", "codewhisperer:conversations", "codewhisperer:transformations", "codewhisperer:taskassist"},
"grantTypes": []string{"authorization_code", "refresh_token"},
"redirectUris": []string{redirectURI},
"issuerUrl": builderIDStartURL,
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/client/register", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
log.Debugf("register client for auth code failed (status %d): %s", resp.StatusCode, string(respBody))
return nil, fmt.Errorf("register client failed (status %d)", resp.StatusCode)
}
var result RegisterClientResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, err
}
return &result, nil
}
// AuthCodeCallbackResult contains the result from authorization code callback.
type AuthCodeCallbackResult struct {
Code string
State string
Error string
}
// startAuthCodeCallbackServer starts a local HTTP server to receive the authorization code callback.
func (c *SSOOIDCClient) startAuthCodeCallbackServer(ctx context.Context, expectedState string) (string, <-chan AuthCodeCallbackResult, error) {
// Try to find an available port
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", authCodeCallbackPort))
if err != nil {
// Try with dynamic port
log.Warnf("sso oidc: default port %d is busy, falling back to dynamic port", authCodeCallbackPort)
listener, err = net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return "", nil, fmt.Errorf("failed to start callback server: %w", err)
}
}
port := listener.Addr().(*net.TCPAddr).Port
redirectURI := fmt.Sprintf("http://127.0.0.1:%d%s", port, authCodeCallbackPath)
resultChan := make(chan AuthCodeCallbackResult, 1)
server := &http.Server{
ReadHeaderTimeout: 10 * time.Second,
}
mux := http.NewServeMux()
mux.HandleFunc(authCodeCallbackPath, func(w http.ResponseWriter, r *http.Request) {
code := r.URL.Query().Get("code")
state := r.URL.Query().Get("state")
errParam := r.URL.Query().Get("error")
// Send response to browser
w.Header().Set("Content-Type", "text/html; charset=utf-8")
if errParam != "" {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprintf(w, `<!DOCTYPE html>
<html><head><title>Login Failed</title></head>
<body><h1>Login Failed</h1><p>Error: %s</p><p>You can close this window.</p></body></html>`, html.EscapeString(errParam))
resultChan <- AuthCodeCallbackResult{Error: errParam}
return
}
if state != expectedState {
w.WriteHeader(http.StatusBadRequest)
fmt.Fprint(w, `<!DOCTYPE html>
<html><head><title>Login Failed</title></head>
<body><h1>Login Failed</h1><p>Invalid state parameter</p><p>You can close this window.</p></body></html>`)
resultChan <- AuthCodeCallbackResult{Error: "state mismatch"}
return
}
fmt.Fprint(w, `<!DOCTYPE html>
<html><head><title>Login Successful</title></head>
<body><h1>Login Successful!</h1><p>You can close this window and return to the terminal.</p>
<script>window.close();</script></body></html>`)
resultChan <- AuthCodeCallbackResult{Code: code, State: state}
})
server.Handler = mux
go func() {
if err := server.Serve(listener); err != nil && err != http.ErrServerClosed {
log.Debugf("auth code callback server error: %v", err)
}
}()
go func() {
select {
case <-ctx.Done():
case <-time.After(10 * time.Minute):
case <-resultChan:
}
_ = server.Shutdown(context.Background())
}()
return redirectURI, resultChan, nil
}
// generatePKCEForAuthCode generates PKCE code verifier and challenge for authorization code flow.
func generatePKCEForAuthCode() (verifier, challenge string, err error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", "", fmt.Errorf("failed to generate random bytes: %w", err)
}
verifier = base64.RawURLEncoding.EncodeToString(b)
h := sha256.Sum256([]byte(verifier))
challenge = base64.RawURLEncoding.EncodeToString(h[:])
return verifier, challenge, nil
}
// generateStateForAuthCode generates a random state parameter.
func generateStateForAuthCode() (string, error) {
b := make([]byte, 16)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
// CreateTokenWithAuthCode exchanges authorization code for tokens.
func (c *SSOOIDCClient) CreateTokenWithAuthCode(ctx context.Context, clientID, clientSecret, code, codeVerifier, redirectURI string) (*CreateTokenResponse, error) {
payload := map[string]string{
"clientId": clientID,
"clientSecret": clientSecret,
"code": code,
"codeVerifier": codeVerifier,
"redirectUri": redirectURI,
"grantType": "authorization_code",
}
body, err := json.Marshal(payload)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, ssoOIDCEndpoint+"/token", strings.NewReader(string(body)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("User-Agent", kiroUserAgent)
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
log.Debugf("create token with auth code failed (status %d): %s", resp.StatusCode, string(respBody))
return nil, fmt.Errorf("create token failed (status %d)", resp.StatusCode)
}
var result CreateTokenResponse
if err := json.Unmarshal(respBody, &result); err != nil {
return nil, err
}
return &result, nil
}
// LoginWithBuilderIDAuthCode performs the authorization code flow for AWS Builder ID.
// This provides a better UX than device code flow as it uses automatic browser callback.
func (c *SSOOIDCClient) LoginWithBuilderIDAuthCode(ctx context.Context) (*KiroTokenData, error) {
fmt.Println("\n╔══════════════════════════════════════════════════════════╗")
fmt.Println("║ Kiro Authentication (AWS Builder ID - Auth Code) ║")
fmt.Println("╚══════════════════════════════════════════════════════════╝")
// Step 1: Generate PKCE and state
codeVerifier, codeChallenge, err := generatePKCEForAuthCode()
if err != nil {
return nil, fmt.Errorf("failed to generate PKCE: %w", err)
}
state, err := generateStateForAuthCode()
if err != nil {
return nil, fmt.Errorf("failed to generate state: %w", err)
}
// Step 2: Start callback server
fmt.Println("\nStarting callback server...")
redirectURI, resultChan, err := c.startAuthCodeCallbackServer(ctx, state)
if err != nil {
return nil, fmt.Errorf("failed to start callback server: %w", err)
}
log.Debugf("Callback server started, redirect URI: %s", redirectURI)
// Step 3: Register client with auth code grant type
fmt.Println("Registering client...")
regResp, err := c.RegisterClientForAuthCode(ctx, redirectURI)
if err != nil {
return nil, fmt.Errorf("failed to register client: %w", err)
}
log.Debugf("Client registered: %s", regResp.ClientID)
// Step 4: Build authorization URL
scopes := "codewhisperer:completions,codewhisperer:analysis,codewhisperer:conversations"
authURL := fmt.Sprintf("%s/authorize?response_type=code&client_id=%s&redirect_uri=%s&scopes=%s&state=%s&code_challenge=%s&code_challenge_method=S256",
ssoOIDCEndpoint,
regResp.ClientID,
redirectURI,
scopes,
state,
codeChallenge,
)
// Step 5: Open browser
fmt.Println("\n════════════════════════════════════════════════════════════")
fmt.Println(" Opening browser for authentication...")
fmt.Println("════════════════════════════════════════════════════════════")
fmt.Printf("\n URL: %s\n\n", authURL)
// Set incognito mode
if c.cfg != nil {
browser.SetIncognitoMode(c.cfg.IncognitoBrowser)
} else {
browser.SetIncognitoMode(true)
}
if err := browser.OpenURL(authURL); err != nil {
log.Warnf("Could not open browser automatically: %v", err)
fmt.Println(" ⚠ Could not open browser automatically.")
fmt.Println(" Please open the URL above in your browser manually.")
} else {
fmt.Println(" (Browser opened automatically)")
}
fmt.Println("\n Waiting for authorization callback...")
// Step 6: Wait for callback
select {
case <-ctx.Done():
browser.CloseBrowser()
return nil, ctx.Err()
case <-time.After(10 * time.Minute):
browser.CloseBrowser()
return nil, fmt.Errorf("authorization timed out")
case result := <-resultChan:
if result.Error != "" {
browser.CloseBrowser()
return nil, fmt.Errorf("authorization failed: %s", result.Error)
}
fmt.Println("\n✓ Authorization received!")
// Close browser
if err := browser.CloseBrowser(); err != nil {
log.Debugf("Failed to close browser: %v", err)
}
// Step 7: Exchange code for tokens
fmt.Println("Exchanging code for tokens...")
tokenResp, err := c.CreateTokenWithAuthCode(ctx, regResp.ClientID, regResp.ClientSecret, result.Code, codeVerifier, redirectURI)
if err != nil {
return nil, fmt.Errorf("failed to exchange code for tokens: %w", err)
}
fmt.Println("\n✓ Authentication successful!")
// Step 8: Get profile ARN
fmt.Println("Fetching profile information...")
profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
// Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing)
email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
if email != "" {
fmt.Printf(" Logged in as: %s\n", email)
}
expiresAt := time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second)
return &KiroTokenData{
AccessToken: tokenResp.AccessToken,
RefreshToken: tokenResp.RefreshToken,
ProfileArn: profileArn,
ExpiresAt: expiresAt.Format(time.RFC3339),
AuthMethod: "builder-id",
Provider: "AWS",
ClientID: regResp.ClientID,
ClientSecret: regResp.ClientSecret,
Email: email,
}, nil
}
}

View File

@@ -116,6 +116,54 @@ func DoKiroAWSLogin(cfg *config.Config, options *LoginOptions) {
fmt.Println("Kiro AWS authentication successful!")
}
// DoKiroAWSAuthCodeLogin triggers Kiro authentication with AWS Builder ID using authorization code flow.
// This provides a better UX than device code flow as it uses automatic browser callback.
//
// Parameters:
// - cfg: The application configuration
// - options: Login options including prompts
func DoKiroAWSAuthCodeLogin(cfg *config.Config, options *LoginOptions) {
if options == nil {
options = &LoginOptions{}
}
// Note: Kiro defaults to incognito mode for multi-account support.
// Users can override with --no-incognito if they want to use existing browser sessions.
manager := newAuthManager()
// Use KiroAuthenticator with AWS Builder ID login (authorization code flow)
authenticator := sdkAuth.NewKiroAuthenticator()
record, err := authenticator.LoginWithAuthCode(context.Background(), cfg, &sdkAuth.LoginOptions{
NoBrowser: options.NoBrowser,
Metadata: map[string]string{},
Prompt: options.Prompt,
})
if err != nil {
log.Errorf("Kiro AWS authentication (auth code) failed: %v", err)
fmt.Println("\nTroubleshooting:")
fmt.Println("1. Make sure you have an AWS Builder ID")
fmt.Println("2. Complete the authorization in the browser")
fmt.Println("3. If callback fails, try: --kiro-aws-login (device code flow)")
return
}
// Save the auth record
savedPath, err := manager.SaveAuth(record, cfg)
if err != nil {
log.Errorf("Failed to save auth: %v", err)
return
}
if savedPath != "" {
fmt.Printf("Authentication saved to %s\n", savedPath)
}
if record != nil && record.Label != "" {
fmt.Printf("Authenticated as %s\n", record.Label)
}
fmt.Println("Kiro AWS authentication successful!")
}
// DoKiroImport imports Kiro token from Kiro IDE's token file.
// This is useful for users who have already logged in via Kiro IDE
// and want to use the same credentials in CLI Proxy API.

View File

@@ -20,6 +20,7 @@ func CodexInstructionsForModel(modelName, systemInstructions string) (bool, stri
lastCodexMaxPrompt := ""
last51Prompt := ""
last52Prompt := ""
last52CodexPrompt := ""
// lastReviewPrompt := ""
for _, entry := range entries {
content, _ := codexInstructionsDir.ReadFile("codex_instructions/" + entry.Name())
@@ -36,12 +37,16 @@ func CodexInstructionsForModel(modelName, systemInstructions string) (bool, stri
last51Prompt = string(content)
} else if strings.HasPrefix(entry.Name(), "gpt_5_2_prompt.md") {
last52Prompt = string(content)
} else if strings.HasPrefix(entry.Name(), "gpt-5.2-codex_prompt.md") {
last52CodexPrompt = string(content)
} else if strings.HasPrefix(entry.Name(), "review_prompt.md") {
// lastReviewPrompt = string(content)
}
}
if strings.Contains(modelName, "codex-max") {
return false, lastCodexMaxPrompt
} else if strings.Contains(modelName, "5.2-codex") {
return false, last52CodexPrompt
} else if strings.Contains(modelName, "codex") {
return false, lastCodexPrompt
} else if strings.Contains(modelName, "5.1") {

View File

@@ -0,0 +1,117 @@
You are Codex, based on GPT-5. You are running as a coding agent in the Codex CLI on a user's computer.
## General
- When searching for text or files, prefer using `rg` or `rg --files` respectively because `rg` is much faster than alternatives like `grep`. (If the `rg` command is not found, then use alternatives.)
## Editing constraints
- Default to ASCII when editing or creating files. Only introduce non-ASCII or other Unicode characters when there is a clear justification and the file already uses them.
- Add succinct code comments that explain what is going on if code is not self-explanatory. You should not add comments like "Assigns the value to the variable", but a brief comment might be useful ahead of a complex code block that the user would otherwise have to spend time parsing out. Usage of these comments should be rare.
- Try to use apply_patch for single file edits, but it is fine to explore other options to make the edit if it does not work well. Do not use apply_patch for changes that are auto-generated (i.e. generating package.json or running a lint or format command like gofmt) or when scripting is more efficient (such as search and replacing a string across a codebase).
- You may be in a dirty git worktree.
* NEVER revert existing changes you did not make unless explicitly requested, since these changes were made by the user.
* If asked to make a commit or code edits and there are unrelated changes to your work or changes that you didn't make in those files, don't revert those changes.
* If the changes are in files you've touched recently, you should read carefully and understand how you can work with the changes rather than reverting them.
* If the changes are in unrelated files, just ignore them and don't revert them.
- Do not amend a commit unless explicitly requested to do so.
- While you are working, you might notice unexpected changes that you didn't make. If this happens, STOP IMMEDIATELY and ask the user how they would like to proceed.
- **NEVER** use destructive commands like `git reset --hard` or `git checkout --` unless specifically requested or approved by the user.
## Plan tool
When using the planning tool:
- Skip using the planning tool for straightforward tasks (roughly the easiest 25%).
- Do not make single-step plans.
- When you made a plan, update it after having performed one of the sub-tasks that you shared on the plan.
## Codex CLI harness, sandboxing, and approvals
The Codex CLI harness supports several different configurations for sandboxing and escalation approvals that the user can choose from.
Filesystem sandboxing defines which files can be read or written. The options for `sandbox_mode` are:
- **read-only**: The sandbox only permits reading files.
- **workspace-write**: The sandbox permits reading files, and editing files in `cwd` and `writable_roots`. Editing files in other directories requires approval.
- **danger-full-access**: No filesystem sandboxing - all commands are permitted.
Network sandboxing defines whether network can be accessed without approval. Options for `network_access` are:
- **restricted**: Requires approval
- **enabled**: No approval needed
Approvals are your mechanism to get user consent to run shell commands without the sandbox. Possible configuration options for `approval_policy` are
- **untrusted**: The harness will escalate most commands for user approval, apart from a limited allowlist of safe "read" commands.
- **on-failure**: The harness will allow all commands to run in the sandbox (if enabled), and failures will be escalated to the user for approval to run again without the sandbox.
- **on-request**: Commands will be run in the sandbox by default, and you can specify in your tool call if you want to escalate a command to run without sandboxing. (Note that this mode is not always available. If it is, you'll see parameters for it in the `shell` command description.)
- **never**: This is a non-interactive mode where you may NEVER ask the user for approval to run commands. Instead, you must always persist and work around constraints to solve the task for the user. You MUST do your utmost best to finish the task and validate your work before yielding. If this mode is paired with `danger-full-access`, take advantage of it to deliver the best outcome for the user. Further, in this mode, your default testing philosophy is overridden: Even if you don't see local patterns for testing, you may add tests and scripts to validate your work. Just remove them before yielding.
When you are running with `approval_policy == on-request`, and sandboxing enabled, here are scenarios where you'll need to request approval:
- You need to run a command that writes to a directory that requires it (e.g. running tests that write to /var)
- You need to run a GUI app (e.g., open/xdg-open/osascript) to open browsers or files.
- You are running sandboxed and need to run a command that requires network access (e.g. installing packages)
- If you run a command that is important to solving the user's query, but it fails because of sandboxing, rerun the command with approval. ALWAYS proceed to use the `sandbox_permissions` and `justification` parameters - do not message the user before requesting approval for the command.
- You are about to take a potentially destructive action such as an `rm` or `git reset` that the user did not explicitly ask for
- (for all of these, you should weigh alternative paths that do not require approval)
When `sandbox_mode` is set to read-only, you'll need to request approval for any command that isn't a read.
You will be told what filesystem sandboxing, network sandboxing, and approval mode are active in a developer or user message. If you are not told about this, assume that you are running with workspace-write, network sandboxing enabled, and approval on-failure.
Although they introduce friction to the user because your work is paused until the user responds, you should leverage them when necessary to accomplish important work. If the completing the task requires escalated permissions, Do not let these settings or the sandbox deter you from attempting to accomplish the user's task unless it is set to "never", in which case never ask for approvals.
When requesting approval to execute a command that will require escalated privileges:
- Provide the `sandbox_permissions` parameter with the value `"require_escalated"`
- Include a short, 1 sentence explanation for why you need escalated permissions in the justification parameter
## Special user requests
- If the user makes a simple request (such as asking for the time) which you can fulfill by running a terminal command (such as `date`), you should do so.
- If the user asks for a "review", default to a code review mindset: prioritise identifying bugs, risks, behavioural regressions, and missing tests. Findings must be the primary focus of the response - keep summaries or overviews brief and only after enumerating the issues. Present findings first (ordered by severity with file/line references), follow with open questions or assumptions, and offer a change-summary only as a secondary detail. If no findings are discovered, state that explicitly and mention any residual risks or testing gaps.
## Frontend tasks
When doing frontend design tasks, avoid collapsing into "AI slop" or safe, average-looking layouts.
Aim for interfaces that feel intentional, bold, and a bit surprising.
- Typography: Use expressive, purposeful fonts and avoid default stacks (Inter, Roboto, Arial, system).
- Color & Look: Choose a clear visual direction; define CSS variables; avoid purple-on-white defaults. No purple bias or dark mode bias.
- Motion: Use a few meaningful animations (page-load, staggered reveals) instead of generic micro-motions.
- Background: Don't rely on flat, single-color backgrounds; use gradients, shapes, or subtle patterns to build atmosphere.
- Overall: Avoid boilerplate layouts and interchangeable UI patterns. Vary themes, type families, and visual languages across outputs.
- Ensure the page loads properly on both desktop and mobile
Exception: If working within an existing website or design system, preserve the established patterns, structure, and visual language.
## Presenting your work and final message
You are producing plain text that will later be styled by the CLI. Follow these rules exactly. Formatting should make results easy to scan, but not feel mechanical. Use judgment to decide how much structure adds value.
- Default: be very concise; friendly coding teammate tone.
- Ask only when needed; suggest ideas; mirror the user's style.
- For substantial work, summarize clearly; follow finalanswer formatting.
- Skip heavy formatting for simple confirmations.
- Don't dump large files you've written; reference paths only.
- No "save/copy this file" - User is on the same machine.
- Offer logical next steps (tests, commits, build) briefly; add verify steps if you couldn't do something.
- For code changes:
* Lead with a quick explanation of the change, and then give more details on the context covering where and why a change was made. Do not start this explanation with "summary", just jump right in.
* If there are natural next steps the user may want to take, suggest them at the end of your response. Do not make suggestions if there are no natural next steps.
* When suggesting multiple options, use numeric lists for the suggestions so the user can quickly respond with a single number.
- The user does not command execution outputs. When asked to show the output of a command (e.g. `git show`), relay the important details in your answer or summarize the key lines so the user understands the result.
### Final answer structure and style guidelines
- Plain text; CLI handles styling. Use structure only when it helps scanability.
- Headers: optional; short Title Case (1-3 words) wrapped in **…**; no blank line before the first bullet; add only if they truly help.
- Bullets: use - ; merge related points; keep to one line when possible; 46 per list ordered by importance; keep phrasing consistent.
- Monospace: backticks for commands/paths/env vars/code ids and inline examples; use for literal keyword bullets; never combine with **.
- Code samples or multi-line snippets should be wrapped in fenced code blocks; include an info string as often as possible.
- Structure: group related bullets; order sections general → specific → supporting; for subsections, start with a bolded keyword bullet, then items; match complexity to the task.
- Tone: collaborative, concise, factual; present tense, active voice; selfcontained; no "above/below"; parallel wording.
- Don'ts: no nested bullets/hierarchies; no ANSI codes; don't cram unrelated keywords; keep keyword lists short—wrap/reformat if long; avoid naming formatting styles in answers.
- Adaptation: code explanations → precise, structured with code refs; simple tasks → lead with outcome; big changes → logical walkthrough + rationale + next actions; casual one-offs → plain sentences, no headers/bullets.
- File References: When referencing files in your response follow the below rules:
* Use inline code to make file paths clickable.
* Each reference should have a stand alone path. Even if it's the same file.
* Accepted: absolute, workspacerelative, a/ or b/ diff prefixes, or bare filename/suffix.
* Optionally include line/column (1based): :line[:column] or #Lline[Ccolumn] (column defaults to 1).
* Do not use URIs like file://, vscode://, or https://.
* Do not provide range of lines
* Examples: src/app.ts, src/app.ts:42, b/server/index.js#L10, C:\repo\project\main.rs:12:5

View File

@@ -160,7 +160,7 @@ func GetGeminiModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}},
},
{
ID: "gemini-3-pro-image-preview",
@@ -175,7 +175,7 @@ func GetGeminiModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}},
},
}
}
@@ -240,7 +240,7 @@ func GetGeminiVertexModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}},
},
{
ID: "gemini-3-flash-preview",
@@ -255,7 +255,7 @@ func GetGeminiVertexModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}},
},
{
ID: "gemini-3-pro-image-preview",
@@ -270,7 +270,7 @@ func GetGeminiVertexModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}},
},
}
}
@@ -336,7 +336,7 @@ func GetGeminiCLIModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}},
},
{
ID: "gemini-3-flash-preview",
@@ -351,7 +351,7 @@ func GetGeminiCLIModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}},
},
}
}
@@ -417,7 +417,7 @@ func GetAIStudioModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}},
},
{
ID: "gemini-3-flash-preview",
@@ -432,7 +432,7 @@ func GetAIStudioModels() []*ModelInfo {
InputTokenLimit: 1048576,
OutputTokenLimit: 65536,
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}},
},
{
ID: "gemini-pro-latest",
@@ -627,6 +627,20 @@ func GetOpenAIModels() []*ModelInfo {
SupportedParameters: []string{"tools"},
Thinking: &ThinkingSupport{Levels: []string{"none", "low", "medium", "high", "xhigh"}},
},
{
ID: "gpt-5.2-codex",
Object: "model",
Created: 1765440000,
OwnedBy: "openai",
Type: "openai",
Version: "gpt-5.2",
DisplayName: "GPT 5.2 Codex",
Description: "Stable version of GPT 5.2 Codex, The best model for coding and agentic tasks across domains.",
ContextLength: 400000,
MaxCompletionTokens: 128000,
SupportedParameters: []string{"tools"},
Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high", "xhigh"}},
},
}
}
@@ -743,8 +757,9 @@ func GetAntigravityModelConfig() map[string]*AntigravityModelConfig {
"gemini-2.5-flash": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash"},
"gemini-2.5-flash-lite": {Thinking: &ThinkingSupport{Min: 0, Max: 24576, ZeroAllowed: true, DynamicAllowed: true}, Name: "models/gemini-2.5-flash-lite"},
"gemini-2.5-computer-use-preview-10-2025": {Name: "models/gemini-2.5-computer-use-preview-10-2025"},
"gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/gemini-3-pro-preview"},
"gemini-3-pro-image-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true}, Name: "models/gemini-3-pro-image-preview"},
"gemini-3-pro-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-preview"},
"gemini-3-pro-image-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"low", "high"}}, Name: "models/gemini-3-pro-image-preview"},
"gemini-3-flash-preview": {Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true, Levels: []string{"minimal", "low", "medium", "high"}}, Name: "models/gemini-3-flash-preview"},
"gemini-claude-sonnet-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 200000, ZeroAllowed: false, DynamicAllowed: true}, MaxCompletionTokens: 64000},
"gemini-claude-opus-4-5-thinking": {Thinking: &ThinkingSupport{Min: 1024, Max: 200000, ZeroAllowed: false, DynamicAllowed: true}, MaxCompletionTokens: 64000},
}
@@ -832,6 +847,17 @@ func GetGitHubCopilotModels() []*ModelInfo {
ContextLength: 128000,
MaxCompletionTokens: 16384,
},
{
ID: "gpt-5.2",
Object: "model",
Created: now,
OwnedBy: "github-copilot",
Type: "github-copilot",
DisplayName: "GPT-5.2",
Description: "OpenAI GPT-5.2 via GitHub Copilot",
ContextLength: 200000,
MaxCompletionTokens: 32768,
},
{
ID: "claude-haiku-4.5",
Object: "model",

View File

@@ -323,9 +323,10 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
to := sdktranslator.FromString("gemini")
payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream)
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)
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

@@ -94,6 +94,7 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
translated = normalizeAntigravityThinking(req.Model, translated)
@@ -187,6 +188,7 @@ func (e *AntigravityExecutor) executeClaudeNonStream(ctx context.Context, auth *
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
translated = normalizeAntigravityThinking(req.Model, translated)
@@ -519,6 +521,7 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)
translated = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, translated)
translated = util.ApplyDefaultThinkingIfNeededCLI(req.Model, translated)
translated = normalizeAntigravityThinking(req.Model, translated)

View File

@@ -79,6 +79,7 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
to := sdktranslator.FromString("gemini-cli")
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)
basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload)
basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
@@ -217,6 +218,7 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
to := sdktranslator.FromString("gemini-cli")
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
basePayload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, basePayload)
basePayload = util.ApplyDefaultThinkingIfNeededCLI(req.Model, basePayload)
basePayload = util.NormalizeGeminiCLIThinkingBudget(req.Model, basePayload)
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
@@ -418,6 +420,7 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
for _, attemptModel := range models {
payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false)
payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model)
payload = util.ApplyGemini3ThinkingLevelFromMetadataCLI(req.Model, req.Metadata, payload)
payload = deleteJSONField(payload, "project")
payload = deleteJSONField(payload, "model")
payload = deleteJSONField(payload, "request.safetySettings")

File diff suppressed because it is too large Load Diff

View File

@@ -73,10 +73,12 @@ func tokenizerForModel(model string) (*TokenizerWrapper, error) {
switch {
case sanitized == "":
enc, err = tokenizer.Get(tokenizer.Cl100kBase)
case strings.HasPrefix(sanitized, "gpt-5"):
case strings.HasPrefix(sanitized, "gpt-5.2"):
enc, err = tokenizer.ForModel(tokenizer.GPT5)
case strings.HasPrefix(sanitized, "gpt-5.1"):
enc, err = tokenizer.ForModel(tokenizer.GPT5)
case strings.HasPrefix(sanitized, "gpt-5"):
enc, err = tokenizer.ForModel(tokenizer.GPT5)
case strings.HasPrefix(sanitized, "gpt-4.1"):
enc, err = tokenizer.ForModel(tokenizer.GPT41)
case strings.HasPrefix(sanitized, "gpt-4o"):
@@ -154,10 +156,10 @@ func countClaudeChatTokens(enc *TokenizerWrapper, payload []byte) (int64, error)
// Collect system prompt (can be string or array of content blocks)
collectClaudeSystem(root.Get("system"), &segments)
// Collect messages
collectClaudeMessages(root.Get("messages"), &segments)
// Collect tools
collectClaudeTools(root.Get("tools"), &segments)

View File

@@ -25,7 +25,7 @@ type Params struct {
HasFirstResponse bool
ResponseType int
ResponseIndex int
HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
HasContent bool // Tracks whether any content (text, thinking, or tool use) has been output
}
// toolUseIDCounter provides a process-wide unique counter for tool use identifiers.

View File

@@ -222,20 +222,19 @@ func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isA
kiroTools := convertClaudeToolsToKiro(tools)
// Thinking mode implementation:
// Kiro API doesn't accept max_tokens for thinking. Instead, thinking mode is enabled
// by injecting <thinking_mode> and <max_thinking_length> tags into the system prompt.
// We use a fixed max_thinking_length value since Kiro handles the actual budget internally.
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
// When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
// rather than inline <thinking> tags in assistantResponseEvent.
// We use a high max_thinking_length to allow extensive reasoning.
if thinkingEnabled {
thinkingHint := `<thinking_mode>interleaved</thinking_mode>
<max_thinking_length>200000</max_thinking_length>
IMPORTANT: You MUST use <thinking>...</thinking> tags to show your reasoning process before providing your final response. Think step by step inside the thinking tags.`
thinkingHint := `<thinking_mode>enabled</thinking_mode>
<max_thinking_length>200000</max_thinking_length>`
if systemPrompt != "" {
systemPrompt = thinkingHint + "\n\n" + systemPrompt
} else {
systemPrompt = thinkingHint
}
log.Infof("kiro: injected thinking prompt, has_tools: %v", len(kiroTools) > 0)
log.Infof("kiro: injected thinking prompt (official mode), has_tools: %v", len(kiroTools) > 0)
}
// Process messages and build history

View File

@@ -231,20 +231,19 @@ func BuildKiroPayloadFromOpenAI(openaiBody []byte, modelID, profileArn, origin s
kiroTools := convertOpenAIToolsToKiro(tools)
// Thinking mode implementation:
// Kiro API doesn't accept max_tokens for thinking. Instead, thinking mode is enabled
// by injecting <thinking_mode> and <max_thinking_length> tags into the system prompt.
// We use a fixed max_thinking_length value since Kiro handles the actual budget internally.
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
// When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
// rather than inline <thinking> tags in assistantResponseEvent.
// We use a high max_thinking_length to allow extensive reasoning.
if thinkingEnabled {
thinkingHint := `<thinking_mode>interleaved</thinking_mode>
<max_thinking_length>200000</max_thinking_length>
IMPORTANT: You MUST use <thinking>...</thinking> tags to show your reasoning process before providing your final response. Think step by step inside the thinking tags.`
thinkingHint := `<thinking_mode>enabled</thinking_mode>
<max_thinking_length>200000</max_thinking_length>`
if systemPrompt != "" {
systemPrompt = thinkingHint + "\n\n" + systemPrompt
} else {
systemPrompt = thinkingHint
}
log.Debugf("kiro-openai: injected thinking prompt")
log.Debugf("kiro-openai: injected thinking prompt (official mode)")
}
// Process messages and build history

View File

@@ -1,6 +1,7 @@
package util
import (
"regexp"
"strings"
"github.com/tidwall/gjson"
@@ -13,6 +14,44 @@ const (
GeminiOriginalModelMetadataKey = "gemini_original_model"
)
// Gemini model family detection patterns
var (
gemini3Pattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]`)
gemini3ProPattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]pro`)
gemini3FlashPattern = regexp.MustCompile(`(?i)^gemini[_-]?3[_-]flash`)
gemini25Pattern = regexp.MustCompile(`(?i)^gemini[_-]?2\.5[_-]`)
)
// IsGemini3Model returns true if the model is a Gemini 3 family model.
// Gemini 3 models should use thinkingLevel (string) instead of thinkingBudget (number).
func IsGemini3Model(model string) bool {
return gemini3Pattern.MatchString(model)
}
// IsGemini3ProModel returns true if the model is a Gemini 3 Pro variant.
// Gemini 3 Pro supports thinkingLevel: "low", "high" (default: "high")
func IsGemini3ProModel(model string) bool {
return gemini3ProPattern.MatchString(model)
}
// IsGemini3FlashModel returns true if the model is a Gemini 3 Flash variant.
// Gemini 3 Flash supports thinkingLevel: "minimal", "low", "medium", "high" (default: "high")
func IsGemini3FlashModel(model string) bool {
return gemini3FlashPattern.MatchString(model)
}
// IsGemini25Model returns true if the model is a Gemini 2.5 family model.
// Gemini 2.5 models should use thinkingBudget (number).
func IsGemini25Model(model string) bool {
return gemini25Pattern.MatchString(model)
}
// Gemini3ProThinkingLevels are the valid thinkingLevel values for Gemini 3 Pro models.
var Gemini3ProThinkingLevels = []string{"low", "high"}
// Gemini3FlashThinkingLevels are the valid thinkingLevel values for Gemini 3 Flash models.
var Gemini3FlashThinkingLevels = []string{"minimal", "low", "medium", "high"}
func ApplyGeminiThinkingConfig(body []byte, budget *int, includeThoughts *bool) []byte {
if budget == nil && includeThoughts == nil {
return body
@@ -69,10 +108,141 @@ func ApplyGeminiCLIThinkingConfig(body []byte, budget *int, includeThoughts *boo
return updated
}
// ApplyGeminiThinkingLevel applies thinkingLevel config for Gemini 3 models.
// For standard Gemini API format (generationConfig.thinkingConfig path).
// Per Google's documentation, Gemini 3 models should use thinkingLevel instead of thinkingBudget.
func ApplyGeminiThinkingLevel(body []byte, level string, includeThoughts *bool) []byte {
if level == "" && includeThoughts == nil {
return body
}
updated := body
if level != "" {
valuePath := "generationConfig.thinkingConfig.thinkingLevel"
rewritten, err := sjson.SetBytes(updated, valuePath, level)
if err == nil {
updated = rewritten
}
}
// Default to including thoughts when a level is set but no explicit include flag is provided.
incl := includeThoughts
if incl == nil && level != "" {
defaultInclude := true
incl = &defaultInclude
}
if incl != nil {
valuePath := "generationConfig.thinkingConfig.includeThoughts"
rewritten, err := sjson.SetBytes(updated, valuePath, *incl)
if err == nil {
updated = rewritten
}
}
return updated
}
// ApplyGeminiCLIThinkingLevel applies thinkingLevel config for Gemini 3 models.
// For Gemini CLI API format (request.generationConfig.thinkingConfig path).
// Per Google's documentation, Gemini 3 models should use thinkingLevel instead of thinkingBudget.
func ApplyGeminiCLIThinkingLevel(body []byte, level string, includeThoughts *bool) []byte {
if level == "" && includeThoughts == nil {
return body
}
updated := body
if level != "" {
valuePath := "request.generationConfig.thinkingConfig.thinkingLevel"
rewritten, err := sjson.SetBytes(updated, valuePath, level)
if err == nil {
updated = rewritten
}
}
// Default to including thoughts when a level is set but no explicit include flag is provided.
incl := includeThoughts
if incl == nil && level != "" {
defaultInclude := true
incl = &defaultInclude
}
if incl != nil {
valuePath := "request.generationConfig.thinkingConfig.includeThoughts"
rewritten, err := sjson.SetBytes(updated, valuePath, *incl)
if err == nil {
updated = rewritten
}
}
return updated
}
// ValidateGemini3ThinkingLevel validates that the thinkingLevel is valid for the Gemini 3 model variant.
// Returns the validated level (normalized to lowercase) and true if valid, or empty string and false if invalid.
func ValidateGemini3ThinkingLevel(model, level string) (string, bool) {
if level == "" {
return "", false
}
normalized := strings.ToLower(strings.TrimSpace(level))
var validLevels []string
if IsGemini3ProModel(model) {
validLevels = Gemini3ProThinkingLevels
} else if IsGemini3FlashModel(model) {
validLevels = Gemini3FlashThinkingLevels
} else if IsGemini3Model(model) {
// Unknown Gemini 3 variant - allow all levels as fallback
validLevels = Gemini3FlashThinkingLevels
} else {
return "", false
}
for _, valid := range validLevels {
if normalized == valid {
return normalized, true
}
}
return "", false
}
// ThinkingBudgetToGemini3Level converts a thinkingBudget to a thinkingLevel for Gemini 3 models.
// This provides backward compatibility when thinkingBudget is provided for Gemini 3 models.
// Returns the appropriate thinkingLevel and true if conversion is possible.
func ThinkingBudgetToGemini3Level(model string, budget int) (string, bool) {
if !IsGemini3Model(model) {
return "", false
}
// Map budget to level based on Google's documentation
// Gemini 3 Pro: "low", "high" (default: "high")
// Gemini 3 Flash: "minimal", "low", "medium", "high" (default: "high")
switch {
case budget == -1:
// Dynamic budget maps to "high" (API default)
return "high", true
case budget == 0:
// Zero budget - Gemini 3 doesn't support disabling thinking
// Map to lowest available level
if IsGemini3FlashModel(model) {
return "minimal", true
}
return "low", true
case budget > 0 && budget <= 512:
if IsGemini3FlashModel(model) {
return "minimal", true
}
return "low", true
case budget <= 1024:
return "low", true
case budget <= 8192:
if IsGemini3FlashModel(model) {
return "medium", true
}
return "low", true // Pro doesn't have medium, use low
default:
return "high", true
}
}
// modelsWithDefaultThinking lists models that should have thinking enabled by default
// when no explicit thinkingConfig is provided.
var modelsWithDefaultThinking = map[string]bool{
"gemini-3-pro-preview": true,
"gemini-3-pro-preview": true,
"gemini-3-pro-image-preview": true,
// "gemini-3-flash-preview": true,
}
// ModelHasDefaultThinking returns true if the model should have thinking enabled by default.
@@ -83,6 +253,7 @@ func ModelHasDefaultThinking(model string) bool {
// ApplyDefaultThinkingIfNeeded injects default thinkingConfig for models that require it.
// For standard Gemini API format (generationConfig.thinkingConfig path).
// Returns the modified body if thinkingConfig was added, otherwise returns the original.
// For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation.
func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte {
if !ModelHasDefaultThinking(model) {
return body
@@ -90,14 +261,59 @@ func ApplyDefaultThinkingIfNeeded(model string, body []byte) []byte {
if gjson.GetBytes(body, "generationConfig.thinkingConfig").Exists() {
return body
}
// Gemini 3 models use thinkingLevel instead of thinkingBudget
if IsGemini3Model(model) {
// Don't set a default - let the API use its dynamic default ("high")
// Only set includeThoughts
updated, _ := sjson.SetBytes(body, "generationConfig.thinkingConfig.includeThoughts", true)
return updated
}
// Gemini 2.5 and other models use thinkingBudget
updated, _ := sjson.SetBytes(body, "generationConfig.thinkingConfig.thinkingBudget", -1)
updated, _ = sjson.SetBytes(updated, "generationConfig.thinkingConfig.include_thoughts", true)
return updated
}
// ApplyGemini3ThinkingLevelFromMetadata applies thinkingLevel from metadata for Gemini 3 models.
// For standard Gemini API format (generationConfig.thinkingConfig path).
// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)).
func ApplyGemini3ThinkingLevelFromMetadata(model string, metadata map[string]any, body []byte) []byte {
if !IsGemini3Model(model) {
return body
}
effort, ok := ReasoningEffortFromMetadata(metadata)
if !ok || effort == "" {
return body
}
// Validate and apply the thinkingLevel
if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid {
return ApplyGeminiThinkingLevel(body, level, nil)
}
return body
}
// ApplyGemini3ThinkingLevelFromMetadataCLI applies thinkingLevel from metadata for Gemini 3 models.
// For Gemini CLI API format (request.generationConfig.thinkingConfig path).
// This handles the case where reasoning_effort is specified via model name suffix (e.g., model(minimal)).
func ApplyGemini3ThinkingLevelFromMetadataCLI(model string, metadata map[string]any, body []byte) []byte {
if !IsGemini3Model(model) {
return body
}
effort, ok := ReasoningEffortFromMetadata(metadata)
if !ok || effort == "" {
return body
}
// Validate and apply the thinkingLevel
if level, valid := ValidateGemini3ThinkingLevel(model, effort); valid {
return ApplyGeminiCLIThinkingLevel(body, level, nil)
}
return body
}
// ApplyDefaultThinkingIfNeededCLI injects default thinkingConfig for models that require it.
// For Gemini CLI API format (request.generationConfig.thinkingConfig path).
// Returns the modified body if thinkingConfig was added, otherwise returns the original.
// For Gemini 3 models, uses thinkingLevel instead of thinkingBudget per Google's documentation.
func ApplyDefaultThinkingIfNeededCLI(model string, body []byte) []byte {
if !ModelHasDefaultThinking(model) {
return body
@@ -105,6 +321,14 @@ func ApplyDefaultThinkingIfNeededCLI(model string, body []byte) []byte {
if gjson.GetBytes(body, "request.generationConfig.thinkingConfig").Exists() {
return body
}
// Gemini 3 models use thinkingLevel instead of thinkingBudget
if IsGemini3Model(model) {
// Don't set a default - let the API use its dynamic default ("high")
// Only set includeThoughts
updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.includeThoughts", true)
return updated
}
// Gemini 2.5 and other models use thinkingBudget
updated, _ := sjson.SetBytes(body, "request.generationConfig.thinkingConfig.thinkingBudget", -1)
updated, _ = sjson.SetBytes(updated, "request.generationConfig.thinkingConfig.include_thoughts", true)
return updated
@@ -128,12 +352,31 @@ func StripThinkingConfigIfUnsupported(model string, body []byte) []byte {
// NormalizeGeminiThinkingBudget normalizes the thinkingBudget value in a standard Gemini
// request body (generationConfig.thinkingConfig.thinkingBudget path).
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"
budget := gjson.GetBytes(body, budgetPath)
if !budget.Exists() {
return body
}
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
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)
return updated
}
// If conversion fails, just remove the budget (let API use default)
updated, _ := sjson.DeleteBytes(body, budgetPath)
return updated
}
// For Gemini 2.5 and other models, normalize the budget value
normalized := NormalizeThinkingBudget(model, int(budget.Int()))
updated, _ := sjson.SetBytes(body, budgetPath, normalized)
return updated
@@ -141,12 +384,31 @@ func NormalizeGeminiThinkingBudget(model string, body []byte) []byte {
// NormalizeGeminiCLIThinkingBudget normalizes the thinkingBudget value in a Gemini CLI
// request body (request.generationConfig.thinkingConfig.thinkingBudget path).
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"
budget := gjson.GetBytes(body, budgetPath)
if !budget.Exists() {
return body
}
// For Gemini 3 models, convert thinkingBudget to thinkingLevel
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)
return updated
}
// If conversion fails, just remove the budget (let API use default)
updated, _ := sjson.DeleteBytes(body, budgetPath)
return updated
}
// For Gemini 2.5 and other models, normalize the budget value
normalized := NormalizeThinkingBudget(model, int(budget.Int()))
updated, _ := sjson.SetBytes(body, budgetPath, normalized)
return updated
@@ -218,44 +480,74 @@ func ApplyReasoningEffortToGeminiCLI(body []byte, effort string) []byte {
}
// ConvertThinkingLevelToBudget checks for "generationConfig.thinkingConfig.thinkingLevel"
// and converts it to "thinkingBudget".
// "high" -> 32768
// "low" -> 128
// It removes "thinkingLevel" after conversion.
func ConvertThinkingLevelToBudget(body []byte) []byte {
// and converts it to "thinkingBudget" for Gemini 2.5 models.
// For Gemini 3 models, preserves thinkingLevel unless skipGemini3Check is provided and true.
// Mappings for Gemini 2.5:
// - "high" -> 32768
// - "medium" -> 8192
// - "low" -> 1024
// - "minimal" -> 512
//
// It removes "thinkingLevel" after conversion (for Gemini 2.5 only).
func ConvertThinkingLevelToBudget(body []byte, model string, skipGemini3Check ...bool) []byte {
levelPath := "generationConfig.thinkingConfig.thinkingLevel"
res := gjson.GetBytes(body, levelPath)
if !res.Exists() {
return body
}
level := strings.ToLower(res.String())
var budget int
switch level {
case "high":
budget = 32768
case "low":
budget = 128
default:
// If unknown level, we might just leave it or default.
// User only specified high and low. We'll assume we shouldn't touch it if it's something else,
// or maybe we should just remove the invalid level?
// For safety adhering to strict instructions: "If high... if low...".
// If it's something else, the upstream might fail anyway if we leave it,
// but let's just delete the level if we processed it.
// Actually, let's check if we need to do anything for other values.
// For now, only handle high/low.
// For Gemini 3 models, preserve thinkingLevel unless explicitly skipped
skipGemini3 := len(skipGemini3Check) > 0 && skipGemini3Check[0]
if IsGemini3Model(model) && !skipGemini3 {
return body
}
// Set budget
budget, ok := ThinkingLevelToBudget(res.String())
if !ok {
updated, _ := sjson.DeleteBytes(body, levelPath)
return updated
}
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
}
return updated
}
// ConvertThinkingLevelToBudgetCLI checks for "request.generationConfig.thinkingConfig.thinkingLevel"
// and converts it to "thinkingBudget" for Gemini 2.5 models.
// For Gemini 3 models, preserves thinkingLevel as-is (does not convert).
func ConvertThinkingLevelToBudgetCLI(body []byte, model string) []byte {
levelPath := "request.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) {
return body
}
budget, ok := ThinkingLevelToBudget(res.String())
if !ok {
updated, _ := sjson.DeleteBytes(body, levelPath)
return updated
}
budgetPath := "request.generationConfig.thinkingConfig.thinkingBudget"
updated, err := sjson.SetBytes(body, budgetPath, budget)
if err != nil {
return body
}
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
}

View File

@@ -117,6 +117,71 @@ func (a *KiroAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
return record, nil
}
// LoginWithAuthCode performs OAuth login for Kiro with AWS Builder ID using authorization code flow.
// This provides a better UX than device code flow as it uses automatic browser callback.
func (a *KiroAuthenticator) LoginWithAuthCode(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
if cfg == nil {
return nil, fmt.Errorf("kiro auth: configuration is required")
}
oauth := kiroauth.NewKiroOAuth(cfg)
// Use AWS Builder ID authorization code flow
tokenData, err := oauth.LoginWithBuilderIDAuthCode(ctx)
if err != nil {
return nil, fmt.Errorf("login failed: %w", err)
}
// Parse expires_at
expiresAt, err := time.Parse(time.RFC3339, tokenData.ExpiresAt)
if err != nil {
expiresAt = time.Now().Add(1 * time.Hour)
}
// Extract identifier for file naming
idPart := extractKiroIdentifier(tokenData.Email, tokenData.ProfileArn)
now := time.Now()
fileName := fmt.Sprintf("kiro-aws-%s.json", idPart)
record := &coreauth.Auth{
ID: fileName,
Provider: "kiro",
FileName: fileName,
Label: "kiro-aws",
Status: coreauth.StatusActive,
CreatedAt: now,
UpdatedAt: now,
Metadata: map[string]any{
"type": "kiro",
"access_token": tokenData.AccessToken,
"refresh_token": tokenData.RefreshToken,
"profile_arn": tokenData.ProfileArn,
"expires_at": tokenData.ExpiresAt,
"auth_method": tokenData.AuthMethod,
"provider": tokenData.Provider,
"client_id": tokenData.ClientID,
"client_secret": tokenData.ClientSecret,
"email": tokenData.Email,
},
Attributes: map[string]string{
"profile_arn": tokenData.ProfileArn,
"source": "aws-builder-id-authcode",
"email": tokenData.Email,
},
// NextRefreshAfter is aligned with RefreshLead (5min)
NextRefreshAfter: expiresAt.Add(-5 * time.Minute),
}
if tokenData.Email != "" {
fmt.Printf("\n✓ Kiro authentication completed successfully! (Account: %s)\n", tokenData.Email)
} else {
fmt.Println("\n✓ Kiro authentication completed successfully!")
}
return record, nil
}
// LoginWithGoogle performs OAuth login for Kiro with Google.
// This uses a custom protocol handler (kiro://) to receive the callback.
func (a *KiroAuthenticator) LoginWithGoogle(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {

View File

@@ -0,0 +1,423 @@
package test
import (
"fmt"
"testing"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
"github.com/tidwall/gjson"
)
// registerGemini3Models loads Gemini 3 models into the registry for testing.
func registerGemini3Models(t *testing.T) func() {
t.Helper()
reg := registry.GetGlobalRegistry()
uid := fmt.Sprintf("gemini3-test-%d", time.Now().UnixNano())
reg.RegisterClient(uid+"-gemini", "gemini", registry.GetGeminiModels())
reg.RegisterClient(uid+"-aistudio", "aistudio", registry.GetAIStudioModels())
return func() {
reg.UnregisterClient(uid + "-gemini")
reg.UnregisterClient(uid + "-aistudio")
}
}
func TestIsGemini3Model(t *testing.T) {
cases := []struct {
model string
expected bool
}{
{"gemini-3-pro-preview", true},
{"gemini-3-flash-preview", true},
{"gemini_3_pro_preview", true},
{"gemini-3-pro", true},
{"gemini-3-flash", true},
{"GEMINI-3-PRO-PREVIEW", true},
{"gemini-2.5-pro", false},
{"gemini-2.5-flash", false},
{"gpt-5", false},
{"claude-sonnet-4-5", false},
{"", false},
}
for _, cs := range cases {
t.Run(cs.model, func(t *testing.T) {
got := util.IsGemini3Model(cs.model)
if got != cs.expected {
t.Fatalf("IsGemini3Model(%q) = %v, want %v", cs.model, got, cs.expected)
}
})
}
}
func TestIsGemini3ProModel(t *testing.T) {
cases := []struct {
model string
expected bool
}{
{"gemini-3-pro-preview", true},
{"gemini_3_pro_preview", true},
{"gemini-3-pro", true},
{"GEMINI-3-PRO-PREVIEW", true},
{"gemini-3-flash-preview", false},
{"gemini-3-flash", false},
{"gemini-2.5-pro", false},
{"", false},
}
for _, cs := range cases {
t.Run(cs.model, func(t *testing.T) {
got := util.IsGemini3ProModel(cs.model)
if got != cs.expected {
t.Fatalf("IsGemini3ProModel(%q) = %v, want %v", cs.model, got, cs.expected)
}
})
}
}
func TestIsGemini3FlashModel(t *testing.T) {
cases := []struct {
model string
expected bool
}{
{"gemini-3-flash-preview", true},
{"gemini_3_flash_preview", true},
{"gemini-3-flash", true},
{"GEMINI-3-FLASH-PREVIEW", true},
{"gemini-3-pro-preview", false},
{"gemini-3-pro", false},
{"gemini-2.5-flash", false},
{"", false},
}
for _, cs := range cases {
t.Run(cs.model, func(t *testing.T) {
got := util.IsGemini3FlashModel(cs.model)
if got != cs.expected {
t.Fatalf("IsGemini3FlashModel(%q) = %v, want %v", cs.model, got, cs.expected)
}
})
}
}
func TestValidateGemini3ThinkingLevel(t *testing.T) {
cases := []struct {
name string
model string
level string
wantOK bool
wantVal string
}{
// Gemini 3 Pro: supports "low", "high"
{"pro-low", "gemini-3-pro-preview", "low", true, "low"},
{"pro-high", "gemini-3-pro-preview", "high", true, "high"},
{"pro-minimal-invalid", "gemini-3-pro-preview", "minimal", false, ""},
{"pro-medium-invalid", "gemini-3-pro-preview", "medium", false, ""},
// Gemini 3 Flash: supports "minimal", "low", "medium", "high"
{"flash-minimal", "gemini-3-flash-preview", "minimal", true, "minimal"},
{"flash-low", "gemini-3-flash-preview", "low", true, "low"},
{"flash-medium", "gemini-3-flash-preview", "medium", true, "medium"},
{"flash-high", "gemini-3-flash-preview", "high", true, "high"},
// Case insensitivity
{"flash-LOW-case", "gemini-3-flash-preview", "LOW", true, "low"},
{"flash-High-case", "gemini-3-flash-preview", "High", true, "high"},
{"pro-HIGH-case", "gemini-3-pro-preview", "HIGH", true, "high"},
// Invalid levels
{"flash-invalid", "gemini-3-flash-preview", "xhigh", false, ""},
{"flash-invalid-auto", "gemini-3-flash-preview", "auto", false, ""},
{"flash-empty", "gemini-3-flash-preview", "", false, ""},
// Non-Gemini 3 models
{"non-gemini3", "gemini-2.5-pro", "high", false, ""},
{"gpt5", "gpt-5", "high", false, ""},
}
for _, cs := range cases {
t.Run(cs.name, func(t *testing.T) {
got, ok := util.ValidateGemini3ThinkingLevel(cs.model, cs.level)
if ok != cs.wantOK {
t.Fatalf("ValidateGemini3ThinkingLevel(%q, %q) ok = %v, want %v", cs.model, cs.level, ok, cs.wantOK)
}
if got != cs.wantVal {
t.Fatalf("ValidateGemini3ThinkingLevel(%q, %q) = %q, want %q", cs.model, cs.level, got, cs.wantVal)
}
})
}
}
func TestThinkingBudgetToGemini3Level(t *testing.T) {
cases := []struct {
name string
model string
budget int
wantOK bool
wantVal string
}{
// Gemini 3 Pro: maps to "low" or "high"
{"pro-dynamic", "gemini-3-pro-preview", -1, true, "high"},
{"pro-zero", "gemini-3-pro-preview", 0, true, "low"},
{"pro-small", "gemini-3-pro-preview", 1000, true, "low"},
{"pro-medium", "gemini-3-pro-preview", 8000, true, "low"},
{"pro-large", "gemini-3-pro-preview", 20000, true, "high"},
{"pro-huge", "gemini-3-pro-preview", 50000, true, "high"},
// Gemini 3 Flash: maps to "minimal", "low", "medium", "high"
{"flash-dynamic", "gemini-3-flash-preview", -1, true, "high"},
{"flash-zero", "gemini-3-flash-preview", 0, true, "minimal"},
{"flash-tiny", "gemini-3-flash-preview", 500, true, "minimal"},
{"flash-small", "gemini-3-flash-preview", 1000, true, "low"},
{"flash-medium-val", "gemini-3-flash-preview", 8000, true, "medium"},
{"flash-large", "gemini-3-flash-preview", 20000, true, "high"},
{"flash-huge", "gemini-3-flash-preview", 50000, true, "high"},
// Non-Gemini 3 models should return false
{"gemini25-budget", "gemini-2.5-pro", 8000, false, ""},
{"gpt5-budget", "gpt-5", 8000, false, ""},
}
for _, cs := range cases {
t.Run(cs.name, func(t *testing.T) {
got, ok := util.ThinkingBudgetToGemini3Level(cs.model, cs.budget)
if ok != cs.wantOK {
t.Fatalf("ThinkingBudgetToGemini3Level(%q, %d) ok = %v, want %v", cs.model, cs.budget, ok, cs.wantOK)
}
if got != cs.wantVal {
t.Fatalf("ThinkingBudgetToGemini3Level(%q, %d) = %q, want %q", cs.model, cs.budget, got, cs.wantVal)
}
})
}
}
func TestApplyGemini3ThinkingLevelFromMetadata(t *testing.T) {
cleanup := registerGemini3Models(t)
defer cleanup()
cases := []struct {
name string
model string
metadata map[string]any
inputBody string
wantLevel string
wantInclude bool
wantNoChange bool
}{
{
name: "flash-minimal-from-suffix",
model: "gemini-3-flash-preview",
metadata: map[string]any{"reasoning_effort": "minimal"},
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
wantLevel: "minimal",
wantInclude: true,
},
{
name: "flash-medium-from-suffix",
model: "gemini-3-flash-preview",
metadata: map[string]any{"reasoning_effort": "medium"},
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
wantLevel: "medium",
wantInclude: true,
},
{
name: "pro-high-from-suffix",
model: "gemini-3-pro-preview",
metadata: map[string]any{"reasoning_effort": "high"},
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
wantLevel: "high",
wantInclude: true,
},
{
name: "no-metadata-no-change",
model: "gemini-3-flash-preview",
metadata: nil,
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
wantNoChange: true,
},
{
name: "non-gemini3-no-change",
model: "gemini-2.5-pro",
metadata: map[string]any{"reasoning_effort": "high"},
inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}`,
wantNoChange: true,
},
{
name: "invalid-level-no-change",
model: "gemini-3-flash-preview",
metadata: map[string]any{"reasoning_effort": "xhigh"},
inputBody: `{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}`,
wantNoChange: true,
},
}
for _, cs := range cases {
t.Run(cs.name, func(t *testing.T) {
input := []byte(cs.inputBody)
result := util.ApplyGemini3ThinkingLevelFromMetadata(cs.model, cs.metadata, input)
if cs.wantNoChange {
if string(result) != cs.inputBody {
t.Fatalf("expected no change, but got: %s", string(result))
}
return
}
level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel")
if !level.Exists() {
t.Fatalf("thinkingLevel not set in result: %s", string(result))
}
if level.String() != cs.wantLevel {
t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel)
}
include := gjson.GetBytes(result, "generationConfig.thinkingConfig.includeThoughts")
if cs.wantInclude && (!include.Exists() || !include.Bool()) {
t.Fatalf("includeThoughts should be true, got: %s", string(result))
}
})
}
}
func TestApplyGemini3ThinkingLevelFromMetadataCLI(t *testing.T) {
cleanup := registerGemini3Models(t)
defer cleanup()
cases := []struct {
name string
model string
metadata map[string]any
inputBody string
wantLevel string
wantInclude bool
wantNoChange bool
}{
{
name: "flash-minimal-from-suffix-cli",
model: "gemini-3-flash-preview",
metadata: map[string]any{"reasoning_effort": "minimal"},
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`,
wantLevel: "minimal",
wantInclude: true,
},
{
name: "flash-low-from-suffix-cli",
model: "gemini-3-flash-preview",
metadata: map[string]any{"reasoning_effort": "low"},
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`,
wantLevel: "low",
wantInclude: true,
},
{
name: "pro-low-from-suffix-cli",
model: "gemini-3-pro-preview",
metadata: map[string]any{"reasoning_effort": "low"},
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`,
wantLevel: "low",
wantInclude: true,
},
{
name: "no-metadata-no-change-cli",
model: "gemini-3-flash-preview",
metadata: nil,
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"includeThoughts":true}}}}`,
wantNoChange: true,
},
{
name: "non-gemini3-no-change-cli",
model: "gemini-2.5-pro",
metadata: map[string]any{"reasoning_effort": "high"},
inputBody: `{"request":{"generationConfig":{"thinkingConfig":{"thinkingBudget":-1}}}}`,
wantNoChange: true,
},
}
for _, cs := range cases {
t.Run(cs.name, func(t *testing.T) {
input := []byte(cs.inputBody)
result := util.ApplyGemini3ThinkingLevelFromMetadataCLI(cs.model, cs.metadata, input)
if cs.wantNoChange {
if string(result) != cs.inputBody {
t.Fatalf("expected no change, but got: %s", string(result))
}
return
}
level := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.thinkingLevel")
if !level.Exists() {
t.Fatalf("thinkingLevel not set in result: %s", string(result))
}
if level.String() != cs.wantLevel {
t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel)
}
include := gjson.GetBytes(result, "request.generationConfig.thinkingConfig.includeThoughts")
if cs.wantInclude && (!include.Exists() || !include.Bool()) {
t.Fatalf("includeThoughts should be true, got: %s", string(result))
}
})
}
}
func TestNormalizeGeminiThinkingBudget_Gemini3Conversion(t *testing.T) {
cleanup := registerGemini3Models(t)
defer cleanup()
cases := []struct {
name string
model string
inputBody string
wantLevel string
wantBudget bool // if true, expect thinkingBudget instead of thinkingLevel
}{
{
name: "gemini3-flash-budget-to-level",
model: "gemini-3-flash-preview",
inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":8000}}}`,
wantLevel: "medium",
},
{
name: "gemini3-pro-budget-to-level",
model: "gemini-3-pro-preview",
inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":20000}}}`,
wantLevel: "high",
},
{
name: "gemini25-keeps-budget",
model: "gemini-2.5-pro",
inputBody: `{"generationConfig":{"thinkingConfig":{"thinkingBudget":8000}}}`,
wantBudget: true,
},
}
for _, cs := range cases {
t.Run(cs.name, func(t *testing.T) {
result := util.NormalizeGeminiThinkingBudget(cs.model, []byte(cs.inputBody))
if cs.wantBudget {
budget := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingBudget")
if !budget.Exists() {
t.Fatalf("thinkingBudget should exist for non-Gemini3 model: %s", string(result))
}
level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel")
if level.Exists() {
t.Fatalf("thinkingLevel should not exist for non-Gemini3 model: %s", string(result))
}
} else {
level := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingLevel")
if !level.Exists() {
t.Fatalf("thinkingLevel should exist for Gemini3 model: %s", string(result))
}
if level.String() != cs.wantLevel {
t.Fatalf("thinkingLevel = %q, want %q", level.String(), cs.wantLevel)
}
budget := gjson.GetBytes(result, "generationConfig.thinkingConfig.thinkingBudget")
if budget.Exists() {
t.Fatalf("thinkingBudget should be removed for Gemini3 model: %s", string(result))
}
}
})
}
}