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-';