Compare commits

..

7 Commits

Author SHA1 Message Date
Luis Pater
25680a8259 revert .gitignore 2026-03-05 20:14:08 +08:00
Luis Pater
13c93e8cfd Merge pull request #414 from CheesesNguyen/fix/remove-soft-limit-and-tool-compression
fix: remove SOFT_LIMIT_REACHED logic, tool compression, and fix bugs
2026-03-05 20:12:50 +08:00
Luis Pater
88aa1b9fd1 Merge pull request #408 from xy-host/feat/dynamic-copilot-models
feat: dynamic model fetching for GitHub Copilot
2026-03-05 20:10:40 +08:00
CheesesNguyen
7fe1d102cb fix: don't treat empty input as truncation for tools without required fields
Tools like TaskList, TaskGet have no required parameters, so empty input
is valid. Previously, the truncation detector flagged all empty inputs as
truncated, causing these tools to be skipped and breaking the tool loop.

Now only flag empty input as truncation when the tool has required fields
defined in RequiredFieldsByTool.
2026-03-05 14:43:45 +07:00
CheesesNguyen
c51851689b fix: remove SOFT_LIMIT_REACHED logic, tool compression, and fix bugs
- Remove SOFT_LIMIT_REACHED marker injection in response path
- Remove SOFT_LIMIT_REACHED detection logic in request path
- Remove SOFT_LIMIT_REACHED streaming logic in executor
- Remove tool_compression.go and related constants
- Fix truncation_detector: string(rune(len)) producing Unicode char instead of decimal string
- Fix WebSearchToolUseId being overwritten by non-web-search tools
- Fix duplicate kiro entry in model_definitions.go comment
- Add build output to .gitignore
2026-03-05 10:05:39 +07:00
yx-bot7
7d6660d181 fix: address PR review feedback
- Fix SSRF: validate API endpoint host against allowlist before use
- Limit /models response body to 2MB to prevent memory exhaustion (DoS)
- Use MakeAuthenticatedRequest for consistent headers across API calls
- Trim trailing slash on API endpoint to prevent double-slash URLs
- Use ListModelsWithGitHubToken to simplify token exchange + listing
- Deduplicate model IDs to prevent incorrect registry reference counting
- Remove dead capabilities enrichment code block
- Remove unused ModelExtra field with misleading json:"-" tag
- Extract magic numbers to named constants (defaultCopilotContextLength)
- Remove redundant hyphenID == id check (already filtered by Contains)
- Use defer cancel() for context timeout in service.go
2026-03-04 15:43:51 +08:00
yx-bot7
d8e3d4e2b6 feat: dynamic model fetching for GitHub Copilot
- Add ListModels/ListModelsWithGitHubToken to CopilotAuth for querying
  the /models endpoint at api.githubcopilot.com
- Add FetchGitHubCopilotModels in executor with static fallback on failure
- Update service.go to use dynamic fetching (15s timeout) instead of
  hardcoded GetGitHubCopilotModels()
- Add GitHubCopilotAliasesFromModels for auto-generating dot-to-hyphen
  model aliases from dynamic model lists
2026-03-04 14:29:28 +08:00
12 changed files with 243 additions and 330 deletions

View File

@@ -8,6 +8,8 @@ import (
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
@@ -222,6 +224,97 @@ func (c *CopilotAuth) MakeAuthenticatedRequest(ctx context.Context, method, url
return req, nil
}
// CopilotModelEntry represents a single model entry returned by the Copilot /models API.
type CopilotModelEntry struct {
ID string `json:"id"`
Object string `json:"object"`
Created int64 `json:"created"`
OwnedBy string `json:"owned_by"`
Name string `json:"name,omitempty"`
Version string `json:"version,omitempty"`
Capabilities map[string]any `json:"capabilities,omitempty"`
}
// CopilotModelsResponse represents the response from the Copilot /models endpoint.
type CopilotModelsResponse struct {
Data []CopilotModelEntry `json:"data"`
Object string `json:"object"`
}
// maxModelsResponseSize is the maximum allowed response size from the /models endpoint (2 MB).
const maxModelsResponseSize = 2 * 1024 * 1024
// allowedCopilotAPIHosts is the set of hosts that are considered safe for Copilot API requests.
var allowedCopilotAPIHosts = map[string]bool{
"api.githubcopilot.com": true,
"api.individual.githubcopilot.com": true,
"api.business.githubcopilot.com": true,
"copilot-proxy.githubusercontent.com": true,
}
// ListModels fetches the list of available models from the Copilot API.
// It requires a valid Copilot API token (not the GitHub access token).
func (c *CopilotAuth) ListModels(ctx context.Context, apiToken *CopilotAPIToken) ([]CopilotModelEntry, error) {
if apiToken == nil || apiToken.Token == "" {
return nil, fmt.Errorf("copilot: api token is required for listing models")
}
// Build models URL, validating the endpoint host to prevent SSRF.
modelsURL := copilotAPIEndpoint + "/models"
if ep := strings.TrimRight(apiToken.Endpoints.API, "/"); ep != "" {
parsed, err := url.Parse(ep)
if err == nil && parsed.Scheme == "https" && allowedCopilotAPIHosts[parsed.Host] {
modelsURL = ep + "/models"
} else {
log.Warnf("copilot: ignoring untrusted API endpoint %q, using default", ep)
}
}
req, err := c.MakeAuthenticatedRequest(ctx, http.MethodGet, modelsURL, nil, apiToken)
if err != nil {
return nil, fmt.Errorf("copilot: failed to create models request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("copilot: models request failed: %w", err)
}
defer func() {
if errClose := resp.Body.Close(); errClose != nil {
log.Errorf("copilot list models: close body error: %v", errClose)
}
}()
// Limit response body to prevent memory exhaustion.
limitedReader := io.LimitReader(resp.Body, maxModelsResponseSize)
bodyBytes, err := io.ReadAll(limitedReader)
if err != nil {
return nil, fmt.Errorf("copilot: failed to read models response: %w", err)
}
if !isHTTPSuccess(resp.StatusCode) {
return nil, fmt.Errorf("copilot: list models failed with status %d: %s", resp.StatusCode, string(bodyBytes))
}
var modelsResp CopilotModelsResponse
if err = json.Unmarshal(bodyBytes, &modelsResp); err != nil {
return nil, fmt.Errorf("copilot: failed to parse models response: %w", err)
}
return modelsResp.Data, nil
}
// ListModelsWithGitHubToken is a convenience method that exchanges a GitHub access token
// for a Copilot API token and then fetches the available models.
func (c *CopilotAuth) ListModelsWithGitHubToken(ctx context.Context, githubAccessToken string) ([]CopilotModelEntry, error) {
apiToken, err := c.GetCopilotAPIToken(ctx, githubAccessToken)
if err != nil {
return nil, fmt.Errorf("copilot: failed to get API token for model listing: %w", err)
}
return c.ListModels(ctx, apiToken)
}
// buildChatCompletionURL builds the URL for chat completions API.
func buildChatCompletionURL() string {
return copilotAPIEndpoint + "/chat/completions"

View File

@@ -1,5 +1,7 @@
package config
import "strings"
// defaultKiroAliases returns default oauth-model-alias entries for Kiro.
// These aliases expose standard Claude IDs for Kiro-prefixed upstream models.
func defaultKiroAliases() []OAuthModelAlias {
@@ -35,3 +37,25 @@ func defaultGitHubCopilotAliases() []OAuthModelAlias {
{Name: "claude-sonnet-4.6", Alias: "claude-sonnet-4-6", Fork: true},
}
}
// GitHubCopilotAliasesFromModels generates oauth-model-alias entries from a dynamic
// list of model IDs fetched from the Copilot API. It auto-creates aliases for
// models whose ID contains a dot (e.g. "claude-opus-4.6" → "claude-opus-4-6"),
// which is the pattern used by Claude models on Copilot.
func GitHubCopilotAliasesFromModels(modelIDs []string) []OAuthModelAlias {
var aliases []OAuthModelAlias
seen := make(map[string]struct{})
for _, id := range modelIDs {
if !strings.Contains(id, ".") {
continue
}
hyphenID := strings.ReplaceAll(id, ".", "-")
key := id + "→" + hyphenID
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
aliases = append(aliases, OAuthModelAlias{Name: id, Alias: hyphenID, Fork: true})
}
return aliases
}

View File

@@ -23,7 +23,6 @@ import (
// - kiro
// - kilo
// - github-copilot
// - kiro
// - amazonq
// - antigravity (returns static overrides only)
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {

View File

@@ -14,6 +14,7 @@ import (
"github.com/google/uuid"
copilotauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/copilot"
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
"github.com/router-for-me/CLIProxyAPI/v6/internal/registry"
"github.com/router-for-me/CLIProxyAPI/v6/internal/thinking"
cliproxyauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth"
cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/executor"
@@ -1264,3 +1265,99 @@ func translateGitHubCopilotResponsesStreamToClaude(line []byte, param *any) []st
func isHTTPSuccess(statusCode int) bool {
return statusCode >= 200 && statusCode < 300
}
const (
// defaultCopilotContextLength is the default context window for unknown Copilot models.
defaultCopilotContextLength = 128000
// defaultCopilotMaxCompletionTokens is the default max output tokens for unknown Copilot models.
defaultCopilotMaxCompletionTokens = 16384
)
// FetchGitHubCopilotModels dynamically fetches available models from the GitHub Copilot API.
// It exchanges the GitHub access token stored in auth.Metadata for a Copilot API token,
// then queries the /models endpoint. Falls back to the static registry on any failure.
func FetchGitHubCopilotModels(ctx context.Context, auth *cliproxyauth.Auth, cfg *config.Config) []*registry.ModelInfo {
if auth == nil {
log.Debug("github-copilot: auth is nil, using static models")
return registry.GetGitHubCopilotModels()
}
accessToken := metaStringValue(auth.Metadata, "access_token")
if accessToken == "" {
log.Debug("github-copilot: no access_token in auth metadata, using static models")
return registry.GetGitHubCopilotModels()
}
copilotAuth := copilotauth.NewCopilotAuth(cfg)
entries, err := copilotAuth.ListModelsWithGitHubToken(ctx, accessToken)
if err != nil {
log.Warnf("github-copilot: failed to fetch dynamic models: %v, using static models", err)
return registry.GetGitHubCopilotModels()
}
if len(entries) == 0 {
log.Debug("github-copilot: API returned no models, using static models")
return registry.GetGitHubCopilotModels()
}
// Build a lookup from the static definitions so we can enrich dynamic entries
// with known context lengths, thinking support, etc.
staticMap := make(map[string]*registry.ModelInfo)
for _, m := range registry.GetGitHubCopilotModels() {
staticMap[m.ID] = m
}
now := time.Now().Unix()
models := make([]*registry.ModelInfo, 0, len(entries))
seen := make(map[string]struct{}, len(entries))
for _, entry := range entries {
if entry.ID == "" {
continue
}
// Deduplicate model IDs to avoid incorrect reference counting.
if _, dup := seen[entry.ID]; dup {
continue
}
seen[entry.ID] = struct{}{}
m := &registry.ModelInfo{
ID: entry.ID,
Object: "model",
Created: now,
OwnedBy: "github-copilot",
Type: "github-copilot",
}
if entry.Created > 0 {
m.Created = entry.Created
}
if entry.Name != "" {
m.DisplayName = entry.Name
} else {
m.DisplayName = entry.ID
}
// Merge known metadata from the static fallback list
if static, ok := staticMap[entry.ID]; ok {
if m.DisplayName == entry.ID && static.DisplayName != "" {
m.DisplayName = static.DisplayName
}
m.Description = static.Description
m.ContextLength = static.ContextLength
m.MaxCompletionTokens = static.MaxCompletionTokens
m.SupportedEndpoints = static.SupportedEndpoints
m.Thinking = static.Thinking
} else {
// Sensible defaults for models not in the static list
m.Description = entry.ID + " via GitHub Copilot"
m.ContextLength = defaultCopilotContextLength
m.MaxCompletionTokens = defaultCopilotMaxCompletionTokens
}
models = append(models, m)
}
log.Infof("github-copilot: fetched %d models from API", len(models))
return models
}

View File

@@ -2458,7 +2458,6 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
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 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
@@ -3286,59 +3285,9 @@ func (e *KiroExecutor) streamToChannel(ctx context.Context, body io.Reader, out
// Emit completed tool uses
for _, tu := range completedToolUses {
// Check if this tool was truncated - emit with SOFT_LIMIT_REACHED marker
// Skip truncated tools - don't emit fake marker tool_use
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 {
blockStop := kiroclaude.BuildClaudeContentBlockStopEvent(contentBlockIndex)
sseData := sdktranslator.TranslateStream(ctx, sdktranslator.FromString("kiro"), targetFormat, model, originalReq, claudeBody, blockStop, &translatorParam)
for _, chunk := range sseData {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
isTextBlockOpen = false
}
contentBlockIndex++
// 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 != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
// 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.",
}
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 != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
// 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 {
if chunk != "" {
out <- cliproxyexecutor.StreamChunk{Payload: []byte(chunk + "\n\n")}
}
}
hasToolUses = true // Keep this so stop_reason = tool_use
log.Warnf("kiro: streamToChannel skipping truncated tool: %s (ID: %s)", tu.Name, tu.ToolUseID)
continue
}
@@ -3640,12 +3589,7 @@ 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

@@ -605,10 +605,6 @@ func convertClaudeToolsToKiro(tools gjson.Result) []KiroToolWrapper {
})
}
// Apply dynamic compression if total tools size exceeds threshold
// This prevents 500 errors when Claude Code sends too many tools
kiroTools = compressToolsIfNeeded(kiroTools)
return kiroTools
}
@@ -858,34 +854,7 @@ func BuildUserMessageStruct(msg gjson.Result, modelID, origin string) (KiroUserI
var textContents []KiroTextContent
// 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() {
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,39 +55,18 @@ func BuildClaudeResponse(content string, toolUses []KiroToolUse, model string, u
}
}
// Add tool_use blocks - emit truncated tools with SOFT_LIMIT_REACHED marker
hasTruncatedTools := false
// Add tool_use blocks - skip truncated tools and log warning
for _, toolUse := range toolUses {
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.Warnf("kiro: buildClaudeResponse skipping truncated tool: %s (ID: %s)", toolUse.Name, toolUse.ToolUseID)
continue
}
}
// Log if we used SOFT_LIMIT_REACHED
if hasTruncatedTools {
log.Infof("kiro: buildClaudeResponse using SOFT_LIMIT_REACHED - keeping stop_reason=tool_use")
contentBlocks = append(contentBlocks, map[string]interface{}{
"type": "tool_use",
"id": toolUse.ToolUseID,
"name": toolUse.Name,
"input": toolUse.Input,
})
}
// Ensure at least one content block (Claude API requires non-empty content)

View File

@@ -192,8 +192,8 @@ func AnalyzeBufferedStream(chunks [][]byte) BufferedStreamResult {
if idx, ok := event["index"].(float64); ok {
currentToolIndex = int(idx)
}
// Capture tool use ID for toolResults handshake
if id, ok := cb["id"].(string); ok {
// Capture tool use ID only for web_search toolResults handshake
if id, ok := cb["id"].(string); ok && (currentToolName == "web_search" || currentToolName == "remote_web_search") {
result.WebSearchToolUseId = id
}
toolInputBuilder.Reset()

View File

@@ -1,191 +0,0 @@
// Package claude provides tool compression functionality for Kiro translator.
// This file implements dynamic tool compression to reduce tool payload size
// when it exceeds the target threshold, preventing 500 errors from Kiro API.
package claude
import (
"encoding/json"
"unicode/utf8"
kirocommon "github.com/router-for-me/CLIProxyAPI/v6/internal/translator/kiro/common"
log "github.com/sirupsen/logrus"
)
// calculateToolsSize calculates the JSON serialized size of the tools list.
// Returns the size in bytes.
func calculateToolsSize(tools []KiroToolWrapper) int {
if len(tools) == 0 {
return 0
}
data, err := json.Marshal(tools)
if err != nil {
log.Warnf("kiro: failed to marshal tools for size calculation: %v", err)
return 0
}
return len(data)
}
// simplifyInputSchema simplifies the input_schema by keeping only essential fields:
// type, enum, required. Recursively processes nested properties.
func simplifyInputSchema(schema interface{}) interface{} {
if schema == nil {
return nil
}
schemaMap, ok := schema.(map[string]interface{})
if !ok {
return schema
}
simplified := make(map[string]interface{})
// Keep essential fields
if t, ok := schemaMap["type"]; ok {
simplified["type"] = t
}
if enum, ok := schemaMap["enum"]; ok {
simplified["enum"] = enum
}
if required, ok := schemaMap["required"]; ok {
simplified["required"] = required
}
// Recursively process properties
if properties, ok := schemaMap["properties"].(map[string]interface{}); ok {
simplifiedProps := make(map[string]interface{})
for key, value := range properties {
simplifiedProps[key] = simplifyInputSchema(value)
}
simplified["properties"] = simplifiedProps
}
// Process items for array types
if items, ok := schemaMap["items"]; ok {
simplified["items"] = simplifyInputSchema(items)
}
// Process additionalProperties if present
if additionalProps, ok := schemaMap["additionalProperties"]; ok {
simplified["additionalProperties"] = simplifyInputSchema(additionalProps)
}
// Process anyOf, oneOf, allOf
for _, key := range []string{"anyOf", "oneOf", "allOf"} {
if arr, ok := schemaMap[key].([]interface{}); ok {
simplifiedArr := make([]interface{}, len(arr))
for i, item := range arr {
simplifiedArr[i] = simplifyInputSchema(item)
}
simplified[key] = simplifiedArr
}
}
return simplified
}
// compressToolDescription compresses a description to the target length.
// Ensures the result is at least MinToolDescriptionLength characters.
// Uses UTF-8 safe truncation.
func compressToolDescription(description string, targetLength int) string {
if targetLength < kirocommon.MinToolDescriptionLength {
targetLength = kirocommon.MinToolDescriptionLength
}
if len(description) <= targetLength {
return description
}
// Find a safe truncation point (UTF-8 boundary)
truncLen := targetLength - 3 // Leave room for "..."
// Ensure we don't cut in the middle of a UTF-8 character
for truncLen > 0 && !utf8.RuneStart(description[truncLen]) {
truncLen--
}
if truncLen <= 0 {
return description[:kirocommon.MinToolDescriptionLength]
}
return description[:truncLen] + "..."
}
// compressToolsIfNeeded compresses tools if their total size exceeds the target threshold.
// Compression strategy:
// 1. First, check if compression is needed (size > ToolCompressionTargetSize)
// 2. Step 1: Simplify input_schema (keep only type/enum/required)
// 3. Step 2: Proportionally compress descriptions (minimum MinToolDescriptionLength chars)
// Returns the compressed tools list.
func compressToolsIfNeeded(tools []KiroToolWrapper) []KiroToolWrapper {
if len(tools) == 0 {
return tools
}
originalSize := calculateToolsSize(tools)
if originalSize <= kirocommon.ToolCompressionTargetSize {
log.Debugf("kiro: tools size %d bytes is within target %d bytes, no compression needed",
originalSize, kirocommon.ToolCompressionTargetSize)
return tools
}
log.Infof("kiro: tools size %d bytes exceeds target %d bytes, starting compression",
originalSize, kirocommon.ToolCompressionTargetSize)
// Create a copy of tools to avoid modifying the original
compressedTools := make([]KiroToolWrapper, len(tools))
for i, tool := range tools {
compressedTools[i] = KiroToolWrapper{
ToolSpecification: KiroToolSpecification{
Name: tool.ToolSpecification.Name,
Description: tool.ToolSpecification.Description,
InputSchema: KiroInputSchema{JSON: tool.ToolSpecification.InputSchema.JSON},
},
}
}
// Step 1: Simplify input_schema
for i := range compressedTools {
compressedTools[i].ToolSpecification.InputSchema.JSON =
simplifyInputSchema(compressedTools[i].ToolSpecification.InputSchema.JSON)
}
sizeAfterSchemaSimplification := calculateToolsSize(compressedTools)
log.Debugf("kiro: size after schema simplification: %d bytes (reduced by %d bytes)",
sizeAfterSchemaSimplification, originalSize-sizeAfterSchemaSimplification)
// Check if we're within target after schema simplification
if sizeAfterSchemaSimplification <= kirocommon.ToolCompressionTargetSize {
log.Infof("kiro: compression complete after schema simplification, final size: %d bytes",
sizeAfterSchemaSimplification)
return compressedTools
}
// Step 2: Compress descriptions proportionally
sizeToReduce := float64(sizeAfterSchemaSimplification - kirocommon.ToolCompressionTargetSize)
var totalDescLen float64
for _, tool := range compressedTools {
totalDescLen += float64(len(tool.ToolSpecification.Description))
}
if totalDescLen > 0 {
// Assume size reduction comes primarily from descriptions.
keepRatio := 1.0 - (sizeToReduce / totalDescLen)
if keepRatio > 1.0 {
keepRatio = 1.0
} else if keepRatio < 0 {
keepRatio = 0
}
for i := range compressedTools {
desc := compressedTools[i].ToolSpecification.Description
targetLen := int(float64(len(desc)) * keepRatio)
compressedTools[i].ToolSpecification.Description = compressToolDescription(desc, targetLen)
}
}
finalSize := calculateToolsSize(compressedTools)
log.Infof("kiro: compression complete, original: %d bytes, final: %d bytes (%.1f%% reduction)",
originalSize, finalSize, float64(originalSize-finalSize)/float64(originalSize)*100)
return compressedTools
}

View File

@@ -84,13 +84,18 @@ func DetectTruncation(toolName, toolUseID, rawInput string, parsedInput map[stri
ParsedFields: make(map[string]string),
}
// Scenario 1: Empty input buffer - no data received at all
// Scenario 1: Empty input buffer - only flag as truncation if tool has required fields
// Many tools (e.g. TaskList, TaskGet) have no required params, so empty input is valid
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)
if _, hasRequirements := RequiredFieldsByTool[toolName]; hasRequirements {
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
}
log.Debugf("kiro: empty input for tool %s (ID: %s) - no required fields, treating as valid", toolName, toolUseID)
return info
}
@@ -342,7 +347,7 @@ func buildTruncationErrorMessage(toolName, truncationType string, parsedFields m
}
sb.WriteString(" Received ")
sb.WriteString(string(rune(len(rawInput))))
sb.WriteString(formatInt(len(rawInput)))
sb.WriteString(" bytes. Please retry with smaller content chunks.")
return sb.String()

View File

@@ -6,14 +6,6 @@ const (
// Kiro API limit is 10240 bytes, leave room for "..."
KiroMaxToolDescLen = 10237
// ToolCompressionTargetSize is the target total size for compressed tools (20KB).
// If tools exceed this size, compression will be applied.
ToolCompressionTargetSize = 20 * 1024 // 20KB
// MinToolDescriptionLength is the minimum description length after compression.
// Descriptions will not be shortened below this length.
MinToolDescriptionLength = 50
// ThinkingStartTag is the start tag for thinking blocks in responses.
ThinkingStartTag = "<thinking>"

View File

@@ -872,7 +872,9 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
models = registry.GetKimiModels()
models = applyExcludedModels(models, excluded)
case "github-copilot":
models = registry.GetGitHubCopilotModels()
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
models = executor.FetchGitHubCopilotModels(ctx, a, s.cfg)
models = applyExcludedModels(models, excluded)
case "kiro":
models = s.fetchKiroModels(a)