mirror of
https://github.com/githubhjs/CLIProxyAPIPlus.git
synced 2026-07-14 10:35: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 kiroLogin bool
|
||||||
var kiroGoogleLogin bool
|
var kiroGoogleLogin bool
|
||||||
var kiroAWSLogin bool
|
var kiroAWSLogin bool
|
||||||
|
var kiroAWSAuthCode bool
|
||||||
var kiroImport bool
|
var kiroImport bool
|
||||||
var githubCopilotLogin bool
|
var githubCopilotLogin bool
|
||||||
var projectID string
|
var projectID string
|
||||||
@@ -101,6 +102,7 @@ func main() {
|
|||||||
flag.BoolVar(&kiroLogin, "kiro-login", false, "Login to Kiro using Google OAuth")
|
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(&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(&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(&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.BoolVar(&githubCopilotLogin, "github-copilot-login", false, "Login to GitHub Copilot using device flow")
|
||||||
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
flag.StringVar(&projectID, "project_id", "", "Project ID (Gemini only, not required)")
|
||||||
@@ -513,6 +515,10 @@ func main() {
|
|||||||
// Users can explicitly override with --no-incognito
|
// Users can explicitly override with --no-incognito
|
||||||
setKiroIncognitoMode(cfg, useIncognito, noIncognito)
|
setKiroIncognitoMode(cfg, useIncognito, noIncognito)
|
||||||
cmd.DoKiroAWSLogin(cfg, options)
|
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 {
|
} else if kiroImport {
|
||||||
cmd.DoKiroImport(cfg, options)
|
cmd.DoKiroImport(cfg, options)
|
||||||
} else {
|
} 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)
|
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.
|
// exchangeCodeForToken exchanges the authorization code for tokens.
|
||||||
func (o *KiroOAuth) exchangeCodeForToken(ctx context.Context, code, codeVerifier, redirectURI string) (*KiroTokenData, error) {
|
func (o *KiroOAuth) exchangeCodeForToken(ctx context.Context, code, codeVerifier, redirectURI string) (*KiroTokenData, error) {
|
||||||
payload := map[string]string{
|
payload := map[string]string{
|
||||||
|
|||||||
@@ -3,9 +3,14 @@ package kiro
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/base64"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"html"
|
||||||
"io"
|
"io"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -25,6 +30,13 @@ const (
|
|||||||
|
|
||||||
// Polling interval
|
// Polling interval
|
||||||
pollInterval = 5 * time.Second
|
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.
|
// SSOOIDCClient handles AWS SSO OIDC authentication.
|
||||||
@@ -73,13 +85,11 @@ type CreateTokenResponse struct {
|
|||||||
|
|
||||||
// RegisterClient registers a new OIDC client with AWS.
|
// RegisterClient registers a new OIDC client with AWS.
|
||||||
func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResponse, error) {
|
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{}{
|
payload := map[string]interface{}{
|
||||||
"clientName": clientName,
|
"clientName": "Kiro IDE",
|
||||||
"clientType": "public",
|
"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)
|
body, err := json.Marshal(payload)
|
||||||
@@ -92,6 +102,7 @@ func (c *SSOOIDCClient) RegisterClient(ctx context.Context) (*RegisterClientResp
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", kiroUserAgent)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,6 +146,7 @@ func (c *SSOOIDCClient) StartDeviceAuthorization(ctx context.Context, clientID,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", kiroUserAgent)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -179,6 +191,7 @@ func (c *SSOOIDCClient) CreateToken(ctx context.Context, clientID, clientSecret,
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", kiroUserAgent)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -240,6 +253,7 @@ func (c *SSOOIDCClient) RefreshToken(ctx context.Context, clientID, clientSecret
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", "application/json")
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
req.Header.Set("User-Agent", kiroUserAgent)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -370,8 +384,8 @@ func (c *SSOOIDCClient) LoginWithBuilderID(ctx context.Context) (*KiroTokenData,
|
|||||||
fmt.Println("Fetching profile information...")
|
fmt.Println("Fetching profile information...")
|
||||||
profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
|
profileArn := c.fetchProfileArn(ctx, tokenResp.AccessToken)
|
||||||
|
|
||||||
// Extract email from JWT access token
|
// Fetch user email (tries CodeWhisperer API first, then userinfo endpoint, then JWT parsing)
|
||||||
email := ExtractEmailFromJWT(tokenResp.AccessToken)
|
email := FetchUserEmailWithFallback(ctx, c.cfg, tokenResp.AccessToken)
|
||||||
if email != "" {
|
if email != "" {
|
||||||
fmt.Printf(" Logged in as: %s\n", 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")
|
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.
|
// fetchProfileArn retrieves the profile ARN from CodeWhisperer API.
|
||||||
// This is needed for file naming since AWS SSO OIDC doesn't return profile info.
|
// 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 {
|
func (c *SSOOIDCClient) fetchProfileArn(ctx context.Context, accessToken string) string {
|
||||||
@@ -525,3 +601,323 @@ func (c *SSOOIDCClient) tryListCustomizations(ctx context.Context, accessToken s
|
|||||||
|
|
||||||
return ""
|
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!")
|
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.
|
// DoKiroImport imports Kiro token from Kiro IDE's token file.
|
||||||
// This is useful for users who have already logged in via Kiro IDE
|
// This is useful for users who have already logged in via Kiro IDE
|
||||||
// and want to use the same credentials in CLI Proxy API.
|
// 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)
|
log.Debugf("kiro: parseEventStream found stopReason in messageStopEvent: %s", stopReason)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "messageMetadataEvent":
|
case "messageMetadataEvent", "metadataEvent":
|
||||||
// Handle message metadata events which may contain token counts
|
// Handle message metadata events which contain token counts
|
||||||
if metadata, ok := event["messageMetadataEvent"].(map[string]interface{}); ok {
|
// 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 {
|
if inputTokens, ok := metadata["inputTokens"].(float64); ok {
|
||||||
usageInfo.InputTokens = int64(inputTokens)
|
usageInfo.InputTokens = int64(inputTokens)
|
||||||
log.Debugf("kiro: parseEventStream found inputTokens in messageMetadataEvent: %d", usageInfo.InputTokens)
|
log.Debugf("kiro: parseEventStream found inputTokens in messageMetadataEvent: %d", usageInfo.InputTokens)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if usageInfo.OutputTokens == 0 {
|
||||||
if outputTokens, ok := metadata["outputTokens"].(float64); ok {
|
if outputTokens, ok := metadata["outputTokens"].(float64); ok {
|
||||||
usageInfo.OutputTokens = int64(outputTokens)
|
usageInfo.OutputTokens = int64(outputTokens)
|
||||||
log.Debugf("kiro: parseEventStream found outputTokens in messageMetadataEvent: %d", usageInfo.OutputTokens)
|
log.Debugf("kiro: parseEventStream found outputTokens in messageMetadataEvent: %d", usageInfo.OutputTokens)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if usageInfo.TotalTokens == 0 {
|
||||||
if totalTokens, ok := metadata["totalTokens"].(float64); ok {
|
if totalTokens, ok := metadata["totalTokens"].(float64); ok {
|
||||||
usageInfo.TotalTokens = int64(totalTokens)
|
usageInfo.TotalTokens = int64(totalTokens)
|
||||||
log.Debugf("kiro: parseEventStream found totalTokens in messageMetadataEvent: %d", usageInfo.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)
|
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:
|
default:
|
||||||
// Check for contextUsagePercentage in any event
|
// Check for contextUsagePercentage in any event
|
||||||
if ctxPct, ok := event["contextUsagePercentage"].(float64); ok {
|
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
|
// IMPORTANT: This must persist across all TranslateStream calls
|
||||||
var translatorParam any
|
var translatorParam any
|
||||||
|
|
||||||
// Thinking mode state tracking - based on amq2api implementation
|
// Thinking mode state tracking - tag-based parsing for <thinking> tags in content
|
||||||
// Tracks whether we're inside a <thinking> block and handles partial tags
|
inThinkBlock := false // Whether we're currently inside a <thinking> block
|
||||||
inThinkBlock := false
|
isThinkingBlockOpen := false // Track if thinking content block SSE event is open
|
||||||
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
|
|
||||||
thinkingBlockIndex := -1 // Index of the thinking content block
|
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
|
// Buffer for handling partial tag matches at chunk boundaries
|
||||||
// When inside a markdown code block, <thinking> tags should NOT be parsed
|
var pendingContent strings.Builder // Buffer content that might be part of a tag
|
||||||
// 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
|
|
||||||
|
|
||||||
// Pre-calculate input tokens from request if possible
|
// Pre-calculate input tokens from request if possible
|
||||||
// Kiro uses Claude format, so try Claude format first, then OpenAI format, then fallback
|
// 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
|
currentToolUse = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Flush any pending tag characters at EOF
|
// DISABLED: Tag-based pending character flushing
|
||||||
// These are partial tag prefixes that were held back waiting for more data
|
// This code block was used for tag-based thinking detection which has been
|
||||||
// Since no more data is coming, output them as regular text
|
// replaced by reasoningContentEvent handling. No pending tag chars to flush.
|
||||||
var pendingText string
|
// Original code preserved in git history.
|
||||||
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")}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
break
|
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)
|
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:
|
default:
|
||||||
// Check for upstream usage events from Kiro API
|
// Check for upstream usage events from Kiro API
|
||||||
// Format: {"unit":"credit","unitPlural":"credits","usage":1.458}
|
// 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()
|
lastUsageUpdateTime = time.Now()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process content with thinking tag detection - based on amq2api implementation
|
// TAG-BASED THINKING PARSING: Parse <thinking> tags from content
|
||||||
// This handles <thinking> and </thinking> tags that may span across chunks
|
// Combine pending content with new content for processing
|
||||||
remaining := contentDelta
|
pendingContent.WriteString(contentDelta)
|
||||||
|
processContent := pendingContent.String()
|
||||||
// If we have pending start tag chars from previous chunk, prepend them
|
pendingContent.Reset()
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Process content looking for thinking tags
|
||||||
|
for len(processContent) > 0 {
|
||||||
if inThinkBlock {
|
if inThinkBlock {
|
||||||
// Inside thinking block - look for </thinking> end tag
|
// We're inside a thinking block, look for </thinking>
|
||||||
// CRITICAL FIX: Skip </thinking> tags that are not the real end tag
|
endIdx := strings.Index(processContent, kirocommon.ThinkingEndTag)
|
||||||
// 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)
|
|
||||||
|
|
||||||
if endIdx >= 0 {
|
if endIdx >= 0 {
|
||||||
// Found end tag - emit any content before end tag, then close block
|
// Found end tag - emit thinking content before the tag
|
||||||
thinkContent := remaining[:endIdx]
|
thinkingText := processContent[:endIdx]
|
||||||
if thinkContent != "" {
|
if thinkingText != "" {
|
||||||
// TRUE STREAMING: Emit thinking content immediately
|
// Ensure thinking block is open
|
||||||
// Start thinking block if not open
|
|
||||||
if !isThinkingBlockOpen {
|
if !isThinkingBlockOpen {
|
||||||
contentBlockIndex++
|
contentBlockIndex++
|
||||||
thinkingBlockIndex = contentBlockIndex
|
thinkingBlockIndex = contentBlockIndex
|
||||||
@@ -2382,22 +2266,16 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Send thinking delta
|
||||||
// Send thinking delta immediately
|
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkingText, thinkingBlockIndex)
|
||||||
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(thinkContent, thinkingBlockIndex)
|
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
if chunk != "" {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Accumulate thinking content for signature generation
|
accumulatedThinkingContent.WriteString(thinkingText)
|
||||||
accumulatedThinkingContent.WriteString(thinkContent)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Note: Partial tag handling is done via pendingEndTagChars
|
|
||||||
// When the next chunk arrives, the partial tag will be reconstructed
|
|
||||||
|
|
||||||
// Close thinking block
|
// Close thinking block
|
||||||
if isThinkingBlockOpen {
|
if isThinkingBlockOpen {
|
||||||
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
|
blockStop := kiroclaude.BuildClaudeThinkingBlockStopEvent(thinkingBlockIndex)
|
||||||
@@ -2408,31 +2286,25 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
isThinkingBlockOpen = false
|
isThinkingBlockOpen = false
|
||||||
accumulatedThinkingContent.Reset() // Reset for potential next thinking block
|
|
||||||
}
|
}
|
||||||
|
|
||||||
inThinkBlock = false
|
inThinkBlock = false
|
||||||
thinkingBlockCompleted = true // Mark that we've completed a thinking block
|
processContent = processContent[endIdx+len(kirocommon.ThinkingEndTag):]
|
||||||
remaining = remaining[endIdx+len(kirocommon.ThinkingEndTag):]
|
log.Debugf("kiro: closed thinking block, remaining content: %d chars", len(processContent))
|
||||||
log.Debugf("kiro: exited thinking block, subsequent <thinking> tags will be treated as text")
|
|
||||||
} else {
|
} else {
|
||||||
// No end tag found - TRUE STREAMING: emit content immediately
|
// No end tag found - check for partial match at end
|
||||||
// Only save potential partial tag length for next iteration
|
partialMatch := false
|
||||||
pendingEnd := kiroclaude.PendingTagSuffix(remaining, kirocommon.ThinkingEndTag)
|
for i := 1; i < len(kirocommon.ThinkingEndTag) && i <= len(processContent); i++ {
|
||||||
|
if strings.HasSuffix(processContent, kirocommon.ThinkingEndTag[:i]) {
|
||||||
// Calculate content to emit immediately (excluding potential partial tag)
|
// Possible partial tag at end, buffer it
|
||||||
var contentToEmit string
|
pendingContent.WriteString(processContent[len(processContent)-i:])
|
||||||
if pendingEnd > 0 {
|
processContent = processContent[:len(processContent)-i]
|
||||||
contentToEmit = remaining[:len(remaining)-pendingEnd]
|
partialMatch = true
|
||||||
// Save partial tag length for next iteration (will be reconstructed from thinkingEndTag)
|
break
|
||||||
pendingEndTagChars = pendingEnd
|
|
||||||
} else {
|
|
||||||
contentToEmit = remaining
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
// TRUE STREAMING: Emit thinking content immediately
|
if !partialMatch || len(processContent) > 0 {
|
||||||
if contentToEmit != "" {
|
// Emit all as thinking content
|
||||||
// Start thinking block if not open
|
if processContent != "" {
|
||||||
if !isThinkingBlockOpen {
|
if !isThinkingBlockOpen {
|
||||||
contentBlockIndex++
|
contentBlockIndex++
|
||||||
thinkingBlockIndex = contentBlockIndex
|
thinkingBlockIndex = contentBlockIndex
|
||||||
@@ -2445,47 +2317,37 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(processContent, thinkingBlockIndex)
|
||||||
// Send thinking delta immediately - TRUE STREAMING!
|
|
||||||
thinkingEvent := kiroclaude.BuildClaudeThinkingDeltaEvent(contentToEmit, thinkingBlockIndex)
|
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, thinkingEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
if chunk != "" {
|
||||||
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Accumulate thinking content for signature generation
|
accumulatedThinkingContent.WriteString(processContent)
|
||||||
accumulatedThinkingContent.WriteString(contentToEmit)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
remaining = ""
|
processContent = ""
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Outside thinking block - look for <thinking> start tag
|
// Not in thinking block, look for <thinking>
|
||||||
// CRITICAL FIX: Only parse <thinking> tags at the very beginning of the response
|
startIdx := strings.Index(processContent, kirocommon.ThinkingStartTag)
|
||||||
// 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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if startIdx >= 0 {
|
if startIdx >= 0 {
|
||||||
// Found start tag - emit text before it and switch to thinking mode
|
// Found start tag - emit text content before the tag
|
||||||
textBefore := remaining[:startIdx]
|
textBefore := processContent[:startIdx]
|
||||||
if textBefore != "" {
|
if textBefore != "" {
|
||||||
// Only whitespace before thinking tag is allowed
|
// Close thinking block if open
|
||||||
// Start text content block if needed
|
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 {
|
if !isTextBlockOpen {
|
||||||
contentBlockIndex++
|
contentBlockIndex++
|
||||||
isTextBlockOpen = true
|
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)
|
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textBefore, contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
@@ -2506,8 +2368,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Close text block before entering thinking
|
||||||
// Close text block before starting thinking block
|
|
||||||
if isTextBlockOpen {
|
if isTextBlockOpen {
|
||||||
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
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
|
isTextBlockOpen = false
|
||||||
}
|
}
|
||||||
|
|
||||||
inThinkBlock = true
|
inThinkBlock = true
|
||||||
remaining = remaining[startIdx+len(kirocommon.ThinkingStartTag):]
|
processContent = processContent[startIdx+len(kirocommon.ThinkingStartTag):]
|
||||||
log.Debugf("kiro: entered thinking block")
|
log.Debugf("kiro: entered thinking block")
|
||||||
} else {
|
} else {
|
||||||
// No start tag found - check for partial start tag at buffer end
|
// No start tag found - check for partial match at end
|
||||||
// Only check for partial tags if we haven't completed a thinking block yet
|
partialMatch := false
|
||||||
pendingStart := 0
|
for i := 1; i < len(kirocommon.ThinkingStartTag) && i <= len(processContent); i++ {
|
||||||
if !thinkingBlockCompleted && !hasSeenNonThinkingContent {
|
if strings.HasSuffix(processContent, kirocommon.ThinkingStartTag[:i]) {
|
||||||
pendingStart = kiroclaude.PendingTagSuffix(remaining, kirocommon.ThinkingStartTag)
|
// 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 {
|
if !isTextBlockOpen {
|
||||||
contentBlockIndex++
|
contentBlockIndex++
|
||||||
isTextBlockOpen = true
|
isTextBlockOpen = true
|
||||||
@@ -2549,8 +2408,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
claudeEvent := kiroclaude.BuildClaudeStreamEvent(processContent, contentBlockIndex)
|
||||||
claudeEvent := kiroclaude.BuildClaudeStreamEvent(textToEmit, contentBlockIndex)
|
|
||||||
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
||||||
for _, chunk := range sseData {
|
for _, chunk := range sseData {
|
||||||
if chunk != "" {
|
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":
|
case "toolUseEvent":
|
||||||
// Handle dedicated tool use events with input buffering
|
// Handle dedicated tool use events with input buffering
|
||||||
completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs)
|
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)
|
totalUsage.OutputTokens = int64(outputTokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
case "messageMetadataEvent":
|
case "messageMetadataEvent", "metadataEvent":
|
||||||
// Handle message metadata events which may contain token counts
|
// Handle message metadata events which contain token counts
|
||||||
if metadata, ok := event["messageMetadataEvent"].(map[string]interface{}); ok {
|
// 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 {
|
if inputTokens, ok := metadata["inputTokens"].(float64); ok {
|
||||||
totalUsage.InputTokens = int64(inputTokens)
|
totalUsage.InputTokens = int64(inputTokens)
|
||||||
|
hasUpstreamUsage = true
|
||||||
log.Debugf("kiro: streamToChannel found inputTokens in messageMetadataEvent: %d", totalUsage.InputTokens)
|
log.Debugf("kiro: streamToChannel found inputTokens in messageMetadataEvent: %d", totalUsage.InputTokens)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if totalUsage.OutputTokens == 0 {
|
||||||
if outputTokens, ok := metadata["outputTokens"].(float64); ok {
|
if outputTokens, ok := metadata["outputTokens"].(float64); ok {
|
||||||
totalUsage.OutputTokens = int64(outputTokens)
|
totalUsage.OutputTokens = int64(outputTokens)
|
||||||
|
hasUpstreamUsage = true
|
||||||
log.Debugf("kiro: streamToChannel found outputTokens in messageMetadataEvent: %d", totalUsage.OutputTokens)
|
log.Debugf("kiro: streamToChannel found outputTokens in messageMetadataEvent: %d", totalUsage.OutputTokens)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
if totalUsage.TotalTokens == 0 {
|
||||||
if totalTokens, ok := metadata["totalTokens"].(float64); ok {
|
if totalTokens, ok := metadata["totalTokens"].(float64); ok {
|
||||||
totalUsage.TotalTokens = int64(totalTokens)
|
totalUsage.TotalTokens = int64(totalTokens)
|
||||||
log.Debugf("kiro: streamToChannel found totalTokens in messageMetadataEvent: %d", totalUsage.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)
|
kiroTools := convertClaudeToolsToKiro(tools)
|
||||||
|
|
||||||
// Thinking mode implementation:
|
// Thinking mode implementation:
|
||||||
// Kiro API doesn't accept max_tokens for thinking. Instead, thinking mode is enabled
|
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
|
||||||
// by injecting <thinking_mode> and <max_thinking_length> tags into the system prompt.
|
// When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
|
||||||
// We use a fixed max_thinking_length value since Kiro handles the actual budget internally.
|
// rather than inline <thinking> tags in assistantResponseEvent.
|
||||||
|
// We use a high max_thinking_length to allow extensive reasoning.
|
||||||
if thinkingEnabled {
|
if thinkingEnabled {
|
||||||
thinkingHint := `<thinking_mode>interleaved</thinking_mode>
|
thinkingHint := `<thinking_mode>enabled</thinking_mode>
|
||||||
<max_thinking_length>200000</max_thinking_length>
|
<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.`
|
|
||||||
if systemPrompt != "" {
|
if systemPrompt != "" {
|
||||||
systemPrompt = thinkingHint + "\n\n" + systemPrompt
|
systemPrompt = thinkingHint + "\n\n" + systemPrompt
|
||||||
} else {
|
} else {
|
||||||
systemPrompt = thinkingHint
|
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
|
// Process messages and build history
|
||||||
|
|||||||
@@ -231,20 +231,19 @@ func BuildKiroPayloadFromOpenAI(openaiBody []byte, modelID, profileArn, origin s
|
|||||||
kiroTools := convertOpenAIToolsToKiro(tools)
|
kiroTools := convertOpenAIToolsToKiro(tools)
|
||||||
|
|
||||||
// Thinking mode implementation:
|
// Thinking mode implementation:
|
||||||
// Kiro API doesn't accept max_tokens for thinking. Instead, thinking mode is enabled
|
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
|
||||||
// by injecting <thinking_mode> and <max_thinking_length> tags into the system prompt.
|
// When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
|
||||||
// We use a fixed max_thinking_length value since Kiro handles the actual budget internally.
|
// rather than inline <thinking> tags in assistantResponseEvent.
|
||||||
|
// We use a high max_thinking_length to allow extensive reasoning.
|
||||||
if thinkingEnabled {
|
if thinkingEnabled {
|
||||||
thinkingHint := `<thinking_mode>interleaved</thinking_mode>
|
thinkingHint := `<thinking_mode>enabled</thinking_mode>
|
||||||
<max_thinking_length>200000</max_thinking_length>
|
<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.`
|
|
||||||
if systemPrompt != "" {
|
if systemPrompt != "" {
|
||||||
systemPrompt = thinkingHint + "\n\n" + systemPrompt
|
systemPrompt = thinkingHint + "\n\n" + systemPrompt
|
||||||
} else {
|
} else {
|
||||||
systemPrompt = thinkingHint
|
systemPrompt = thinkingHint
|
||||||
}
|
}
|
||||||
log.Debugf("kiro-openai: injected thinking prompt")
|
log.Debugf("kiro-openai: injected thinking prompt (official mode)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process messages and build history
|
// Process messages and build history
|
||||||
|
|||||||
@@ -117,6 +117,71 @@ func (a *KiroAuthenticator) Login(ctx context.Context, cfg *config.Config, opts
|
|||||||
return record, nil
|
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.
|
// LoginWithGoogle performs OAuth login for Kiro with Google.
|
||||||
// This uses a custom protocol handler (kiro://) to receive the callback.
|
// 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) {
|
func (a *KiroAuthenticator) LoginWithGoogle(ctx context.Context, cfg *config.Config, opts *LoginOptions) (*coreauth.Auth, error) {
|
||||||
|
|||||||
Reference in New Issue
Block a user