Merge pull request #42 from Ravens2121/master

feat(kiro): 新增授权码登录流程,优化邮箱获取与官方 Thinking 模式解析 预支持
This commit is contained in:
Luis Pater
2025-12-18 08:36:27 +08:00
committed by GitHub
9 changed files with 1126 additions and 484 deletions
+6
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 {
+166
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)
}
+7
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{
+403 -7
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
}
}
+48
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.
+403 -447
View File
@@ -1293,17 +1293,66 @@ func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroclaude.Ki
log.Debugf("kiro: parseEventStream found stopReason in messageStopEvent: %s", stopReason)
}
case "messageMetadataEvent":
// Handle message metadata events which may contain token counts
if metadata, ok := event["messageMetadataEvent"].(map[string]interface{}); ok {
case "messageMetadataEvent", "metadataEvent":
// Handle message metadata events which contain token counts
// Official format: { tokenUsage: { outputTokens, totalTokens, uncachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens, contextUsagePercentage } }
var metadata map[string]interface{}
if m, ok := event["messageMetadataEvent"].(map[string]interface{}); ok {
metadata = m
} else if m, ok := event["metadataEvent"].(map[string]interface{}); ok {
metadata = m
} else {
metadata = event // event itself might be the metadata
}
// Check for nested tokenUsage object (official format)
if tokenUsage, ok := metadata["tokenUsage"].(map[string]interface{}); ok {
// outputTokens - precise output token count
if outputTokens, ok := tokenUsage["outputTokens"].(float64); ok {
usageInfo.OutputTokens = int64(outputTokens)
log.Infof("kiro: parseEventStream found precise outputTokens in tokenUsage: %d", usageInfo.OutputTokens)
}
// totalTokens - precise total token count
if totalTokens, ok := tokenUsage["totalTokens"].(float64); ok {
usageInfo.TotalTokens = int64(totalTokens)
log.Infof("kiro: parseEventStream found precise totalTokens in tokenUsage: %d", usageInfo.TotalTokens)
}
// uncachedInputTokens - input tokens not from cache
if uncachedInputTokens, ok := tokenUsage["uncachedInputTokens"].(float64); ok {
usageInfo.InputTokens = int64(uncachedInputTokens)
log.Infof("kiro: parseEventStream found uncachedInputTokens in tokenUsage: %d", usageInfo.InputTokens)
}
// cacheReadInputTokens - tokens read from cache
if cacheReadTokens, ok := tokenUsage["cacheReadInputTokens"].(float64); ok {
// Add to input tokens if we have uncached tokens, otherwise use as input
if usageInfo.InputTokens > 0 {
usageInfo.InputTokens += int64(cacheReadTokens)
} else {
usageInfo.InputTokens = int64(cacheReadTokens)
}
log.Debugf("kiro: parseEventStream found cacheReadInputTokens in tokenUsage: %d", int64(cacheReadTokens))
}
// contextUsagePercentage - can be used as fallback for input token estimation
if ctxPct, ok := tokenUsage["contextUsagePercentage"].(float64); ok {
upstreamContextPercentage = ctxPct
log.Debugf("kiro: parseEventStream found contextUsagePercentage in tokenUsage: %.2f%%", ctxPct)
}
}
// Fallback: check for direct fields in metadata (legacy format)
if usageInfo.InputTokens == 0 {
if inputTokens, ok := metadata["inputTokens"].(float64); ok {
usageInfo.InputTokens = int64(inputTokens)
log.Debugf("kiro: parseEventStream found inputTokens in messageMetadataEvent: %d", usageInfo.InputTokens)
}
}
if usageInfo.OutputTokens == 0 {
if outputTokens, ok := metadata["outputTokens"].(float64); ok {
usageInfo.OutputTokens = int64(outputTokens)
log.Debugf("kiro: parseEventStream found outputTokens in messageMetadataEvent: %d", usageInfo.OutputTokens)
}
}
if usageInfo.TotalTokens == 0 {
if totalTokens, ok := metadata["totalTokens"].(float64); ok {
usageInfo.TotalTokens = int64(totalTokens)
log.Debugf("kiro: parseEventStream found totalTokens in messageMetadataEvent: %d", usageInfo.TotalTokens)
@@ -1356,6 +1405,78 @@ func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroclaude.Ki
usageInfo.InputTokens, usageInfo.OutputTokens)
}
case "meteringEvent":
// Handle metering events from Kiro API (usage billing information)
// Official format: { unit: string, unitPlural: string, usage: number }
if metering, ok := event["meteringEvent"].(map[string]interface{}); ok {
unit := ""
if u, ok := metering["unit"].(string); ok {
unit = u
}
usageVal := 0.0
if u, ok := metering["usage"].(float64); ok {
usageVal = u
}
log.Infof("kiro: parseEventStream received meteringEvent: usage=%.2f %s", usageVal, unit)
// Store metering info for potential billing/statistics purposes
// Note: This is separate from token counts - it's AWS billing units
} else {
// Try direct fields
unit := ""
if u, ok := event["unit"].(string); ok {
unit = u
}
usageVal := 0.0
if u, ok := event["usage"].(float64); ok {
usageVal = u
}
if unit != "" || usageVal > 0 {
log.Infof("kiro: parseEventStream received meteringEvent (direct): usage=%.2f %s", usageVal, unit)
}
}
case "error", "exception", "internalServerException", "invalidStateEvent":
// Handle error events from Kiro API stream
errMsg := ""
errType := eventType
// Try to extract error message from various formats
if msg, ok := event["message"].(string); ok {
errMsg = msg
} else if errObj, ok := event[eventType].(map[string]interface{}); ok {
if msg, ok := errObj["message"].(string); ok {
errMsg = msg
}
if t, ok := errObj["type"].(string); ok {
errType = t
}
} else if errObj, ok := event["error"].(map[string]interface{}); ok {
if msg, ok := errObj["message"].(string); ok {
errMsg = msg
}
if t, ok := errObj["type"].(string); ok {
errType = t
}
}
// Check for specific error reasons
if reason, ok := event["reason"].(string); ok {
errMsg = fmt.Sprintf("%s (reason: %s)", errMsg, reason)
}
log.Errorf("kiro: parseEventStream received error event: type=%s, message=%s", errType, errMsg)
// For invalidStateEvent, we may want to continue processing other events
if eventType == "invalidStateEvent" {
log.Warnf("kiro: invalidStateEvent received, continuing stream processing")
continue
}
// For other errors, return the error
if errMsg != "" {
return "", nil, usageInfo, stopReason, fmt.Errorf("kiro API error (%s): %s", errType, errMsg)
}
default:
// Check for contextUsagePercentage in any event
if ctxPct, ok := event["contextUsagePercentage"].(float64); ok {
@@ -1693,30 +1814,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
// IMPORTANT: This must persist across all TranslateStream calls
var translatorParam any
// Thinking mode state tracking - based on amq2api implementation
// Tracks whether we're inside a <thinking> block and handles partial tags
inThinkBlock := false
pendingStartTagChars := 0 // Number of chars that might be start of <thinking>
pendingEndTagChars := 0 // Number of chars that might be start of </thinking>
isThinkingBlockOpen := false // Track if thinking content block is open
// Thinking mode state tracking - tag-based parsing for <thinking> tags in content
inThinkBlock := false // Whether we're currently inside a <thinking> block
isThinkingBlockOpen := false // Track if thinking content block SSE event is open
thinkingBlockIndex := -1 // Index of the thinking content block
var accumulatedThinkingContent strings.Builder // Accumulate thinking content for signature generation
var accumulatedThinkingContent strings.Builder // Accumulate thinking content for token counting
// Code block state tracking for heuristic thinking tag parsing
// When inside a markdown code block, <thinking> tags should NOT be parsed
// This prevents false positives when the model outputs code examples containing these tags
inCodeBlock := false
codeFenceType := "" // Track which fence type opened the block ("```" or "~~~")
// Inline code state tracking - when inside backticks, don't parse thinking tags
// This handles cases like `<thinking>` being discussed in text
inInlineCode := false
// Track if we've seen any non-whitespace content before a thinking tag
// Real thinking blocks from Kiro always start at the very beginning of the response
// If we see content before <thinking>, subsequent <thinking> tags are likely discussion text
hasSeenNonThinkingContent := false
thinkingBlockCompleted := false // Track if we've already completed a thinking block
// Buffer for handling partial tag matches at chunk boundaries
var pendingContent strings.Builder // Buffer content that might be part of a tag
// Pre-calculate input tokens from request if possible
// Kiro uses Claude format, so try Claude format first, then OpenAI format, then fallback
@@ -1820,57 +1925,10 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
currentToolUse = nil
}
// Flush any pending tag characters at EOF
// These are partial tag prefixes that were held back waiting for more data
// Since no more data is coming, output them as regular text
var pendingText string
if pendingStartTagChars > 0 {
pendingText = kirocommon.ThinkingStartTag[:pendingStartTagChars]
log.Debugf("kiro: flushing pending start tag chars at EOF: %q", pendingText)
pendingStartTagChars = 0
}
if pendingEndTagChars > 0 {
pendingText += kirocommon.ThinkingEndTag[:pendingEndTagChars]
log.Debugf("kiro: flushing pending end tag chars at EOF: %q", pendingText)
pendingEndTagChars = 0
}
// Output pending text if any
if pendingText != "" {
// If we're in a thinking block, output as thinking content
if inThinkBlock && isThinkingBlockOpen {
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(pendingText, thinkingBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
// Accumulate thinking content for signature generation
accumulatedThinkingContent.WriteString(pendingText)
} else {
// Output as regular text
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(pendingText, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
}
// DISABLED: Tag-based pending character flushing
// This code block was used for tag-based thinking detection which has been
// replaced by reasoningContentEvent handling. No pending tag chars to flush.
// Original code preserved in git history.
break
}
@@ -1954,6 +2012,76 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
log.Debugf("kiro: streamToChannel found stopReason in messageStopEvent: %s", upstreamStopReason)
}
case "meteringEvent":
// Handle metering events from Kiro API (usage billing information)
// Official format: { unit: string, unitPlural: string, usage: number }
if metering, ok := event["meteringEvent"].(map[string]interface{}); ok {
unit := ""
if u, ok := metering["unit"].(string); ok {
unit = u
}
usageVal := 0.0
if u, ok := metering["usage"].(float64); ok {
usageVal = u
}
upstreamCreditUsage = usageVal
hasUpstreamUsage = true
log.Infof("kiro: streamToChannel received meteringEvent: usage=%.4f %s", usageVal, unit)
} else {
// Try direct fields (event is meteringEvent itself)
if unit, ok := event["unit"].(string); ok {
if usage, ok := event["usage"].(float64); ok {
upstreamCreditUsage = usage
hasUpstreamUsage = true
log.Infof("kiro: streamToChannel received meteringEvent (direct): usage=%.4f %s", usage, unit)
}
}
}
case "error", "exception", "internalServerException":
// Handle error events from Kiro API stream
errMsg := ""
errType := eventType
// Try to extract error message from various formats
if msg, ok := event["message"].(string); ok {
errMsg = msg
} else if errObj, ok := event[eventType].(map[string]interface{}); ok {
if msg, ok := errObj["message"].(string); ok {
errMsg = msg
}
if t, ok := errObj["type"].(string); ok {
errType = t
}
} else if errObj, ok := event["error"].(map[string]interface{}); ok {
if msg, ok := errObj["message"].(string); ok {
errMsg = msg
}
}
log.Errorf("kiro: streamToChannel received error event: type=%s, message=%s", errType, errMsg)
// Send error to the stream and exit
if errMsg != "" {
out <- cliproxyexecutor.StreamChunk{
Err: fmt.Errorf("kiro API error (%s): %s", errType, errMsg),
}
return
}
case "invalidStateEvent":
// Handle invalid state events - log and continue (non-fatal)
errMsg := ""
if msg, ok := event["message"].(string); ok {
errMsg = msg
} else if stateEvent, ok := event["invalidStateEvent"].(map[string]interface{}); ok {
if msg, ok := stateEvent["message"].(string); ok {
errMsg = msg
}
}
log.Warnf("kiro: streamToChannel received invalidStateEvent: %s, continuing", errMsg)
continue
default:
// Check for upstream usage events from Kiro API
// Format: {"unit":"credit","unitPlural":"credits","usage":1.458}
@@ -2110,266 +2238,22 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
lastUsageUpdateTime = time.Now()
}
// Process content with thinking tag detection - based on amq2api implementation
// This handles <thinking> and </thinking> tags that may span across chunks
remaining := contentDelta
// If we have pending start tag chars from previous chunk, prepend them
if pendingStartTagChars > 0 {
remaining = kirocommon.ThinkingStartTag[:pendingStartTagChars] + remaining
pendingStartTagChars = 0
}
// If we have pending end tag chars from previous chunk, prepend them
if pendingEndTagChars > 0 {
remaining = kirocommon.ThinkingEndTag[:pendingEndTagChars] + remaining
pendingEndTagChars = 0
}
for len(remaining) > 0 {
// CRITICAL FIX: Only parse <thinking> tags when thinking mode was enabled in the request.
// When thinking is NOT enabled, <thinking> tags in responses should be treated as
// regular text content, not as thinking blocks. This prevents normal text content
// from being incorrectly parsed as thinking when the model outputs <thinking> tags
// without the user requesting thinking mode.
if !thinkingEnabled {
// Thinking not enabled - emit all content as regular text without parsing tags
if remaining != "" {
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(remaining, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
break // Exit the for loop - all content processed as text
}
// HEURISTIC FIX: Track code block and inline code state to avoid parsing <thinking> tags
// inside code contexts. When the model outputs code examples containing these tags,
// they should be treated as text.
if !inThinkBlock {
// Check for inline code backticks first (higher priority than code fences)
// This handles cases like `<thinking>` being discussed in text
backtickIdx := strings.Index(remaining, kirocommon.InlineCodeMarker)
thinkingIdx := strings.Index(remaining, kirocommon.ThinkingStartTag)
// If backtick comes before thinking tag, handle inline code
if backtickIdx >= 0 && (thinkingIdx < 0 || backtickIdx < thinkingIdx) {
if inInlineCode {
// Closing backtick - emit content up to and including backtick, exit inline code
textToEmit := remaining[:backtickIdx+1]
if textToEmit != "" {
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
remaining = remaining[backtickIdx+1:]
inInlineCode = false
continue
} else {
// Opening backtick - emit content before backtick, enter inline code
textToEmit := remaining[:backtickIdx+1]
if textToEmit != "" {
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
remaining = remaining[backtickIdx+1:]
inInlineCode = true
continue
}
}
// If inside inline code, emit all content as text (don't parse thinking tags)
if inInlineCode {
if remaining != "" {
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(remaining, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
break // Exit loop - remaining content is inside inline code
}
// Check for code fence markers (``` or ~~~) to toggle code block state
fenceIdx := strings.Index(remaining, kirocommon.CodeFenceMarker)
altFenceIdx := strings.Index(remaining, kirocommon.AltCodeFenceMarker)
// Find the earliest fence marker
earliestFenceIdx := -1
earliestFenceType := ""
if fenceIdx >= 0 && (altFenceIdx < 0 || fenceIdx < altFenceIdx) {
earliestFenceIdx = fenceIdx
earliestFenceType = kirocommon.CodeFenceMarker
} else if altFenceIdx >= 0 {
earliestFenceIdx = altFenceIdx
earliestFenceType = kirocommon.AltCodeFenceMarker
}
if earliestFenceIdx >= 0 {
// Check if this fence comes before any thinking tag
thinkingIdx := strings.Index(remaining, kirocommon.ThinkingStartTag)
if inCodeBlock {
// Inside code block - check if this fence closes it
if earliestFenceType == codeFenceType {
// This fence closes the code block
// Emit content up to and including the fence as text
textToEmit := remaining[:earliestFenceIdx+len(earliestFenceType)]
if textToEmit != "" {
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
remaining = remaining[earliestFenceIdx+len(earliestFenceType):]
inCodeBlock = false
codeFenceType = ""
log.Debugf("kiro: exited code block")
continue
}
} else if thinkingIdx < 0 || earliestFenceIdx < thinkingIdx {
// Not in code block, and fence comes before thinking tag (or no thinking tag)
// Emit content up to and including the fence as text, then enter code block
textToEmit := remaining[:earliestFenceIdx+len(earliestFenceType)]
if textToEmit != "" {
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
remaining = remaining[earliestFenceIdx+len(earliestFenceType):]
inCodeBlock = true
codeFenceType = earliestFenceType
log.Debugf("kiro: entered code block with fence: %s", earliestFenceType)
continue
}
}
// If inside code block, emit all content as text (don't parse thinking tags)
if inCodeBlock {
if remaining != "" {
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(remaining, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
break // Exit loop - all remaining content is inside code block
}
}
// TAG-BASED THINKING PARSING: Parse <thinking> tags from content
// Combine pending content with new content for processing
pendingContent.WriteString(contentDelta)
processContent := pendingContent.String()
pendingContent.Reset()
// Process content looking for thinking tags
for len(processContent) > 0 {
if inThinkBlock {
// Inside thinking block - look for </thinking> end tag
// CRITICAL FIX: Skip </thinking> tags that are not the real end tag
// This prevents false positives when thinking content discusses these tags
// Pass current code block/inline code state for accurate detection
endIdx := findRealThinkingEndTag(remaining, inCodeBlock, inInlineCode)
// We're inside a thinking block, look for </thinking>
endIdx := strings.Index(processContent, kirocommon.ThinkingEndTag)
if endIdx >= 0 {
// Found end tag - emit any content before end tag, then close block
thinkContent := remaining[:endIdx]
if thinkContent != "" {
// TRUE STREAMING: Emit thinking content immediately
// Start thinking block if not open
// Found end tag - emit thinking content before the tag
thinkingText := processContent[:endIdx]
if thinkingText != "" {
// Ensure thinking block is open
if !isThinkingBlockOpen {
contentBlockIndex++
thinkingBlockIndex = contentBlockIndex
@@ -2382,22 +2266,16 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
}
// Send thinking delta immediately
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkContent, thinkingBlockIndex)
// Send thinking delta
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
// Accumulate thinking content for signature generation
accumulatedThinkingContent.WriteString(thinkContent)
accumulatedThinkingContent.WriteString(thinkingText)
}
// Note: Partial tag handling is done via pendingEndTagChars
// When the next chunk arrives, the partial tag will be reconstructed
// Close thinking block
if isThinkingBlockOpen {
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
@@ -2408,31 +2286,25 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
isThinkingBlockOpen = false
accumulatedThinkingContent.Reset() // Reset for potential next thinking block
}
inThinkBlock = false
thinkingBlockCompleted = true // Mark that we've completed a thinking block
remaining = remaining[endIdx+len(kirocommon.ThinkingEndTag):]
log.Debugf("kiro: exited thinking block, subsequent <thinking> tags will be treated as text")
processContent = processContent[endIdx+len(kirocommon.ThinkingEndTag):]
log.Debugf("kiro: closed thinking block, remaining content: %d chars", len(processContent))
} else {
// No end tag found - TRUE STREAMING: emit content immediately
// Only save potential partial tag length for next iteration
pendingEnd := kiroclaude.PendingTagSuffix(remaining, kirocommon.ThinkingEndTag)
// Calculate content to emit immediately (excluding potential partial tag)
var contentToEmit string
if pendingEnd > 0 {
contentToEmit = remaining[:len(remaining)-pendingEnd]
// Save partial tag length for next iteration (will be reconstructed from thinkingEndTag)
pendingEndTagChars = pendingEnd
} else {
contentToEmit = remaining
// No end tag found - check for partial match at end
partialMatch := false
for i := 1; i < len(kirocommon.ThinkingEndTag) && i <= len(processContent); i++ {
if strings.HasSuffix(processContent, kirocommon.ThinkingEndTag[:i]) {
// Possible partial tag at end, buffer it
pendingContent.WriteString(processContent[len(processContent)-i:])
processContent = processContent[:len(processContent)-i]
partialMatch = true
break
}
// TRUE STREAMING: Emit thinking content immediately
if contentToEmit != "" {
// Start thinking block if not open
}
if !partialMatch || len(processContent) > 0 {
// Emit all as thinking content
if processContent != "" {
if !isThinkingBlockOpen {
contentBlockIndex++
thinkingBlockIndex = contentBlockIndex
@@ -2445,47 +2317,37 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
}
// Send thinking delta immediately - TRUE STREAMING!
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(contentToEmit, thinkingBlockIndex)
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(processContent, thinkingBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
// Accumulate thinking content for signature generation
accumulatedThinkingContent.WriteString(contentToEmit)
accumulatedThinkingContent.WriteString(processContent)
}
remaining = ""
}
processContent = ""
}
} else {
// Outside thinking block - look for <thinking> start tag
// CRITICAL FIX: Only parse <thinking> tags at the very beginning of the response
// or if we haven't completed a thinking block yet.
// After a thinking block is completed, subsequent <thinking> tags are likely
// discussion text (e.g., "Kiro returns `<thinking>` tags") and should NOT be parsed.
startIdx := -1
if !thinkingBlockCompleted && !hasSeenNonThinkingContent {
startIdx = strings.Index(remaining, kirocommon.ThinkingStartTag)
// If there's non-whitespace content before the tag, it's not a real thinking block
if startIdx > 0 {
textBefore := remaining[:startIdx]
if strings.TrimSpace(textBefore) != "" {
// There's real content before the tag - this is discussion text, not thinking
hasSeenNonThinkingContent = true
startIdx = -1
log.Debugf("kiro: found <thinking> tag after non-whitespace content, treating as text")
}
}
}
// Not in thinking block, look for <thinking>
startIdx := strings.Index(processContent, kirocommon.ThinkingStartTag)
if startIdx >= 0 {
// Found start tag - emit text before it and switch to thinking mode
textBefore := remaining[:startIdx]
// Found start tag - emit text content before the tag
textBefore := processContent[:startIdx]
if textBefore != "" {
// Only whitespace before thinking tag is allowed
// Start text content block if needed
// Close thinking block if open
if isThinkingBlockOpen {
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
isThinkingBlockOpen = false
}
// Ensure text block is open
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
@@ -2497,7 +2359,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
}
// Send text delta
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textBefore, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
@@ -2506,8 +2368,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
}
// Close text block before starting thinking block
// Close text block before entering thinking
if isTextBlockOpen {
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
@@ -2518,26 +2379,24 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
isTextBlockOpen = false
}
inThinkBlock = true
remaining = remaining[startIdx+len(kirocommon.ThinkingStartTag):]
processContent = processContent[startIdx+len(kirocommon.ThinkingStartTag):]
log.Debugf("kiro: entered thinking block")
} else {
// No start tag found - check for partial start tag at buffer end
// Only check for partial tags if we haven't completed a thinking block yet
pendingStart := 0
if !thinkingBlockCompleted && !hasSeenNonThinkingContent {
pendingStart = kiroclaude.PendingTagSuffix(remaining, kirocommon.ThinkingStartTag)
// No start tag found - check for partial match at end
partialMatch := false
for i := 1; i < len(kirocommon.ThinkingStartTag) && i <= len(processContent); i++ {
if strings.HasSuffix(processContent, kirocommon.ThinkingStartTag[:i]) {
// Possible partial tag at end, buffer it
pendingContent.WriteString(processContent[len(processContent)-i:])
processContent = processContent[:len(processContent)-i]
partialMatch = true
break
}
if pendingStart > 0 {
// Emit text except potential partial tag
textToEmit := remaining[:len(remaining)-pendingStart]
if textToEmit != "" {
// Mark that we've seen non-thinking content
if strings.TrimSpace(textToEmit) != "" {
hasSeenNonThinkingContent = true
}
// Start text content block if needed
if !partialMatch || len(processContent) > 0 {
// Emit all as text content
if processContent != "" {
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
@@ -2549,8 +2408,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex)
claudeEvent := kiroclaude.BuildClaudeStreamEvent(processContent, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
@@ -2558,38 +2416,8 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
}
pendingStartTagChars = pendingStart
remaining = ""
} else {
// No partial tag - emit all as text
if remaining != "" {
// Mark that we've seen non-thinking content
if strings.TrimSpace(remaining) != "" {
hasSeenNonThinkingContent = true
}
// Start text content block if needed
if !isTextBlockOpen {
contentBlockIndex++
isTextBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
claudeEvent := kiroclaude.BuildClaudeStreamEvent(remaining, contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
remaining = ""
}
processContent = ""
}
}
}
@@ -2658,6 +2486,80 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
case "reasoningContentEvent":
// Handle official reasoningContentEvent from Kiro API
// This replaces tag-based thinking detection with the proper event type
// Official format: { text: string, signature?: string, redactedContent?: base64 }
var thinkingText string
var signature string
if re, ok := event["reasoningContentEvent"].(map[string]interface{}); ok {
if text, ok := re["text"].(string); ok {
thinkingText = text
}
if sig, ok := re["signature"].(string); ok {
signature = sig
if len(sig) > 20 {
log.Debugf("kiro: reasoningContentEvent has signature: %s...", sig[:20])
} else {
log.Debugf("kiro: reasoningContentEvent has signature: %s", sig)
}
}
} else {
// Try direct fields
if text, ok := event["text"].(string); ok {
thinkingText = text
}
if sig, ok := event["signature"].(string); ok {
signature = sig
}
}
if thinkingText != "" {
// Close text block if open before starting thinking block
if isTextBlockOpen && contentBlockIndex >= 0 {
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
isTextBlockOpen = false
}
// Start thinking block if not already open
if !isThinkingBlockOpen {
contentBlockIndex++
thinkingBlockIndex = contentBlockIndex
isThinkingBlockOpen = true
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(thinkingBlockIndex, "thinking", "", "")
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
}
// Send thinking content
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
// Accumulate for token counting
accumulatedThinkingContent.WriteString(thinkingText)
log.Debugf("kiro: received reasoningContentEvent, text length: %d, has signature: %v", len(thinkingText), signature != "")
}
// Note: We don't close the thinking block here - it will be closed when we see
// the next assistantResponseEvent or at the end of the stream
_ = signature // Signature can be used for verification if needed
case "toolUseEvent":
// Handle dedicated tool use events with input buffering
completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs)
@@ -2721,17 +2623,71 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
totalUsage.OutputTokens = int64(outputTokens)
}
case "messageMetadataEvent":
// Handle message metadata events which may contain token counts
if metadata, ok := event["messageMetadataEvent"].(map[string]interface{}); ok {
case "messageMetadataEvent", "metadataEvent":
// Handle message metadata events which contain token counts
// Official format: { tokenUsage: { outputTokens, totalTokens, uncachedInputTokens, cacheReadInputTokens, cacheWriteInputTokens, contextUsagePercentage } }
var metadata map[string]interface{}
if m, ok := event["messageMetadataEvent"].(map[string]interface{}); ok {
metadata = m
} else if m, ok := event["metadataEvent"].(map[string]interface{}); ok {
metadata = m
} else {
metadata = event // event itself might be the metadata
}
// Check for nested tokenUsage object (official format)
if tokenUsage, ok := metadata["tokenUsage"].(map[string]interface{}); ok {
// outputTokens - precise output token count
if outputTokens, ok := tokenUsage["outputTokens"].(float64); ok {
totalUsage.OutputTokens = int64(outputTokens)
hasUpstreamUsage = true
log.Infof("kiro: streamToChannel found precise outputTokens in tokenUsage: %d", totalUsage.OutputTokens)
}
// totalTokens - precise total token count
if totalTokens, ok := tokenUsage["totalTokens"].(float64); ok {
totalUsage.TotalTokens = int64(totalTokens)
log.Infof("kiro: streamToChannel found precise totalTokens in tokenUsage: %d", totalUsage.TotalTokens)
}
// uncachedInputTokens - input tokens not from cache
if uncachedInputTokens, ok := tokenUsage["uncachedInputTokens"].(float64); ok {
totalUsage.InputTokens = int64(uncachedInputTokens)
hasUpstreamUsage = true
log.Infof("kiro: streamToChannel found uncachedInputTokens in tokenUsage: %d", totalUsage.InputTokens)
}
// cacheReadInputTokens - tokens read from cache
if cacheReadTokens, ok := tokenUsage["cacheReadInputTokens"].(float64); ok {
// Add to input tokens if we have uncached tokens, otherwise use as input
if totalUsage.InputTokens > 0 {
totalUsage.InputTokens += int64(cacheReadTokens)
} else {
totalUsage.InputTokens = int64(cacheReadTokens)
}
hasUpstreamUsage = true
log.Debugf("kiro: streamToChannel found cacheReadInputTokens in tokenUsage: %d", int64(cacheReadTokens))
}
// contextUsagePercentage - can be used as fallback for input token estimation
if ctxPct, ok := tokenUsage["contextUsagePercentage"].(float64); ok {
upstreamContextPercentage = ctxPct
log.Debugf("kiro: streamToChannel found contextUsagePercentage in tokenUsage: %.2f%%", ctxPct)
}
}
// Fallback: check for direct fields in metadata (legacy format)
if totalUsage.InputTokens == 0 {
if inputTokens, ok := metadata["inputTokens"].(float64); ok {
totalUsage.InputTokens = int64(inputTokens)
hasUpstreamUsage = true
log.Debugf("kiro: streamToChannel found inputTokens in messageMetadataEvent: %d", totalUsage.InputTokens)
}
}
if totalUsage.OutputTokens == 0 {
if outputTokens, ok := metadata["outputTokens"].(float64); ok {
totalUsage.OutputTokens = int64(outputTokens)
hasUpstreamUsage = true
log.Debugf("kiro: streamToChannel found outputTokens in messageMetadataEvent: %d", totalUsage.OutputTokens)
}
}
if totalUsage.TotalTokens == 0 {
if totalTokens, ok := metadata["totalTokens"].(float64); ok {
totalUsage.TotalTokens = int64(totalTokens)
log.Debugf("kiro: streamToChannel found totalTokens in messageMetadataEvent: %d", totalUsage.TotalTokens)
@@ -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
@@ -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
+65
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) {