Compare commits

..

36 Commits

Author SHA1 Message Date
Luis Pater
b854ee4680 fix(registry): remove redundant kiro model definition entry 2026-02-04 01:28:12 +08:00
Luis Pater
533a6bd15c Merge pull request #176 from router-for-me/plus
v6.7.45
2026-02-04 01:25:54 +08:00
Luis Pater
45546c1cf7 Merge branch 'main' into plus 2026-02-04 01:25:45 +08:00
Luis Pater
e2169e3987 Merge pull request #175 from Skyuno/fix/json-truncation-rework
fix(kiro): Rework JSON Truncation Handling with SOFT_LIMIT_REACHED
2026-02-04 01:24:35 +08:00
Luis Pater
e85305c815 Merge pull request #174 from gogoing1024/main
feat(registry): add kiro channel support for model definitions
2026-02-04 01:08:43 +08:00
Luis Pater
8d4554bf17 Merge pull request #173 from starsdream666/main
修复:docker镜像上传时用户名使用变量并增加手动构建,修复OAuth 排除列表与OAuth 模型别名中kiro无法获取模型问题
2026-02-04 01:06:49 +08:00
Luis Pater
f628e4dcbb Merge pull request #172 from Skyuno/fix/idc-filename-collision
fix(kiro): prioritize email for filename to prevent collisions
2026-02-04 01:04:33 +08:00
Luis Pater
7accae4b6a Merge pull request #171 from cielhaidir/main
feat(copilot): Add copilot usage monitoring in endpoint /api-call
2026-02-04 01:02:57 +08:00
Luis Pater
3354fae391 Merge pull request #162 from taetaetae/fix/kiro-compaction-empty-content
fix(kiro): handle empty content in messages to prevent Bad Request errors
2026-02-04 01:01:48 +08:00
Luis Pater
2707377fcb docs: add AICodeMirror sponsorship details to README files 2026-02-03 22:34:50 +08:00
Luis Pater
259f586ff7 Fixed: #1398
fix(translator): use model group caching for client signature validation
2026-02-03 22:04:52 +08:00
Luis Pater
d885b81f23 Fixed: #1403
fix(translator): handle "input" field transformation for OpenAI responses
2026-02-03 21:49:30 +08:00
Luis Pater
fe6bffd080 fixed: #1407
fix(translator): adjust "developer" role to "user" and ignore unsupported tool types
2026-02-03 21:41:17 +08:00
starsdream666
1a81e8a98a 一致性问题修复 2026-02-03 21:11:20 +08:00
yuechenglong.5
0b889c6028 feat(registry): add kiro channel support for model definitions
Add kiro as a new supported channel in GetStaticModelDefinitionsByChannel
function, enabling retrieval of Kiro model definitions alongside existing
providers like qwen, iflow, and github-copilot.
2026-02-03 20:55:10 +08:00
starsdream666
f6bb0011f9 修复kiro模型列表缺失 2026-02-03 20:33:13 +08:00
Skyuno
fcdd91895e Merge remote-tracking branch 'upstream/main' into fix/json-truncation-rework 2026-02-03 20:28:32 +08:00
Skyuno
8dc4fc4ff5 fix(idc): prioritize email for filename to prevent collisions
- Use email as primary identifier for IDC tokens (unique, no sequence needed)
- Add sequence number only when email is unavailable
- Use startUrl identifier as secondary fallback with sequence
- Update GenerateTokenFileName in aws.go with consistent logic
2026-02-03 20:04:36 +08:00
starsdream666
9e9a860bda Merge branch 'router-for-me:main' into main 2026-02-03 16:50:42 +08:00
“cielhaidir”
6cd32028c3 refactor: clean up whitespace in enrichCopilotTokenResponse function 2026-02-03 13:14:21 +08:00
“cielhaidir”
ebd58ef33a feat(copilot): enhance quota response with reset dates for enterprise and non-enterprise accounts 2026-02-03 13:13:17 +08:00
“cielhaidir”
92791194e5 feat(copilot): add GitHub Copilot quota management endpoints and response enrichment 2026-02-03 13:02:51 +08:00
taetaetae
1f7c58f7ce refactor: use constants for default assistant messages
Apply code review feedback from gemini-code-assist:
- Define default messages as local constants to improve maintainability
- Avoid magic strings in the empty content handling logic
2026-02-03 07:10:38 +09:00
Luis Pater
b9cdc2f54c chore: remove .air.toml configuration file and update .gitignore 2026-02-03 01:52:35 +08:00
Luis Pater
5e23975d6e Merge branch 'router-for-me:main' into main 2026-02-03 01:50:45 +08:00
Luis Pater
420937c848 Merge pull request #166 from cielhaidir/main
feat: add .air.toml configuration file and update .gitignore for build artifacts
2026-02-03 01:46:02 +08:00
Luis Pater
e1a353ca20 Merge pull request #159 from Skyuno/fix/filter-web-search-tool
fix(kiro): filter web search tool
2026-02-03 01:45:23 +08:00
Luis Pater
250f212fa3 fix(executor): handle "global" location in AI platform URL generation 2026-02-03 01:39:57 +08:00
“cielhaidir”
95a3e32a12 feat: add .air.toml configuration file and update .gitignore for build artifacts
fix: improve PatchOAuthModelAlias logic for handling channel aliases

feat: add support for GitHub Copilot in model definitions
2026-02-02 17:53:58 +08:00
Skyuno
3c7a5afdcc feat: inject web_search alternative hint instead of silently filtering 2026-02-02 05:19:06 +08:00
Skyuno
5dc936a9a4 fix: filter out web_search/websearch tools unsupported by Kiro API 2026-02-02 05:19:06 +08:00
Skyuno
ba168ec003 fix(kiro): skip _partial field (may contain hallucinated paths), add pwd hint for retry 2026-02-02 05:17:39 +08:00
Skyuno
a12e22c66f Revert "Merge pull request #150 from PancakeZik/fix/write-tool-truncation-handling"
This reverts commit fd5b669c87, reversing
changes made to 30d832c9b1.
2026-02-02 05:17:39 +08:00
starsdream666
4c50a7281a Update docker-image.yml 2026-02-02 00:01:00 +08:00
starsdream666
80d3fa384e Update docker-image.yml 2026-02-01 23:58:06 +08:00
taetaetae
b45ede0b71 fix(kiro): handle empty content in messages to prevent Bad Request errors
Problem:
- OpenCode's /compaction command and auto-compaction (at 80%+ context)
  sends requests that can result in empty assistant message content
- Kiro API strictly requires non-empty content for all messages
- This causes 'Bad Request: Improperly formed request' errors
- After compaction failure, the malformed message stays in history,
  breaking all subsequent requests in the session

Solution:
- Add fallback content for empty assistant messages in
  buildAssistantMessageFromOpenAI()
- Add history truncation (max 50 messages) to prevent oversized requests
- This ensures all messages have valid content before sending to Kiro API

Fixes issues with:
- /compaction command returning Bad Request
- Auto-compaction breaking sessions
- Conversations becoming unresponsive after compaction failure
2026-02-01 15:47:18 +09:00
19 changed files with 1140 additions and 199 deletions

View File

@@ -1,13 +1,14 @@
name: docker-image
on:
workflow_dispatch:
push:
tags:
- v*
env:
APP_NAME: CLIProxyAPI
DOCKERHUB_REPO: eceasy/cli-proxy-api-plus
DOCKERHUB_REPO: ${{ secrets.DOCKERHUB_USERNAME }}/cli-proxy-api-plus
jobs:
docker_amd64:

1
.gitignore vendored
View File

@@ -13,6 +13,7 @@ logs/*
conv/*
temp/*
refs/*
tmp/*
# Storage backends
pgstore/*

BIN
assets/aicodemirror.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

4
go.mod
View File

@@ -5,6 +5,7 @@ go 1.24.0
require (
github.com/andybalholm/brotli v1.0.6
github.com/fsnotify/fsnotify v1.9.0
github.com/fxamacker/cbor/v2 v2.9.0
github.com/gin-gonic/gin v1.10.1
github.com/go-git/go-git/v6 v6.0.0-20251009132922-75a182125145
github.com/google/uuid v1.6.0
@@ -13,8 +14,8 @@ require (
github.com/joho/godotenv v1.5.1
github.com/klauspost/compress v1.17.4
github.com/minio/minio-go/v7 v7.0.66
github.com/refraction-networking/utls v1.8.2
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/refraction-networking/utls v1.8.2
github.com/sirupsen/logrus v1.9.3
github.com/tidwall/gjson v1.18.0
github.com/tidwall/sjson v1.2.5
@@ -41,7 +42,6 @@ require (
github.com/dlclark/regexp2 v1.11.5 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/fxamacker/cbor/v2 v2.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/sse v0.1.0 // indirect
github.com/go-git/gcfg/v2 v2.0.2 // indirect

View File

@@ -13,12 +13,13 @@ import (
"github.com/fxamacker/cbor/v2"
"github.com/gin-gonic/gin"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
log "github.com/sirupsen/logrus"
"golang.org/x/net/proxy"
"golang.org/x/oauth2"
"golang.org/x/oauth2/google"
"github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/geminicli"
coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
)
const defaultAPICallTimeout = 60 * time.Second
@@ -55,6 +56,7 @@ type apiCallResponse struct {
StatusCode int `json:"status_code"`
Header map[string][]string `json:"header"`
Body string `json:"body"`
Quota *QuotaSnapshots `json:"quota,omitempty"`
}
// APICall makes a generic HTTP request on behalf of the management API caller.
@@ -97,6 +99,8 @@ type apiCallResponse struct {
// - status_code: Upstream HTTP status code.
// - header: Upstream response headers.
// - body: Upstream response body as string.
// - quota (optional): For GitHub Copilot enterprise accounts, contains quota_snapshots
// with details for chat, completions, and premium_interactions.
//
// Example:
//
@@ -236,6 +240,13 @@ func (h *Handler) APICall(c *gin.Context) {
Body: string(respBody),
}
// If this is a GitHub Copilot token endpoint response, try to enrich with quota information
if resp.StatusCode == http.StatusOK &&
strings.Contains(urlStr, "copilot_internal") &&
strings.Contains(urlStr, "/token") {
response = h.enrichCopilotTokenResponse(c.Request.Context(), response, auth, urlStr)
}
// Return response in the same format as the request
if isCBOR {
cborData, errMarshal := cbor.Marshal(response)
@@ -735,3 +746,344 @@ func buildProxyTransport(proxyStr string) *http.Transport {
log.Debugf("unsupported proxy scheme: %s", proxyURL.Scheme)
return nil
}
// QuotaDetail represents quota information for a specific resource type
type QuotaDetail struct {
Entitlement float64 `json:"entitlement"`
OverageCount float64 `json:"overage_count"`
OveragePermitted bool `json:"overage_permitted"`
PercentRemaining float64 `json:"percent_remaining"`
QuotaID string `json:"quota_id"`
QuotaRemaining float64 `json:"quota_remaining"`
Remaining float64 `json:"remaining"`
Unlimited bool `json:"unlimited"`
}
// QuotaSnapshots contains quota details for different resource types
type QuotaSnapshots struct {
Chat QuotaDetail `json:"chat"`
Completions QuotaDetail `json:"completions"`
PremiumInteractions QuotaDetail `json:"premium_interactions"`
}
// CopilotUsageResponse represents the GitHub Copilot usage information
type CopilotUsageResponse struct {
AccessTypeSKU string `json:"access_type_sku"`
AnalyticsTrackingID string `json:"analytics_tracking_id"`
AssignedDate string `json:"assigned_date"`
CanSignupForLimited bool `json:"can_signup_for_limited"`
ChatEnabled bool `json:"chat_enabled"`
CopilotPlan string `json:"copilot_plan"`
OrganizationLoginList []interface{} `json:"organization_login_list"`
OrganizationList []interface{} `json:"organization_list"`
QuotaResetDate string `json:"quota_reset_date"`
QuotaSnapshots QuotaSnapshots `json:"quota_snapshots"`
}
type copilotQuotaRequest struct {
AuthIndexSnake *string `json:"auth_index"`
AuthIndexCamel *string `json:"authIndex"`
AuthIndexPascal *string `json:"AuthIndex"`
}
// GetCopilotQuota fetches GitHub Copilot quota information from the /copilot_internal/user endpoint.
//
// Endpoint:
//
// GET /v0/management/copilot-quota
//
// Query Parameters (optional):
// - auth_index: The credential "auth_index" from GET /v0/management/auth-files.
// If omitted, uses the first available GitHub Copilot credential.
//
// Response:
//
// Returns the CopilotUsageResponse with quota_snapshots containing detailed quota information
// for chat, completions, and premium_interactions.
//
// Example:
//
// curl -sS -X GET "http://127.0.0.1:8317/v0/management/copilot-quota?auth_index=<AUTH_INDEX>" \
// -H "Authorization: Bearer <MANAGEMENT_KEY>"
func (h *Handler) GetCopilotQuota(c *gin.Context) {
authIndex := strings.TrimSpace(c.Query("auth_index"))
if authIndex == "" {
authIndex = strings.TrimSpace(c.Query("authIndex"))
}
if authIndex == "" {
authIndex = strings.TrimSpace(c.Query("AuthIndex"))
}
auth := h.findCopilotAuth(authIndex)
if auth == nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "no github copilot credential found"})
return
}
token, tokenErr := h.resolveTokenForAuth(c.Request.Context(), auth)
if tokenErr != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to refresh copilot token"})
return
}
if token == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "copilot token not found"})
return
}
apiURL := "https://api.github.com/copilot_internal/user"
req, errNewRequest := http.NewRequestWithContext(c.Request.Context(), http.MethodGet, apiURL, nil)
if errNewRequest != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to build request"})
return
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "CLIProxyAPIPlus")
req.Header.Set("Accept", "application/json")
httpClient := &http.Client{
Timeout: defaultAPICallTimeout,
Transport: h.apiCallTransport(auth),
}
resp, errDo := httpClient.Do(req)
if errDo != nil {
log.WithError(errDo).Debug("copilot quota request failed")
c.JSON(http.StatusBadGateway, gin.H{"error": "request failed"})
return
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("response body close error: %v", errClose)
}
}()
respBody, errReadAll := io.ReadAll(resp.Body)
if errReadAll != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "failed to read response"})
return
}
if resp.StatusCode != http.StatusOK {
c.JSON(http.StatusBadGateway, gin.H{
"error": "github api request failed",
"status_code": resp.StatusCode,
"body": string(respBody),
})
return
}
var usage CopilotUsageResponse
if errUnmarshal := json.Unmarshal(respBody, &usage); errUnmarshal != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to parse response"})
return
}
c.JSON(http.StatusOK, usage)
}
// findCopilotAuth locates a GitHub Copilot credential by auth_index or returns the first available one
func (h *Handler) findCopilotAuth(authIndex string) *coreauth.Auth {
if h == nil || h.authManager == nil {
return nil
}
auths := h.authManager.List()
var firstCopilot *coreauth.Auth
for _, auth := range auths {
if auth == nil {
continue
}
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
if provider != "copilot" && provider != "github" && provider != "github-copilot" {
continue
}
if firstCopilot == nil {
firstCopilot = auth
}
if authIndex != "" {
auth.EnsureIndex()
if auth.Index == authIndex {
return auth
}
}
}
return firstCopilot
}
// enrichCopilotTokenResponse fetches quota information and adds it to the Copilot token response body
func (h *Handler) enrichCopilotTokenResponse(ctx context.Context, response apiCallResponse, auth *coreauth.Auth, originalURL string) apiCallResponse {
if auth == nil || response.Body == "" {
return response
}
// Parse the token response to check if it's enterprise (null limited_user_quotas)
var tokenResp map[string]interface{}
if err := json.Unmarshal([]byte(response.Body), &tokenResp); err != nil {
log.WithError(err).Debug("enrichCopilotTokenResponse: failed to parse copilot token response")
return response
}
// Get the GitHub token to call the copilot_internal/user endpoint
token, tokenErr := h.resolveTokenForAuth(ctx, auth)
if tokenErr != nil {
log.WithError(tokenErr).Debug("enrichCopilotTokenResponse: failed to resolve token")
return response
}
if token == "" {
return response
}
// Fetch quota information from /copilot_internal/user
// Derive the base URL from the original token request to support proxies and test servers
parsedURL, errParse := url.Parse(originalURL)
if errParse != nil {
log.WithError(errParse).Debug("enrichCopilotTokenResponse: failed to parse URL")
return response
}
quotaURL := fmt.Sprintf("%s://%s/copilot_internal/user", parsedURL.Scheme, parsedURL.Host)
req, errNewRequest := http.NewRequestWithContext(ctx, http.MethodGet, quotaURL, nil)
if errNewRequest != nil {
log.WithError(errNewRequest).Debug("enrichCopilotTokenResponse: failed to build request")
return response
}
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("User-Agent", "CLIProxyAPIPlus")
req.Header.Set("Accept", "application/json")
httpClient := &http.Client{
Timeout: defaultAPICallTimeout,
Transport: h.apiCallTransport(auth),
}
quotaResp, errDo := httpClient.Do(req)
if errDo != nil {
log.WithError(errDo).Debug("enrichCopilotTokenResponse: quota fetch HTTP request failed")
return response
}
defer func() {
if errClose := quotaResp.Body.Close(); errClose != nil {
log.Errorf("quota response body close error: %v", errClose)
}
}()
if quotaResp.StatusCode != http.StatusOK {
return response
}
quotaBody, errReadAll := io.ReadAll(quotaResp.Body)
if errReadAll != nil {
log.WithError(errReadAll).Debug("enrichCopilotTokenResponse: failed to read response")
return response
}
// Parse the quota response
var quotaData CopilotUsageResponse
if err := json.Unmarshal(quotaBody, &quotaData); err != nil {
log.WithError(err).Debug("enrichCopilotTokenResponse: failed to parse response")
return response
}
// Check if this is an enterprise account by looking for quota_snapshots in the response
// Enterprise accounts have quota_snapshots, non-enterprise have limited_user_quotas
var quotaRaw map[string]interface{}
if err := json.Unmarshal(quotaBody, &quotaRaw); err == nil {
if _, hasQuotaSnapshots := quotaRaw["quota_snapshots"]; hasQuotaSnapshots {
// Enterprise account - has quota_snapshots
tokenResp["quota_snapshots"] = quotaData.QuotaSnapshots
tokenResp["access_type_sku"] = quotaData.AccessTypeSKU
tokenResp["copilot_plan"] = quotaData.CopilotPlan
// Add quota reset date for enterprise (quota_reset_date_utc)
if quotaResetDateUTC, ok := quotaRaw["quota_reset_date_utc"]; ok {
tokenResp["quota_reset_date"] = quotaResetDateUTC
} else if quotaData.QuotaResetDate != "" {
tokenResp["quota_reset_date"] = quotaData.QuotaResetDate
}
} else {
// Non-enterprise account - build quota from limited_user_quotas and monthly_quotas
var quotaSnapshots QuotaSnapshots
// Get monthly quotas (total entitlement) and limited_user_quotas (remaining)
monthlyQuotas, hasMonthly := quotaRaw["monthly_quotas"].(map[string]interface{})
limitedQuotas, hasLimited := quotaRaw["limited_user_quotas"].(map[string]interface{})
// Process chat quota
if hasMonthly && hasLimited {
if chatTotal, ok := monthlyQuotas["chat"].(float64); ok {
chatRemaining := chatTotal // default to full if no limited quota
if chatLimited, ok := limitedQuotas["chat"].(float64); ok {
chatRemaining = chatLimited
}
percentRemaining := 0.0
if chatTotal > 0 {
percentRemaining = (chatRemaining / chatTotal) * 100.0
}
quotaSnapshots.Chat = QuotaDetail{
Entitlement: chatTotal,
Remaining: chatRemaining,
QuotaRemaining: chatRemaining,
PercentRemaining: percentRemaining,
QuotaID: "chat",
Unlimited: false,
}
}
// Process completions quota
if completionsTotal, ok := monthlyQuotas["completions"].(float64); ok {
completionsRemaining := completionsTotal // default to full if no limited quota
if completionsLimited, ok := limitedQuotas["completions"].(float64); ok {
completionsRemaining = completionsLimited
}
percentRemaining := 0.0
if completionsTotal > 0 {
percentRemaining = (completionsRemaining / completionsTotal) * 100.0
}
quotaSnapshots.Completions = QuotaDetail{
Entitlement: completionsTotal,
Remaining: completionsRemaining,
QuotaRemaining: completionsRemaining,
PercentRemaining: percentRemaining,
QuotaID: "completions",
Unlimited: false,
}
}
}
// Premium interactions don't exist for non-enterprise, leave as zero values
quotaSnapshots.PremiumInteractions = QuotaDetail{
QuotaID: "premium_interactions",
Unlimited: false,
}
// Add quota_snapshots to the token response
tokenResp["quota_snapshots"] = quotaSnapshots
tokenResp["access_type_sku"] = quotaData.AccessTypeSKU
tokenResp["copilot_plan"] = quotaData.CopilotPlan
// Add quota reset date for non-enterprise (limited_user_reset_date)
if limitedResetDate, ok := quotaRaw["limited_user_reset_date"]; ok {
tokenResp["quota_reset_date"] = limitedResetDate
}
}
}
// Re-serialize the enriched response
enrichedBody, errMarshal := json.Marshal(tokenResp)
if errMarshal != nil {
log.WithError(errMarshal).Debug("failed to marshal enriched response")
return response
}
response.Body = string(enrichedBody)
return response
}

View File

@@ -754,18 +754,22 @@ func (h *Handler) PatchOAuthModelAlias(c *gin.Context) {
normalizedMap := sanitizedOAuthModelAlias(map[string][]config.OAuthModelAlias{channel: body.Aliases})
normalized := normalizedMap[channel]
if len(normalized) == 0 {
// Only delete if channel exists, otherwise just create empty entry
if h.cfg.OAuthModelAlias != nil {
if _, ok := h.cfg.OAuthModelAlias[channel]; ok {
delete(h.cfg.OAuthModelAlias, channel)
if len(h.cfg.OAuthModelAlias) == 0 {
h.cfg.OAuthModelAlias = nil
}
h.persist(c)
return
}
}
// Create new channel with empty aliases
if h.cfg.OAuthModelAlias == nil {
c.JSON(404, gin.H{"error": "channel not found"})
return
}
if _, ok := h.cfg.OAuthModelAlias[channel]; !ok {
c.JSON(404, gin.H{"error": "channel not found"})
return
}
delete(h.cfg.OAuthModelAlias, channel)
if len(h.cfg.OAuthModelAlias) == 0 {
h.cfg.OAuthModelAlias = nil
h.cfg.OAuthModelAlias = make(map[string][]config.OAuthModelAlias)
}
h.cfg.OAuthModelAlias[channel] = []config.OAuthModelAlias{}
h.persist(c)
return
}

View File

@@ -92,7 +92,7 @@ const KiroIDETokenFile = ".aws/sso/cache/kiro-auth-token.json"
// Default retry configuration for file reading
const (
defaultTokenReadMaxAttempts = 10 // Maximum retry attempts
defaultTokenReadMaxAttempts = 10 // Maximum retry attempts
defaultTokenReadBaseDelay = 50 * time.Millisecond // Base delay between retries
)
@@ -301,7 +301,7 @@ func ListKiroTokenFiles() ([]string, error) {
}
cacheDir := filepath.Join(homeDir, ".aws", "sso", "cache")
// Check if directory exists
if _, err := os.Stat(cacheDir); os.IsNotExist(err) {
return nil, nil // No token files
@@ -488,14 +488,16 @@ func ExtractIDCIdentifier(startURL string) string {
// GenerateTokenFileName generates a unique filename for token storage.
// Priority: email > startUrl identifier (for IDC) > authMethod only
// Format: kiro-{authMethod}-{identifier}.json
// Email is unique, so no sequence suffix needed. Sequence is only added
// when email is unavailable to prevent filename collisions.
// Format: kiro-{authMethod}-{identifier}[-{seq}].json
func GenerateTokenFileName(tokenData *KiroTokenData) string {
authMethod := tokenData.AuthMethod
if authMethod == "" {
authMethod = "unknown"
}
// Priority 1: Use email if available
// Priority 1: Use email if available (no sequence needed, email is unique)
if tokenData.Email != "" {
// Sanitize email for filename (replace @ and . with -)
sanitizedEmail := tokenData.Email
@@ -504,14 +506,17 @@ func GenerateTokenFileName(tokenData *KiroTokenData) string {
return fmt.Sprintf("kiro-%s-%s.json", authMethod, sanitizedEmail)
}
// Priority 2: For IDC, use startUrl identifier
// Generate sequence only when email is unavailable
seq := time.Now().UnixNano() % 100000
// Priority 2: For IDC, use startUrl identifier with sequence
if authMethod == "idc" && tokenData.StartURL != "" {
identifier := ExtractIDCIdentifier(tokenData.StartURL)
if identifier != "" {
return fmt.Sprintf("kiro-%s-%s.json", authMethod, identifier)
return fmt.Sprintf("kiro-%s-%s-%05d.json", authMethod, identifier, seq)
}
}
// Priority 3: Fallback to authMethod only
return fmt.Sprintf("kiro-%s.json", authMethod)
// Priority 3: Fallback to authMethod only with sequence
return fmt.Sprintf("kiro-%s-%05d.json", authMethod, seq)
}

View File

@@ -19,6 +19,10 @@ import (
// - codex
// - qwen
// - iflow
// - kiro
// - github-copilot
// - kiro
// - amazonq
// - antigravity (returns static overrides only)
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
key := strings.ToLower(strings.TrimSpace(channel))
@@ -39,6 +43,12 @@ func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
return GetQwenModels()
case "iflow":
return GetIFlowModels()
case "github-copilot":
return GetGitHubCopilotModels()
case "kiro":
return GetKiroModels()
case "amazonq":
return GetAmazonQModels()
case "antigravity":
cfg := GetAntigravityModelConfig()
if len(cfg) == 0 {
@@ -83,6 +93,9 @@ func LookupStaticModelInfo(modelID string) *ModelInfo {
GetOpenAIModels(),
GetQwenModels(),
GetIFlowModels(),
GetGitHubCopilotModels(),
GetKiroModels(),
GetAmazonQModels(),
}
for _, models := range allModels {
for _, m := range models {

View File

@@ -1003,6 +1003,8 @@ func vertexBaseURL(location string) string {
loc := strings.TrimSpace(location)
if loc == "" {
loc = "us-central1"
} else if loc == "global" {
return "https://aiplatform.googleapis.com"
}
return fmt.Sprintf("https://%s-aiplatform.googleapis.com", loc)
}

View File

@@ -2442,8 +2442,9 @@ func (e *KiroExecutor) extractEventTypeFromBytes(headers []byte) string {
func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out chan<- cliproxyexecutor.StreamChunk, targetFormat sdktranslator.Format, model string, originalReq, claudeBody []byte, reporter *usageReporter, thinkingEnabled bool) {
reader := bufio.NewReaderSize(body, 20*1024*1024) // 20MB buffer to match other providers
var totalUsage usage.Detail
var hasToolUses bool // Track if any tool uses were emitted
var upstreamStopReason string // Track stop_reason from upstream events
var hasToolUses bool // Track if any tool uses were emitted
var hasTruncatedTools bool // Track if any tool uses were truncated
var upstreamStopReason string // Track stop_reason from upstream events
// Tool use state tracking for input buffering and deduplication
processedIDs := make(map[string]bool)
@@ -3221,40 +3222,16 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
_ = signature // Signature can be used for verification if needed
case "toolUseEvent":
// Debug: log raw toolUseEvent payload for large tool inputs
if log.IsLevelEnabled(log.DebugLevel) {
payloadStr := string(payload)
if len(payloadStr) > 500 {
payloadStr = payloadStr[:500] + "...[truncated]"
}
log.Debugf("kiro: raw toolUseEvent payload (%d bytes): %s", len(payload), payloadStr)
}
// Handle dedicated tool use events with input buffering
completedToolUses, newState := kiroclaude.ProcessToolUseEvent(event, currentToolUse, processedIDs)
currentToolUse = newState
// Emit completed tool uses
for _, tu := range completedToolUses {
// Check for truncated write marker - emit as a Bash tool that echoes the error
// This way Claude Code will execute it, see the error, and the agent can retry
if tu.Name == "__truncated_write__" {
filePath := ""
if fp, ok := tu.Input["file_path"].(string); ok && fp != "" {
filePath = fp
}
// Create a Bash tool that echoes the error message
// This will be executed by Claude Code and the agent will see the result
var errorMsg string
if filePath != "" {
errorMsg = fmt.Sprintf("echo '[WRITE TOOL ERROR] The file content for \"%s\" is too large to be transmitted by the upstream API. You MUST retry by writing the file in smaller chunks: First use Write to create the file with the first 700 lines, then use multiple Edit operations to append the remaining content in chunks of ~700 lines each.'", filePath)
} else {
errorMsg = "echo '[WRITE TOOL ERROR] The file content is too large to be transmitted by the upstream API. The Write tool input was truncated. You MUST retry by writing the file in smaller chunks: First use Write to create the file with the first 700 lines, then use multiple Edit operations to append the remaining content in chunks of ~700 lines each.'"
}
log.Warnf("kiro: converting truncated write to Bash echo for file: %s", filePath)
hasToolUses = true
// Check if this tool was truncated - emit with SOFT_LIMIT_REACHED marker
if tu.IsTruncated {
hasTruncatedTools = true
log.Infof("kiro: streamToChannel emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", tu.Name, tu.ToolUseID)
// Close text block if open
if isTextBlockOpen && contentBlockIndex >= 0 {
@@ -3270,8 +3247,8 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
contentBlockIndex++
// Emit as Bash tool_use
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, "Bash")
// Emit tool_use with SOFT_LIMIT_REACHED marker input
blockStart := kiroclaude.BuildClaudeContentBlockStartEvent(contentBlockIndex, "tool_use", tu.ToolUseID, tu.Name)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStart, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
@@ -3279,16 +3256,14 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
// Emit the Bash command as input
bashInput := map[string]interface{}{
"command": errorMsg,
// Build SOFT_LIMIT_REACHED marker input
markerInput := map[string]interface{}{
"_status": "SOFT_LIMIT_REACHED",
"_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.",
}
inputJSON, err := json.Marshal(bashInput)
if err != nil {
log.Errorf("kiro: failed to marshal bash input for truncated write error: %v", err)
continue
}
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(inputJSON), contentBlockIndex)
markerJSON, _ := json.Marshal(markerInput)
inputDelta := kiroclaude.BuildClaudeInputJsonDeltaEvent(string(markerJSON), contentBlockIndex)
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, inputDelta, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
@@ -3296,6 +3271,7 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
// Close tool_use block
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
sseData = sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
for _, chunk := range sseData {
@@ -3304,7 +3280,8 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
}
continue // Skip the normal tool_use emission
hasToolUses = true // Keep this so stop_reason = tool_use
continue
}
hasToolUses = true
@@ -3605,7 +3582,12 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
}
// Determine stop reason: prefer upstream, then detect tool_use, default to end_turn
// SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop
stopReason := upstreamStopReason
if hasTruncatedTools {
// Log that we're using SOFT_LIMIT_REACHED approach
log.Infof("kiro: streamToChannel using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use for truncated tools")
}
if stopReason == "" {
if hasToolUses {
stopReason = "tool_use"

View File

@@ -115,7 +115,7 @@ func ConvertClaudeRequestToAntigravity(modelName string, inputRawJSON []byte, _
if signatureResult.Exists() && signatureResult.String() != "" {
arrayClientSignatures := strings.SplitN(signatureResult.String(), "#", 2)
if len(arrayClientSignatures) == 2 {
if modelName == arrayClientSignatures[0] {
if cache.GetModelGroup(modelName) == arrayClientSignatures[0] {
clientSignature = arrayClientSignatures[1]
}
}

View File

@@ -11,6 +11,12 @@ import (
func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := bytes.Clone(inputRawJSON)
inputResult := gjson.GetBytes(rawJSON, "input")
if inputResult.Type == gjson.String {
input, _ := sjson.Set(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`, "0.content.0.text", inputResult.String())
rawJSON, _ = sjson.SetRawBytes(rawJSON, "input", []byte(input))
}
rawJSON, _ = sjson.SetBytes(rawJSON, "stream", true)
rawJSON, _ = sjson.SetBytes(rawJSON, "store", false)
rawJSON, _ = sjson.SetBytes(rawJSON, "parallel_tool_calls", true)

View File

@@ -17,7 +17,6 @@ import (
"github.com/tidwall/gjson"
)
// Kiro API request structs - field order determines JSON key order
// KiroPayload is the top-level request structure for Kiro API
@@ -34,7 +33,6 @@ type KiroInferenceConfig struct {
TopP float64 `json:"topP,omitempty"`
}
// KiroConversationState holds the conversation context
type KiroConversationState struct {
ChatTriggerType string `json:"chatTriggerType"` // Required: "MANUAL" - must be first field
@@ -117,9 +115,11 @@ type KiroAssistantResponseMessage struct {
// KiroToolUse represents a tool invocation by the assistant
type KiroToolUse struct {
ToolUseID string `json:"toolUseId"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
ToolUseID string `json:"toolUseId"`
Name string `json:"name"`
Input map[string]interface{} `json:"input"`
IsTruncated bool `json:"-"` // Internal flag, not serialized
TruncationInfo *TruncationInfo `json:"-"` // Truncation details, not serialized
}
// ConvertClaudeRequestToKiro converts a Claude API request to Kiro format.
@@ -219,16 +219,35 @@ func BuildKiroPayload(claudeBody []byte, modelID, profileArn, origin string, isA
}
// Convert Claude tools to Kiro format
kiroTools := convertClaudeToolsToKiro(tools)
kiroTools, hasWebSearch := convertClaudeToolsToKiro(tools)
// If web_search was requested but filtered, inject alternative hint
if hasWebSearch {
webSearchHint := `[CRITICAL WEB ACCESS INSTRUCTION]
You have the Fetch/read_url_content tool available. When the user asks about current events, weather, news, or any information that requires web access:
- DO NOT say you cannot search the web
- DO NOT refuse to help with web-related queries
- IMMEDIATELY use the Fetch tool to access relevant URLs
- Use well-known official websites, documentation sites, or API endpoints
- Construct appropriate URLs based on the query context
IMPORTANT: Always attempt to fetch information FIRST before declining. You CAN access the web via Fetch.`
if systemPrompt != "" {
systemPrompt += "\n" + webSearchHint
} else {
systemPrompt = webSearchHint
}
log.Infof("kiro: injected web_search alternative hint (tool was filtered)")
}
// Thinking mode implementation:
// Kiro API supports official thinking/reasoning mode via <thinking_mode> tag.
// When set to "enabled", Kiro returns reasoning content as official reasoningContentEvent
// rather than inline <thinking> tags in assistantResponseEvent.
// We use a high max_thinking_length to allow extensive reasoning.
// We cap max_thinking_length to reserve space for tool outputs and prevent truncation.
if thinkingEnabled {
thinkingHint := `<thinking_mode>enabled</thinking_mode>
<max_thinking_length>200000</max_thinking_length>`
<max_thinking_length>16000</max_thinking_length>`
if systemPrompt != "" {
systemPrompt = thinkingHint + "\n\n" + systemPrompt
} else {
@@ -378,7 +397,6 @@ func hasThinkingTagInBody(body []byte) bool {
return strings.Contains(bodyStr, "<thinking_mode>") || strings.Contains(bodyStr, "<max_thinking_length>")
}
// IsThinkingEnabledFromHeader checks if thinking mode is enabled via Anthropic-Beta header.
// Claude CLI uses "Anthropic-Beta: interleaved-thinking-2025-05-14" to enable thinking.
func IsThinkingEnabledFromHeader(headers http.Header) bool {
@@ -509,15 +527,27 @@ func ensureKiroInputSchema(parameters interface{}) interface{} {
}
}
// convertClaudeToolsToKiro converts Claude tools to Kiro format
func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
// convertClaudeToolsToKiro converts Claude tools to Kiro format.
// Returns the converted tools and a boolean indicating if web_search was filtered.
func convertClaudeToolsToKiro(tools gjson.Result) ([]KiroToolWrapper, bool) {
var kiroTools []KiroToolWrapper
hasWebSearch := false
if !tools.IsArray() {
return kiroTools
return kiroTools, hasWebSearch
}
for _, tool := range tools.Array() {
name := tool.Get("name").String()
// Filter out web_search/websearch tools (Kiro API doesn't support them)
// This matches the behavior in AIClient-2-API/claude-kiro.js
nameLower := strings.ToLower(name)
if nameLower == "web_search" || nameLower == "websearch" {
log.Debugf("kiro: skipping unsupported tool: %s", name)
hasWebSearch = true
continue
}
description := tool.Get("description").String()
inputSchemaResult := tool.Get("input_schema")
var inputSchema interface{}
@@ -561,7 +591,7 @@ func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
// This prevents 500 errors when Claude Code sends too many tools
kiroTools = compressToolsIfNeeded(kiroTools)
return kiroTools
return kiroTools, hasWebSearch
}
// processMessages processes Claude messages and builds Kiro history
@@ -743,7 +773,35 @@ func BuildUserMessageStruct(msg gjson.Result, modelID, origin string) (KiroUserI
resultContent := part.Get("content")
var textContents []KiroTextContent
if resultContent.IsArray() {
// Check if this tool_result contains error from our SOFT_LIMIT_REACHED tool_use
// The client will return an error when trying to execute a tool with marker input
resultStr := resultContent.String()
isSoftLimitError := strings.Contains(resultStr, "SOFT_LIMIT_REACHED") ||
strings.Contains(resultStr, "_status") ||
strings.Contains(resultStr, "truncated") ||
strings.Contains(resultStr, "missing required") ||
strings.Contains(resultStr, "invalid input") ||
strings.Contains(resultStr, "Error writing file")
if isError && isSoftLimitError {
// Replace error content with SOFT_LIMIT_REACHED guidance
log.Infof("kiro: detected SOFT_LIMIT_REACHED in tool_result for %s, replacing with guidance", toolUseID)
softLimitMsg := `SOFT_LIMIT_REACHED
Your previous tool call was incomplete due to API output size limits.
The content was PARTIALLY transmitted but NOT executed.
REQUIRED ACTION:
1. Split your content into smaller chunks (max 300 lines per call)
2. For file writes: Create file with first chunk, then use append for remaining
3. Do NOT regenerate content you already attempted - continue from where you stopped
STATUS: This is NOT an error. Continue with smaller chunks.`
textContents = append(textContents, KiroTextContent{Text: softLimitMsg})
// Mark as SUCCESS so Claude doesn't treat it as a failure
isError = false
} else if resultContent.IsArray() {
for _, item := range resultContent.Array() {
if item.Get("type").String() == "text" {
textContents = append(textContents, KiroTextContent{Text: item.Get("text").String()})

View File

@@ -55,14 +55,39 @@ func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, u
}
}
// Add tool_use blocks
// Add tool_use blocks - emit truncated tools with SOFT_LIMIT_REACHED marker
hasTruncatedTools := false
for _, toolUse := range toolUses {
contentBlocks = append(contentBlocks, map[string]interface{}{
"type": "tool_use",
"id": toolUse.ToolUseID,
"name": toolUse.Name,
"input": toolUse.Input,
})
if toolUse.IsTruncated && toolUse.TruncationInfo != nil {
// Emit tool_use with SOFT_LIMIT_REACHED marker input
hasTruncatedTools = true
log.Infof("kiro: buildClaudeResponse emitting truncated tool with SOFT_LIMIT_REACHED: %s (ID: %s)", toolUse.Name, toolUse.ToolUseID)
markerInput := map[string]interface{}{
"_status": "SOFT_LIMIT_REACHED",
"_message": "Tool output was truncated. Split content into smaller chunks (max 300 lines). Due to potential model hallucination, you MUST re-fetch the current working directory and generate the correct file_path.",
}
contentBlocks = append(contentBlocks, map[string]interface{}{
"type": "tool_use",
"id": toolUse.ToolUseID,
"name": toolUse.Name,
"input": markerInput,
})
} else {
// Normal tool use
contentBlocks = append(contentBlocks, map[string]interface{}{
"type": "tool_use",
"id": toolUse.ToolUseID,
"name": toolUse.Name,
"input": toolUse.Input,
})
}
}
// Log if we used SOFT_LIMIT_REACHED
if hasTruncatedTools {
log.Infof("kiro: buildClaudeResponse using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use")
}
// Ensure at least one content block (Claude API requires non-empty content)
@@ -74,6 +99,7 @@ func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, u
}
// Use upstream stopReason; apply fallback logic if not provided
// SOFT_LIMIT_REACHED: Keep stop_reason = "tool_use" so Claude continues the loop
if stopReason == "" {
stopReason = "end_turn"
if len(toolUses) > 0 {
@@ -201,4 +227,4 @@ func ExtractThinkingFromContent(content string) []map[string]interface{} {
}
return blocks
}
}

View File

@@ -14,10 +14,11 @@ import (
// ToolUseState tracks the state of an in-progress tool use during streaming.
type ToolUseState struct {
ToolUseID string
Name string
InputBuffer strings.Builder
IsComplete bool
ToolUseID string
Name string
InputBuffer strings.Builder
IsComplete bool
TruncationInfo *TruncationInfo // Truncation detection result (set when complete)
}
// Pre-compiled regex patterns for performance
@@ -395,17 +396,6 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt
isStop = stop
}
// Debug: log when stop event arrives
if isStop {
log.Debugf("kiro: toolUseEvent stop=true received for tool %s (ID: %s), currentToolUse buffer len: %d",
toolName, toolUseID, func() int {
if currentToolUse != nil {
return currentToolUse.InputBuffer.Len()
}
return -1
}())
}
// Get input - can be string (fragment) or object (complete)
var inputFragment string
var inputMap map[string]interface{}
@@ -477,98 +467,39 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt
if isStop && currentToolUse != nil {
fullInput := currentToolUse.InputBuffer.String()
// Check for Write tool with empty or missing input - this happens when Kiro API
// completely skips sending input for large file writes
if currentToolUse.Name == "Write" && len(strings.TrimSpace(fullInput)) == 0 {
log.Warnf("kiro: Write tool received no input from upstream API. The file content may be too large to transmit.")
// Return nil to skip this tool use - it will be handled as a truncation error
// The caller should emit a text block explaining the error instead
if processedIDs != nil {
processedIDs[currentToolUse.ToolUseID] = true
}
log.Infof("kiro: skipping Write tool use %s due to empty input (content too large)", currentToolUse.ToolUseID)
// Return a special marker tool use that indicates truncation
toolUse := KiroToolUse{
ToolUseID: currentToolUse.ToolUseID,
Name: "__truncated_write__", // Special marker name
Input: map[string]interface{}{
"error": "Write tool input was not transmitted by upstream API. The file content is too large.",
},
}
toolUses = append(toolUses, toolUse)
return toolUses, nil
}
// Repair and parse the accumulated JSON
repairedJSON := RepairJSON(fullInput)
var finalInput map[string]interface{}
if err := json.Unmarshal([]byte(repairedJSON), &finalInput); err != nil {
log.Warnf("kiro: failed to parse accumulated tool input: %v, raw: %s", err, fullInput)
finalInput = make(map[string]interface{})
// Check if this is a Write tool with truncated input (missing content field)
// This happens when the Kiro API truncates large tool inputs
if currentToolUse.Name == "Write" && strings.Contains(fullInput, "file_path") && !strings.Contains(fullInput, "content") {
log.Warnf("kiro: Write tool input was truncated by upstream API (content field missing). The file content may be too large.")
// Extract file_path if possible for error context
filePath := ""
if idx := strings.Index(fullInput, "file_path"); idx >= 0 {
// Try to extract the file path value
rest := fullInput[idx:]
if colonIdx := strings.Index(rest, ":"); colonIdx >= 0 {
rest = strings.TrimSpace(rest[colonIdx+1:])
if len(rest) > 0 && rest[0] == '"' {
rest = rest[1:]
if endQuote := strings.Index(rest, "\""); endQuote >= 0 {
filePath = rest[:endQuote]
}
}
}
}
if processedIDs != nil {
processedIDs[currentToolUse.ToolUseID] = true
}
// Return a special marker tool use that indicates truncation
toolUse := KiroToolUse{
ToolUseID: currentToolUse.ToolUseID,
Name: "__truncated_write__", // Special marker name
Input: map[string]interface{}{
"error": "Write tool content was truncated by upstream API. The file content is too large.",
"file_path": filePath,
},
}
toolUses = append(toolUses, toolUse)
return toolUses, nil
}
}
// Additional check: Write tool parsed successfully but missing content field
if currentToolUse.Name == "Write" {
if _, hasContent := finalInput["content"]; !hasContent {
if filePath, hasPath := finalInput["file_path"]; hasPath {
log.Warnf("kiro: Write tool input missing 'content' field, likely truncated by upstream API")
if processedIDs != nil {
processedIDs[currentToolUse.ToolUseID] = true
}
// Return a special marker tool use that indicates truncation
toolUse := KiroToolUse{
ToolUseID: currentToolUse.ToolUseID,
Name: "__truncated_write__", // Special marker name
Input: map[string]interface{}{
"error": "Write tool content field was missing. The file content is too large.",
"file_path": filePath,
},
}
toolUses = append(toolUses, toolUse)
return toolUses, nil
}
// Detect truncation for all tools
truncInfo := DetectTruncation(currentToolUse.Name, currentToolUse.ToolUseID, fullInput, finalInput)
if truncInfo.IsTruncated {
log.Warnf("kiro: TRUNCATION DETECTED for tool %s (ID: %s): type=%s, raw_size=%d bytes",
currentToolUse.Name, currentToolUse.ToolUseID, truncInfo.TruncationType, len(fullInput))
log.Warnf("kiro: truncation details: %s", truncInfo.ErrorMessage)
if len(truncInfo.ParsedFields) > 0 {
log.Infof("kiro: partial fields received: %v", truncInfo.ParsedFields)
}
// Store truncation info in the state for upstream handling
currentToolUse.TruncationInfo = &truncInfo
} else {
log.Infof("kiro: tool use %s input length: %d bytes (no truncation)", currentToolUse.Name, len(fullInput))
}
// Create the tool use with truncation info if applicable
toolUse := KiroToolUse{
ToolUseID: currentToolUse.ToolUseID,
Name: currentToolUse.Name,
Input: finalInput,
ToolUseID: currentToolUse.ToolUseID,
Name: currentToolUse.Name,
Input: finalInput,
IsTruncated: truncInfo.IsTruncated,
TruncationInfo: nil, // Will be set below if truncated
}
if truncInfo.IsTruncated {
toolUse.TruncationInfo = &truncInfo
}
toolUses = append(toolUses, toolUse)
@@ -576,7 +507,7 @@ func ProcessToolUseEvent(event map[string]interface{}, currentToolUse *ToolUseSt
processedIDs[currentToolUse.ToolUseID] = true
}
log.Infof("kiro: completed tool use: %s (ID: %s)", currentToolUse.Name, currentToolUse.ToolUseID)
log.Infof("kiro: completed tool use: %s (ID: %s, truncated: %v)", currentToolUse.Name, currentToolUse.ToolUseID, truncInfo.IsTruncated)
return toolUses, nil
}
@@ -610,4 +541,3 @@ func DeduplicateToolUses(toolUses []KiroToolUse) []KiroToolUse {
return unique
}

View File

@@ -0,0 +1,517 @@
// Package claude provides truncation detection for Kiro tool call responses.
// When Kiro API reaches its output token limit, tool call JSON may be truncated,
// resulting in incomplete or unparseable input parameters.
package claude
import (
"encoding/json"
"strings"
log "github.com/sirupsen/logrus"
)
// TruncationInfo contains details about detected truncation in a tool use event.
type TruncationInfo struct {
IsTruncated bool // Whether truncation was detected
TruncationType string // Type of truncation detected
ToolName string // Name of the truncated tool
ToolUseID string // ID of the truncated tool use
RawInput string // The raw (possibly truncated) input string
ParsedFields map[string]string // Fields that were successfully parsed before truncation
ErrorMessage string // Human-readable error message
}
// TruncationType constants for different truncation scenarios
const (
TruncationTypeNone = "" // No truncation detected
TruncationTypeEmptyInput = "empty_input" // No input data received at all
TruncationTypeInvalidJSON = "invalid_json" // JSON is syntactically invalid (truncated mid-value)
TruncationTypeMissingFields = "missing_fields" // JSON parsed but critical fields are missing
TruncationTypeIncompleteString = "incomplete_string" // String value was cut off mid-content
)
// KnownWriteTools lists tool names that typically write content and have a "content" field.
// These tools are checked for content field truncation specifically.
var KnownWriteTools = map[string]bool{
"Write": true,
"write_to_file": true,
"fsWrite": true,
"create_file": true,
"edit_file": true,
"apply_diff": true,
"str_replace_editor": true,
"insert": true,
}
// KnownCommandTools lists tool names that execute commands.
var KnownCommandTools = map[string]bool{
"Bash": true,
"execute": true,
"run_command": true,
"shell": true,
"terminal": true,
"execute_python": true,
}
// RequiredFieldsByTool maps tool names to their required fields.
// If any of these fields are missing, the tool input is considered truncated.
var RequiredFieldsByTool = map[string][]string{
"Write": {"file_path", "content"},
"write_to_file": {"path", "content"},
"fsWrite": {"path", "content"},
"create_file": {"path", "content"},
"edit_file": {"path"},
"apply_diff": {"path", "diff"},
"str_replace_editor": {"path", "old_str", "new_str"},
"Bash": {"command"},
"execute": {"command"},
"run_command": {"command"},
}
// DetectTruncation checks if the tool use input appears to be truncated.
// It returns detailed information about the truncation status and type.
func DetectTruncation(toolName, toolUseID, rawInput string, parsedInput map[string]interface{}) TruncationInfo {
info := TruncationInfo{
ToolName: toolName,
ToolUseID: toolUseID,
RawInput: rawInput,
ParsedFields: make(map[string]string),
}
// Scenario 1: Empty input buffer - no data received at all
if strings.TrimSpace(rawInput) == "" {
info.IsTruncated = true
info.TruncationType = TruncationTypeEmptyInput
info.ErrorMessage = "Tool input was completely empty - API response may have been truncated before tool parameters were transmitted"
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): empty input buffer",
info.TruncationType, toolName, toolUseID)
return info
}
// Scenario 2: JSON parse failure - syntactically invalid JSON
if parsedInput == nil || len(parsedInput) == 0 {
// Check if the raw input looks like truncated JSON
if looksLikeTruncatedJSON(rawInput) {
info.IsTruncated = true
info.TruncationType = TruncationTypeInvalidJSON
info.ParsedFields = extractPartialFields(rawInput)
info.ErrorMessage = buildTruncationErrorMessage(toolName, info.TruncationType, info.ParsedFields, rawInput)
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): JSON parse failed, raw length=%d bytes",
info.TruncationType, toolName, toolUseID, len(rawInput))
return info
}
}
// Scenario 3: JSON parsed but critical fields are missing
if parsedInput != nil {
requiredFields, hasRequirements := RequiredFieldsByTool[toolName]
if hasRequirements {
missingFields := findMissingRequiredFields(parsedInput, requiredFields)
if len(missingFields) > 0 {
info.IsTruncated = true
info.TruncationType = TruncationTypeMissingFields
info.ParsedFields = extractParsedFieldNames(parsedInput)
info.ErrorMessage = buildMissingFieldsErrorMessage(toolName, missingFields, info.ParsedFields)
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): missing required fields: %v",
info.TruncationType, toolName, toolUseID, missingFields)
return info
}
}
// Scenario 4: Check for incomplete string values (very short content for write tools)
if isWriteTool(toolName) {
if contentTruncation := detectContentTruncation(parsedInput, rawInput); contentTruncation != "" {
info.IsTruncated = true
info.TruncationType = TruncationTypeIncompleteString
info.ParsedFields = extractParsedFieldNames(parsedInput)
info.ErrorMessage = contentTruncation
log.Warnf("kiro: truncation detected [%s] for tool %s (ID: %s): %s",
info.TruncationType, toolName, toolUseID, contentTruncation)
return info
}
}
}
// No truncation detected
info.IsTruncated = false
info.TruncationType = TruncationTypeNone
return info
}
// looksLikeTruncatedJSON checks if the raw string appears to be truncated JSON.
func looksLikeTruncatedJSON(raw string) bool {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return false
}
// Must start with { to be considered JSON
if !strings.HasPrefix(trimmed, "{") {
return false
}
// Count brackets to detect imbalance
openBraces := strings.Count(trimmed, "{")
closeBraces := strings.Count(trimmed, "}")
openBrackets := strings.Count(trimmed, "[")
closeBrackets := strings.Count(trimmed, "]")
// Bracket imbalance suggests truncation
if openBraces > closeBraces || openBrackets > closeBrackets {
return true
}
// Check for obvious truncation patterns
// - Ends with a quote but no closing brace
// - Ends with a colon (mid key-value)
// - Ends with a comma (mid object/array)
lastChar := trimmed[len(trimmed)-1]
if lastChar != '}' && lastChar != ']' {
// Check if it's not a complete simple value
if lastChar == '"' || lastChar == ':' || lastChar == ',' {
return true
}
}
// Check for unclosed strings (odd number of unescaped quotes)
inString := false
escaped := false
for i := 0; i < len(trimmed); i++ {
c := trimmed[i]
if escaped {
escaped = false
continue
}
if c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = !inString
}
}
if inString {
return true // Unclosed string
}
return false
}
// extractPartialFields attempts to extract any field names from malformed JSON.
// This helps provide context about what was received before truncation.
func extractPartialFields(raw string) map[string]string {
fields := make(map[string]string)
// Simple pattern matching for "key": "value" or "key": value patterns
// This works even with truncated JSON
trimmed := strings.TrimSpace(raw)
if !strings.HasPrefix(trimmed, "{") {
return fields
}
// Remove opening brace
content := strings.TrimPrefix(trimmed, "{")
// Split by comma (rough parsing)
parts := strings.Split(content, ",")
for _, part := range parts {
part = strings.TrimSpace(part)
if colonIdx := strings.Index(part, ":"); colonIdx > 0 {
key := strings.TrimSpace(part[:colonIdx])
key = strings.Trim(key, `"`)
value := strings.TrimSpace(part[colonIdx+1:])
// Truncate long values for display
if len(value) > 50 {
value = value[:50] + "..."
}
fields[key] = value
}
}
return fields
}
// extractParsedFieldNames returns the field names from a successfully parsed map.
func extractParsedFieldNames(parsed map[string]interface{}) map[string]string {
fields := make(map[string]string)
for key, val := range parsed {
switch v := val.(type) {
case string:
if len(v) > 50 {
fields[key] = v[:50] + "..."
} else {
fields[key] = v
}
case nil:
fields[key] = "<null>"
default:
// For complex types, just indicate presence
fields[key] = "<present>"
}
}
return fields
}
// findMissingRequiredFields checks which required fields are missing from the parsed input.
func findMissingRequiredFields(parsed map[string]interface{}, required []string) []string {
var missing []string
for _, field := range required {
if _, exists := parsed[field]; !exists {
missing = append(missing, field)
}
}
return missing
}
// isWriteTool checks if the tool is a known write/file operation tool.
func isWriteTool(toolName string) bool {
return KnownWriteTools[toolName]
}
// detectContentTruncation checks if the content field appears truncated for write tools.
func detectContentTruncation(parsed map[string]interface{}, rawInput string) string {
// Check for content field
content, hasContent := parsed["content"]
if !hasContent {
return ""
}
contentStr, isString := content.(string)
if !isString {
return ""
}
// Heuristic: if raw input is very large but content is suspiciously short,
// it might indicate truncation during JSON repair
if len(rawInput) > 1000 && len(contentStr) < 100 {
return "content field appears suspiciously short compared to raw input size"
}
// Check for code blocks that appear to be cut off
if strings.Contains(contentStr, "```") {
openFences := strings.Count(contentStr, "```")
if openFences%2 != 0 {
return "content contains unclosed code fence (```) suggesting truncation"
}
}
return ""
}
// buildTruncationErrorMessage creates a human-readable error message for truncation.
func buildTruncationErrorMessage(toolName, truncationType string, parsedFields map[string]string, rawInput string) string {
var sb strings.Builder
sb.WriteString("Tool input was truncated by the API. ")
switch truncationType {
case TruncationTypeEmptyInput:
sb.WriteString("No input data was received.")
case TruncationTypeInvalidJSON:
sb.WriteString("JSON was cut off mid-transmission. ")
if len(parsedFields) > 0 {
sb.WriteString("Partial fields received: ")
first := true
for k := range parsedFields {
if !first {
sb.WriteString(", ")
}
sb.WriteString(k)
first = false
}
}
case TruncationTypeMissingFields:
sb.WriteString("Required fields are missing from the input.")
case TruncationTypeIncompleteString:
sb.WriteString("Content appears to be shortened or incomplete.")
}
sb.WriteString(" Received ")
sb.WriteString(string(rune(len(rawInput))))
sb.WriteString(" bytes. Please retry with smaller content chunks.")
return sb.String()
}
// buildMissingFieldsErrorMessage creates an error message for missing required fields.
func buildMissingFieldsErrorMessage(toolName string, missingFields []string, parsedFields map[string]string) string {
var sb strings.Builder
sb.WriteString("Tool '")
sb.WriteString(toolName)
sb.WriteString("' is missing required fields: ")
sb.WriteString(strings.Join(missingFields, ", "))
sb.WriteString(". Fields received: ")
first := true
for k := range parsedFields {
if !first {
sb.WriteString(", ")
}
sb.WriteString(k)
first = false
}
sb.WriteString(". This usually indicates the API response was truncated.")
return sb.String()
}
// IsTruncated is a convenience function to check if a tool use appears truncated.
func IsTruncated(toolName, rawInput string, parsedInput map[string]interface{}) bool {
info := DetectTruncation(toolName, "", rawInput, parsedInput)
return info.IsTruncated
}
// GetTruncationSummary returns a short summary string for logging.
func GetTruncationSummary(info TruncationInfo) string {
if !info.IsTruncated {
return ""
}
result, _ := json.Marshal(map[string]interface{}{
"tool": info.ToolName,
"type": info.TruncationType,
"parsed_fields": info.ParsedFields,
"raw_input_size": len(info.RawInput),
})
return string(result)
}
// SoftFailureMessage contains the message structure for a truncation soft failure.
// This is returned to Claude as a tool_result to guide retry behavior.
type SoftFailureMessage struct {
Status string // "incomplete" - not an error, just incomplete
Reason string // Why the tool call was incomplete
Guidance []string // Step-by-step retry instructions
Context string // Any context about what was received
MaxLineHint int // Suggested maximum lines per chunk
}
// BuildSoftFailureMessage creates a structured message for Claude when truncation is detected.
// This follows the "soft failure" pattern:
// - For Claude: Clear explanation of what happened and how to fix
// - For User: Hidden or minimized (appears as normal processing)
//
// Key principle: "Conclusion First"
// 1. First state what happened (incomplete)
// 2. Then explain how to fix (chunked approach)
// 3. Provide specific guidance (line limits)
func BuildSoftFailureMessage(info TruncationInfo) SoftFailureMessage {
msg := SoftFailureMessage{
Status: "incomplete",
MaxLineHint: 300, // Conservative default
}
// Build reason based on truncation type
switch info.TruncationType {
case TruncationTypeEmptyInput:
msg.Reason = "Your tool call was too large and the input was completely lost during transmission."
msg.MaxLineHint = 200
case TruncationTypeInvalidJSON:
msg.Reason = "Your tool call was truncated mid-transmission, resulting in incomplete JSON."
msg.MaxLineHint = 250
case TruncationTypeMissingFields:
msg.Reason = "Your tool call was partially received but critical fields were cut off."
msg.MaxLineHint = 300
case TruncationTypeIncompleteString:
msg.Reason = "Your tool call content was truncated - the full content did not arrive."
msg.MaxLineHint = 350
default:
msg.Reason = "Your tool call was truncated by the API due to output size limits."
}
// Build context from parsed fields
if len(info.ParsedFields) > 0 {
var parts []string
for k, v := range info.ParsedFields {
if len(v) > 30 {
v = v[:30] + "..."
}
parts = append(parts, k+"="+v)
}
msg.Context = "Received partial data: " + strings.Join(parts, ", ")
}
// Build retry guidance - CRITICAL: Conclusion first approach
msg.Guidance = []string{
"CONCLUSION: Split your output into smaller chunks and retry.",
"",
"REQUIRED APPROACH:",
"1. For file writes: Write in chunks of ~" + formatInt(msg.MaxLineHint) + " lines maximum",
"2. For new files: First create with initial chunk, then append remaining sections",
"3. For edits: Make surgical, targeted changes - avoid rewriting entire files",
"",
"EXAMPLE (writing a 600-line file):",
" - Step 1: Write lines 1-300 (create file)",
" - Step 2: Append lines 301-600 (extend file)",
"",
"DO NOT attempt to write the full content again in a single call.",
"The API has a hard output limit that cannot be bypassed.",
}
return msg
}
// formatInt converts an integer to string (helper to avoid strconv import)
func formatInt(n int) string {
if n == 0 {
return "0"
}
result := ""
for n > 0 {
result = string(rune('0'+n%10)) + result
n /= 10
}
return result
}
// BuildSoftFailureToolResult creates a tool_result content for Claude.
// This is what Claude will see when a tool call is truncated.
// Returns a string that should be used as the tool_result content.
func BuildSoftFailureToolResult(info TruncationInfo) string {
msg := BuildSoftFailureMessage(info)
var sb strings.Builder
sb.WriteString("TOOL_CALL_INCOMPLETE\n")
sb.WriteString("status: ")
sb.WriteString(msg.Status)
sb.WriteString("\n")
sb.WriteString("reason: ")
sb.WriteString(msg.Reason)
sb.WriteString("\n")
if msg.Context != "" {
sb.WriteString("context: ")
sb.WriteString(msg.Context)
sb.WriteString("\n")
}
sb.WriteString("\n")
for _, line := range msg.Guidance {
if line != "" {
sb.WriteString(line)
sb.WriteString("\n")
}
}
return sb.String()
}
// CreateTruncationToolResult creates a KiroToolUse that represents a soft failure.
// Instead of returning the truncated tool_use, we return a tool with a special
// error result that guides Claude to retry with smaller chunks.
//
// This is the key mechanism for "soft failure":
// - stop_reason remains "tool_use" so Claude continues
// - The tool_result content explains the issue and how to fix it
// - Claude will read this and adjust its approach
func CreateTruncationToolResult(info TruncationInfo) KiroToolUse {
// We create a pseudo tool_use that represents the failed attempt
// The executor will convert this to a tool_result with the guidance message
return KiroToolUse{
ToolUseID: info.ToolUseID,
Name: info.ToolName,
Input: nil, // No input since it was truncated
IsTruncated: true,
TruncationInfo: &info,
}
}

View File

@@ -576,9 +576,23 @@ func processOpenAIMessages(messages gjson.Result, modelID, origin string) ([]Kir
}
}
// Truncate history if too long to prevent Kiro API errors
history = truncateHistoryIfNeeded(history)
return history, currentUserMsg, currentToolResults
}
const kiroMaxHistoryMessages = 50
func truncateHistoryIfNeeded(history []KiroHistoryMessage) []KiroHistoryMessage {
if len(history) <= kiroMaxHistoryMessages {
return history
}
log.Debugf("kiro-openai: truncating history from %d to %d messages", len(history), kiroMaxHistoryMessages)
return history[len(history)-kiroMaxHistoryMessages:]
}
// buildUserMessageFromOpenAI builds a user message from OpenAI format and extracts tool results
func buildUserMessageFromOpenAI(msg gjson.Result, modelID, origin string) (KiroUserInputMessage, []KiroToolResult) {
content := msg.Get("content")
@@ -677,8 +691,23 @@ func buildAssistantMessageFromOpenAI(msg gjson.Result) KiroAssistantResponseMess
}
}
// CRITICAL FIX: Kiro API requires non-empty content for assistant messages
// This can happen with compaction requests or error recovery scenarios
finalContent := contentBuilder.String()
if strings.TrimSpace(finalContent) == "" {
const defaultAssistantContentWithTools = "I'll help you with that."
const defaultAssistantContent = "I understand."
if len(toolUses) > 0 {
finalContent = defaultAssistantContentWithTools
} else {
finalContent = defaultAssistantContent
}
log.Debugf("kiro-openai: assistant content was empty, using default: %s", finalContent)
}
return KiroAssistantResponseMessage{
Content: contentBuilder.String(),
Content: finalContent,
ToolUses: toolUses,
}
}

View File

@@ -68,6 +68,9 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
case "message", "":
// Handle regular message conversion
role := item.Get("role").String()
if role == "developer" {
role = "user"
}
message := `{"role":"","content":""}`
message, _ = sjson.Set(message, "role", role)
@@ -167,7 +170,8 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
// Only function tools need structural conversion because Chat Completions nests details under "function".
toolType := tool.Get("type").String()
if toolType != "" && toolType != "function" && tool.IsObject() {
chatCompletionsTools = append(chatCompletionsTools, tool.Value())
// Almost all providers lack built-in tools, so we just ignore them.
// chatCompletionsTools = append(chatCompletionsTools, tool.Value())
return true
}

View File

@@ -70,14 +70,25 @@ func (a *KiroAuthenticator) createAuthRecord(tokenData *kiroauth.KiroTokenData,
}
// Determine label and identifier based on auth method
// Generate sequence number for uniqueness
seq := time.Now().UnixNano() % 100000
var label, idPart string
if tokenData.AuthMethod == "idc" {
label = "kiro-idc"
// For IDC auth, always use clientID as identifier
if tokenData.ClientID != "" {
idPart = kiroauth.SanitizeEmailForFilename(tokenData.ClientID)
// Priority: email > startUrl identifier > sequence only
// Email is unique, so no sequence needed when email is available
if tokenData.Email != "" {
idPart = kiroauth.SanitizeEmailForFilename(tokenData.Email)
} else if tokenData.StartURL != "" {
identifier := kiroauth.ExtractIDCIdentifier(tokenData.StartURL)
if identifier != "" {
idPart = fmt.Sprintf("%s-%05d", identifier, seq)
} else {
idPart = fmt.Sprintf("%05d", seq)
}
} else {
idPart = fmt.Sprintf("%d", time.Now().UnixNano()%100000)
idPart = fmt.Sprintf("%05d", seq)
}
} else {
label = fmt.Sprintf("kiro-%s", source)
@@ -126,14 +137,14 @@ func (a *KiroAuthenticator) createAuthRecord(tokenData *kiroauth.KiroTokenData,
}
record := &coreauth.Auth{
ID: fileName,
Provider: "kiro",
FileName: fileName,
Label: label,
Status: coreauth.StatusActive,
CreatedAt: now,
UpdatedAt: now,
Metadata: metadata,
ID: fileName,
Provider: "kiro",
FileName: fileName,
Label: label,
Status: coreauth.StatusActive,
CreatedAt: now,
UpdatedAt: now,
Metadata: metadata,
Attributes: attributes,
// NextRefreshAfter: 20 minutes before expiry
NextRefreshAfter: expiresAt.Add(-20 * time.Minute),