| package executor |
|
|
| import ( |
| "bufio" |
| "bytes" |
| "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| "net/http" |
| "regexp" |
| "strings" |
| "time" |
|
|
| "github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/thinking" |
| "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" |
| sdktranslator "github.com/router-for-me/CLIProxyAPI/v6/sdk/translator" |
| log "github.com/sirupsen/logrus" |
| "github.com/tidwall/gjson" |
| "github.com/tidwall/sjson" |
| ) |
|
|
| |
| |
| type KiroExecutor struct { |
| cfg *config.Config |
| } |
|
|
| |
| func NewKiroExecutor(cfg *config.Config) *KiroExecutor { |
| return &KiroExecutor{cfg: cfg} |
| } |
|
|
| |
| func (e *KiroExecutor) Identifier() string { return "kiro" } |
|
|
| |
| func kiroAPIHost(region string) string { |
| if region == "" { |
| region = "us-east-1" |
| } |
| return fmt.Sprintf("https://codewhisperer.%s.amazonaws.com", region) |
| } |
|
|
| |
| func kiroRefreshURL(region string) string { |
| if region == "" { |
| region = "us-east-1" |
| } |
| return fmt.Sprintf("https://prod.%s.auth.desktop.kiro.dev/refreshToken", region) |
| } |
|
|
| |
| func kiroCreds(a *cliproxyauth.Auth) (accessToken, refreshToken, region, profileARN string) { |
| if a == nil { |
| return |
| } |
| if a.Attributes != nil { |
| region = a.Attributes["region"] |
| profileARN = a.Attributes["profile_arn"] |
| } |
| if a.Metadata != nil { |
| if v, ok := a.Metadata["access_token"].(string); ok { |
| accessToken = v |
| } |
| if v, ok := a.Metadata["refresh_token"].(string); ok { |
| refreshToken = v |
| } |
| if v, ok := a.Metadata["region"].(string); ok && v != "" { |
| region = v |
| } |
| if v, ok := a.Metadata["profile_arn"].(string); ok && v != "" { |
| profileARN = v |
| } |
| } |
| if region == "" { |
| region = "us-east-1" |
| } |
| return |
| } |
|
|
| |
| var kiroModelMappings = map[string]string{ |
| "claude-sonnet-4": "CLAUDE_SONNET_4_V1_0", |
| "claude-sonnet-4.5": "CLAUDE_SONNET_4_5_V1_0", |
| "claude-haiku-4.5": "CLAUDE_HAIKU_4_5_V1_0", |
| "claude-opus-4.5": "CLAUDE_OPUS_4_5_V1_0", |
| "claude-3.7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0", |
| "claude-3-7-sonnet": "CLAUDE_3_7_SONNET_20250219_V1_0", |
| "auto": "auto", |
| } |
|
|
| |
| func normalizeKiroModel(model string) string { |
| model = strings.TrimSpace(model) |
| |
| model = regexp.MustCompile(`(\d)-(\d)`).ReplaceAllString(model, "${1}.${2}") |
|
|
| |
| model = regexp.MustCompile(`-\d{8}$`).ReplaceAllString(model, "") |
|
|
| if kiroID, ok := kiroModelMappings[model]; ok { |
| return kiroID |
| } |
|
|
| |
| return model |
| } |
|
|
| |
| func buildKiroPayload(body []byte, model string, profileARN string) ([]byte, error) { |
| kiroModel := normalizeKiroModel(model) |
|
|
| |
| messages := gjson.GetBytes(body, "messages") |
| if !messages.Exists() { |
| return nil, fmt.Errorf("messages field is required") |
| } |
|
|
| |
| var userInputs []map[string]any |
| var assistantResponses []map[string]any |
|
|
| messages.ForEach(func(_, msg gjson.Result) bool { |
| role := msg.Get("role").String() |
| content := msg.Get("content") |
|
|
| switch role { |
| case "user": |
| userMsg := map[string]any{ |
| "content": extractTextContent(content), |
| } |
| userInputs = append(userInputs, userMsg) |
| case "assistant": |
| assistantMsg := map[string]any{ |
| "content": extractTextContent(content), |
| } |
| assistantResponses = append(assistantResponses, assistantMsg) |
| } |
| return true |
| }) |
|
|
| |
| var systemPrompt string |
| if sys := gjson.GetBytes(body, "system"); sys.Exists() { |
| if sys.IsArray() { |
| var parts []string |
| sys.ForEach(func(_, part gjson.Result) bool { |
| if part.Get("type").String() == "text" { |
| parts = append(parts, part.Get("text").String()) |
| } |
| return true |
| }) |
| systemPrompt = strings.Join(parts, "\n") |
| } else { |
| systemPrompt = sys.String() |
| } |
| } |
|
|
| |
| conversationState := map[string]any{ |
| "currentMessage": map[string]any{ |
| "origin": "USER", |
| "userInputs": userInputs, |
| "userInputOrigin": "CONVERSATION", |
| }, |
| } |
|
|
| if len(assistantResponses) > 0 { |
| conversationState["history"] = []map[string]any{ |
| { |
| "turn": map[string]any{ |
| "userInputs": userInputs[:len(userInputs)-1], |
| "assistantResponses": assistantResponses, |
| }, |
| }, |
| } |
| } |
|
|
| |
| kiroPayload := map[string]any{ |
| "conversationState": conversationState, |
| "additionalInstructions": systemPrompt, |
| "profileArn": profileARN, |
| } |
|
|
| |
| if kiroModel != "auto" { |
| kiroPayload["modelRoutingConfiguration"] = map[string]any{ |
| "explicitModelId": kiroModel, |
| } |
| } |
|
|
| return json.Marshal(kiroPayload) |
| } |
|
|
| |
| func extractTextContent(content gjson.Result) string { |
| if content.IsArray() { |
| var parts []string |
| content.ForEach(func(_, part gjson.Result) bool { |
| if part.Get("type").String() == "text" { |
| parts = append(parts, part.Get("text").String()) |
| } |
| return true |
| }) |
| return strings.Join(parts, "\n") |
| } |
| return content.String() |
| } |
|
|
| |
| func (e *KiroExecutor) PrepareRequest(req *http.Request, auth *cliproxyauth.Auth) error { |
| if req == nil { |
| return nil |
| } |
| accessToken, _, _, _ := kiroCreds(auth) |
| if accessToken == "" { |
| return nil |
| } |
| req.Header.Set("Authorization", "Bearer "+accessToken) |
| var attrs map[string]string |
| if auth != nil { |
| attrs = auth.Attributes |
| } |
| util.ApplyCustomHeadersFromAttrs(req, attrs) |
| return nil |
| } |
|
|
| |
| func (e *KiroExecutor) HttpRequest(ctx context.Context, auth *cliproxyauth.Auth, req *http.Request) (*http.Response, error) { |
| if req == nil { |
| return nil, fmt.Errorf("kiro executor: request is nil") |
| } |
| if ctx == nil { |
| ctx = req.Context() |
| } |
| httpReq := req.WithContext(ctx) |
| if err := e.PrepareRequest(httpReq, auth); err != nil { |
| return nil, err |
| } |
| httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0) |
| return httpClient.Do(httpReq) |
| } |
|
|
| |
| func (e *KiroExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (resp cliproxyexecutor.Response, err error) { |
| baseModel := thinking.ParseSuffix(req.Model).ModelName |
|
|
| accessToken, _, region, profileARN := kiroCreds(auth) |
| apiHost := kiroAPIHost(region) |
|
|
| reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) |
| defer reporter.trackFailure(ctx, &err) |
|
|
| from := opts.SourceFormat |
| to := sdktranslator.FromString("claude") |
|
|
| |
| body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), false) |
| body, _ = sjson.SetBytes(body, "model", baseModel) |
|
|
| |
| kiroBody, err := buildKiroPayload(body, baseModel, profileARN) |
| if err != nil { |
| return resp, err |
| } |
|
|
| url := fmt.Sprintf("%s/v1/generateAssistantResponse", apiHost) |
| httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(kiroBody)) |
| if err != nil { |
| return resp, err |
| } |
|
|
| applyKiroHeaders(httpReq, accessToken, profileARN, false) |
|
|
| 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: kiroBody, |
| 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 resp, err |
| } |
| recordAPIResponseMetadata(ctx, e.cfg, httpResp.StatusCode, httpResp.Header.Clone()) |
|
|
| if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { |
| b, _ := io.ReadAll(httpResp.Body) |
| appendAPIResponseChunk(ctx, e.cfg, b) |
| log.Debugf("kiro request error, status: %d, message: %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) |
| } |
| }() |
|
|
| |
| data, err := io.ReadAll(httpResp.Body) |
| if err != nil { |
| recordAPIResponseError(ctx, e.cfg, err) |
| return resp, err |
| } |
| appendAPIResponseChunk(ctx, e.cfg, data) |
|
|
| |
| claudeResp := convertKiroToClaudeResponse(data, req.Model) |
|
|
| var param any |
| out := sdktranslator.TranslateNonStream( |
| ctx, |
| to, |
| from, |
| req.Model, |
| bytes.Clone(opts.OriginalRequest), |
| body, |
| claudeResp, |
| ¶m, |
| ) |
| resp = cliproxyexecutor.Response{Payload: []byte(out)} |
| return resp, nil |
| } |
|
|
| |
| func (e *KiroExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (stream <-chan cliproxyexecutor.StreamChunk, err error) { |
| baseModel := thinking.ParseSuffix(req.Model).ModelName |
|
|
| accessToken, _, region, profileARN := kiroCreds(auth) |
| apiHost := kiroAPIHost(region) |
|
|
| reporter := newUsageReporter(ctx, e.Identifier(), baseModel, auth) |
| defer reporter.trackFailure(ctx, &err) |
|
|
| from := opts.SourceFormat |
| to := sdktranslator.FromString("claude") |
|
|
| |
| body := sdktranslator.TranslateRequest(from, to, baseModel, bytes.Clone(req.Payload), true) |
| body, _ = sjson.SetBytes(body, "model", baseModel) |
|
|
| |
| kiroBody, err := buildKiroPayload(body, baseModel, profileARN) |
| if err != nil { |
| return nil, err |
| } |
|
|
| url := fmt.Sprintf("%s/v1/generateAssistantResponse", apiHost) |
| httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(kiroBody)) |
| if err != nil { |
| return nil, err |
| } |
|
|
| applyKiroHeaders(httpReq, accessToken, profileARN, true) |
|
|
| 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: kiroBody, |
| 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()) |
|
|
| if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { |
| b, _ := io.ReadAll(httpResp.Body) |
| appendAPIResponseChunk(ctx, e.cfg, b) |
| log.Debugf("kiro request error, status: %d, message: %s", httpResp.StatusCode, summarizeErrorBody(httpResp.Header.Get("Content-Type"), b)) |
| if errClose := httpResp.Body.Close(); errClose != nil { |
| log.Errorf("response body close error: %v", errClose) |
| } |
| err = statusErr{code: httpResp.StatusCode, msg: string(b)} |
| return nil, err |
| } |
|
|
| out := make(chan cliproxyexecutor.StreamChunk) |
| stream = out |
|
|
| go func() { |
| defer close(out) |
| defer func() { |
| if errClose := httpResp.Body.Close(); errClose != nil { |
| log.Errorf("response body close error: %v", errClose) |
| } |
| }() |
|
|
| scanner := bufio.NewScanner(httpResp.Body) |
| scanner.Buffer(nil, 52_428_800) |
|
|
| var contentBuilder strings.Builder |
| var param any |
|
|
| for scanner.Scan() { |
| line := scanner.Bytes() |
| appendAPIResponseChunk(ctx, e.cfg, line) |
|
|
| |
| chunk := parseKiroStreamLine(line, req.Model, &contentBuilder) |
| if chunk == nil { |
| continue |
| } |
|
|
| |
| chunks := sdktranslator.TranslateStream( |
| ctx, |
| to, |
| from, |
| req.Model, |
| bytes.Clone(opts.OriginalRequest), |
| body, |
| chunk, |
| ¶m, |
| ) |
| for i := range chunks { |
| out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunks[i])} |
| } |
| } |
|
|
| if errScan := scanner.Err(); errScan != nil { |
| recordAPIResponseError(ctx, e.cfg, errScan) |
| reporter.publishFailure(ctx) |
| out <- cliproxyexecutor.StreamChunk{Err: errScan} |
| } |
| }() |
|
|
| return stream, nil |
| } |
|
|
| |
| func (e *KiroExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Auth, req cliproxyexecutor.Request, opts cliproxyexecutor.Options) (cliproxyexecutor.Response, error) { |
| |
| from := opts.SourceFormat |
| to := sdktranslator.FromString("claude") |
|
|
| body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false) |
|
|
| |
| contentLen := len(body) |
| estimatedTokens := contentLen / 4 |
|
|
| result := map[string]any{ |
| "input_tokens": estimatedTokens, |
| } |
| data, _ := json.Marshal(result) |
|
|
| out := sdktranslator.TranslateTokenCount(ctx, to, from, int64(estimatedTokens), data) |
| return cliproxyexecutor.Response{Payload: []byte(out)}, nil |
| } |
|
|
| |
| func (e *KiroExecutor) Refresh(ctx context.Context, auth *cliproxyauth.Auth) (*cliproxyauth.Auth, error) { |
| log.Debugf("kiro executor: refresh called") |
| if auth == nil { |
| return nil, fmt.Errorf("kiro executor: auth is nil") |
| } |
|
|
| _, refreshToken, region, _ := kiroCreds(auth) |
| if refreshToken == "" { |
| return auth, nil |
| } |
|
|
| refreshURL := kiroRefreshURL(region) |
|
|
| payload := map[string]string{ |
| "refreshToken": refreshToken, |
| } |
| payloadBytes, _ := json.Marshal(payload) |
|
|
| req, err := http.NewRequestWithContext(ctx, http.MethodPost, refreshURL, bytes.NewReader(payloadBytes)) |
| if err != nil { |
| return nil, err |
| } |
| req.Header.Set("Content-Type", "application/json") |
|
|
| httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 30*time.Second) |
| resp, err := httpClient.Do(req) |
| if err != nil { |
| return nil, err |
| } |
| defer func() { |
| if errClose := resp.Body.Close(); errClose != nil { |
| log.Errorf("response body close error: %v", errClose) |
| } |
| }() |
|
|
| if resp.StatusCode < 200 || resp.StatusCode >= 300 { |
| b, _ := io.ReadAll(resp.Body) |
| return nil, fmt.Errorf("kiro refresh failed: %d %s", resp.StatusCode, string(b)) |
| } |
|
|
| data, err := io.ReadAll(resp.Body) |
| if err != nil { |
| return nil, err |
| } |
|
|
| var result struct { |
| AccessToken string `json:"accessToken"` |
| RefreshToken string `json:"refreshToken,omitempty"` |
| ExpiresIn int64 `json:"expiresIn,omitempty"` |
| ProfileArn string `json:"profileArn,omitempty"` |
| } |
| if err := json.Unmarshal(data, &result); err != nil { |
| return nil, err |
| } |
|
|
| if auth.Metadata == nil { |
| auth.Metadata = make(map[string]any) |
| } |
| auth.Metadata["access_token"] = result.AccessToken |
| if result.RefreshToken != "" { |
| auth.Metadata["refresh_token"] = result.RefreshToken |
| } |
| if result.ExpiresIn > 0 { |
| auth.Metadata["expired"] = time.Now().Add(time.Duration(result.ExpiresIn) * time.Second).Format(time.RFC3339) |
| } |
| |
| if result.ProfileArn != "" { |
| auth.Metadata["profile_arn"] = result.ProfileArn |
| } |
| auth.Metadata["type"] = "kiro" |
| auth.Metadata["last_refresh"] = time.Now().Format(time.RFC3339) |
|
|
| return auth, nil |
| } |
|
|
| |
| func applyKiroHeaders(r *http.Request, accessToken, profileARN string, stream bool) { |
| r.Header.Set("Authorization", "Bearer "+accessToken) |
| r.Header.Set("Content-Type", "application/json") |
| r.Header.Set("Accept", "application/vnd.amazon.eventstream") |
| r.Header.Set("X-Amz-Target", "AmazonQDeveloperStreamingService.GenerateAssistantResponse") |
| r.Header.Set("X-Amzn-Codewhisperer-Profilearn", profileARN) |
| r.Header.Set("Connection", "keep-alive") |
|
|
| if stream { |
| r.Header.Set("Accept", "application/vnd.amazon.eventstream") |
| } else { |
| r.Header.Set("Accept", "application/json") |
| } |
| } |
|
|
| |
| func convertKiroToClaudeResponse(data []byte, model string) []byte { |
| |
| var contentBuilder strings.Builder |
|
|
| lines := bytes.Split(data, []byte("\n")) |
| for _, line := range lines { |
| line = bytes.TrimSpace(line) |
| if len(line) == 0 { |
| continue |
| } |
|
|
| |
| if bytes.HasPrefix(line, []byte(":event-type")) || bytes.HasPrefix(line, []byte(":message-type")) { |
| continue |
| } |
|
|
| |
| text := gjson.GetBytes(line, "assistantResponseEvent.content").String() |
| if text != "" { |
| contentBuilder.WriteString(text) |
| } |
| } |
|
|
| |
| claudeResp := map[string]any{ |
| "id": fmt.Sprintf("msg_%d", time.Now().UnixNano()), |
| "type": "message", |
| "role": "assistant", |
| "model": model, |
| "content": []map[string]any{ |
| { |
| "type": "text", |
| "text": contentBuilder.String(), |
| }, |
| }, |
| "stop_reason": "end_turn", |
| "usage": map[string]any{ |
| "input_tokens": 0, |
| "output_tokens": len(contentBuilder.String()) / 4, |
| }, |
| } |
|
|
| result, _ := json.Marshal(claudeResp) |
| return result |
| } |
|
|
| |
| func parseKiroStreamLine(line []byte, model string, contentBuilder *strings.Builder) []byte { |
| line = bytes.TrimSpace(line) |
| if len(line) == 0 { |
| return nil |
| } |
|
|
| |
| if bytes.HasPrefix(line, []byte(":")) { |
| return nil |
| } |
|
|
| |
| if bytes.HasPrefix(line, []byte("data:")) { |
| line = bytes.TrimPrefix(line, []byte("data:")) |
| line = bytes.TrimSpace(line) |
| } |
|
|
| if len(line) == 0 || !gjson.ValidBytes(line) { |
| return nil |
| } |
|
|
| |
| text := gjson.GetBytes(line, "assistantResponseEvent.content").String() |
| if text == "" { |
| return nil |
| } |
|
|
| contentBuilder.WriteString(text) |
|
|
| |
| event := map[string]any{ |
| "type": "content_block_delta", |
| "index": 0, |
| "delta": map[string]any{ |
| "type": "text_delta", |
| "text": text, |
| }, |
| } |
|
|
| data, _ := json.Marshal(event) |
| return append([]byte("data: "), data...) |
| } |
|
|