diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 4727b6d..125fecc 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -23,8 +23,14 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Set lowercase image name + id: image + run: echo "name=$(echo '${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}' | tr '[:upper:]' '[:lower:]')" >> "$GITHUB_OUTPUT" + - name: Set up QEMU uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 @@ -41,7 +47,7 @@ jobs: id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + images: ${{ steps.image.outputs.name }} tags: | type=raw,value=latest,enable={{is_default_branch}} type=ref,event=branch @@ -50,7 +56,7 @@ jobs: type=sha,prefix= - name: Build and push - uses: docker/build-push-action@v5 + uses: docker/build-push-action@v6 with: context: . platforms: linux/amd64,linux/arm64 @@ -59,3 +65,5 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + provenance: false + diff --git a/Dockerfile b/Dockerfile index db8766c..7c6cfa4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,11 +1,18 @@ -FROM golang:1.21-alpine AS builder +# builder 阶段始终运行在构建机原生平台(amd64),用 Go 交叉编译目标平台二进制 +FROM --platform=$BUILDPLATFORM golang:1.21-alpine AS builder + +ARG TARGETOS +ARG TARGETARCH WORKDIR /app COPY go.mod go.sum ./ -RUN go mod download +RUN --mount=type=cache,target=/go/pkg/mod \ + go mod download COPY . . -RUN CGO_ENABLED=0 GOOS=linux go build -o kiro-go . +RUN --mount=type=cache,target=/go/pkg/mod \ + --mount=type=cache,target=/root/.cache/go-build \ + CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} go build -o kiro-go . FROM alpine:latest RUN apk --no-cache add ca-certificates diff --git a/README.md b/README.md index 49a1263..17a8649 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ If this project helps you, a Star would mean a lot. - Auto token refresh, SSE streaming, Web admin panel - Multiple auth: AWS Builder ID, IAM Identity Center (Enterprise SSO), SSO Token, local cache, credentials JSON - Usage tracking, account import/export, i18n (CN / EN) +- Support configuring outbound proxy (SOCKS5 / HTTP) ## Quick Start @@ -72,7 +73,13 @@ curl http://localhost:8080/v1/chat/completions \ ## Thinking Mode -Append a suffix (default `-thinking`) to the model name, e.g. `claude-sonnet-4.5-thinking`. Configure output format in the admin panel under Settings - Thinking Mode. +Append a suffix (default `-thinking`) to the model name, e.g. `claude-sonnet-4.5-thinking`. Claude-compatible requests that include a top-level `thinking` config such as `{"type":"enabled","budget_tokens":2048}` or `{"type":"adaptive"}` also enable thinking mode automatically. Configure output format in the admin panel under Settings - Thinking Mode. + +## Outbound Proxy + +For users in restricted network regions, configure an outbound proxy in the admin panel under **Settings - Outbound Proxy Settings**. Supports SOCKS5 and HTTP proxies. + +The setting takes effect immediately without restarting. ## Environment Variables diff --git a/README_CN.md b/README_CN.md index b6b79d2..8e9fdf6 100644 --- a/README_CN.md +++ b/README_CN.md @@ -17,6 +17,7 @@ - 自动 Token 刷新、SSE 流式输出、Web 管理面板 - 多种认证方式:AWS Builder ID、IAM Identity Center (企业 SSO)、SSO Token、本地缓存、凭证 JSON - 用量追踪、账号导入导出、中英双语 +- 支持设置出站代理(SOCKS5 / HTTP) ## 快速开始 @@ -72,7 +73,13 @@ curl http://localhost:8080/v1/chat/completions \ ## 思考模式 -在模型名后加后缀(默认 `-thinking`)即可启用,例如 `claude-sonnet-4.5-thinking`。输出格式可在管理面板「设置 - Thinking 模式」中配置。 +在模型名后加后缀(默认 `-thinking`)即可启用,例如 `claude-sonnet-4.5-thinking`。Claude 兼容请求如果带有顶层 `thinking` 配置,例如 `{"type":"enabled","budget_tokens":2048}` 或 `{"type":"adaptive"}`,也会自动启用 thinking 模式。输出格式可在管理面板「设置 - Thinking 模式」中配置。 + +## 出站代理 + +可在管理面板「设置 - 出站代理设置」中配置代理。支持 SOCKS5 和 HTTP 代理。 + +设置保存后即时生效,无需重启服务。 ## 环境变量 diff --git a/auth/builderid.go b/auth/builderid.go index 460ad6b..21a74d9 100644 --- a/auth/builderid.go +++ b/auth/builderid.go @@ -57,7 +57,7 @@ func StartBuilderIdLogin(region string) (*BuilderIdSession, error) { regReq, _ := http.NewRequest("POST", oidcBase+"/client/register", bytes.NewReader(regBody)) regReq.Header.Set("Content-Type", "application/json") - client := httpClient + client := httpClient() regResp, err := client.Do(regReq) if err != nil { return nil, fmt.Errorf("register client failed: %v", err) @@ -175,7 +175,7 @@ func PollBuilderIdAuth(sessionID string) (accessToken, refreshToken, clientID, c tokenReq, _ := http.NewRequest("POST", oidcBase+"/token", bytes.NewReader(tokenBody)) tokenReq.Header.Set("Content-Type", "application/json") - client := httpClient + client := httpClient() tokenResp, err := client.Do(tokenReq) if err != nil { return "", "", "", "", "", 0, "", fmt.Errorf("token request failed: %v", err) diff --git a/auth/http_client.go b/auth/http_client.go index 836fb7c..fa5443e 100644 --- a/auth/http_client.go +++ b/auth/http_client.go @@ -3,18 +3,46 @@ package auth import ( "net/http" + "net/url" + "sync/atomic" "time" ) -// 全局 HTTP 客户端,复用连接池 -// 用于所有 auth 模块的 HTTP 请求 -var httpClient = &http.Client{ - Timeout: 30 * time.Second, - Transport: &http.Transport{ - MaxIdleConns: 50, // 最大空闲连接数 - MaxIdleConnsPerHost: 10, // 每个 Host 最大空闲连接数 - IdleConnTimeout: 90 * time.Second, // 空闲连接超时 - DisableCompression: false, // 启用压缩 - ForceAttemptHTTP2: true, // 尝试使用 HTTP/2 - }, +// 全局 HTTP 客户端存储,支持运行时代理重配置 +var httpClientStore atomic.Pointer[http.Client] + +// httpClient 返回当前全局 auth HTTP 客户端 +func httpClient() *http.Client { + return httpClientStore.Load() +} + +func init() { + InitHttpClient("") +} + +// buildAuthTransport 构建带可选代理的 Transport +func buildAuthTransport(proxyURL string) *http.Transport { + t := &http.Transport{ + MaxIdleConns: 50, + MaxIdleConnsPerHost: 10, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + ForceAttemptHTTP2: true, + } + if proxyURL != "" { + if u, err := url.Parse(proxyURL); err == nil { + t.Proxy = http.ProxyURL(u) + t.ForceAttemptHTTP2 = false + } + } + return t +} + +// InitHttpClient 初始化(或重新初始化)auth 模块的全局 HTTP 客户端 +func InitHttpClient(proxyURL string) { + client := &http.Client{ + Timeout: 30 * time.Second, + Transport: buildAuthTransport(proxyURL), + } + httpClientStore.Store(client) } diff --git a/auth/iam_sso.go b/auth/iam_sso.go index e17e4eb..bfd4a4a 100644 --- a/auth/iam_sso.go +++ b/auth/iam_sso.go @@ -170,7 +170,7 @@ func registerOIDCClient(oidcBase, startUrl, redirectUri string) (clientID, clien req, _ := http.NewRequest("POST", oidcBase+"/client/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) + resp, err := httpClient().Do(req) if err != nil { return "", "", err } @@ -207,7 +207,7 @@ func exchangeToken(oidcBase, clientID, clientSecret, code, codeVerifier, redirec req, _ := http.NewRequest("POST", oidcBase+"/token", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) + resp, err := httpClient().Do(req) if err != nil { return "", "", 0, err } diff --git a/auth/oidc.go b/auth/oidc.go index 5a405d6..7dcb494 100644 --- a/auth/oidc.go +++ b/auth/oidc.go @@ -40,7 +40,7 @@ func refreshOIDCToken(refreshToken, clientID, clientSecret, region string) (stri req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) + resp, err := httpClient().Do(req) if err != nil { return "", "", 0, err } @@ -77,7 +77,7 @@ func refreshSocialToken(refreshToken string) (string, string, int64, error) { req, _ := http.NewRequest("POST", url, bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - resp, err := httpClient.Do(req) + resp, err := httpClient().Do(req) if err != nil { return "", "", 0, err } diff --git a/auth/sso_token.go b/auth/sso_token.go index 22da746..dee0540 100644 --- a/auth/sso_token.go +++ b/auth/sso_token.go @@ -79,7 +79,7 @@ func registerDeviceClient(oidcBase, startUrl string) (clientID, clientSecret str req, _ := http.NewRequest("POST", oidcBase+"/client/register", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - client := httpClient + client := httpClient() resp, err := client.Do(req) if err != nil { return "", "", err @@ -110,7 +110,7 @@ func startDeviceAuth(oidcBase, clientID, clientSecret, startUrl string) (deviceC req, _ := http.NewRequest("POST", oidcBase+"/device_authorization", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - client := httpClient + client := httpClient() resp, err := client.Do(req) if err != nil { return "", "", 0, err @@ -139,7 +139,7 @@ func verifyBearerToken(portalBase, bearerToken string) error { req.Header.Set("Authorization", "Bearer "+bearerToken) req.Header.Set("Accept", "application/json") - client := httpClient + client := httpClient() resp, err := client.Do(req) if err != nil { return err @@ -157,7 +157,7 @@ func getDeviceSessionToken(portalBase, bearerToken string) (string, error) { req.Header.Set("Authorization", "Bearer "+bearerToken) req.Header.Set("Content-Type", "application/json") - client := httpClient + client := httpClient() resp, err := client.Do(req) if err != nil { return "", err @@ -193,7 +193,7 @@ func acceptUserCode(oidcBase, userCode, deviceSessionToken string) (*deviceConte req.Header.Set("Content-Type", "application/json") req.Header.Set("Referer", "https://view.awsapps.com/") - client := httpClient + client := httpClient() resp, err := client.Do(req) if err != nil { return nil, err @@ -227,7 +227,7 @@ func approveAuth(oidcBase string, deviceContext *deviceContextInfo, deviceSessio req.Header.Set("Content-Type", "application/json") req.Header.Set("Referer", "https://view.awsapps.com/") - client := httpClient + client := httpClient() resp, err := client.Do(req) if err != nil { return err @@ -262,7 +262,7 @@ func pollForToken(oidcBase, clientID, clientSecret, deviceCode string, interval req, _ := http.NewRequest("POST", oidcBase+"/token", bytes.NewReader(body)) req.Header.Set("Content-Type", "application/json") - client := httpClient + client := httpClient() resp, err := client.Do(req) if err != nil { continue @@ -311,7 +311,7 @@ func GetUserInfo(accessToken string) (email, userID string, err error) { req.Header.Set("User-Agent", "aws-sdk-js/1.0.18 KiroAPIProxy") req.Header.Set("x-amz-user-agent", "aws-sdk-js/1.0.18 KiroAPIProxy") - client := httpClient + client := httpClient() resp, err := client.Do(req) if err != nil { return "", "", err diff --git a/config/config.go b/config/config.go index b96f743..21b9adf 100644 --- a/config/config.go +++ b/config/config.go @@ -108,6 +108,12 @@ type Config struct { // Endpoint configuration: "auto", "codewhisperer", or "amazonq" PreferredEndpoint string `json:"preferredEndpoint,omitempty"` + // Proxy configuration: optional outbound proxy for Kiro API requests + // Format: "socks5://host:port", "socks5://user:pass@host:port", + // "http://host:port", "http://user:pass@host:port" + // Leave empty to connect directly. + ProxyURL string `json:"proxyURL,omitempty"` + // General behavior settings InvalidModelRetries int `json:"invalidModelRetries,omitempty"` // Same-endpoint retry count on INVALID_MODEL_ID (default: 3) @@ -140,7 +146,7 @@ type AccountInfo struct { } // Version current version -const Version = "1.0.5" +const Version = "1.0.6" var ( cfg *Config @@ -448,6 +454,21 @@ func UpdatePreferredEndpoint(endpoint string) error { return Save() } +// GetProxyURL 获取出站代理地址 +func GetProxyURL() string { + cfgLock.RLock() + defer cfgLock.RUnlock() + return cfg.ProxyURL +} + +// UpdateProxySettings 更新出站代理配置 +func UpdateProxySettings(proxyURL string) error { + cfgLock.Lock() + defer cfgLock.Unlock() + cfg.ProxyURL = proxyURL + return Save() +} + // GetInvalidModelRetries 返回 INVALID_MODEL_ID 同端点重试次数(默认 3) func GetInvalidModelRetries() int { cfgLock.RLock() diff --git a/proxy/handler.go b/proxy/handler.go index 3332b08..2e5ee76 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -66,6 +66,9 @@ func validateClaudeRequestShape(req *ClaudeRequest) string { if len(req.Messages) == 0 { return "messages must not be empty" } + if msg := validateClaudeThinkingConfig(req.Thinking, req.MaxTokens); msg != "" { + return msg + } hasUserContext := false lastRole := "" @@ -94,6 +97,75 @@ func validateClaudeRequestShape(req *ClaudeRequest) string { return "" } +func validateClaudeThinkingConfig(thinking *ClaudeThinkingConfig, maxTokens int) string { + if thinking == nil { + return "" + } + + kind := strings.ToLower(strings.TrimSpace(thinking.Type)) + switch kind { + case "enabled": + if maxTokens == 0 { + return "thinking.type enabled cannot be used with max_tokens=0" + } + if thinking.BudgetTokens <= 0 { + return "thinking.budget_tokens is required when thinking.type is enabled" + } + if thinking.BudgetTokens < 1024 { + return "thinking.budget_tokens must be at least 1024" + } + if maxTokens > 0 && thinking.BudgetTokens >= maxTokens { + return "thinking.budget_tokens must be less than max_tokens" + } + case "adaptive": + if thinking.BudgetTokens != 0 { + return "thinking.budget_tokens is not supported when thinking.type is adaptive" + } + case "disabled": + if thinking.BudgetTokens != 0 { + return "thinking.budget_tokens is not supported when thinking.type is disabled" + } + default: + return "thinking.type must be one of: enabled, adaptive, disabled" + } + + display := strings.ToLower(strings.TrimSpace(thinking.Display)) + if display != "" && display != "summarized" && display != "omitted" { + return "thinking.display must be one of: summarized, omitted" + } + if kind == "disabled" && display != "" { + return "thinking.display is not supported when thinking.type is disabled" + } + + return "" +} + +type claudeThinkingResponseOptions struct { + Format string + OmitDisplay bool +} + +func resolveClaudeThinkingResponseOptions(thinking *ClaudeThinkingConfig, defaultFormat string) claudeThinkingResponseOptions { + opts := claudeThinkingResponseOptions{Format: defaultFormat} + if opts.Format == "" { + opts.Format = "thinking" + } + if thinking == nil { + return opts + } + + display := strings.ToLower(strings.TrimSpace(thinking.Display)) + switch display { + case "summarized": + opts.Format = "thinking" + case "omitted": + opts.Format = "thinking" + opts.OmitDisplay = true + } + + return opts +} + func validateOpenAIRequestShape(req *OpenAIRequest) string { if len(req.Messages) == 0 { return "messages must not be empty" @@ -134,6 +206,9 @@ func validateOpenAIRequestShape(req *OpenAIRequest) string { } func NewHandler() *Handler { + // 启动时应用代理配置 + applyProxyConfig(config.GetProxyURL()) + totalReq, successReq, failedReq, totalTokens, totalCredits := config.GetStats() h := &Handler{ pool: pool.GetPool(), @@ -569,8 +644,17 @@ func (h *Handler) handleCountTokens(w http.ResponseWriter, r *http.Request) { h.sendClaudeError(w, 400, "invalid_request_error", "Invalid JSON") return } + if msg := validateClaudeThinkingConfig(req.Thinking, req.MaxTokens); msg != "" { + h.sendClaudeError(w, 400, "invalid_request_error", msg) + return + } - estimatedTokens := estimateClaudeRequestInputTokens(&req) + thinkingCfg := config.GetThinkingConfig() + actualModel, thinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) + req.Model = actualModel + effectiveReq := cloneClaudeRequestForThinking(&req, thinking) + + estimatedTokens := estimateClaudeRequestInputTokens(effectiveReq) if estimatedTokens < 1 { estimatedTokens = 1 } @@ -622,25 +706,27 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re // 解析模型和 thinking 模式 thinkingCfg := config.GetThinkingConfig() - actualModel, thinking := ParseModelAndThinking(req.Model, thinkingCfg.Suffix) + actualModel, thinking := resolveClaudeThinkingMode(req.Model, req.Thinking, thinkingCfg.Suffix) req.Model = actualModel - estimatedInputTokens := estimateClaudeRequestInputTokens(&req) - cacheProfile := h.promptCache.BuildClaudeProfile(&req, estimatedInputTokens) + effectiveReq := cloneClaudeRequestForThinking(&req, thinking) + thinkingResponseOpts := resolveClaudeThinkingResponseOptions(req.Thinking, thinkingCfg.ClaudeFormat) + estimatedInputTokens := estimateClaudeRequestInputTokens(effectiveReq) + cacheProfile := h.promptCache.BuildClaudeProfile(effectiveReq, estimatedInputTokens) cacheUsage := h.promptCache.Compute(account.ID, cacheProfile) // 转换请求 kiroPayload := ClaudeToKiro(&req, thinking) - // 流式或非流式 + // Stream or non-stream if req.Stream { - h.handleClaudeStream(w, account, kiroPayload, req.Model, thinking, estimatedInputTokens, cacheUsage, cacheProfile) + h.handleClaudeStream(w, account, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheUsage, cacheProfile) } else { - h.handleClaudeNonStream(w, account, kiroPayload, req.Model, thinking, estimatedInputTokens, cacheUsage, cacheProfile) + h.handleClaudeNonStream(w, account, kiroPayload, req.Model, thinking, thinkingResponseOpts, estimatedInputTokens, cacheUsage, cacheProfile) } } // handleClaudeStream Claude 流式响应 -func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, thinking bool, estimatedInputTokens int, cacheUsage promptCacheUsage, cacheProfile *promptCacheProfile) { +func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheUsage promptCacheUsage, cacheProfile *promptCacheProfile) { w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -652,11 +738,12 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Acco } // 获取 thinking 输出格式配置 - thinkingFormat := config.GetThinkingConfig().ClaudeFormat + thinkingFormat := thinkingOpts.Format msgID := "msg_" + uuid.New().String() var inputTokens, outputTokens int var credits float64 + var realInputTokens int var toolUses []KiroToolUse var nextContentIndex int var rawContentBuilder strings.Builder @@ -768,6 +855,19 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Acco "delta": map[string]string{"type": "text_delta", "text": text}, }) default: + if thinkingOpts.OmitDisplay { + if thinkingState == 1 { + startContentBlock("thinking") + return + } + if thinkingState == 3 { + if activeBlockType != "thinking" { + startContentBlock("thinking") + } + closeActiveBlock() + } + return + } if thinkingState == 3 && text == "" { if activeBlockType == "thinking" { closeActiveBlock() @@ -978,6 +1078,9 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Acco OnCredits: func(c float64) { credits = c }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, } err := CallKiroAPI(account, payload, callback) @@ -999,7 +1102,9 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, account *config.Acco } closeActiveBlock() - if inputTokens <= 0 { + if realInputTokens > 0 { + inputTokens = realInputTokens + } else if inputTokens <= 0 { inputTokens = estimatedInputTokens } outputContent, extractedReasoning := extractThinkingFromContent(rawContentBuilder.String()) @@ -1097,12 +1202,13 @@ func (h *Handler) recordFailure() { } // handleClaudeNonStream Claude 非流式响应 -func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, thinking bool, estimatedInputTokens int, cacheUsage promptCacheUsage, cacheProfile *promptCacheProfile) { +func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.Account, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheUsage promptCacheUsage, cacheProfile *promptCacheProfile) { var content string var thinkingContent string var toolUses []KiroToolUse var inputTokens, outputTokens int var credits float64 + var realInputTokens int callback := &KiroStreamCallback{ OnText: func(text string, isThinking bool) { @@ -1125,6 +1231,9 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.A OnCredits: func(c float64) { credits = c }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, } err := CallKiroAPI(account, payload, callback) @@ -1136,38 +1245,47 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, account *config.A } // 合并 thinking 内容(如果有 reasoningContentEvent 的内容) - thinkingFormat := config.GetThinkingConfig().ClaudeFormat + thinkingFormat := thinkingOpts.Format finalContent, extractedReasoning := extractThinkingFromContent(content) - if thinking && thinkingContent == "" && extractedReasoning != "" { - thinkingContent = extractedReasoning + rawThinkingContent := thinkingContent + if thinking && rawThinkingContent == "" && extractedReasoning != "" { + rawThinkingContent = extractedReasoning } if !thinking { - thinkingContent = "" + rawThinkingContent = "" } - if inputTokens <= 0 { + if realInputTokens > 0 { + inputTokens = realInputTokens + } else if inputTokens <= 0 { inputTokens = estimatedInputTokens } - outputTokens = estimateClaudeOutputTokens(finalContent, thinkingContent, toolUses) + outputTokens = estimateClaudeOutputTokens(finalContent, rawThinkingContent, toolUses) h.recordSuccess(inputTokens, outputTokens, credits) h.pool.RecordSuccess(account.ID) h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) h.promptCache.Update(account.ID, cacheProfile) - if thinking && thinkingContent != "" { + responseThinkingContent := rawThinkingContent + includeEmptyThinkingBlock := thinking && thinkingOpts.OmitDisplay && rawThinkingContent != "" + if includeEmptyThinkingBlock { + responseThinkingContent = "" + } + + if thinking && responseThinkingContent != "" { switch thinkingFormat { case "think": - finalContent = "" + thinkingContent + "" + finalContent - thinkingContent = "" + finalContent = "" + responseThinkingContent + "" + finalContent + responseThinkingContent = "" case "reasoning_content": - finalContent = thinkingContent + finalContent // Claude 格式不支持 reasoning_content,直接拼接 - thinkingContent = "" + finalContent = responseThinkingContent + finalContent // Claude 格式不支持 reasoning_content,直接拼接 + responseThinkingContent = "" default: } } - resp := KiroToClaudeResponse(finalContent, thinkingContent, toolUses, inputTokens, outputTokens, model) + resp := KiroToClaudeResponse(finalContent, responseThinkingContent, includeEmptyThinkingBlock, toolUses, inputTokens, outputTokens, model) resp.Usage.InputTokens = billedClaudeInputTokens(inputTokens, cacheUsage) resp.Usage.CacheCreationInputTokens = cacheUsage.CacheCreationInputTokens resp.Usage.CacheReadInputTokens = cacheUsage.CacheReadInputTokens @@ -1262,6 +1380,7 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, account *config.Acco var toolCallIndex int var inputTokens, outputTokens int var credits float64 + var realInputTokens int var rawContentBuilder strings.Builder var rawReasoningBuilder strings.Builder @@ -1554,6 +1673,9 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, account *config.Acco OnCredits: func(c float64) { credits = c }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, } err := CallKiroAPI(account, payload, callback) @@ -1570,7 +1692,9 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, account *config.Acco eventThinkingOpen = false } - if inputTokens <= 0 { + if realInputTokens > 0 { + inputTokens = realInputTokens + } else if inputTokens <= 0 { inputTokens = estimatedInputTokens } outputContent, extractedReasoning := extractThinkingFromContent(rawContentBuilder.String()) @@ -1626,6 +1750,7 @@ func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, account *config.A var toolUses []KiroToolUse var inputTokens, outputTokens int var credits float64 + var realInputTokens int callback := &KiroStreamCallback{ OnText: func(text string, isThinking bool) { @@ -1639,6 +1764,9 @@ func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, account *config.A OnComplete: func(inTok, outTok int) { inputTokens = inTok; outputTokens = outTok }, OnError: func(err error) { h.pool.RecordError(account.ID, strings.Contains(err.Error(), "429")) }, OnCredits: func(c float64) { credits = c }, + OnContextUsage: func(pct float64) { + realInputTokens = int(pct * float64(getContextWindowSize(model)) / 100.0) + }, } err := CallKiroAPI(account, payload, callback) @@ -1657,7 +1785,9 @@ func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, account *config.A reasoningContent = "" } - if inputTokens <= 0 { + if realInputTokens > 0 { + inputTokens = realInputTokens + } else if inputTokens <= 0 { inputTokens = estimatedInputTokens } outputTokens = estimateOpenAIOutputTokens(finalContent, reasoningContent, toolUses) @@ -1781,6 +1911,10 @@ func (h *Handler) handleAdminAPI(w http.ResponseWriter, r *http.Request) { h.apiGetEndpointConfig(w, r) case path == "/endpoint" && r.Method == "POST": h.apiUpdateEndpointConfig(w, r) + case path == "/proxy" && r.Method == "GET": + h.apiGetProxy(w, r) + case path == "/proxy" && r.Method == "POST": + h.apiUpdateProxy(w, r) case path == "/general" && r.Method == "GET": h.apiGetGeneralConfig(w, r) case path == "/general" && r.Method == "POST": @@ -2749,6 +2883,54 @@ func (h *Handler) apiUpdateEndpointConfig(w http.ResponseWriter, r *http.Request json.NewEncoder(w).Encode(map[string]bool{"success": true}) } +// applyProxyConfig 将代理配置应用到所有出站 HTTP 客户端(Kiro API + auth 模块) +func applyProxyConfig(proxyURL string) { + InitKiroHttpClient(proxyURL) + auth.InitHttpClient(proxyURL) +} + +// apiGetProxy 获取当前代理配置 +func (h *Handler) apiGetProxy(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]string{ + "proxyURL": config.GetProxyURL(), + }) +} + +// apiUpdateProxy 更新代理配置并立即生效 +func (h *Handler) apiUpdateProxy(w http.ResponseWriter, r *http.Request) { + var req struct { + ProxyURL string `json:"proxyURL"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) + return + } + + // 验证代理 URL 格式(非空时) + if req.ProxyURL != "" { + if !strings.HasPrefix(req.ProxyURL, "http://") && + !strings.HasPrefix(req.ProxyURL, "https://") && + !strings.HasPrefix(req.ProxyURL, "socks5://") && + !strings.HasPrefix(req.ProxyURL, "socks5h://") { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]string{"error": "proxyURL must start with http://, https://, socks5://, or socks5h://"}) + return + } + } + + if err := config.UpdateProxySettings(req.ProxyURL); err != nil { + w.WriteHeader(500) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + // 立即应用新的代理配置 + applyProxyConfig(req.ProxyURL) + + json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + // apiGetGeneralConfig 获取通用设置 func (h *Handler) apiGetGeneralConfig(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/proxy/handler_test.go b/proxy/handler_test.go index 672092a..e905bf1 100644 --- a/proxy/handler_test.go +++ b/proxy/handler_test.go @@ -1,8 +1,6 @@ package proxy -import ( - "testing" -) +import "testing" func TestThinkingSourceReasoningFirst(t *testing.T) { var source thinkingStreamSource @@ -101,6 +99,240 @@ func TestValidateClaudeRequestShapeRejectsAssistantPrefill(t *testing.T) { } } +func TestResolveClaudeThinkingModeHonorsRequestThinking(t *testing.T) { + tests := []struct { + name string + model string + thinking *ClaudeThinkingConfig + wantModel string + wantThinking bool + }{ + { + name: "adaptive request enables thinking", + model: "claude-sonnet-4.6", + thinking: &ClaudeThinkingConfig{Type: "adaptive"}, + wantModel: "claude-sonnet-4.6", + wantThinking: true, + }, + { + name: "enabled request enables thinking", + model: "claude-opus-4.5", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 2048}, + wantModel: "claude-opus-4.5", + wantThinking: true, + }, + { + name: "disabled request keeps thinking off", + model: "claude-opus-4.7", + thinking: &ClaudeThinkingConfig{Type: "disabled"}, + wantModel: "claude-opus-4.7", + wantThinking: false, + }, + { + name: "suffix remains supported when thinking is disabled", + model: "claude-sonnet-4.5-thinking", + thinking: &ClaudeThinkingConfig{Type: "disabled"}, + wantModel: "claude-sonnet-4.5", + wantThinking: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + gotModel, gotThinking := resolveClaudeThinkingMode(tc.model, tc.thinking, "-thinking") + if gotModel != tc.wantModel { + t.Fatalf("expected model %q, got %q", tc.wantModel, gotModel) + } + if gotThinking != tc.wantThinking { + t.Fatalf("expected thinking=%v, got %v", tc.wantThinking, gotThinking) + } + }) + } +} + +func TestCloneClaudeRequestForThinkingInjectsPromptWithoutMutatingOriginal(t *testing.T) { + req := &ClaudeRequest{ + Model: "claude-sonnet-4.6", + System: "Follow the user instructions.", + } + + cloned := cloneClaudeRequestForThinking(req, true) + blocks, ok := cloned.System.([]interface{}) + if !ok { + t.Fatalf("expected cloned system prompt to be structured blocks, got %T", cloned.System) + } + if len(blocks) != 2 { + t.Fatalf("expected 2 system blocks after prepend, got %d", len(blocks)) + } + gotPrompt := extractSystemPrompt(cloned.System) + expected := ThinkingModePrompt + "\n\nFollow the user instructions." + if gotPrompt != expected { + t.Fatalf("expected injected system prompt %q, got %q", expected, gotPrompt) + } + if original, ok := req.System.(string); !ok || original != "Follow the user instructions." { + t.Fatalf("expected original request system prompt to stay unchanged, got %#v", req.System) + } +} + +func TestCloneClaudeRequestForThinkingPreservesStructuredSystemBlocks(t *testing.T) { + req := &ClaudeRequest{ + Model: "claude-sonnet-4.6", + System: []interface{}{ + map[string]interface{}{ + "type": "text", + "text": "cached system", + "cache_control": map[string]interface{}{ + "type": "ephemeral", + "ttl": "5m", + }, + }, + }, + } + + cloned := cloneClaudeRequestForThinking(req, true) + blocks, ok := cloned.System.([]interface{}) + if !ok { + t.Fatalf("expected structured system blocks, got %T", cloned.System) + } + if len(blocks) != 2 { + t.Fatalf("expected 2 system blocks after prepend, got %d", len(blocks)) + } + first, ok := blocks[0].(map[string]interface{}) + if !ok || first["text"] != ThinkingModePrompt+"\n" { + t.Fatalf("expected first block to be thinking prompt, got %#v", blocks[0]) + } + second, ok := blocks[1].(map[string]interface{}) + if !ok { + t.Fatalf("expected original system block to remain a map, got %T", blocks[1]) + } + cacheControl, ok := second["cache_control"].(map[string]interface{}) + if !ok || cacheControl["type"] != "ephemeral" { + t.Fatalf("expected original cache_control to be preserved, got %#v", second["cache_control"]) + } +} + +func TestThinkingPromptAffectsClaudeTokenEstimate(t *testing.T) { + req := &ClaudeRequest{ + Model: "claude-sonnet-4.6", + Messages: []ClaudeMessage{{Role: "user", Content: "hello"}}, + } + + baseTokens := estimateClaudeRequestInputTokens(req) + thinkingTokens := estimateClaudeRequestInputTokens(cloneClaudeRequestForThinking(req, true)) + + if thinkingTokens <= baseTokens { + t.Fatalf("expected thinking tokens (%d) to exceed base tokens (%d)", thinkingTokens, baseTokens) + } +} + +func TestValidateClaudeThinkingConfig(t *testing.T) { + tests := []struct { + name string + thinking *ClaudeThinkingConfig + maxTokens int + expectError bool + }{ + { + name: "adaptive is valid", + thinking: &ClaudeThinkingConfig{Type: "adaptive"}, + maxTokens: 4096, + expectError: false, + }, + { + name: "enabled requires budget", + thinking: &ClaudeThinkingConfig{Type: "enabled"}, + maxTokens: 4096, + expectError: true, + }, + { + name: "enabled requires at least 1024 budget tokens", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 512}, + maxTokens: 4096, + expectError: true, + }, + { + name: "enabled rejects max tokens zero", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 2048}, + maxTokens: 0, + expectError: true, + }, + { + name: "enabled budget must stay below max tokens", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 4096}, + maxTokens: 4096, + expectError: true, + }, + { + name: "disabled rejects display", + thinking: &ClaudeThinkingConfig{Type: "disabled", Display: "summarized"}, + maxTokens: 4096, + expectError: true, + }, + { + name: "missing type is rejected", + thinking: &ClaudeThinkingConfig{}, + maxTokens: 4096, + expectError: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + errMsg := validateClaudeThinkingConfig(tc.thinking, tc.maxTokens) + if tc.expectError && errMsg == "" { + t.Fatalf("expected validation error") + } + if !tc.expectError && errMsg != "" { + t.Fatalf("expected thinking config to be valid, got %q", errMsg) + } + }) + } +} + +func TestResolveClaudeThinkingResponseOptions(t *testing.T) { + tests := []struct { + name string + thinking *ClaudeThinkingConfig + defaultFmt string + wantFmt string + wantOmit bool + }{ + { + name: "default config is preserved when display unset", + thinking: &ClaudeThinkingConfig{Type: "enabled", BudgetTokens: 2048}, + defaultFmt: "think", + wantFmt: "think", + wantOmit: false, + }, + { + name: "summarized forces official thinking blocks", + thinking: &ClaudeThinkingConfig{Type: "adaptive", Display: "summarized"}, + defaultFmt: "reasoning_content", + wantFmt: "thinking", + wantOmit: false, + }, + { + name: "omitted forces official thinking blocks and hides content", + thinking: &ClaudeThinkingConfig{Type: "adaptive", Display: "omitted"}, + defaultFmt: "think", + wantFmt: "thinking", + wantOmit: true, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + opts := resolveClaudeThinkingResponseOptions(tc.thinking, tc.defaultFmt) + if opts.Format != tc.wantFmt { + t.Fatalf("expected format %q, got %q", tc.wantFmt, opts.Format) + } + if opts.OmitDisplay != tc.wantOmit { + t.Fatalf("expected omitDisplay=%v, got %v", tc.wantOmit, opts.OmitDisplay) + } + }) + } +} + func TestMergeUniqueModelsPreservesUnionAcrossAccounts(t *testing.T) { base := []ModelInfo{ {ModelId: "claude-sonnet-4.5", InputTypes: []string{"TEXT"}}, diff --git a/proxy/kiro.go b/proxy/kiro.go index 9d5014d..4f37e4b 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -13,6 +13,7 @@ import ( "net/url" "strconv" "strings" + "sync/atomic" "time" "github.com/google/uuid" @@ -41,16 +42,39 @@ var kiroEndpoints = []kiroEndpoint{ }, } -// 全局 HTTP 客户端,复用连接池 -var kiroHttpClient = &http.Client{ - Timeout: 5 * time.Minute, - Transport: &http.Transport{ - MaxIdleConns: 100, // 最大空闲连接数 - MaxIdleConnsPerHost: 20, // 每个 Host 最大空闲连接数 - IdleConnTimeout: 90 * time.Second, // 空闲连接超时 - DisableCompression: false, // 启用压缩 - ForceAttemptHTTP2: true, // 尝试使用 HTTP/2 - }, +// 全局 HTTP 客户端,支持运行时更换(代理重配置) +var kiroHttpStore atomic.Pointer[http.Client] + +func init() { + InitKiroHttpClient("") +} + +// buildKiroTransport 构建带可选代理的 Transport +func buildKiroTransport(proxyURL string) *http.Transport { + t := &http.Transport{ + MaxIdleConns: 100, + MaxIdleConnsPerHost: 20, + IdleConnTimeout: 90 * time.Second, + DisableCompression: false, + ForceAttemptHTTP2: true, + } + if proxyURL != "" { + if u, err := url.Parse(proxyURL); err == nil { + t.Proxy = http.ProxyURL(u) + // 代理不支持 HTTP/2 协议升级 + t.ForceAttemptHTTP2 = false + } + } + return t +} + +// InitKiroHttpClient 初始化(或重新初始化)Kiro API 的 HTTP 客户端 +func InitKiroHttpClient(proxyURL string) { + client := &http.Client{ + Timeout: 5 * time.Minute, + Transport: buildKiroTransport(proxyURL), + } + kiroHttpStore.Store(client) } // ==================== 请求结构 ==================== @@ -133,15 +157,16 @@ type InferenceConfig struct { TopP float64 `json:"topP,omitempty"` } -// ==================== 流式回调 ==================== +// ==================== Stream Callbacks ==================== -// KiroStreamCallback 流式响应回调 +// KiroStreamCallback stream response callbacks type KiroStreamCallback struct { - OnText func(text string, isThinking bool) - OnToolUse func(toolUse KiroToolUse) - OnComplete func(inputTokens, outputTokens int) - OnError func(err error) - OnCredits func(credits float64) + OnText func(text string, isThinking bool) + OnToolUse func(toolUse KiroToolUse) + OnComplete func(inputTokens, outputTokens int) + OnError func(err error) + OnCredits func(credits float64) + OnContextUsage func(percentage float64) } // ==================== API 调用 ==================== @@ -164,16 +189,16 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt return err } + // 根据配置排序端点 + endpoints := getSortedEndpoints(config.GetPreferredEndpoint()) + invalidModelRetries := config.GetInvalidModelRetries() + modelID := payload.ConversationState.CurrentMessage.UserInputMessage.ModelID accountLabel := account.Email if accountLabel == "" { accountLabel = account.ID } - // 根据配置排序端点 - endpoints := getSortedEndpoints(config.GetPreferredEndpoint()) - invalidModelRetries := config.GetInvalidModelRetries() - endpointNames := make([]string, 0, len(endpoints)) for _, ep := range endpoints { endpointNames = append(endpointNames, ep.Name) @@ -217,7 +242,7 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt attemptStart := time.Now() log.Printf("[KiroAPI] try endpoint=%s attempt=%d/%d account=%s model=%q origin=%s", ep.Name, attempt, maxAttempts, accountLabel, modelID, ep.Origin) - resp, err := kiroHttpClient.Do(req) + resp, err := kiroHttpStore.Load().Do(req) if err != nil { lastErr = err log.Printf("[KiroAPI] endpoint=%s attempt=%d/%d account=%s model=%q transport_error elapsed=%s err=%v", ep.Name, attempt, maxAttempts, accountLabel, modelID, time.Since(attemptStart), err) @@ -364,6 +389,12 @@ func parseEventStream(body io.Reader, callback *KiroStreamCallback) error { if usage, ok := event["usage"].(float64); ok { totalCredits += usage } + case "contextUsageEvent": + if pct, ok := event["contextUsagePercentage"].(float64); ok { + if callback.OnContextUsage != nil { + callback.OnContextUsage(pct) + } + } } } @@ -428,6 +459,17 @@ func updateTokensFromEvent(event map[string]interface{}, currentInputTokens, cur return inputTokens, outputTokens } +// getContextWindowSize returns the context window size (in tokens) for a model. +func getContextWindowSize(model string) int { + m := strings.ToLower(model) + // sonnet-4.6, opus-4.6, opus-4.7 all have 1M context windows + if strings.Contains(m, "4.6") || strings.Contains(m, "4-6") || + strings.Contains(m, "4.7") || strings.Contains(m, "4-7") { + return 1_000_000 + } + return 200_000 +} + func collectUsageMaps(v interface{}, out *[]map[string]interface{}) { switch t := v.(type) { case map[string]interface{}: diff --git a/proxy/translator.go b/proxy/translator.go index ca3fdf3..4a4d312 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -109,6 +109,19 @@ func ParseModelAndThinking(model string, thinkingSuffix string) (string, bool) { return model, thinking } +func resolveClaudeThinkingMode(model string, thinkingCfg *ClaudeThinkingConfig, thinkingSuffix string) (string, bool) { + actualModel, suffixThinking := ParseModelAndThinking(model, thinkingSuffix) + return actualModel, suffixThinking || isClaudeThinkingRequested(thinkingCfg) +} + +func isClaudeThinkingRequested(thinkingCfg *ClaudeThinkingConfig) bool { + if thinkingCfg == nil { + return false + } + kind := strings.ToLower(strings.TrimSpace(thinkingCfg.Type)) + return kind == "enabled" || kind == "adaptive" +} + func MapModel(model string) string { mapped, _ := ParseModelAndThinking(model, "-thinking") return mapped @@ -117,15 +130,22 @@ func MapModel(model string) string { // ==================== Claude API 类型 ==================== type ClaudeRequest struct { - Model string `json:"model"` - Messages []ClaudeMessage `json:"messages"` - MaxTokens int `json:"max_tokens"` - Temperature float64 `json:"temperature,omitempty"` - TopP float64 `json:"top_p,omitempty"` - Stream bool `json:"stream,omitempty"` - System interface{} `json:"system,omitempty"` // string or []SystemBlock - Tools []ClaudeTool `json:"tools,omitempty"` - ToolChoice interface{} `json:"tool_choice,omitempty"` + Model string `json:"model"` + Messages []ClaudeMessage `json:"messages"` + MaxTokens int `json:"max_tokens"` + Temperature float64 `json:"temperature,omitempty"` + TopP float64 `json:"top_p,omitempty"` + Stream bool `json:"stream,omitempty"` + System interface{} `json:"system,omitempty"` // string or []SystemBlock + Thinking *ClaudeThinkingConfig `json:"thinking,omitempty"` + Tools []ClaudeTool `json:"tools,omitempty"` + ToolChoice interface{} `json:"tool_choice,omitempty"` +} + +type ClaudeThinkingConfig struct { + Type string `json:"type,omitempty"` + BudgetTokens int `json:"budget_tokens,omitempty"` + Display string `json:"display,omitempty"` } type ClaudeMessage struct { @@ -137,6 +157,7 @@ type ClaudeContentBlock struct { Type string `json:"type"` Text string `json:"text,omitempty"` Thinking string `json:"thinking,omitempty"` + Signature string `json:"signature,omitempty"` ID string `json:"id,omitempty"` Name string `json:"name,omitempty"` Input interface{} `json:"input,omitempty"` @@ -190,12 +211,7 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { origin := "AI_EDITOR" // 提取系统提示 - systemPrompt := extractSystemPrompt(req.System) - - // 如果启用 thinking 模式,注入 thinking 提示 - if thinking { - systemPrompt = ThinkingModePrompt + "\n\n" + systemPrompt - } + systemPrompt := buildClaudeSystemPrompt(req.System, thinking) // 构建历史消息 history := make([]KiroHistoryMessage, 0) @@ -296,6 +312,88 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { return payload } +func buildClaudeSystemPrompt(system interface{}, thinking bool) string { + systemPrompt := extractSystemPrompt(system) + if !thinking { + return systemPrompt + } + if systemPrompt == "" { + return ThinkingModePrompt + } + return ThinkingModePrompt + "\n\n" + systemPrompt +} + +func cloneClaudeRequestForThinking(req *ClaudeRequest, thinking bool) *ClaudeRequest { + if req == nil { + return nil + } + + cloned := *req + if thinking { + cloned.System = prependThinkingSystem(req.System) + } + return &cloned +} + +func prependThinkingSystem(system interface{}) interface{} { + thinkingText := ThinkingModePrompt + if hasClaudeSystemContent(system) { + thinkingText += "\n" + } + thinkingBlock := map[string]interface{}{ + "type": "text", + "text": thinkingText, + } + + switch v := system.(type) { + case nil: + return []interface{}{thinkingBlock} + case string: + if v == "" { + return []interface{}{thinkingBlock} + } + return []interface{}{ + thinkingBlock, + map[string]interface{}{ + "type": "text", + "text": v, + }, + } + case []interface{}: + blocks := make([]interface{}, 0, len(v)+1) + blocks = append(blocks, thinkingBlock) + blocks = append(blocks, v...) + return blocks + case []string: + blocks := make([]interface{}, 0, len(v)+1) + blocks = append(blocks, thinkingBlock) + for _, block := range v { + blocks = append(blocks, map[string]interface{}{ + "type": "text", + "text": block, + }) + } + return blocks + default: + return []interface{}{thinkingBlock} + } +} + +func hasClaudeSystemContent(system interface{}) bool { + switch v := system.(type) { + case nil: + return false + case string: + return v != "" + case []interface{}: + return len(v) > 0 + case []string: + return len(v) > 0 + default: + return true + } +} + func extractSystemPrompt(system interface{}) string { if system == nil { return "" @@ -492,10 +590,10 @@ func shortenToolName(name string) string { // ==================== Kiro -> Claude 转换 ==================== -func KiroToClaudeResponse(content, thinkingContent string, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *ClaudeResponse { +func KiroToClaudeResponse(content, thinkingContent string, includeEmptyThinkingBlock bool, toolUses []KiroToolUse, inputTokens, outputTokens int, model string) *ClaudeResponse { blocks := make([]ClaudeContentBlock, 0) - if thinkingContent != "" { + if thinkingContent != "" || includeEmptyThinkingBlock { blocks = append(blocks, ClaudeContentBlock{ Type: "thinking", Thinking: thinkingContent, diff --git a/proxy/translator_test.go b/proxy/translator_test.go index bf7cde8..984c97f 100644 --- a/proxy/translator_test.go +++ b/proxy/translator_test.go @@ -233,6 +233,23 @@ func TestClaudeToKiroDropsLeadingAssistantHistory(t *testing.T) { } } +func TestKiroToClaudeResponseCanEmitEmptyThinkingBlock(t *testing.T) { + resp := KiroToClaudeResponse("final answer", "", true, nil, 10, 20, "claude-sonnet-4.6") + + if len(resp.Content) != 2 { + t.Fatalf("expected empty thinking block plus text block, got %d blocks", len(resp.Content)) + } + if resp.Content[0].Type != "thinking" { + t.Fatalf("expected first block to be thinking, got %#v", resp.Content[0]) + } + if resp.Content[0].Thinking != "" { + t.Fatalf("expected omitted thinking block to have empty content, got %#v", resp.Content[0].Thinking) + } + if resp.Content[1].Type != "text" || resp.Content[1].Text != "final answer" { + t.Fatalf("expected text block to be preserved, got %#v", resp.Content[1]) + } +} + func TestToolResultsContinuationIncludesInstructionPrefix(t *testing.T) { req := &OpenAIRequest{ Model: "claude-sonnet-4.5", diff --git a/version.json b/version.json index 3942b7b..14e14d7 100644 --- a/version.json +++ b/version.json @@ -1,5 +1,5 @@ { - "version": "1.0.5", + "version": "1.0.6", "changelog": "✨ Added and fixed several improvements across the project.\n✨ 新增并修复了一些内容,包含若干功能改进与问题修复。", "download": "https://github.com/Quorinex/Kiro-Go" } diff --git a/web/index.html b/web/index.html index 4c08fb4..88b07ee 100644 --- a/web/index.html +++ b/web/index.html @@ -1028,6 +1028,34 @@ id="newPassword" data-i18n-placeholder="settings.newPasswordPlaceholder"> +
+
+
+ + +
+ + +
@@ -1162,6 +1190,16 @@ 'settings.statistics': '统计', 'settings.resetStats': '重置统计', 'settings.confirmReset': '确定重置统计?', + 'settings.proxySettings': '出站代理设置', + 'settings.proxyType': '代理类型', + 'settings.proxyNone': '直连(不使用代理)', + 'settings.proxyHost': '地址 / 端口', + 'settings.proxyAuth': '认证(可选)', + 'settings.proxyUsername': '用户名', + 'settings.proxyPassword': '密码', + 'settings.proxyHostRequired': '请填写代理地址和端口', + 'settings.saveProxy': '保存代理设置', + 'settings.proxySaved': '代理设置已保存', 'api.endpoints': 'API 端点', 'api.modelList': '模型列表', 'api.stats': '统计数据', @@ -1373,6 +1411,16 @@ 'settings.statistics': 'Statistics', 'settings.resetStats': 'Reset Statistics', 'settings.confirmReset': 'Confirm reset statistics?', + 'settings.proxySettings': 'Outbound Proxy Settings', + 'settings.proxyType': 'Proxy Type', + 'settings.proxyNone': 'Direct (no proxy)', + 'settings.proxyHost': 'Host / Port', + 'settings.proxyAuth': 'Authentication (optional)', + 'settings.proxyUsername': 'Username', + 'settings.proxyPassword': 'Password', + 'settings.proxyHostRequired': 'Please enter proxy host and port', + 'settings.saveProxy': 'Save Proxy Settings', + 'settings.proxySaved': 'Proxy settings saved', 'api.endpoints': 'API Endpoints', 'api.modelList': 'Model List', 'api.stats': 'Statistics', @@ -2012,6 +2060,7 @@ document.getElementById('apiKeyInput').value = d.apiKey || ''; loadThinkingConfig(); loadEndpointConfig(); + loadProxyConfig(); loadGeneralConfig(); } async function loadThinkingConfig() { @@ -2062,6 +2111,52 @@ const d = await res.json(); if (d.success) { alert(t('settings.generalSaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); } } + async function loadProxyConfig() { + const res = await fetch('/admin/api/proxy', { headers: { 'X-Admin-Password': password } }); + const d = await res.json(); + const proxyURL = d.proxyURL || ''; + if (!proxyURL) { + document.getElementById('proxyType').value = 'none'; + document.getElementById('proxyFields').style.display = 'none'; + return; + } + try { + const u = new URL(proxyURL); + const scheme = u.protocol.replace(':', ''); + document.getElementById('proxyType').value = scheme.startsWith('socks5') ? 'socks5' : 'http'; + document.getElementById('proxyHost').value = u.hostname; + document.getElementById('proxyPort').value = u.port; + document.getElementById('proxyUsername').value = decodeURIComponent(u.username); + document.getElementById('proxyPassword').value = decodeURIComponent(u.password); + document.getElementById('proxyFields').style.display = ''; + } catch(e) { + document.getElementById('proxyType').value = 'none'; + document.getElementById('proxyFields').style.display = 'none'; + } + } + function onProxyTypeChange() { + const type = document.getElementById('proxyType').value; + document.getElementById('proxyFields').style.display = type === 'none' ? 'none' : ''; + } + async function saveProxyConfig() { + const type = document.getElementById('proxyType').value; + let proxyURL = ''; + if (type !== 'none') { + const host = document.getElementById('proxyHost').value.trim(); + const port = document.getElementById('proxyPort').value.trim(); + if (!host || !port) { alert(t('settings.proxyHostRequired')); return; } + const user = document.getElementById('proxyUsername').value.trim(); + const pass = document.getElementById('proxyPassword').value.trim(); + const auth = user ? (pass ? `${encodeURIComponent(user)}:${encodeURIComponent(pass)}@` : `${encodeURIComponent(user)}@`) : ''; + proxyURL = `${type}://${auth}${host}:${port}`; + } + const res = await fetch('/admin/api/proxy', { + method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password }, + body: JSON.stringify({ proxyURL }) + }); + const d = await res.json(); + if (d.success) { alert(t('settings.proxySaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); } + } function generateApiKey() { const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'; let key = 'sk-';