backend/kiro: port fixes from goq amzcodewhisp

- Switch endpoint from q.*.amazonaws.com to runtime.*.kiro.dev
- Use management.*.kiro.dev for ListModels
- Fix content encoding: plain string instead of json.RawMessage
- Fix tool results: always use text field (upstream rejects bare JSON primitives)
- Normalize tool call arguments (deduplicate keys)
- Expand retry conditions: add 502, 503, 408, InsufficientModelCapacity
- Add SQLite busy-timeout and Go-level retry for lock contention
- Add 30s refresh timeout and fullLoad before refresh
- Add SSO cache fallback for token refresh
- Add jitter to token expiry checks
- Add sqliteHasNewUsableAccessToken for cross-process recovery
- Add envState to current message
- Update user-agent strings to 1.3.15/2.5.1
- Set X-Amzn-Codewhisperer-Optout to true
- Add reasoning/thinking support (additionalModelRequestFields)
- Add counter-thinking tags on user messages before assistants
- Handle reasoningContentEvent in stream
This commit is contained in:
Levi Neely 2026-07-10 11:43:18 +02:00
parent 0fc9c9613c
commit 75f137dda5
2 changed files with 552 additions and 95 deletions

View File

@ -16,6 +16,7 @@ package backend
import (
"context"
"crypto/rand"
"encoding/json"
"errors"
"fmt"
@ -133,7 +134,7 @@ func (b *CodeWhispererBackend) ChatStream(
return nil, fmt.Errorf("kiro profile ARN: %w", err)
}
req, err := buildKiroRequest(model, messages, tools, profileARN)
req, err := buildKiroRequest(model, messages, tools, profileARN, params)
if err != nil {
return nil, err
}
@ -181,6 +182,16 @@ func (b *CodeWhispererBackend) runStream(ctx context.Context, req *kiroGenerateR
stopped = true
}
},
OnReasoningDelta: func(event kiroReasoningContentEvent) {
if stopped || (event.Text == "" && event.RedactedContent == "" && event.Signature == "") {
return
}
select {
case ch <- StreamEvent{Reasoning: event.Text}:
case <-ctx.Done():
stopped = true
}
},
})
if streamErr != nil {
@ -255,12 +266,15 @@ func kiroSanitizeModel(model string) string {
return model
}
func buildKiroRequest(model string, messages []Message, tools []Tool, profileARN string) (*kiroGenerateRequest, error) {
func buildKiroRequest(model string, messages []Message, tools []Tool, profileARN string, params GenerationParams) (*kiroGenerateRequest, error) {
model = kiroSanitizeModel(model)
if len(messages) == 0 {
return nil, fmt.Errorf("at least one message is required")
}
// Apply counter-thinking tags to user messages before assistant responses.
messages = kiroApplyCounterThinkingTags(messages)
encodedTools := encodeKiroToolDefinitions(tools)
lastRole := strings.ToLower(strings.TrimSpace(messages[len(messages)-1].Role))
@ -275,7 +289,7 @@ func buildKiroRequest(model string, messages []Message, tools []Tool, profileARN
if err != nil {
return nil, err
}
current = kiroChatMessage{UserInputMessage: encodeKiroUserMessage(messages[len(messages)-1], model, encodedTools, nil)}
current = kiroChatMessage{UserInputMessage: encodeKiroUserMessage(messages[len(messages)-1], model, encodedTools, nil, true)}
case "tool":
// Find the last non-tool message.
@ -288,7 +302,7 @@ func buildKiroRequest(model string, messages []Message, tools []Tool, profileARN
return nil, err
}
toolResults, images := encodeKiroToolResults(messages[cutoff+1:])
current = kiroChatMessage{UserInputMessage: emptyKiroUserMessage(model, encodedTools, toolResults, images)}
current = kiroChatMessage{UserInputMessage: emptyKiroUserMessage(model, encodedTools, toolResults, images, true)}
default:
return nil, fmt.Errorf("last message role %q is not supported (expected user, system, or tool)", lastRole)
@ -303,7 +317,7 @@ func buildKiroRequest(model string, messages []Message, tools []Tool, profileARN
return nil, err
}
return &kiroGenerateRequest{
req := &kiroGenerateRequest{
ProfileARN: profileARN,
ConversationState: kiroConversationState{
ConversationID: convID,
@ -313,7 +327,14 @@ func buildKiroRequest(model string, messages []Message, tools []Tool, profileARN
AgentContinuationID: contID,
AgentTaskType: kiroAgentTaskType,
},
}, nil
}
// Set additionalModelRequestFields for reasoning/thinking support.
if fields := kiroModelRequestFieldsFromParams(params); fields != nil {
req.AdditionalModelRequestFields = fields
}
return req, nil
}
func encodeKiroHistory(messages []Message) ([]kiroChatMessage, error) {
@ -322,7 +343,7 @@ func encodeKiroHistory(messages []Message) ([]kiroChatMessage, error) {
m := messages[i]
switch strings.ToLower(strings.TrimSpace(m.Role)) {
case "user", "system":
out = append(out, kiroChatMessage{UserInputMessage: encodeKiroUserMessage(m, "", nil, nil)})
out = append(out, kiroChatMessage{UserInputMessage: encodeKiroUserMessage(m, "", nil, nil, false)})
case "assistant":
out = append(out, kiroChatMessage{AssistantResponseMessage: encodeKiroAssistantMessage(m)})
case "tool":
@ -332,17 +353,18 @@ func encodeKiroHistory(messages []Message) ([]kiroChatMessage, error) {
i++
}
results, imgs := encodeKiroToolResults(messages[start : i+1])
out = append(out, kiroChatMessage{UserInputMessage: emptyKiroUserMessage("", nil, results, imgs)})
out = append(out, kiroChatMessage{UserInputMessage: emptyKiroUserMessage("", nil, results, imgs, false)})
}
}
return out, nil
}
func encodeKiroUserMessage(m Message, modelID string, tools []kiroTool, toolResults []kiroToolResult) *kiroUserInputMessage {
func encodeKiroUserMessage(m Message, modelID string, tools []kiroTool, toolResults []kiroToolResult, includeEnvState bool) *kiroUserInputMessage {
msg := &kiroUserInputMessage{
Content: kiroEncodeContent(m),
Content: kiroExtractTextContent(m),
Origin: kiroDefaultOrigin,
ModelID: modelID,
Images: kiroExtractImages(m),
}
ctx := &kiroUserInputContext{}
if len(tools) > 0 {
@ -351,15 +373,18 @@ func encodeKiroUserMessage(m Message, modelID string, tools []kiroTool, toolResu
if len(toolResults) > 0 {
ctx.ToolResults = toolResults
}
if len(ctx.Tools) > 0 || len(ctx.ToolResults) > 0 {
if includeEnvState {
ctx.EnvState = kiroCurrentEnvState()
}
if len(ctx.Tools) > 0 || len(ctx.ToolResults) > 0 || ctx.EnvState != nil {
msg.UserInputMessageContext = ctx
}
return msg
}
func emptyKiroUserMessage(modelID string, tools []kiroTool, toolResults []kiroToolResult, images []kiroImageBlock) *kiroUserInputMessage {
func emptyKiroUserMessage(modelID string, tools []kiroTool, toolResults []kiroToolResult, images []kiroImageBlock, includeEnvState bool) *kiroUserInputMessage {
msg := &kiroUserInputMessage{
Content: kiroEncodeStringContent(""),
Content: "",
Origin: kiroDefaultOrigin,
ModelID: modelID,
Images: images,
@ -371,7 +396,10 @@ func emptyKiroUserMessage(modelID string, tools []kiroTool, toolResults []kiroTo
if len(toolResults) > 0 {
ctx.ToolResults = toolResults
}
if len(ctx.Tools) > 0 || len(ctx.ToolResults) > 0 {
if includeEnvState {
ctx.EnvState = kiroCurrentEnvState()
}
if len(ctx.Tools) > 0 || len(ctx.ToolResults) > 0 || ctx.EnvState != nil {
msg.UserInputMessageContext = ctx
}
return msg
@ -383,6 +411,16 @@ func encodeKiroAssistantMessage(m Message) *kiroAssistantResponseMessage {
input := tc.Arguments
if len(input) == 0 || !json.Valid(input) {
input = json.RawMessage("{}")
} else {
// Normalize the JSON by round-tripping through unmarshal/marshal.
// This deduplicates keys (the model sometimes produces duplicate
// keys which the upstream API rejects) and ensures well-formed output.
var parsed any
if err := json.Unmarshal(input, &parsed); err == nil {
if normalized, err := json.Marshal(parsed); err == nil {
input = normalized
}
}
}
toolUseID := strings.TrimSpace(tc.ID)
if toolUseID == "" {
@ -464,63 +502,136 @@ func encodeKiroToolResultBlocks(m Message) ([]kiroToolResultContent, []kiroImage
}
func encodeKiroToolResultContent(content string) kiroToolResultContent {
content = strings.TrimSpace(content)
if len(content) > 0 && content[0] == '{' && json.Valid([]byte(content)) {
return kiroToolResultContent{JSON: json.RawMessage(content)}
}
return kiroToolResultContent{Text: content}
// Always use the text field. The upstream Amazon Q API rejects bare JSON
// primitives (numbers, strings, booleans, null) in the "json" field with
// "ValidationException: Improperly formed request." — it expects only
// objects or arrays. Using text is always safe; the model sees the same
// content either way.
return kiroToolResultContent{Text: strings.TrimSpace(content)}
}
// kiroEncodeContent returns a json.RawMessage that is either a JSON string
// (plain text) or a JSON array of Bedrock Converse content blocks (when images
// are present).
func kiroEncodeContent(m Message) json.RawMessage {
// kiroExtractTextContent returns the text content of a message. When images are
// present, they are encoded as separate image blocks on the message; this
// function only returns the text portion.
func kiroExtractTextContent(m Message) string {
if len(m.ContentBlocks) == 0 {
return kiroEncodeStringContent(m.Content)
return m.Content
}
hasImage := false
var sb strings.Builder
for _, cb := range m.ContentBlocks {
if cb.Type == "text" {
sb.WriteString(cb.Text)
}
}
return sb.String()
}
// kiroExtractImages extracts image blocks from a message's ContentBlocks.
func kiroExtractImages(m Message) []kiroImageBlock {
if len(m.ContentBlocks) == 0 {
return nil
}
var images []kiroImageBlock
for _, cb := range m.ContentBlocks {
if cb.Type == "image" && cb.ImageSource != nil {
hasImage = true
break
}
}
if !hasImage {
// Text-only blocks: concatenate and send as string.
var sb strings.Builder
for _, cb := range m.ContentBlocks {
if cb.Type == "text" {
sb.WriteString(cb.Text)
}
}
return kiroEncodeStringContent(sb.String())
}
// Mixed content: encode as Bedrock Converse content block array.
blocks := make([]kiroContentBlock, 0, len(m.ContentBlocks))
for _, cb := range m.ContentBlocks {
switch cb.Type {
case "text":
blocks = append(blocks, kiroContentBlock{Text: cb.Text})
case "image":
if cb.ImageSource == nil {
continue
}
format := strings.TrimPrefix(cb.ImageSource.MediaType, "image/")
blocks = append(blocks, kiroContentBlock{
Image: &kiroImageBlock{
Format: format,
Source: kiroImageSource{Bytes: cb.ImageSource.Data},
},
images = append(images, kiroImageBlock{
Format: format,
Source: kiroImageSource{Bytes: cb.ImageSource.Data},
})
}
}
data, _ := json.Marshal(blocks)
return json.RawMessage(data)
return images
}
func kiroEncodeStringContent(s string) json.RawMessage {
data, _ := json.Marshal(s)
return json.RawMessage(data)
func kiroCurrentEnvState() *kiroEnvState {
return &kiroEnvState{
OperatingSystem: runtime.GOOS,
CurrentWorkingDirectory: "/",
}
}
// ── Counter-thinking & reasoning support ──────────────────────────────────────
const kiroCounterThinkingInstructions = `Be thorough in your work; don't skip steps or cut corners to save time. Think deeply and step by step, do not skip ahead prematurely.`
const kiroDefaultReasoningEffort = "95"
// kiroApplyCounterThinkingTags appends corrective tags to user messages that
// immediately precede assistant responses, countering server-side injections
// that degrade reasoning quality.
func kiroApplyCounterThinkingTags(messages []Message) []Message {
if len(messages) < 2 {
return messages
}
var mangled []Message
for i := 1; i < len(messages); i++ {
if !strings.EqualFold(messages[i].Role, "assistant") {
continue
}
prev := messages[i-1]
role := strings.ToLower(strings.TrimSpace(prev.Role))
if role != "user" && role != "system" {
continue
}
if mangled == nil {
mangled = make([]Message, len(messages))
copy(mangled, messages)
}
suffix := "\n\n<reasoning_effort>" + kiroDefaultReasoningEffort + "</reasoning_effort>\n" +
"<reasoning_effort>" + kiroDefaultReasoningEffort + "</reasoning_effort>\n" +
"<implicitInstruction>" + kiroCounterThinkingInstructions + "</implicitInstruction>"
mangled[i-1].Content = prev.Content + suffix
}
if mangled != nil {
return mangled
}
return messages
}
// kiroModelRequestFieldsFromParams builds additionalModelRequestFields from
// GenerationParams. Returns nil if no reasoning/thinking fields are needed.
func kiroModelRequestFieldsFromParams(params GenerationParams) *kiroModelRequestFields {
fields := &kiroModelRequestFields{}
hasFields := false
// Enable summarized thinking by default for reasoning-capable models.
if params.ThinkingBudget > 0 || params.ReasoningEffort != "" {
fields.Thinking = &kiroThinkingConfig{
Type: "adaptive",
Display: "summarized",
}
hasFields = true
}
// Map reasoning effort.
if effort := kiroMapEffort(params.ReasoningEffort); effort != "" {
fields.OutputConfig = &kiroOutputConfig{Effort: effort}
hasFields = true
} else if params.ThinkingBudget > 0 {
// Default to high effort when thinking is requested but no specific effort.
fields.OutputConfig = &kiroOutputConfig{Effort: "high"}
hasFields = true
}
if !hasFields {
return nil
}
return fields
}
func kiroMapEffort(effort string) string {
switch strings.ToLower(strings.TrimSpace(effort)) {
case "max":
return "max"
case "high":
return "high"
case "medium":
return "medium"
case "low", "minimal":
return "low"
default:
return ""
}
}
// ── Auth sources ──────────────────────────────────────────────────────────────
@ -565,6 +676,10 @@ type sqliteKiroAuth struct {
loaded bool
initMu sync.Mutex
mu sync.Mutex
// jitter is computed once at creation time so all checks within this
// process use the same refresh threshold. Different processes get
// different jitter values, spreading refresh attempts over time.
jitter time.Duration
}
type kiroSQLiteState struct {
@ -609,7 +724,7 @@ func (s *sqliteKiroAuth) AccessToken(ctx context.Context) (string, error) {
if err != nil {
return "", err
}
if state.AccessToken != "" && !kiroTokenExpiringSoon(state.ExpiresAt) {
if state.AccessToken != "" && !s.tokenExpiringSoon(state.ExpiresAt) {
return state.AccessToken, nil
}
if !s.canRefreshState(state) {
@ -635,7 +750,14 @@ func (s *sqliteKiroAuth) Refresh(ctx context.Context) error {
s.mu.Lock()
defer s.mu.Unlock()
state, err := s.load(ctx)
// Bound the refresh operation to avoid hanging indefinitely.
refreshCtx, cancel := context.WithTimeout(ctx, 30*time.Second)
defer cancel()
// Always perform a full reload of all sqlite state (including device
// registration) before refreshing. Another process (e.g. kiro-cli login)
// may have rotated the client credentials since our initial load.
state, err := s.fullLoad(refreshCtx)
if err != nil {
return err
}
@ -644,8 +766,8 @@ func (s *sqliteKiroAuth) Refresh(ctx context.Context) error {
}
// Re-read current token — another process may have already refreshed it.
currentAccess, currentRefresh, currentExpiry, err := kiroReadSQLiteTokens(ctx, s.path)
if err == nil && currentAccess != "" && currentAccess != state.AccessToken && !kiroTokenExpiringSoon(currentExpiry) {
currentAccess, currentRefresh, currentExpiry, err := kiroReadSQLiteTokens(refreshCtx, s.path)
if err == nil && currentAccess != "" && currentAccess != state.AccessToken && !s.tokenExpiringSoon(currentExpiry) {
return nil // already refreshed by another process
}
@ -655,12 +777,12 @@ func (s *sqliteKiroAuth) Refresh(ctx context.Context) error {
}
if state.IsSocial {
return s.refreshSocial(ctx, state, refreshToken)
return s.refreshSocial(refreshCtx, state, refreshToken)
}
return s.refreshOIDC(ctx, state, refreshToken)
return s.refreshOIDC(refreshCtx, state, refreshToken, currentAccess)
}
func (s *sqliteKiroAuth) refreshOIDC(ctx context.Context, state kiroSQLiteState, refreshToken string) error {
func (s *sqliteKiroAuth) refreshOIDC(ctx context.Context, state kiroSQLiteState, refreshToken, currentAccess string) error {
oidcEndpoint := fmt.Sprintf("https://oidc.%s.amazonaws.com/token", strings.TrimSpace(state.Region))
client := newKiroOIDCClient(oidcEndpoint, nil)
@ -671,6 +793,14 @@ func (s *sqliteKiroAuth) refreshOIDC(ctx context.Context, state kiroSQLiteState,
RefreshToken: refreshToken,
})
if err != nil {
// Try SSO cache fallback.
if fallbackErr := s.refreshFromSSOCache(ctx, currentAccess); fallbackErr == nil {
return nil
}
// Check if another process refreshed while we were trying.
if s.sqliteHasNewUsableAccessToken(ctx, currentAccess) {
return nil
}
return err
}
@ -680,7 +810,13 @@ func (s *sqliteKiroAuth) refreshOIDC(ctx context.Context, state kiroSQLiteState,
if resp.ExpiresIn != nil && *resp.ExpiresIn > 0 {
expiresAt = time.Now().Add(time.Duration(*resp.ExpiresIn) * time.Second).UTC().Format(time.RFC3339)
}
return kiroUpdateSQLiteTokens(ctx, s.path, newAccess, newRefresh, expiresAt)
if err := kiroUpdateSQLiteTokens(ctx, s.path, newAccess, newRefresh, expiresAt); err != nil {
if s.sqliteHasNewUsableAccessToken(ctx, currentAccess) {
return nil
}
return err
}
return nil
}
func (s *sqliteKiroAuth) refreshSocial(ctx context.Context, state kiroSQLiteState, refreshToken string) error {
@ -747,8 +883,117 @@ func (s *sqliteKiroAuth) load(ctx context.Context) (kiroSQLiteState, error) {
return state, nil
}
// fullLoad performs a complete reload of all sqlite state including device
// registration (ClientID, ClientSecret). Used in Refresh() to pick up
// credentials rotated by another process (e.g. kiro-cli login).
func (s *sqliteKiroAuth) fullLoad(ctx context.Context) (kiroSQLiteState, error) {
state, err := kiroLoadSQLiteState(ctx, s.path)
if err != nil {
return kiroSQLiteState{}, err
}
s.initMu.Lock()
s.state = state
s.loaded = true
s.initMu.Unlock()
return state, nil
}
// tokenExpiringSoon checks if the token is about to expire, using instance
// jitter to spread refresh attempts across processes.
func (s *sqliteKiroAuth) tokenExpiringSoon(expiresAt string) bool {
if expiresAt == "" {
return false
}
t, err := time.Parse(time.RFC3339Nano, expiresAt)
if err != nil {
t, err = time.Parse(time.RFC3339, expiresAt)
if err != nil {
return false
}
}
return time.Until(t) < kiroTokenExpiryBuffer+s.jitter
}
// refreshFromSSOCache attempts to refresh using the AWS SSO cache as a
// fallback when the primary OIDC refresh token is invalid/expired.
func (s *sqliteKiroAuth) refreshFromSSOCache(ctx context.Context, previousAccess string) error {
cacheState, err := kiroLoadSSOCacheAuthState()
if err != nil {
return err
}
if strings.TrimSpace(cacheState.RefreshToken) == "" ||
strings.TrimSpace(cacheState.Region) == "" ||
strings.TrimSpace(cacheState.ClientID) == "" ||
strings.TrimSpace(cacheState.ClientSecret) == "" {
return fmt.Errorf("SSO cache does not contain enough data to refresh")
}
endpoint := fmt.Sprintf("https://oidc.%s.amazonaws.com/token", strings.TrimSpace(cacheState.Region))
client := newKiroOIDCClient(endpoint, nil)
resp, err := client.RefreshToken(ctx, kiroRefreshTokenRequest{
ClientID: cacheState.ClientID,
ClientSecret: cacheState.ClientSecret,
GrantType: "refresh_token",
RefreshToken: cacheState.RefreshToken,
})
if err != nil {
return err
}
newAccess := strings.TrimSpace(resp.AccessToken)
newRefresh := strings.TrimSpace(resp.RefreshToken)
var expiresAt string
if resp.ExpiresIn != nil && *resp.ExpiresIn > 0 {
expiresAt = time.Now().Add(time.Duration(*resp.ExpiresIn) * time.Second).UTC().Format(time.RFC3339)
}
if err := kiroUpdateSQLiteTokens(ctx, s.path, newAccess, newRefresh, expiresAt); err != nil {
if s.sqliteHasNewUsableAccessToken(ctx, previousAccess) {
return nil
}
return err
}
// Also update SSO cache file.
_ = kiroUpdateSSOCacheToken(cacheState.TokenPath, newAccess, newRefresh, expiresAt)
return nil
}
// sqliteHasNewUsableAccessToken retries reading the SQLite token to detect if
// another process refreshed during our attempt.
func (s *sqliteKiroAuth) sqliteHasNewUsableAccessToken(ctx context.Context, previousAccess string) bool {
const attempts = 3
const backoff = 500 * time.Millisecond
for i := range attempts {
if i > 0 {
select {
case <-ctx.Done():
return false
case <-time.After(backoff):
}
}
access, _, expiresAt, err := kiroReadSQLiteTokens(ctx, s.path)
if err != nil {
continue
}
if access != "" && access != strings.TrimSpace(previousAccess) && !s.tokenExpiringSoon(expiresAt) {
return true
}
}
return false
}
const kiroTokenExpiryBuffer = 2 * time.Minute
// kiroTokenExpiryJitter is the maximum random offset added to kiroTokenExpiryBuffer.
const kiroTokenExpiryJitter = 2 * time.Minute
func kiroRandomJitter() time.Duration {
b := make([]byte, 4)
_, _ = rand.Read(b)
n := uint32(b[0]) | uint32(b[1])<<8 | uint32(b[2])<<16 | uint32(b[3])<<24
return time.Duration(n%uint32(kiroTokenExpiryJitter/time.Millisecond)) * time.Millisecond
}
func kiroTokenExpiringSoon(expiresAt string) bool {
if expiresAt == "" {
return false
@ -972,23 +1217,161 @@ func kiroQuerySQLiteOptional(ctx context.Context, path, query string) (string, b
return "", false, err
}
// sqliteBusyTimeout is the per-connection busy timeout passed to the sqlite3
// CLI via -cmd. This is session-scoped and does not modify the DB file.
const kiroSQLiteBusyTimeout = "5000"
// sqliteBusyRetries is the number of times we retry at the Go level when
// sqlite3 still returns a busy/locked error after its internal timeout.
const kiroSQLiteBusyRetries = 2
// sqliteBusyBackoff is the delay between Go-level retries.
const kiroSQLiteBusyBackoff = 200 * time.Millisecond
func kiroRunSQLite(ctx context.Context, args ...string) ([]byte, error) {
cmd := exec.CommandContext(ctx, "sqlite3", args...)
out, err := cmd.CombinedOutput()
if err != nil {
msg := strings.TrimSpace(string(out))
if msg != "" {
return nil, fmt.Errorf("%s", msg)
// Prepend busy timeout so sqlite3 retries internally on lock contention.
fullArgs := make([]string, 0, len(args)+2)
fullArgs = append(fullArgs, "-cmd", ".timeout "+kiroSQLiteBusyTimeout)
fullArgs = append(fullArgs, args...)
var out []byte
var err error
for attempt := range kiroSQLiteBusyRetries + 1 {
if attempt > 0 {
select {
case <-ctx.Done():
return out, ctx.Err()
case <-time.After(kiroSQLiteBusyBackoff):
}
}
cmd := exec.CommandContext(ctx, "sqlite3", fullArgs...)
out, err = cmd.CombinedOutput()
if err == nil || !kiroIsSQLiteBusy(out, err) {
return out, err
}
return nil, err
}
return out, nil
return out, err
}
// kiroIsSQLiteBusy checks whether the sqlite3 invocation failed due to lock
// contention. The sqlite3 CLI returns exit code 5 (SQLITE_BUSY) or 6
// (SQLITE_LOCKED).
func kiroIsSQLiteBusy(output []byte, err error) bool {
if exitErr, ok := err.(*exec.ExitError); ok {
code := exitErr.ExitCode()
if code == 5 || code == 6 {
return true
}
}
s := strings.ToLower(string(output))
return strings.Contains(s, "database is locked") ||
strings.Contains(s, "database table is locked")
}
func kiroSQLiteQuote(s string) string {
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
}
// ── SSO cache fallback ────────────────────────────────────────────────────────
type kiroSSOCacheState struct {
TokenPath string
AccessToken string
RefreshToken string
ExpiresAt string
Region string
ClientID string
ClientSecret string
}
func kiroLoadSSOCacheAuthState() (kiroSSOCacheState, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return kiroSSOCacheState{}, fmt.Errorf("cannot determine home directory for SSO cache: %w", err)
}
cacheDir := filepath.Join(homeDir, ".aws", "sso", "cache")
tokenPath := filepath.Join(cacheDir, "kiro-auth-token-cli.json")
tokenBytes, err := os.ReadFile(tokenPath)
if err != nil {
return kiroSSOCacheState{}, fmt.Errorf("read SSO token cache %q: %w", tokenPath, err)
}
var tokenState struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt string `json:"expiresAt"`
Region string `json:"region"`
ClientIDHash string `json:"clientIdHash"`
}
if err := json.Unmarshal(tokenBytes, &tokenState); err != nil {
return kiroSSOCacheState{}, fmt.Errorf("decode SSO token cache %q: %w", tokenPath, err)
}
clientIDHash := strings.TrimSpace(tokenState.ClientIDHash)
if clientIDHash == "" {
return kiroSSOCacheState{}, fmt.Errorf("SSO token cache %q did not contain clientIdHash", tokenPath)
}
clientPath := filepath.Join(cacheDir, clientIDHash+".json")
clientBytes, err := os.ReadFile(clientPath)
if err != nil {
return kiroSSOCacheState{}, fmt.Errorf("read SSO client cache %q: %w", clientPath, err)
}
var clientState struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"clientSecret"`
}
if err := json.Unmarshal(clientBytes, &clientState); err != nil {
return kiroSSOCacheState{}, fmt.Errorf("decode SSO client cache %q: %w", clientPath, err)
}
return kiroSSOCacheState{
TokenPath: tokenPath,
AccessToken: strings.TrimSpace(tokenState.AccessToken),
RefreshToken: strings.TrimSpace(tokenState.RefreshToken),
ExpiresAt: strings.TrimSpace(tokenState.ExpiresAt),
Region: strings.TrimSpace(tokenState.Region),
ClientID: strings.TrimSpace(clientState.ClientID),
ClientSecret: strings.TrimSpace(clientState.ClientSecret),
}, nil
}
func kiroUpdateSSOCacheToken(path, accessToken, refreshToken, expiresAt string) error {
accessToken = strings.TrimSpace(accessToken)
if accessToken == "" {
return fmt.Errorf("access token must not be empty")
}
body, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read SSO token cache %q: %w", path, err)
}
var tokenState map[string]any
if err := json.Unmarshal(body, &tokenState); err != nil {
return fmt.Errorf("decode SSO token cache %q: %w", path, err)
}
tokenState["accessToken"] = accessToken
if strings.TrimSpace(refreshToken) != "" {
tokenState["refreshToken"] = strings.TrimSpace(refreshToken)
}
if strings.TrimSpace(expiresAt) != "" {
tokenState["expiresAt"] = strings.TrimSpace(expiresAt)
}
updated, err := json.MarshalIndent(tokenState, "", " ")
if err != nil {
return fmt.Errorf("encode SSO token cache %q: %w", path, err)
}
updated = append(updated, '\n')
info, err := os.Stat(path)
if err != nil {
return fmt.Errorf("stat SSO token cache %q: %w", path, err)
}
tmpPath := path + ".tmp"
if err := os.WriteFile(tmpPath, updated, info.Mode().Perm()); err != nil {
return fmt.Errorf("write SSO token cache temp %q: %w", tmpPath, err)
}
if err := os.Rename(tmpPath, path); err != nil {
os.Remove(tmpPath)
return fmt.Errorf("rename SSO token cache %q: %w", path, err)
}
return nil
}
// ── Auth source factory ───────────────────────────────────────────────────────
func newKiroAuthSource(apiKey string) (kiroAuthSource, error) {
@ -1012,7 +1395,7 @@ func newKiroAuthSource(apiKey string) (kiroAuthSource, error) {
if strings.TrimSpace(path) == "" {
return nil, fmt.Errorf("sqlite URL %q does not contain a database path", apiKey)
}
return &sqliteKiroAuth{path: path}, nil
return &sqliteKiroAuth{path: path, jitter: kiroRandomJitter()}, nil
}
func kiroDefaultSQLiteKey() string {

View File

@ -21,6 +21,7 @@ import (
"io"
"maps"
"net/http"
"net/url"
"strings"
)
@ -33,11 +34,11 @@ const (
kiroAgentTaskType = "vibe"
kiroAmzSDKRequest = "attempt=1; max=3"
kiroUserAgent = "aws-sdk-rust/1.3.14 ua/2.1 api/codewhispererstreaming/0.1.16551 os/linux lang/rust/1.92.0 md/appVersion-0.12.333 app/AmazonQ-For-CLI"
kiroAmzUserAgent = "aws-sdk-rust/1.3.14 ua/2.1 api/codewhispererstreaming/0.1.16551 os/linux lang/rust/1.92.0 m/F app/AmazonQ-For-CLI"
kiroUserAgent = "aws-sdk-rust/1.3.15 ua/2.1 api/codewhispererstreaming/0.1.16551 os/linux lang/rust/1.92.0 md/appVersion-2.5.1 app/AmazonQ-For-CLI"
kiroAmzUserAgent = "aws-sdk-rust/1.3.15 ua/2.1 api/codewhispererstreaming/0.1.16551 os/linux lang/rust/1.92.0 m/F app/AmazonQ-For-CLI"
kiroOIDCUserAgent = "aws-sdk-rust/1.3.10 os/linux lang/rust/1.92.0"
kiroOIDCAmzUserAgent = "aws-sdk-rust/1.3.10 ua/2.1 api/ssooidc/1.92.0 os/linux lang/rust/1.92.0 m/E app/AmazonQ-For-CLI"
kiroOIDCUserAgent = "aws-sdk-rust/1.3.15 os/linux lang/rust/1.92.0"
kiroOIDCAmzUserAgent = "aws-sdk-rust/1.3.15 ua/2.1 api/ssooidc/1.100.0 os/linux lang/rust/1.92.0 m/E,N app/AmazonQ-For-CLI"
)
type kiroRefreshTokenRequest struct {
@ -54,8 +55,24 @@ type kiroRefreshTokenResponse struct {
}
type kiroGenerateRequest struct {
ConversationState kiroConversationState `json:"conversationState"`
ProfileARN string `json:"profileArn,omitempty"`
ConversationState kiroConversationState `json:"conversationState"`
ProfileARN string `json:"profileArn,omitempty"`
AdditionalModelRequestFields *kiroModelRequestFields `json:"additionalModelRequestFields,omitempty"`
}
type kiroModelRequestFields struct {
Thinking *kiroThinkingConfig `json:"thinking,omitempty"`
OutputConfig *kiroOutputConfig `json:"output_config,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
}
type kiroThinkingConfig struct {
Type string `json:"type"`
Display string `json:"display,omitempty"`
}
type kiroOutputConfig struct {
Effort string `json:"effort,omitempty"`
}
type kiroConversationState struct {
@ -73,18 +90,13 @@ type kiroChatMessage struct {
}
type kiroUserInputMessage struct {
Content json.RawMessage `json:"content"`
Content string `json:"content"`
UserInputMessageContext *kiroUserInputContext `json:"userInputMessageContext,omitempty"`
Origin string `json:"origin,omitempty"`
Images []kiroImageBlock `json:"images,omitempty"`
ModelID string `json:"modelId,omitempty"`
}
type kiroContentBlock struct {
Text string `json:"text,omitempty"`
Image *kiroImageBlock `json:"image,omitempty"`
}
type kiroImageBlock struct {
Format string `json:"format"`
Source kiroImageSource `json:"source"`
@ -97,6 +109,12 @@ type kiroImageSource struct {
type kiroUserInputContext struct {
ToolResults []kiroToolResult `json:"toolResults,omitempty"`
Tools []kiroTool `json:"tools,omitempty"`
EnvState *kiroEnvState `json:"envState,omitempty"`
}
type kiroEnvState struct {
OperatingSystem string `json:"operatingSystem,omitempty"`
CurrentWorkingDirectory string `json:"currentWorkingDirectory,omitempty"`
}
type kiroAssistantResponseMessage struct {
@ -145,6 +163,12 @@ type kiroAssistantEvent struct {
Content string `json:"content"`
}
type kiroReasoningContentEvent struct {
Text string `json:"text,omitempty"`
RedactedContent string `json:"redactedContent,omitempty"`
Signature string `json:"signature,omitempty"`
}
type kiroToolUseEvent struct {
ToolUseID string `json:"toolUseId"`
Name string `json:"name"`
@ -350,11 +374,22 @@ func (e *kiroAPIError) isThrottled() bool {
}
func (e *kiroAPIError) isTransient() bool {
return e.StatusCode == http.StatusInternalServerError || e.Code == "InternalServerException"
switch e.StatusCode {
case http.StatusRequestTimeout, // 408
http.StatusInternalServerError, // 500
http.StatusBadGateway, // 502
http.StatusServiceUnavailable: // 503
return true
}
code := strings.ToLower(e.Code)
return strings.Contains(code, "insufficientmodelcapacity") ||
strings.Contains(code, "serviceunavailable") ||
strings.Contains(code, "internalserver")
}
type kiroStreamCallbacks struct {
OnAssistantDelta func(kiroAssistantEvent)
OnReasoningDelta func(kiroReasoningContentEvent)
}
func newKiroAPIClient(endpoint, token string, extraHeaders map[string]string, httpClient *http.Client) *kiroAPIClient {
@ -426,6 +461,14 @@ func (c *kiroAPIClient) Stream(
if callbacks.OnAssistantDelta != nil {
callbacks.OnAssistantDelta(event)
}
case "reasoningContentEvent":
var event kiroReasoningContentEvent
if err := json.Unmarshal(frame.Payload, &event); err != nil {
return nil, fmt.Errorf("decode reasoning content event: %w", err)
}
if callbacks.OnReasoningDelta != nil {
callbacks.OnReasoningDelta(event)
}
case "toolUseEvent":
var event kiroToolUseEvent
if err := json.Unmarshal(frame.Payload, &event); err != nil {
@ -483,7 +526,7 @@ func (c *kiroAPIClient) applyHeaders(req *http.Request) error {
req.Header.Set("X-Amz-User-Agent", kiroAmzUserAgent)
req.Header.Set("Amz-Sdk-Request", kiroAmzSDKRequest)
req.Header.Set("Amz-Sdk-Invocation-Id", invocationID)
req.Header.Set("X-Amzn-Codewhisperer-Optout", "false")
req.Header.Set("X-Amzn-Codewhisperer-Optout", "true")
for k, v := range c.extraHeaders {
req.Header.Set(k, v)
}
@ -553,11 +596,25 @@ func kiroDefaultEndpoint(profileARN string) (string, error) {
if region == "" {
return "", fmt.Errorf("profile ARN %q does not contain a region", profileARN)
}
return "https://q." + region + ".amazonaws.com/", nil
return "https://runtime." + region + ".kiro.dev/", nil
}
func kiroEndpointForRegion(region string) string {
return "https://q." + strings.TrimSpace(region) + ".amazonaws.com/"
return "https://runtime." + strings.TrimSpace(region) + ".kiro.dev/"
}
func kiroManagementEndpointFor(endpoint string) (string, error) {
parsed, err := url.Parse(strings.TrimSpace(endpoint))
if err != nil {
return "", fmt.Errorf("parse endpoint: %w", err)
}
if parsed.Host == "" {
return "", fmt.Errorf("endpoint %q does not contain a host", endpoint)
}
if strings.HasPrefix(parsed.Host, "runtime.") && strings.HasSuffix(parsed.Host, ".kiro.dev") {
parsed.Host = "management." + strings.TrimPrefix(parsed.Host, "runtime.")
}
return strings.TrimRight(parsed.String(), "/") + "/", nil
}
func kiroNewUUID() (string, error) {
@ -638,8 +695,8 @@ func (c *kiroOIDCClient) RefreshToken(ctx context.Context, req kiroRefreshTokenR
const (
kiroListModelsTarget = "AmazonCodeWhispererService.ListAvailableModels"
kiroListModelsUserAgent = "aws-sdk-rust/1.3.14 ua/2.1 api/codewhispererruntime/0.1.16551 os/linux lang/rust/1.92.0 md/appVersion-0.12.333 app/AmazonQ-For-CLI"
kiroListModelsAmzUserAgent = "aws-sdk-rust/1.3.14 ua/2.1 api/codewhispererruntime/0.1.16551 os/linux lang/rust/1.92.0 m/F,C app/AmazonQ-For-CLI"
kiroListModelsUserAgent = "aws-sdk-rust/1.3.15 ua/2.1 api/codewhispererruntime/0.1.16551 os/linux lang/rust/1.92.0 md/appVersion-2.5.1 app/AmazonQ-For-CLI"
kiroListModelsAmzUserAgent = "aws-sdk-rust/1.3.15 ua/2.1 api/codewhispererruntime/0.1.16551 os/linux lang/rust/1.92.0 m/F,C app/AmazonQ-For-CLI"
)
type kiroListModelsRequest struct {
@ -670,7 +727,24 @@ func (c *kiroAPIClient) ListModels(ctx context.Context, profileARN string) (*kir
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint, bytes.NewReader(body))
// ListModels uses the management endpoint (management.*.kiro.dev) rather
// than the streaming endpoint (runtime.*.kiro.dev).
mgmtEndpoint, err := kiroManagementEndpointFor(c.endpoint)
if err != nil {
mgmtEndpoint = c.endpoint // fall back to streaming endpoint
}
targetURL, err := url.Parse(mgmtEndpoint)
if err != nil {
return nil, fmt.Errorf("parse management endpoint: %w", err)
}
query := targetURL.Query()
query.Set("origin", kiroDefaultOrigin)
if profileARN != "" {
query.Set("profileArn", profileARN)
}
targetURL.RawQuery = query.Encode()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, targetURL.String(), bytes.NewReader(body))
if err != nil {
return nil, err
}