mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-03-26 22:02:23 +00:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25680a8259 | ||
|
|
13c93e8cfd | ||
|
|
88aa1b9fd1 | ||
|
|
7fe1d102cb | ||
|
|
c51851689b | ||
|
|
7d6660d181 | ||
|
|
d8e3d4e2b6 |
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
// - kiro
|
||||
// - kilo
|
||||
// - github-copilot
|
||||
// - kiro
|
||||
// - amazonq
|
||||
// - antigravity (returns static overrides only)
|
||||
func GetStaticModelDefinitionsByChannel(channel string) []*ModelInfo {
|
||||
|
||||
@@ -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 := ®istry.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
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
@@ -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>"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user