mirror of
https://github.com/githubhjs/CLIProxyAPIPlus.git
synced 2026-07-13 18:15:24 +00:00
Merge pull request #42 from Ravens2121/master
feat(kiro): 新增授权码登录流程,优化邮箱获取与官方 Thinking 模式解析 预支持
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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{
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user