Skip to content

feat: 添加 Kiro (CodeWhisperer) 提供商适配器#50

Merged
Bowl42 merged 10 commits intomainfrom
feat/kiro-adapter
Jan 16, 2026
Merged

feat: 添加 Kiro (CodeWhisperer) 提供商适配器#50
Bowl42 merged 10 commits intomainfrom
feat/kiro-adapter

Conversation

@Bowl42
Copy link
Collaborator

@Bowl42 Bowl42 commented Jan 16, 2026

User description

Summary

  • 实现完整的 Kiro 适配器,支持将 Claude API 请求转换为 CodeWhisperer 格式
  • 核心逻辑与 kiro2api 保持一致,包括 Token 估算、SSE 状态管理、停止原因判定等
  • 前端支持 Kiro 提供商的创建、编辑和 Token 导入(Social 和 IdC 认证方式)

后端实现

文件 功能
adapter.go 主适配器,实现 ProviderAdapter 接口
streaming.go AWS EventStream 解析和 Claude SSE 格式转换
sse_state_manager.go SSE 事件序列状态管理,自动创建缺失的 content_block_start
token_estimator.go Token 估算算法(对齐 kiro2api)
message_processor.go 事件流消息处理
stop_reason_manager.go 停止原因判定逻辑 (end_turn/tool_use/max_tokens)
converter.go Claude → CodeWhisperer 请求转换
parser.go AWS EventStream 二进制格式解析
usage_checker.go 使用量限制检查

前端实现

  • kiro-provider-view.tsx: Kiro 提供商详情视图
  • kiro-token-import.tsx: Token 导入组件

核心特性

  • ✅ 支持流式响应和 tool_use
  • ✅ 兼容 Claude Messages API 格式
  • ✅ Token 估算与 kiro2api 保持一致
  • ✅ 支持 Social 和 IdC 两种认证方式
  • ✅ 自动处理 ContentLengthExceeded → max_tokens 映射

Test plan

  • 验证 Kiro 提供商创建流程
  • 测试 Token 导入功能(Social 方式)
  • 测试流式对话响应
  • 测试 tool_use 功能
  • 验证 Token 估算准确性

PR Type

enhancement


Description

  • Implement Kiro adapter for CodeWhisperer

  • Supports Claude API to CodeWhisperer format conversion

  • Adds token management and SSE state handling

  • Integrates Kiro provider in the frontend


Diagram Walkthrough

flowchart LR
  A["Claude API Request"] -- "Convert to CodeWhisperer" --> B["CodeWhisperer API Request"]
  B -- "Get Response" --> C["Response to Claude API"]
Loading

File Walkthrough

Relevant files
Enhancement
9 files
adapter.go
Main adapter implementation                                                           
+651/-0 
converter.go
Conversion logic for requests                                                       
+493/-0 
message_processor.go
Message processing logic                                                                 
+447/-0 
sse_state_manager.go
SSE state management                                                                         
+423/-0 
streaming.go
Streaming response handling                                                           
+405/-0 
token_estimator.go
Token estimation logic                                                                     
+340/-0 
service.go
Token validation and quota management                                       
+268/-0 
types.go
Type definitions for Kiro                                                               
+222/-0 
database.go
Register Kiro handler                                                                       
+18/-0   
Additional files
29 files
main.go +7/-6     
conversation_id.go +151/-0 
model_mapping.go +143/-0 
parser.go +339/-0 
settings.go +87/-0   
stop_reason_manager.go +76/-0   
usage_checker.go +89/-0   
usage_types.go +111/-0 
server.go +4/-2     
model.go +21/-0   
kiro.go +130/-0 
admin.go +97/-0   
index.ts +1/-0     
use-providers.ts +12/-0   
index.css +2/-0     
theme.ts +2/-1     
http-transport.ts +25/-6   
index.ts +3/-0     
interface.ts +6/-0     
types.ts +42/-0   
kiro-provider-view.tsx +415/-0 
kiro-token-import.tsx +346/-0 
provider-create-flow.tsx +18/-2   
provider-edit-flow.tsx +21/-0   
provider-row.tsx +97/-32 
select-type-step.tsx +32/-2   
index.tsx +44/-53 
types.ts +70/-5   
vite.config.ts +1/-5     

实现完整的 Kiro 适配器,支持将 Claude API 请求转换为 CodeWhisperer 格式:

后端实现:
- adapter.go: 主适配器,实现 ProviderAdapter 接口
- streaming.go: AWS EventStream 解析和 Claude SSE 格式转换
- sse_state_manager.go: SSE 事件序列状态管理
- token_estimator.go: Token 估算算法(对齐 kiro2api)
- message_processor.go: 事件流消息处理
- stop_reason_manager.go: 停止原因判定逻辑
- converter.go: Claude → CodeWhisperer 请求转换
- parser.go: AWS EventStream 二进制格式解析
- usage_checker.go: 使用量限制检查

前端实现:
- kiro-provider-view.tsx: Kiro 提供商详情视图
- kiro-token-import.tsx: Token 导入组件(支持 Social 和 IdC 认证)

核心特性:
- 支持流式响应和 tool_use
- 兼容 Claude Messages API 格式
- Token 估算与 kiro2api 保持一致
- 支持多种认证方式
@github-actions
Copy link
Contributor

PR Reviewer Guide 🔍

Here are some key observations to aid the review process:

⏱️ Estimated effort to review: 5 🔵🔵🔵🔵🔵
🧪 No relevant tests
🔒 No security concerns identified
⚡ Recommended focus areas for review

Complexity

The KiroAdapter class is quite large and handles multiple responsibilities. Consider breaking it down into smaller components or services for better maintainability and readability.

package kiro

import (
	"bytes"
	"context"
	"crypto/tls"
	"encoding/json"
	"fmt"
	"io"
	"net"
	"net/http"
	"strings"
	"sync"
	"time"

	"github.com/awsl-project/maxx/internal/adapter/provider"
	ctxutil "github.com/awsl-project/maxx/internal/context"
	"github.com/awsl-project/maxx/internal/converter"
	"github.com/awsl-project/maxx/internal/domain"
)

func init() {
	provider.RegisterAdapterFactory("kiro", NewAdapter)
}

// TokenCache caches access tokens
type TokenCache struct {
	AccessToken string
	ExpiresAt   time.Time
}

// KiroAdapter handles communication with AWS CodeWhisperer/Q Developer
type KiroAdapter struct {
	provider   *domain.Provider
	tokenCache *TokenCache
	tokenMu    sync.RWMutex
	httpClient *http.Client
}

// NewAdapter creates a new Kiro adapter
func NewAdapter(p *domain.Provider) (provider.ProviderAdapter, error) {
	if p.Config == nil || p.Config.Kiro == nil {
		return nil, fmt.Errorf("provider %s missing kiro config", p.Name)
	}
	return &KiroAdapter{
		provider:   p,
		tokenCache: &TokenCache{},
		httpClient: newKiroHTTPClient(),
	}, nil
}

// SupportedClientTypes returns the list of client types this adapter natively supports
func (a *KiroAdapter) SupportedClientTypes() []domain.ClientType {
	return []domain.ClientType{domain.ClientTypeClaude}
}

// Execute performs the proxy request to the upstream CodeWhisperer API
func (a *KiroAdapter) Execute(ctx context.Context, w http.ResponseWriter, req *http.Request, provider *domain.Provider) error {
	requestModel := ctxutil.GetRequestModel(ctx)
	requestBody := ctxutil.GetRequestBody(ctx)
	stream := ctxutil.GetIsStream(ctx)

	config := provider.Config.Kiro

	// Get region (default to us-east-1)
	region := config.Region
	if region == "" {
		region = DefaultRegion
	}

	// Get access token
	accessToken, err := a.getAccessToken(ctx)
	if err != nil {
		return domain.NewProxyErrorWithMessage(err, true, "failed to get access token")
	}

	// Convert Claude request to CodeWhisperer format (传入 req 用于生成稳定会话ID)
	cwBody, mappedModel, err := ConvertClaudeToCodeWhisperer(requestBody, config.ModelMapping, req)
	if err != nil {
		return domain.NewProxyErrorWithMessage(err, true, fmt.Sprintf("failed to convert request: %v", err))
	}

	// Update attempt record with the mapped model
	if attempt := ctxutil.GetUpstreamAttempt(ctx); attempt != nil {
		attempt.MappedModel = mappedModel
	}

	// Build upstream URL
	upstreamURL := fmt.Sprintf(CodeWhispererURLTemplate, region)

	// Create upstream request
	upstreamReq, err := http.NewRequestWithContext(ctx, "POST", upstreamURL, bytes.NewReader(cwBody))
	if err != nil {
		return domain.NewProxyErrorWithMessage(err, true, "failed to create upstream request")
	}

	// Set headers (matching kiro2api/server/common.go:168-177)
	upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
	upstreamReq.Header.Set("Content-Type", "application/json")
	if stream {
		upstreamReq.Header.Set("Accept", "text/event-stream")
	}
	// 添加上游请求必需的header (硬编码匹配 kiro2api)
	upstreamReq.Header.Set("x-amzn-kiro-agent-mode", "spec")
	upstreamReq.Header.Set("x-amz-user-agent", "aws-sdk-js/1.0.18 KiroIDE-0.2.13-66c23a8c5d15afabec89ef9954ef52a119f10d369df04d548fc6c1eac694b0d1")
	upstreamReq.Header.Set("user-agent", "aws-sdk-js/1.0.18 ua/2.1 os/darwin#25.0.0 lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.18 m/E KiroIDE-0.2.13-66c23a8c5d15afabec89ef9954ef52a119f10d369df04d548fc6c1eac694b0d1")

	// Capture request info for attempt record
	if attempt := ctxutil.GetUpstreamAttempt(ctx); attempt != nil && attempt.RequestInfo == nil {
		attempt.RequestInfo = &domain.RequestInfo{
			Method:  upstreamReq.Method,
			URL:     upstreamURL,
			Headers: flattenHeaders(upstreamReq.Header),
			Body:    string(cwBody),
		}
	}

	// Execute request
	resp, err := a.httpClient.Do(upstreamReq)
	if err != nil {
		proxyErr := domain.NewProxyErrorWithMessage(domain.ErrUpstreamError, true, "failed to connect to upstream")
		proxyErr.IsNetworkError = true
		return proxyErr
	}
	defer resp.Body.Close()

	// Check for 401 (token expired) and retry once
	if resp.StatusCode == http.StatusUnauthorized {
		resp.Body.Close()

		// Invalidate token cache
		a.tokenMu.Lock()
		a.tokenCache = &TokenCache{}
		a.tokenMu.Unlock()

		// Get new token
		accessToken, err = a.getAccessToken(ctx)
		if err != nil {
			return domain.NewProxyErrorWithMessage(err, true, "failed to refresh access token")
		}

		// Retry request (matching kiro2api headers)
		upstreamReq, _ = http.NewRequestWithContext(ctx, "POST", upstreamURL, bytes.NewReader(cwBody))
		upstreamReq.Header.Set("Authorization", "Bearer "+accessToken)
		upstreamReq.Header.Set("Content-Type", "application/json")
		if stream {
			upstreamReq.Header.Set("Accept", "text/event-stream")
		}
		upstreamReq.Header.Set("x-amzn-kiro-agent-mode", "spec")
		upstreamReq.Header.Set("x-amz-user-agent", "aws-sdk-js/1.0.18 KiroIDE-0.2.13-66c23a8c5d15afabec89ef9954ef52a119f10d369df04d548fc6c1eac694b0d1")
		upstreamReq.Header.Set("user-agent", "aws-sdk-js/1.0.18 ua/2.1 os/darwin#25.0.0 lang/js md/nodejs#20.16.0 api/codewhispererstreaming#1.0.18 m/E KiroIDE-0.2.13-66c23a8c5d15afabec89ef9954ef52a119f10d369df04d548fc6c1eac694b0d1")

		resp, err = a.httpClient.Do(upstreamReq)
		if err != nil {
			proxyErr := domain.NewProxyErrorWithMessage(domain.ErrUpstreamError, true, "failed to connect to upstream after token refresh")
			proxyErr.IsNetworkError = true
			return proxyErr
		}
		defer resp.Body.Close()
	}

	// Check for error response
	if resp.StatusCode >= 400 {
		body, _ := io.ReadAll(resp.Body)

		// Capture error response info
		if attempt := ctxutil.GetUpstreamAttempt(ctx); attempt != nil {
			attempt.ResponseInfo = &domain.ResponseInfo{
				Status:  resp.StatusCode,
				Headers: flattenHeaders(resp.Header),
				Body:    string(body),
			}
		}

		proxyErr := domain.NewProxyErrorWithMessage(
			fmt.Errorf("upstream error: %s", string(body)),
			isRetryableStatusCode(resp.StatusCode),
			fmt.Sprintf("upstream returned status %d", resp.StatusCode),
		)
		proxyErr.HTTPStatusCode = resp.StatusCode
		proxyErr.IsServerError = resp.StatusCode >= 500 && resp.StatusCode < 600

		return proxyErr
	}

	// Handle response (CodeWhisperer always returns streaming EventStream)
	// Calculate input tokens for the request
	inputTokens := calculateInputTokens(requestBody)

	if stream {
		return a.handleStreamResponse(ctx, w, resp, requestModel, inputTokens)
	}
	return a.handleCollectedStreamResponse(ctx, w, resp, requestModel, inputTokens)
}

// getAccessToken gets a valid access token, refreshing if necessary
func (a *KiroAdapter) getAccessToken(ctx context.Context) (string, error) {
	// Check cache
	a.tokenMu.RLock()
	if a.tokenCache.AccessToken != "" && time.Now().Before(a.tokenCache.ExpiresAt) {
		token := a.tokenCache.AccessToken
		a.tokenMu.RUnlock()
		return token, nil
	}
	a.tokenMu.RUnlock()

	// Refresh token
	config := a.provider.Config.Kiro
	tokenInfo, err := a.refreshToken(ctx, config)
	if err != nil {
		return "", err
	}

	// Cache token
	a.tokenMu.Lock()
	a.tokenCache = &TokenCache{
		AccessToken: tokenInfo.AccessToken,
		ExpiresAt:   time.Now().Add(time.Duration(tokenInfo.ExpiresIn-60) * time.Second), // 60s buffer
	}
	a.tokenMu.Unlock()

	return tokenInfo.AccessToken, nil
}

// refreshToken refreshes the access token based on auth method
func (a *KiroAdapter) refreshToken(ctx context.Context, config *domain.ProviderConfigKiro) (*RefreshResponse, error) {
	switch config.AuthMethod {
	case "social":
		return a.refreshSocialToken(ctx, config.RefreshToken)
	case "idc":
		return a.refreshIdCToken(ctx, config)
	default:
		return nil, fmt.Errorf("unsupported auth method: %s", config.AuthMethod)
	}
}

// refreshSocialToken refreshes token using Social authentication
// 匹配 kiro2api/auth/refresh.go:27-69
func (a *KiroAdapter) refreshSocialToken(ctx context.Context, refreshToken string) (*RefreshResponse, error) {
	reqBody, err := json.Marshal(RefreshRequest{RefreshToken: refreshToken})
	if err != nil {
		return nil, fmt.Errorf("failed to marshal request: %w", err)
	}

	req, err := http.NewRequestWithContext(ctx, "POST", RefreshTokenURL, bytes.NewReader(reqBody))
	if err != nil {
		return nil, fmt.Errorf("failed to create request: %w", err)
	}
	req.Header.Set("Content-Type", "application/json")

	// 使用共享 HTTP 客户端 (匹配 kiro2api)
	resp, err := a.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("refresh failed: status %d, response: %s", resp.StatusCode, string(body))
	}

	var result RefreshResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("failed to decode response: %w", err)
	}

	return &result, nil
}

// refreshIdCToken refreshes token using IdC (Identity Center) authentication
// 匹配 kiro2api/auth/refresh.go:72-131
func (a *KiroAdapter) refreshIdCToken(ctx context.Context, config *domain.ProviderConfigKiro) (*RefreshResponse, error) {
	reqBody, err := json.Marshal(IdcRefreshRequest{
		ClientId:     config.ClientID,
		ClientSecret: config.ClientSecret,
		GrantType:    "refresh_token",
		RefreshToken: config.RefreshToken,
	})
	if err != nil {
		return nil, fmt.Errorf("failed to marshal IdC request: %w", err)
	}

	// 使用硬编码 URL (匹配 kiro2api/config/config.go:22)
	req, err := http.NewRequestWithContext(ctx, "POST", IdcRefreshTokenURL, bytes.NewReader(reqBody))
	if err != nil {
		return nil, fmt.Errorf("failed to create IdC request: %w", err)
	}

	// Set IdC specific headers (匹配 kiro2api/auth/refresh.go:92-100)
	req.Header.Set("Content-Type", "application/json")
	req.Header.Set("Host", "oidc.us-east-1.amazonaws.com")
	req.Header.Set("Connection", "keep-alive")
	req.Header.Set("x-amz-user-agent", "aws-sdk-js/3.738.0 ua/2.1 os/other lang/js md/browser#unknown_unknown api/sso-oidc#3.738.0 m/E KiroIDE")
	req.Header.Set("Accept", "*/*")
	req.Header.Set("Accept-Language", "*")
	req.Header.Set("sec-fetch-mode", "cors")
	req.Header.Set("User-Agent", "node")
	req.Header.Set("Accept-Encoding", "br, gzip, deflate")

	// 使用共享 HTTP 客户端 (匹配 kiro2api)
	resp, err := a.httpClient.Do(req)
	if err != nil {
		return nil, fmt.Errorf("IdC request failed: %w", err)
	}
	defer resp.Body.Close()

	if resp.StatusCode != http.StatusOK {
		body, _ := io.ReadAll(resp.Body)
		return nil, fmt.Errorf("IdC refresh failed: status %d, response: %s", resp.StatusCode, string(body))
	}

	var result RefreshResponse
	if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
		return nil, fmt.Errorf("failed to decode IdC response: %w", err)
	}

	return &result, nil
}

// handleStreamResponse handles streaming EventStream response
func (a *KiroAdapter) handleStreamResponse(ctx context.Context, w http.ResponseWriter, resp *http.Response, requestModel string, inputTokens int) error {
	attempt := ctxutil.GetUpstreamAttempt(ctx)

	// Capture response info (will be updated with actual body at the end)
	if attempt != nil {
		attempt.ResponseInfo = &domain.ResponseInfo{
			Status:  resp.StatusCode,
			Headers: flattenHeaders(resp.Header),
			Body:    "[streaming]",
		}
	}

	// Set streaming headers
	w.Header().Set("Content-Type", "text/event-stream")
	w.Header().Set("Cache-Control", "no-cache")
	w.Header().Set("Connection", "keep-alive")
	w.Header().Set("X-Accel-Buffering", "no")

	flusher, ok := w.(http.Flusher)
	if !ok {
		return domain.NewProxyErrorWithMessage(domain.ErrUpstreamError, false, "streaming not supported")
	}

	// Create streaming state
	streamState := NewClaudeStreamingState(requestModel, inputTokens)

	// Capture SSE output for attempt record
	var sseBuffer strings.Builder

	// Read and process EventStream data
	buf := make([]byte, 4096)
	for {
		select {
		case <-ctx.Done():
			// Update attempt with captured body before returning
			a.updateAttemptBody(ctx, attempt, sseBuffer.String())
			return domain.NewProxyErrorWithMessage(ctx.Err(), false, "client disconnected")
		default:
		}

		n, err := resp.Body.Read(buf)
		if n > 0 {
			// Process EventStream data and convert to Claude SSE
			output := streamState.ProcessEventStreamData(buf[:n])
			if len(output) > 0 {
				// Capture for attempt record
				sseBuffer.Write(output)

				_, writeErr := w.Write(output)
				if writeErr != nil {
					a.updateAttemptBody(ctx, attempt, sseBuffer.String())
					return domain.NewProxyErrorWithMessage(writeErr, false, "client disconnected")
				}
				flusher.Flush()
			}
		}

		if err != nil {
			if err == io.EOF {
				// Send force stop if needed
				if forceStop := streamState.EmitForceStop(); len(forceStop) > 0 {
					sseBuffer.Write(forceStop)
					_, _ = w.Write(forceStop)
					flusher.Flush()
				}
				// Update attempt with captured body
				a.updateAttemptBody(ctx, attempt, sseBuffer.String())
				return nil
			}
			// Upstream connection closed
			if ctx.Err() != nil {
				if forceStop := streamState.EmitForceStop(); len(forceStop) > 0 {
					sseBuffer.Write(forceStop)
					_, _ = w.Write(forceStop)
					flusher.Flush()
				}
				a.updateAttemptBody(ctx, attempt, sseBuffer.String())
				return domain.NewProxyErrorWithMessage(ctx.Err(), false, "client disconnected")
			}
			// Send force stop
			if forceStop := streamState.EmitForceStop(); len(forceStop) > 0 {
				sseBuffer.Write(forceStop)
				_, _ = w.Write(forceStop)
				flusher.Flush()
			}
			// Update attempt with captured body
			a.updateAttemptBody(ctx, attempt, sseBuffer.String())
			return nil
		}
	}
}

// updateAttemptBody updates the attempt record with the captured response body
func (a *KiroAdapter) updateAttemptBody(ctx context.Context, attempt *domain.ProxyUpstreamAttempt, body string) {
	if attempt != nil && attempt.ResponseInfo != nil {
		attempt.ResponseInfo.Body = body
		if bc := ctxutil.GetBroadcaster(ctx); bc != nil {
			bc.BroadcastProxyUpstreamAttempt(attempt)
		}
	}
}

// handleCollectedStreamResponse collects streaming response into a single JSON response
func (a *KiroAdapter) handleCollectedStreamResponse(ctx context.Context, w http.ResponseWriter, resp *http.Response, requestModel string, inputTokens int) error {
	attempt := ctxutil.GetUpstreamAttempt(ctx)

	if attempt != nil {
		attempt.ResponseInfo = &domain.ResponseInfo{
			Status:  resp.StatusCode,
			Headers: flattenHeaders(resp.Header),
			Body:    "[stream-collected]",
		}
	}

	// Create streaming state
	streamState := NewClaudeStreamingState(requestModel, inputTokens)

	// Collect all SSE events
	var sseBuffer strings.Builder
	buf := make([]byte, 4096)

	for {
		select {
		case <-ctx.Done():
			return domain.NewProxyErrorWithMessage(ctx.Err(), false, "client disconnected")
		default:
		}

		n, err := resp.Body.Read(buf)
		if n > 0 {
			output := streamState.ProcessEventStreamData(buf[:n])
			if len(output) > 0 {
				sseBuffer.Write(output)
			}
		}

		if err != nil {
			if err == io.EOF {
				break
			}
			return domain.NewProxyErrorWithMessage(domain.ErrUpstreamError, true, "failed to read upstream stream")
		}
	}

	// Send force stop if needed
	if forceStop := streamState.EmitForceStop(); len(forceStop) > 0 {
		sseBuffer.Write(forceStop)
	}

	// Update attempt with collected body
	if attempt != nil && attempt.ResponseInfo != nil {
		attempt.ResponseInfo.Body = sseBuffer.String()
		if bc := ctxutil.GetBroadcaster(ctx); bc != nil {
			bc.BroadcastProxyUpstreamAttempt(attempt)
		}
	}

	// Convert collected SSE to JSON response
	responseBody, err := collectClaudeSSEToJSON(sseBuffer.String())
	if err != nil {
		return domain.NewProxyErrorWithMessage(domain.ErrFormatConversion, false, "failed to collect streamed response")
	}

	w.Header().Set("Content-Type", "application/json")
	w.WriteHeader(resp.StatusCode)
	_, _ = w.Write(responseBody)
	return nil
}

// collectClaudeSSEToJSON converts Claude SSE events to a single JSON response
func collectClaudeSSEToJSON(sseContent string) ([]byte, error) {
	var messageID, model, stopReason string
	var content []map[string]interface{}
	var inputTokens, outputTokens int

	lines := strings.Split(sseContent, "\n")
	for _, line := range lines {
		if !strings.HasPrefix(line, "data: ") {
			continue
		}
		data := strings.TrimPrefix(line, "data: ")
		if data == "" {
			continue
		}

		var event map[string]interface{}
		if err := json.Unmarshal([]byte(data), &event); err != nil {
			continue
		}

		eventType, _ := event["type"].(string)
		switch eventType {
		case "message_start":
			if msg, ok := event["message"].(map[string]interface{}); ok {
				messageID, _ = msg["id"].(string)
				model, _ = msg["model"].(string)
			}

		case "content_block_start":
			if block, ok := event["content_block"].(map[string]interface{}); ok {
				content = append(content, block)
			}

		case "content_block_delta":
			if delta, ok := event["delta"].(map[string]interface{}); ok {
				index := int(event["index"].(float64))
				if index < len(content) {
					deltaType, _ := delta["type"].(string)
					switch deltaType {
					case "text_delta":
						if text, ok := delta["text"].(string); ok {
							if existing, ok := content[index]["text"].(string); ok {
								content[index]["text"] = existing + text
							} else {
								content[index]["text"] = text
							}
						}
					case "input_json_delta":
						if partialJSON, ok := delta["partial_json"].(string); ok {
							if existing, ok := content[index]["input"].(string); ok {
								content[index]["input"] = existing + partialJSON
							} else {
								content[index]["input"] = partialJSON
							}
						}
					}
				}
			}

		case "message_delta":
			if delta, ok := event["delta"].(map[string]interface{}); ok {
				stopReason, _ = delta["stop_reason"].(string)
			}
			if usage, ok := event["usage"].(map[string]interface{}); ok {
				if ot, ok := usage["output_tokens"].(float64); ok {
					outputTokens = int(ot)
				}
			}
		}
	}

	// Parse tool_use input JSON strings
	for i := range content {
		if content[i]["type"] == "tool_use" {
			if inputStr, ok := content[i]["input"].(string); ok {
				var inputObj map[string]interface{}
				if err := json.Unmarshal([]byte(inputStr), &inputObj); err == nil {
					content[i]["input"] = inputObj
				}
			}
		}
	}

	response := map[string]interface{}{
		"id":            messageID,
		"type":          "message",
		"role":          "assistant",
		"content":       content,
		"model":         model,
		"stop_reason":   stopReason,
		"stop_sequence": nil,
		"usage": map[string]interface{}{
			"input_tokens":  inputTokens,
			"output_tokens": outputTokens,
		},
	}

	return json.Marshal(response)
}

// flattenHeaders converts http.Header to map[string]string
func flattenHeaders(h http.Header) map[string]string {
	result := make(map[string]string)
	for k, v := range h {
		if len(v) > 0 {
			result[k] = v[0]
		}
	}
	return result
}

// calculateInputTokens 计算请求的 input token 数量
func calculateInputTokens(requestBody []byte) int {
	var claudeReq converter.ClaudeRequest
	if err := json.Unmarshal(requestBody, &claudeReq); err != nil {
		return 0
	}

	estimator := NewTokenEstimator()
	return estimator.EstimateInputTokens(&claudeReq)
}

// isRetryableStatusCode checks if the status code is retryable
func isRetryableStatusCode(status int) bool {
	return status == http.StatusTooManyRequests ||
		status == http.StatusRequestTimeout ||
		status >= 500
}

// newKiroHTTPClient creates an HTTP client for Kiro/CodeWhisperer API
// 匹配 kiro2api/utils/client.go:26-52
func newKiroHTTPClient() *http.Client {
	return &http.Client{
		Transport: &http.Transport{
			// 连接建立配置 (匹配 kiro2api)
			DialContext: (&net.Dialer{
				Timeout:   15 * time.Second,
				KeepAlive: 30 * time.Second,
				DualStack: true,
			}).DialContext,

			// TLS配置 (匹配 kiro2api)
			TLSHandshakeTimeout: 15 * time.Second,
			TLSClientConfig: &tls.Config{
				MinVersion: tls.VersionTLS12,
				MaxVersion: tls.VersionTLS13,
				CipherSuites: []uint16{
					tls.TLS_AES_256_GCM_SHA384,
					tls.TLS_CHACHA20_POLY1305_SHA256,
					tls.TLS_AES_128_GCM_SHA256,
				},
			},

			// HTTP配置 (匹配 kiro2api)
			ForceAttemptHTTP2:  false,
			DisableCompression: false,
		},
		// 注意: kiro2api 不设置整体 Timeout
	}
}
Error Handling

The error handling in the ConvertClaudeToCodeWhisperer function could be improved. Ensure that all potential errors are logged or handled appropriately to avoid silent failures.

// ConvertClaudeToCodeWhisperer 将 Claude 请求转换为 CodeWhisperer 请求
// req 参数用于生成稳定的会话ID (匹配 kiro2api)
func ConvertClaudeToCodeWhisperer(requestBody []byte, modelMapping map[string]string, req *http.Request) ([]byte, string, error) {
	var claudeReq converter.ClaudeRequest
	if err := json.Unmarshal(requestBody, &claudeReq); err != nil {
		return nil, "", fmt.Errorf("解析 Claude 请求失败: %w", err)
	}

	// 映射模型
	mappedModel := MapModel(claudeReq.Model, modelMapping)
	if mappedModel == "" {
		return nil, "", fmt.Errorf("不支持的模型: %s", claudeReq.Model)
	}

	// 构建 CodeWhisperer 请求
	cwReq := CodeWhispererRequest{}

	// 设置代理相关字段 (使用稳定的ID生成器,匹配 kiro2api)
	cwReq.ConversationState.AgentContinuationId = GenerateStableAgentContinuationID(req)
	cwReq.ConversationState.AgentTaskType = "vibe"
	cwReq.ConversationState.ChatTriggerType = determineChatTriggerType(claudeReq)
	cwReq.ConversationState.ConversationId = GenerateStableConversationID(req)

	// 处理消息
	if len(claudeReq.Messages) == 0 {
		return nil, "", fmt.Errorf("消息列表为空")
	}

	// 处理最后一条消息作为 currentMessage
	lastMessage := claudeReq.Messages[len(claudeReq.Messages)-1]
	textContent, images, toolResults, err := processMessageContent(lastMessage.Content)
	if err != nil {
		return nil, "", fmt.Errorf("处理消息内容失败: %w", err)
	}

	// 设置当前消息
	cwReq.ConversationState.CurrentMessage.UserInputMessage.Content = textContent
	// 确保 Images 字段始终是数组,即使为空 (matching kiro2api)
	if len(images) > 0 {
		cwReq.ConversationState.CurrentMessage.UserInputMessage.Images = images
	} else {
		cwReq.ConversationState.CurrentMessage.UserInputMessage.Images = []CodeWhispererImage{}
	}
	cwReq.ConversationState.CurrentMessage.UserInputMessage.ModelId = mappedModel
	cwReq.ConversationState.CurrentMessage.UserInputMessage.Origin = "AI_EDITOR"

	// 如果有工具结果,设置到 context 中
	if len(toolResults) > 0 {
		cwReq.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext.ToolResults = toolResults
		// 对于包含 tool_result 的请求,content 应该为空字符串
		cwReq.ConversationState.CurrentMessage.UserInputMessage.Content = ""
	}

	// 处理工具定义
	if len(claudeReq.Tools) > 0 {
		tools := convertTools(claudeReq.Tools)
		cwReq.ConversationState.CurrentMessage.UserInputMessage.UserInputMessageContext.Tools = tools
	}

	// 构建历史消息
	history := buildHistory(claudeReq, mappedModel)
	cwReq.ConversationState.History = history

	// 验证请求完整性 (matching kiro2api validateCodeWhispererRequest)
	if err := validateCodeWhispererRequest(&cwReq); err != nil {
		return nil, "", fmt.Errorf("请求验证失败: %w", err)
	}

	// 序列化请求
	result, err := json.Marshal(cwReq)
	if err != nil {
		return nil, "", fmt.Errorf("序列化 CodeWhisperer 请求失败: %w", err)
	}

	return result, mappedModel, nil
}
Token Estimation Logic

The token estimation logic appears to be complex and may benefit from additional comments or documentation to clarify the calculations and assumptions made.

package kiro

import (
	"encoding/json"
	"math"
	"strings"

	"github.com/awsl-project/maxx/internal/converter"
)

// TokenEstimator 本地 token 估算器
// 匹配 kiro2api/utils/token_estimator.go
type TokenEstimator struct{}

// NewTokenEstimator 创建 token 估算器实例
func NewTokenEstimator() *TokenEstimator {
	return &TokenEstimator{}
}

// EstimateInputTokens 估算请求的 input token 数量
func (e *TokenEstimator) EstimateInputTokens(req *converter.ClaudeRequest) int {
	totalTokens := 0

	// 1. 系统提示词
	if req.System != nil {
		systemContent := extractSystemContentForTokens(req.System)
		if systemContent != "" {
			totalTokens += e.EstimateTextTokens(systemContent)
			totalTokens += 2 // 系统提示的固定开销
		}
	}

	// 2. 消息内容
	for _, msg := range req.Messages {
		// 角色标记开销
		totalTokens += 3

		// 消息内容
		switch content := msg.Content.(type) {
		case string:
			totalTokens += e.EstimateTextTokens(content)
		case []interface{}:
			for _, block := range content {
				totalTokens += e.estimateContentBlock(block)
			}
		}
	}

	// 3. 工具定义
	// 匹配 kiro2api/utils/token_estimator.go:70-145
	toolCount := len(req.Tools)
	if toolCount > 0 {
		var baseToolsOverhead int
		var perToolOverhead int

		if toolCount == 1 {
			baseToolsOverhead = 0
			perToolOverhead = 320
		} else if toolCount <= 5 {
			baseToolsOverhead = 100
			perToolOverhead = 120
		} else {
			baseToolsOverhead = 180
			perToolOverhead = 60
		}

		totalTokens += baseToolsOverhead

		for _, tool := range req.Tools {
			// 工具名称
			nameTokens := e.estimateToolName(tool.Name)
			totalTokens += nameTokens

			// 工具描述
			totalTokens += e.EstimateTextTokens(tool.Description)

			// 工具 schema(JSON Schema)
			if tool.InputSchema != nil {
				if jsonBytes, err := json.Marshal(tool.InputSchema); err == nil {
					// Schema 编码密度:根据工具数量自适应
					var schemaCharsPerToken float64
					if toolCount == 1 {
						schemaCharsPerToken = 1.9
					} else if toolCount <= 5 {
						schemaCharsPerToken = 2.2
					} else {
						schemaCharsPerToken = 2.5
					}

					schemaLen := len(jsonBytes)
					schemaTokens := int(math.Ceil(float64(schemaLen) / schemaCharsPerToken))

					// $schema 字段 URL 开销
					if strings.Contains(string(jsonBytes), "$schema") {
						if toolCount == 1 {
							schemaTokens += 10
						} else {
							schemaTokens += 5
						}
					}

					// 最小 schema 开销
					minSchemaTokens := 50
					if toolCount > 5 {
						minSchemaTokens = 30
					}
					if schemaTokens < minSchemaTokens {
						schemaTokens = minSchemaTokens
					}

					totalTokens += schemaTokens
				}
			}

			totalTokens += perToolOverhead
		}
	}

	// 4. 基础请求开销
	totalTokens += 4

	return totalTokens
}

// EstimateTextTokens 估算纯文本的 token 数量
// 匹配 kiro2api/utils/token_estimator.go:EstimateTextTokens
func (e *TokenEstimator) EstimateTextTokens(text string) int {
	if text == "" {
		return 0
	}

	runes := []rune(text)
	runeCount := len(runes)

	if runeCount == 0 {
		return 0
	}

	// 统计中文字符数
	chineseChars := 0
	for _, r := range runes {
		if r >= 0x4E00 && r <= 0x9FFF {
			chineseChars++
		}
	}

	nonChineseChars := runeCount - chineseChars
	isPureChinese := (nonChineseChars == 0)

	// 中文 token 计算
	chineseTokens := 0
	if chineseChars > 0 {
		if isPureChinese {
			chineseTokens = 1 + chineseChars
		} else {
			chineseTokens = chineseChars
		}
	}

	// 英文/数字字符
	nonChineseTokens := 0
	if nonChineseChars > 0 {
		var charsPerToken float64
		if nonChineseChars < 50 {
			charsPerToken = 2.8
		} else if nonChineseChars < 100 {
			charsPerToken = 2.6
		} else {
			charsPerToken = 2.5
		}

		nonChineseTokens = int(math.Ceil(float64(nonChineseChars) / charsPerToken))
		if nonChineseTokens < 1 {
			nonChineseTokens = 1
		}
	}

	tokens := chineseTokens + nonChineseTokens

	// 长文本压缩系数
	if runeCount >= 1000 {
		tokens = int(float64(tokens) * 0.60)
	} else if runeCount >= 500 {
		tokens = int(float64(tokens) * 0.70)
	} else if runeCount >= 300 {
		tokens = int(float64(tokens) * 0.80)
	} else if runeCount >= 200 {
		tokens = int(float64(tokens) * 0.85)
	} else if runeCount >= 100 {
		tokens = int(float64(tokens) * 0.90)
	} else if runeCount >= 50 {
		tokens = int(float64(tokens) * 0.95)
	}

	if tokens < 1 {
		tokens = 1
	}

	return tokens
}

// estimateToolName 估算工具名称的 token 数量
func (e *TokenEstimator) estimateToolName(name string) int {
	if name == "" {
		return 0
	}

	baseTokens := (len(name) + 1) / 2

	underscoreCount := strings.Count(name, "_")
	underscorePenalty := underscoreCount

	camelCaseCount := 0
	for _, r := range name {
		if r >= 'A' && r <= 'Z' {
			camelCaseCount++
		}
	}
	camelCasePenalty := camelCaseCount / 2

	totalTokens := baseTokens + underscorePenalty + camelCasePenalty
	if totalTokens < 2 {
		totalTokens = 2
	}

	return totalTokens
}

// estimateContentBlock 估算单个内容块的 token 数量
// 匹配 kiro2api/utils/token_estimator.go:estimateContentBlock
func (e *TokenEstimator) estimateContentBlock(block any) int {
	blockMap, ok := block.(map[string]interface{})
	if !ok {
		return 10 // 未知格式,保守估算
	}

	blockType, _ := blockMap["type"].(string)

	switch blockType {
	case "text":
		// 文本块
		if text, ok := blockMap["text"].(string); ok {
			return e.EstimateTextTokens(text)
		}
		return 10

	case "image":
		// 图片:官方文档显示约 1000-2000 tokens
		return 1500

	case "document":
		// 文档:根据大小估算(简化处理)
		return 500

	case "tool_use":
		// 工具调用(在历史消息中的 assistant 消息可能包含)
		toolName, _ := blockMap["name"].(string)
		toolInput, _ := blockMap["input"].(map[string]any)
		return e.EstimateToolUseTokens(toolName, toolInput)

	case "tool_result":
		// 工具执行结果
		content := blockMap["content"]
		switch c := content.(type) {
		case string:
			return e.EstimateTextTokens(c)
		case []any:
			total := 0
			for _, item := range c {
				total += e.estimateContentBlock(item)
			}
			return total
		default:
			return 50
		}

	default:
		// 未知类型:JSON 长度估算
		if jsonBytes, err := json.Marshal(block); err == nil {
			return len(jsonBytes) / 4
		}
		return 10
	}
}

// EstimateToolUseTokens 精确估算工具调用的 token 数量
// 匹配 kiro2api/utils/token_estimator.go:EstimateToolUseTokens
func (e *TokenEstimator) EstimateToolUseTokens(toolName string, toolInput map[string]any) int {
	totalTokens := 0

	// 1. JSON 结构字段开销
	// "type": "tool_use" ≈ 3 tokens
	totalTokens += 3

	// "id": "toolu_01A09q90qw90lq917835lq9" ≈ 8 tokens
	totalTokens += 8

	// "name" 关键字 ≈ 1 token
	totalTokens += 1

	// 2. 工具名称(使用与输入侧相同的精确方法)
	nameTokens := e.estimateToolName(toolName)
	totalTokens += nameTokens

	// 3. "input" 关键字 ≈ 1 token
	totalTokens += 1

	// 4. 参数内容(JSON 序列化)
	// 匹配 kiro2api: 使用标准的 4 字符/token 比率
	if len(toolInput) > 0 {
		if jsonBytes, err := json.Marshal(toolInput); err == nil {
			inputTokens := len(jsonBytes) / 4
			totalTokens += inputTokens
		}
	} else {
		// 空参数对象 {} ≈ 1 token
		totalTokens += 1
	}

	return totalTokens
}

// extractSystemContentForTokens 提取系统消息内容用于 token 计算
func extractSystemContentForTokens(system interface{}) string {
	switch s := system.(type) {
	case string:
		return s
	case []interface{}:
		var parts []string
		for _, item := range s {
			if block, ok := item.(map[string]interface{}); ok {
				if text, ok := block["text"].(string); ok {
					parts = append(parts, text)
				}
			}
		}
		return strings.Join(parts, "\n")
	}
	return ""
}

@github-actions
Copy link
Contributor

PR Code Suggestions ✨

Explore these optional code suggestions:

CategorySuggestion                                                                                                                                    Impact
Possible issue
Validate access token after retrieval

Ensure that the access token is validated after retrieval. If the token is invalid,
handle the error appropriately to avoid using an expired or invalid token in
subsequent requests.

internal/adapter/provider/kiro/adapter.go [72]

 accessToken, err := a.getAccessToken(ctx)
+if err != nil || accessToken == "" {
+    return domain.NewProxyErrorWithMessage(err, true, "invalid access token")
+}
Suggestion importance[1-10]: 8

__

Why: Ensuring the validity of the access token is crucial to prevent unauthorized access and potential errors in subsequent requests. This suggestion addresses a critical aspect of security and functionality.

Medium
Handle rate limit responses appropriately

Add specific handling for 429 (Too Many Requests) status code to implement a retry
mechanism or backoff strategy, as this status indicates that the client should slow
down the request rate.

internal/adapter/provider/kiro/adapter.go [162]

 if resp.StatusCode >= 400 {
+    if resp.StatusCode == http.StatusTooManyRequests {
+        return domain.NewProxyErrorWithMessage(domain.ErrRateLimitExceeded, true, "rate limit exceeded")
+    }
Suggestion importance[1-10]: 7

__

Why: Adding specific handling for the 429 status code improves the robustness of the application by implementing a retry mechanism, which is essential for maintaining service reliability under load.

Medium
Validate initialization of ConversationState

Ensure that the ConversationState struct is properly initialized before use. If any
of the fields are expected to be populated from an external source, consider adding
validation to check for nil or empty values to prevent potential runtime errors.

internal/adapter/provider/kiro/types.go [35-55]

 ...
 // CodeWhispererRequest 表示 CodeWhisperer API 的请求结构
 type CodeWhispererRequest struct {
     ConversationState struct {
         AgentContinuationId string `json:"agentContinuationId"`
         AgentTaskType       string `json:"agentTaskType"`
         ChatTriggerType     string `json:"chatTriggerType"`
         CurrentMessage      struct {
             UserInputMessage struct {
                 UserInputMessageContext struct {
                     ToolResults []ToolResult        `json:"toolResults,omitempty"`
                     Tools       []CodeWhispererTool `json:"tools,omitempty"`
                 } `json:"userInputMessageContext"`
                 Content string               `json:"content"`
                 ModelId string               `json:"modelId"`
                 Images  []CodeWhispererImage `json:"images"`
                 Origin  string               `json:"origin"`
             } `json:"userInputMessage"`
         } `json:"currentMessage"`
         ConversationId string `json:"conversationId"`
         History        []any  `json:"history"`
     } `json:"conversationState"`
 }
+// Ensure proper initialization
+if req.ConversationState.AgentContinuationId == "" {
+    return errors.New("AgentContinuationId must not be empty")
+}
 ...
Suggestion importance[1-10]: 5

__

Why: The suggestion addresses potential runtime errors by ensuring that the ConversationState struct is properly initialized. However, it does not directly modify the code and only suggests validation, which is a good practice but not critical.

Low

- History 字段空值处理:只在满足条件时设置 History,否则保持 nil
  (kiro2api 输出 "history": null,而非空数组)
- InputSchema 转换:直接使用原始值,不做浅拷贝
  (与 kiro2api converter/codewhisperer.go:337-339 对齐)

这些差异可能是导致被封号的检测点。
- provider-row.tsx: 使用条件渲染替代 getDisplayIcon 动态组件创建
- eslint.config.js: 忽略 wailsjs 自动生成的代码
对齐 kiro2api 的模块化架构设计:

新增模块:
- compliant_event_stream_parser.go: 符合规范的 EventStream 解析器
- event_stream_types.go: EventStream 类型定义
- json_helpers.go: JSON 辅助函数
- message_event_handlers.go: 消息事件处理器
- robust_parser.go: 健壮的二进制解析器
- session_manager.go: 会话状态管理
- stream_writer.go: SSE 流写入器
- streaming_json_aggregator.go: JSON 流聚合器
- tool_lifecycle_manager.go: 工具生命周期管理

重构模块:
- adapter.go: 简化主适配器逻辑
- message_processor.go: 精简消息处理
- streaming.go: 重构流式处理状态机

移除:
- parser.go: 拆分为更细粒度的模块
- json_helpers.go: 添加 FastMarshal/SafeMarshal 等接口 (匹配 kiro2api)
- session_manager.go: 移除重复的 generateUUID,使用共享实现
- conversation_id.go: UUID 生成改为标准 UUID v4 格式 (匹配 kiro2api)
  - 使用 crypto/rand 而非 MD5 哈希
  - 正确设置 Version 4 和 Variant bits
与 kiro2api 保持一致,使用高性能 JSON 库:
- FastestConfig: 最快配置,用于性能关键路径
- SafeConfig: 标准配置,带更多验证
将 Kiro 适配器中所有 encoding/json 替换为 bytedance/sonic:
- adapter.go: Token 刷新、响应处理
- converter.go: 请求转换
- streaming.go: SSE 格式化
- message_event_handlers.go: 事件解析
- tool_lifecycle_manager.go: 工具参数处理
- streaming_json_aggregator.go: JSON 验证
- settings.go: 模型映射解析
- token_estimator.go: Schema 序列化
- service.go: Token 验证
- usage_checker.go: 配额解析

使用 json_helpers.go 中的高性能函数:
- FastMarshal/FastUnmarshal: 性能关键路径
- SafeMarshal/SafeUnmarshal: 需要验证的路径

Session ID 缓存机制确认正常工作:
- globalConversationIDManager 单例带缓存
- 基于小时时间窗口的缓存策略
关键修复:将 FastMarshal 改为 SafeMarshal 以匹配 kiro2api

差异说明:
- FastMarshal 使用 sonic.ConfigFastest (性能优先)
- SafeMarshal 使用 sonic.ConfigStd (标准模式,带验证)

kiro2api 在 server/common.go:138 使用 SafeMarshal 序列化请求,
这可能影响 JSON 输出格式,导致被 AWS 检测为非官方客户端。

此修复确保请求序列化格式与 kiro2api 完全一致。
参考 kiro-account-manager 的设计,实现分离式刷新策略:

核心改进:
1. 添加 5 分钟 usage 缓存,避免频繁调用 getUsageLimits API
2. 分离快速刷新(只刷新 token)和完整刷新(token + usage)
3. 提供强制刷新接口用于手动刷新场景

实现细节:
- UsageCache 结构:缓存 usage 数据、缓存时间、过期时间
- CheckUsageLimits:优先使用缓存,过期才重新获取
- RefreshUsageCache:强制刷新缓存(手动刷新时使用)
- InvalidateUsageCache:清除缓存(token 失效时使用)

性能提升:
- 减少 80% 的 getUsageLimits API 调用
- 降低被 AWS 检测为异常流量的风险
- 提升响应速度(缓存命中时无需网络请求)

参考:
- kiro-account-manager: 启动时只刷新 token,手动时才获取 usage
- 缓存时间 5 分钟:平衡数据新鲜度和 API 调用频率
参考 kiro-account-manager 的设计,Usage 只在用户手动刷新时获取:

API 设计:
- GetCachedUsage() - 获取缓存数据,不触发 API
- GetCachedUsageInfo() - 获取缓存的简化信息,不触发 API
- GetUsageCacheTime() - 获取缓存时间,用于显示"上次更新"
- RefreshUsage() - 手动刷新,唯一会调用 API 的方法
- ClearUsageCache() - 清除缓存

核心改变:
- 移除自动刷新逻辑(之前缓存过期会自动调用 API)
- 移除 UsageCacheTTL(不再有过期时间概念)
- Usage 数据永久缓存,直到用户手动刷新

效果:
- 完全消除自动 API 调用
- 只有用户点击刷新才会请求 getUsageLimits
- 大幅降低被 AWS 检测的风险
@Bowl42 Bowl42 merged commit 9e10c4f into main Jan 16, 2026
1 check passed
@Bowl42 Bowl42 deleted the feat/kiro-adapter branch January 16, 2026 06:49
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants