mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-09 15:25:17 +00:00
3143 lines
106 KiB
Go
3143 lines
106 KiB
Go
package executor
|
|
|
|
import (
|
|
"bufio"
|
|
"bytes"
|
|
"context"
|
|
"encoding/base64"
|
|
"encoding/binary"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
"unicode/utf8"
|
|
|
|
"github.com/google/uuid"
|
|
kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
|
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
|
|
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
|
|
"github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/usage"
|
|
sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator"
|
|
log "github.com/sirupsen/logrus"
|
|
"github.com/tidwall/gjson"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
)
|
|
|
|
const (
|
|
// kiroEndpoint is the CodeWhisperer streaming endpoint for chat API (GenerateAssistantResponse).
|
|
// Based on AIClient-2-API reference implementation.
|
|
// Note: Amazon Q uses a different endpoint (q.us-east-1.amazonaws.com) with different request format.
|
|
kiroEndpoint = "https://codewhisperer.us-east-1.amazonaws.com/generateAssistantResponse"
|
|
kiroContentType = "application/json"
|
|
kiroAcceptStream = "application/json"
|
|
kiroMaxMessageSize = 10 * 1024 * 1024 // 10MB max message size for event stream
|
|
kiroMaxToolDescLen = 10237 // Kiro API limit is 10240 bytes, leave room for "..."
|
|
// kiroUserAgent matches AIClient-2-API format for x-amz-user-agent header
|
|
kiroUserAgent = "aws-sdk-js/1.0.7 KiroIDE-0.1.25"
|
|
// kiroFullUserAgent is the complete user-agent header matching AIClient-2-API
|
|
kiroFullUserAgent = "aws-sdk-js/1.0.7 ua/2.1 os/linux lang/go api/codewhispererstreaming#1.0.7 m/E KiroIDE-0.1.25"
|
|
|
|
// kiroAgenticSystemPrompt is injected only for -agentic models to prevent timeouts on large writes.
|
|
// AWS Kiro API has a 2-3 minute timeout for large file write operations.
|
|
kiroAgenticSystemPrompt = `
|
|
# CRITICAL: CHUNKED WRITE PROTOCOL (MANDATORY)
|
|
|
|
You MUST follow these rules for ALL file operations. Violation causes server timeouts and task failure.
|
|
|
|
## ABSOLUTE LIMITS
|
|
- **MAXIMUM 350 LINES** per single write/edit operation - NO EXCEPTIONS
|
|
- **RECOMMENDED 300 LINES** or less for optimal performance
|
|
- **NEVER** write entire files in one operation if >300 lines
|
|
|
|
## MANDATORY CHUNKED WRITE STRATEGY
|
|
|
|
### For NEW FILES (>300 lines total):
|
|
1. FIRST: Write initial chunk (first 250-300 lines) using write_to_file/fsWrite
|
|
2. THEN: Append remaining content in 250-300 line chunks using file append operations
|
|
3. REPEAT: Continue appending until complete
|
|
|
|
### For EDITING EXISTING FILES:
|
|
1. Use surgical edits (apply_diff/targeted edits) - change ONLY what's needed
|
|
2. NEVER rewrite entire files - use incremental modifications
|
|
3. Split large refactors into multiple small, focused edits
|
|
|
|
### For LARGE CODE GENERATION:
|
|
1. Generate in logical sections (imports, types, functions separately)
|
|
2. Write each section as a separate operation
|
|
3. Use append operations for subsequent sections
|
|
|
|
## EXAMPLES OF CORRECT BEHAVIOR
|
|
|
|
✅ CORRECT: Writing a 600-line file
|
|
- Operation 1: Write lines 1-300 (initial file creation)
|
|
- Operation 2: Append lines 301-600
|
|
|
|
✅ CORRECT: Editing multiple functions
|
|
- Operation 1: Edit function A
|
|
- Operation 2: Edit function B
|
|
- Operation 3: Edit function C
|
|
|
|
❌ WRONG: Writing 500 lines in single operation → TIMEOUT
|
|
❌ WRONG: Rewriting entire file to change 5 lines → TIMEOUT
|
|
❌ WRONG: Generating massive code blocks without chunking → TIMEOUT
|
|
|
|
## WHY THIS MATTERS
|
|
- Server has 2-3 minute timeout for operations
|
|
- Large writes exceed timeout and FAIL completely
|
|
- Chunked writes are FASTER and more RELIABLE
|
|
- Failed writes waste time and require retry
|
|
|
|
REMEMBER: When in doubt, write LESS per operation. Multiple small operations > one large operation.`
|
|
)
|
|
|
|
// KiroExecutor handles requests to AWS CodeWhisperer (Kiro) API.
|
|
type KiroExecutor struct {
|
|
cfg *config.Config
|
|
refreshMu sync.Mutex // Serializes token refresh operations to prevent race conditions
|
|
}
|
|
|
|
// NewKiroExecutor creates a new Kiro executor instance.
|
|
func NewKiroExecutor(cfg *config.Config) *KiroExecutor {
|
|
return &KiroExecutor{cfg: cfg}
|
|
}
|
|
|
|
// Identifier returns the unique identifier for this executor.
|
|
func (e *KiroExecutor) Identifier() string { return "kiro" }
|
|
|
|
// PrepareRequest prepares the HTTP request before execution.
|
|
func (e *KiroExecutor) PrepareRequest(_ *http.Request, _ *cliproxyauth.Auth) error { return nil }
|
|
|
|
|
|
// Execute sends the request to Kiro API and returns the response.
|
|
// Supports automatic token refresh on 401/403 errors.
|
|
func (e *KiroExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) {
|
|
accessToken, profileArn := kiroCredentials(auth)
|
|
if accessToken == "" {
|
|
return resp, fmt.Errorf("kiro: access token not found in auth")
|
|
}
|
|
if profileArn == "" {
|
|
// Only warn if not using builder-id auth (which doesn't need profileArn)
|
|
if auth == nil || auth.Metadata == nil {
|
|
log.Debugf("kiro: profile ARN not found in auth (may be normal for builder-id)")
|
|
} else if authMethod, ok := auth.Metadata["auth_method"].(string); !ok || authMethod != "builder-id" {
|
|
log.Warnf("kiro: profile ARN not found in auth, API calls may fail")
|
|
}
|
|
}
|
|
|
|
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
defer reporter.trackFailure(ctx, &err)
|
|
|
|
// Check if token is expired before making request
|
|
if e.isTokenExpired(accessToken) {
|
|
log.Infof("kiro: access token expired, attempting refresh before request")
|
|
refreshedAuth, refreshErr := e.Refresh(ctx, auth)
|
|
if refreshErr != nil {
|
|
log.Warnf("kiro: pre-request token refresh failed: %v", refreshErr)
|
|
} else if refreshedAuth != nil {
|
|
auth = refreshedAuth
|
|
accessToken, profileArn = kiroCredentials(auth)
|
|
log.Infof("kiro: token refreshed successfully before request")
|
|
}
|
|
}
|
|
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("kiro")
|
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
|
|
|
kiroModelID := e.mapModelToKiro(req.Model)
|
|
|
|
// Check if this is an agentic model variant
|
|
isAgentic := strings.HasSuffix(req.Model, "-agentic")
|
|
|
|
// Check if this is a chat-only model variant (no tool calling)
|
|
isChatOnly := strings.HasSuffix(req.Model, "-chat")
|
|
|
|
// Determine initial origin - always use AI_EDITOR to match AIClient-2-API behavior
|
|
// AIClient-2-API uses AI_EDITOR for all models, which is the Kiro IDE quota
|
|
// Note: CLI origin is for Amazon Q quota, but AIClient-2-API doesn't use it
|
|
currentOrigin := "AI_EDITOR"
|
|
|
|
// Determine if profileArn should be included based on auth method
|
|
// profileArn is only needed for social auth (Google OAuth), not for builder-id (AWS SSO)
|
|
effectiveProfileArn := profileArn
|
|
if auth != nil && auth.Metadata != nil {
|
|
if authMethod, ok := auth.Metadata["auth_method"].(string); ok && authMethod == "builder-id" {
|
|
effectiveProfileArn = "" // Don't include profileArn for builder-id auth
|
|
}
|
|
}
|
|
|
|
kiroPayload := e.buildKiroPayload(body, kiroModelID, effectiveProfileArn, currentOrigin, isAgentic, isChatOnly)
|
|
|
|
// Execute with retry on 401/403 and 429 (quota exhausted)
|
|
resp, err = e.executeWithRetry(ctx, auth, req, opts, accessToken, effectiveProfileArn, kiroPayload, body, from, to, reporter, currentOrigin, kiroModelID, isAgentic, isChatOnly)
|
|
return resp, err
|
|
}
|
|
|
|
// executeWithRetry performs the actual HTTP request with automatic retry on auth errors.
|
|
// Supports automatic fallback from CLI (Amazon Q) quota to AI_EDITOR (Kiro IDE) quota on 429.
|
|
func (e *KiroExecutor) executeWithRetry(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, accessToken, profileArn string, kiroPayload, body []byte, from, to sdktranslator.Format, reporter *usageReporter, currentOrigin, kiroModelID string, isAgentic, isChatOnly bool) (cliproxyexecutor.Response, error) {
|
|
var resp cliproxyexecutor.Response
|
|
maxRetries := 2 // Allow retries for token refresh + origin fallback
|
|
|
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
url := kiroEndpoint
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(kiroPayload))
|
|
if err != nil {
|
|
return resp, err
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", kiroContentType)
|
|
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
|
|
httpReq.Header.Set("Accept", kiroAcceptStream)
|
|
httpReq.Header.Set("x-amz-user-agent", kiroUserAgent)
|
|
httpReq.Header.Set("User-Agent", kiroFullUserAgent)
|
|
httpReq.Header.Set("amz-sdk-request", "attempt=1; max=1")
|
|
httpReq.Header.Set("x-amzn-kiro-agent-mode", "vibe")
|
|
httpReq.Header.Set("amz-sdk-invocation-id", uuid.New().String())
|
|
|
|
var attrs map[string]string
|
|
if auth != nil {
|
|
attrs = auth.Attributes
|
|
}
|
|
util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
|
|
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: kiroPayload,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 120*time.Second)
|
|
httpResp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
return resp, err
|
|
}
|
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
|
|
// Handle 429 errors (quota exhausted) with origin fallback
|
|
if httpResp.StatusCode == 429 {
|
|
respBody, _ := io.ReadAll(httpResp.Body)
|
|
_ = httpResp.Body.Close()
|
|
appendAPIResponseChunk(ctx, e.cfg, respBody)
|
|
|
|
// If currently using CLI quota and it's exhausted, switch to AI_EDITOR (Kiro IDE) quota
|
|
if currentOrigin == "CLI" {
|
|
log.Warnf("kiro: Amazon Q (CLI) quota exhausted (429), switching to Kiro (AI_EDITOR) fallback")
|
|
currentOrigin = "AI_EDITOR"
|
|
|
|
// Rebuild payload with new origin
|
|
kiroPayload = e.buildKiroPayload(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly)
|
|
|
|
// Retry with new origin
|
|
continue
|
|
}
|
|
|
|
// Already on AI_EDITOR or other origin, return the error
|
|
log.Debugf("kiro request error, status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), respBody))
|
|
return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
|
|
}
|
|
|
|
// Handle 5xx server errors with exponential backoff retry
|
|
if httpResp.StatusCode >= 500 && httpResp.StatusCode < 600 {
|
|
respBody, _ := io.ReadAll(httpResp.Body)
|
|
_ = httpResp.Body.Close()
|
|
appendAPIResponseChunk(ctx, e.cfg, respBody)
|
|
|
|
if attempt < maxRetries {
|
|
// Exponential backoff: 1s, 2s, 4s... (max 30s)
|
|
backoff := time.Duration(1<<attempt) * time.Second
|
|
if backoff > 30*time.Second {
|
|
backoff = 30 * time.Second
|
|
}
|
|
log.Warnf("kiro: server error %d, retrying in %v (attempt %d/%d)", httpResp.StatusCode, backoff, attempt+1, maxRetries)
|
|
time.Sleep(backoff)
|
|
continue
|
|
}
|
|
log.Errorf("kiro: server error %d after %d retries", httpResp.StatusCode, maxRetries)
|
|
return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
|
|
}
|
|
|
|
// Handle 401/403 errors with token refresh and retry
|
|
if httpResp.StatusCode == 401 || httpResp.StatusCode == 403 {
|
|
respBody, _ := io.ReadAll(httpResp.Body)
|
|
_ = httpResp.Body.Close()
|
|
appendAPIResponseChunk(ctx, e.cfg, respBody)
|
|
|
|
if attempt < maxRetries {
|
|
log.Warnf("kiro: received %d error, attempting token refresh and retry (attempt %d/%d)", httpResp.StatusCode, attempt+1, maxRetries+1)
|
|
|
|
refreshedAuth, refreshErr := e.Refresh(ctx, auth)
|
|
if refreshErr != nil {
|
|
log.Errorf("kiro: token refresh failed: %v", refreshErr)
|
|
return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
|
|
}
|
|
|
|
if refreshedAuth != nil {
|
|
auth = refreshedAuth
|
|
accessToken, profileArn = kiroCredentials(auth)
|
|
// Rebuild payload with new profile ARN if changed
|
|
kiroPayload = e.buildKiroPayload(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly)
|
|
log.Infof("kiro: token refreshed successfully, retrying request")
|
|
continue
|
|
}
|
|
}
|
|
|
|
log.Debugf("kiro request error, status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), respBody))
|
|
return resp, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
|
|
}
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(httpResp.Body)
|
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
|
log.Debugf("kiro request error, status: %d, body: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b))
|
|
err = statusErr{code: httpResp.StatusCode, msg: string(b)}
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
return resp, err
|
|
}
|
|
|
|
defer func() {
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
}()
|
|
|
|
content, toolUses, usageInfo, err := e.parseEventStream(httpResp.Body)
|
|
if err != nil {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
return resp, err
|
|
}
|
|
|
|
// Fallback for usage if missing from upstream
|
|
if usageInfo.TotalTokens == 0 {
|
|
if enc, encErr := tokenizerForModel(req.Model); encErr == nil {
|
|
if inp, countErr := countOpenAIChatTokens(enc, opts.OriginalRequest); countErr == nil {
|
|
usageInfo.InputTokens = inp
|
|
}
|
|
}
|
|
if len(content) > 0 {
|
|
// Use tiktoken for more accurate output token calculation
|
|
if enc, encErr := tokenizerForModel(req.Model); encErr == nil {
|
|
if tokenCount, countErr := enc.Count(content); countErr == nil {
|
|
usageInfo.OutputTokens = int64(tokenCount)
|
|
}
|
|
}
|
|
// Fallback to character count estimation if tiktoken fails
|
|
if usageInfo.OutputTokens == 0 {
|
|
usageInfo.OutputTokens = int64(len(content) / 4)
|
|
if usageInfo.OutputTokens == 0 {
|
|
usageInfo.OutputTokens = 1
|
|
}
|
|
}
|
|
}
|
|
usageInfo.TotalTokens = usageInfo.InputTokens + usageInfo.OutputTokens
|
|
}
|
|
|
|
appendAPIResponseChunk(ctx, e.cfg, []byte(content))
|
|
reporter.publish(ctx, usageInfo)
|
|
|
|
// Build response in Claude format for Kiro translator
|
|
kiroResponse := e.buildClaudeResponse(content, toolUses, req.Model, usageInfo)
|
|
out := sdktranslator.TranslateNonStream(ctx, to, from, req.Model, bytes.Clone(opts.OriginalRequest), body, kiroResponse, nil)
|
|
resp = cliproxyexecutor.Response{Payload: []byte(out)}
|
|
return resp, nil
|
|
}
|
|
|
|
return resp, fmt.Errorf("kiro: max retries exceeded")
|
|
}
|
|
|
|
// ExecuteStream handles streaming requests to Kiro API.
|
|
// Supports automatic token refresh on 401/403 errors and quota fallback on 429.
|
|
func (e *KiroExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) {
|
|
accessToken, profileArn := kiroCredentials(auth)
|
|
if accessToken == "" {
|
|
return nil, fmt.Errorf("kiro: access token not found in auth")
|
|
}
|
|
if profileArn == "" {
|
|
// Only warn if not using builder-id auth (which doesn't need profileArn)
|
|
if auth == nil || auth.Metadata == nil {
|
|
log.Debugf("kiro: profile ARN not found in auth (may be normal for builder-id)")
|
|
} else if authMethod, ok := auth.Metadata["auth_method"].(string); !ok || authMethod != "builder-id" {
|
|
log.Warnf("kiro: profile ARN not found in auth, API calls may fail")
|
|
}
|
|
}
|
|
|
|
reporter := newUsageReporter(ctx, e.Identifier(), req.Model, auth)
|
|
defer reporter.trackFailure(ctx, &err)
|
|
|
|
// Check if token is expired before making request
|
|
if e.isTokenExpired(accessToken) {
|
|
log.Infof("kiro: access token expired, attempting refresh before stream request")
|
|
refreshedAuth, refreshErr := e.Refresh(ctx, auth)
|
|
if refreshErr != nil {
|
|
log.Warnf("kiro: pre-request token refresh failed: %v", refreshErr)
|
|
} else if refreshedAuth != nil {
|
|
auth = refreshedAuth
|
|
accessToken, profileArn = kiroCredentials(auth)
|
|
log.Infof("kiro: token refreshed successfully before stream request")
|
|
}
|
|
}
|
|
|
|
from := opts.SourceFormat
|
|
to := sdktranslator.FromString("kiro")
|
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
|
|
|
kiroModelID := e.mapModelToKiro(req.Model)
|
|
|
|
// Check if this is an agentic model variant
|
|
isAgentic := strings.HasSuffix(req.Model, "-agentic")
|
|
|
|
// Check if this is a chat-only model variant (no tool calling)
|
|
isChatOnly := strings.HasSuffix(req.Model, "-chat")
|
|
|
|
// Determine initial origin - always use AI_EDITOR to match AIClient-2-API behavior
|
|
// AIClient-2-API uses AI_EDITOR for all models, which is the Kiro IDE quota
|
|
currentOrigin := "AI_EDITOR"
|
|
|
|
// Determine if profileArn should be included based on auth method
|
|
// profileArn is only needed for social auth (Google OAuth), not for builder-id (AWS SSO)
|
|
effectiveProfileArn := profileArn
|
|
if auth != nil && auth.Metadata != nil {
|
|
if authMethod, ok := auth.Metadata["auth_method"].(string); ok && authMethod == "builder-id" {
|
|
effectiveProfileArn = "" // Don't include profileArn for builder-id auth
|
|
}
|
|
}
|
|
|
|
kiroPayload := e.buildKiroPayload(body, kiroModelID, effectiveProfileArn, currentOrigin, isAgentic, isChatOnly)
|
|
|
|
// Execute stream with retry on 401/403 and 429 (quota exhausted)
|
|
return e.executeStreamWithRetry(ctx, auth, req, opts, accessToken, effectiveProfileArn, kiroPayload, body, from, reporter, currentOrigin, kiroModelID, isAgentic, isChatOnly)
|
|
}
|
|
|
|
// executeStreamWithRetry performs the streaming HTTP request with automatic retry on auth errors.
|
|
// Supports automatic fallback from CLI (Amazon Q) quota to AI_EDITOR (Kiro IDE) quota on 429.
|
|
func (e *KiroExecutor) executeStreamWithRetry(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options, accessToken, profileArn string, kiroPayload, body []byte, from sdktranslator.Format, reporter *usageReporter, currentOrigin, kiroModelID string, isAgentic, isChatOnly bool) (<-chan cliproxyexecutor.StreamChunk, error) {
|
|
maxRetries := 2 // Allow retries for token refresh + origin fallback
|
|
|
|
for attempt := 0; attempt <= maxRetries; attempt++ {
|
|
url := kiroEndpoint
|
|
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(kiroPayload))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
httpReq.Header.Set("Content-Type", kiroContentType)
|
|
httpReq.Header.Set("Authorization", "Bearer "+accessToken)
|
|
httpReq.Header.Set("Accept", kiroAcceptStream)
|
|
httpReq.Header.Set("x-amz-user-agent", kiroUserAgent)
|
|
httpReq.Header.Set("User-Agent", kiroFullUserAgent)
|
|
httpReq.Header.Set("amz-sdk-request", "attempt=1; max=1")
|
|
httpReq.Header.Set("x-amzn-kiro-agent-mode", "vibe")
|
|
httpReq.Header.Set("amz-sdk-invocation-id", uuid.New().String())
|
|
|
|
var attrs map[string]string
|
|
if auth != nil {
|
|
attrs = auth.Attributes
|
|
}
|
|
util.ApplyCustomHeadersFromAttrs(httpReq, attrs)
|
|
|
|
var authID, authLabel, authType, authValue string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
authLabel = auth.Label
|
|
authType, authValue = auth.AccountInfo()
|
|
}
|
|
recordAPIRequest(ctx, e.cfg, upstreamRequestLog{
|
|
URL: url,
|
|
Method: http.MethodPost,
|
|
Headers: httpReq.Header.Clone(),
|
|
Body: kiroPayload,
|
|
Provider: e.Identifier(),
|
|
AuthID: authID,
|
|
AuthLabel: authLabel,
|
|
AuthType: authType,
|
|
AuthValue: authValue,
|
|
})
|
|
|
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
|
httpResp, err := httpClient.Do(httpReq)
|
|
if err != nil {
|
|
recordAPIResponseError(ctx, e.cfg, err)
|
|
return nil, err
|
|
}
|
|
recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone())
|
|
|
|
// Handle 429 errors (quota exhausted) with origin fallback
|
|
if httpResp.StatusCode == 429 {
|
|
respBody, _ := io.ReadAll(httpResp.Body)
|
|
_ = httpResp.Body.Close()
|
|
appendAPIResponseChunk(ctx, e.cfg, respBody)
|
|
|
|
// If currently using CLI quota and it's exhausted, switch to AI_EDITOR (Kiro IDE) quota
|
|
if currentOrigin == "CLI" {
|
|
log.Warnf("kiro: stream Amazon Q (CLI) quota exhausted (429), switching to Kiro (AI_EDITOR) fallback")
|
|
currentOrigin = "AI_EDITOR"
|
|
|
|
// Rebuild payload with new origin
|
|
kiroPayload = e.buildKiroPayload(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly)
|
|
|
|
// Retry with new origin
|
|
continue
|
|
}
|
|
|
|
// Already on AI_EDITOR or other origin, return the error
|
|
log.Debugf("kiro stream error, status: %d, body: %s", httpResp.StatusCode, string(respBody))
|
|
return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
|
|
}
|
|
|
|
// Handle 5xx server errors with exponential backoff retry
|
|
if httpResp.StatusCode >= 500 && httpResp.StatusCode < 600 {
|
|
respBody, _ := io.ReadAll(httpResp.Body)
|
|
_ = httpResp.Body.Close()
|
|
appendAPIResponseChunk(ctx, e.cfg, respBody)
|
|
|
|
if attempt < maxRetries {
|
|
// Exponential backoff: 1s, 2s, 4s... (max 30s)
|
|
backoff := time.Duration(1<<attempt) * time.Second
|
|
if backoff > 30*time.Second {
|
|
backoff = 30 * time.Second
|
|
}
|
|
log.Warnf("kiro: stream server error %d, retrying in %v (attempt %d/%d)", httpResp.StatusCode, backoff, attempt+1, maxRetries)
|
|
time.Sleep(backoff)
|
|
continue
|
|
}
|
|
log.Errorf("kiro: stream server error %d after %d retries", httpResp.StatusCode, maxRetries)
|
|
return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
|
|
}
|
|
|
|
// Handle 401/403 errors with token refresh and retry
|
|
if httpResp.StatusCode == 401 || httpResp.StatusCode == 403 {
|
|
respBody, _ := io.ReadAll(httpResp.Body)
|
|
_ = httpResp.Body.Close()
|
|
appendAPIResponseChunk(ctx, e.cfg, respBody)
|
|
|
|
if attempt < maxRetries {
|
|
log.Warnf("kiro: stream received %d error, attempting token refresh and retry (attempt %d/%d)", httpResp.StatusCode, attempt+1, maxRetries+1)
|
|
|
|
refreshedAuth, refreshErr := e.Refresh(ctx, auth)
|
|
if refreshErr != nil {
|
|
log.Errorf("kiro: token refresh failed: %v", refreshErr)
|
|
return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
|
|
}
|
|
|
|
if refreshedAuth != nil {
|
|
auth = refreshedAuth
|
|
accessToken, profileArn = kiroCredentials(auth)
|
|
// Rebuild payload with new profile ARN if changed
|
|
kiroPayload = e.buildKiroPayload(body, kiroModelID, profileArn, currentOrigin, isAgentic, isChatOnly)
|
|
log.Infof("kiro: token refreshed successfully, retrying stream request")
|
|
continue
|
|
}
|
|
}
|
|
|
|
log.Debugf("kiro stream error, status: %d, body: %s", httpResp.StatusCode, string(respBody))
|
|
return nil, statusErr{code: httpResp.StatusCode, msg: string(respBody)}
|
|
}
|
|
|
|
if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 {
|
|
b, _ := io.ReadAll(httpResp.Body)
|
|
appendAPIResponseChunk(ctx, e.cfg, b)
|
|
log.Debugf("kiro stream error, status: %d, body: %s", httpResp.StatusCode, string(b))
|
|
if errClose := httpResp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
return nil, statusErr{code: httpResp.StatusCode, msg: string(b)}
|
|
}
|
|
|
|
out := make(chan cliproxyexecutor.StreamChunk)
|
|
|
|
go func(resp *http.Response) {
|
|
defer close(out)
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
log.Errorf("kiro: panic in stream handler: %v", r)
|
|
out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("internal error: %v", r)}
|
|
}
|
|
}()
|
|
defer func() {
|
|
if errClose := resp.Body.Close(); errClose != nil {
|
|
log.Errorf("response body close error: %v", errClose)
|
|
}
|
|
}()
|
|
|
|
e.streamToChannel(ctx, resp.Body, out, from, req.Model, opts.OriginalRequest, body, reporter)
|
|
}(httpResp)
|
|
|
|
return out, nil
|
|
}
|
|
|
|
return nil, fmt.Errorf("kiro: max retries exceeded for stream")
|
|
}
|
|
|
|
|
|
// kiroCredentials extracts access token and profile ARN from auth.
|
|
func kiroCredentials(auth *cliproxyauth.Auth) (accessToken, profileArn string) {
|
|
if auth == nil {
|
|
return "", ""
|
|
}
|
|
|
|
// Try Metadata first (wrapper format)
|
|
if auth.Metadata != nil {
|
|
if token, ok := auth.Metadata["access_token"].(string); ok {
|
|
accessToken = token
|
|
}
|
|
if arn, ok := auth.Metadata["profile_arn"].(string); ok {
|
|
profileArn = arn
|
|
}
|
|
}
|
|
|
|
// Try Attributes
|
|
if accessToken == "" && auth.Attributes != nil {
|
|
accessToken = auth.Attributes["access_token"]
|
|
profileArn = auth.Attributes["profile_arn"]
|
|
}
|
|
|
|
// Try direct fields from flat JSON format (new AWS Builder ID format)
|
|
if accessToken == "" && auth.Metadata != nil {
|
|
if token, ok := auth.Metadata["accessToken"].(string); ok {
|
|
accessToken = token
|
|
}
|
|
if arn, ok := auth.Metadata["profileArn"].(string); ok {
|
|
profileArn = arn
|
|
}
|
|
}
|
|
|
|
return accessToken, profileArn
|
|
}
|
|
|
|
// mapModelToKiro maps external model names to Kiro model IDs.
|
|
// Supports both Kiro and Amazon Q prefixes since they use the same API.
|
|
// Agentic variants (-agentic suffix) map to the same backend model IDs.
|
|
func (e *KiroExecutor) mapModelToKiro(model string) string {
|
|
modelMap := map[string]string{
|
|
// Amazon Q format (amazonq- prefix) - same API as Kiro
|
|
"amazonq-auto": "auto",
|
|
"amazonq-claude-opus-4-5": "claude-opus-4.5",
|
|
"amazonq-claude-sonnet-4-5": "claude-sonnet-4.5",
|
|
"amazonq-claude-sonnet-4-5-20250929": "claude-sonnet-4.5",
|
|
"amazonq-claude-sonnet-4": "claude-sonnet-4",
|
|
"amazonq-claude-sonnet-4-20250514": "claude-sonnet-4",
|
|
"amazonq-claude-haiku-4-5": "claude-haiku-4.5",
|
|
// Kiro format (kiro- prefix) - valid model names that should be preserved
|
|
"kiro-claude-opus-4-5": "claude-opus-4.5",
|
|
"kiro-claude-sonnet-4-5": "claude-sonnet-4.5",
|
|
"kiro-claude-sonnet-4-5-20250929": "claude-sonnet-4.5",
|
|
"kiro-claude-sonnet-4": "claude-sonnet-4",
|
|
"kiro-claude-sonnet-4-20250514": "claude-sonnet-4",
|
|
"kiro-claude-haiku-4-5": "claude-haiku-4.5",
|
|
"kiro-auto": "auto",
|
|
// Native format (no prefix) - used by Kiro IDE directly
|
|
"claude-opus-4-5": "claude-opus-4.5",
|
|
"claude-opus-4.5": "claude-opus-4.5",
|
|
"claude-haiku-4-5": "claude-haiku-4.5",
|
|
"claude-haiku-4.5": "claude-haiku-4.5",
|
|
"claude-sonnet-4-5": "claude-sonnet-4.5",
|
|
"claude-sonnet-4-5-20250929": "claude-sonnet-4.5",
|
|
"claude-sonnet-4.5": "claude-sonnet-4.5",
|
|
"claude-sonnet-4": "claude-sonnet-4",
|
|
"claude-sonnet-4-20250514": "claude-sonnet-4",
|
|
"auto": "auto",
|
|
// Agentic variants (same backend model IDs, but with special system prompt)
|
|
"claude-opus-4.5-agentic": "claude-opus-4.5",
|
|
"claude-sonnet-4.5-agentic": "claude-sonnet-4.5",
|
|
"claude-sonnet-4-agentic": "claude-sonnet-4",
|
|
"claude-haiku-4.5-agentic": "claude-haiku-4.5",
|
|
"kiro-claude-opus-4-5-agentic": "claude-opus-4.5",
|
|
"kiro-claude-sonnet-4-5-agentic": "claude-sonnet-4.5",
|
|
"kiro-claude-sonnet-4-agentic": "claude-sonnet-4",
|
|
"kiro-claude-haiku-4-5-agentic": "claude-haiku-4.5",
|
|
}
|
|
if kiroID, ok := modelMap[model]; ok {
|
|
return kiroID
|
|
}
|
|
|
|
// Smart fallback: try to infer model type from name patterns
|
|
modelLower := strings.ToLower(model)
|
|
|
|
// Check for Haiku variants
|
|
if strings.Contains(modelLower, "haiku") {
|
|
log.Debugf("kiro: unknown Haiku model '%s', mapping to claude-haiku-4.5", model)
|
|
return "claude-haiku-4.5"
|
|
}
|
|
|
|
// Check for Sonnet variants
|
|
if strings.Contains(modelLower, "sonnet") {
|
|
// Check for specific version patterns
|
|
if strings.Contains(modelLower, "3-7") || strings.Contains(modelLower, "3.7") {
|
|
log.Debugf("kiro: unknown Sonnet 3.7 model '%s', mapping to claude-3-7-sonnet-20250219", model)
|
|
return "claude-3-7-sonnet-20250219"
|
|
}
|
|
if strings.Contains(modelLower, "4-5") || strings.Contains(modelLower, "4.5") {
|
|
log.Debugf("kiro: unknown Sonnet 4.5 model '%s', mapping to claude-sonnet-4.5", model)
|
|
return "claude-sonnet-4.5"
|
|
}
|
|
// Default to Sonnet 4
|
|
log.Debugf("kiro: unknown Sonnet model '%s', mapping to claude-sonnet-4", model)
|
|
return "claude-sonnet-4"
|
|
}
|
|
|
|
// Check for Opus variants
|
|
if strings.Contains(modelLower, "opus") {
|
|
log.Debugf("kiro: unknown Opus model '%s', mapping to claude-opus-4.5", model)
|
|
return "claude-opus-4.5"
|
|
}
|
|
|
|
// Final fallback to Sonnet 4.5 (most commonly used model)
|
|
log.Warnf("kiro: unknown model '%s', falling back to claude-sonnet-4.5", model)
|
|
return "claude-sonnet-4.5"
|
|
}
|
|
|
|
// Kiro API request structs - field order determines JSON key order
|
|
|
|
type kiroPayload struct {
|
|
ConversationState kiroConversationState `json:"conversationState"`
|
|
ProfileArn string `json:"profileArn,omitempty"`
|
|
}
|
|
|
|
type kiroConversationState struct {
|
|
ChatTriggerType string `json:"chatTriggerType"` // Required: "MANUAL" - must be first field
|
|
ConversationID string `json:"conversationId"`
|
|
CurrentMessage kiroCurrentMessage `json:"currentMessage"`
|
|
History []kiroHistoryMessage `json:"history,omitempty"`
|
|
}
|
|
|
|
type kiroCurrentMessage struct {
|
|
UserInputMessage kiroUserInputMessage `json:"userInputMessage"`
|
|
}
|
|
|
|
type kiroHistoryMessage struct {
|
|
UserInputMessage *kiroUserInputMessage `json:"userInputMessage,omitempty"`
|
|
AssistantResponseMessage *kiroAssistantResponseMessage `json:"assistantResponseMessage,omitempty"`
|
|
}
|
|
|
|
// kiroImage represents an image in Kiro API format
|
|
type kiroImage struct {
|
|
Format string `json:"format"`
|
|
Source kiroImageSource `json:"source"`
|
|
}
|
|
|
|
// kiroImageSource contains the image data
|
|
type kiroImageSource struct {
|
|
Bytes string `json:"bytes"` // base64 encoded image data
|
|
}
|
|
|
|
type kiroUserInputMessage struct {
|
|
Content string `json:"content"`
|
|
ModelID string `json:"modelId"`
|
|
Origin string `json:"origin"`
|
|
Images []kiroImage `json:"images,omitempty"`
|
|
UserInputMessageContext *kiroUserInputMessageContext `json:"userInputMessageContext,omitempty"`
|
|
}
|
|
|
|
type kiroUserInputMessageContext struct {
|
|
ToolResults []kiroToolResult `json:"toolResults,omitempty"`
|
|
Tools []kiroToolWrapper `json:"tools,omitempty"`
|
|
}
|
|
|
|
type kiroToolResult struct {
|
|
Content []kiroTextContent `json:"content"`
|
|
Status string `json:"status"`
|
|
ToolUseID string `json:"toolUseId"`
|
|
}
|
|
|
|
type kiroTextContent struct {
|
|
Text string `json:"text"`
|
|
}
|
|
|
|
type kiroToolWrapper struct {
|
|
ToolSpecification kiroToolSpecification `json:"toolSpecification"`
|
|
}
|
|
|
|
type kiroToolSpecification struct {
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
InputSchema kiroInputSchema `json:"inputSchema"`
|
|
}
|
|
|
|
type kiroInputSchema struct {
|
|
JSON interface{} `json:"json"`
|
|
}
|
|
|
|
type kiroAssistantResponseMessage struct {
|
|
Content string `json:"content"`
|
|
ToolUses []kiroToolUse `json:"toolUses,omitempty"`
|
|
}
|
|
|
|
type kiroToolUse struct {
|
|
ToolUseID string `json:"toolUseId"`
|
|
Name string `json:"name"`
|
|
Input map[string]interface{} `json:"input"`
|
|
}
|
|
|
|
// buildKiroPayload constructs the Kiro API request payload.
|
|
// Supports tool calling - tools are passed via userInputMessageContext.
|
|
// origin parameter determines which quota to use: "CLI" for Amazon Q, "AI_EDITOR" for Kiro IDE.
|
|
// isAgentic parameter enables chunked write optimization prompt for -agentic model variants.
|
|
// isChatOnly parameter disables tool calling for -chat model variants (pure conversation mode).
|
|
func (e *KiroExecutor) buildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isAgentic, isChatOnly bool) []byte {
|
|
// Normalize origin value for Kiro API compatibility
|
|
// Kiro API only accepts "CLI" or "AI_EDITOR" as valid origin values
|
|
switch origin {
|
|
case "KIRO_CLI":
|
|
origin = "CLI"
|
|
case "KIRO_AI_EDITOR":
|
|
origin = "AI_EDITOR"
|
|
case "AMAZON_Q":
|
|
origin = "CLI"
|
|
case "KIRO_IDE":
|
|
origin = "AI_EDITOR"
|
|
// Add any other non-standard origin values that need normalization
|
|
default:
|
|
// Keep the original value if it's already standard
|
|
// Valid values: "CLI", "AI_EDITOR"
|
|
}
|
|
log.Debugf("kiro: normalized origin value: %s", origin)
|
|
|
|
messages := gjson.GetBytes(claudeBody, "messages")
|
|
|
|
// For chat-only mode, don't include tools
|
|
var tools gjson.Result
|
|
if !isChatOnly {
|
|
tools = gjson.GetBytes(claudeBody, "tools")
|
|
}
|
|
|
|
// Extract system prompt - can be string or array of content blocks
|
|
systemField := gjson.GetBytes(claudeBody, "system")
|
|
var systemPrompt string
|
|
if systemField.IsArray() {
|
|
// System is array of content blocks, extract text
|
|
var sb strings.Builder
|
|
for _, block := range systemField.Array() {
|
|
if block.Get("type").String() == "text" {
|
|
sb.WriteString(block.Get("text").String())
|
|
} else if block.Type == gjson.String {
|
|
sb.WriteString(block.String())
|
|
}
|
|
}
|
|
systemPrompt = sb.String()
|
|
} else {
|
|
systemPrompt = systemField.String()
|
|
}
|
|
|
|
// Inject agentic optimization prompt for -agentic model variants
|
|
// This prevents AWS Kiro API timeouts during large file write operations
|
|
if isAgentic {
|
|
if systemPrompt != "" {
|
|
systemPrompt += "\n"
|
|
}
|
|
systemPrompt += kiroAgenticSystemPrompt
|
|
}
|
|
|
|
// Convert Claude tools to Kiro format
|
|
var kiroTools []kiroToolWrapper
|
|
if tools.IsArray() {
|
|
for _, tool := range tools.Array() {
|
|
name := tool.Get("name").String()
|
|
description := tool.Get("description").String()
|
|
inputSchema := tool.Get("input_schema").Value()
|
|
|
|
// Truncate long descriptions (Kiro API limit is in bytes)
|
|
// Truncate at valid UTF-8 boundary to avoid breaking multi-byte chars
|
|
if len(description) > kiroMaxToolDescLen {
|
|
// Find a valid UTF-8 boundary before the limit
|
|
truncLen := kiroMaxToolDescLen
|
|
for truncLen > 0 && !utf8.RuneStart(description[truncLen]) {
|
|
truncLen--
|
|
}
|
|
description = description[:truncLen] + "..."
|
|
}
|
|
|
|
kiroTools = append(kiroTools, kiroToolWrapper{
|
|
ToolSpecification: kiroToolSpecification{
|
|
Name: name,
|
|
Description: description,
|
|
InputSchema: kiroInputSchema{JSON: inputSchema},
|
|
},
|
|
})
|
|
}
|
|
}
|
|
|
|
var history []kiroHistoryMessage
|
|
var currentUserMsg *kiroUserInputMessage
|
|
var currentToolResults []kiroToolResult
|
|
|
|
// Merge adjacent messages with the same role before processing
|
|
// This reduces API call complexity and improves compatibility
|
|
messagesArray := mergeAdjacentMessages(messages.Array())
|
|
for i, msg := range messagesArray {
|
|
role := msg.Get("role").String()
|
|
isLastMessage := i == len(messagesArray)-1
|
|
|
|
if role == "user" {
|
|
userMsg, toolResults := e.buildUserMessageStruct(msg, modelID, origin)
|
|
if isLastMessage {
|
|
currentUserMsg = &userMsg
|
|
currentToolResults = toolResults
|
|
} else {
|
|
// CRITICAL: Kiro API requires content to be non-empty for history messages too
|
|
if strings.TrimSpace(userMsg.Content) == "" {
|
|
if len(toolResults) > 0 {
|
|
userMsg.Content = "Tool results provided."
|
|
} else {
|
|
userMsg.Content = "Continue"
|
|
}
|
|
}
|
|
// For history messages, embed tool results in context
|
|
if len(toolResults) > 0 {
|
|
userMsg.UserInputMessageContext = &kiroUserInputMessageContext{
|
|
ToolResults: toolResults,
|
|
}
|
|
}
|
|
history = append(history, kiroHistoryMessage{
|
|
UserInputMessage: &userMsg,
|
|
})
|
|
}
|
|
} else if role == "assistant" {
|
|
assistantMsg := e.buildAssistantMessageStruct(msg)
|
|
// If this is the last message and it's an assistant message,
|
|
// we need to add it to history and create a "Continue" user message
|
|
// because Kiro API requires currentMessage to be userInputMessage type
|
|
if isLastMessage {
|
|
history = append(history, kiroHistoryMessage{
|
|
AssistantResponseMessage: &assistantMsg,
|
|
})
|
|
// Create a "Continue" user message as currentMessage
|
|
currentUserMsg = &kiroUserInputMessage{
|
|
Content: "Continue",
|
|
ModelID: modelID,
|
|
Origin: origin,
|
|
}
|
|
} else {
|
|
history = append(history, kiroHistoryMessage{
|
|
AssistantResponseMessage: &assistantMsg,
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build content with system prompt
|
|
if currentUserMsg != nil {
|
|
var contentBuilder strings.Builder
|
|
|
|
// Add system prompt if present
|
|
if systemPrompt != "" {
|
|
contentBuilder.WriteString("--- SYSTEM PROMPT ---\n")
|
|
contentBuilder.WriteString(systemPrompt)
|
|
contentBuilder.WriteString("\n--- END SYSTEM PROMPT ---\n\n")
|
|
}
|
|
|
|
// Add the actual user message
|
|
contentBuilder.WriteString(currentUserMsg.Content)
|
|
finalContent := contentBuilder.String()
|
|
|
|
// CRITICAL: Kiro API requires content to be non-empty, even when toolResults are present
|
|
// If content is empty or only whitespace, provide a default message
|
|
if strings.TrimSpace(finalContent) == "" {
|
|
if len(currentToolResults) > 0 {
|
|
finalContent = "Tool results provided."
|
|
} else {
|
|
finalContent = "Continue"
|
|
}
|
|
log.Debugf("kiro: content was empty, using default: %s", finalContent)
|
|
}
|
|
currentUserMsg.Content = finalContent
|
|
|
|
// Deduplicate currentToolResults before adding to context
|
|
// Kiro API does not accept duplicate toolUseIds
|
|
if len(currentToolResults) > 0 {
|
|
seenIDs := make(map[string]bool)
|
|
uniqueToolResults := make([]kiroToolResult, 0, len(currentToolResults))
|
|
for _, tr := range currentToolResults {
|
|
if !seenIDs[tr.ToolUseID] {
|
|
seenIDs[tr.ToolUseID] = true
|
|
uniqueToolResults = append(uniqueToolResults, tr)
|
|
} else {
|
|
log.Debugf("kiro: skipping duplicate toolResult in currentMessage: %s", tr.ToolUseID)
|
|
}
|
|
}
|
|
currentToolResults = uniqueToolResults
|
|
}
|
|
|
|
// Build userInputMessageContext with tools and tool results
|
|
if len(kiroTools) > 0 || len(currentToolResults) > 0 {
|
|
currentUserMsg.UserInputMessageContext = &kiroUserInputMessageContext{
|
|
Tools: kiroTools,
|
|
ToolResults: currentToolResults,
|
|
}
|
|
}
|
|
}
|
|
|
|
// Build payload using structs (preserves key order)
|
|
var currentMessage kiroCurrentMessage
|
|
if currentUserMsg != nil {
|
|
currentMessage = kiroCurrentMessage{UserInputMessage: *currentUserMsg}
|
|
} else {
|
|
// Fallback when no user messages - still include system prompt if present
|
|
fallbackContent := ""
|
|
if systemPrompt != "" {
|
|
fallbackContent = "--- SYSTEM PROMPT ---\n" + systemPrompt + "\n--- END SYSTEM PROMPT ---\n"
|
|
}
|
|
currentMessage = kiroCurrentMessage{UserInputMessage: kiroUserInputMessage{
|
|
Content: fallbackContent,
|
|
ModelID: modelID,
|
|
Origin: origin,
|
|
}}
|
|
}
|
|
|
|
// Build payload with correct field order (matches struct definition)
|
|
payload := kiroPayload{
|
|
ConversationState: kiroConversationState{
|
|
ChatTriggerType: "MANUAL", // Required by Kiro API - must be first
|
|
ConversationID: uuid.New().String(),
|
|
CurrentMessage: currentMessage,
|
|
History: history, // Now always included (non-nil slice)
|
|
},
|
|
ProfileArn: profileArn,
|
|
}
|
|
|
|
result, err := json.Marshal(payload)
|
|
if err != nil {
|
|
log.Debugf("kiro: failed to marshal payload: %v", err)
|
|
return nil
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
// buildUserMessageStruct builds a user message and extracts tool results
|
|
// origin parameter determines which quota to use: "CLI" for Amazon Q, "AI_EDITOR" for Kiro IDE.
|
|
// IMPORTANT: Kiro API does not accept duplicate toolUseIds, so we deduplicate here.
|
|
func (e *KiroExecutor) buildUserMessageStruct(msg gjson.Result, modelID, origin string) (kiroUserInputMessage, []kiroToolResult) {
|
|
content := msg.Get("content")
|
|
var contentBuilder strings.Builder
|
|
var toolResults []kiroToolResult
|
|
var images []kiroImage
|
|
|
|
// Track seen toolUseIds to deduplicate - Kiro API rejects duplicate toolUseIds
|
|
seenToolUseIDs := make(map[string]bool)
|
|
|
|
if content.IsArray() {
|
|
for _, part := range content.Array() {
|
|
partType := part.Get("type").String()
|
|
switch partType {
|
|
case "text":
|
|
contentBuilder.WriteString(part.Get("text").String())
|
|
case "image":
|
|
// Extract image data from Claude API format
|
|
mediaType := part.Get("source.media_type").String()
|
|
data := part.Get("source.data").String()
|
|
|
|
// Extract format from media_type (e.g., "image/png" -> "png")
|
|
format := ""
|
|
if idx := strings.LastIndex(mediaType, "/"); idx != -1 {
|
|
format = mediaType[idx+1:]
|
|
}
|
|
|
|
if format != "" && data != "" {
|
|
images = append(images, kiroImage{
|
|
Format: format,
|
|
Source: kiroImageSource{
|
|
Bytes: data,
|
|
},
|
|
})
|
|
}
|
|
case "tool_result":
|
|
// Extract tool result for API
|
|
toolUseID := part.Get("tool_use_id").String()
|
|
|
|
// Skip duplicate toolUseIds - Kiro API does not accept duplicates
|
|
if seenToolUseIDs[toolUseID] {
|
|
log.Debugf("kiro: skipping duplicate tool_result with toolUseId: %s", toolUseID)
|
|
continue
|
|
}
|
|
seenToolUseIDs[toolUseID] = true
|
|
|
|
isError := part.Get("is_error").Bool()
|
|
resultContent := part.Get("content")
|
|
|
|
// Convert content to Kiro format: [{text: "..."}]
|
|
var textContents []kiroTextContent
|
|
if resultContent.IsArray() {
|
|
for _, item := range resultContent.Array() {
|
|
if item.Get("type").String() == "text" {
|
|
textContents = append(textContents, kiroTextContent{Text: item.Get("text").String()})
|
|
} else if item.Type == gjson.String {
|
|
textContents = append(textContents, kiroTextContent{Text: item.String()})
|
|
}
|
|
}
|
|
} else if resultContent.Type == gjson.String {
|
|
textContents = append(textContents, kiroTextContent{Text: resultContent.String()})
|
|
}
|
|
|
|
// If no content, add default message
|
|
if len(textContents) == 0 {
|
|
textContents = append(textContents, kiroTextContent{Text: "Tool use was cancelled by the user"})
|
|
}
|
|
|
|
status := "success"
|
|
if isError {
|
|
status = "error"
|
|
}
|
|
|
|
toolResults = append(toolResults, kiroToolResult{
|
|
ToolUseID: toolUseID,
|
|
Content: textContents,
|
|
Status: status,
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
contentBuilder.WriteString(content.String())
|
|
}
|
|
|
|
userMsg := kiroUserInputMessage{
|
|
Content: contentBuilder.String(),
|
|
ModelID: modelID,
|
|
Origin: origin,
|
|
}
|
|
|
|
// Add images to message if present
|
|
if len(images) > 0 {
|
|
userMsg.Images = images
|
|
}
|
|
|
|
return userMsg, toolResults
|
|
}
|
|
|
|
// buildAssistantMessageStruct builds an assistant message with tool uses
|
|
func (e *KiroExecutor) buildAssistantMessageStruct(msg gjson.Result) kiroAssistantResponseMessage {
|
|
content := msg.Get("content")
|
|
var contentBuilder strings.Builder
|
|
var toolUses []kiroToolUse
|
|
|
|
if content.IsArray() {
|
|
for _, part := range content.Array() {
|
|
partType := part.Get("type").String()
|
|
switch partType {
|
|
case "text":
|
|
contentBuilder.WriteString(part.Get("text").String())
|
|
case "tool_use":
|
|
// Extract tool use for API
|
|
toolUseID := part.Get("id").String()
|
|
toolName := part.Get("name").String()
|
|
toolInput := part.Get("input")
|
|
|
|
// Convert input to map
|
|
var inputMap map[string]interface{}
|
|
if toolInput.IsObject() {
|
|
inputMap = make(map[string]interface{})
|
|
toolInput.ForEach(func(key, value gjson.Result) bool {
|
|
inputMap[key.String()] = value.Value()
|
|
return true
|
|
})
|
|
}
|
|
|
|
toolUses = append(toolUses, kiroToolUse{
|
|
ToolUseID: toolUseID,
|
|
Name: toolName,
|
|
Input: inputMap,
|
|
})
|
|
}
|
|
}
|
|
} else {
|
|
contentBuilder.WriteString(content.String())
|
|
}
|
|
|
|
return kiroAssistantResponseMessage{
|
|
Content: contentBuilder.String(),
|
|
ToolUses: toolUses,
|
|
}
|
|
}
|
|
|
|
// NOTE: Tool calling is now supported via userInputMessageContext.tools and toolResults
|
|
|
|
// parseEventStream parses AWS Event Stream binary format.
|
|
// Extracts text content and tool uses from the response.
|
|
// Supports embedded [Called ...] tool calls and input buffering for toolUseEvent.
|
|
func (e *KiroExecutor) parseEventStream(body io.Reader) (string, []kiroToolUse, usage.Detail, error) {
|
|
var content strings.Builder
|
|
var toolUses []kiroToolUse
|
|
var usageInfo usage.Detail
|
|
reader := bufio.NewReader(body)
|
|
|
|
// Tool use state tracking for input buffering and deduplication
|
|
processedIDs := make(map[string]bool)
|
|
var currentToolUse *toolUseState
|
|
|
|
for {
|
|
prelude := make([]byte, 8)
|
|
_, err := io.ReadFull(reader, prelude)
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return content.String(), toolUses, usageInfo, fmt.Errorf("failed to read prelude: %w", err)
|
|
}
|
|
|
|
totalLen := binary.BigEndian.Uint32(prelude[0:4])
|
|
if totalLen < 8 {
|
|
return content.String(), toolUses, usageInfo, fmt.Errorf("invalid message length: %d", totalLen)
|
|
}
|
|
if totalLen > kiroMaxMessageSize {
|
|
return content.String(), toolUses, usageInfo, fmt.Errorf("message too large: %d bytes", totalLen)
|
|
}
|
|
headersLen := binary.BigEndian.Uint32(prelude[4:8])
|
|
|
|
remaining := make([]byte, totalLen-8)
|
|
_, err = io.ReadFull(reader, remaining)
|
|
if err != nil {
|
|
return content.String(), toolUses, usageInfo, fmt.Errorf("failed to read message: %w", err)
|
|
}
|
|
|
|
// Validate headersLen to prevent slice out of bounds
|
|
if headersLen+4 > uint32(len(remaining)) {
|
|
log.Warnf("kiro: invalid headersLen %d exceeds remaining buffer %d", headersLen, len(remaining))
|
|
continue
|
|
}
|
|
|
|
// Extract event type from headers
|
|
eventType := e.extractEventType(remaining[:headersLen+4])
|
|
|
|
payloadStart := 4 + headersLen
|
|
payloadEnd := uint32(len(remaining)) - 4
|
|
if payloadStart >= payloadEnd {
|
|
continue
|
|
}
|
|
|
|
payload := remaining[payloadStart:payloadEnd]
|
|
|
|
var event map[string]interface{}
|
|
if err := json.Unmarshal(payload, &event); err != nil {
|
|
log.Debugf("kiro: skipping malformed event: %v", err)
|
|
continue
|
|
}
|
|
|
|
// DIAGNOSTIC: Log all received event types for debugging
|
|
log.Debugf("kiro: parseEventStream received event type: %s", eventType)
|
|
if log.IsLevelEnabled(log.TraceLevel) {
|
|
log.Tracef("kiro: parseEventStream event payload: %s", string(payload))
|
|
}
|
|
|
|
// Check for error/exception events in the payload (Kiro API may return errors with HTTP 200)
|
|
// These can appear as top-level fields or nested within the event
|
|
if errType, hasErrType := event["_type"].(string); hasErrType {
|
|
// AWS-style error: {"_type": "com.amazon.aws.codewhisperer#ValidationException", "message": "..."}
|
|
errMsg := ""
|
|
if msg, ok := event["message"].(string); ok {
|
|
errMsg = msg
|
|
}
|
|
log.Errorf("kiro: received AWS error in event stream: type=%s, message=%s", errType, errMsg)
|
|
return "", nil, usageInfo, fmt.Errorf("kiro API error: %s - %s", errType, errMsg)
|
|
}
|
|
if errType, hasErrType := event["type"].(string); hasErrType && (errType == "error" || errType == "exception") {
|
|
// Generic error event
|
|
errMsg := ""
|
|
if msg, ok := event["message"].(string); ok {
|
|
errMsg = msg
|
|
} else if errObj, ok := event["error"].(map[string]interface{}); ok {
|
|
if msg, ok := errObj["message"].(string); ok {
|
|
errMsg = msg
|
|
}
|
|
}
|
|
log.Errorf("kiro: received error event in stream: type=%s, message=%s", errType, errMsg)
|
|
return "", nil, usageInfo, fmt.Errorf("kiro API error: %s", errMsg)
|
|
}
|
|
|
|
// Handle different event types
|
|
switch eventType {
|
|
case "followupPromptEvent":
|
|
// Filter out followupPrompt events - these are UI suggestions, not content
|
|
log.Debugf("kiro: parseEventStream ignoring followupPrompt event")
|
|
continue
|
|
|
|
case "assistantResponseEvent":
|
|
if assistantResp, ok := event["assistantResponseEvent"].(map[string]interface{}); ok {
|
|
if contentText, ok := assistantResp["content"].(string); ok {
|
|
content.WriteString(contentText)
|
|
}
|
|
// Extract tool uses from response
|
|
if toolUsesRaw, ok := assistantResp["toolUses"].([]interface{}); ok {
|
|
for _, tuRaw := range toolUsesRaw {
|
|
if tu, ok := tuRaw.(map[string]interface{}); ok {
|
|
toolUseID := getString(tu, "toolUseId")
|
|
// Check for duplicate
|
|
if processedIDs[toolUseID] {
|
|
log.Debugf("kiro: skipping duplicate tool use from assistantResponse: %s", toolUseID)
|
|
continue
|
|
}
|
|
processedIDs[toolUseID] = true
|
|
|
|
toolUse := kiroToolUse{
|
|
ToolUseID: toolUseID,
|
|
Name: getString(tu, "name"),
|
|
}
|
|
if input, ok := tu["input"].(map[string]interface{}); ok {
|
|
toolUse.Input = input
|
|
}
|
|
toolUses = append(toolUses, toolUse)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
// Also try direct format
|
|
if contentText, ok := event["content"].(string); ok {
|
|
content.WriteString(contentText)
|
|
}
|
|
// Direct tool uses
|
|
if toolUsesRaw, ok := event["toolUses"].([]interface{}); ok {
|
|
for _, tuRaw := range toolUsesRaw {
|
|
if tu, ok := tuRaw.(map[string]interface{}); ok {
|
|
toolUseID := getString(tu, "toolUseId")
|
|
// Check for duplicate
|
|
if processedIDs[toolUseID] {
|
|
log.Debugf("kiro: skipping duplicate direct tool use: %s", toolUseID)
|
|
continue
|
|
}
|
|
processedIDs[toolUseID] = true
|
|
|
|
toolUse := kiroToolUse{
|
|
ToolUseID: toolUseID,
|
|
Name: getString(tu, "name"),
|
|
}
|
|
if input, ok := tu["input"].(map[string]interface{}); ok {
|
|
toolUse.Input = input
|
|
}
|
|
toolUses = append(toolUses, toolUse)
|
|
}
|
|
}
|
|
}
|
|
|
|
case "toolUseEvent":
|
|
// Handle dedicated tool use events with input buffering
|
|
completedToolUses, newState := e.processToolUseEvent(event, currentToolUse, processedIDs)
|
|
currentToolUse = newState
|
|
toolUses = append(toolUses, completedToolUses...)
|
|
|
|
case "supplementaryWebLinksEvent":
|
|
if inputTokens, ok := event["inputTokens"].(float64); ok {
|
|
usageInfo.InputTokens = int64(inputTokens)
|
|
}
|
|
if outputTokens, ok := event["outputTokens"].(float64); ok {
|
|
usageInfo.OutputTokens = int64(outputTokens)
|
|
}
|
|
}
|
|
|
|
// Also check nested supplementaryWebLinksEvent
|
|
if usageEvent, ok := event["supplementaryWebLinksEvent"].(map[string]interface{}); ok {
|
|
if inputTokens, ok := usageEvent["inputTokens"].(float64); ok {
|
|
usageInfo.InputTokens = int64(inputTokens)
|
|
}
|
|
if outputTokens, ok := usageEvent["outputTokens"].(float64); ok {
|
|
usageInfo.OutputTokens = int64(outputTokens)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Parse embedded tool calls from content (e.g., [Called tool_name with args: {...}])
|
|
contentStr := content.String()
|
|
cleanedContent, embeddedToolUses := e.parseEmbeddedToolCalls(contentStr, processedIDs)
|
|
toolUses = append(toolUses, embeddedToolUses...)
|
|
|
|
// Deduplicate all tool uses
|
|
toolUses = deduplicateToolUses(toolUses)
|
|
|
|
return cleanedContent, toolUses, usageInfo, nil
|
|
}
|
|
|
|
// extractEventType extracts the event type from AWS Event Stream headers
|
|
func (e *KiroExecutor) extractEventType(headerBytes []byte) string {
|
|
// Skip prelude CRC (4 bytes)
|
|
if len(headerBytes) < 4 {
|
|
return ""
|
|
}
|
|
headers := headerBytes[4:]
|
|
|
|
offset := 0
|
|
for offset < len(headers) {
|
|
if offset >= len(headers) {
|
|
break
|
|
}
|
|
nameLen := int(headers[offset])
|
|
offset++
|
|
if offset+nameLen > len(headers) {
|
|
break
|
|
}
|
|
name := string(headers[offset : offset+nameLen])
|
|
offset += nameLen
|
|
|
|
if offset >= len(headers) {
|
|
break
|
|
}
|
|
valueType := headers[offset]
|
|
offset++
|
|
|
|
if valueType == 7 { // String type
|
|
if offset+2 > len(headers) {
|
|
break
|
|
}
|
|
valueLen := int(binary.BigEndian.Uint16(headers[offset : offset+2]))
|
|
offset += 2
|
|
if offset+valueLen > len(headers) {
|
|
break
|
|
}
|
|
value := string(headers[offset : offset+valueLen])
|
|
offset += valueLen
|
|
|
|
if name == ":event-type" {
|
|
return value
|
|
}
|
|
} else {
|
|
// Skip other types
|
|
break
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// getString safely extracts a string from a map
|
|
func getString(m map[string]interface{}, key string) string {
|
|
if v, ok := m[key].(string); ok {
|
|
return v
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// buildClaudeResponse constructs a Claude-compatible response.
|
|
// Supports tool_use blocks when tools are present in the response.
|
|
func (e *KiroExecutor) buildClaudeResponse(content string, toolUses []kiroToolUse, model string, usageInfo usage.Detail) []byte {
|
|
var contentBlocks []map[string]interface{}
|
|
|
|
// Add text content if present
|
|
if content != "" {
|
|
contentBlocks = append(contentBlocks, map[string]interface{}{
|
|
"type": "text",
|
|
"text": content,
|
|
})
|
|
}
|
|
|
|
// Add tool_use blocks
|
|
for _, toolUse := range toolUses {
|
|
contentBlocks = append(contentBlocks, map[string]interface{}{
|
|
"type": "tool_use",
|
|
"id": toolUse.ToolUseID,
|
|
"name": toolUse.Name,
|
|
"input": toolUse.Input,
|
|
})
|
|
}
|
|
|
|
// Ensure at least one content block (Claude API requires non-empty content)
|
|
if len(contentBlocks) == 0 {
|
|
contentBlocks = append(contentBlocks, map[string]interface{}{
|
|
"type": "text",
|
|
"text": "",
|
|
})
|
|
}
|
|
|
|
// Determine stop reason
|
|
stopReason := "end_turn"
|
|
if len(toolUses) > 0 {
|
|
stopReason = "tool_use"
|
|
}
|
|
|
|
response := map[string]interface{}{
|
|
"id": "msg_" + uuid.New().String()[:24],
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"model": model,
|
|
"content": contentBlocks,
|
|
"stop_reason": stopReason,
|
|
"usage": map[string]interface{}{
|
|
"input_tokens": usageInfo.InputTokens,
|
|
"output_tokens": usageInfo.OutputTokens,
|
|
},
|
|
}
|
|
result, _ := json.Marshal(response)
|
|
return result
|
|
}
|
|
|
|
// NOTE: Tool uses are now extracted from API response, not parsed from text
|
|
|
|
|
|
// streamToChannel converts AWS Event Stream to channel-based streaming.
|
|
// Supports tool calling - emits tool_use content blocks when tools are used.
|
|
// Includes embedded [Called ...] tool call parsing and input buffering for toolUseEvent.
|
|
// Implements duplicate content filtering using lastContentEvent detection (based on AIClient-2-API).
|
|
func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out chan<- cliproxyexecutor.StreamChunk, targetFormat sdktranslator.Format, model string, originalReq, claudeBody []byte, reporter *usageReporter) {
|
|
reader := bufio.NewReaderSize(body, 20*1024*1024) // 20MB buffer to match other providers
|
|
var totalUsage usage.Detail
|
|
var hasToolUses bool // Track if any tool uses were emitted
|
|
|
|
// Tool use state tracking for input buffering and deduplication
|
|
processedIDs := make(map[string]bool)
|
|
var currentToolUse *toolUseState
|
|
|
|
// Duplicate content detection - tracks last content event to filter duplicates
|
|
// Based on AIClient-2-API implementation for Kiro
|
|
var lastContentEvent string
|
|
|
|
// Streaming token calculation - accumulate content for real-time token counting
|
|
// Based on AIClient-2-API implementation
|
|
var accumulatedContent strings.Builder
|
|
accumulatedContent.Grow(4096) // Pre-allocate 4KB capacity to reduce reallocations
|
|
|
|
// Translator param for maintaining tool call state across streaming events
|
|
// IMPORTANT: This must persist across all TranslateStream calls
|
|
var translatorParam any
|
|
|
|
// Pre-calculate input tokens from request if possible
|
|
if enc, err := tokenizerForModel(model); err == nil {
|
|
// Try OpenAI format first, then fall back to raw byte count estimation
|
|
if inp, err := countOpenAIChatTokens(enc, originalReq); err == nil && inp > 0 {
|
|
totalUsage.InputTokens = inp
|
|
} else {
|
|
// Fallback: estimate from raw request size (roughly 4 chars per token)
|
|
totalUsage.InputTokens = int64(len(originalReq) / 4)
|
|
if totalUsage.InputTokens == 0 && len(originalReq) > 0 {
|
|
totalUsage.InputTokens = 1
|
|
}
|
|
}
|
|
log.Debugf("kiro: streamToChannel pre-calculated input tokens: %d (request size: %d bytes)", totalUsage.InputTokens, len(originalReq))
|
|
}
|
|
|
|
contentBlockIndex := -1
|
|
messageStartSent := false
|
|
isTextBlockOpen := false
|
|
var outputLen int
|
|
|
|
// Ensure usage is published even on early return
|
|
defer func() {
|
|
reporter.publish(ctx, totalUsage)
|
|
}()
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return
|
|
default:
|
|
}
|
|
|
|
prelude := make([]byte, 8)
|
|
_, err := io.ReadFull(reader, prelude)
|
|
if err == io.EOF {
|
|
// Flush any incomplete tool use before ending stream
|
|
if currentToolUse != nil && !processedIDs[currentToolUse.toolUseID] {
|
|
log.Warnf("kiro: flushing incomplete tool use at EOF: %s (ID: %s)", currentToolUse.name, currentToolUse.toolUseID)
|
|
fullInput := currentToolUse.inputBuffer.String()
|
|
repairedJSON := repairJSON(fullInput)
|
|
var finalInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(repairedJSON), &finalInput); err != nil {
|
|
log.Warnf("kiro: failed to parse incomplete tool input at EOF: %v", err)
|
|
finalInput = make(map[string]interface{})
|
|
}
|
|
|
|
processedIDs[currentToolUse.toolUseID] = true
|
|
contentBlockIndex++
|
|
|
|
// Send tool_use content block
|
|
blockStart := e.buildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", currentToolUse.toolUseID, currentToolUse.name)
|
|
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 tool input as delta
|
|
inputBytes, _ := json.Marshal(finalInput)
|
|
inputDelta := e.buildClaudeInputJsonDeltaEvent(string(inputBytes), contentBlockIndex)
|
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
}
|
|
}
|
|
|
|
// Close block
|
|
blockStop := e.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")}
|
|
}
|
|
}
|
|
|
|
hasToolUses = true
|
|
currentToolUse = nil
|
|
}
|
|
break
|
|
}
|
|
if err != nil {
|
|
out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("failed to read prelude: %w", err)}
|
|
return
|
|
}
|
|
|
|
totalLen := binary.BigEndian.Uint32(prelude[0:4])
|
|
if totalLen < 8 {
|
|
out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("invalid message length: %d", totalLen)}
|
|
return
|
|
}
|
|
if totalLen > kiroMaxMessageSize {
|
|
out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("message too large: %d bytes", totalLen)}
|
|
return
|
|
}
|
|
headersLen := binary.BigEndian.Uint32(prelude[4:8])
|
|
|
|
remaining := make([]byte, totalLen-8)
|
|
_, err = io.ReadFull(reader, remaining)
|
|
if err != nil {
|
|
out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("failed to read message: %w", err)}
|
|
return
|
|
}
|
|
|
|
// Validate headersLen to prevent slice out of bounds
|
|
if headersLen+4 > uint32(len(remaining)) {
|
|
log.Warnf("kiro: invalid headersLen %d exceeds remaining buffer %d", headersLen, len(remaining))
|
|
continue
|
|
}
|
|
|
|
eventType := e.extractEventType(remaining[:headersLen+4])
|
|
|
|
payloadStart := 4 + headersLen
|
|
payloadEnd := uint32(len(remaining)) - 4
|
|
if payloadStart >= payloadEnd {
|
|
continue
|
|
}
|
|
|
|
payload := remaining[payloadStart:payloadEnd]
|
|
appendAPIResponseChunk(ctx, e.cfg, payload)
|
|
|
|
var event map[string]interface{}
|
|
if err := json.Unmarshal(payload, &event); err != nil {
|
|
log.Warnf("kiro: failed to unmarshal event payload: %v, raw: %s", err, string(payload))
|
|
continue
|
|
}
|
|
|
|
// DIAGNOSTIC: Log all received event types for debugging
|
|
log.Debugf("kiro: streamToChannel received event type: %s", eventType)
|
|
if log.IsLevelEnabled(log.TraceLevel) {
|
|
log.Tracef("kiro: streamToChannel event payload: %s", string(payload))
|
|
}
|
|
|
|
// Check for error/exception events in the payload (Kiro API may return errors with HTTP 200)
|
|
// These can appear as top-level fields or nested within the event
|
|
if errType, hasErrType := event["_type"].(string); hasErrType {
|
|
// AWS-style error: {"_type": "com.amazon.aws.codewhisperer#ValidationException", "message": "..."}
|
|
errMsg := ""
|
|
if msg, ok := event["message"].(string); ok {
|
|
errMsg = msg
|
|
}
|
|
log.Errorf("kiro: received AWS error in stream: type=%s, message=%s", errType, errMsg)
|
|
out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("kiro API error: %s - %s", errType, errMsg)}
|
|
return
|
|
}
|
|
if errType, hasErrType := event["type"].(string); hasErrType && (errType == "error" || errType == "exception") {
|
|
// Generic error event
|
|
errMsg := ""
|
|
if msg, ok := event["message"].(string); ok {
|
|
errMsg = msg
|
|
} else if errObj, ok := event["error"].(map[string]interface{}); ok {
|
|
if msg, ok := errObj["message"].(string); ok {
|
|
errMsg = msg
|
|
}
|
|
}
|
|
log.Errorf("kiro: received error event in stream: type=%s, message=%s", errType, errMsg)
|
|
out <- cliproxyexecutor.StreamChunk{Err: fmt.Errorf("kiro API error: %s", errMsg)}
|
|
return
|
|
}
|
|
|
|
// Send message_start on first event
|
|
if !messageStartSent {
|
|
msgStart := e.buildClaudeMessageStartEvent(model, totalUsage.InputTokens)
|
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStart, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
}
|
|
}
|
|
messageStartSent = true
|
|
}
|
|
|
|
switch eventType {
|
|
case "followupPromptEvent":
|
|
// Filter out followupPrompt events - these are UI suggestions, not content
|
|
log.Debugf("kiro: streamToChannel ignoring followupPrompt event")
|
|
continue
|
|
|
|
case "assistantResponseEvent":
|
|
var contentDelta string
|
|
var toolUses []map[string]interface{}
|
|
|
|
if assistantResp, ok := event["assistantResponseEvent"].(map[string]interface{}); ok {
|
|
if c, ok := assistantResp["content"].(string); ok {
|
|
contentDelta = c
|
|
}
|
|
// Extract tool uses from response
|
|
if tus, ok := assistantResp["toolUses"].([]interface{}); ok {
|
|
for _, tuRaw := range tus {
|
|
if tu, ok := tuRaw.(map[string]interface{}); ok {
|
|
toolUses = append(toolUses, tu)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
if contentDelta == "" {
|
|
if c, ok := event["content"].(string); ok {
|
|
contentDelta = c
|
|
}
|
|
}
|
|
// Direct tool uses
|
|
if tus, ok := event["toolUses"].([]interface{}); ok {
|
|
for _, tuRaw := range tus {
|
|
if tu, ok := tuRaw.(map[string]interface{}); ok {
|
|
toolUses = append(toolUses, tu)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle text content with duplicate detection
|
|
if contentDelta != "" {
|
|
// Check for duplicate content - skip if identical to last content event
|
|
// Based on AIClient-2-API implementation for Kiro
|
|
if contentDelta == lastContentEvent {
|
|
log.Debugf("kiro: skipping duplicate content event (len: %d)", len(contentDelta))
|
|
continue
|
|
}
|
|
lastContentEvent = contentDelta
|
|
|
|
outputLen += len(contentDelta)
|
|
// Accumulate content for streaming token calculation
|
|
accumulatedContent.WriteString(contentDelta)
|
|
// Start text content block if needed
|
|
if !isTextBlockOpen {
|
|
contentBlockIndex++
|
|
isTextBlockOpen = true
|
|
blockStart := e.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 := e.buildClaudeStreamEvent(contentDelta, 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")}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Handle tool uses in response (with deduplication)
|
|
for _, tu := range toolUses {
|
|
toolUseID := getString(tu, "toolUseId")
|
|
|
|
// Check for duplicate
|
|
if processedIDs[toolUseID] {
|
|
log.Debugf("kiro: skipping duplicate tool use in stream: %s", toolUseID)
|
|
continue
|
|
}
|
|
processedIDs[toolUseID] = true
|
|
|
|
hasToolUses = true
|
|
// Close text block if open before starting tool_use block
|
|
if isTextBlockOpen && contentBlockIndex >= 0 {
|
|
blockStop := e.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
|
|
}
|
|
|
|
// Emit tool_use content block
|
|
contentBlockIndex++
|
|
toolName := getString(tu, "name")
|
|
|
|
blockStart := e.buildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", toolUseID, toolName)
|
|
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 input_json_delta with the tool input
|
|
if input, ok := tu["input"].(map[string]interface{}); ok {
|
|
inputJSON, err := json.Marshal(input)
|
|
if err != nil {
|
|
log.Debugf("kiro: failed to marshal tool input: %v", err)
|
|
// Don't continue - still need to close the block
|
|
} else {
|
|
inputDelta := e.buildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
|
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close tool_use block (always close even if input marshal failed)
|
|
blockStop := e.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")}
|
|
}
|
|
}
|
|
}
|
|
|
|
case "toolUseEvent":
|
|
// Handle dedicated tool use events with input buffering
|
|
completedToolUses, newState := e.processToolUseEvent(event, currentToolUse, processedIDs)
|
|
currentToolUse = newState
|
|
|
|
// Emit completed tool uses
|
|
for _, tu := range completedToolUses {
|
|
hasToolUses = true
|
|
|
|
// Close text block if open
|
|
if isTextBlockOpen && contentBlockIndex >= 0 {
|
|
blockStop := e.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
|
|
}
|
|
|
|
contentBlockIndex++
|
|
|
|
blockStart := e.buildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name)
|
|
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")}
|
|
}
|
|
}
|
|
|
|
if tu.Input != nil {
|
|
inputJSON, err := json.Marshal(tu.Input)
|
|
if err != nil {
|
|
log.Debugf("kiro: failed to marshal tool input in toolUseEvent: %v", err)
|
|
} else {
|
|
inputDelta := e.buildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
|
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
blockStop := e.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")}
|
|
}
|
|
}
|
|
}
|
|
|
|
case "supplementaryWebLinksEvent":
|
|
if inputTokens, ok := event["inputTokens"].(float64); ok {
|
|
totalUsage.InputTokens = int64(inputTokens)
|
|
}
|
|
if outputTokens, ok := event["outputTokens"].(float64); ok {
|
|
totalUsage.OutputTokens = int64(outputTokens)
|
|
}
|
|
}
|
|
|
|
// Check nested usage event
|
|
if usageEvent, ok := event["supplementaryWebLinksEvent"].(map[string]interface{}); ok {
|
|
if inputTokens, ok := usageEvent["inputTokens"].(float64); ok {
|
|
totalUsage.InputTokens = int64(inputTokens)
|
|
}
|
|
if outputTokens, ok := usageEvent["outputTokens"].(float64); ok {
|
|
totalUsage.OutputTokens = int64(outputTokens)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close content block if open
|
|
if isTextBlockOpen && contentBlockIndex >= 0 {
|
|
blockStop := e.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")}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Streaming token calculation - calculate output tokens from accumulated content
|
|
// This provides more accurate token counting than simple character division
|
|
if totalUsage.OutputTokens == 0 && accumulatedContent.Len() > 0 {
|
|
// Try to use tiktoken for accurate counting
|
|
if enc, err := tokenizerForModel(model); err == nil {
|
|
if tokenCount, countErr := enc.Count(accumulatedContent.String()); countErr == nil {
|
|
totalUsage.OutputTokens = int64(tokenCount)
|
|
log.Debugf("kiro: streamToChannel calculated output tokens using tiktoken: %d", totalUsage.OutputTokens)
|
|
} else {
|
|
// Fallback on count error: estimate from character count
|
|
totalUsage.OutputTokens = int64(accumulatedContent.Len() / 4)
|
|
if totalUsage.OutputTokens == 0 {
|
|
totalUsage.OutputTokens = 1
|
|
}
|
|
log.Debugf("kiro: streamToChannel tiktoken count failed, estimated from chars: %d", totalUsage.OutputTokens)
|
|
}
|
|
} else {
|
|
// Fallback: estimate from character count (roughly 4 chars per token)
|
|
totalUsage.OutputTokens = int64(accumulatedContent.Len() / 4)
|
|
if totalUsage.OutputTokens == 0 {
|
|
totalUsage.OutputTokens = 1
|
|
}
|
|
log.Debugf("kiro: streamToChannel estimated output tokens from chars: %d (content len: %d)", totalUsage.OutputTokens, accumulatedContent.Len())
|
|
}
|
|
} else if totalUsage.OutputTokens == 0 && outputLen > 0 {
|
|
// Legacy fallback using outputLen
|
|
totalUsage.OutputTokens = int64(outputLen / 4)
|
|
if totalUsage.OutputTokens == 0 {
|
|
totalUsage.OutputTokens = 1
|
|
}
|
|
}
|
|
totalUsage.TotalTokens = totalUsage.InputTokens + totalUsage.OutputTokens
|
|
|
|
// Determine stop reason based on whether tool uses were emitted
|
|
stopReason := "end_turn"
|
|
if hasToolUses {
|
|
stopReason = "tool_use"
|
|
}
|
|
|
|
// Send message_delta event
|
|
msgDelta := e.buildClaudeMessageDeltaEvent(stopReason, totalUsage)
|
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgDelta, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
}
|
|
}
|
|
|
|
// Send message_stop event separately
|
|
msgStop := e.buildClaudeMessageStopOnlyEvent()
|
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStop, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
|
|
}
|
|
}
|
|
// reporter.publish is called via defer
|
|
}
|
|
|
|
|
|
// Claude SSE event builders
|
|
// All builders return complete SSE format with "event:" line for Claude client compatibility.
|
|
func (e *KiroExecutor) buildClaudeMessageStartEvent(model string, inputTokens int64) []byte {
|
|
event := map[string]interface{}{
|
|
"type": "message_start",
|
|
"message": map[string]interface{}{
|
|
"id": "msg_" + uuid.New().String()[:24],
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": []interface{}{},
|
|
"model": model,
|
|
"stop_reason": nil,
|
|
"stop_sequence": nil,
|
|
"usage": map[string]interface{}{"input_tokens": inputTokens, "output_tokens": 0},
|
|
},
|
|
}
|
|
result, _ := json.Marshal(event)
|
|
return []byte("event: message_start\ndata: " + string(result))
|
|
}
|
|
|
|
func (e *KiroExecutor) buildClaudeContentBlockStartEvent(index int, blockType, toolUseID, toolName string) []byte {
|
|
var contentBlock map[string]interface{}
|
|
if blockType == "tool_use" {
|
|
contentBlock = map[string]interface{}{
|
|
"type": "tool_use",
|
|
"id": toolUseID,
|
|
"name": toolName,
|
|
"input": map[string]interface{}{},
|
|
}
|
|
} else {
|
|
contentBlock = map[string]interface{}{
|
|
"type": "text",
|
|
"text": "",
|
|
}
|
|
}
|
|
|
|
event := map[string]interface{}{
|
|
"type": "content_block_start",
|
|
"index": index,
|
|
"content_block": contentBlock,
|
|
}
|
|
result, _ := json.Marshal(event)
|
|
return []byte("event: content_block_start\ndata: " + string(result))
|
|
}
|
|
|
|
func (e *KiroExecutor) buildClaudeStreamEvent(contentDelta string, index int) []byte {
|
|
event := map[string]interface{}{
|
|
"type": "content_block_delta",
|
|
"index": index,
|
|
"delta": map[string]interface{}{
|
|
"type": "text_delta",
|
|
"text": contentDelta,
|
|
},
|
|
}
|
|
result, _ := json.Marshal(event)
|
|
return []byte("event: content_block_delta\ndata: " + string(result))
|
|
}
|
|
|
|
// buildClaudeInputJsonDeltaEvent creates an input_json_delta event for tool use streaming
|
|
func (e *KiroExecutor) buildClaudeInputJsonDeltaEvent(partialJSON string, index int) []byte {
|
|
event := map[string]interface{}{
|
|
"type": "content_block_delta",
|
|
"index": index,
|
|
"delta": map[string]interface{}{
|
|
"type": "input_json_delta",
|
|
"partial_json": partialJSON,
|
|
},
|
|
}
|
|
result, _ := json.Marshal(event)
|
|
return []byte("event: content_block_delta\ndata: " + string(result))
|
|
}
|
|
|
|
func (e *KiroExecutor) buildClaudeContentBlockStopEvent(index int) []byte {
|
|
event := map[string]interface{}{
|
|
"type": "content_block_stop",
|
|
"index": index,
|
|
}
|
|
result, _ := json.Marshal(event)
|
|
return []byte("event: content_block_stop\ndata: " + string(result))
|
|
}
|
|
|
|
// buildClaudeMessageDeltaEvent creates the message_delta event with stop_reason and usage.
|
|
func (e *KiroExecutor) buildClaudeMessageDeltaEvent(stopReason string, usageInfo usage.Detail) []byte {
|
|
deltaEvent := map[string]interface{}{
|
|
"type": "message_delta",
|
|
"delta": map[string]interface{}{
|
|
"stop_reason": stopReason,
|
|
"stop_sequence": nil,
|
|
},
|
|
"usage": map[string]interface{}{
|
|
"input_tokens": usageInfo.InputTokens,
|
|
"output_tokens": usageInfo.OutputTokens,
|
|
},
|
|
}
|
|
deltaResult, _ := json.Marshal(deltaEvent)
|
|
return []byte("event: message_delta\ndata: " + string(deltaResult))
|
|
}
|
|
|
|
// buildClaudeMessageStopOnlyEvent creates only the message_stop event.
|
|
func (e *KiroExecutor) buildClaudeMessageStopOnlyEvent() []byte {
|
|
stopEvent := map[string]interface{}{
|
|
"type": "message_stop",
|
|
}
|
|
stopResult, _ := json.Marshal(stopEvent)
|
|
return []byte("event: message_stop\ndata: " + string(stopResult))
|
|
}
|
|
|
|
// buildClaudeFinalEvent constructs the final Claude-style event.
|
|
func (e *KiroExecutor) buildClaudeFinalEvent() []byte {
|
|
event := map[string]interface{}{
|
|
"type": "message_stop",
|
|
}
|
|
result, _ := json.Marshal(event)
|
|
return []byte("event: message_stop\ndata: " + string(result))
|
|
}
|
|
|
|
// CountTokens is not supported for Kiro provider.
|
|
// Kiro/Amazon Q backend doesn't expose a token counting API.
|
|
func (e *KiroExecutor) CountTokens(context.Context, *cliproxyauth.Auth, cliproxyexecutor.Request, cliproxyexecutor.Options) (cliproxyexecutor.Response, error) {
|
|
return cliproxyexecutor.Response{}, statusErr{code: http.StatusNotImplemented, msg: "count tokens not supported for kiro"}
|
|
}
|
|
|
|
// Refresh refreshes the Kiro OAuth token.
|
|
// Supports both AWS Builder ID (SSO OIDC) and Google OAuth (social login).
|
|
// Uses mutex to prevent race conditions when multiple concurrent requests try to refresh.
|
|
func (e *KiroExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) {
|
|
// Serialize token refresh operations to prevent race conditions
|
|
e.refreshMu.Lock()
|
|
defer e.refreshMu.Unlock()
|
|
|
|
var authID string
|
|
if auth != nil {
|
|
authID = auth.ID
|
|
} else {
|
|
authID = "<nil>"
|
|
}
|
|
log.Debugf("kiro executor: refresh called for auth %s", authID)
|
|
if auth == nil {
|
|
return nil, fmt.Errorf("kiro executor: auth is nil")
|
|
}
|
|
|
|
// Double-check: After acquiring lock, verify token still needs refresh
|
|
// Another goroutine may have already refreshed while we were waiting
|
|
// NOTE: This check has a design limitation - it reads from the auth object passed in,
|
|
// not from persistent storage. If another goroutine returns a new Auth object (via Clone),
|
|
// this check won't see those updates. The mutex still prevents truly concurrent refreshes,
|
|
// but queued goroutines may still attempt redundant refreshes. This is acceptable as
|
|
// the refresh operation is idempotent and the extra API calls are infrequent.
|
|
if auth.Metadata != nil {
|
|
if lastRefresh, ok := auth.Metadata["last_refresh"].(string); ok {
|
|
if refreshTime, err := time.Parse(time.RFC3339, lastRefresh); err == nil {
|
|
// If token was refreshed within the last 30 seconds, skip refresh
|
|
if time.Since(refreshTime) < 30*time.Second {
|
|
log.Debugf("kiro executor: token was recently refreshed by another goroutine, skipping")
|
|
return auth, nil
|
|
}
|
|
}
|
|
}
|
|
// Also check if expires_at is now in the future with sufficient buffer
|
|
if expiresAt, ok := auth.Metadata["expires_at"].(string); ok {
|
|
if expTime, err := time.Parse(time.RFC3339, expiresAt); err == nil {
|
|
// If token expires more than 2 minutes from now, it's still valid
|
|
if time.Until(expTime) > 2*time.Minute {
|
|
log.Debugf("kiro executor: token is still valid (expires in %v), skipping refresh", time.Until(expTime))
|
|
return auth, nil
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
var refreshToken string
|
|
var clientID, clientSecret string
|
|
var authMethod string
|
|
|
|
if auth.Metadata != nil {
|
|
if rt, ok := auth.Metadata["refresh_token"].(string); ok {
|
|
refreshToken = rt
|
|
}
|
|
if cid, ok := auth.Metadata["client_id"].(string); ok {
|
|
clientID = cid
|
|
}
|
|
if cs, ok := auth.Metadata["client_secret"].(string); ok {
|
|
clientSecret = cs
|
|
}
|
|
if am, ok := auth.Metadata["auth_method"].(string); ok {
|
|
authMethod = am
|
|
}
|
|
}
|
|
|
|
if refreshToken == "" {
|
|
return nil, fmt.Errorf("kiro executor: refresh token not found")
|
|
}
|
|
|
|
var tokenData *kiroauth.KiroTokenData
|
|
var err error
|
|
|
|
// Use SSO OIDC refresh for AWS Builder ID, otherwise use Kiro's OAuth refresh endpoint
|
|
if clientID != "" && clientSecret != "" && authMethod == "builder-id" {
|
|
log.Debugf("kiro executor: using SSO OIDC refresh for AWS Builder ID")
|
|
ssoClient := kiroauth.NewSSOOIDCClient(e.cfg)
|
|
tokenData, err = ssoClient.RefreshToken(ctx, clientID, clientSecret, refreshToken)
|
|
} else {
|
|
log.Debugf("kiro executor: using Kiro OAuth refresh endpoint")
|
|
oauth := kiroauth.NewKiroOAuth(e.cfg)
|
|
tokenData, err = oauth.RefreshToken(ctx, refreshToken)
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, fmt.Errorf("kiro executor: token refresh failed: %w", err)
|
|
}
|
|
|
|
updated := auth.Clone()
|
|
now := time.Now()
|
|
updated.UpdatedAt = now
|
|
updated.LastRefreshedAt = now
|
|
|
|
if updated.Metadata == nil {
|
|
updated.Metadata = make(map[string]any)
|
|
}
|
|
updated.Metadata["access_token"] = tokenData.AccessToken
|
|
updated.Metadata["refresh_token"] = tokenData.RefreshToken
|
|
updated.Metadata["expires_at"] = tokenData.ExpiresAt
|
|
updated.Metadata["last_refresh"] = now.Format(time.RFC3339)
|
|
if tokenData.ProfileArn != "" {
|
|
updated.Metadata["profile_arn"] = tokenData.ProfileArn
|
|
}
|
|
if tokenData.AuthMethod != "" {
|
|
updated.Metadata["auth_method"] = tokenData.AuthMethod
|
|
}
|
|
if tokenData.Provider != "" {
|
|
updated.Metadata["provider"] = tokenData.Provider
|
|
}
|
|
// Preserve client credentials for future refreshes (AWS Builder ID)
|
|
if tokenData.ClientID != "" {
|
|
updated.Metadata["client_id"] = tokenData.ClientID
|
|
}
|
|
if tokenData.ClientSecret != "" {
|
|
updated.Metadata["client_secret"] = tokenData.ClientSecret
|
|
}
|
|
|
|
if updated.Attributes == nil {
|
|
updated.Attributes = make(map[string]string)
|
|
}
|
|
updated.Attributes["access_token"] = tokenData.AccessToken
|
|
if tokenData.ProfileArn != "" {
|
|
updated.Attributes["profile_arn"] = tokenData.ProfileArn
|
|
}
|
|
|
|
// Set next refresh time to 30 minutes before expiry
|
|
if expiresAt, parseErr := time.Parse(time.RFC3339, tokenData.ExpiresAt); parseErr == nil {
|
|
updated.NextRefreshAfter = expiresAt.Add(-30 * time.Minute)
|
|
}
|
|
|
|
log.Infof("kiro executor: token refreshed successfully, expires at %s", tokenData.ExpiresAt)
|
|
return updated, nil
|
|
}
|
|
|
|
// streamEventStream converts AWS Event Stream to SSE (legacy method for gin.Context).
|
|
// Note: For full tool calling support, use streamToChannel instead.
|
|
func (e *KiroExecutor) streamEventStream(ctx context.Context, body io.Reader, c *gin.Context, targetFormat sdktranslator.Format, model string, originalReq, claudeBody []byte, reporter *usageReporter) error {
|
|
reader := bufio.NewReader(body)
|
|
var totalUsage usage.Detail
|
|
|
|
// Translator param for maintaining tool call state across streaming events
|
|
var translatorParam any
|
|
|
|
// Pre-calculate input tokens from request if possible
|
|
if enc, err := tokenizerForModel(model); err == nil {
|
|
// Try OpenAI format first, then fall back to raw byte count estimation
|
|
if inp, err := countOpenAIChatTokens(enc, originalReq); err == nil && inp > 0 {
|
|
totalUsage.InputTokens = inp
|
|
} else {
|
|
// Fallback: estimate from raw request size (roughly 4 chars per token)
|
|
totalUsage.InputTokens = int64(len(originalReq) / 4)
|
|
if totalUsage.InputTokens == 0 && len(originalReq) > 0 {
|
|
totalUsage.InputTokens = 1
|
|
}
|
|
}
|
|
log.Debugf("kiro: streamEventStream pre-calculated input tokens: %d (request size: %d bytes)", totalUsage.InputTokens, len(originalReq))
|
|
}
|
|
|
|
contentBlockIndex := -1
|
|
messageStartSent := false
|
|
isBlockOpen := false
|
|
var outputLen int
|
|
|
|
for {
|
|
select {
|
|
case <-ctx.Done():
|
|
return ctx.Err()
|
|
default:
|
|
}
|
|
|
|
prelude := make([]byte, 8)
|
|
_, err := io.ReadFull(reader, prelude)
|
|
if err == io.EOF {
|
|
break
|
|
}
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read prelude: %w", err)
|
|
}
|
|
|
|
totalLen := binary.BigEndian.Uint32(prelude[0:4])
|
|
if totalLen < 8 {
|
|
return fmt.Errorf("invalid message length: %d", totalLen)
|
|
}
|
|
if totalLen > kiroMaxMessageSize {
|
|
return fmt.Errorf("message too large: %d bytes", totalLen)
|
|
}
|
|
headersLen := binary.BigEndian.Uint32(prelude[4:8])
|
|
|
|
remaining := make([]byte, totalLen-8)
|
|
_, err = io.ReadFull(reader, remaining)
|
|
if err != nil {
|
|
return fmt.Errorf("failed to read message: %w", err)
|
|
}
|
|
|
|
// Validate headersLen to prevent slice out of bounds
|
|
if headersLen+4 > uint32(len(remaining)) {
|
|
log.Warnf("kiro: invalid headersLen %d exceeds remaining buffer %d", headersLen, len(remaining))
|
|
continue
|
|
}
|
|
|
|
eventType := e.extractEventType(remaining[:headersLen+4])
|
|
|
|
payloadStart := 4 + headersLen
|
|
payloadEnd := uint32(len(remaining)) - 4
|
|
if payloadStart >= payloadEnd {
|
|
continue
|
|
}
|
|
|
|
payload := remaining[payloadStart:payloadEnd]
|
|
appendAPIResponseChunk(ctx, e.cfg, payload)
|
|
|
|
var event map[string]interface{}
|
|
if err := json.Unmarshal(payload, &event); err != nil {
|
|
log.Warnf("kiro: failed to unmarshal event payload: %v, raw: %s", err, string(payload))
|
|
continue
|
|
}
|
|
|
|
if !messageStartSent {
|
|
msgStart := e.buildClaudeMessageStartEvent(model, totalUsage.InputTokens)
|
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStart, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
c.Writer.Write([]byte(chunk + "\n\n"))
|
|
}
|
|
}
|
|
c.Writer.Flush()
|
|
messageStartSent = true
|
|
}
|
|
|
|
switch eventType {
|
|
case "assistantResponseEvent":
|
|
var contentDelta string
|
|
if assistantResp, ok := event["assistantResponseEvent"].(map[string]interface{}); ok {
|
|
if ct, ok := assistantResp["content"].(string); ok {
|
|
contentDelta = ct
|
|
}
|
|
}
|
|
if contentDelta == "" {
|
|
if ct, ok := event["content"].(string); ok {
|
|
contentDelta = ct
|
|
}
|
|
}
|
|
|
|
if contentDelta != "" {
|
|
outputLen += len(contentDelta)
|
|
// Start text content block if needed
|
|
if !isBlockOpen {
|
|
contentBlockIndex++
|
|
isBlockOpen = true
|
|
blockStart := e.buildClaudeContentBlockStartEvent(contentBlockIndex, "text", "", "")
|
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
c.Writer.Write([]byte(chunk + "\n\n"))
|
|
}
|
|
}
|
|
c.Writer.Flush()
|
|
}
|
|
|
|
claudeEvent := e.buildClaudeStreamEvent(contentDelta, contentBlockIndex)
|
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, claudeEvent, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
c.Writer.Write([]byte(chunk + "\n\n"))
|
|
}
|
|
}
|
|
c.Writer.Flush()
|
|
}
|
|
|
|
// Note: For full toolUseEvent support, use streamToChannel
|
|
|
|
case "supplementaryWebLinksEvent":
|
|
if inputTokens, ok := event["inputTokens"].(float64); ok {
|
|
totalUsage.InputTokens = int64(inputTokens)
|
|
}
|
|
if outputTokens, ok := event["outputTokens"].(float64); ok {
|
|
totalUsage.OutputTokens = int64(outputTokens)
|
|
}
|
|
}
|
|
|
|
if usageEvent, ok := event["supplementaryWebLinksEvent"].(map[string]interface{}); ok {
|
|
if inputTokens, ok := usageEvent["inputTokens"].(float64); ok {
|
|
totalUsage.InputTokens = int64(inputTokens)
|
|
}
|
|
if outputTokens, ok := usageEvent["outputTokens"].(float64); ok {
|
|
totalUsage.OutputTokens = int64(outputTokens)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Close content block if open
|
|
if isBlockOpen && contentBlockIndex >= 0 {
|
|
blockStop := e.buildClaudeContentBlockStopEvent(contentBlockIndex)
|
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
c.Writer.Write([]byte(chunk + "\n\n"))
|
|
}
|
|
}
|
|
c.Writer.Flush()
|
|
}
|
|
|
|
// Fallback for output tokens if not received from upstream
|
|
if totalUsage.OutputTokens == 0 && outputLen > 0 {
|
|
totalUsage.OutputTokens = int64(outputLen / 4)
|
|
if totalUsage.OutputTokens == 0 {
|
|
totalUsage.OutputTokens = 1
|
|
}
|
|
}
|
|
totalUsage.TotalTokens = totalUsage.InputTokens + totalUsage.OutputTokens
|
|
|
|
// Send message_delta event
|
|
msgDelta := e.buildClaudeMessageDeltaEvent("end_turn", totalUsage)
|
|
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgDelta, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
c.Writer.Write([]byte(chunk + "\n\n"))
|
|
}
|
|
}
|
|
c.Writer.Flush()
|
|
|
|
// Send message_stop event separately
|
|
msgStop := e.buildClaudeMessageStopOnlyEvent()
|
|
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, msgStop, &translatorParam)
|
|
for _, chunk := range sseData {
|
|
if chunk != "" {
|
|
c.Writer.Write([]byte(chunk + "\n\n"))
|
|
}
|
|
}
|
|
|
|
c.Writer.Write([]byte("data: [DONE]\n\n"))
|
|
c.Writer.Flush()
|
|
|
|
reporter.publish(ctx, totalUsage)
|
|
return nil
|
|
}
|
|
|
|
// isTokenExpired checks if a JWT access token has expired.
|
|
// Returns true if the token is expired or cannot be parsed.
|
|
func (e *KiroExecutor) isTokenExpired(accessToken string) bool {
|
|
if accessToken == "" {
|
|
return true
|
|
}
|
|
|
|
// JWT tokens have 3 parts separated by dots
|
|
parts := strings.Split(accessToken, ".")
|
|
if len(parts) != 3 {
|
|
// Not a JWT token, assume not expired
|
|
return false
|
|
}
|
|
|
|
// Decode the payload (second part)
|
|
// JWT uses base64url encoding without padding (RawURLEncoding)
|
|
payload := parts[1]
|
|
decoded, err := base64.RawURLEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
// Try with padding added as fallback
|
|
switch len(payload) % 4 {
|
|
case 2:
|
|
payload += "=="
|
|
case 3:
|
|
payload += "="
|
|
}
|
|
decoded, err = base64.URLEncoding.DecodeString(payload)
|
|
if err != nil {
|
|
log.Debugf("kiro: failed to decode JWT payload: %v", err)
|
|
return false
|
|
}
|
|
}
|
|
|
|
var claims struct {
|
|
Exp int64 `json:"exp"`
|
|
}
|
|
if err := json.Unmarshal(decoded, &claims); err != nil {
|
|
log.Debugf("kiro: failed to parse JWT claims: %v", err)
|
|
return false
|
|
}
|
|
|
|
if claims.Exp == 0 {
|
|
// No expiration claim, assume not expired
|
|
return false
|
|
}
|
|
|
|
expTime := time.Unix(claims.Exp, 0)
|
|
now := time.Now()
|
|
|
|
// Consider token expired if it expires within 1 minute (buffer for clock skew)
|
|
isExpired := now.After(expTime) || expTime.Sub(now) < time.Minute
|
|
if isExpired {
|
|
log.Debugf("kiro: token expired at %s (now: %s)", expTime.Format(time.RFC3339), now.Format(time.RFC3339))
|
|
}
|
|
|
|
return isExpired
|
|
}
|
|
|
|
// ============================================================================
|
|
// Message Merging Support - Merge adjacent messages with the same role
|
|
// Based on AIClient-2-API implementation
|
|
// ============================================================================
|
|
|
|
// mergeAdjacentMessages merges adjacent messages with the same role.
|
|
// This reduces API call complexity and improves compatibility.
|
|
// Based on AIClient-2-API implementation.
|
|
func mergeAdjacentMessages(messages []gjson.Result) []gjson.Result {
|
|
if len(messages) <= 1 {
|
|
return messages
|
|
}
|
|
|
|
var merged []gjson.Result
|
|
for _, msg := range messages {
|
|
if len(merged) == 0 {
|
|
merged = append(merged, msg)
|
|
continue
|
|
}
|
|
|
|
lastMsg := merged[len(merged)-1]
|
|
currentRole := msg.Get("role").String()
|
|
lastRole := lastMsg.Get("role").String()
|
|
|
|
if currentRole == lastRole {
|
|
// Merge content from current message into last message
|
|
mergedContent := mergeMessageContent(lastMsg, msg)
|
|
// Create a new merged message JSON
|
|
mergedMsg := createMergedMessage(lastRole, mergedContent)
|
|
merged[len(merged)-1] = gjson.Parse(mergedMsg)
|
|
} else {
|
|
merged = append(merged, msg)
|
|
}
|
|
}
|
|
|
|
return merged
|
|
}
|
|
|
|
// mergeMessageContent merges the content of two messages with the same role.
|
|
// Handles both string content and array content (with text, tool_use, tool_result blocks).
|
|
func mergeMessageContent(msg1, msg2 gjson.Result) string {
|
|
content1 := msg1.Get("content")
|
|
content2 := msg2.Get("content")
|
|
|
|
// Extract content blocks from both messages
|
|
var blocks1, blocks2 []map[string]interface{}
|
|
|
|
if content1.IsArray() {
|
|
for _, block := range content1.Array() {
|
|
blocks1 = append(blocks1, blockToMap(block))
|
|
}
|
|
} else if content1.Type == gjson.String {
|
|
blocks1 = append(blocks1, map[string]interface{}{
|
|
"type": "text",
|
|
"text": content1.String(),
|
|
})
|
|
}
|
|
|
|
if content2.IsArray() {
|
|
for _, block := range content2.Array() {
|
|
blocks2 = append(blocks2, blockToMap(block))
|
|
}
|
|
} else if content2.Type == gjson.String {
|
|
blocks2 = append(blocks2, map[string]interface{}{
|
|
"type": "text",
|
|
"text": content2.String(),
|
|
})
|
|
}
|
|
|
|
// Merge text blocks if both end/start with text
|
|
if len(blocks1) > 0 && len(blocks2) > 0 {
|
|
if blocks1[len(blocks1)-1]["type"] == "text" && blocks2[0]["type"] == "text" {
|
|
// Merge the last text block of msg1 with the first text block of msg2
|
|
text1 := blocks1[len(blocks1)-1]["text"].(string)
|
|
text2 := blocks2[0]["text"].(string)
|
|
blocks1[len(blocks1)-1]["text"] = text1 + "\n" + text2
|
|
blocks2 = blocks2[1:] // Remove the merged block from blocks2
|
|
}
|
|
}
|
|
|
|
// Combine all blocks
|
|
allBlocks := append(blocks1, blocks2...)
|
|
|
|
// Convert to JSON
|
|
result, _ := json.Marshal(allBlocks)
|
|
return string(result)
|
|
}
|
|
|
|
// blockToMap converts a gjson.Result block to a map[string]interface{}
|
|
func blockToMap(block gjson.Result) map[string]interface{} {
|
|
result := make(map[string]interface{})
|
|
block.ForEach(func(key, value gjson.Result) bool {
|
|
if value.IsObject() {
|
|
result[key.String()] = blockToMap(value)
|
|
} else if value.IsArray() {
|
|
var arr []interface{}
|
|
for _, item := range value.Array() {
|
|
if item.IsObject() {
|
|
arr = append(arr, blockToMap(item))
|
|
} else {
|
|
arr = append(arr, item.Value())
|
|
}
|
|
}
|
|
result[key.String()] = arr
|
|
} else {
|
|
result[key.String()] = value.Value()
|
|
}
|
|
return true
|
|
})
|
|
return result
|
|
}
|
|
|
|
// createMergedMessage creates a JSON string for a merged message
|
|
func createMergedMessage(role string, content string) string {
|
|
msg := map[string]interface{}{
|
|
"role": role,
|
|
"content": json.RawMessage(content),
|
|
}
|
|
result, _ := json.Marshal(msg)
|
|
return string(result)
|
|
}
|
|
|
|
// ============================================================================
|
|
// Tool Calling Support - Embedded tool call parsing and input buffering
|
|
// Based on amq2api and AIClient-2-API implementations
|
|
// ============================================================================
|
|
|
|
// toolUseState tracks the state of an in-progress tool use during streaming.
|
|
type toolUseState struct {
|
|
toolUseID string
|
|
name string
|
|
inputBuffer strings.Builder
|
|
isComplete bool
|
|
}
|
|
|
|
// Pre-compiled regex patterns for performance (avoid recompilation on each call)
|
|
var (
|
|
// embeddedToolCallPattern matches [Called tool_name with args: {...}] format
|
|
// This pattern is used by Kiro when it embeds tool calls in text content
|
|
embeddedToolCallPattern = regexp.MustCompile(`\[Called\s+(\w+)\s+with\s+args:\s*`)
|
|
// whitespaceCollapsePattern collapses multiple whitespace characters into single space
|
|
whitespaceCollapsePattern = regexp.MustCompile(`\s+`)
|
|
// trailingCommaPattern matches trailing commas before closing braces/brackets
|
|
trailingCommaPattern = regexp.MustCompile(`,\s*([}\]])`)
|
|
)
|
|
|
|
// parseEmbeddedToolCalls extracts [Called tool_name with args: {...}] format from text.
|
|
// Kiro sometimes embeds tool calls in text content instead of using toolUseEvent.
|
|
// Returns the cleaned text (with tool calls removed) and extracted tool uses.
|
|
func (e *KiroExecutor) parseEmbeddedToolCalls(text string, processedIDs map[string]bool) (string, []kiroToolUse) {
|
|
if !strings.Contains(text, "[Called") {
|
|
return text, nil
|
|
}
|
|
|
|
var toolUses []kiroToolUse
|
|
cleanText := text
|
|
|
|
// Find all [Called markers
|
|
matches := embeddedToolCallPattern.FindAllStringSubmatchIndex(text, -1)
|
|
if len(matches) == 0 {
|
|
return text, nil
|
|
}
|
|
|
|
// Process matches in reverse order to maintain correct indices
|
|
for i := len(matches) - 1; i >= 0; i-- {
|
|
matchStart := matches[i][0]
|
|
toolNameStart := matches[i][2]
|
|
toolNameEnd := matches[i][3]
|
|
|
|
if toolNameStart < 0 || toolNameEnd < 0 {
|
|
continue
|
|
}
|
|
|
|
toolName := text[toolNameStart:toolNameEnd]
|
|
|
|
// Find the JSON object start (after "with args:")
|
|
jsonStart := matches[i][1]
|
|
if jsonStart >= len(text) {
|
|
continue
|
|
}
|
|
|
|
// Skip whitespace to find the opening brace
|
|
for jsonStart < len(text) && (text[jsonStart] == ' ' || text[jsonStart] == '\t') {
|
|
jsonStart++
|
|
}
|
|
|
|
if jsonStart >= len(text) || text[jsonStart] != '{' {
|
|
continue
|
|
}
|
|
|
|
// Find matching closing bracket
|
|
jsonEnd := findMatchingBracket(text, jsonStart)
|
|
if jsonEnd < 0 {
|
|
continue
|
|
}
|
|
|
|
// Extract JSON and find the closing bracket of [Called ...]
|
|
jsonStr := text[jsonStart : jsonEnd+1]
|
|
|
|
// Find the closing ] after the JSON
|
|
closingBracket := jsonEnd + 1
|
|
for closingBracket < len(text) && text[closingBracket] != ']' {
|
|
closingBracket++
|
|
}
|
|
if closingBracket >= len(text) {
|
|
continue
|
|
}
|
|
|
|
// Extract and repair the full tool call text
|
|
fullMatch := text[matchStart : closingBracket+1]
|
|
|
|
// Repair and parse JSON
|
|
repairedJSON := repairJSON(jsonStr)
|
|
var inputMap map[string]interface{}
|
|
if err := json.Unmarshal([]byte(repairedJSON), &inputMap); err != nil {
|
|
log.Debugf("kiro: failed to parse embedded tool call JSON: %v, raw: %s", err, jsonStr)
|
|
continue
|
|
}
|
|
|
|
// Generate unique tool ID
|
|
toolUseID := "toolu_" + uuid.New().String()[:12]
|
|
|
|
// Check for duplicates using name+input as key
|
|
dedupeKey := toolName + ":" + repairedJSON
|
|
if processedIDs != nil {
|
|
if processedIDs[dedupeKey] {
|
|
log.Debugf("kiro: skipping duplicate embedded tool call: %s", toolName)
|
|
// Still remove from text even if duplicate
|
|
cleanText = strings.Replace(cleanText, fullMatch, "", 1)
|
|
continue
|
|
}
|
|
processedIDs[dedupeKey] = true
|
|
}
|
|
|
|
toolUses = append(toolUses, kiroToolUse{
|
|
ToolUseID: toolUseID,
|
|
Name: toolName,
|
|
Input: inputMap,
|
|
})
|
|
|
|
log.Infof("kiro: extracted embedded tool call: %s (ID: %s)", toolName, toolUseID)
|
|
|
|
// Remove from clean text
|
|
cleanText = strings.Replace(cleanText, fullMatch, "", 1)
|
|
}
|
|
|
|
// Clean up extra whitespace
|
|
cleanText = strings.TrimSpace(cleanText)
|
|
cleanText = whitespaceCollapsePattern.ReplaceAllString(cleanText, " ")
|
|
|
|
return cleanText, toolUses
|
|
}
|
|
|
|
// findMatchingBracket finds the index of the closing brace/bracket that matches
|
|
// the opening one at startPos. Handles nested objects and strings correctly.
|
|
func findMatchingBracket(text string, startPos int) int {
|
|
if startPos >= len(text) {
|
|
return -1
|
|
}
|
|
|
|
openChar := text[startPos]
|
|
var closeChar byte
|
|
switch openChar {
|
|
case '{':
|
|
closeChar = '}'
|
|
case '[':
|
|
closeChar = ']'
|
|
default:
|
|
return -1
|
|
}
|
|
|
|
depth := 1
|
|
inString := false
|
|
escapeNext := false
|
|
|
|
for i := startPos + 1; i < len(text); i++ {
|
|
char := text[i]
|
|
|
|
if escapeNext {
|
|
escapeNext = false
|
|
continue
|
|
}
|
|
|
|
if char == '\\' && inString {
|
|
escapeNext = true
|
|
continue
|
|
}
|
|
|
|
if char == '"' {
|
|
inString = !inString
|
|
continue
|
|
}
|
|
|
|
if !inString {
|
|
if char == openChar {
|
|
depth++
|
|
} else if char == closeChar {
|
|
depth--
|
|
if depth == 0 {
|
|
return i
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return -1
|
|
}
|
|
|
|
// repairJSON attempts to fix common JSON issues that may occur in tool call arguments.
|
|
// Based on AIClient-2-API's JSON repair implementation with a more conservative strategy.
|
|
//
|
|
// Conservative repair strategy:
|
|
// 1. First try to parse JSON directly - if valid, return as-is
|
|
// 2. Only attempt repair if parsing fails
|
|
// 3. After repair, validate the result - if still invalid, return original
|
|
//
|
|
// Handles incomplete JSON by balancing brackets and removing trailing incomplete content.
|
|
// Uses pre-compiled regex patterns for performance.
|
|
func repairJSON(jsonString string) string {
|
|
// Handle empty or invalid input
|
|
if jsonString == "" {
|
|
return "{}"
|
|
}
|
|
|
|
str := strings.TrimSpace(jsonString)
|
|
if str == "" {
|
|
return "{}"
|
|
}
|
|
|
|
// CONSERVATIVE STRATEGY: First try to parse directly
|
|
// If the JSON is already valid, return it unchanged
|
|
var testParse interface{}
|
|
if err := json.Unmarshal([]byte(str), &testParse); err == nil {
|
|
log.Debugf("kiro: repairJSON - JSON is already valid, returning unchanged")
|
|
return str
|
|
}
|
|
|
|
log.Debugf("kiro: repairJSON - JSON parse failed, attempting repair")
|
|
originalStr := str // Keep original for fallback
|
|
|
|
// First, escape unescaped newlines/tabs within JSON string values
|
|
str = escapeNewlinesInStrings(str)
|
|
// Remove trailing commas before closing braces/brackets
|
|
str = trailingCommaPattern.ReplaceAllString(str, "$1")
|
|
|
|
// Calculate bracket balance to detect incomplete JSON
|
|
braceCount := 0 // {} balance
|
|
bracketCount := 0 // [] balance
|
|
inString := false
|
|
escape := false
|
|
lastValidIndex := -1
|
|
|
|
for i := 0; i < len(str); i++ {
|
|
char := str[i]
|
|
|
|
// Handle escape sequences
|
|
if escape {
|
|
escape = false
|
|
continue
|
|
}
|
|
|
|
if char == '\\' {
|
|
escape = true
|
|
continue
|
|
}
|
|
|
|
// Handle string boundaries
|
|
if char == '"' {
|
|
inString = !inString
|
|
continue
|
|
}
|
|
|
|
// Skip characters inside strings (they don't affect bracket balance)
|
|
if inString {
|
|
continue
|
|
}
|
|
|
|
// Track bracket balance
|
|
switch char {
|
|
case '{':
|
|
braceCount++
|
|
case '}':
|
|
braceCount--
|
|
case '[':
|
|
bracketCount++
|
|
case ']':
|
|
bracketCount--
|
|
}
|
|
|
|
// Record last valid position (where brackets are balanced or positive)
|
|
if braceCount >= 0 && bracketCount >= 0 {
|
|
lastValidIndex = i
|
|
}
|
|
}
|
|
|
|
// If brackets are unbalanced, try to repair
|
|
if braceCount > 0 || bracketCount > 0 {
|
|
// Truncate to last valid position if we have incomplete content
|
|
if lastValidIndex > 0 && lastValidIndex < len(str)-1 {
|
|
// Check if truncation would help (only truncate if there's trailing garbage)
|
|
truncated := str[:lastValidIndex+1]
|
|
// Recount brackets after truncation
|
|
braceCount = 0
|
|
bracketCount = 0
|
|
inString = false
|
|
escape = false
|
|
for i := 0; i < len(truncated); i++ {
|
|
char := truncated[i]
|
|
if escape {
|
|
escape = false
|
|
continue
|
|
}
|
|
if char == '\\' {
|
|
escape = true
|
|
continue
|
|
}
|
|
if char == '"' {
|
|
inString = !inString
|
|
continue
|
|
}
|
|
if inString {
|
|
continue
|
|
}
|
|
switch char {
|
|
case '{':
|
|
braceCount++
|
|
case '}':
|
|
braceCount--
|
|
case '[':
|
|
bracketCount++
|
|
case ']':
|
|
bracketCount--
|
|
}
|
|
}
|
|
str = truncated
|
|
}
|
|
|
|
// Add missing closing brackets
|
|
for braceCount > 0 {
|
|
str += "}"
|
|
braceCount--
|
|
}
|
|
for bracketCount > 0 {
|
|
str += "]"
|
|
bracketCount--
|
|
}
|
|
}
|
|
|
|
// CONSERVATIVE STRATEGY: Validate repaired JSON
|
|
// If repair didn't produce valid JSON, return original string
|
|
if err := json.Unmarshal([]byte(str), &testParse); err != nil {
|
|
log.Warnf("kiro: repairJSON - repair failed to produce valid JSON, returning original")
|
|
return originalStr
|
|
}
|
|
|
|
log.Debugf("kiro: repairJSON - successfully repaired JSON")
|
|
return str
|
|
}
|
|
|
|
// escapeNewlinesInStrings escapes literal newlines, tabs, and other control characters
|
|
// that appear inside JSON string values. This handles cases where streaming fragments
|
|
// contain unescaped control characters within string content.
|
|
func escapeNewlinesInStrings(raw string) string {
|
|
var result strings.Builder
|
|
result.Grow(len(raw) + 100) // Pre-allocate with some extra space
|
|
|
|
inString := false
|
|
escaped := false
|
|
|
|
for i := 0; i < len(raw); i++ {
|
|
c := raw[i]
|
|
|
|
if escaped {
|
|
// Previous character was backslash, this is an escape sequence
|
|
result.WriteByte(c)
|
|
escaped = false
|
|
continue
|
|
}
|
|
|
|
if c == '\\' && inString {
|
|
// Start of escape sequence
|
|
result.WriteByte(c)
|
|
escaped = true
|
|
continue
|
|
}
|
|
|
|
if c == '"' {
|
|
// Toggle string state
|
|
inString = !inString
|
|
result.WriteByte(c)
|
|
continue
|
|
}
|
|
|
|
if inString {
|
|
// Inside a string, escape control characters
|
|
switch c {
|
|
case '\n':
|
|
result.WriteString("\\n")
|
|
case '\r':
|
|
result.WriteString("\\r")
|
|
case '\t':
|
|
result.WriteString("\\t")
|
|
default:
|
|
result.WriteByte(c)
|
|
}
|
|
} else {
|
|
result.WriteByte(c)
|
|
}
|
|
}
|
|
|
|
return result.String()
|
|
}
|
|
|
|
// processToolUseEvent handles a toolUseEvent from the Kiro stream.
|
|
// It accumulates input fragments and emits tool_use blocks when complete.
|
|
// Returns events to emit and updated state.
|
|
func (e *KiroExecutor) processToolUseEvent(event map[string]interface{}, currentToolUse *toolUseState, processedIDs map[string]bool) ([]kiroToolUse, *toolUseState) {
|
|
var toolUses []kiroToolUse
|
|
|
|
// Extract from nested toolUseEvent or direct format
|
|
tu := event
|
|
if nested, ok := event["toolUseEvent"].(map[string]interface{}); ok {
|
|
tu = nested
|
|
}
|
|
|
|
toolUseID := getString(tu, "toolUseId")
|
|
toolName := getString(tu, "name")
|
|
isStop := false
|
|
if stop, ok := tu["stop"].(bool); ok {
|
|
isStop = stop
|
|
}
|
|
|
|
// Get input - can be string (fragment) or object (complete)
|
|
var inputFragment string
|
|
var inputMap map[string]interface{}
|
|
|
|
if inputRaw, ok := tu["input"]; ok {
|
|
switch v := inputRaw.(type) {
|
|
case string:
|
|
inputFragment = v
|
|
case map[string]interface{}:
|
|
inputMap = v
|
|
}
|
|
}
|
|
|
|
// New tool use starting
|
|
if toolUseID != "" && toolName != "" {
|
|
if currentToolUse != nil && currentToolUse.toolUseID != toolUseID {
|
|
// New tool use arrived while another is in progress (interleaved events)
|
|
// This is unusual - log warning and complete the previous one
|
|
log.Warnf("kiro: interleaved tool use detected - new ID %s arrived while %s in progress, completing previous",
|
|
toolUseID, currentToolUse.toolUseID)
|
|
// Emit incomplete previous tool use
|
|
if !processedIDs[currentToolUse.toolUseID] {
|
|
incomplete := kiroToolUse{
|
|
ToolUseID: currentToolUse.toolUseID,
|
|
Name: currentToolUse.name,
|
|
}
|
|
if currentToolUse.inputBuffer.Len() > 0 {
|
|
var input map[string]interface{}
|
|
if err := json.Unmarshal([]byte(currentToolUse.inputBuffer.String()), &input); err == nil {
|
|
incomplete.Input = input
|
|
}
|
|
}
|
|
toolUses = append(toolUses, incomplete)
|
|
processedIDs[currentToolUse.toolUseID] = true
|
|
}
|
|
currentToolUse = nil
|
|
}
|
|
|
|
if currentToolUse == nil {
|
|
// Check for duplicate
|
|
if processedIDs != nil && processedIDs[toolUseID] {
|
|
log.Debugf("kiro: skipping duplicate toolUseEvent: %s", toolUseID)
|
|
return nil, nil
|
|
}
|
|
|
|
currentToolUse = &toolUseState{
|
|
toolUseID: toolUseID,
|
|
name: toolName,
|
|
}
|
|
log.Infof("kiro: starting new tool use: %s (ID: %s)", toolName, toolUseID)
|
|
}
|
|
}
|
|
|
|
// Accumulate input fragments
|
|
if currentToolUse != nil && inputFragment != "" {
|
|
// Accumulate fragments directly - they form valid JSON when combined
|
|
// The fragments are already decoded from JSON, so we just concatenate them
|
|
currentToolUse.inputBuffer.WriteString(inputFragment)
|
|
log.Debugf("kiro: accumulated input fragment, total length: %d", currentToolUse.inputBuffer.Len())
|
|
}
|
|
|
|
// If complete input object provided directly
|
|
if currentToolUse != nil && inputMap != nil {
|
|
inputBytes, _ := json.Marshal(inputMap)
|
|
currentToolUse.inputBuffer.Reset()
|
|
currentToolUse.inputBuffer.Write(inputBytes)
|
|
}
|
|
|
|
// Tool use complete
|
|
if isStop && currentToolUse != nil {
|
|
fullInput := currentToolUse.inputBuffer.String()
|
|
|
|
// Repair and parse the accumulated JSON
|
|
repairedJSON := repairJSON(fullInput)
|
|
var finalInput map[string]interface{}
|
|
if err := json.Unmarshal([]byte(repairedJSON), &finalInput); err != nil {
|
|
log.Warnf("kiro: failed to parse accumulated tool input: %v, raw: %s", err, fullInput)
|
|
// Use empty input as fallback
|
|
finalInput = make(map[string]interface{})
|
|
}
|
|
|
|
toolUse := kiroToolUse{
|
|
ToolUseID: currentToolUse.toolUseID,
|
|
Name: currentToolUse.name,
|
|
Input: finalInput,
|
|
}
|
|
toolUses = append(toolUses, toolUse)
|
|
|
|
// Mark as processed
|
|
if processedIDs != nil {
|
|
processedIDs[currentToolUse.toolUseID] = true
|
|
}
|
|
|
|
log.Infof("kiro: completed tool use: %s (ID: %s)", currentToolUse.name, currentToolUse.toolUseID)
|
|
return toolUses, nil // Reset state
|
|
}
|
|
|
|
return toolUses, currentToolUse
|
|
}
|
|
|
|
// deduplicateToolUses removes duplicate tool uses based on toolUseId and content (name+arguments).
|
|
// This prevents both ID-based duplicates and content-based duplicates (same tool call with different IDs).
|
|
func deduplicateToolUses(toolUses []kiroToolUse) []kiroToolUse {
|
|
seenIDs := make(map[string]bool)
|
|
seenContent := make(map[string]bool) // Content-based deduplication (name + arguments)
|
|
var unique []kiroToolUse
|
|
|
|
for _, tu := range toolUses {
|
|
// Skip if we've already seen this ID
|
|
if seenIDs[tu.ToolUseID] {
|
|
log.Debugf("kiro: removing ID-duplicate tool use: %s (name: %s)", tu.ToolUseID, tu.Name)
|
|
continue
|
|
}
|
|
|
|
// Build content key for content-based deduplication
|
|
inputJSON, _ := json.Marshal(tu.Input)
|
|
contentKey := tu.Name + ":" + string(inputJSON)
|
|
|
|
// Skip if we've already seen this content (same name + arguments)
|
|
if seenContent[contentKey] {
|
|
log.Debugf("kiro: removing content-duplicate tool use: %s (id: %s)", tu.Name, tu.ToolUseID)
|
|
continue
|
|
}
|
|
|
|
seenIDs[tu.ToolUseID] = true
|
|
seenContent[contentKey] = true
|
|
unique = append(unique, tu)
|
|
}
|
|
|
|
return unique
|
|
}
|