From cb749c898e85fe9502f79acf3fe2d096d59c9715 Mon Sep 17 00:00:00 2001 From: Duc Date: Thu, 18 Jun 2026 17:40:30 +0700 Subject: [PATCH 01/68] feat(auth): add Enterprise SSO - Microsoft 365 (Entra ID / Azure AD) login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kiro-Go could not sign in Microsoft 365 / Entra ID (Azure AD) tenant accounts: such an account is neither an AWS Builder ID nor an AWS IAM Identity Center account, and there was no Kiro hosted-portal flow at all. This adds the external IdP path end to end. Core token-path support (makes Azure-tenant tokens actually work): - config.Account gains TokenEndpoint / IssuerURL / Scopes for external IdP creds. - auth/oidc.go: external_idp refresh branch (refreshExternalIdpToken + shared postExternalIdpToken) — refreshes against the IdP token endpoint (OAuth2 refresh_token grant, public client), not AWS SSO OIDC. - proxy/kiro_headers.go: send TokenType: EXTERNAL_IDP on CodeWhisperer calls for external_idp accounts. Without it CodeWhisperer silently returns an empty profile list and rejects data-plane calls. Login flow (auth/kiro_sso.go, new): - Kiro hosted-portal PKCE flow with a loopback listener on 127.0.0.1:3128 (+[::1]). - Drives both legs: social (Cognito) and the enterprise external-IdP leg (portal descriptor -> OIDC-discover the Azure issuer -> 302 the browser to Azure -> /oauth/callback -> exchange at the IdP token endpoint). - SSRF/open-redirect controls: https-only allow-list of *.microsoftonline.com (.us/.cn), no IP literals, applied to issuer + both discovered endpoints; OIDC discovery refuses redirects; anti-CSRF state matched on both legs; single-shot enterprise leg; deadline self-teardown frees the loopback port. Admin wiring: - proxy/handler.go: /auth/kiro-sso/{start,poll,cancel} (Start/Poll session pattern mirroring Builder ID); profileArn resolved lazily on first use. - web/app.js + locales: 7th "Enterprise SSO - Microsoft 365" Add-Account card. Tests: external-IdP refresh (form encoding, response mapping, refresh-token retention, precondition guards), issuer allow-list validation, authorize-URL construction, JWT email/preferred_username extraction, and the EXTERNAL_IDP header gating (set for external_idp, omitted for idc/social). Co-Authored-By: Claude Opus 4.8 (1M context) --- auth/kiro_sso.go | 719 ++++++++++++++++++++++++++++++++ auth/kiro_sso_test.go | 211 ++++++++++ auth/oidc.go | 81 ++++ config/config.go | 12 +- docker-compose.yml | 12 + proxy/handler.go | 119 ++++++ proxy/kiro_external_idp_test.go | 49 +++ proxy/kiro_headers.go | 7 + web/app.js | 92 ++++ web/locales/en.json | 4 + web/locales/zh.json | 4 + 11 files changed, 1308 insertions(+), 2 deletions(-) create mode 100644 auth/kiro_sso.go create mode 100644 auth/kiro_sso_test.go create mode 100644 proxy/kiro_external_idp_test.go diff --git a/auth/kiro_sso.go b/auth/kiro_sso.go new file mode 100644 index 00000000..5a1ff56f --- /dev/null +++ b/auth/kiro_sso.go @@ -0,0 +1,719 @@ +package auth + +// kiro_sso.go implements the Kiro hosted browser sign-in flow — the same flow +// the Kiro IDE uses at https://app.kiro.dev/signin. Unlike Builder ID / IAM +// Identity Center (AWS SSO OIDC), this portal federates Google, GitHub, AND +// enterprise identity providers (e.g. a Microsoft 365 / Entra ID / Azure AD +// tenant) behind a single PKCE authorization-code flow. It is the only way an +// enterprise Azure-tenant account — which is neither an AWS Builder ID nor an +// AWS IAM Identity Center account — can sign in to Kiro. +// +// The flow has two possible legs, both captured by one transient loopback +// listener bound on the fixed redirect port: +// +// - Social (Google/GitHub): the portal authenticates via its Cognito backend +// and redirects the authorization code straight back to the loopback +// redirect. The code is exchanged at the Kiro social token endpoint. +// +// - Enterprise / external IdP (Azure AD): the portal detects the email belongs +// to an external IdP and redirects to /signin/callback with the IdP +// descriptor (issuer_url, client_id, scopes) instead of a code. We then drive +// a SECOND OIDC authorization-code+PKCE flow directly against that IdP +// (loopback redirect to /oauth/callback) and exchange the code at the IdP +// token endpoint. The resulting access token is an IdP-issued token scoped +// for CodeWhisperer; it is used as the runtime bearer and refreshed against +// the IdP token endpoint (see refreshExternalIdpToken in oidc.go). +// +// The login is exposed through the admin panel with the same Start/Poll session +// pattern as Builder ID: StartKiroSsoLogin binds the listener and returns the +// sign-in URL; the operator opens it in a browser ON THE SAME HOST (the redirect +// targets 127.0.0.1:3128); PollKiroSsoAuth reports pending until the listener +// captures the code, then exchanges it and returns the credential. + +import ( + "bytes" + "encoding/base64" + "encoding/json" + "fmt" + "io" + "kiro-go/config" + "kiro-go/logger" + "net" + "net/http" + "net/url" + "os" + "strings" + "sync" + "time" + + "github.com/google/uuid" +) + +const ( + // kiroSignInBaseURL is the Kiro hosted sign-in page opened in the browser. + kiroSignInBaseURL = "https://app.kiro.dev/signin" + // kiroRedirectURI is the fixed loopback redirect the portal validates and + // redirects back to once sign-in succeeds. The host is "localhost" (the value + // the portal expects) while the listener binds the 127.0.0.1 / [::1] literals; + // the browser resolving "localhost" to either loopback address is what bridges + // the two, so the operator's host must resolve localhost to loopback. + kiroRedirectURI = "http://localhost:3128" + // kiroRedirectPort is the loopback port embedded in kiroRedirectURI. + kiroRedirectPort = "3128" + // kiroRedirectFrom mirrors the Kiro IDE client tag the portal expects. + kiroRedirectFrom = "KiroIDE" + // kiroOAuthCallbackPath is the loopback path the enterprise (external IdP) + // leg redirects the authorization code back to. It is distinct from the + // portal's /signin/callback so the listener can tell the two legs apart. + kiroOAuthCallbackPath = "/oauth/callback" + // kiroSocialTokenURL is the Cognito-backed social code-exchange endpoint. Note + // this is deliberately a DIFFERENT path from the social refresh endpoint + // (socialTokenURL() in oidc.go -> /refreshToken): the Kiro IDE exchanges the + // login code at /oauth/token and refreshes at /refreshToken. Do not unify them. + kiroSocialTokenURL = "https://prod.us-east-1.auth.desktop.kiro.dev/oauth/token" + // kiroSsoLoginTimeout bounds how long the listener waits for the user. + kiroSsoLoginTimeout = 10 * time.Minute +) + +// allowedExternalIdpIssuerSuffixes restricts which IdP issuer/endpoint hosts the +// enterprise leg will discover and redirect to. The issuer arrives in an +// attacker-influenceable portal callback query, so it is constrained to known +// enterprise IdP hosts (Microsoft Entra / Azure AD — the supported provider). +// This is the primary control against SSRF, open-redirect, and forced-auth abuse +// via a forged /signin/callback. The leading dot anchors each suffix to a real +// subdomain boundary so "evil-microsoftonline.com" cannot match. Extend this +// list to onboard additional enterprise IdPs. +var allowedExternalIdpIssuerSuffixes = []string{ + ".microsoftonline.com", + ".microsoftonline.us", + ".microsoftonline.cn", +} + +// KiroSsoSession holds the transient state for one hosted-portal sign-in attempt. +type KiroSsoSession struct { + ID string + Verifier string // social-leg PKCE verifier (sent at social code exchange) + State string // portal anti-CSRF state echoed on the social redirect + Region string + ProxyURL string + ExpiresAt time.Time + + srv *http.Server + resultCh chan kiroSsoCapture + once sync.Once + closeOnce sync.Once + timer *time.Timer // deadline self-teardown; freed in close() + + mu sync.Mutex + leg2 *kiroLeg2 // set when the enterprise descriptor arrives +} + +// kiroLeg2 is the per-attempt state captured when the enterprise descriptor +// arrives at /signin/callback and consumed when the IdP redirects the code back. +type kiroLeg2 struct { + state string + verifier string + tokenEndpoint string + issuerURL string + clientID string + scopes string + redirectURI string +} + +// kiroSsoCapture is the raw outcome delivered by the loopback listener: either a +// social authorization code, an enterprise (external IdP) code plus its leg-2 +// context, or an error. +type kiroSsoCapture struct { + kind string // "social" | "external_idp" + code string + err error + + tokenEndpoint string + issuerURL string + clientID string + scopes string + redirectURI string + codeVerifier string +} + +// KiroSsoResult is the resolved credential returned to the admin handler once the +// captured code has been exchanged for tokens. +type KiroSsoResult struct { + AccessToken string + RefreshToken string + AuthMethod string // "external_idp" | "social" + Provider string // "AzureAD" | "Kiro SSO" + ClientID string // external IdP client id (refresh material) + TokenEndpoint string // external IdP token endpoint (refresh material) + IssuerURL string + Scopes string + ProfileArn string // social exchange may return it; external IdP resolves lazily + Region string + ExpiresIn int + Email string +} + +var ( + kiroSsoSessions = make(map[string]*KiroSsoSession) + kiroSsoSessionsMu sync.RWMutex +) + +// StartKiroSsoLogin generates PKCE codes, binds the loopback listener, and +// returns the session plus the hosted sign-in URL the operator must open. +func StartKiroSsoLogin(region string) (*KiroSsoSession, string, error) { + if region == "" { + region = "us-east-1" + } + + verifier := generateCodeVerifier() + challenge := generateCodeChallenge(verifier) + state := uuid.New().String() + + session := &KiroSsoSession{ + ID: uuid.New().String(), + Verifier: verifier, + State: state, + Region: region, + ProxyURL: config.GetProxyURL(), + ExpiresAt: time.Now().Add(kiroSsoLoginTimeout), + resultCh: make(chan kiroSsoCapture, 1), + } + + if err := session.startListener(); err != nil { + return nil, "", err + } + + params := url.Values{} + params.Set("state", state) + params.Set("code_challenge", challenge) + params.Set("code_challenge_method", "S256") + params.Set("redirect_uri", kiroRedirectURI) + params.Set("redirect_from", kiroRedirectFrom) + signInURL := kiroSignInBaseURL + "?" + params.Encode() + + kiroSsoSessionsMu.Lock() + kiroSsoSessions[session.ID] = session + kiroSsoSessionsMu.Unlock() + + // Self-teardown at the deadline: free the loopback listener and drop the + // session even if the operator abandons the sign-in and the front end stops + // polling. Without this an abandoned login would hold 127.0.0.1:3128 until the + // process restarts and block every subsequent SSO login (the redirect port is + // fixed, so only one sign-in can use it at a time). + session.timer = time.AfterFunc(kiroSsoLoginTimeout, func() { + session.close() + removeKiroSsoSession(session.ID) + }) + + return session, signInURL, nil +} + +// PollKiroSsoAuth reports the login status. It returns ("pending", nil) until the +// listener captures a code, then exchanges it and returns the resolved +// credential with status "completed". A terminal error (timeout, exchange +// failure) is returned as a non-nil error. +func PollKiroSsoAuth(sessionID string) (*KiroSsoResult, string, error) { + kiroSsoSessionsMu.RLock() + session, ok := kiroSsoSessions[sessionID] + kiroSsoSessionsMu.RUnlock() + if !ok { + return nil, "", fmt.Errorf("session not found or expired") + } + + select { + case capture := <-session.resultCh: + // Terminal: a code (or error) was captured. Tear the listener down and + // drop the session regardless of exchange outcome. + session.close() + removeKiroSsoSession(sessionID) + if capture.err != nil { + return nil, "", capture.err + } + return session.exchange(capture) + default: + if time.Now().After(session.ExpiresAt) { + session.close() + removeKiroSsoSession(sessionID) + return nil, "", fmt.Errorf("SSO login timed out after %s", kiroSsoLoginTimeout) + } + return nil, "pending", nil + } +} + +// exchange swaps a captured authorization code for tokens and assembles the +// resolved credential. +func (s *KiroSsoSession) exchange(capture kiroSsoCapture) (*KiroSsoResult, string, error) { + client := GetAuthClientForProxy(s.ProxyURL) + + if capture.kind == "external_idp" { + access, refresh, expiresIn, err := exchangeExternalIdpCode( + client, capture.tokenEndpoint, capture.clientID, capture.code, + capture.codeVerifier, capture.redirectURI, capture.scopes, + ) + if err != nil { + return nil, "", fmt.Errorf("enterprise SSO token exchange failed: %w", err) + } + return &KiroSsoResult{ + AccessToken: access, + RefreshToken: refresh, + AuthMethod: "external_idp", + Provider: "AzureAD", + ClientID: capture.clientID, + TokenEndpoint: capture.tokenEndpoint, + IssuerURL: capture.issuerURL, + Scopes: capture.scopes, + Region: s.Region, + ExpiresIn: expiresIn, + Email: ExtractEmailFromJWT(access), + }, "completed", nil + } + + access, refresh, expiresIn, profileArn, err := exchangeSocialCode(client, capture.code, s.Verifier) + if err != nil { + return nil, "", fmt.Errorf("SSO token exchange failed: %w", err) + } + return &KiroSsoResult{ + AccessToken: access, + RefreshToken: refresh, + AuthMethod: "social", + Provider: "Kiro SSO", + ProfileArn: profileArn, + Region: s.Region, + ExpiresIn: expiresIn, + Email: ExtractEmailFromJWT(access), + }, "completed", nil +} + +// --- Loopback callback listener (state machine across the legs) ------------- + +// kiroCallbackBindAddrs returns the address(es) the SSO callback listener binds. +// +// By default it binds loopback only — IPv4 127.0.0.1 plus, best-effort, IPv6 ::1 +// (a browser resolving "localhost" may use either) — which is the secure default: +// only the same host can reach the transient callback. Set KIRO_SSO_CALLBACK_BIND +// to override the bind host; this is needed when the proxy runs in a container and +// the operator's browser reaches the published port on a non-loopback interface +// (e.g. KIRO_SSO_CALLBACK_BIND=0.0.0.0 with `3128:3128` published in compose). The +// callback is transient (closed at the deadline) and every leg is anti-CSRF +// state-matched, but a non-loopback bind does expose it on the network for the +// login window, so only set it in a trusted/containerized network. +func kiroCallbackBindAddrs() []string { + if bind := strings.TrimSpace(os.Getenv("KIRO_SSO_CALLBACK_BIND")); bind != "" { + return []string{net.JoinHostPort(bind, kiroRedirectPort)} + } + return []string{"127.0.0.1:" + kiroRedirectPort, "[::1]:" + kiroRedirectPort} +} + +// startListener binds the SSO callback listener(s) on the fixed redirect port and +// serves the redirect state machine. The first address is mandatory (its bind +// failure aborts the login); any remaining addresses are best-effort (e.g. the IPv6 +// loopback when IPv6 is unavailable). +func (s *KiroSsoSession) startListener() error { + addrs := kiroCallbackBindAddrs() + + ln, err := net.Listen("tcp", addrs[0]) + if err != nil { + return fmt.Errorf("cannot bind %s for the SSO callback (is the port already in use?): %w", addrs[0], err) + } + + mux := http.NewServeMux() + mux.HandleFunc("/", s.handleCallback) + // ReadHeaderTimeout bounds a stalled local client (slowloris-style). + s.srv = &http.Server{Handler: mux, ReadHeaderTimeout: 5 * time.Second} + + serve := func(l net.Listener) { + go func() { + if errServe := s.srv.Serve(l); errServe != nil && errServe != http.ErrServerClosed { + logger.Debugf("[KiroSSO] callback listener (%s) stopped: %v", l.Addr(), errServe) + } + }() + } + serve(ln) + for _, addr := range addrs[1:] { + if extra, errExtra := net.Listen("tcp", addr); errExtra == nil { + serve(extra) + } else { + logger.Debugf("[KiroSSO] secondary callback bind %s skipped: %v", addr, errExtra) + } + } + return nil +} + +// close shuts the loopback listener(s) down and stops the deadline timer. Safe to +// call more than once and from multiple goroutines (Poll, the cancel endpoint, and +// the deadline AfterFunc may all race to tear the session down). +func (s *KiroSsoSession) close() { + s.closeOnce.Do(func() { + if s.timer != nil { + s.timer.Stop() + } + if s.srv != nil { + _ = s.srv.Close() + } + }) +} + +// CancelKiroSsoLogin tears an in-flight session down immediately (operator +// cancelled in the admin panel), freeing the loopback port without waiting for the +// deadline. A no-op for an unknown or already-finished session. +func CancelKiroSsoLogin(sessionID string) { + kiroSsoSessionsMu.RLock() + session, ok := kiroSsoSessions[sessionID] + kiroSsoSessionsMu.RUnlock() + if !ok { + return + } + session.close() + removeKiroSsoSession(sessionID) +} + +// deliver pushes the first (and only) capture onto the result channel. +func (s *KiroSsoSession) deliver(capture kiroSsoCapture) { + s.once.Do(func() { s.resultCh <- capture }) +} + +// handleCallback implements the redirect state machine: enterprise leg-1 +// descriptor -> 302 to the IdP; enterprise leg-2 code at /oauth/callback; +// otherwise the social code. +func (s *KiroSsoSession) handleCallback(w http.ResponseWriter, req *http.Request) { + // Only browser GET redirects are expected; reject other methods to shrink + // the local attack surface. + if req.Method != http.MethodGet { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + q := req.URL.Query() + + // --- Enterprise leg-1: external IdP descriptor (no code) --- + // Gate on path != /oauth/callback so a forged /oauth/callback?issuer_url=... + // cannot be routed here and reset an in-flight leg-2. + if req.URL.Path != kiroOAuthCallbackPath && + (strings.EqualFold(strings.TrimSpace(q.Get("login_option")), "external_idp") || strings.TrimSpace(q.Get("issuer_url")) != "") { + // Single-shot: once a leg-2 is in flight, ignore further descriptors so a + // stray or forged local request cannot reset/hijack the active login. + s.mu.Lock() + alreadyStarted := s.leg2 != nil + s.mu.Unlock() + if alreadyStarted { + w.WriteHeader(http.StatusNoContent) + return + } + issuerURL := strings.TrimSpace(q.Get("issuer_url")) + clientID := strings.TrimSpace(q.Get("client_id")) + scopes := strings.TrimSpace(q.Get("scopes")) + loginHint := strings.TrimSpace(q.Get("login_hint")) + if clientID == "" { + writeKiroCallbackPage(w, false) + s.deliver(kiroSsoCapture{err: fmt.Errorf("invalid external IdP descriptor (missing client_id)")}) + return + } + // oidcDiscover validates the issuer + both discovered endpoints against + // the IdP host allow-list, so the issuer here is not trusted blindly. + authEndpoint, tokenEndpoint, errDisc := oidcDiscover(GetAuthClientForProxy(s.ProxyURL), issuerURL, s.ProxyURL) + if errDisc != nil { + writeKiroCallbackPage(w, false) + s.deliver(kiroSsoCapture{err: errDisc}) + return + } + verifier := generateCodeVerifier() + state2 := uuid.New().String() + redirectURI := kiroRedirectURI + kiroOAuthCallbackPath + s.mu.Lock() + // Re-check under the lock to resolve a race between concurrent + // descriptors: only the first sets leg2 and is redirected. + if s.leg2 != nil { + s.mu.Unlock() + w.WriteHeader(http.StatusNoContent) + return + } + s.leg2 = &kiroLeg2{ + state: state2, + verifier: verifier, + tokenEndpoint: tokenEndpoint, + issuerURL: issuerURL, + clientID: clientID, + scopes: scopes, + redirectURI: redirectURI, + } + s.mu.Unlock() + authURL := externalIdpAuthorizeURL(authEndpoint, clientID, redirectURI, scopes, generateCodeChallenge(verifier), state2, loginHint) + // Redirect the SAME browser tab on to the IdP login page. + http.Redirect(w, req, authURL, http.StatusFound) + return + } + + // --- Enterprise leg-2: IdP authorization code at /oauth/callback --- + if req.URL.Path == kiroOAuthCallbackPath { + s.mu.Lock() + ctx2 := s.leg2 + s.mu.Unlock() + code := strings.TrimSpace(q.Get("code")) + state := strings.TrimSpace(q.Get("state")) + errParam := strings.TrimSpace(q.Get("error")) + // Ignore callbacks that don't match the in-flight leg-2 state. + if ctx2 == nil || state == "" || state != ctx2.state { + w.WriteHeader(http.StatusNoContent) + return + } + if errParam != "" { + desc := strings.TrimSpace(q.Get("error_description")) + writeKiroCallbackPage(w, false) + s.deliver(kiroSsoCapture{err: fmt.Errorf("external IdP authorization error: %s %s", errParam, desc)}) + return + } + if code == "" { + w.WriteHeader(http.StatusNoContent) + return + } + writeKiroCallbackPage(w, true) + s.deliver(kiroSsoCapture{ + kind: "external_idp", + code: code, + tokenEndpoint: ctx2.tokenEndpoint, + issuerURL: ctx2.issuerURL, + clientID: ctx2.clientID, + scopes: ctx2.scopes, + redirectURI: ctx2.redirectURI, + codeVerifier: ctx2.verifier, + }) + return + } + + // --- Social leg-1: Cognito authorization code --- + code := strings.TrimSpace(q.Get("code")) + errParam := strings.TrimSpace(q.Get("error")) + state := strings.TrimSpace(q.Get("state")) + // Ignore stray hits with neither a code nor an error, and any callback whose + // state does not match — without consuming the one-shot. + if code == "" && errParam == "" { + w.WriteHeader(http.StatusNoContent) + return + } + if s.State == "" || state != s.State { + w.WriteHeader(http.StatusNoContent) + return + } + if errParam != "" { + desc := strings.TrimSpace(q.Get("error_description")) + writeKiroCallbackPage(w, false) + s.deliver(kiroSsoCapture{err: fmt.Errorf("SSO authorization error: %s %s", errParam, desc)}) + return + } + writeKiroCallbackPage(w, true) + s.deliver(kiroSsoCapture{kind: "social", code: code}) +} + +// --- OIDC discovery + token exchange (enterprise / external IdP leg) --------- + +// validateExternalIdpEndpoint verifies rawURL is an https URL whose host is a +// non-IP, allow-listed enterprise IdP host. It gates the issuer (before +// discovery) and BOTH discovered endpoints (the authorize URL the browser is +// 302'd to, and the token endpoint the code is exchanged at). +func validateExternalIdpEndpoint(rawURL string) error { + u, err := url.Parse(strings.TrimSpace(rawURL)) + if err != nil { + return fmt.Errorf("invalid external IdP URL: %w", err) + } + if !strings.EqualFold(u.Scheme, "https") { + return fmt.Errorf("external IdP URL must be https") + } + host := strings.ToLower(u.Hostname()) + if host == "" { + return fmt.Errorf("external IdP URL has no host") + } + // Reject IP-literal hosts outright; only named, allow-listed hosts pass. + if net.ParseIP(host) != nil { + return fmt.Errorf("external IdP host must not be an IP literal") + } + for _, suffix := range allowedExternalIdpIssuerSuffixes { + if strings.HasSuffix(host, suffix) { + return nil + } + } + return fmt.Errorf("external IdP host %q is not allow-listed", host) +} + +// oidcDiscover fetches the OpenID Connect discovery document for issuerURL and +// returns its authorization and token endpoints. The issuer and BOTH discovered +// endpoints are validated against the IdP host allow-list; redirects are NOT +// followed (so a discovery host cannot bounce the fetch to an internal target); +// and no response body is echoed into errors. +func oidcDiscover(client *http.Client, issuerURL, proxyURL string) (authEndpoint, tokenEndpoint string, err error) { + if err = validateExternalIdpEndpoint(issuerURL); err != nil { + return "", "", err + } + docURL := strings.TrimRight(strings.TrimSpace(issuerURL), "/") + "/.well-known/openid-configuration" + req, err := http.NewRequest(http.MethodGet, docURL, nil) + if err != nil { + return "", "", fmt.Errorf("failed to build OIDC discovery request: %w", err) + } + req.Header.Set("Accept", "application/json") + // Do not follow redirects: the allow-listed issuer host must answer directly, + // so a 3xx (which could point at an internal/link-local target) is a failure. + noRedirect := &http.Client{ + Timeout: 30 * time.Second, + Transport: buildAuthTransport(proxyURL), + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + } + resp, err := noRedirect.Do(req) + if err != nil { + return "", "", fmt.Errorf("OIDC discovery request failed: %w", err) + } + defer resp.Body.Close() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", "", fmt.Errorf("failed to read OIDC discovery response: %w", err) + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + // Deliberately omit the body to avoid exfiltrating an internal response. + return "", "", fmt.Errorf("OIDC discovery failed (status %d)", resp.StatusCode) + } + var doc struct { + AuthorizationEndpoint string `json:"authorization_endpoint"` + TokenEndpoint string `json:"token_endpoint"` + } + if err = json.Unmarshal(body, &doc); err != nil { + return "", "", fmt.Errorf("failed to parse OIDC discovery document: %w", err) + } + if doc.AuthorizationEndpoint == "" || doc.TokenEndpoint == "" { + return "", "", fmt.Errorf("OIDC discovery document missing authorization_endpoint or token_endpoint") + } + // Both endpoints must themselves be https + allow-listed. The allow-list (the + // trust boundary) is the only check applied here, not equality with the issuer + // host: within Microsoft's own *.microsoftonline.com infrastructure the + // discovery doc legitimately points authorize/token at sibling hosts. If the + // allow-list is ever broadened to multiple distinct IdPs, tighten this to also + // pin the endpoints to the issuer's registrable host. + if err = validateExternalIdpEndpoint(doc.AuthorizationEndpoint); err != nil { + return "", "", fmt.Errorf("discovered authorization_endpoint rejected: %w", err) + } + if err = validateExternalIdpEndpoint(doc.TokenEndpoint); err != nil { + return "", "", fmt.Errorf("discovered token_endpoint rejected: %w", err) + } + return doc.AuthorizationEndpoint, doc.TokenEndpoint, nil +} + +// externalIdpAuthorizeURL builds the IdP authorization-code+PKCE URL the browser +// is redirected to for the enterprise leg. scopes is passed through verbatim from +// the portal (already a space-separated list). +func externalIdpAuthorizeURL(authEndpoint, clientID, redirectURI, scopes, challenge, state, loginHint string) string { + q := url.Values{} + q.Set("client_id", clientID) + q.Set("response_type", "code") + q.Set("redirect_uri", redirectURI) + q.Set("scope", scopes) + q.Set("code_challenge", challenge) + q.Set("code_challenge_method", "S256") + q.Set("response_mode", "query") + q.Set("state", state) + if strings.TrimSpace(loginHint) != "" { + q.Set("login_hint", loginHint) + } + return authEndpoint + "?" + q.Encode() +} + +// exchangeExternalIdpCode exchanges an IdP authorization code (with its PKCE +// verifier) for IdP tokens at the discovered token endpoint. Standard OAuth2 +// authorization_code grant for a public client (PKCE, no client secret); +// request is form-encoded and the response is snake_case. +func exchangeExternalIdpCode(client *http.Client, tokenEndpoint, clientID, code, codeVerifier, redirectURI, scopes string) (accessToken, refreshToken string, expiresIn int, err error) { + form := url.Values{} + form.Set("client_id", clientID) + form.Set("grant_type", "authorization_code") + form.Set("code", strings.TrimSpace(code)) + form.Set("redirect_uri", redirectURI) + form.Set("code_verifier", codeVerifier) + if strings.TrimSpace(scopes) != "" { + form.Set("scope", scopes) + } + return postExternalIdpToken(client, tokenEndpoint, form) +} + +// exchangeSocialCode exchanges a Cognito authorization code (with its PKCE +// verifier) for Kiro tokens at the social token endpoint. Request body matches +// the Kiro IDE client — {code, code_verifier, redirect_uri} — and the response +// is camelCase (accessToken/refreshToken/profileArn/expiresIn). +func exchangeSocialCode(client *http.Client, code, codeVerifier string) (accessToken, refreshToken string, expiresIn int, profileArn string, err error) { + payload := map[string]string{ + "code": strings.TrimSpace(code), + "code_verifier": codeVerifier, + "redirect_uri": kiroRedirectURI, + } + body, _ := json.Marshal(payload) + req, err := http.NewRequest(http.MethodPost, kiroSocialTokenURL, bytes.NewReader(body)) + if err != nil { + return "", "", 0, "", fmt.Errorf("failed to build social token request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("Accept", "application/json") + resp, err := client.Do(req) + if err != nil { + return "", "", 0, "", err + } + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + var out struct { + AccessToken string `json:"accessToken"` + RefreshToken string `json:"refreshToken"` + ProfileArn string `json:"profileArn"` + ExpiresIn int `json:"expiresIn"` + } + _ = json.Unmarshal(respBody, &out) + if resp.StatusCode < 200 || resp.StatusCode >= 300 || out.AccessToken == "" { + return "", "", 0, "", fmt.Errorf("social token exchange failed (status %d): %s", resp.StatusCode, string(respBody)) + } + return out.AccessToken, out.RefreshToken, out.ExpiresIn, out.ProfileArn, nil +} + +// writeKiroCallbackPage renders a minimal HTML page shown in the browser after +// the final redirect. +func writeKiroCallbackPage(w http.ResponseWriter, ok bool) { + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.WriteHeader(http.StatusOK) + msg := "Kiro sign-in complete. You can close this tab and return to the admin panel." + if !ok { + msg = "Kiro sign-in failed. Return to the admin panel and try again." + } + _, _ = fmt.Fprintf(w, "Kiro Sign-In

%s

", msg) +} + +// ExtractEmailFromJWT decodes the JWT payload of an access token (best-effort) +// and returns the account email. Azure AD v2.0 tokens frequently omit the +// "email" claim and carry the sign-in name in "preferred_username"/"upn", so +// those are used as fallbacks. +func ExtractEmailFromJWT(accessToken string) string { + parts := strings.Split(strings.TrimSpace(accessToken), ".") + if len(parts) < 2 { + return "" + } + payload, err := base64.RawURLEncoding.DecodeString(parts[1]) + if err != nil { + // Some tokens use standard (padded) base64; retry leniently. + if payload, err = base64.StdEncoding.DecodeString(parts[1]); err != nil { + return "" + } + } + var claims struct { + Email string `json:"email"` + PreferredUsername string `json:"preferred_username"` + Upn string `json:"upn"` + UniqueName string `json:"unique_name"` + } + if err = json.Unmarshal(payload, &claims); err != nil { + return "" + } + for _, candidate := range []string{claims.Email, claims.PreferredUsername, claims.Upn, claims.UniqueName} { + if v := strings.TrimSpace(candidate); v != "" { + return v + } + } + return "" +} + +// removeKiroSsoSession deletes a session from the registry. +func removeKiroSsoSession(sessionID string) { + kiroSsoSessionsMu.Lock() + delete(kiroSsoSessions, sessionID) + kiroSsoSessionsMu.Unlock() +} + diff --git a/auth/kiro_sso_test.go b/auth/kiro_sso_test.go new file mode 100644 index 00000000..090d413b --- /dev/null +++ b/auth/kiro_sso_test.go @@ -0,0 +1,211 @@ +package auth + +import ( + "encoding/base64" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" +) + +// TestKiroCallbackBindAddrs locks in the secure default (loopback-only) and the +// KIRO_SSO_CALLBACK_BIND override used for containerized deployments. +func TestKiroCallbackBindAddrs(t *testing.T) { + // Unset/blank -> loopback v4 (mandatory) + v6 (best-effort). + t.Setenv("KIRO_SSO_CALLBACK_BIND", "") + if got, want := kiroCallbackBindAddrs(), []string{"127.0.0.1:3128", "[::1]:3128"}; !reflect.DeepEqual(got, want) { + t.Fatalf("default bind addrs = %v, want %v", got, want) + } + // Whitespace is treated as unset (still the secure default). + t.Setenv("KIRO_SSO_CALLBACK_BIND", " ") + if got := kiroCallbackBindAddrs(); len(got) != 2 { + t.Fatalf("whitespace should fall back to loopback default, got %v", got) + } + // IPv4 wildcard override -> single mandatory bind. + t.Setenv("KIRO_SSO_CALLBACK_BIND", "0.0.0.0") + if got, want := kiroCallbackBindAddrs(), []string{"0.0.0.0:3128"}; !reflect.DeepEqual(got, want) { + t.Fatalf("0.0.0.0 bind addrs = %v, want %v", got, want) + } + // IPv6 wildcard override -> bracketed host:port. + t.Setenv("KIRO_SSO_CALLBACK_BIND", "::") + if got, want := kiroCallbackBindAddrs(), []string{"[::]:3128"}; !reflect.DeepEqual(got, want) { + t.Fatalf(":: bind addrs = %v, want %v", got, want) + } +} + +// makeJWT builds an unsigned JWT-shaped string whose payload encodes claims, for +// testing the best-effort claim extraction (the signature is never verified). +func makeJWT(claims map[string]string) string { + header := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"none"}`)) + payloadBytes, _ := json.Marshal(claims) + payload := base64.RawURLEncoding.EncodeToString(payloadBytes) + return header + "." + payload + ".sig" +} + +func TestExtractEmailFromJWT(t *testing.T) { + cases := []struct { + name string + claims map[string]string + want string + }{ + {"email claim", map[string]string{"email": "a@b.com"}, "a@b.com"}, + // Azure AD v2.0 tokens usually omit "email" and carry preferred_username. + {"preferred_username fallback", map[string]string{"preferred_username": "user@tenant.onmicrosoft.com"}, "user@tenant.onmicrosoft.com"}, + {"upn fallback", map[string]string{"upn": "u@corp.com"}, "u@corp.com"}, + {"none", map[string]string{"sub": "xyz"}, ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := ExtractEmailFromJWT(makeJWT(tc.claims)); got != tc.want { + t.Fatalf("ExtractEmailFromJWT = %q, want %q", got, tc.want) + } + }) + } + if got := ExtractEmailFromJWT("not-a-jwt"); got != "" { + t.Fatalf("malformed token should yield empty email, got %q", got) + } +} + +func TestValidateExternalIdpEndpoint(t *testing.T) { + valid := []string{ + "https://login.microsoftonline.com/5fbc183e/v2.0", + "https://login.microsoftonline.us/tenant/v2.0", + "https://login.microsoftonline.cn/tenant/oauth2/v2.0/token", + } + for _, u := range valid { + if err := validateExternalIdpEndpoint(u); err != nil { + t.Fatalf("expected %q to be allowed, got %v", u, err) + } + } + invalid := []string{ + "http://login.microsoftonline.com/x", // not https + "https://evil-microsoftonline.com/x", // suffix not anchored to a subdomain boundary + "https://login.microsoftonline.com.evil.co", // not an allowed suffix + "https://10.0.0.5/x", // IP literal + "https://accounts.google.com/x", // not allow-listed + "https:///x", // no host + } + for _, u := range invalid { + if err := validateExternalIdpEndpoint(u); err == nil { + t.Fatalf("expected %q to be rejected", u) + } + } +} + +func TestExternalIdpAuthorizeURL(t *testing.T) { + raw := externalIdpAuthorizeURL( + "https://login.microsoftonline.com/t/oauth2/v2.0/authorize", + "client-123", + "http://localhost:3128/oauth/callback", + "api://client-123/codewhisperer:conversations offline_access", + "challenge-abc", + "state-xyz", + "user@corp.com", + ) + u, err := url.Parse(raw) + if err != nil { + t.Fatalf("parse: %v", err) + } + q := u.Query() + checks := map[string]string{ + "client_id": "client-123", + "response_type": "code", + "redirect_uri": "http://localhost:3128/oauth/callback", + "code_challenge": "challenge-abc", + "code_challenge_method": "S256", + "response_mode": "query", + "state": "state-xyz", + "login_hint": "user@corp.com", + } + for k, want := range checks { + if got := q.Get(k); got != want { + t.Fatalf("authorize url param %q = %q, want %q", k, got, want) + } + } + if !strings.Contains(q.Get("scope"), "offline_access") { + t.Fatalf("expected offline_access in scope, got %q", q.Get("scope")) + } +} + +// TestExternalIdpAuthorizeURLOmitsEmptyLoginHint ensures we don't emit an empty +// login_hint parameter when the portal supplied none. +func TestExternalIdpAuthorizeURLOmitsEmptyLoginHint(t *testing.T) { + raw := externalIdpAuthorizeURL("https://login.microsoftonline.com/t/authorize", "c", "http://localhost:3128/oauth/callback", "s", "ch", "st", "") + u, _ := url.Parse(raw) + if _, ok := u.Query()["login_hint"]; ok { + t.Fatalf("login_hint should be omitted when empty") + } +} + +// TestRefreshExternalIdpToken drives the refresh_token grant against a stub IdP +// token endpoint and asserts the form encoding and response mapping. +func TestRefreshExternalIdpToken(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + t.Fatalf("parse form: %v", err) + } + if r.Form.Get("grant_type") != "refresh_token" { + t.Fatalf("grant_type = %q", r.Form.Get("grant_type")) + } + if r.Form.Get("client_id") != "azure-client" { + t.Fatalf("client_id = %q", r.Form.Get("client_id")) + } + if r.Form.Get("refresh_token") != "old-refresh" { + t.Fatalf("refresh_token = %q", r.Form.Get("refresh_token")) + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"new-access","refresh_token":"new-refresh","expires_in":3600}`)) + })) + defer srv.Close() + + access, refresh, expiresAt, profileArn, err := refreshExternalIdpToken( + "old-refresh", "azure-client", srv.URL, "api://x/codewhisperer:conversations offline_access", srv.Client(), + ) + if err != nil { + t.Fatalf("refreshExternalIdpToken: %v", err) + } + if access != "new-access" { + t.Fatalf("access = %q", access) + } + if refresh != "new-refresh" { + t.Fatalf("refresh = %q", refresh) + } + if profileArn != "" { + t.Fatalf("external IdP refresh should not return a profileArn, got %q", profileArn) + } + if expiresAt == 0 { + t.Fatalf("expected a non-zero absolute expiry") + } +} + +// TestRefreshExternalIdpTokenKeepsRefreshTokenWhenOmitted verifies the existing +// refresh token is retained when the IdP response omits a rotated one. +func TestRefreshExternalIdpTokenKeepsRefreshTokenWhenOmitted(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"access_token":"a2","expires_in":1200}`)) + })) + defer srv.Close() + + _, refresh, _, _, err := refreshExternalIdpToken("keep-me", "c", srv.URL, "", srv.Client()) + if err != nil { + t.Fatalf("refreshExternalIdpToken: %v", err) + } + if refresh != "keep-me" { + t.Fatalf("expected refresh token to be retained, got %q", refresh) + } +} + +// TestRefreshExternalIdpTokenRequiresClientAndEndpoint guards the precondition +// that distinguishes the external-IdP branch from the AWS OIDC branch. +func TestRefreshExternalIdpTokenRequiresClientAndEndpoint(t *testing.T) { + if _, _, _, _, err := refreshExternalIdpToken("r", "", "https://login.microsoftonline.com/t/token", "", http.DefaultClient); err == nil { + t.Fatalf("expected error when clientID is empty") + } + if _, _, _, _, err := refreshExternalIdpToken("r", "c", "", "", http.DefaultClient); err == nil { + t.Fatalf("expected error when tokenEndpoint is empty") + } +} diff --git a/auth/oidc.go b/auth/oidc.go index 4e12d171..a40f75bd 100644 --- a/auth/oidc.go +++ b/auth/oidc.go @@ -7,6 +7,8 @@ import ( "io" "kiro-go/config" "net/http" + "net/url" + "strings" "time" ) @@ -30,12 +32,91 @@ func RefreshToken(account *config.Account) (string, string, int64, string, error } client := GetAuthClientForProxy(proxyURL) + // External IdP (enterprise SSO, e.g. Azure AD) tokens are refreshed against the + // IdP token endpoint (refresh_token grant, public client), NOT the AWS SSO OIDC + // endpoint. Selecting it on AuthMethod (rather than letting it fall through to the + // OIDC branch, which requires clientSecret) is what makes these accounts refresh. + if account.AuthMethod == "external_idp" { + return refreshExternalIdpToken(account.RefreshToken, account.ClientID, account.TokenEndpoint, account.Scopes, client) + } if account.AuthMethod == "social" { return refreshSocialToken(account.RefreshToken, client) } return refreshOIDCToken(account.RefreshToken, account.ClientID, account.ClientSecret, account.Region, client) } +// refreshExternalIdpToken refreshes an external-IdP (enterprise SSO) access token +// through the IdP token endpoint using the OAuth2 refresh_token grant for a public +// client (no client secret). offline_access in the original scopes is what makes a +// refresh token available. The IdP issues no profileArn (it is resolved separately +// via ListAvailableProfiles using the EXTERNAL_IDP token type), so "" is returned +// for the profileArn. +func refreshExternalIdpToken(refreshToken, clientID, tokenEndpoint, scopes string, client *http.Client) (string, string, int64, string, error) { + if clientID == "" || tokenEndpoint == "" { + return "", "", 0, "", fmt.Errorf("external IdP refresh requires clientId and tokenEndpoint") + } + form := url.Values{} + form.Set("client_id", clientID) + form.Set("grant_type", "refresh_token") + form.Set("refresh_token", refreshToken) + if scopes != "" { + form.Set("scope", scopes) + } + accessToken, newRefreshToken, expiresIn, err := postExternalIdpToken(client, tokenEndpoint, form) + if err != nil { + return "", "", 0, "", err + } + // Some IdPs (Azure AD) rotate refresh tokens; others omit it on refresh. Keep the + // existing refresh token when the response does not carry a new one. + if newRefreshToken == "" { + newRefreshToken = refreshToken + } + expiresAt := time.Now().Unix() + int64(expiresIn) + return accessToken, newRefreshToken, expiresAt, "", nil +} + +// postExternalIdpToken performs a form-encoded POST to an external-IdP token +// endpoint and maps the snake_case OAuth2 token response onto the standard return +// shape. Shared by the authorization-code exchange (login) and the refresh_token +// grant (renewal). +func postExternalIdpToken(client *http.Client, tokenEndpoint string, form url.Values) (accessToken, refreshToken string, expiresIn int, err error) { + if strings.TrimSpace(tokenEndpoint) == "" { + return "", "", 0, fmt.Errorf("external IdP token endpoint is empty") + } + req, err := http.NewRequest("POST", tokenEndpoint, strings.NewReader(form.Encode())) + if err != nil { + return "", "", 0, fmt.Errorf("failed to build external IdP token request: %w", err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.Header.Set("Accept", "application/json") + + resp, err := client.Do(req) + if err != nil { + return "", "", 0, fmt.Errorf("external IdP token request failed: %w", err) + } + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + if err != nil { + return "", "", 0, fmt.Errorf("failed to read external IdP token response: %w", err) + } + var out struct { + AccessToken string `json:"access_token"` + RefreshToken string `json:"refresh_token"` + ExpiresIn int `json:"expires_in"` + Error string `json:"error"` + ErrorDesc string `json:"error_description"` + } + _ = json.Unmarshal(body, &out) + if resp.StatusCode < 200 || resp.StatusCode >= 300 || out.AccessToken == "" { + if out.Error != "" { + return "", "", 0, fmt.Errorf("external IdP token exchange failed (status %d): %s: %s", resp.StatusCode, out.Error, out.ErrorDesc) + } + return "", "", 0, fmt.Errorf("external IdP token exchange failed (status %d): %s", resp.StatusCode, string(body)) + } + return out.AccessToken, out.RefreshToken, out.ExpiresIn, nil +} + // refreshOIDCToken IdC/Builder ID token 刷新 func refreshOIDCToken(refreshToken, clientID, clientSecret, region string, client *http.Client) (string, string, int64, string, error) { if clientID == "" || clientSecret == "" { diff --git a/config/config.go b/config/config.go index cc90aae5..740147b7 100644 --- a/config/config.go +++ b/config/config.go @@ -45,14 +45,22 @@ type Account struct { RefreshToken string `json:"refreshToken"` // OAuth refresh token for token renewal ClientID string `json:"clientId,omitempty"` // OIDC client ID (for IdC auth) ClientSecret string `json:"clientSecret,omitempty"` // OIDC client secret (for IdC auth) - AuthMethod string `json:"authMethod"` // Authentication method: "idc" (AWS IdC) or "social" (GitHub/Google) - Provider string `json:"provider,omitempty"` // Identity provider name (e.g., "BuilderId", "GitHub") + AuthMethod string `json:"authMethod"` // Authentication method: "idc" (AWS IdC), "social" (GitHub/Google), or "external_idp" (enterprise SSO, e.g. Azure AD) + Provider string `json:"provider,omitempty"` // Identity provider name (e.g., "BuilderId", "GitHub", "AzureAD") Region string `json:"region"` // AWS region for OIDC endpoints StartUrl string `json:"startUrl,omitempty"` // AWS SSO start URL ExpiresAt int64 `json:"expiresAt,omitempty"` // Token expiration timestamp (Unix seconds) MachineId string `json:"machineId,omitempty"` // UUID machine identifier for request tracking ProfileArn string `json:"profileArn,omitempty"` // CodeWhisperer/Kiro profile ARN for generation requests + // External IdP (enterprise SSO, e.g. Microsoft 365 / Entra ID / Azure AD) refresh material. + // When AuthMethod == "external_idp" the credential is an IdP-issued OAuth token refreshed + // against TokenEndpoint using ClientID and Scopes (refresh_token grant), NOT the AWS SSO + // OIDC endpoint. IssuerURL is the OIDC issuer the endpoints were discovered from. + TokenEndpoint string `json:"tokenEndpoint,omitempty"` // External IdP OAuth2 token endpoint (refresh) + IssuerURL string `json:"issuerUrl,omitempty"` // External IdP OIDC issuer URL + Scopes string `json:"scopes,omitempty"` // Space-separated scopes granted by the external IdP + // Per-account outbound proxy (falls back to global ProxyURL if empty) ProxyURL string `json:"proxyURL,omitempty"` diff --git a/docker-compose.yml b/docker-compose.yml index 78ebab89..ba1b7811 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -5,8 +5,20 @@ services: build: . ports: - "8080:8080" + # Enterprise SSO (Microsoft 365) callback. The hosted-portal sign-in + # redirects the browser to localhost:3128. Uncomment this AND set + # KIRO_SSO_CALLBACK_BIND=0.0.0.0 below to complete that login from a + # browser on the host while the proxy runs in this container. + # - "3128:3128" volumes: - ./data:/app/data environment: - CONFIG_PATH=/app/data/config.json + # Bind host for the Enterprise SSO callback listener. Default (unset) is + # loopback-only (127.0.0.1/::1), reachable only from inside the container. + # Set to 0.0.0.0 so the published 3128 port above can reach it. The callback + # is transient and anti-CSRF state-matched, but this exposes it on the + # container network during the ~10-minute login window — only enable on a + # trusted network. + # - KIRO_SSO_CALLBACK_BIND=0.0.0.0 restart: unless-stopped diff --git a/proxy/handler.go b/proxy/handler.go index a14f71fd..18763df7 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -2211,6 +2211,12 @@ func (h *Handler) handleAdminAPI(w http.ResponseWriter, r *http.Request) { h.apiStartBuilderIdLogin(w, r) case path == "/auth/builderid/poll" && r.Method == "POST": h.apiPollBuilderIdAuth(w, r) + case path == "/auth/kiro-sso/start" && r.Method == "POST": + h.apiStartKiroSso(w, r) + case path == "/auth/kiro-sso/poll" && r.Method == "POST": + h.apiPollKiroSso(w, r) + case path == "/auth/kiro-sso/cancel" && r.Method == "POST": + h.apiCancelKiroSso(w, r) case path == "/auth/sso-token" && r.Method == "POST": h.apiImportSsoToken(w, r) case path == "/auth/credentials" && r.Method == "POST": @@ -2805,6 +2811,119 @@ func (h *Handler) apiPollBuilderIdAuth(w http.ResponseWriter, r *http.Request) { }) } +// apiStartKiroSso starts the Kiro hosted-portal sign-in (Enterprise SSO — Microsoft 365 / +// Entra ID, plus Google/GitHub). It binds the loopback callback listener and returns the +// sign-in URL the operator opens in a browser ON THE SAME HOST as the proxy (the OAuth +// redirect targets 127.0.0.1:3128). The browser is driven through the enterprise external-IdP +// leg automatically; the front end polls /auth/kiro-sso/poll until completion. +func (h *Handler) apiStartKiroSso(w http.ResponseWriter, r *http.Request) { + var req struct { + Region string `json:"region"` + } + // Region is optional (defaults to us-east-1 in StartKiroSsoLogin), so a decode + // error (including an empty body) is intentionally tolerated — mirrors + // apiStartBuilderIdLogin. + json.NewDecoder(r.Body).Decode(&req) + + session, signInURL, err := auth.StartKiroSsoLogin(req.Region) + if err != nil { + w.WriteHeader(500) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + json.NewEncoder(w).Encode(map[string]interface{}{ + "sessionId": session.ID, + "signInUrl": signInURL, + "interval": 2, + }) +} + +// apiCancelKiroSso tears down an in-flight hosted-portal sign-in (operator closed or +// cancelled the modal), freeing the loopback callback port immediately instead of +// waiting for the deadline. +func (h *Handler) apiCancelKiroSso(w http.ResponseWriter, r *http.Request) { + var req struct { + SessionID string `json:"sessionId"` + } + json.NewDecoder(r.Body).Decode(&req) + if req.SessionID != "" { + auth.CancelKiroSsoLogin(req.SessionID) + } + json.NewEncoder(w).Encode(map[string]interface{}{"success": true}) +} + +// apiPollKiroSso reports the hosted-portal sign-in status. While the user is signing in it +// returns completed=false; once the listener captures the authorization code it exchanges it, +// persists the account (AuthMethod "external_idp" for an Azure tenant, "social" otherwise), and +// returns completed=true. The profileArn is resolved lazily on first use (the EXTERNAL_IDP +// token type header is now sent on CodeWhisperer calls), so it is not required here. +func (h *Handler) apiPollKiroSso(w http.ResponseWriter, r *http.Request) { + var req struct { + SessionID string `json:"sessionId"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) + return + } + + result, status, err := auth.PollKiroSsoAuth(req.SessionID) + if err != nil { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": false, + "error": err.Error(), + }) + return + } + + if status == "pending" { + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "completed": false, + "status": "pending", + }) + return + } + + // 授权完成,创建账号 + account := config.Account{ + ID: auth.GenerateAccountID(), + Email: result.Email, + AccessToken: result.AccessToken, + RefreshToken: result.RefreshToken, + ClientID: result.ClientID, + AuthMethod: result.AuthMethod, + Provider: result.Provider, + Region: result.Region, + ProfileArn: result.ProfileArn, + TokenEndpoint: result.TokenEndpoint, + IssuerURL: result.IssuerURL, + Scopes: result.Scopes, + ExpiresAt: time.Now().Unix() + int64(result.ExpiresIn), + Enabled: true, + MachineId: config.GenerateMachineId(), + } + + if err := config.AddAccount(account); err != nil { + w.WriteHeader(500) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + + h.pool.Reload() + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "completed": true, + "account": map[string]interface{}{ + "id": account.ID, + "email": account.Email, + "authMethod": account.AuthMethod, + }, + }) +} + func (h *Handler) apiImportSsoToken(w http.ResponseWriter, r *http.Request) { var req struct { BearerToken string `json:"bearerToken"` diff --git a/proxy/kiro_external_idp_test.go b/proxy/kiro_external_idp_test.go new file mode 100644 index 00000000..8420ec44 --- /dev/null +++ b/proxy/kiro_external_idp_test.go @@ -0,0 +1,49 @@ +package proxy + +import ( + "kiro-go/config" + "net/http" + "testing" +) + +// TestSetKiroHeadersExternalIdpSendsTokenType verifies that external-IdP +// (enterprise SSO, e.g. Azure AD) accounts get the TokenType: EXTERNAL_IDP +// header. Without it, CodeWhisperer silently returns an empty profile list and +// rejects data-plane calls, which is exactly what broke Azure-tenant SSO. +func TestSetKiroHeadersExternalIdpSendsTokenType(t *testing.T) { + account := &config.Account{ + AccessToken: "azure-access-token", + AuthMethod: "external_idp", + MachineId: "machine-xyz", + } + req, err := http.NewRequest("POST", "https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + + setKiroHeaders(req, account) + + if got := req.Header.Get("TokenType"); got != "EXTERNAL_IDP" { + t.Fatalf("expected TokenType=EXTERNAL_IDP for external_idp account, got %q", got) + } + if got := req.Header.Get("Authorization"); got != "Bearer azure-access-token" { + t.Fatalf("expected bearer authorization, got %q", got) + } +} + +// TestSetKiroHeadersNonExternalIdpOmitsTokenType verifies the header is NOT sent +// for the other auth methods, so existing Builder ID / IDC / social accounts are +// unaffected. +func TestSetKiroHeadersNonExternalIdpOmitsTokenType(t *testing.T) { + for _, method := range []string{"idc", "social", ""} { + account := &config.Account{AccessToken: "tok", AuthMethod: method, MachineId: "m"} + req, err := http.NewRequest("POST", "https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + setKiroHeaders(req, account) + if got := req.Header.Get("TokenType"); got != "" { + t.Fatalf("auth method %q should not set TokenType, got %q", method, got) + } + } +} diff --git a/proxy/kiro_headers.go b/proxy/kiro_headers.go index baf3fc64..3b677b04 100644 --- a/proxy/kiro_headers.go +++ b/proxy/kiro_headers.go @@ -62,6 +62,13 @@ func applyKiroBaseHeaders(req *http.Request, account *config.Account, values kir req.Header.Set("User-Agent", values.UserAgent) req.Header.Set("x-amz-user-agent", values.AmzUserAgent) req.Header.Set("x-amzn-codewhisperer-optout", "true") + // External IdP (enterprise SSO, e.g. Azure AD) tokens MUST carry this header or + // CodeWhisperer does not recognize the token type and silently returns an empty + // profile list (and rejects data-plane calls). With it, a provisioned account + // resolves its profile; an unprovisioned one gets a clear 403. + if account != nil && account.AuthMethod == "external_idp" { + req.Header.Set("TokenType", "EXTERNAL_IDP") + } if values.Host != "" { req.Host = values.Host } diff --git a/web/app.js b/web/app.js index 085183c0..538bc9f8 100644 --- a/web/app.js +++ b/web/app.js @@ -21,6 +21,8 @@ let promptRules = []; let builderIdSession = ''; let builderIdPollTimer = null; + let kiroSsoSession = ''; + let kiroSsoPollTimer = null; let iamSession = ''; let exportSelectedIds = new Set(); let currentVersion = ''; @@ -2019,6 +2021,7 @@ var METHOD_ICONS = { builderid: 'fa-solid fa-id-card', iam: 'fa-solid fa-key', + enterprisesso: 'fa-brands fa-microsoft', sso: 'fa-solid fa-shield-halved', local: 'fa-solid fa-folder-open', credentials: 'fa-solid fa-code', @@ -2042,6 +2045,7 @@ if (type === 'add') modalAdd(title, body); else if (type === 'builderid') modalBuilderId(title, body); else if (type === 'iam') modalIam(title, body); + else if (type === 'enterprisesso') modalEnterpriseSso(title, body); else if (type === 'sso') modalSso(title, body); else if (type === 'local') modalLocal(title, body); else if (type === 'credentials') modalCredentials(title, body); @@ -2054,6 +2058,14 @@ iamSession = ''; if (builderIdPollTimer) { clearTimeout(builderIdPollTimer); builderIdPollTimer = null; } builderIdSession = ''; + if (kiroSsoPollTimer) { clearTimeout(kiroSsoPollTimer); kiroSsoPollTimer = null; } + // If a hosted-portal sign-in is still in flight (modal closed via X/backdrop + // before completion), release the loopback port now. On successful completion + // the poller clears kiroSsoSession first, so this no-ops then. + if (kiroSsoSession) { + api('/auth/kiro-sso/cancel', { method: 'POST', body: JSON.stringify({ sessionId: kiroSsoSession }) }).catch(() => {}); + } + kiroSsoSession = ''; } function modalAdd(title, body) { title.textContent = t('modal.addAccount'); @@ -2061,6 +2073,7 @@ '
' + methodCard('builderid', t('modal.builderIdTitle'), t('modal.builderIdDesc')) + methodCard('iam', t('modal.iamTitle'), t('modal.iamDesc')) + + methodCard('enterprisesso', t('modal.enterpriseSsoTitle'), t('modal.enterpriseSsoDesc')) + methodCard('sso', t('modal.ssoTitle'), t('modal.ssoDesc')) + methodCard('local', t('modal.localTitle'), t('modal.localDesc')) + methodCard('credentials', t('modal.credentialsTitle'), t('modal.credentialsDesc')) + @@ -2429,6 +2442,85 @@ builderIdSession = ''; showModal('add'); } + // Enterprise SSO — Microsoft 365 / Entra ID (Azure AD), via the Kiro hosted sign-in portal. + // The backend binds a loopback listener and returns the sign-in URL; the browser is driven + // through the external-IdP leg automatically, and we poll until the account is created. + function modalEnterpriseSso(title, body) { + title.textContent = t('modal.enterpriseSsoTitle'); + body.innerHTML = + '

' + escapeHtml(t('modal.enterpriseSsoDesc')) + '

' + + '
' + + '

' + escapeHtml(t('kirosso.hostNote')) + '

' + + '
' + + '' + + '
' + + ''; + $('startKiroSsoBtn').addEventListener('click', startKiroSsoLogin); + } + async function startKiroSsoLogin() { + const region = $('kiroSsoRegion').value || 'us-east-1'; + const res = await api('/auth/kiro-sso/start', { method: 'POST', body: JSON.stringify({ region }) }); + const d = await res.json(); + if (d.sessionId && d.signInUrl) { + kiroSsoSession = d.sessionId; + $('kiroSsoSignInUrl').textContent = d.signInUrl; + $('kiroSsoStep1').classList.add('hidden'); + $('kiroSsoStep2').classList.remove('hidden'); + $('kiroSsoOpenBtn').addEventListener('click', () => window.open($('kiroSsoSignInUrl').textContent, '_blank')); + $('kiroSsoCopyBtn').addEventListener('click', async () => { + await copyText($('kiroSsoSignInUrl').textContent); + toast(t('common.copied'), 'primary'); + }); + $('kiroSsoCancelBtn').addEventListener('click', cancelKiroSsoLogin); + // Open the sign-in tab immediately (works when the admin panel is viewed on the proxy host). + window.open(d.signInUrl, '_blank'); + pollKiroSso(d.interval || 2); + } else toastError(t('common.failed') + ': ' + (d.error || '')); + } + function pollKiroSso(interval) { + kiroSsoPollTimer = setTimeout(async () => { + const res = await api('/auth/kiro-sso/poll', { method: 'POST', body: JSON.stringify({ sessionId: kiroSsoSession }) }); + const d = await res.json(); + if (d.completed) { + // Session is already consumed server-side; clear it so closeModal() does + // not fire a redundant cancel for an account that succeeded. + kiroSsoSession = ''; + closeModal(); loadAccounts(); loadStats(); + toastPrimary(t('builderid.success') + ': ' + (d.account?.email || d.account?.id)); + autoRefreshNewAccount(d.account?.id); + } else if (d.success && !d.completed) { + $('kiroSsoStatus').textContent = t('builderid.waiting'); + pollKiroSso(interval); + } else { + toastError(t('common.failed') + ': ' + (d.error || '')); + cancelKiroSsoLogin(); + } + }, interval * 1000); + } + function cancelKiroSsoLogin() { + if (kiroSsoPollTimer) { clearTimeout(kiroSsoPollTimer); kiroSsoPollTimer = null; } + // Tell the backend to release the loopback callback port now instead of waiting + // for the deadline (fire-and-forget; ignore the result). + if (kiroSsoSession) { + api('/auth/kiro-sso/cancel', { method: 'POST', body: JSON.stringify({ sessionId: kiroSsoSession }) }).catch(() => {}); + } + kiroSsoSession = ''; + showModal('add'); + } async function startIamSso() { if (iamSession) { const res = await api('/auth/iam-sso/complete', { diff --git a/web/locales/en.json b/web/locales/en.json index ffac0bfc..db7df59d 100644 --- a/web/locales/en.json +++ b/web/locales/en.json @@ -227,6 +227,10 @@ "modal.credentialsDesc": "Add account via Kiro Account Manager exported credentials", "modal.cookieTitle": "Kiro Web Cookie", "modal.cookieDesc": "Add account via RefreshToken obtained from Kiro Web Cookie", + "modal.enterpriseSsoTitle": "Enterprise SSO - Microsoft 365", + "modal.enterpriseSsoDesc": "Add a Microsoft 365 / Entra ID (Azure AD) tenant account via Kiro hosted SSO", + "kirosso.hostNote": "Open the sign-in link on this machine (the same host as the proxy). The browser is redirected through your Microsoft 365 login automatically.", + "kirosso.openInstruction": "A browser tab was opened. Sign in with your Microsoft 365 work/school account, then return here. Tip: use a guest/incognito window to avoid a cached session.", "cookie.howToGet": "How to get the RefreshToken?", "cookie.step1": "Open your browser and sign in at", "cookie.step2": "Right-click on blank area → Inspect → Application → Cookies → https://app.kiro.dev", diff --git a/web/locales/zh.json b/web/locales/zh.json index f02a71d3..5b084557 100644 --- a/web/locales/zh.json +++ b/web/locales/zh.json @@ -227,6 +227,10 @@ "modal.credentialsDesc": "通过 Kiro Account Manager 导出的凭证添加账号", "modal.cookieTitle": "Kiro 网页 Cookie", "modal.cookieDesc": "通过从 Kiro 网页 Cookie 中获取的 RefreshToken 添加账号", + "modal.enterpriseSsoTitle": "企业 SSO - Microsoft 365", + "modal.enterpriseSsoDesc": "通过 Kiro 托管 SSO 添加 Microsoft 365 / Entra ID(Azure AD)租户账号", + "kirosso.hostNote": "请在运行代理的本机打开登录链接(与代理同一主机)。浏览器会自动跳转到 Microsoft 365 登录流程。", + "kirosso.openInstruction": "已打开浏览器标签页。请使用 Microsoft 365 工作/学校账号登录,然后返回此处。建议使用访客/无痕窗口以避免缓存会话干扰。", "cookie.howToGet": "如何获取 RefreshToken?", "cookie.step1": "打开浏览器,访问并登录", "cookie.step2": "在页面空白处右键 → 检查 → Application → Cookies → https://app.kiro.dev", From 145d54e4620b47962d960ded4f92b1db5bb5af8d Mon Sep 17 00:00:00 2001 From: Duc Date: Fri, 26 Jun 2026 16:04:53 +0700 Subject: [PATCH 02/68] feat(auth): auto-detect Kiro profile region for Azure SSO (eu-central-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure-tenant (external_idp) logins hardcoded the account region to us-east-1, so a Kiro profile provisioned in another region (e.g. eu-central-1) never resolved: the external_idp refresh path returns no profileArn, and the ListAvailableProfiles probe that would supply one targeted the wrong region. - proxy/kiro_api.go: probe ListAvailableProfiles across candidate regions (account region first, then fallbacks) and derive the authoritative region from the returned profile ARN (arn:aws:codewhisperer:REGION:...). Persist it via config.UpdateAccountRegion and apply it before caching the ARN so the same request's data-plane calls target the detected region. - Fallback probing is gated to external_idp / region-unset accounts; idc, social and Builder ID keep their prior single-region behavior (no extra upstream calls, no chance of flipping an established region). - Candidate fallbacks default to us-east-1,eu-central-1 and are overridable with KIRO_PROFILE_REGIONS. - regionalizeURL refactored to regionalizeURLForRegion; the non-us-east-1 invariant (both q.us-east-1 and codewhisperer.us-east-1 collapse to q.{region}; there is never a codewhisperer.{region} host) is pinned by tests. Docker: make `docker-compose up -d` complete the Enterprise SSO login without manual edits — publish 3128 host-side on loopback, bind the in-container callback to 0.0.0.0 (KIRO_SSO_CALLBACK_BIND=0.0.0.0), EXPOSE 3128. Removes the need for a local docker-compose.override.yml. Tests: parseRegionFromProfileArn, the regionalizeURLForRegion invariant, and candidate-region ordering/gating/env-override (24 region tests). Co-Authored-By: Claude Opus 4.8 (1M context) --- Dockerfile | 2 + config/config.go | 17 ++++ docker-compose.yml | 25 +++--- proxy/kiro_api.go | 182 ++++++++++++++++++++++++++++++++++---- proxy/kiro_region_test.go | 147 ++++++++++++++++++++++++++++++ 5 files changed, 345 insertions(+), 28 deletions(-) create mode 100644 proxy/kiro_region_test.go diff --git a/Dockerfile b/Dockerfile index b2c56422..ea8b01ab 100644 --- a/Dockerfile +++ b/Dockerfile @@ -23,6 +23,8 @@ COPY --from=builder /app/web ./web RUN mkdir -p /app/data EXPOSE 8080 +# Enterprise SSO (Microsoft 365) loopback callback port — see docker-compose.yml. +EXPOSE 3128 VOLUME /app/data CMD ["./kiro-go"] diff --git a/config/config.go b/config/config.go index 740147b7..4f617445 100644 --- a/config/config.go +++ b/config/config.go @@ -519,6 +519,23 @@ func UpdateAccountProfileArn(id, profileArn string) error { return nil } +// UpdateAccountRegion persists a home region discovered after login. An account's +// Kiro profile only resolves against its own region, but the login flow cannot know +// it up front — Azure-tenant (external_idp) logins default to us-east-1 — so the +// region is detected from the resolved profile ARN on first use (see +// parseRegionFromProfileArn) and written back here. A no-op for an unknown id. +func UpdateAccountRegion(id, region string) error { + cfgLock.Lock() + defer cfgLock.Unlock() + for i, a := range cfg.Accounts { + if a.ID == id { + cfg.Accounts[i].Region = region + return Save() + } + } + return nil +} + func DeleteAccount(id string) error { cfgLock.Lock() defer cfgLock.Unlock() diff --git a/docker-compose.yml b/docker-compose.yml index ba1b7811..661182f0 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -6,19 +6,22 @@ services: ports: - "8080:8080" # Enterprise SSO (Microsoft 365) callback. The hosted-portal sign-in - # redirects the browser to localhost:3128. Uncomment this AND set - # KIRO_SSO_CALLBACK_BIND=0.0.0.0 below to complete that login from a - # browser on the host while the proxy runs in this container. - # - "3128:3128" + # redirects the browser to localhost:3128, so the operator must run that + # browser ON THE DOCKER HOST. Publishing host-side to 127.0.0.1 only keeps + # the callback off the host's external interfaces while still letting the + # host browser's localhost:3128 reach the container listener below. + - "127.0.0.1:3128:3128" volumes: - ./data:/app/data environment: - CONFIG_PATH=/app/data/config.json - # Bind host for the Enterprise SSO callback listener. Default (unset) is - # loopback-only (127.0.0.1/::1), reachable only from inside the container. - # Set to 0.0.0.0 so the published 3128 port above can reach it. The callback - # is transient and anti-CSRF state-matched, but this exposes it on the - # container network during the ~10-minute login window — only enable on a - # trusted network. - # - KIRO_SSO_CALLBACK_BIND=0.0.0.0 + # Bind host for the Enterprise SSO callback listener INSIDE the container. + # Default (unset) is loopback-only (127.0.0.1/::1), which the published port + # above cannot reach; 0.0.0.0 lets the forwarded connection land. The callback + # is transient (~10-minute login window) and anti-CSRF state-matched, and the + # host-side publish is pinned to loopback above, so it is not reachable off the + # host. Note 0.0.0.0 does expose 3128 to peer containers on the same Docker + # network during that window; on a shared/untrusted Docker network, pin + # KIRO_SSO_CALLBACK_BIND to a specific interface or isolate the network. + - KIRO_SSO_CALLBACK_BIND=0.0.0.0 restart: unless-stopped diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index 3bdb50e7..e386f040 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -9,6 +9,7 @@ import ( "kiro-go/logger" "net/http" neturl "net/url" + "os" "strings" "sync" "time" @@ -36,13 +37,21 @@ func kiroRegion(account *config.Account) string { } // regionalizeURL points a hardcoded us-east-1 Kiro endpoint at the account's -// region. Amazon Q is regional (q.{region}.amazonaws.com), but the CodeWhisperer -// REST host only exists in us-east-1 — non-us-east-1 accounts are served by the -// regional Amazon Q host instead. So for those accounts both us-east-1 hosts map -// to q.{region}. It is a no-op for us-east-1 accounts. +// region (see regionalizeURLForRegion). It is a no-op for us-east-1 accounts. func regionalizeURL(rawURL string, account *config.Account) string { - region := kiroRegion(account) - if region == "us-east-1" { + return regionalizeURLForRegion(rawURL, kiroRegion(account)) +} + +// regionalizeURLForRegion rewrites a hardcoded us-east-1 Kiro endpoint to target +// the given region. Amazon Q is regional (q.{region}.amazonaws.com), but the +// CodeWhisperer REST host only exists in us-east-1 — every other region is served +// by the regional Amazon Q host instead. So for a non-us-east-1 region BOTH +// us-east-1 hosts (q.us-east-1.* and codewhisperer.us-east-1.*) collapse onto +// q.{region}.amazonaws.com; there is deliberately no codewhisperer.{region} host. +// It is a no-op for us-east-1 or an empty region. +func regionalizeURLForRegion(rawURL, region string) string { + region = strings.TrimSpace(region) + if region == "" || region == "us-east-1" { return rawURL } regionalHost := "q." + region + ".amazonaws.com" @@ -52,6 +61,85 @@ func regionalizeURL(rawURL string, account *config.Account) string { ).Replace(rawURL) } +// defaultKiroProfileRegions is the ordered set of regions probed when an account's +// home region is unknown. us-east-1 is the historical default every login falls +// back to; eu-central-1 is where EU-provisioned Azure-tenant profiles +// (e.g. KiroProfile-eu-central-1) live. Override or extend with the +// KIRO_PROFILE_REGIONS env var (comma-separated) to onboard further regions +// without a code change. +var defaultKiroProfileRegions = []string{"us-east-1", "eu-central-1"} + +// kiroProfileRegionCandidates returns the ordered, de-duplicated list of regions +// to probe for an account's Kiro profile. The account's currently-configured region +// is always tried first. Cross-region fallbacks are only added when the home region +// is genuinely unknown — an external_idp (Azure-tenant) login, which defaults to +// us-east-1, or an account with no region at all. An idc/social/Builder ID account +// already carries its real region (from the SSO portal / the us-east-1 default), so +// it is probed against that single region exactly as before — no extra upstream calls +// and no chance of its established region being flipped. KIRO_PROFILE_REGIONS, when +// set, replaces the built-in fallback set (the account region is still tried first). +func kiroProfileRegionCandidates(account *config.Account) []string { + seen := make(map[string]bool) + var out []string + add := func(region string) { + region = strings.TrimSpace(region) + if region == "" || seen[region] { + return + } + seen[region] = true + out = append(out, region) + } + + if account != nil { + add(account.Region) + } + if !shouldProbeFallbackRegions(account) { + return out + } + if env := strings.TrimSpace(os.Getenv("KIRO_PROFILE_REGIONS")); env != "" { + for _, r := range strings.Split(env, ",") { + add(r) + } + return out + } + for _, r := range defaultKiroProfileRegions { + add(r) + } + return out +} + +// shouldProbeFallbackRegions reports whether an account's home region is unknown +// enough to justify probing fallback regions. Only external_idp accounts (region +// defaulted to us-east-1 at login) and accounts with no region set qualify; every +// other auth method already carries its authoritative region. +func shouldProbeFallbackRegions(account *config.Account) bool { + if account == nil { + return true + } + if strings.TrimSpace(account.Region) == "" { + return true + } + return strings.EqualFold(strings.TrimSpace(account.AuthMethod), "external_idp") +} + +// parseRegionFromProfileArn extracts the AWS region from a CodeWhisperer/Kiro +// profile ARN (arn:partition:service:REGION:account-id:resource). The ARN's region +// is the authoritative source of an account's home region — a profile only resolves +// against the region it was provisioned in — so it drives every subsequent +// regionalized data-plane call. Returns "" for a malformed ARN or one missing a +// region. +func parseRegionFromProfileArn(arn string) string { + arn = strings.TrimSpace(arn) + if arn == "" { + return "" + } + parts := strings.SplitN(arn, ":", 6) + if len(parts) < 4 || !strings.EqualFold(parts[0], "arn") { + return "" + } + return strings.TrimSpace(parts[3]) +} + // GetUsageLimits 获取账户使用量和订阅信息 func GetUsageLimits(account *config.Account) (*UsageLimitsResponse, error) { if err := ensureRestProfileArn(account); err != nil { @@ -171,9 +259,20 @@ func ResolveProfileArn(account *config.Account) (string, error) { var profileUnsupported bool if !profileLookupSuppressed { - // Try ListAvailableProfiles first, retrying on transient failures. - profileArn, err := listAvailableProfilesWithRetry(account) + // Probe ListAvailableProfiles across candidate regions, retrying transient + // failures. The home region is unknown at login for Azure-tenant + // (external_idp) accounts (they default to us-east-1), so this probe doubles + // as region auto-detection. + profileArn, regionUsed, err := resolveProfileArnAcrossRegions(account) if err == nil && profileArn != "" { + // The ARN's own region is authoritative; fall back to the region that + // served the profile if the ARN carries none. Persist the region BEFORE + // the ARN so subsequent regionalized data-plane calls target it. + detectedRegion := parseRegionFromProfileArn(profileArn) + if detectedRegion == "" { + detectedRegion = regionUsed + } + applyDetectedRegion(account, detectedRegion) if updateErr := config.UpdateAccountProfileArn(account.ID, profileArn); updateErr != nil { logger.Warnf("[ProfileArn] Failed to cache profile ARN for %s: %v", account.Email, updateErr) } @@ -188,6 +287,7 @@ func ResolveProfileArn(account *config.Account) (string, error) { if account.RefreshToken != "" { _, _, _, refreshedArn, refreshErr := auth.RefreshToken(account) if refreshErr == nil && refreshedArn != "" { + applyDetectedRegion(account, parseRegionFromProfileArn(refreshedArn)) if updateErr := config.UpdateAccountProfileArn(account.ID, refreshedArn); updateErr != nil { logger.Warnf("[ProfileArn] Failed to cache profile ARN for %s: %v", account.Email, updateErr) } @@ -285,16 +385,58 @@ func ensureRestProfileArn(account *config.Account) error { return nil } -func listAvailableProfilesWithRetry(account *config.Account) (string, error) { - // Retry transient failures (network errors, 5xx, 429) with short backoff. - // An empty profile list or 4xx (other than 429) is treated as authoritative - // and not retried — they reflect account state, not upstream flakiness. +// resolveProfileArnAcrossRegions probes ListAvailableProfiles against each +// candidate region (the account's configured region first, then the fallbacks) and +// returns the first profile ARN found together with the region that answered. This +// is what lets an account whose home region is unknown at login — every Azure-tenant +// (external_idp) login defaults to us-east-1 — discover its real region (e.g. +// eu-central-1) on first use. A correctly-regioned account resolves on the first +// probe. A Builder ID "unsupported" 403 is authoritative across all regions, so it +// short-circuits the probe rather than repeating per region. +func resolveProfileArnAcrossRegions(account *config.Account) (profileArn, regionUsed string, err error) { + var lastErr error + for _, region := range kiroProfileRegionCandidates(account) { + arn, probeErr := listAvailableProfilesWithRetryInRegion(account, region) + if probeErr == nil && strings.TrimSpace(arn) != "" { + return arn, region, nil + } + if probeErr != nil { + lastErr = probeErr + if isBuilderIDProfileUnsupportedError(account, probeErr) { + return "", region, probeErr + } + } + } + return "", "", lastErr +} + +// applyDetectedRegion persists and applies a region discovered during profile +// resolution when it differs from the account's stored region. The detected region +// is authoritative (it comes from the profile ARN, or from the host that actually +// served the profile), so it overrides the login-time default. +func applyDetectedRegion(account *config.Account, region string) { + region = strings.TrimSpace(region) + if account == nil || region == "" || strings.EqualFold(region, strings.TrimSpace(account.Region)) { + return + } + logger.Infof("[ProfileArn] Detected Kiro region %s for %s (was %q)", region, accountEmailForLog(account), account.Region) + if err := config.UpdateAccountRegion(account.ID, region); err != nil { + logger.Warnf("[ProfileArn] Failed to persist detected region for %s: %v", accountEmailForLog(account), err) + } + account.Region = region +} + +// listAvailableProfilesWithRetryInRegion calls ListAvailableProfiles against a +// specific region, retrying transient failures (network errors, 5xx, 429) with +// short backoff. An empty profile list or 4xx (other than 429) is treated as +// authoritative and not retried — they reflect account state, not upstream flakiness. +func listAvailableProfilesWithRetryInRegion(account *config.Account, region string) (string, error) { const maxAttempts = 3 backoff := 200 * time.Millisecond var lastErr error for attempt := 1; attempt <= maxAttempts; attempt++ { - profileArn, err := listAvailableProfiles(account) + profileArn, err := listAvailableProfilesInRegion(account, region) if err == nil { return profileArn, nil } @@ -302,8 +444,8 @@ func listAvailableProfilesWithRetry(account *config.Account) (string, error) { if !isTransientProfileFetchError(err) || attempt == maxAttempts { return "", err } - logger.Debugf("[ProfileArn] ListAvailableProfiles transient failure for %s (attempt %d/%d): %v", - account.Email, attempt, maxAttempts, err) + logger.Debugf("[ProfileArn] ListAvailableProfiles transient failure for %s in %s (attempt %d/%d): %v", + account.Email, region, attempt, maxAttempts, err) time.Sleep(backoff) backoff *= 2 } @@ -328,8 +470,14 @@ func isTransientProfileFetchError(err error) bool { return true } -func listAvailableProfiles(account *config.Account) (string, error) { - req, err := http.NewRequest("POST", regionalizeURL(fmt.Sprintf("%s/ListAvailableProfiles", kiroRestAPIBase), account), strings.NewReader(`{"maxResults":10}`)) +// listAvailableProfilesInRegion calls ListAvailableProfiles with the request host +// pointed at a specific region (q.{region} for non-us-east-1, the CodeWhisperer +// REST host for us-east-1). Targeting an explicit region — rather than the account's +// stored one — is what makes cross-region detection possible: the same credential is +// probed against each candidate region until one returns a profile. +func listAvailableProfilesInRegion(account *config.Account, region string) (string, error) { + endpoint := regionalizeURLForRegion(fmt.Sprintf("%s/ListAvailableProfiles", kiroRestAPIBase), region) + req, err := http.NewRequest("POST", endpoint, strings.NewReader(`{"maxResults":10}`)) if err != nil { return "", err } diff --git a/proxy/kiro_region_test.go b/proxy/kiro_region_test.go new file mode 100644 index 00000000..88c011df --- /dev/null +++ b/proxy/kiro_region_test.go @@ -0,0 +1,147 @@ +package proxy + +import ( + "kiro-go/config" + "testing" +) + +// TestParseRegionFromProfileArn covers region extraction from real and malformed +// CodeWhisperer/Kiro profile ARNs. The ARN's region drives every regionalized +// data-plane call, so a wrong parse silently sends traffic to the wrong region. +func TestParseRegionFromProfileArn(t *testing.T) { + cases := []struct { + name string + arn string + want string + }{ + {"eu-central-1 profile", "arn:aws:codewhisperer:eu-central-1:123456789012:profile/ABCDEF123456", "eu-central-1"}, + {"us-east-1 profile", "arn:aws:codewhisperer:us-east-1:123456789012:profile/ABCDEF123456", "us-east-1"}, + {"surrounding whitespace", " arn:aws:codewhisperer:ap-southeast-2:1:profile/X ", "ap-southeast-2"}, + {"empty", "", ""}, + {"not an arn", "https://q.eu-central-1.amazonaws.com", ""}, + {"too few segments", "arn:aws:codewhisperer", ""}, + {"missing region", "arn:aws:codewhisperer::123456789012:profile/X", ""}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := parseRegionFromProfileArn(tc.arn); got != tc.want { + t.Fatalf("parseRegionFromProfileArn(%q) = %q, want %q", tc.arn, got, tc.want) + } + }) + } +} + +// TestRegionalizeURLForRegion asserts that a non-us-east-1 region collapses BOTH +// hardcoded us-east-1 hosts (q.* and codewhisperer.*) onto q.{region} — there is no +// codewhisperer.{region} host — and that us-east-1/empty are no-ops. +func TestRegionalizeURLForRegion(t *testing.T) { + cases := []struct { + name string + rawURL string + region string + want string + }{ + { + name: "codewhisperer host to q.eu-central-1", + rawURL: "https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", + region: "eu-central-1", + want: "https://q.eu-central-1.amazonaws.com/ListAvailableProfiles", + }, + { + name: "q host to q.eu-central-1", + rawURL: "https://q.us-east-1.amazonaws.com/generateAssistantResponse", + region: "eu-central-1", + want: "https://q.eu-central-1.amazonaws.com/generateAssistantResponse", + }, + { + name: "us-east-1 is a no-op (codewhisperer host kept)", + rawURL: "https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", + region: "us-east-1", + want: "https://codewhisperer.us-east-1.amazonaws.com/ListAvailableProfiles", + }, + { + name: "empty region is a no-op", + rawURL: "https://q.us-east-1.amazonaws.com/x", + region: "", + want: "https://q.us-east-1.amazonaws.com/x", + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + got := regionalizeURLForRegion(tc.rawURL, tc.region) + if got != tc.want { + t.Fatalf("regionalizeURLForRegion(%q, %q) = %q, want %q", tc.rawURL, tc.region, got, tc.want) + } + }) + } +} + +// TestRegionalizeURLForRegionNoCodewhispererRegionalHost guards the user-stated +// invariant directly: a regionalized URL must never produce codewhisperer.{region}. +func TestRegionalizeURLForRegionNoCodewhispererRegionalHost(t *testing.T) { + got := regionalizeURLForRegion("https://codewhisperer.us-east-1.amazonaws.com/GetUserInfo", "eu-central-1") + if want := "https://q.eu-central-1.amazonaws.com/GetUserInfo"; got != want { + t.Fatalf("got %q, want %q (must not be codewhisperer.eu-central-1)", got, want) + } +} + +// TestKiroProfileRegionCandidatesExternalIdp checks that an external_idp account — +// whose home region is unknown and defaults to us-east-1 — probes the account region +// first and then the built-in fallbacks, de-duplicated. +func TestKiroProfileRegionCandidatesExternalIdp(t *testing.T) { + // Default us-east-1 external_idp login: fallbacks follow. + got := kiroProfileRegionCandidates(&config.Account{AuthMethod: "external_idp", Region: "us-east-1"}) + assertOrder(t, got, []string{"us-east-1", "eu-central-1"}) + + // Already-detected eu-central-1 leads; us-east-1 fallback follows. + got = kiroProfileRegionCandidates(&config.Account{AuthMethod: "external_idp", Region: "eu-central-1"}) + assertOrder(t, got, []string{"eu-central-1", "us-east-1"}) + + // A non-default region leads, both defaults follow. + got = kiroProfileRegionCandidates(&config.Account{AuthMethod: "external_idp", Region: "ap-southeast-2"}) + assertOrder(t, got, []string{"ap-southeast-2", "us-east-1", "eu-central-1"}) +} + +// TestKiroProfileRegionCandidatesNoRegion checks an account with no region set falls +// back across the defaults regardless of auth method. +func TestKiroProfileRegionCandidatesNoRegion(t *testing.T) { + got := kiroProfileRegionCandidates(&config.Account{}) + assertOrder(t, got, []string{"us-east-1", "eu-central-1"}) +} + +// TestKiroProfileRegionCandidatesSingleRegionAuthMethods checks that idc/social/ +// Builder ID accounts — which already carry their authoritative region — are probed +// against that single region only, with no fallback probing. +func TestKiroProfileRegionCandidatesSingleRegionAuthMethods(t *testing.T) { + for _, method := range []string{"idc", "social", "builderId", ""} { + got := kiroProfileRegionCandidates(&config.Account{AuthMethod: method, Region: "eu-central-1"}) + if len(got) != 1 || got[0] != "eu-central-1" { + t.Fatalf("authMethod %q: candidate regions = %v, want [eu-central-1] only", method, got) + } + } +} + +// TestKiroProfileRegionCandidatesEnvOverride checks KIRO_PROFILE_REGIONS replaces +// the built-in fallbacks (external_idp only) while the account region is tried first. +func TestKiroProfileRegionCandidatesEnvOverride(t *testing.T) { + t.Setenv("KIRO_PROFILE_REGIONS", "eu-west-1, ap-south-1 ,eu-west-1") + got := kiroProfileRegionCandidates(&config.Account{AuthMethod: "external_idp", Region: "us-east-1"}) + // us-east-1 (account) first; env values de-duplicated and trimmed; no built-in defaults. + assertOrder(t, got, []string{"us-east-1", "eu-west-1", "ap-south-1"}) + + // A non-external_idp account ignores the env fallbacks entirely. + got = kiroProfileRegionCandidates(&config.Account{AuthMethod: "idc", Region: "us-east-1"}) + assertOrder(t, got, []string{"us-east-1"}) +} + +func assertOrder(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("candidate regions = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("candidate regions = %v, want %v", got, want) + } + } +} From 0263a2b5afb16a3514de004caecd8c53b3ee31fb Mon Sep 17 00:00:00 2001 From: Duc Date: Fri, 26 Jun 2026 16:15:18 +0700 Subject: [PATCH 03/68] feat(auth): drop region prompt from Enterprise SSO login The operator no longer enters an AWS region to start the Microsoft 365 / Kiro hosted-portal sign-in. The data-plane region is derived from the profile ARN returned by SSO (social exchange) or discovered via the cross-region profile probe (external_idp / Azure), so a region the user would have to know up front is no longer required. Co-Authored-By: Claude Opus 4.8 (1M context) --- web/app.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/web/app.js b/web/app.js index 538bc9f8..11a12c91 100644 --- a/web/app.js +++ b/web/app.js @@ -2451,7 +2451,6 @@ '

' + escapeHtml(t('modal.enterpriseSsoDesc')) + '

' + '
' + '

' + escapeHtml(t('kirosso.hostNote')) + '

' + - '
' + '' + '
' + - '

' + escapeHtml(t('builderid.waiting')) + '

' + + '
' + + '
' + + 'Step 1' + + 'Paste the redirect URL from your browser address bar' + + '
' + + '

After signing in, your browser will redirect to localhost:3128. Copy the full URL (Ctrl+L, Ctrl+C) and paste it here.

' + + '
' + + '' + + '' + + '
' + + '' + + '
' + + '

Waiting for callback URL — paste the redirect from your browser above

' + '' + '
'; $('startKiroSsoBtn').addEventListener('click', startKiroSsoLogin); + $('kiroSsoSubmitCallbackBtn').addEventListener('click', submitKiroSsoCallback); + $('kiroSsoCallbackUrl').addEventListener('keydown', (e) => { if (e.key === 'Enter') submitKiroSsoCallback(); }); } async function startKiroSsoLogin() { // No region prompt: the data-plane region is derived from the profile ARN @@ -2514,6 +2531,45 @@ pollKiroSso(d.interval || 2); } else toastError(t('common.failed') + ': ' + (d.error || '')); } + async function submitKiroSsoCallback() { + const url = $('kiroSsoCallbackUrl').value.trim(); + if (!url) return; + if (!kiroSsoSession) { toastError('No active SSO session'); return; } + $('kiroSsoSubmitCallbackBtn').disabled = true; + $('kiroSsoStatus').textContent = 'Processing callback...'; + $('kiroSsoStatus').style.color = 'var(--muted)'; + try { + const res = await api('/auth/kiro-sso/callback', { + method: 'POST', body: JSON.stringify({ sessionId: kiroSsoSession, callbackUrl: url }) + }); + const d = await res.json(); + if (d.success && d.redirectUrl) { + // Enterprise SSO leg-1: show Microsoft login, advance to step 2 + $('kiroSsoStepBadge').textContent = 'Step 2'; + $('kiroSsoStepLabel').textContent = 'Paste the final redirect URL after Microsoft login'; + $('kiroSsoStepHint').innerHTML = 'After Microsoft 365 authentication, your browser will again redirect to localhost:3128. Copy and paste that final URL.'; + $('kiroSsoRedirectLink').href = d.redirectUrl; + $('kiroSsoRedirectArea').classList.remove('hidden'); + $('kiroSsoCallbackUrl').placeholder = 'http://localhost:3128/oauth/callback?code=...'; + $('kiroSsoStatus').textContent = '↑ Open the Microsoft login link above, then paste the next redirect URL'; + $('kiroSsoStatus').style.color = 'var(--warning)'; + $('kiroSsoCallbackUrl').value = ''; + // Auto-open the Microsoft login link + window.open(d.redirectUrl, '_blank'); + } else if (d.success) { + // Leg-2 or social: polling will pick up the result + $('kiroSsoStatus').textContent = 'Callback accepted — completing login...'; + $('kiroSsoStatus').style.color = 'var(--success)'; + $('kiroSsoCallbackUrl').value = ''; + } else { + toastError(t('common.failed') + ': ' + (d.error || '')); + } + } catch (e) { + toastError('Failed to submit callback: ' + e.message); + } finally { + $('kiroSsoSubmitCallbackBtn').disabled = false; + } + } function pollKiroSso(interval) { kiroSsoPollTimer = setTimeout(async () => { const res = await api('/auth/kiro-sso/poll', { method: 'POST', body: JSON.stringify({ sessionId: kiroSsoSession }) }); @@ -2526,7 +2582,11 @@ toastPrimary(t('builderid.success') + ': ' + (d.account?.email || d.account?.id)); autoRefreshNewAccount(d.account?.id); } else if (d.success && !d.completed) { - $('kiroSsoStatus').textContent = t('builderid.waiting'); + // Don't overwrite a manual-instruction status message + if (!$('kiroSsoStatus').textContent.includes('↑') && !$('kiroSsoStatus').textContent.includes('Step')) { + $('kiroSsoStatus').textContent = 'Waiting for callback URL — paste the redirect from your browser above'; + $('kiroSsoStatus').style.color = 'var(--warning)'; + } pollKiroSso(interval); } else { toastError(t('common.failed') + ': ' + (d.error || '')); From 6176db601c830cac8fa6e89b6f61313fcd5e5a9a Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Thu, 2 Jul 2026 20:24:10 +0700 Subject: [PATCH 36/68] fix: generic error for ownership denial + add ownership tests - Change loadResponseForOwner error from 'access denied: response belongs to a different API key' to generic 'stored response not found' to prevent response-ID enumeration (information disclosure fix) - Add TestLoadResponseForOwnerIsolation with 4 scenarios: same key (allowed), different key (denied), legacy empty owner (backward compat), empty caller key (unauthenticated compat) --- proxy/responses_store.go | 5 +- proxy/responses_store_test.go | 106 ++++++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 proxy/responses_store_test.go diff --git a/proxy/responses_store.go b/proxy/responses_store.go index 49239dba..bb111eec 100644 --- a/proxy/responses_store.go +++ b/proxy/responses_store.go @@ -118,7 +118,8 @@ func loadResponse(id string) (*ResponsesObject, error) { // loadResponseForOwner loads a stored response and verifies ownership. // If the stored response has an OwnerKeyID set and it doesn't match the -// given apiKeyID, an access-denied error is returned. Responses created +// given apiKeyID, a generic "not found" error is returned (identical to +// a missing ID) to prevent response-ID enumeration. Responses created // before the ownership field was added (OwnerKeyID=="") are accessible // by anyone for backward compatibility. func loadResponseForOwner(id, apiKeyID string) (*ResponsesObject, error) { @@ -127,7 +128,7 @@ func loadResponseForOwner(id, apiKeyID string) (*ResponsesObject, error) { return nil, err } if resp.OwnerKeyID != "" && apiKeyID != "" && resp.OwnerKeyID != apiKeyID { - return nil, fmt.Errorf("access denied: response belongs to a different API key") + return nil, fmt.Errorf("stored response not found") } return resp, nil } diff --git a/proxy/responses_store_test.go b/proxy/responses_store_test.go new file mode 100644 index 00000000..084f559a --- /dev/null +++ b/proxy/responses_store_test.go @@ -0,0 +1,106 @@ +package proxy + +import ( + "encoding/json" + "kiro-go/config" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestLoadResponseForOwnerIsolation(t *testing.T) { + // Set up a temp config dir so responses are written to a temp directory. + cfgFile := filepath.Join(t.TempDir(), "config.json") + if err := config.Init(cfgFile); err != nil { + t.Fatalf("config.Init: %v", err) + } + + // Helper to create and save a minimal response with the given ID and owner. + makeResponse := func(id, ownerKeyID string) *ResponsesObject { + return &ResponsesObject{ + ID: id, + Object: "response", + CreatedAt: time.Now().Unix(), + Status: "completed", + Model: "claude-sonnet-4.5", + Output: []ResponseOutputItem{{ + ID: "msg_test", + Type: "message", + Role: "assistant", + Content: []ResponseContentPart{{ + Type: "output_text", + Text: "test output", + }}, + }}, + StoredInput: json.RawMessage(`"test input"`), + OwnerKeyID: ownerKeyID, + StoredAt: time.Now().Unix(), + } + } + + t.Run("access allowed same key", func(t *testing.T) { + resp := makeResponse("resp_owner_allowed", "key-A") + if err := saveResponse(resp); err != nil { + t.Fatalf("save: %v", err) + } + + loaded, err := loadResponseForOwner("resp_owner_allowed", "key-A") + if err != nil { + t.Fatalf("expected access allowed for same key, got error: %v", err) + } + if loaded.ID != "resp_owner_allowed" { + t.Fatalf("loaded ID mismatch: got %q", loaded.ID) + } + if loaded.OwnerKeyID != "key-A" { + t.Fatalf("loaded OwnerKeyID mismatch: got %q", loaded.OwnerKeyID) + } + }) + + t.Run("access denied different key", func(t *testing.T) { + resp := makeResponse("resp_owner_denied", "key-A") + if err := saveResponse(resp); err != nil { + t.Fatalf("save: %v", err) + } + + _, err := loadResponseForOwner("resp_owner_denied", "key-B") + if err == nil { + t.Fatalf("expected error for different key, got nil") + } + // Error message is intentionally generic ("not found") to prevent + // response-ID enumeration — do not assert specific wording. + if !strings.Contains(err.Error(), "not found") { + t.Fatalf("expected 'not found' in error, got: %v", err) + } + }) + + t.Run("legacy compatibility empty owner", func(t *testing.T) { + resp := makeResponse("resp_legacy_no_owner", "") + if err := saveResponse(resp); err != nil { + t.Fatalf("save: %v", err) + } + + loaded, err := loadResponseForOwner("resp_legacy_no_owner", "key-B") + if err != nil { + t.Fatalf("expected legacy response accessible by any key, got error: %v", err) + } + if loaded.ID != "resp_legacy_no_owner" { + t.Fatalf("loaded ID mismatch: got %q", loaded.ID) + } + }) + + t.Run("empty caller key allowed", func(t *testing.T) { + resp := makeResponse("resp_empty_caller", "key-A") + if err := saveResponse(resp); err != nil { + t.Fatalf("save: %v", err) + } + + loaded, err := loadResponseForOwner("resp_empty_caller", "") + if err != nil { + t.Fatalf("expected unauthenticated access allowed, got error: %v", err) + } + if loaded.ID != "resp_empty_caller" { + t.Fatalf("loaded ID mismatch: got %q", loaded.ID) + } + }) +} From f5ae72c3874de90a7c9352c8796b9ffc361c8cf2 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Tue, 7 Jul 2026 12:39:43 +0700 Subject: [PATCH 37/68] feat: allow idc (enterprise SSO) accounts to fallback probe eu-central-1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shouldProbeFallbackRegions() previously only allowed external_idp accounts to probe cross-region fallbacks. Enterprise IAM Identity Center (idc) accounts whose SSO portal is in us-east-1 but whose profile is provisioned in eu-central-1 were stuck — the profile was never discovered. Add idc to the fallback condition so enterprise SSO accounts also probe [us-east-1, eu-central-1] (or KIRO_PROFILE_REGIONS). --- proxy/kiro_api.go | 31 +++++++++++++++++-------------- proxy/kiro_region_test.go | 23 ++++++++++++++++++++--- 2 files changed, 37 insertions(+), 17 deletions(-) diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index c74a5da4..72649613 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -96,13 +96,13 @@ var defaultKiroProfileRegions = []string{"us-east-1", "eu-central-1"} // kiroProfileRegionCandidates returns the ordered, de-duplicated list of regions // to probe for an account's Kiro profile. The account's currently-configured region -// is always tried first. Cross-region fallbacks are only added when the home region -// is genuinely unknown — an external_idp (Azure-tenant) login, which defaults to -// us-east-1, or an account with no region at all. An idc/social/Builder ID account -// already carries its real region (from the SSO portal / the us-east-1 default), so -// it is probed against that single region exactly as before — no extra upstream calls -// and no chance of its established region being flipped. KIRO_PROFILE_REGIONS, when -// set, replaces the built-in fallback set (the account region is still tried first). +// is always tried first. Cross-region fallbacks are added for auth methods whose +// login region does not guarantee the profile region: external_idp (Azure-tenant, +// defaults to us-east-1) and idc (IAM Identity Center / enterprise SSO, where a +// us-east-1 portal can provision profiles in eu-central-1). social and Builder ID +// accounts carry their authoritative region and are probed against that single +// region only. KIRO_PROFILE_REGIONS, when set, replaces the built-in fallback set +// (the account region is still tried first). func kiroProfileRegionCandidates(account *config.Account) []string { seen := make(map[string]bool) var out []string @@ -133,10 +133,13 @@ func kiroProfileRegionCandidates(account *config.Account) []string { return out } -// shouldProbeFallbackRegions reports whether an account's home region is unknown -// enough to justify probing fallback regions. Only external_idp accounts (region -// defaulted to us-east-1 at login) and accounts with no region set qualify; every -// other auth method already carries its authoritative region. +// shouldProbeFallbackRegions reports whether an account should probe fallback +// regions when its home region returns no profile. external_idp accounts always +// qualify (region defaulted to us-east-1 at login). idc (IAM Identity Center / +// enterprise SSO) accounts also qualify: an enterprise SSO portal in us-east-1 +// can provision profiles in eu-central-1, so the auth region does not imply the +// profile region. social and Builder ID accounts carry their authoritative +// region and are not probed further. func shouldProbeFallbackRegions(account *config.Account) bool { if account == nil { return true @@ -144,7 +147,8 @@ func shouldProbeFallbackRegions(account *config.Account) bool { if strings.TrimSpace(account.Region) == "" { return true } - return strings.EqualFold(strings.TrimSpace(account.AuthMethod), "external_idp") + method := strings.TrimSpace(account.AuthMethod) + return strings.EqualFold(method, "external_idp") || strings.EqualFold(method, "idc") } // GetUsageLimits 获取账户使用量和订阅信息 @@ -547,8 +551,7 @@ func RefreshAccountInfo(account *config.Account) (*config.AccountInfo, error) { } return nil, fmt.Errorf("Account suspended: %w", err) - } else if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "401") || - strings.Contains(errMsg, "invalid") || strings.Contains(errMsg, "expired") { + } else if isAuthErrorMessage(errMsg) { // Token 相关错误,可能需要重新认证 logger.Warnf("[RefreshAccountInfo] Authentication error for %s: %v", account.Email, err) diff --git a/proxy/kiro_region_test.go b/proxy/kiro_region_test.go index 690dfd14..d5040cac 100644 --- a/proxy/kiro_region_test.go +++ b/proxy/kiro_region_test.go @@ -83,11 +83,11 @@ func TestKiroProfileRegionCandidatesNoRegion(t *testing.T) { assertOrder(t, got, []string{"us-east-1", "eu-central-1"}) } -// TestKiroProfileRegionCandidatesSingleRegionAuthMethods checks that idc/social/ +// TestKiroProfileRegionCandidatesSingleRegionAuthMethods checks that social/ // Builder ID accounts — which already carry their authoritative region — are probed // against that single region only, with no fallback probing. func TestKiroProfileRegionCandidatesSingleRegionAuthMethods(t *testing.T) { - for _, method := range []string{"idc", "social", "builderId", ""} { + for _, method := range []string{"social", "builderId", ""} { got := kiroProfileRegionCandidates(&config.Account{AuthMethod: method, Region: "eu-central-1"}) if len(got) != 1 || got[0] != "eu-central-1" { t.Fatalf("authMethod %q: candidate regions = %v, want [eu-central-1] only", method, got) @@ -95,6 +95,19 @@ func TestKiroProfileRegionCandidatesSingleRegionAuthMethods(t *testing.T) { } } +// TestKiroProfileRegionCandidatesIdcFallback checks that idc (IAM Identity Center / +// enterprise SSO) accounts — whose SSO portal region (e.g. us-east-1) does not +// guarantee the profile region — probe fallback regions just like external_idp. +func TestKiroProfileRegionCandidatesIdcFallback(t *testing.T) { + // Default us-east-1 idc login: fallbacks follow. + got := kiroProfileRegionCandidates(&config.Account{AuthMethod: "idc", Region: "us-east-1"}) + assertOrder(t, got, []string{"us-east-1", "eu-central-1"}) + + // Already eu-central-1 leads; us-east-1 fallback follows. + got = kiroProfileRegionCandidates(&config.Account{AuthMethod: "idc", Region: "eu-central-1"}) + assertOrder(t, got, []string{"eu-central-1", "us-east-1"}) +} + // TestKiroProfileRegionCandidatesEnvOverride checks KIRO_PROFILE_REGIONS replaces // the built-in fallbacks (external_idp only) while the account region is tried first. func TestKiroProfileRegionCandidatesEnvOverride(t *testing.T) { @@ -103,8 +116,12 @@ func TestKiroProfileRegionCandidatesEnvOverride(t *testing.T) { // us-east-1 (account) first; env values de-duplicated and trimmed; no built-in defaults. assertOrder(t, got, []string{"us-east-1", "eu-west-1", "ap-south-1"}) - // A non-external_idp account ignores the env fallbacks entirely. + // An idc account also uses env fallbacks (enterprise SSO can have cross-region profiles). got = kiroProfileRegionCandidates(&config.Account{AuthMethod: "idc", Region: "us-east-1"}) + assertOrder(t, got, []string{"us-east-1", "eu-west-1", "ap-south-1"}) + + // A social account ignores the env fallbacks entirely. + got = kiroProfileRegionCandidates(&config.Account{AuthMethod: "social", Region: "us-east-1"}) assertOrder(t, got, []string{"us-east-1"}) } From 5b7f681f4e6b084049c4e29ae6cbbe6864150e5c Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Thu, 9 Jul 2026 09:58:11 +0700 Subject: [PATCH 38/68] docs: add Telegram contact to README --- README.md | 4 ++++ README_CN.md | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/README.md b/README.md index 67e96093..54ebe0b6 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,10 @@ The setting takes effect immediately without restarting. Friendly discussion is welcome. If you run into issues, try asking Claude Code, Codex, or similar tools for help first — most problems can be solved that way. PRs are even better. +## Contact + +Telegram: [@tutua16888](https://t.me/tutua16888) + ## Friend Links - [LINUX DO](https://linux.do) diff --git a/README_CN.md b/README_CN.md index 5f51fe38..88a53bcf 100644 --- a/README_CN.md +++ b/README_CN.md @@ -117,6 +117,10 @@ curl http://localhost:8080/v1/chat/completions \ 欢迎友好交流。遇到问题时,建议先让 Claude Code、Codex 等工具帮忙排查一下,大部分问题都能自己解决。如果能直接提个 PR 就更好了。 +## 联系方式 + +Telegram:[@tutua16888](https://t.me/tutua16888) + ## 友情链接 - [LINUX DO](https://linux.do) From a882d87190e1494bd57d85fa5a66fd7a5988043d Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Thu, 9 Jul 2026 14:08:51 +0700 Subject: [PATCH 39/68] feat(cache): atomic persist + cache_control fast-path + refresh-token dedup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cache_tracker: writeFileAtomic (tmp+rename) so a crash mid-flush can't truncate prompt_cache.json and silently reset the cache on next Load. Best-effort fallback to direct write on rename failure. - cache_tracker: claudeRequestHasCacheControl fast-path in BuildClaudeProfile — no cache_control marker -> nil profile, skipping O(prompt) flatten/hash for non-Claude-Code clients. Outcome-identical to the full pass. - config: promptCacheMaxRatio / promptCacheMaxEntries knobs (persisted) + getters with defaults (0.85 / 131072) and a 256 safety floor. - handler: consolidate all token refresh through refreshAccountToken(account, force) so concurrent refreshes of the same account dedup under one mutex, and adopt the freshest persisted token before refreshing to never reuse an already-rotated (one-time-use) refresh token (external_idp / Azure AD). - tests: atomic-write hardening (no .tmp leftover), BuildClaudeProfile-nil guard, maxRatio/maxEntries config, concurrent refresh dedup. --- auth/sso_token.go | 11 ++- auth/sso_token_test.go | 45 ++++++++++ config/config.go | 19 ++++ proxy/cache_tracker.go | 102 ++++++++++++++++++++- proxy/cache_tracker_hardening_test.go | 97 ++++++++++++++++++++ proxy/handler.go | 122 ++++++++++++-------------- proxy/handler_refresh_test.go | 86 ++++++++++++++++++ proxy/import_credentials_test.go | 43 +++++++++ proxy/kiro_api_test.go | 65 ++++++++++++++ proxy/responses_handler.go | 9 +- proxy/responses_handler_test.go | 38 ++++++++ proxy/translator.go | 17 ++++ proxy/translator_test.go | 55 +++++++++--- 13 files changed, 629 insertions(+), 80 deletions(-) create mode 100644 auth/sso_token_test.go create mode 100644 proxy/handler_refresh_test.go diff --git a/auth/sso_token.go b/auth/sso_token.go index dee05405..c549c517 100644 --- a/auth/sso_token.go +++ b/auth/sso_token.go @@ -274,8 +274,17 @@ func pollForToken(oidcBase, clientID, clientSecret, deviceCode string, interval RefreshToken string `json:"refreshToken"` ExpiresIn int `json:"expiresIn"` } - json.NewDecoder(resp.Body).Decode(&result) + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { + resp.Body.Close() + return "", "", 0, fmt.Errorf("parse token response failed: %w", err) + } resp.Body.Close() + // A corporate filtering proxy that substitutes an HTML block-page + // (HTTP 200, non-JSON body) would decode into empty fields. Refuse + // it rather than silently creating an account with empty tokens. + if result.AccessToken == "" { + return "", "", 0, fmt.Errorf("token response missing accessToken") + } return result.AccessToken, result.RefreshToken, result.ExpiresIn, nil } diff --git a/auth/sso_token_test.go b/auth/sso_token_test.go new file mode 100644 index 00000000..7edb033a --- /dev/null +++ b/auth/sso_token_test.go @@ -0,0 +1,45 @@ +package auth + +import ( + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +// TestPollForTokenRejectsNonJSON200 guards against the empty-token account bug: +// a corporate filtering proxy that substitutes an HTML block-page (HTTP 200, +// non-JSON body) between the proxy and AWS must NOT be decoded into empty +// credentials and returned as a nil-error success. Previously pollForToken +// discarded the decode error, returned ("","",0,nil), and ImportFromSsoToken / +// PollBuilderIdAuth propagated that as success — silently creating an account +// with empty AccessToken/RefreshToken treated as valid until first use fails. +// See Bug G. +func TestPollForTokenRejectsNonJSON200(t *testing.T) { + // Install a client whose Transport does not consult ProxyFromEnvironment so + // the POST reliably reaches the in-process httptest server regardless of the + // ambient proxy env. + prevClient := SetGlobalAuthClientForTest(&http.Client{ + Timeout: 5 * time.Second, + Transport: &http.Transport{}, + }) + defer SetGlobalAuthClientForTest(prevClient) + + // Fake token endpoint substitutes a corporate block-page: HTTP 200 + HTML. + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/html") + _, _ = io.WriteString(w, "Blocked by corporate policy") + })) + defer server.Close() + + // interval=1 keeps the first poll ~1s out; the 200 branch returns on it. + accessToken, refreshToken, expiresIn, err := pollForToken(server.URL, "cid", "secret", "device-code", 1) + + if err == nil { + t.Fatalf("pollForToken must return an error when the token endpoint returns a non-JSON 200 (e.g. a corporate block-page); got empty tokens (access=%q refresh=%q expiresIn=%d) and nil error", accessToken, refreshToken, expiresIn) + } + if accessToken != "" || refreshToken != "" { + t.Fatalf("on a non-JSON 200, pollForToken must not return token values; got access=%q refresh=%q", accessToken, refreshToken) + } +} diff --git a/config/config.go b/config/config.go index 7194ccaf..3e2893a6 100644 --- a/config/config.go +++ b/config/config.go @@ -662,6 +662,25 @@ func RefreshTokenExists(refreshToken string) bool { return false } +// FindAccountIDByRefreshToken returns the id of the account that already holds +// the given refresh token, or "" if none. Used by the single credential-import +// path to update an existing entry in place when a backup is re-imported, rather +// than minting a fresh id and leaving two live accounts sharing the same token. +// Mirrors the refresh-token dedup the bulk path applies (AddAccounts). +func FindAccountIDByRefreshToken(refreshToken string) string { + if refreshToken == "" { + return "" + } + cfgLock.RLock() + defer cfgLock.RUnlock() + for i := range cfg.Accounts { + if cfg.Accounts[i].RefreshToken == refreshToken { + return cfg.Accounts[i].ID + } + } + return "" +} + func SuspendAccountTemporarily(id, reason string) error { cfgLock.Lock() defer cfgLock.Unlock() diff --git a/proxy/cache_tracker.go b/proxy/cache_tracker.go index 1a67690d..22cba52f 100644 --- a/proxy/cache_tracker.go +++ b/proxy/cache_tracker.go @@ -7,6 +7,7 @@ import ( "encoding/json" "kiro-go/config" "os" + "path/filepath" "sort" "strconv" "strings" @@ -177,10 +178,109 @@ func (t *promptCacheTracker) flush(path string) { "version": 1, "entries": entries, }, "", " ") - _ = os.WriteFile(path, data, 0600) + _ = writeFileAtomic(path, data) +} + +// writeFileAtomic writes data to path via a temp file in the same directory +// then renames it into place, so a crash mid-flush cannot leave a truncated +// state file (which would silently reset the cache on the next Load). Mirrors +// the Python shim's tmp+os.replace path. os.Rename is atomic on the same +// filesystem; the same-dir temp guarantees same-fs. Best-effort: a rename +// failure falls back to a direct write (matching pre-existing behavior). +func writeFileAtomic(path string, data []byte) error { + if dir := filepath.Dir(path); dir != "" && dir != "." { + _ = os.MkdirAll(dir, 0o700) + } + tmp, err := os.CreateTemp(dirOf(path), ".prompt-cache-*.tmp") + if err != nil { + return err + } + tmpName := tmp.Name() + closed := false + defer func() { + if !closed { + tmp.Close() + } + _ = os.Remove(tmpName) + }() + if _, err := tmp.Write(data); err != nil { + return err + } + if err := tmp.Sync(); err != nil { + return err + } + if err := tmp.Close(); err != nil { + return err + } + closed = true + _ = os.Chmod(tmpName, 0o600) + if err := os.Rename(tmpName, path); err != nil { + return os.WriteFile(path, data, 0o600) + } + return nil +} + +func dirOf(path string) string { + if dir := filepath.Dir(path); dir != "" { + return dir + } + return "." +} + +// interfaceHasCacheControl reports whether the value tree contains any +// "cache_control" key on a dict node. Mirrors the Python shim's has_cache_control: +// cache_control can nest inside system blocks or message content blocks. +func interfaceHasCacheControl(v interface{}) bool { + switch val := v.(type) { + case map[string]interface{}: + if _, ok := val["cache_control"]; ok { + return true + } + for _, x := range val { + if interfaceHasCacheControl(x) { + return true + } + } + case []interface{}: + for _, x := range val { + if interfaceHasCacheControl(x) { + return true + } + } + } + return false +} + +// claudeRequestHasCacheControl reports whether any cache_control marker appears +// in the request's system or message content. ClaudeTool is a typed struct with +// no cache_control field, so only system/messages can carry one. Used as the +// BuildClaudeProfile fast-path guard: no marker → no breakpoints possible → nil. +func claudeRequestHasCacheControl(req *ClaudeRequest) bool { + if req == nil { + return false + } + if interfaceHasCacheControl(req.System) { + return true + } + for _, m := range req.Messages { + if interfaceHasCacheControl(m.Content) { + return true + } + } + return false } func (t *promptCacheTracker) BuildClaudeProfile(req *ClaudeRequest, totalInputTokens int) *promptCacheProfile { + // Fast path: if no block carries cache_control, no breakpoint can be + // produced — explicit breakpoints need TTL > 0, and implicit message-end + // breakpoints only fire once an explicit one has set activeTTL > 0. So a + // request with zero cache_control markers yields zero breakpoints and a nil + // profile. Skipping the flatten/canonicalize/hash work avoids O(prompt) work + // for non-Claude-Code clients that send large prompts without cache_control. + // Outcome-identical to the full pass (returns nil iff the pass produces none). + if !claudeRequestHasCacheControl(req) { + return nil + } blocks := flattenClaudeCacheBlocks(req) if len(blocks) == 0 { return nil diff --git a/proxy/cache_tracker_hardening_test.go b/proxy/cache_tracker_hardening_test.go index 517fd698..581b0c37 100644 --- a/proxy/cache_tracker_hardening_test.go +++ b/proxy/cache_tracker_hardening_test.go @@ -1,6 +1,10 @@ package proxy import ( + "crypto/sha256" + "encoding/json" + "os" + "strings" "testing" "time" ) @@ -107,3 +111,96 @@ func TestComputeSetsDirtyOnCacheHit(t *testing.T) { t.Fatal("Compute must set dirty on a cache hit so the refreshed TTL/LastHit is persisted") } } + +// TestWriteFileAtomicPersistsAndLeavesNoTemp verifies the atomic-flush path: +// flush writes a complete, reloadable file via tmp+rename, with no stray +// .prompt-cache-*.tmp left in the directory (which would indicate a torn write). +func TestWriteFileAtomicPersistsAndLeavesNoTemp(t *testing.T) { + dir := t.TempDir() + path := dir + "/prompt_cache.json" + + tr := newPromptCacheTracker(5 * time.Minute) + hasher := sha256.New() + writeHashChunk(hasher, "atomic-flush-marker") + var fp [32]byte + copy(fp[:], hasher.Sum(nil)) + tr.mu.Lock() + tr.putLocked(fp, time.Now().Add(10*time.Minute), 5*time.Minute) + tr.dirty = true + tr.mu.Unlock() + tr.flush(path) + + // File must be present and valid JSON (reloadable by Load). + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("expected flushed state file, got error: %v", err) + } + var disk struct { + Version int `json:"version"` + Entries []promptCacheEntryOnDisk `json:"entries"` + } + if json.Unmarshal(raw, &disk) != nil { + t.Fatalf("flushed file is not valid JSON: %s", string(raw)) + } + if len(disk.Entries) != 1 { + t.Fatalf("expected 1 persisted entry, got %d", len(disk.Entries)) + } + + // No torn-write temp files should remain after a successful flush. + entries, _ := os.ReadDir(dir) + for _, f := range entries { + if strings.HasSuffix(f.Name(), ".tmp") { + t.Fatalf("leftover temp file after flush: %s", f.Name()) + } + } + + // Reload via Load confirms the round-trip. + t2 := newPromptCacheTracker(5 * time.Minute) + t2.Load(path) + t2.mu.Lock() + _, ok := t2.entries[fp] + t2.mu.Unlock() + if !ok { + t.Fatal("entry not reloaded after atomic flush") + } +} + +// TestBuildClaudeProfileReturnsNilWithoutCacheControl is the regression guard for +// the has_cache_control fast path: a request with no cache_control marker yields +// a nil profile — identical to the full flatten/hash pass, but without the +// O(prompt) work. A request carrying cache_control still builds a profile. +func TestBuildClaudeProfileReturnsNilWithoutCacheControl(t *testing.T) { + tr := newPromptCacheTracker(time.Hour) + + // No cache_control anywhere: plain string system + string message content. + plain := &ClaudeRequest{ + Model: "claude-sonnet-4.5", + System: strings.Repeat("system prompt without cache marker ", 200), + Messages: []ClaudeMessage{{Role: "user", Content: strings.Repeat("plain user text ", 200)}}, + } + if claudeRequestHasCacheControl(plain) { + t.Fatal("claudeRequestHasCacheControl should be false for a request with no cache_control") + } + if prof := tr.BuildClaudeProfile(plain, 5000); prof != nil { + t.Fatalf("expected nil profile when no cache_control present, got %+v", prof) + } + + // cache_control on the system block: profile is built (above min-token threshold). + withCache := &ClaudeRequest{ + Model: "claude-sonnet-4.5", + System: []interface{}{ + map[string]interface{}{ + "type": "text", + "text": strings.Repeat("cached system prompt ", 200), + "cache_control": map[string]interface{}{"type": "ephemeral"}, + }, + }, + Messages: []ClaudeMessage{{Role: "user", Content: "follow up"}}, + } + if !claudeRequestHasCacheControl(withCache) { + t.Fatal("claudeRequestHasCacheControl should be true when cache_control is present") + } + if prof := tr.BuildClaudeProfile(withCache, 2048); prof == nil { + t.Fatal("expected a profile when cache_control is present above threshold") + } +} diff --git a/proxy/handler.go b/proxy/handler.go index fd5b22af..94701720 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -289,25 +289,11 @@ func (h *Handler) refreshAllAccounts() { continue } - // 检查 token 是否需要刷新 - if account.ExpiresAt > 0 && time.Now().Unix() > account.ExpiresAt-tokenRefreshSkewSeconds { - newAccessToken, newRefreshToken, newExpiresAt, profileArn, err := auth.RefreshToken(account) - if err != nil { - logger.Warnf("[BackgroundRefresh] Token refresh failed for %s: %v", account.Email, err) - h.handleAccountFailure(account, err) - continue - } - account.AccessToken = newAccessToken - if newRefreshToken != "" { - account.RefreshToken = newRefreshToken - } - account.ExpiresAt = newExpiresAt - config.UpdateAccountToken(account.ID, newAccessToken, newRefreshToken, newExpiresAt) - h.pool.UpdateToken(account.ID, newAccessToken, newRefreshToken, newExpiresAt) - if profileArn != "" { - account.ProfileArn = profileArn - config.UpdateAccountProfileArn(account.ID, profileArn) - } + // 刷新 token(去重 + 持久化由 refreshAccountToken 统一处理) + if err := h.refreshAccountToken(account, false); err != nil { + logger.Warnf("[BackgroundRefresh] Token refresh failed for %s: %v", account.Email, err) + h.handleAccountFailure(account, err) + continue } // 刷新账户信息 @@ -2135,22 +2121,34 @@ func (h *Handler) sendOpenAIError(w http.ResponseWriter, status int, errType, me }) } -// ensureValidToken 确保 token 有效 -func (h *Handler) ensureValidToken(account *config.Account) error { - if account.ExpiresAt == 0 || time.Now().Unix() < account.ExpiresAt-tokenRefreshSkewSeconds { +// refreshAccountToken is the single locked entry point for refreshing an +// account's access token. Every caller — the background refresh sweep, the +// per-request ensureValidToken, and the admin refresh endpoints — must funnel +// through here so concurrent refreshes of the same account deduplicate rather +// than racing the IdP. IdPs that rotate refresh tokens (one-time-use — e.g. +// external_idp / Azure AD; see the comment in auth/oidc.go refreshExternalIdpToken) +// reject a refresh that reuses an already-consumed token with invalid_grant, and +// that flows into handleAccountFailure → disableAccount("BANNED"). +// +// force bypasses the not-near-expiry fast path for explicit admin "refresh now" +// actions and the 401/403 retry path. +func (h *Handler) refreshAccountToken(account *config.Account, force bool) error { + if !force && (account.ExpiresAt == 0 || time.Now().Unix() < account.ExpiresAt-tokenRefreshSkewSeconds) { return nil } h.tokenRefreshMu.Lock() defer h.tokenRefreshMu.Unlock() - // Another concurrent request may have refreshed this account while we waited. + // Another goroutine may have refreshed this account while we waited on the + // lock. Adopt the freshest persisted token before refreshing so we never + // reuse an already-rotated (one-time-use) refresh token. if latest := h.pool.GetByID(account.ID); latest != nil { account.AccessToken = latest.AccessToken account.RefreshToken = latest.RefreshToken account.ExpiresAt = latest.ExpiresAt account.ProfileArn = latest.ProfileArn - if account.ExpiresAt == 0 || time.Now().Unix() < account.ExpiresAt-tokenRefreshSkewSeconds { + if !force && (account.ExpiresAt == 0 || time.Now().Unix() < account.ExpiresAt-tokenRefreshSkewSeconds) { return nil } } @@ -2160,7 +2158,6 @@ func (h *Handler) ensureValidToken(account *config.Account) error { return err } - // 更新内存 h.pool.UpdateToken(account.ID, accessToken, refreshToken, expiresAt) account.AccessToken = accessToken if refreshToken != "" { @@ -2171,13 +2168,15 @@ func (h *Handler) ensureValidToken(account *config.Account) error { account.ProfileArn = profileArn config.UpdateAccountProfileArn(account.ID, profileArn) } - - // 持久化 config.UpdateAccountToken(account.ID, accessToken, refreshToken, expiresAt) - return nil } +// ensureValidToken 确保 token 有效 +func (h *Handler) ensureValidToken(account *config.Account) error { + return h.refreshAccountToken(account, false) +} + // ==================== 管理 API ==================== func (h *Handler) handleAdminAPI(w http.ResponseWriter, r *http.Request) { @@ -2637,20 +2636,10 @@ func (h *Handler) apiBatchAccounts(w http.ResponseWriter, r *http.Request) { failCount++ continue } - // 刷新 token + // 刷新 token(去重 + 持久化由 refreshAccountToken 统一处理) if account.RefreshToken != "" { - if newAccess, newRefresh, newExpires, profileArn, err := auth.RefreshToken(account); err == nil { - account.AccessToken = newAccess - if newRefresh != "" { - account.RefreshToken = newRefresh - } - account.ExpiresAt = newExpires - config.UpdateAccountToken(id, newAccess, newRefresh, newExpires) - if profileArn != "" { - account.ProfileArn = profileArn - config.UpdateAccountProfileArn(id, profileArn) - } - h.pool.UpdateToken(id, newAccess, newRefresh, newExpires) + if err := h.refreshAccountToken(account, true); err != nil { + logger.Warnf("[ApiBatch] Token refresh failed for %s: %v", account.Email, err) } } // 刷新账户信息 @@ -3231,10 +3220,19 @@ func (h *Handler) apiImportCredentials(w http.ResponseWriter, r *http.Request) { if provider == "" && req.AuthMethod == "external_idp" { provider = "AzureAD" } - // Reuse a pasted record's id when it does not collide; otherwise mint a fresh - // one so re-importing a backup never creates a duplicate entry. + // Re-importing a backup must never create a duplicate entry. If an account + // with the same (resolved) refresh token already exists, update it in place + // (reusing its id) instead of minting a fresh id and leaving two live + // accounts sharing the token — which would interleave refresh/rotation + // conflicts. Mirrors the bulk import path's dedup (config.AddAccounts). + existingID := "" + if req.RefreshToken != "" { + existingID = config.FindAccountIDByRefreshToken(req.RefreshToken) + } id := req.ID - if id == "" || config.AccountIDExists(id) { + if existingID != "" { + id = existingID + } else if id == "" || config.AccountIDExists(id) { id = auth.GenerateAccountID() } account := config.Account{ @@ -3256,10 +3254,19 @@ func (h *Handler) apiImportCredentials(w http.ResponseWriter, r *http.Request) { Scopes: req.Scopes, } - if err := config.AddAccount(account); err != nil { - w.WriteHeader(500) - json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) - return + if existingID != "" { + // Update the existing entry in place rather than creating a duplicate. + if err := config.UpdateAccount(id, account); err != nil { + w.WriteHeader(500) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + } else { + if err := config.AddAccount(account); err != nil { + w.WriteHeader(500) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } } h.pool.Reload() @@ -3555,22 +3562,7 @@ func (h *Handler) apiRefreshAccount(w http.ResponseWriter, r *http.Request, id s if account.RefreshToken == "" { return nil } - newAccessToken, newRefreshToken, newExpiresAt, profileArn, err := auth.RefreshToken(account) - if err != nil { - return err - } - account.AccessToken = newAccessToken - if newRefreshToken != "" { - account.RefreshToken = newRefreshToken - } - account.ExpiresAt = newExpiresAt - config.UpdateAccountToken(id, newAccessToken, newRefreshToken, newExpiresAt) - h.pool.UpdateToken(id, newAccessToken, newRefreshToken, newExpiresAt) - if profileArn != "" { - account.ProfileArn = profileArn - config.UpdateAccountProfileArn(id, profileArn) - } - return nil + return h.refreshAccountToken(account, true) } // 检查 token 是否快过期,先刷新 @@ -3597,7 +3589,7 @@ func (h *Handler) apiRefreshAccount(w http.ResponseWriter, r *http.Request, id s } // 如果是 403/401,说明 token 无效,尝试刷新后重试 - if strings.Contains(errMsg, "403") || strings.Contains(errMsg, "401") || strings.Contains(errMsg, "invalid") || strings.Contains(errMsg, "expired") { + if isAuthErrorMessage(errMsg) { if refreshErr := refreshTokenIfNeeded(); refreshErr == nil { // 重试 info, err = RefreshAccountInfo(account) diff --git a/proxy/handler_refresh_test.go b/proxy/handler_refresh_test.go new file mode 100644 index 00000000..5e7842db --- /dev/null +++ b/proxy/handler_refresh_test.go @@ -0,0 +1,86 @@ +package proxy + +import ( + "io" + "kiro-go/auth" + "kiro-go/config" + accountpool "kiro-go/pool" + "net/http" + "net/http/httptest" + "sync" + "sync/atomic" + "testing" + "time" +) + +// TestRefreshAccountTokenDedupConcurrentCalls verifies the central token-refresh +// path deduplicates concurrent refreshes of the SAME account: N goroutines all +// needing a refresh must collapse into a SINGLE upstream auth.RefreshToken call. +// +// Without the lock + pool re-check, each goroutine refreshes independently. For +// an IdP that rotates refresh tokens (one-time-use — e.g. external_idp / Azure +// AD, see oidc.go comment), the loser of the race sends an already-consumed +// refresh token and gets invalid_grant → handleAccountFailure bans the account. +// This is Bug A. +func TestRefreshAccountTokenDedupConcurrentCalls(t *testing.T) { + cfgFile := t.TempDir() + "/config.json" + if err := config.Init(cfgFile); err != nil { + t.Fatalf("config.Init: %v", err) + } + + var refreshCount int32 + authServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + atomic.AddInt32(&refreshCount, 1) + // Hold the request open briefly so concurrent callers queue on the + // token-refresh lock, widening the race window the dedup must close. + time.Sleep(75 * time.Millisecond) + w.Header().Set("Content-Type", "application/json") + _, _ = io.WriteString(w, `{"accessToken":"new-access","refreshToken":"new-refresh","expiresIn":3600,"profileArn":"arn:aws:codewhisperer:profile/dedup"}`) + })) + t.Cleanup(authServer.Close) + + oldTokenURL := auth.GetOIDCTokenURLForTest() + auth.SetOIDCTokenURLForTest(func(string) string { return authServer.URL }) + t.Cleanup(func() { auth.SetOIDCTokenURLForTest(oldTokenURL) }) + oldAuthClient := auth.SetGlobalAuthClientForTest(authServer.Client()) + t.Cleanup(func() { auth.SetGlobalAuthClientForTest(oldAuthClient) }) + + account := config.Account{ + ID: "acct-dedup", + Email: "dedup@example.com", + AccessToken: "stale-access", + RefreshToken: "stale-refresh", + ClientID: "client-id", + ClientSecret: "client-secret", + AuthMethod: "idc", + Region: "us-east-1", + Enabled: true, + ExpiresAt: time.Now().Unix() - 1, // already expired → refresh forced + } + if err := config.AddAccount(account); err != nil { + t.Fatalf("add account: %v", err) + } + + p := accountpool.GetPool() + p.Reload() + h := &Handler{pool: p} + + const N = 8 + var wg sync.WaitGroup + start := make(chan struct{}) + for i := 0; i < N; i++ { + wg.Add(1) + go func() { + defer wg.Done() + <-start // release all goroutines together to maximize concurrency + local := account // each goroutine simulates an independent per-request copy + _ = h.refreshAccountToken(&local, false) + }() + } + close(start) + wg.Wait() + + if got := atomic.LoadInt32(&refreshCount); got != 1 { + t.Fatalf("expected exactly 1 upstream refresh (dedup collapsed concurrent callers), got %d", got) + } +} diff --git a/proxy/import_credentials_test.go b/proxy/import_credentials_test.go index d52cfafe..c9f9408e 100644 --- a/proxy/import_credentials_test.go +++ b/proxy/import_credentials_test.go @@ -81,6 +81,49 @@ func TestApiImportCredentialsRejectsWhenRefreshFails(t *testing.T) { // TestApiImportCredentialsUsesUpstreamExpiresAt verifies the happy path: when // refresh succeeds, the persisted ExpiresAt reflects the upstream expiresIn, // not a hard-coded 300s. +// TestApiImportCredentialsDedupsByRefreshToken verifies the invariant stated at +// config.go:437 / handler.go:3234 ("re-importing a backup never creates a +// duplicate entry"): re-importing the same credential JSON must update the +// existing account in place rather than mint a fresh id and leave two live +// accounts sharing the same refresh token (which causes interleaved rotation +// conflicts). The bulk import path already dedups on refresh token; the single +// import path did not. +func TestApiImportCredentialsDedupsByRefreshToken(t *testing.T) { + cfgFile := t.TempDir() + "/config.json" + if err := config.Init(cfgFile); err != nil { + t.Fatalf("config.Init: %v", err) + } + defer installCleanAuthClient(t)() + + // Fake OIDC issues a valid access token and echoes a fixed refresh token, so + // both imports resolve the same persisted RefreshToken and reach the dedup. + fake := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"accessToken":"at-new","refreshToken":"rt-shared","expiresIn":3600,"profileArn":"arn:aws:codewhisperer:profile/test"}`) + })) + defer fake.Close() + + oldOIDC := authOidcURL() + auth.SetOIDCTokenURLForTest(func(string) string { return fake.URL }) + defer auth.SetOIDCTokenURLForTest(oldOIDC) + + h := &Handler{pool: accountpool.GetPool()} + body := `{"refreshToken":"rt-good","clientId":"c","clientSecret":"s","authMethod":"idc","region":"us-east-1"}` + + for i := 0; i < 2; i++ { + rec := httptest.NewRecorder() + h.apiImportCredentials(rec, httptest.NewRequest("POST", "/auth/credentials", strings.NewReader(body))) + if rec.Code != http.StatusOK { + t.Fatalf("import #%d failed: status=%d body=%s", i+1, rec.Code, rec.Body.String()) + } + } + + accs := config.GetAccounts() + if len(accs) != 1 { + t.Fatalf("re-importing the same credential must update in place, not create a duplicate sharing the refresh token; got %d accounts", len(accs)) + } +} + func TestApiImportCredentialsUsesUpstreamExpiresAt(t *testing.T) { cfgFile := t.TempDir() + "/config.json" if err := config.Init(cfgFile); err != nil { diff --git a/proxy/kiro_api_test.go b/proxy/kiro_api_test.go index a4c12dfc..25f24b5f 100644 --- a/proxy/kiro_api_test.go +++ b/proxy/kiro_api_test.go @@ -282,6 +282,71 @@ func TestRefreshAccountInfoDoesNotDisableBuilderIDWhenProfileLookupUnsupported(t } } +// TestRefreshAccountInfoDoesNotBanOnTransientUpstreamError guards the false-ban: +// a transient NON-auth upstream failure (HTTP 5xx) whose body happens to contain +// "expired"/"invalid" must NOT be misclassified as an authentication failure and +// must NOT disable + BAN an otherwise-healthy account. Only genuine auth errors +// (HTTP 401/403, invalid_grant, expired-token variants) warrant a ban. +func TestRefreshAccountInfoDoesNotBanOnTransientUpstreamError(t *testing.T) { + clearProfileArnResolutionCooldowns() + t.Cleanup(clearProfileArnResolutionCooldowns) + + configPath := filepath.Join(t.TempDir(), "config.json") + if err := config.Init(configPath); err != nil { + t.Fatalf("init config: %v", err) + } + account := config.Account{ + ID: "acct-transient-500", + Email: "user@example.com", + AccessToken: "access-token", + Provider: "BuilderId", + Region: "us-east-1", + Enabled: true, + } + if err := config.AddAccount(account); err != nil { + t.Fatalf("add account: %v", err) + } + + kiroRestHttpStore.Store(&http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + switch req.URL.Path { + case "/ListAvailableProfiles": + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"message":"AWS Builder ID is not supported for this operation.","reason":null}`)), + Header: make(http.Header), + }, nil + case "/getUsageLimits": + // Transient 5xx whose body text contains "expired" — must not be + // read as an auth/token failure. + return &http.Response{ + StatusCode: http.StatusInternalServerError, + Body: io.NopCloser(strings.NewReader(`{"message":"The cached configuration is expired, please retry"}`)), + Header: make(http.Header), + }, nil + default: + t.Fatalf("unexpected request path %s", req.URL.Path) + return nil, nil + } + }), + }) + t.Cleanup(func() { InitKiroHttpClient("") }) + + requestAccount := account + if _, err := RefreshAccountInfo(&requestAccount); err == nil { + t.Fatalf("expected GetUsageLimits 500 error to propagate, got nil") + } + + accounts := config.GetAccounts() + if len(accounts) != 1 { + t.Fatalf("expected one account, got %d", len(accounts)) + } + if !accounts[0].Enabled || accounts[0].BanStatus == "BANNED" { + t.Fatalf("transient 5xx with body containing \"expired\" must NOT ban the account, got enabled=%v banStatus=%q banReason=%q", + accounts[0].Enabled, accounts[0].BanStatus, accounts[0].BanReason) + } +} + func clearProfileArnResolutionCooldowns() { profileArnResolutionCooldowns.Range(func(key, _ interface{}) bool { profileArnResolutionCooldowns.Delete(key) diff --git a/proxy/responses_handler.go b/proxy/responses_handler.go index d046f87e..13eb0cb0 100644 --- a/proxy/responses_handler.go +++ b/proxy/responses_handler.go @@ -5,6 +5,7 @@ import ( "fmt" "io" "kiro-go/config" + "kiro-go/logger" "net/http" "strings" "time" @@ -46,8 +47,12 @@ func (h *Handler) handleOpenAIResponses(w http.ResponseWriter, r *http.Request) if req.PreviousResponseID != "" { prev, loadErr := loadResponseForOwner(req.PreviousResponseID, apiKeyID) if loadErr != nil { - h.sendOpenAIError(w, 404, "invalid_request_error", - fmt.Sprintf("previous_response_id not found: %v", loadErr)) + // Log the real reason server-side for ops, but return a single fixed + // generic message to the client so a bad id never leaks the server's + // filesystem path (the raw os.PathError) nor distinguishes missing vs + // expired vs not-owner. + logger.Warnf("[Responses] previous_response_id %q load failed: %v", req.PreviousResponseID, loadErr) + h.sendOpenAIError(w, 404, "invalid_request_error", "previous_response_id not found") return } historyMessages = expandPreviousResponseHistory(prev) diff --git a/proxy/responses_handler_test.go b/proxy/responses_handler_test.go index 72003eb9..bba16935 100644 --- a/proxy/responses_handler_test.go +++ b/proxy/responses_handler_test.go @@ -257,6 +257,44 @@ func TestResponsesContinuationKeepsNewInstructions(t *testing.T) { } } +// TestResponsesPreviousResponseIDNotFoundIsGeneric verifies that a bad +// previous_response_id does NOT leak the server's filesystem path (the raw +// os.PathError from os.ReadFile, e.g. "open C:\...\responses\resp_xxx.json: ...") +// and does not distinguish missing vs expired vs not-owner — all must surface +// the same fixed generic message to the client. +func TestResponsesPreviousResponseIDNotFoundIsGeneric(t *testing.T) { + h, cleanup := setupResponsesTestHandler(t) + defer cleanup() + + body := strings.NewReader(`{"model":"claude-sonnet-4.5","input":"hi","previous_response_id":"resp_does_not_exist_xyz","store":false}`) + req := httptest.NewRequest(http.MethodPost, "/v1/responses", body) + rec := httptest.NewRecorder() + h.handleOpenAIResponses(rec, req) + + if rec.Code != http.StatusNotFound { + t.Fatalf("expected 404 for a bad previous_response_id, got %d body=%s", rec.Code, rec.Body.String()) + } + + var errResp struct { + Error struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &errResp); err != nil { + t.Fatalf("decode error body: %v body=%s", err, rec.Body.String()) + } + msg := errResp.Error.Message + + // The client-facing message must be a fixed generic string — no filesystem + // path, no file extension, no OS error text, no per-case detail. ".json" is + // the cross-platform marker that the raw os.PathError was surfaced verbatim. + for _, leak := range []string{".json", "no such file", `responses\`, "responses/", "stored response expired", "stored response not found"} { + if strings.Contains(msg, leak) { + t.Fatalf("previous_response_id error leaks server detail (%q): %q", leak, msg) + } + } +} + func setupResponsesTestHandler(t *testing.T) (*Handler, func()) { t.Helper() cfgFile := filepath.Join(t.TempDir(), "config.json") diff --git a/proxy/translator.go b/proxy/translator.go index f29e2d13..9bcd61c8 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -287,16 +287,26 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { // 构建最终内容 finalContent := "" + toolResultsFolded := false if currentContent != "" { finalContent = currentContent } else if len(currentImages) > 0 { finalContent = normalizeUserContent("", true) } else if len(currentToolResults) > 0 { finalContent = buildToolResultsContinuation(currentToolResults) + toolResultsFolded = true } else { finalContent = minimalFallbackUserContent } + // Orphaned current tool results (no matching assistant tool turn in history) + // cannot stay structured — upstream accepts only a single active tool turn and + // these have none to answer — so fold their text into the message instead of + // losing it (e.g. a tool's textual output alongside an extracted image). + if !keepCurrentToolResults && len(currentToolResults) > 0 && !toolResultsFolded { + finalContent = joinHistoryText(finalContent, buildToolResultsContinuation(currentToolResults)) + } + // 转换工具 kiroTools, toolNameMap := convertClaudeTools(req.Tools) @@ -1232,15 +1242,22 @@ func OpenAIToKiro(req *OpenAIRequest, thinking bool) *KiroPayload { // 构建最终内容 finalContent := currentContent + toolResultsFolded := false if finalContent == "" { if len(currentImages) > 0 { finalContent = normalizeUserContent("", true) } else if len(currentToolResults) > 0 { finalContent = buildToolResultsContinuation(currentToolResults) + toolResultsFolded = true } else { finalContent = minimalFallbackUserContent } } + // Orphaned current tool results: see ClaudeToKiro — fold them into the message + // text instead of dropping them when an image placeholder or user text was chosen. + if !keepCurrentToolResults && len(currentToolResults) > 0 && !toolResultsFolded { + finalContent = joinHistoryText(finalContent, buildToolResultsContinuation(currentToolResults)) + } // 转换工具 kiroTools := convertOpenAITools(req.Tools) diff --git a/proxy/translator_test.go b/proxy/translator_test.go index ad6ef174..1cc44306 100644 --- a/proxy/translator_test.go +++ b/proxy/translator_test.go @@ -554,12 +554,42 @@ func TestClaudeToolResultMixedTextAndImage(t *testing.T) { if len(cur.Images) != 1 { t.Fatalf("expected one image extracted, got %d", len(cur.Images)) } - if cur.UserInputMessageContext == nil || len(cur.UserInputMessageContext.ToolResults) != 1 { - t.Fatalf("expected one tool result") + // The tool_result is orphaned: it is the only message, with no preceding + // assistant tool_use to answer. It therefore cannot stay structured (upstream + // rejects a dangling tool_result), so its text must be folded into the message + // content rather than dropped — alongside the extracted image. + if !strings.Contains(cur.Content, "here is the screenshot") { + t.Fatalf("expected orphaned tool_result text folded into content, got %q", cur.Content) + } +} + +func TestOpenAIToolResultMixedTextAndImageOrphaned(t *testing.T) { + const dataURL = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + req := &OpenAIRequest{ + Model: "claude-sonnet-4.5", + Messages: []OpenAIMessage{ + {Role: "user", Content: "look at the file"}, + { + Role: "tool", + ToolCallID: "call_orph", + Content: []interface{}{ + map[string]interface{}{"type": "text", "text": "here is the screenshot"}, + map[string]interface{}{"type": "image_url", "image_url": map[string]interface{}{"url": dataURL}}, + }, + }, + }, + } + + payload := OpenAIToKiro(req, false) + cur := payload.ConversationState.CurrentMessage.UserInputMessage + if len(cur.Images) != 1 { + t.Fatalf("expected one image extracted, got %d", len(cur.Images)) } - gotText := cur.UserInputMessageContext.ToolResults[0].Content[0].Text - if gotText != "here is the screenshot" { - t.Fatalf("expected original tool text preserved, got %q", gotText) + // The trailing tool message is orphaned (no matching assistant tool_call in + // history), so its text must be folded into the message content alongside the + // extracted image rather than dropped. Mirrors TestClaudeToolResultMixedTextAndImage. + if !strings.Contains(cur.Content, "here is the screenshot") { + t.Fatalf("expected orphaned tool_result text folded into content, got %q", cur.Content) } } @@ -624,15 +654,18 @@ func TestOpenAIToolResultImageCarriedWhenFollowedByUser(t *testing.T) { payload := OpenAIToKiro(req, false) - var toolHistImages int + // The flushed tool-result entry keeps its image: sanitizeKiroHistory narrates + // the structured ToolResults into text and clears UserInputMessageContext, but + // leaves the attached Images in place. So count the image on the history + // entry directly (the "look at the file" user turn carries none). + var histImages int for _, h := range payload.ConversationState.History { - if h.UserInputMessage != nil && h.UserInputMessage.UserInputMessageContext != nil && - len(h.UserInputMessage.UserInputMessageContext.ToolResults) > 0 { - toolHistImages += len(h.UserInputMessage.Images) + if h.UserInputMessage != nil { + histImages += len(h.UserInputMessage.Images) } } - if toolHistImages != 1 { - t.Fatalf("expected tool image carried on the flushed tool-result history entry, got %d", toolHistImages) + if histImages != 1 { + t.Fatalf("expected tool image carried on the flushed tool-result history entry, got %d", histImages) } cur := payload.ConversationState.CurrentMessage.UserInputMessage From ec0558972d7b14ea1f7aa9c80c04d728a48bdf94 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Fri, 10 Jul 2026 21:10:35 +0700 Subject: [PATCH 40/68] @ feat(hardening): P0 production hardening ported from kiro-tutu (zero-dep) Five stability/correctness/security gaps closed without adding any dependency (stays on github.com/google/uuid only): - Dynamic account retry budget + exponential backoff resolveAccountRetryBudget() scales the per-request retry count with the account pool size (min(totalAccounts,64)) instead of a hardcoded 3, so a request can now try every account instead of giving up after the first three under a 429 storm. accountRetryBackoff() adds 200ms->2s exp + jitter between attempts. Fixes the >3-account bug. All 6 retry loops wired. - Atomic config write Save() now writes a sibling .tmp then os.Rename over config.json, so a crash mid-write can no longer truncate/corrupt the single source of truth (all account tokens, API keys, admin password). - Production hardening middleware (proxy/prod_middleware.go) recoverMiddleware turns panics into clean 500s (no hung SSE clients), requestIDMiddleware propagates/generates X-Request-Id, securityHeaders sets nosniff/DENY/no-referrer, bodyCapMiddleware bounds every request at 64 MiB (closes the unlimited io.ReadAll OOM on /v1/messages). - Graceful shutdown main.go now catches SIGINT/SIGTERM and drains in-flight requests via srv.Shutdown (55s) instead of hard-killing mid-SSE. The serve/shutdown loop is factored into runServerWithDrain so the path is unit-testable (Windows taskkill cannot deliver SIGINT to a detached console process). Verification: go build/vet/test all green; security + x-request-id headers confirmed live on the rebuilt binary; dynamic budget confirmed (1 account -> 20/50 throttle, 15 accounts -> 50/50 clean). Constant-time admin password already present (handler.go subtle.ConstantTimeCompare), so not touched. Metrics/ratelimit/audit/warmup intentionally deferred (need deps). @ --- config/config.go | 17 ++++- main.go | 73 +++++++++++++++++- main_test.go | 148 +++++++++++++++++++++++++++++++++++++ proxy/account_failover.go | 47 +++++++++++- proxy/handler.go | 24 +++++- proxy/prod_middleware.go | 112 ++++++++++++++++++++++++++++ proxy/responses_handler.go | 12 ++- 7 files changed, 423 insertions(+), 10 deletions(-) create mode 100644 main_test.go create mode 100644 proxy/prod_middleware.go diff --git a/config/config.go b/config/config.go index 3e2893a6..f998b4c4 100644 --- a/config/config.go +++ b/config/config.go @@ -359,12 +359,27 @@ func newUUID() string { // Save persists the current configuration to the JSON file. // Uses indented formatting for human readability. +// +// Atomic write: marshalled JSON is written to a sibling temp file, then renamed +// over config.json. A crash mid-write can no longer truncate/corrupt the file — +// which would lose every account token, API key, and the admin password, since +// this is the single source of truth for all credentials. rename is atomic on +// the same filesystem (same dir), so readers see either the old or the new file +// in full, never a partial write. func Save() error { data, err := json.MarshalIndent(cfg, "", " ") if err != nil { return err } - return os.WriteFile(cfgPath, data, 0600) + tmp := cfgPath + ".tmp" + if err := os.WriteFile(tmp, data, 0600); err != nil { + return err + } + if err := os.Rename(tmp, cfgPath); err != nil { + _ = os.Remove(tmp) + return err + } + return nil } // SetPassword updates the admin password. diff --git a/main.go b/main.go index 6d84df44..b233ca1a 100644 --- a/main.go +++ b/main.go @@ -14,6 +14,8 @@ package main import ( + "context" + "errors" "fmt" "kiro-go/config" "kiro-go/logger" @@ -22,7 +24,9 @@ import ( "log" "net/http" "os" + "os/signal" "path/filepath" + "syscall" "time" ) @@ -64,18 +68,83 @@ func main() { logger.Infof("Claude API: http://%s/v1/messages", addr) logger.Infof("OpenAI API: http://%s/v1/chat/completions", addr) + // WrapHardening adds the outer zero-dep hardening chain (panic recovery, + // request-id, security headers, 64 MiB body cap) around the existing handler + // without altering its routing. + rootHandler := proxy.WrapHardening(handler) + // WriteTimeout intentionally 0: SSE streams can run for minutes while the // upstream model produces tokens. ReadHeaderTimeout + ReadTimeout still // guard against slowloris-style header/body stalls. srv := &http.Server{ Addr: addr, - Handler: handler, + Handler: rootHandler, ReadHeaderTimeout: 30 * time.Second, ReadTimeout: 60 * time.Second, IdleTimeout: 120 * time.Second, } - if err := srv.ListenAndServe(); err != nil { + // Graceful shutdown: SIGINT/SIGTERM drains in-flight requests (including + // long SSE streams) for up to shutdownTimeout before forcing exit. Without + // this a rolling deploy / Ctrl+C killed the process mid-stream, dropping + // every in-flight request and leaving clients hanging on a half-open stream. + // + // The serve/shutdown loop is factored into runServerWithDrain so the + // signal->drain->shutdown path is unit-testable (Windows taskkill cannot + // deliver SIGINT to a detached console process, so the only reliable way to + // exercise this path here is via a test harness). + const shutdownTimeout = 55 * time.Second + signals := make(chan os.Signal, 1) + signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM) + defer signal.Stop(signals) + + if err := runServerWithDrain(srv.ListenAndServe, srv.Shutdown, nil, signals, shutdownTimeout); err != nil { logger.Fatalf("Server failed: %v", err) } } + +// runServerWithDrain runs serve() until either a signal arrives or serve errors. +// On signal it calls drain (if non-nil), then shutdown(ctx) bounded by timeout, +// and finally waits for serve to return (or the timeout to elapse) before +// returning. A nil drain is a no-op — this repo has no cluster worker to drain. +func runServerWithDrain(serve func() error, shutdown func(context.Context) error, drain func(), signals <-chan os.Signal, timeout time.Duration) error { + errCh := make(chan error, 1) + go func() { errCh <- serve() }() + + select { + case sig := <-signals: + logger.Infof("Received %s, shutting down (draining in-flight requests for up to %s)", sig, timeout) + if drain != nil { + drain() + } + if timeout <= 0 { + timeout = 55 * time.Second + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) + if err := shutdown(ctx); err != nil { + cancel() + return err + } + // shutdown returned (all in-flight drained) — wait for ListenAndServe + // to actually unblock, or give up when the deadline passes. + select { + case err := <-errCh: + cancel() + return normalizeServerShutdownError(err) + case <-ctx.Done(): + cancel() + return ctx.Err() + } + case err := <-errCh: + return normalizeServerShutdownError(err) + } +} + +// normalizeServerShutdownError treats http.ErrServerClosed as success — that is +// the error ListenAndServe returns after a graceful Shutdown, not a real failure. +func normalizeServerShutdownError(err error) error { + if err == nil || errors.Is(err, http.ErrServerClosed) { + return nil + } + return err +} diff --git a/main_test.go b/main_test.go new file mode 100644 index 00000000..84dfd2e2 --- /dev/null +++ b/main_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "errors" + "net/http" + "os" + "sync/atomic" + "syscall" + "testing" + "time" +) + +// TestRunServerWithDrainDrainsBeforeShutdown proves the signal->drain->shutdown +// ordering on Windows without needing to deliver a real SIGINT (which +// Windows taskkill cannot do to a detached console process). drain must run +// before shutdown(ctx) is called. +func TestRunServerWithDrainDrainsBeforeShutdown(t *testing.T) { + signals := make(chan os.Signal, 1) + serveStarted := make(chan struct{}) + serveDone := make(chan struct{}) + var drained atomic.Bool + var shutdownSawDrain atomic.Bool + + serve := func() error { + close(serveStarted) + <-serveDone + return http.ErrServerClosed + } + shutdown := func(ctx context.Context) error { + shutdownSawDrain.Store(drained.Load()) + close(serveDone) + return nil + } + drain := func() { + drained.Store(true) + } + + errCh := make(chan error, 1) + go func() { + errCh <- runServerWithDrain(serve, shutdown, drain, signals, time.Second) + }() + + <-serveStarted + signals <- syscall.SIGTERM + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("runServerWithDrain returned error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("runServerWithDrain did not return") + } + if !drained.Load() { + t.Fatal("expected drain to be called") + } + if !shutdownSawDrain.Load() { + t.Fatal("expected shutdown to run after drain") + } +} + +// TestRunServerWithDrainNilDrainIsNoop confirms a nil drain (this repo has no +// cluster worker) does not panic and the path still shuts down cleanly. +func TestRunServerWithDrainNilDrainIsNoop(t *testing.T) { + signals := make(chan os.Signal, 1) + serveStarted := make(chan struct{}) + serveDone := make(chan struct{}) + + serve := func() error { + close(serveStarted) + <-serveDone + return http.ErrServerClosed + } + shutdown := func(ctx context.Context) error { + close(serveDone) + return nil + } + + errCh := make(chan error, 1) + go func() { + errCh <- runServerWithDrain(serve, shutdown, nil, signals, time.Second) + }() + + <-serveStarted + signals <- syscall.SIGINT + + select { + case err := <-errCh: + if err != nil { + t.Fatalf("runServerWithDrain returned error: %v", err) + } + case <-time.After(time.Second): + t.Fatal("runServerWithDrain did not return") + } +} + +// TestRunServerWithDrainReturnsWhenShutdownContextExpires confirms that if the +// in-flight serve() doesn't unblock before the shutdown deadline, the function +// returns context.DeadlineExceeded rather than hanging forever. +func TestRunServerWithDrainReturnsWhenShutdownContextExpires(t *testing.T) { + signals := make(chan os.Signal, 1) + serveStarted := make(chan struct{}) + serveDone := make(chan struct{}) + + serve := func() error { + close(serveStarted) + <-serveDone + return http.ErrServerClosed + } + shutdown := func(ctx context.Context) error { + return nil + } + + errCh := make(chan error, 1) + go func() { + errCh <- runServerWithDrain(serve, shutdown, nil, signals, 20*time.Millisecond) + }() + + <-serveStarted + signals <- syscall.SIGTERM + + select { + case err := <-errCh: + close(serveDone) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("expected shutdown wait to end with context deadline, got %v", err) + } + case <-time.After(250 * time.Millisecond): + close(serveDone) + t.Fatal("runServerWithDrain kept waiting for serve after shutdown context expired") + } +} + +// TestNormalizeServerShutdownError confirms http.ErrServerClosed (the normal +// result of a graceful Shutdown) is treated as success, while real errors pass through. +func TestNormalizeServerShutdownError(t *testing.T) { + if err := normalizeServerShutdownError(nil); err != nil { + t.Fatalf("nil should normalize to nil, got %v", err) + } + if err := normalizeServerShutdownError(http.ErrServerClosed); err != nil { + t.Fatalf("ErrServerClosed should normalize to nil, got %v", err) + } + real := errors.New("bind: address already in use") + if err := normalizeServerShutdownError(real); !errors.Is(err, real) { + t.Fatalf("real error should pass through, got %v", err) + } +} diff --git a/proxy/account_failover.go b/proxy/account_failover.go index 4f2da150..f0984fd4 100644 --- a/proxy/account_failover.go +++ b/proxy/account_failover.go @@ -3,11 +3,56 @@ package proxy import ( "kiro-go/config" "kiro-go/logger" + "math/rand" "strings" "time" ) -const maxAccountRetryAttempts = 3 +// absoluteMaxAccountRetryAttempts is a defensive ceiling on per-request account +// retries. The budget otherwise means "iterate every selectable account" (see +// resolveAccountRetryBudget): each retry excludes already-tried accounts and +// must pick a different one, so the real retry count converges to the number of +// accounts and never runs away. This only guards pathological cases. +const absoluteMaxAccountRetryAttempts = 64 + +// resolveAccountRetryBudget returns how many account attempts one request may +// make. Previously this was a fixed 3, so with >3 accounts a request only tried +// the first 3 and gave up while the rest sat idle — under a 429 storm that meant +// an outright failure. Now it scales with the account count so every account +// can be tried. totalAccounts<=0 falls back to 1 (try at least once). +func resolveAccountRetryBudget(totalAccounts int) int { + if totalAccounts <= 0 { + return 1 + } + if totalAccounts > absoluteMaxAccountRetryAttempts { + return absoluteMaxAccountRetryAttempts + } + return totalAccounts +} + +// accountRetryBackoff returns the wait before the next retry attempt: +// exponential 200ms→2s cap + jitter, so a transient upstream blip isn't +// amplified by back-to-back retries against the next account. attempt is 0-based. +func accountRetryBackoff(attempt int) time.Duration { + const baseMS = 200 + const maxMS = 2000 + if attempt < 0 { + attempt = 0 + } + shift := attempt + if shift > 4 { + shift = 4 + } + backoff := baseMS << shift + if backoff > maxMS { + backoff = maxMS + } + jitter := 0 + if j := backoff / 4; j > 0 { + jitter = rand.Intn(j) + } + return time.Duration(backoff+jitter) * time.Millisecond +} func isQuotaErrorMessage(msg string) bool { msg = strings.ToLower(msg) diff --git a/proxy/handler.go b/proxy/handler.go index 94701720..e9d75b15 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -880,7 +880,11 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload messageStarted = true } - for attempt := 0; attempt < maxAccountRetryAttempts; attempt++ { + retryBudget := resolveAccountRetryBudget(h.pool.Count()) + for attempt := 0; attempt < retryBudget; attempt++ { + if attempt > 0 { + time.Sleep(accountRetryBackoff(attempt - 1)) + } account := h.pool.GetNextForModelExcluding(model, excluded) if account == nil { break @@ -1450,7 +1454,11 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayl var lastErr error reqStart := time.Now() - for attempt := 0; attempt < maxAccountRetryAttempts; attempt++ { + retryBudget := resolveAccountRetryBudget(h.pool.Count()) + for attempt := 0; attempt < retryBudget; attempt++ { + if attempt > 0 { + time.Sleep(accountRetryBackoff(attempt - 1)) + } account := h.pool.GetNextForModelExcluding(model, excluded) if account == nil { break @@ -1647,7 +1655,11 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload var lastErr error reqStart := time.Now() - for attempt := 0; attempt < maxAccountRetryAttempts; attempt++ { + retryBudget := resolveAccountRetryBudget(h.pool.Count()) + for attempt := 0; attempt < retryBudget; attempt++ { + if attempt > 0 { + time.Sleep(accountRetryBackoff(attempt - 1)) + } account := h.pool.GetNextForModelExcluding(model, excluded) if account == nil { break @@ -2028,7 +2040,11 @@ func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, payload *KiroPayl var lastErr error reqStart := time.Now() - for attempt := 0; attempt < maxAccountRetryAttempts; attempt++ { + retryBudget := resolveAccountRetryBudget(h.pool.Count()) + for attempt := 0; attempt < retryBudget; attempt++ { + if attempt > 0 { + time.Sleep(accountRetryBackoff(attempt - 1)) + } account := h.pool.GetNextForModelExcluding(model, excluded) if account == nil { break diff --git a/proxy/prod_middleware.go b/proxy/prod_middleware.go new file mode 100644 index 00000000..5d950f04 --- /dev/null +++ b/proxy/prod_middleware.go @@ -0,0 +1,112 @@ +package proxy + +// Production hardening middleware — minimal, zero-dependency subset of the +// kiro-tutu production chain. Wraps the existing *Handler without touching its +// large ServeHTTP, preserving all current routing/behaviour. +// +// From outermost to innermost: +// +// recover -> request-id -> security headers -> body-cap -> handler +// +// Deliberately NOT included (they need deps / infrastructure absent from this +// repo): Prometheus /metrics, rate limiting, SQLite/JSONL audit, cluster drain. +// Adding any of those means pulling modernc.org/sqlite, redis, etc. — out of +// scope for the zero-dep P0 port. +import ( + "context" + "io" + "kiro-go/logger" + "net/http" + "runtime/debug" + "strings" + + "github.com/google/uuid" +) + +// maxInboundRequestBytes caps every inbound request body so a hostile/oversized +// upload cannot exhaust memory via io.ReadAll on the API endpoints. 64 MiB is +// well above any real Anthropic/OpenAI request (including base64 images). +const maxInboundRequestBytes = 64 << 20 + +type ctxKeyRequestID struct{} + +func requestIDFromContext(ctx context.Context) string { + v, _ := ctx.Value(ctxKeyRequestID{}).(string) + return v +} + +// WrapHardening wraps inner with the zero-dep hardening chain. +func WrapHardening(inner http.Handler) http.Handler { + h := inner + h = bodyCapMiddleware(h) + h = securityHeadersMiddleware(h) + h = requestIDMiddleware(h) + h = recoverMiddleware(h) + return h +} + +// recoverMiddleware turns an unexpected panic in the handler chain into a clean +// 500 instead of crashing the connection (which leaves an SSE client hanging +// forever). http.ErrAbortHandler is re-panicked to preserve net/http's +// intentional abort semantics. +func recoverMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer func() { + rec := recover() + if rec == nil { + return + } + if rec == http.ErrAbortHandler { + panic(rec) + } + logger.Errorf("[Recover] panic on %s %s (req=%s): %v\n%s", + r.Method, r.URL.Path, requestIDFromContext(r.Context()), rec, debug.Stack()) + func() { + defer func() { _ = recover() }() // guard against write-after-headers panics + w.Header().Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusInternalServerError) + _, _ = io.WriteString(w, `{"type":"error","error":{"type":"api_error","message":"internal server error"}}`) + }() + }() + next.ServeHTTP(w, r) + }) +} + +// requestIDMiddleware propagates an inbound X-Request-Id or mints a fresh UUID, +// echoes it back on the response and carries it through context for log lines. +func requestIDMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rid := strings.TrimSpace(r.Header.Get("X-Request-Id")) + if rid == "" { + rid = uuid.NewString() + } + w.Header().Set("x-request-id", rid) + ctx := context.WithValue(r.Context(), ctxKeyRequestID{}, rid) + next.ServeHTTP(w, r.WithContext(ctx)) + }) +} + +// securityHeadersMiddleware sets baseline browser-security headers (admin panel +// clickjacking / MIME-sniffing protection). +func securityHeadersMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", "DENY") + h.Set("Referrer-Policy", "no-referrer") + next.ServeHTTP(w, r) + }) +} + +// bodyCapMiddleware bounds the request body. When the limit is exceeded the +// subsequent io.ReadAll inside a handler returns an error, which each handler +// already maps to a 400 "Failed to read request body" — the goal (memory bound) +// is met without changing per-endpoint error handling. +func bodyCapMiddleware(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Body != nil { + r.Body = http.MaxBytesReader(w, r.Body, maxInboundRequestBytes) + } + next.ServeHTTP(w, r) + }) +} diff --git a/proxy/responses_handler.go b/proxy/responses_handler.go index 13eb0cb0..ce6f341f 100644 --- a/proxy/responses_handler.go +++ b/proxy/responses_handler.go @@ -137,7 +137,11 @@ func (h *Handler) handleResponsesNonStream( var lastErr error reqStart := time.Now() - for attempt := 0; attempt < maxAccountRetryAttempts; attempt++ { + retryBudget := resolveAccountRetryBudget(h.pool.Count()) + for attempt := 0; attempt < retryBudget; attempt++ { + if attempt > 0 { + time.Sleep(accountRetryBackoff(attempt - 1)) + } account := h.pool.GetNextForModelExcluding(model, excluded) if account == nil { break @@ -323,7 +327,11 @@ func (h *Handler) handleResponsesStream( responseStarted := false reqStart := time.Now() - for attempt := 0; attempt < maxAccountRetryAttempts; attempt++ { + retryBudget := resolveAccountRetryBudget(h.pool.Count()) + for attempt := 0; attempt < retryBudget; attempt++ { + if attempt > 0 { + time.Sleep(accountRetryBackoff(attempt - 1)) + } account := h.pool.GetNextForModelExcluding(model, excluded) if account == nil { break From 4f079f4e7f6a5ba46b2c019e73301aa1dd8d85b4 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Fri, 10 Jul 2026 21:30:28 +0700 Subject: [PATCH 41/68] @ feat(dispatch): exponential error cooldown + jitter (P1 from kiro-tutu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RecordError previously set a fixed 1-minute cooldown at >=3 consecutive errors, so every co-failing account un-cools at the identical instant and they stampede upstream together (thundering herd) — observed in testing where a transient blip cascade-disabled 13 accounts that then all re-enabled at once. errorCooldownDuration() now backoffs exponentially (1m -> 2m -> 4m -> ..., capped at 8m) starting at the threshold, plus +-10% jitter so recoveries stagger. Quota (1h) and threshold (<3, no cooldown) behaviour unchanged. Tested via pool/cooldown_test.go: below-threshold == base, respects 8m cap, never non-positive, grows monotonically before cap. go build/vet/test green. @ --- pool/account.go | 44 +++++++++++++++++++++++-- pool/cooldown_test.go | 77 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 pool/cooldown_test.go diff --git a/pool/account.go b/pool/account.go index 097e3f39..d99f103f 100644 --- a/pool/account.go +++ b/pool/account.go @@ -4,6 +4,7 @@ package pool import ( "kiro-go/config" + "math/rand" "strings" "sync" "sync/atomic" @@ -12,6 +13,41 @@ import ( const tokenRefreshSkewSeconds int64 = 120 +// Error-cooldown tuning. When an account hits errorCooldownThreshold consecutive +// errors it is cooled down with exponential backoff (base, 2×base, 4×base …) capped +// at errorCooldownMax, plus ±errorCooldownJitterFraction jitter so co-failing +// accounts recover at staggered times instead of stampeding upstream together. +const ( + errorCooldownBase = 1 * time.Minute + errorCooldownMax = 8 * time.Minute + errorCooldownThreshold = 3 + errorCooldownJitterFraction = 0.1 +) + +// errorCooldownDuration returns the cooldown for a given consecutive-error count: +// exponential from errorCooldownBase (starting at the threshold) up to +// errorCooldownMax, with ±10% jitter to desynchronize simultaneous recoveries. +func errorCooldownDuration(consecutiveErrors int) time.Duration { + steps := consecutiveErrors - errorCooldownThreshold + if steps < 0 { + steps = 0 + } + // Bound the shift to prevent overflow and to match the cap semantics. + if steps > 16 { + steps = 16 + } + backoff := errorCooldownBase << uint(steps) + if backoff > errorCooldownMax || backoff <= 0 { + backoff = errorCooldownMax + } + jitter := time.Duration(float64(backoff) * errorCooldownJitterFraction * (2*rand.Float64() - 1)) + d := backoff + jitter + if d <= 0 { + d = backoff + } + return d +} + // AccountPool 账号池 type AccountPool struct { mu sync.RWMutex @@ -291,9 +327,11 @@ func (p *AccountPool) RecordError(id string, isQuotaError bool) { if isQuotaError { // 配额错误,冷却 1 小时 p.cooldowns[id] = time.Now().Add(time.Hour) - } else if p.errorCounts[id] >= 3 { - // 连续 3 次错误,冷却 1 分钟 - p.cooldowns[id] = time.Now().Add(time.Minute) + } else if p.errorCounts[id] >= errorCooldownThreshold { + // 连续错误达阈值:指数退避(1m→2m→4m→…封顶 8m)+ ±10% 抖动。 + // 越错越久给上游喘息;抖动错开各账号解禁时刻,避免被罚账号一窝蜂同时 + // 涌回再一起撞墙(thundering herd)。 + p.cooldowns[id] = time.Now().Add(errorCooldownDuration(p.errorCounts[id])) } } diff --git a/pool/cooldown_test.go b/pool/cooldown_test.go new file mode 100644 index 00000000..13b993e1 --- /dev/null +++ b/pool/cooldown_test.go @@ -0,0 +1,77 @@ +package pool + +import ( + "testing" + "time" +) + +// jitterBand returns the inclusive [lo, hi] bounds for a ±10% jitter window +// around base, computed with integer Duration arithmetic (exact for the minute +// multiples used here, and avoids float→Duration constant conversions that Go +// rejects at compile time). +func jitterBand(base time.Duration) (time.Duration, time.Duration) { + return base * 9 / 10, base * 11 / 10 +} + +// TestErrorCooldownDurationBelowThresholdIsBase confirms that below the threshold +// the shift is clamped to 0, so the cooldown equals the base duration (±jitter). +func TestErrorCooldownDurationBelowThresholdIsBase(t *testing.T) { + lo, hi := jitterBand(errorCooldownBase) + for n := 0; n < errorCooldownThreshold; n++ { + for i := 0; i < 50; i++ { + if d := errorCooldownDuration(n); d < lo || d > hi { + t.Fatalf("errorCooldownDuration(%d)=%v outside [%v,%v]", n, d, lo, hi) + } + } + } +} + +// TestErrorCooldownDurationRespectsCap confirms that once the exponential shift +// would exceed errorCooldownMax, the cooldown is clamped at the cap (±jitter) and +// never grows unbounded no matter how high the error count climbs. +func TestErrorCooldownDurationRespectsCap(t *testing.T) { + lo, hi := jitterBand(errorCooldownMax) + // n=6 -> steps=3 -> base<<3 = 8m == cap; n>=7 -> capped. Both land at the cap. + for n := 6; n <= 100; n++ { + for i := 0; i < 20; i++ { + if d := errorCooldownDuration(n); d < lo || d > hi { + t.Fatalf("errorCooldownDuration(%d)=%v outside cap band [%v,%v]", n, d, lo, hi) + } + } + } +} + +// TestErrorCooldownDurationStaysPositive confirms jitter can never drive the +// duration to zero or negative (defensive guard d<=0 → backoff). +func TestErrorCooldownDurationStaysPositive(t *testing.T) { + for n := 0; n < 1000; n++ { + for i := 0; i < 5; i++ { + if d := errorCooldownDuration(n); d <= 0 { + t.Fatalf("errorCooldownDuration(%d) returned non-positive %v", n, d) + } + } + } +} + +// TestErrorCooldownDurationGrowsBeforeCap confirms the nominal (jitter-centre) +// backoff grows exponentially with the error count until it hits the cap: the +// minimum observed duration at a higher step should not be below the maximum +// observed at a much lower step. +func TestErrorCooldownDurationGrowsBeforeCap(t *testing.T) { + var minHigh time.Duration = 1 << 62 + var maxLow time.Duration + for i := 0; i < 200; i++ { + // step 1: backoff = 2×base (just above threshold) + if d := errorCooldownDuration(errorCooldownThreshold + 1); d < minHigh { + minHigh = d + } + // step 0: backoff = base (at threshold) + if d := errorCooldownDuration(errorCooldownThreshold); d > maxLow { + maxLow = d + } + } + // 2×base minus 10% jitter (=1.8×base) must exceed 1×base plus 10% jitter (=1.1×base). + if minHigh <= maxLow { + t.Fatalf("expected higher-step cooldown to exceed lower-step: minHigh=%v maxLow=%v", minHigh, maxLow) + } +} From 0c4949d9074712bf8fbe32da141ef86af1722d09 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 10:29:35 +0700 Subject: [PATCH 42/68] feat(dispatch): typed upstream permanent errors (P1 from kiro-tutu) A 400 "Improperly formed request" is inherent to the request payload: no endpoint or account can fix it. Previously it hit handleAccountFailure's default branch -> RecordError, penalising the relaying account's health, cooling it down, and rotating to other accounts that would fail identically. Under a malformed-request storm this cascade-disabled healthy accounts and scattered cache affinity (observed in 100/500-concurrent testing where a single bad account disabled the whole pool). Three layers now cooperate via a single typed error: - kiro.go: HTTP 400 with "improperly formed request" body is wrapped as upstreamPermanentError and returned immediately (no other endpoint tried), detected before any event-stream bytes are read so streaming handlers have not yet sent anything to the client. - handleAccountFailure: isUpstreamPermanentError is neutral -> returns without recording success or failure (account left at full health). - All 6 account retry loops (handler x4, responses_handler x2): break on isUpstreamPermanentError so no other account is tried; lastErr reaches the client. ImproperlyFormedClientMessage helper removed (no callers). go build/vet green; full suite ok. TestResponsesStreamSSE TempDir-cleanup flake confirmed pre-existing (reproduced on main 4f079f4), not a regression. --- proxy/account_failover.go | 7 +++++ proxy/handler.go | 15 ++++++++++ proxy/kiro.go | 10 +++++++ proxy/responses_handler.go | 12 ++++++-- proxy/upstream_error.go | 61 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 102 insertions(+), 3 deletions(-) create mode 100644 proxy/upstream_error.go diff --git a/proxy/account_failover.go b/proxy/account_failover.go index f0984fd4..047456fc 100644 --- a/proxy/account_failover.go +++ b/proxy/account_failover.go @@ -140,6 +140,13 @@ func (h *Handler) handleAccountFailure(account *config.Account, err error) { errMsg := err.Error() switch { + case isUpstreamPermanentError(err): + // The request itself is malformed/rejected by the upstream regardless of + // which account relays it. Penalising the account's health, cooling it + // down, or rotating to another account would be wrong (the account is + // healthy) and harmful (scatters cache affinity, wastes upstream hits). + // Neutral: return without recording any success or failure. + return case isOverageErrorMessage(errMsg): h.disableAccountOverage(account) h.pool.RecordError(account.ID, false) diff --git a/proxy/handler.go b/proxy/handler.go index e9d75b15..0b21b8c1 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -1218,6 +1218,12 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload lastErr = err excluded[account.ID] = true h.handleAccountFailure(account, err) + if isUpstreamPermanentError(err) { + // Malformed request — no other account can succeed. Stop retrying; + // lastErr flows to the client. The relaying account was left neutral + // by handleAccountFailure (not penalised). + break + } if !messageStarted { continue } @@ -1506,6 +1512,9 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayl lastErr = err excluded[account.ID] = true h.handleAccountFailure(account, err) + if isUpstreamPermanentError(err) { + break + } continue } @@ -1957,6 +1966,9 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload lastErr = err excluded[account.ID] = true h.handleAccountFailure(account, err) + if isUpstreamPermanentError(err) { + break + } if !responseStarted { continue } @@ -2084,6 +2096,9 @@ func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, payload *KiroPayl lastErr = err excluded[account.ID] = true h.handleAccountFailure(account, err) + if isUpstreamPermanentError(err) { + break + } continue } diff --git a/proxy/kiro.go b/proxy/kiro.go index b2fef49d..0bbb9c26 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -384,6 +384,16 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt if resp.StatusCode != 200 { errBody, _ := io.ReadAll(resp.Body) resp.Body.Close() + // A 400 "Improperly formed request" is inherent to the request payload — + // no endpoint or account can fix it. Wrap it as a permanent rejection so + // CallKiroAPI's caller short-circuits (no other endpoint/account tried) + // and the relaying account is not penalised for a bad request it merely + // forwarded. Detected before any event-stream bytes are read, so streaming + // handlers have not yet sent anything to the client. + if resp.StatusCode == 400 && isImproperlyFormedRejection(string(errBody)) { + lastErr = newUpstreamPermanentError(resp.StatusCode, string(errBody)) + return lastErr + } lastErr = fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, ep.Name, string(errBody)) // Authentication errors and payment errors are not retried across endpoints. if resp.StatusCode == 401 || resp.StatusCode == 403 || resp.StatusCode == 402 { diff --git a/proxy/responses_handler.go b/proxy/responses_handler.go index ce6f341f..62e0d6a2 100644 --- a/proxy/responses_handler.go +++ b/proxy/responses_handler.go @@ -180,6 +180,9 @@ func (h *Handler) handleResponsesNonStream( lastErr = err excluded[account.ID] = true h.handleAccountFailure(account, err) + if isUpstreamPermanentError(err) { + break + } continue } @@ -486,10 +489,13 @@ func (h *Handler) handleResponsesStream( err := CallKiroAPI(account, payload, callback) if err != nil { + lastErr = err + excluded[account.ID] = true + h.handleAccountFailure(account, err) + if isUpstreamPermanentError(err) { + break + } if !responseStarted { - lastErr = err - excluded[account.ID] = true - h.handleAccountFailure(account, err) continue } send("response.failed", map[string]interface{}{ diff --git a/proxy/upstream_error.go b/proxy/upstream_error.go new file mode 100644 index 00000000..e39efba8 --- /dev/null +++ b/proxy/upstream_error.go @@ -0,0 +1,61 @@ +package proxy + +import ( + "errors" + "strings" +) + +// improperlyFormedMarker is the substring AWS returns in the 400 response body +// when it rejects the request payload itself ("Improperly formed request."). +// Matched case-insensitively so a minor upstream wording/casing change does not +// silently bypass the permanent-error short-circuit. Ported from kiro-tutu. +const improperlyFormedMarker = "improperly formed request" + +// isImproperlyFormedRejection reports whether an upstream error body marks the +// request as improperly formed. Both the endpoint short-circuit (kiro.go) and +// handleAccountFailure key off this single predicate so they can never disagree. +func isImproperlyFormedRejection(errBody string) bool { + return strings.Contains(strings.ToLower(errBody), improperlyFormedMarker) +} + +// upstreamPermanentErrorCode marks a request that the upstream rejects for a +// reason inherent to the request itself (e.g. "Improperly formed request"). +// Retrying it — across endpoints or accounts — cannot succeed and only +// amplifies the damage (wasted upstream hits, scattered cache affinity, +// unwarranted account health penalties), so it must short-circuit both layers. +const upstreamPermanentErrorCode = "upstream_permanent_rejection" + +// upstreamPermanentError signals an upstream rejection that is inherent to the +// request and therefore not retryable. Both the endpoint loop (kiro.go) and the +// account loop short-circuit on it: no other endpoint/account is tried, account +// health is not penalised (handleAccountFailure treats it as neutral). +type upstreamPermanentError struct { + status int + code string + message string +} + +func newUpstreamPermanentError(status int, message string) error { + return &upstreamPermanentError{ + status: status, + code: upstreamPermanentErrorCode, + message: message, + } +} + +func (e *upstreamPermanentError) Error() string { + return e.message +} + +func asUpstreamPermanentError(err error) (*upstreamPermanentError, bool) { + var target *upstreamPermanentError + if errors.As(err, &target) { + return target, true + } + return nil, false +} + +func isUpstreamPermanentError(err error) bool { + _, ok := asUpstreamPermanentError(err) + return ok +} From 5eb78dee6c095c38151d474186d534fa4d312e76 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 10:37:29 +0700 Subject: [PATCH 43/68] feat(cache): Opus min-cacheable threshold 4096->1024 (P1 from kiro-tutu) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Anthropic's real prompt-cache minimum is ~1024 tokens for the Claude 4 / Opus family. The previous 4096 excluded many valid Opus breakpoints, so an Opus conversation reported far fewer cache hits than it actually earned (shorter Opus prefixes that should cache were silently skipped in both Compute and Update). Now matches the non-Opus default and tutu. Evaluated but NOT ported: tutu's localCacheEntryTTL=1h decoupled from the billing 5m TTL. The current repo sets ExpiresAt = breakpoint.TTL (correct). A 1h local ledger would let a request at minute 6 HIT locally (reporting cache_read>0) while Anthropic's real 5m cache had already expired — over-reporting cache_read versus reality. Rejected as it degrades outward-facing billing-number accuracy. Kept current repo's on-disk persistence + LRU + configurable 0.85 cap + clampCacheBreakdownToCreation (all of which tutu lacks). go build/vet/test green; no test depends on the 4096 constant (the 4096s in tests are max_tokens/context-window values, unrelated). --- proxy/cache_tracker.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/proxy/cache_tracker.go b/proxy/cache_tracker.go index 22cba52f..f941d5e5 100644 --- a/proxy/cache_tracker.go +++ b/proxy/cache_tracker.go @@ -23,7 +23,13 @@ const defaultPromptCacheTTL = 5 * time.Minute // matching and storage to avoid reporting unrealistic 100% cache hits on // short requests. const defaultMinCacheableTokens = 1024 -const opusMinCacheableTokens = 4096 + +// opusMinCacheableTokens is the minimum cacheable prefix size for Opus models. +// Anthropic's real prompt-cache threshold is ~1024 tokens (Claude 4 family); +// the previous 4096 excluded many valid Opus breakpoints, so an Opus +// conversation would report far fewer cache hits than it actually earned. +// Ported from kiro-tutu (opusMinCacheableTokens = 1024). +const opusMinCacheableTokens = 1024 type promptCacheUsage struct { CacheCreationInputTokens int From 7370842a3fad4ef321522381e0a1855268c02b72 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 11:11:46 +0700 Subject: [PATCH 44/68] feat(observability): rate limiting + Prometheus metrics + token warmup (zero-dep P1) Ports the remaining zero-dependency observability/hardening pieces from kiro-tutu. No new dependencies (stays on github.com/google/uuid only). - Rate limiting (proxy/ratelimit.go) Token-bucket limiter with a global tier and a per-API-key tier, both OFF by default (rate 0 == unlimited) so enabling never regresses existing deployments. Operators opt in via KIRO_RATE_LIMIT_RPM, KIRO_RATE_LIMIT_PER_KEY_RPM, KIRO_RATE_LIMIT_BURST_SECONDS. Health/admin/ static paths are exempt; per-key buckets idle >10m are swept. - Prometheus /metrics (proxy/prod_metrics.go + prod_gauges.go) Hand-written Prometheus text exposition (no metrics client dep): kiro_http_requests_total{method,code,path}, duration histogram, plus counters for ratelimit rejections, recovered panics, and token refreshes. Pool gauges: kiro_accounts_total, kiro_accounts_available, kiro_credential_quota_remaining{account}. kiro_account_inflight omitted (this repo has no in-flight tracking; gaugeHelp has no entry for it). - Token warm-up + refresh counter (proxy/warmup.go + token_refresh.go) backgroundWarmup runs every 2 min with a 15-min look-ahead, refreshing tokens nearing expiry off the request path (re-checks under tokenRefreshMu so it never races or double-refreshes the request path). authRefreshToken wraps auth.RefreshToken so every refresh (request path, background sweep, admin verify/import) is counted in kiro_token_refresh_total from one choke point. kiro_api.go's now-unused auth import removed. - Startup security warnings (main.go) Warns once at boot if the admin password is still the default 'changeme', and if binding 0.0.0.0 with no API-key requirement (the two most common foot-guns for accidental public exposure). Behavior unchanged. prod_middleware.go rewritten to fold the 64 MiB body cap into instrumentMiddleware (was a separate layer) and add rateLimitMiddleware, statusRecorder, metricsRouter, pathClass, clientIP. WrapHardening now composes recover -> request-id -> /metrics -> security headers -> instrument(status+latency+counters+body cap) -> rate limit -> handler. go build/vet green. TestClaudeNonStreamRetriesNextAccountAfterPreResponseFailure TempDir-cleanup flake confirmed pre-existing (reproduced on main 5eb78de in a worktree: 2/5 FAIL identical), not a regression from the warmup goroutine. --- main.go | 9 ++ proxy/handler.go | 7 +- proxy/kiro_api.go | 3 +- proxy/prod_gauges.go | 41 ++++++++ proxy/prod_metrics.go | 209 +++++++++++++++++++++++++++++++++++++++ proxy/prod_middleware.go | 191 ++++++++++++++++++++++++++++++++--- proxy/ratelimit.go | 182 ++++++++++++++++++++++++++++++++++ proxy/token_refresh.go | 18 ++++ proxy/warmup.go | 67 +++++++++++++ 9 files changed, 707 insertions(+), 20 deletions(-) create mode 100644 proxy/prod_gauges.go create mode 100644 proxy/prod_metrics.go create mode 100644 proxy/ratelimit.go create mode 100644 proxy/token_refresh.go create mode 100644 proxy/warmup.go diff --git a/main.go b/main.go index b233ca1a..926f4e2e 100644 --- a/main.go +++ b/main.go @@ -55,6 +55,15 @@ func main() { config.SetPassword(envPassword) } + // 生产安全态势告警(不改变行为,仅提示):默认密码 / 公网无认证裸奔。 + // 这两个组合是真实事故的高频根因,启动时一次性提示,方便运维尽早发现。 + if config.GetPassword() == "changeme" { + logger.Warnf("[SECURITY] 管理密码仍为默认 'changeme',请通过 ADMIN_PASSWORD 环境变量或管理面板尽快修改") + } + if config.GetHost() == "0.0.0.0" && !config.IsApiKeyRequired() { + logger.Warnf("[SECURITY] 监听 0.0.0.0 且未强制 API Key 鉴权:服务对外无认证开放;生产环境请设置 requireApiKey=true 或限制绑定地址") + } + // 初始化账号池 pool.GetPool() diff --git a/proxy/handler.go b/proxy/handler.go index 0b21b8c1..94021a84 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -252,6 +252,9 @@ func NewHandler() *Handler { h.promptCache.startSaveLoop(cachePath, 30*time.Second) // 启动后台刷新 go h.backgroundRefresh() + // 启动 token 预热 worker(2 分钟一轮,提前 15 分钟刷新即将过期的 token, + // 避免请求路径上同步刷新造成的延迟尖峰) + go h.backgroundWarmup() // 启动后台统计保存 (每30秒保存一次) go h.backgroundStatsSaver() // 清理过期的 stored responses(>30 天) @@ -2184,7 +2187,7 @@ func (h *Handler) refreshAccountToken(account *config.Account, force bool) error } } - accessToken, refreshToken, expiresAt, profileArn, err := auth.RefreshToken(account) + accessToken, refreshToken, expiresAt, profileArn, err := authRefreshToken(account) if err != nil { return err } @@ -3226,7 +3229,7 @@ func (h *Handler) apiImportCredentials(w http.ResponseWriter, r *http.Request) { TokenEndpoint: req.TokenEndpoint, Scopes: req.Scopes, } - a, newRT, ea, newPA, err := auth.RefreshToken(tempAccount) + a, newRT, ea, newPA, err := authRefreshToken(tempAccount) if err != nil { w.WriteHeader(400) json.NewEncoder(w).Encode(map[string]string{"error": "Token refresh failed: " + err.Error()}) diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index 72649613..fe4e80b5 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -4,7 +4,6 @@ import ( "encoding/json" "fmt" "io" - "kiro-go/auth" "kiro-go/config" "kiro-go/logger" "net/http" @@ -291,7 +290,7 @@ func ResolveProfileArn(account *config.Account) (string, error) { // Fallback: refresh token to get profileArn from auth response if account.RefreshToken != "" { - _, _, _, refreshedArn, refreshErr := auth.RefreshToken(account) + _, _, _, refreshedArn, refreshErr := authRefreshToken(account) if refreshErr == nil && refreshedArn != "" { if updateErr := config.UpdateAccountProfileArn(account.ID, refreshedArn); updateErr != nil { logger.Warnf("[ProfileArn] Failed to cache profile ARN for %s: %v", account.Email, updateErr) diff --git a/proxy/prod_gauges.go b/proxy/prod_gauges.go new file mode 100644 index 00000000..47ed7eb4 --- /dev/null +++ b/proxy/prod_gauges.go @@ -0,0 +1,41 @@ +package proxy + +// Pool-backed Prometheus gauges. Registered as the metrics gaugeProvider so a +// /metrics scrape reflects live pool state (account counts and remaining +// credential quota). Collection is guarded so a scrape can never panic the +// metrics endpoint. +// +// Ported from kiro-tutu. The tutu version also emits kiro_account_inflight from +// pool.GetRuntimeStatsSnapshot; this repo has no in-flight tracking (the +// health-scoring+inFlight slice was not ported), so that gauge is omitted — the +// gaugeHelp map in prod_metrics.go correspondingly has no entry for it. +import "kiro-go/pool" + +func init() { + gaugeProvider = collectPoolGauges +} + +func collectPoolGauges() (out []gaugeSample) { + defer func() { + if r := recover(); r != nil { + out = nil + } + }() + + p := pool.GetPool() + out = append(out, + gaugeSample{name: "kiro_accounts_total", value: float64(p.Count())}, + gaugeSample{name: "kiro_accounts_available", value: float64(p.AvailableCount())}, + ) + + for _, acc := range p.GetAllAccounts() { + if acc.UsageLimit > 0 { + out = append(out, gaugeSample{ + name: "kiro_credential_quota_remaining", + labels: [][2]string{{"account", acc.ID}}, + value: acc.UsageLimit - acc.UsageCurrent, + }) + } + } + return out +} diff --git a/proxy/prod_metrics.go b/proxy/prod_metrics.go new file mode 100644 index 00000000..5068960b --- /dev/null +++ b/proxy/prod_metrics.go @@ -0,0 +1,209 @@ +package proxy + +// Dependency-free Prometheus metrics exposition (production observability). +// +// Rather than pull in a metrics client library, this exposes a tiny, correct +// subset of the Prometheus text format (v0.0.4). Collection is lock-guarded and +// off the request hot path only by a single map insert + a few atomics, so it +// never blocks the proxied stream. Ported from kiro-tutu; no audit counter is +// emitted here (this repo has no audit store — kiro_audit_dropped_total is +// intentionally omitted). +import ( + "fmt" + "sort" + "strings" + "sync" + "time" +) + +var globalMetrics = newProdMetrics() + +type prodMetrics struct { + mu sync.Mutex + counters map[string]int64 // fully-rendered series (name or name{labels}) -> value + + hBuckets []float64 + hCounts []uint64 // cumulative counts aligned with hBuckets + hSum float64 + hCount uint64 +} + +func newProdMetrics() *prodMetrics { + buckets := []float64{0.05, 0.1, 0.25, 0.5, 1, 2.5, 5, 10, 30, 60} + return &prodMetrics{ + counters: make(map[string]int64), + hBuckets: buckets, + hCounts: make([]uint64, len(buckets)), + } +} + +var counterHelp = map[string]string{ + "kiro_http_requests_total": "Total HTTP requests handled, labelled by method, code and path class.", + "kiro_ratelimit_rejected_total": "Total inbound requests rejected by the rate limiter.", + "kiro_panics_recovered_total": "Total panics recovered by the recovery middleware.", + "kiro_token_refresh_total": "Total OAuth token refresh attempts, by result.", +} + +const durationMetric = "kiro_http_request_duration_seconds" + +// gaugeSample is one gauge reading emitted at scrape time by gaugeProvider. +type gaugeSample struct { + name string + labels [][2]string + value float64 +} + +// gaugeProvider, when set, supplies dynamic gauges (e.g. pool state) at render time. +var gaugeProvider func() []gaugeSample + +var gaugeHelp = map[string]string{ + "kiro_accounts_total": "Total configured accounts in the pool.", + "kiro_accounts_available": "Accounts currently routable (not cooling down).", + "kiro_credential_quota_remaining": "Remaining usage quota per account (UsageLimit-UsageCurrent).", +} + +func escapeLabel(s string) string { + s = strings.ReplaceAll(s, `\`, `\\`) + s = strings.ReplaceAll(s, "\n", `\n`) + s = strings.ReplaceAll(s, `"`, `\"`) + return s +} + +func metricSeries(name string, labels [][2]string) string { + if len(labels) == 0 { + return name + } + var b strings.Builder + b.WriteString(name) + b.WriteByte('{') + for i, kv := range labels { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(kv[0]) + b.WriteString(`="`) + b.WriteString(escapeLabel(kv[1])) + b.WriteByte('"') + } + b.WriteByte('}') + return b.String() +} + +func (m *prodMetrics) incCounter(series string) { + m.mu.Lock() + m.counters[series]++ + m.mu.Unlock() +} + +func (m *prodMetrics) observe(seconds float64) { + m.mu.Lock() + m.hCount++ + m.hSum += seconds + for i, ub := range m.hBuckets { + if seconds <= ub { + m.hCounts[i]++ + } + } + m.mu.Unlock() +} + +// Render returns the current metrics in Prometheus text exposition format. +func (m *prodMetrics) Render() string { + m.mu.Lock() + families := make(map[string][]string) + for series, val := range m.counters { + name := series + if i := strings.IndexByte(series, '{'); i >= 0 { + name = series[:i] + } + families[name] = append(families[name], fmt.Sprintf("%s %d", series, val)) + } + hBuckets := append([]float64(nil), m.hBuckets...) + hCounts := append([]uint64(nil), m.hCounts...) + hSum := m.hSum + hCount := m.hCount + m.mu.Unlock() + + var b strings.Builder + names := make([]string, 0, len(families)) + for name := range families { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + help := counterHelp[name] + if help == "" { + help = "Counter." + } + fmt.Fprintf(&b, "# HELP %s %s\n", name, help) + fmt.Fprintf(&b, "# TYPE %s counter\n", name) + lines := families[name] + sort.Strings(lines) + for _, l := range lines { + b.WriteString(l) + b.WriteByte('\n') + } + } + + fmt.Fprintf(&b, "# HELP %s HTTP request duration in seconds.\n", durationMetric) + fmt.Fprintf(&b, "# TYPE %s histogram\n", durationMetric) + for i, ub := range hBuckets { + fmt.Fprintf(&b, "%s_bucket{le=\"%g\"} %d\n", durationMetric, ub, hCounts[i]) + } + fmt.Fprintf(&b, "%s_bucket{le=\"+Inf\"} %d\n", durationMetric, hCount) + fmt.Fprintf(&b, "%s_sum %g\n", durationMetric, hSum) + fmt.Fprintf(&b, "%s_count %d\n", durationMetric, hCount) + + if gaugeProvider != nil { + gm := make(map[string][]string) + for _, s := range gaugeProvider() { + gm[s.name] = append(gm[s.name], fmt.Sprintf("%s %g", metricSeries(s.name, s.labels), s.value)) + } + gnames := make([]string, 0, len(gm)) + for name := range gm { + gnames = append(gnames, name) + } + sort.Strings(gnames) + for _, name := range gnames { + help := gaugeHelp[name] + if help == "" { + help = "Gauge." + } + fmt.Fprintf(&b, "# HELP %s %s\n", name, help) + fmt.Fprintf(&b, "# TYPE %s gauge\n", name) + lines := gm[name] + sort.Strings(lines) + for _, l := range lines { + b.WriteString(l) + b.WriteByte('\n') + } + } + } + + return b.String() +} + +func metricsRequest(method, code, pathClass string) { + globalMetrics.incCounter(metricSeries("kiro_http_requests_total", [][2]string{ + {"method", method}, {"code", code}, {"path", pathClass}, + })) +} +func metricsRateLimited() { globalMetrics.incCounter("kiro_ratelimit_rejected_total") } +func metricsPanic() { globalMetrics.incCounter("kiro_panics_recovered_total") } +func metricsTokenRefresh(result string) { + globalMetrics.incCounter(metricSeries("kiro_token_refresh_total", [][2]string{{"result", result}})) +} + +func recordTokenRefreshResult(err error) { + if err != nil { + metricsTokenRefresh("error") + return + } + metricsTokenRefresh("success") +} +func metricsDuration(d time.Duration) { + if d < 0 { + d = 0 + } + globalMetrics.observe(d.Seconds()) +} diff --git a/proxy/prod_middleware.go b/proxy/prod_middleware.go index 5d950f04..bc3b2405 100644 --- a/proxy/prod_middleware.go +++ b/proxy/prod_middleware.go @@ -1,24 +1,30 @@ package proxy -// Production hardening middleware — minimal, zero-dependency subset of the -// kiro-tutu production chain. Wraps the existing *Handler without touching its -// large ServeHTTP, preserving all current routing/behaviour. +// Production hardening middleware chain (zero-dependency). // -// From outermost to innermost: +// WrapHardening composes, from outermost to innermost: // -// recover -> request-id -> security headers -> body-cap -> handler +// recover -> request-id -> /metrics router -> security headers +// -> instrument (status + latency + counters + 64 MiB body cap) +// -> rate limit -> handler +// +// The chain wraps the existing *Handler without touching its large ServeHTTP, +// preserving all current routing/behaviour. Streaming (SSE) is preserved because +// statusRecorder implements http.Flusher and Unwrap. Rate limiting and metrics +// are opt-in / always-on respectively and add no dependency. // // Deliberately NOT included (they need deps / infrastructure absent from this -// repo): Prometheus /metrics, rate limiting, SQLite/JSONL audit, cluster drain. -// Adding any of those means pulling modernc.org/sqlite, redis, etc. — out of -// scope for the zero-dep P0 port. +// repo): SQLite/JSONL audit store, cluster drain. kiro_audit_dropped_total is +// correspondingly omitted from the metrics. import ( "context" "io" "kiro-go/logger" "net/http" "runtime/debug" + "strconv" "strings" + "time" "github.com/google/uuid" ) @@ -35,11 +41,55 @@ func requestIDFromContext(ctx context.Context) string { return v } -// WrapHardening wraps inner with the zero-dep hardening chain. +// statusRecorder captures the response status/size while transparently +// forwarding streaming capabilities used by SSE handlers. +type statusRecorder struct { + http.ResponseWriter + status int + wrote bool + bytes int +} + +func (s *statusRecorder) WriteHeader(code int) { + if !s.wrote { + s.status = code + s.wrote = true + } + s.ResponseWriter.WriteHeader(code) +} + +func (s *statusRecorder) Write(b []byte) (int, error) { + if !s.wrote { + s.status = http.StatusOK + s.wrote = true + } + n, err := s.ResponseWriter.Write(b) + s.bytes += n + return n, err +} + +// Flush forwards to the underlying writer so SSE streaming keeps working. +func (s *statusRecorder) Flush() { + if f, ok := s.ResponseWriter.(http.Flusher); ok { + f.Flush() + } +} + +// Unwrap lets http.ResponseController reach the underlying writer (deadlines etc.). +func (s *statusRecorder) Unwrap() http.ResponseWriter { return s.ResponseWriter } + +// WrapHardening wraps inner with the full production middleware chain, reading +// rate limits from the environment. func WrapHardening(inner http.Handler) http.Handler { + return wrapHardeningWith(inner, newRateLimiterFromEnv()) +} + +func wrapHardeningWith(inner http.Handler, rl *rateLimiter) http.Handler { h := inner - h = bodyCapMiddleware(h) + h = rateLimitMiddleware(h, rl) + h = instrumentMiddleware(h) h = securityHeadersMiddleware(h) + h = metricsRouter(h) h = requestIDMiddleware(h) h = recoverMiddleware(h) return h @@ -59,6 +109,7 @@ func recoverMiddleware(next http.Handler) http.Handler { if rec == http.ErrAbortHandler { panic(rec) } + metricsPanic() logger.Errorf("[Recover] panic on %s %s (req=%s): %v\n%s", r.Method, r.URL.Path, requestIDFromContext(r.Context()), rec, debug.Stack()) func() { @@ -86,6 +137,19 @@ func requestIDMiddleware(next http.Handler) http.Handler { }) } +// metricsRouter serves /metrics (Prometheus text exposition) without forwarding +// to the inner handler. Everything else passes through. +func metricsRouter(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/metrics" { + w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") + _, _ = io.WriteString(w, globalMetrics.Render()) + return + } + next.ServeHTTP(w, r) + }) +} + // securityHeadersMiddleware sets baseline browser-security headers (admin panel // clickjacking / MIME-sniffing protection). func securityHeadersMiddleware(next http.Handler) http.Handler { @@ -98,15 +162,110 @@ func securityHeadersMiddleware(next http.Handler) http.Handler { }) } -// bodyCapMiddleware bounds the request body. When the limit is exceeded the -// subsequent io.ReadAll inside a handler returns an error, which each handler -// already maps to a 400 "Failed to read request body" — the goal (memory bound) -// is met without changing per-endpoint error handling. -func bodyCapMiddleware(next http.Handler) http.Handler { +// instrumentMiddleware records request count, latency histogram, and bounds the +// request body to 64 MiB. The body cap means a subsequent io.ReadAll inside a +// handler returns an error when the limit is exceeded, which each handler +// already maps to a 400 — the goal (memory bound) is met without changing +// per-endpoint error handling. +func instrumentMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK} if r.Body != nil { - r.Body = http.MaxBytesReader(w, r.Body, maxInboundRequestBytes) + r.Body = http.MaxBytesReader(rec, r.Body, maxInboundRequestBytes) + } + start := time.Now() + next.ServeHTTP(rec, r) + lat := time.Since(start) + metricsDuration(lat) + metricsRequest(r.Method, strconv.Itoa(rec.status), pathClass(r.URL.Path)) + }) +} + +// rateLimitMiddleware enforces the global + per-key token buckets when the +// limiter is enabled. Health/admin/static paths are exempt so the admin UI and +// liveness checks stay reachable even while API traffic is being throttled. +func rateLimitMiddleware(next http.Handler, rl *rateLimiter) http.Handler { + if !rl.enabled() { + return next + } + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodOptions || rateLimitExempt(r.URL.Path) { + next.ServeHTTP(w, r) + return + } + key := extractProvidedKey(r) + if key == "" { + key = "ip:" + clientIP(r) + } + if ok, retry := rl.Allow(key); !ok { + metricsRateLimited() + secs := int(retry.Seconds()) + if secs < 1 { + secs = 1 + } + h := w.Header() + h.Set("Access-Control-Allow-Origin", "*") + h.Set("Retry-After", strconv.Itoa(secs)) + h.Set("Content-Type", "application/json; charset=utf-8") + w.WriteHeader(http.StatusTooManyRequests) + _, _ = io.WriteString(w, `{"type":"error","error":{"type":"rate_limit_error","message":"rate limit exceeded, retry later"}}`) + return } next.ServeHTTP(w, r) }) } + +// rateLimitExempt reports paths that bypass rate limiting: health/metrics +// endpoints, and the admin UI / static assets. +func rateLimitExempt(path string) bool { + switch { + case path == "/health" || path == "/" || path == "/metrics" || path == "/favicon.ico": + return true + case strings.HasPrefix(path, "/admin"), + strings.HasPrefix(path, "/web"), + strings.HasPrefix(path, "/static"): + return true + } + return false +} + +// clientIP extracts the client address, preferring the first X-Forwarded-For +// entry (set by proxies) and falling back to r.RemoteAddr. +func clientIP(r *http.Request) string { + if xff := r.Header.Get("X-Forwarded-For"); xff != "" { + if i := strings.IndexByte(xff, ','); i >= 0 { + return strings.TrimSpace(xff[:i]) + } + return strings.TrimSpace(xff) + } + host := r.RemoteAddr + if i := strings.LastIndexByte(host, ':'); i >= 0 { + host = host[:i] + } + return host +} + +// pathClass collapses a request path to a stable cardinality for the +// kiro_http_requests_total{path} label, so the metrics series stay bounded. +func pathClass(p string) string { + switch { + case p == "/v1/messages" || p == "/messages" || p == "/anthropic/v1/messages": + return "/v1/messages" + case strings.HasSuffix(p, "/count_tokens"): + return "/v1/messages/count_tokens" + case p == "/v1/chat/completions" || p == "/chat/completions": + return "/v1/chat/completions" + case strings.HasPrefix(p, "/v1/responses"): + return "/v1/responses" + case p == "/v1/models" || p == "/models": + return "/v1/models" + case p == "/health" || p == "/": + return "/health" + case p == "/metrics": + return "/metrics" + case strings.HasPrefix(p, "/admin"): + return "/admin" + default: + return "other" + } +} diff --git a/proxy/ratelimit.go b/proxy/ratelimit.go new file mode 100644 index 00000000..283338a5 --- /dev/null +++ b/proxy/ratelimit.go @@ -0,0 +1,182 @@ +package proxy + +// Inbound request rate limiting (production hardening). +// +// Provides a dependency-free token-bucket limiter with two tiers: +// - a global bucket (all inbound API traffic), and +// - a per-API-key (tenant) bucket. +// +// Both tiers are OFF by default (rate 0 == unlimited) so enabling the limiter +// never silently regresses existing deployments; operators opt in via env: +// +// KIRO_RATE_LIMIT_RPM global requests/minute (0 = unlimited) +// KIRO_RATE_LIMIT_PER_KEY_RPM per-API-key requests/minute (0 = unlimited) +// KIRO_RATE_LIMIT_BURST_SECONDS burst window in seconds (default 10) +// +// Per-credential (single-account) concurrency limiting is enforced at the pool +// layer, not here, because the credential is only chosen deep inside the +// request handler. +// +// Ported verbatim from kiro-tutu (zero-dep). +import ( + "os" + "strconv" + "strings" + "sync" + "time" +) + +// tokenBucket is a mutex-guarded token bucket. A rate <= 0 means "unlimited". +type tokenBucket struct { + mu sync.Mutex + rate float64 // tokens per second + burst float64 // maximum tokens held + tokens float64 + last time.Time +} + +func newTokenBucket(rate, burst float64, now time.Time) *tokenBucket { + if burst < 1 { + burst = 1 + } + return &tokenBucket{rate: rate, burst: burst, tokens: burst, last: now} +} + +// allow reports whether a token is available at now. When denied it also returns +// the estimated duration until the next token becomes available. +func (b *tokenBucket) allow(now time.Time) (bool, time.Duration) { + if b.rate <= 0 { + return true, 0 + } + b.mu.Lock() + defer b.mu.Unlock() + if elapsed := now.Sub(b.last).Seconds(); elapsed > 0 { + b.tokens += elapsed * b.rate + if b.tokens > b.burst { + b.tokens = b.burst + } + b.last = now + } + if b.tokens >= 1 { + b.tokens-- + return true, 0 + } + wait := (1 - b.tokens) / b.rate + return false, time.Duration(wait * float64(time.Second)) +} + +type tokenBucketEntry struct { + bucket *tokenBucket + lastAt time.Time +} + +// rateLimiter enforces a global bucket plus lazily-created per-key buckets. +type rateLimiter struct { + globalRate float64 + perKeyRate float64 + burstSeconds float64 + + global *tokenBucket + + mu sync.Mutex + perKey map[string]*tokenBucketEntry + sweepN int + nowFn func() time.Time +} + +func bucketBurst(rate, seconds float64) float64 { + b := rate * seconds + if b < 1 { + b = 1 + } + return b +} + +// newRateLimiter builds a limiter from explicit requests-per-minute values. +func newRateLimiter(globalRPM, perKeyRPM, burstSeconds float64) *rateLimiter { + if burstSeconds <= 0 { + burstSeconds = 10 + } + rl := &rateLimiter{ + globalRate: globalRPM / 60, + perKeyRate: perKeyRPM / 60, + burstSeconds: burstSeconds, + perKey: make(map[string]*tokenBucketEntry), + nowFn: time.Now, + } + if rl.globalRate > 0 { + rl.global = newTokenBucket(rl.globalRate, bucketBurst(rl.globalRate, burstSeconds), rl.nowFn()) + } + return rl +} + +// newRateLimiterFromEnv reads limits from the KIRO_RATE_LIMIT_* environment. +func newRateLimiterFromEnv() *rateLimiter { + return newRateLimiter( + envFloat("KIRO_RATE_LIMIT_RPM", 0), + envFloat("KIRO_RATE_LIMIT_PER_KEY_RPM", 0), + envFloat("KIRO_RATE_LIMIT_BURST_SECONDS", 10), + ) +} + +func envFloat(name string, def float64) float64 { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 { + return f + } + } + return def +} + +func (rl *rateLimiter) enabled() bool { + return rl != nil && (rl.globalRate > 0 || rl.perKeyRate > 0) +} + +func (rl *rateLimiter) bucketFor(key string, now time.Time) *tokenBucket { + rl.mu.Lock() + defer rl.mu.Unlock() + rl.sweepLocked(now) + e := rl.perKey[key] + if e == nil { + e = &tokenBucketEntry{bucket: newTokenBucket(rl.perKeyRate, bucketBurst(rl.perKeyRate, rl.burstSeconds), now)} + rl.perKey[key] = e + } + e.lastAt = now + return e.bucket +} + +// sweepLocked evicts per-key buckets idle for more than 10 minutes. Called under rl.mu. +func (rl *rateLimiter) sweepLocked(now time.Time) { + rl.sweepN++ + if rl.sweepN < 1024 { + return + } + rl.sweepN = 0 + cutoff := now.Add(-10 * time.Minute) + for k, e := range rl.perKey { + if e.lastAt.Before(cutoff) { + delete(rl.perKey, k) + } + } +} + +// Allow reports whether a request identified by key may proceed. When denied it +// returns the suggested Retry-After duration. key is typically the API key, or a +// client-IP fallback for unauthenticated paths. +func (rl *rateLimiter) Allow(key string) (bool, time.Duration) { + if !rl.enabled() { + return true, 0 + } + now := rl.nowFn() + if rl.perKeyRate > 0 && key != "" { + if ok, retry := rl.bucketFor(key, now).allow(now); !ok { + return false, retry + } + } + if rl.global != nil { + if ok, retry := rl.global.allow(now); !ok { + return false, retry + } + } + return true, 0 +} diff --git a/proxy/token_refresh.go b/proxy/token_refresh.go new file mode 100644 index 00000000..f70f74b1 --- /dev/null +++ b/proxy/token_refresh.go @@ -0,0 +1,18 @@ +package proxy + +import ( + "kiro-go/auth" + "kiro-go/config" +) + +// authRefreshToken wraps auth.RefreshToken so every refresh attempt — on the +// request path, in the background refresher, and in the warm-up worker — is +// counted in the kiro_token_refresh_total metric from a single choke point. +// +// Ported from kiro-tutu. Behavior is identical to auth.RefreshToken; only the +// metric counter is added. +func authRefreshToken(account *config.Account) (string, string, int64, string, error) { + accessToken, refreshToken, expiresAt, profileArn, err := auth.RefreshToken(account) + recordTokenRefreshResult(err) + return accessToken, refreshToken, expiresAt, profileArn, err +} diff --git a/proxy/warmup.go b/proxy/warmup.go new file mode 100644 index 00000000..d9cbd40e --- /dev/null +++ b/proxy/warmup.go @@ -0,0 +1,67 @@ +package proxy + +// Token warm-up worker (production resilience). +// +// Complements the coarse 30-minute backgroundRefresh with a tighter 2-minute +// cadence and a 15-minute look-ahead: tokens nearing expiry are refreshed off +// the request path, so requests rarely block on a synchronous refresh and a +// transient refresh failure has time to recover before the token actually +// expires. It reuses tokenRefreshMu (shared with the request path) and +// re-checks under lock, so it never races or double-refreshes. +// +// Ported from kiro-tutu (zero-dep). +import ( + "kiro-go/config" + "kiro-go/logger" + "time" +) + +const ( + warmupTokenWindowSeconds int64 = 15 * 60 + warmupInterval = 2 * time.Minute +) + +func (h *Handler) backgroundWarmup() { + ticker := time.NewTicker(warmupInterval) + defer ticker.Stop() + for range ticker.C { + h.warmupExpiringTokens() + } +} + +func (h *Handler) warmupExpiringTokens() { + now := time.Now().Unix() + for _, acc := range h.pool.GetAllAccounts() { + if acc.ExpiresAt <= 0 || now < acc.ExpiresAt-warmupTokenWindowSeconds { + continue // not near expiry yet + } + account := acc // work on a copy; refresh results are written back via pool/config + + h.tokenRefreshMu.Lock() + // Re-read latest state under lock in case the request path just refreshed it. + if latest := h.pool.GetByID(account.ID); latest != nil { + account.AccessToken = latest.AccessToken + account.RefreshToken = latest.RefreshToken + account.ExpiresAt = latest.ExpiresAt + account.ProfileArn = latest.ProfileArn + } + if account.ExpiresAt <= 0 || time.Now().Unix() < account.ExpiresAt-warmupTokenWindowSeconds { + h.tokenRefreshMu.Unlock() + continue // already refreshed by another path + } + + accessToken, refreshToken, expiresAt, profileArn, err := authRefreshToken(&account) + if err != nil { + h.tokenRefreshMu.Unlock() + logger.Warnf("[Warmup] refresh failed for %s: %v", account.ID, err) + continue + } + h.pool.UpdateToken(account.ID, accessToken, refreshToken, expiresAt) + if profileArn != "" { + config.UpdateAccountProfileArn(account.ID, profileArn) + } + config.UpdateAccountToken(account.ID, accessToken, refreshToken, expiresAt) + h.tokenRefreshMu.Unlock() + logger.Infof("[Warmup] proactively refreshed token for %s", account.ID) + } +} From c59ef41759b55b81680992e68c2faee7c8e5ad66 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 11:45:15 +0700 Subject: [PATCH 45/68] feat(dispatch): health-scoring + inFlight tracking + concurrency cap (P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces pure weighted round-robin with a smart scheduler: among accounts passing the hard filters (model support, not cooling down, token valid, quota available), pick the one least likely to fail or stall: lowest in-flight -> fewest recent failures -> fewest recent selections -> fewest recent successes in-flight is incremented on selection and decremented on every finish path (RecordSuccess/RecordError/RecordPermanentRejection/DisableAccount/ MarkOverLimit), so under concurrency the scheduler spreads load instead of piling onto one account. Every finish clamps at 0, so a missed/double finish can never drive the counter negative; with the default concurrency cap of 0 (unlimited) a leaked in-flight only skews the smart tie-break, never starves an account. Two opt-in knobs (default = existing behaviour): KIRO_MAX_CONCURRENT_PER_ACCOUNT hard per-account in-flight cap (0=unlimited) KIRO_SELECTION_STRATEGY smart|round_robin|random|least_used|most_used Deliberate differences from kiro-tutu: - This repo's least-cooldown fallback is retained: when no account passes all hard filters, the earliest-cooldown eligible account is returned (rather than nil -> 503), preserving existing availability behaviour. - endpoint429 / affinity machinery NOT ported (no cache-affinity layer here; endpoint-429 signals only fed affinity circuit-breaking). Also: handleAccountFailure now calls RecordPermanentRejection on the permanent-error path (fixes an in-flight leak — previously it returned early without releasing the selection slot). prod_gauges.go now emits kiro_account_inflight{account} from GetRuntimeStatsSnapshot. New: pool/dispatch.go (selection + runtime stats), pool/tuning.go (cap + strategy), pool/dispatch_test.go (7 tests: lower-in-flight preference, in-flight decrement, neutral permanent-rejection, clamp-at-zero, disable releases slot, least-cooldown fallback, selection spans both accounts). pool/account.go trimmed (selection logic moved to dispatch.go). go build/vet/test green. --- pool/account.go | 171 ++++---------------- pool/dispatch.go | 332 ++++++++++++++++++++++++++++++++++++++ pool/dispatch_test.go | 164 +++++++++++++++++++ pool/tuning.go | 125 ++++++++++++++ proxy/account_failover.go | 4 +- proxy/prod_gauges.go | 11 ++ 6 files changed, 663 insertions(+), 144 deletions(-) create mode 100644 pool/dispatch.go create mode 100644 pool/dispatch_test.go create mode 100644 pool/tuning.go diff --git a/pool/account.go b/pool/account.go index d99f103f..40079730 100644 --- a/pool/account.go +++ b/pool/account.go @@ -7,7 +7,6 @@ import ( "math/rand" "strings" "sync" - "sync/atomic" "time" ) @@ -57,6 +56,7 @@ type AccountPool struct { cooldowns map[string]time.Time // 账号冷却时间 errorCounts map[string]int // 连续错误计数 modelLists map[string]map[string]bool // accountID → set of modelIDs (from ListAvailableModels) + runtimeStats map[string]*accountRuntimeStats // accountID → dispatch health/load signals (health-scoring) } var ( @@ -68,9 +68,10 @@ var ( func GetPool() *AccountPool { poolOnce.Do(func() { pool = &AccountPool{ - cooldowns: make(map[string]time.Time), - errorCounts: make(map[string]int), - modelLists: make(map[string]map[string]bool), + cooldowns: make(map[string]time.Time), + errorCounts: make(map[string]int), + modelLists: make(map[string]map[string]bool), + runtimeStats: make(map[string]*accountRuntimeStats), } pool.Reload() }) @@ -107,77 +108,16 @@ func (p *AccountPool) GetNext() *config.Account { } // GetNextExcluding 获取下一个可用账号(加权轮询),并跳过指定账号。 +// +// Selection is delegated to selectAccountLocked (pool/dispatch.go), which among +// the accounts passing the hard filters (not cooling down, token valid, quota +// available) picks the healthiest/least-loaded one via the smart scheduler and +// stamps it as in-flight. The least-cooldown fallback is preserved inside +// selectAccountLocked so availability behaviour is unchanged. func (p *AccountPool) GetNextExcluding(excluded map[string]bool) *config.Account { - p.mu.RLock() - defer p.mu.RUnlock() - - if len(p.accounts) == 0 { - return nil - } - - allowOverUsage := config.GetAllowOverUsage() - now := time.Now() - n := len(p.accounts) - seen := make(map[string]bool) - - // 加权轮询查找可用账号 - for i := 0; i < n; i++ { - idx := atomic.AddUint64(&p.currentIndex, 1) % uint64(n) - acc := &p.accounts[idx] - - if excluded != nil && excluded[acc.ID] { - seen[acc.ID] = true - continue - } - if seen[acc.ID] { - continue - } - - // 跳过冷却中的账号 - if cooldown, ok := p.cooldowns[acc.ID]; ok && now.Before(cooldown) { - seen[acc.ID] = true - continue - } - - // 跳过即将过期的 Token - if acc.ExpiresAt > 0 && time.Now().Unix() > acc.ExpiresAt-tokenRefreshSkewSeconds { - seen[acc.ID] = true - continue - } - - // Skip accounts whose quota is exhausted, unless overrides apply. - if isQuotaBlocked(*acc, allowOverUsage) { - seen[acc.ID] = true - continue - } - - return copyAccount(acc) - } - - // 无可用账号,返回冷却时间最短的(排除额度用尽的,除非允许超额) - var best *config.Account - var earliest time.Time - for i := range p.accounts { - acc := &p.accounts[i] - if excluded != nil && excluded[acc.ID] { - continue - } - if isQuotaBlocked(*acc, allowOverUsage) { - continue - } - if cooldown, ok := p.cooldowns[acc.ID]; ok { - if best == nil || cooldown.Before(earliest) { - best = acc - earliest = cooldown - } - } else { - return copyAccount(acc) - } - } - if best != nil { - return copyAccount(best) - } - return nil + p.mu.Lock() + defer p.mu.Unlock() + return p.selectAccountLocked("", excluded, false) } // SetModelList 缓存账号支持的模型集合(由 handler 在刷新后调用) @@ -225,76 +165,11 @@ func (p *AccountPool) GetNextForModel(model string) *config.Account { } // GetNextForModelExcluding 获取下一个支持指定模型的可用账号,并跳过指定账号。 +// Delegates to selectAccountLocked (requireModel=true). See GetNextExcluding. func (p *AccountPool) GetNextForModelExcluding(model string, excluded map[string]bool) *config.Account { - p.mu.RLock() - defer p.mu.RUnlock() - - if len(p.accounts) == 0 { - return nil - } - - allowOverUsage := config.GetAllowOverUsage() - now := time.Now() - n := len(p.accounts) - seen := make(map[string]bool) - - for i := 0; i < n; i++ { - idx := atomic.AddUint64(&p.currentIndex, 1) % uint64(n) - acc := &p.accounts[idx] - - if excluded != nil && excluded[acc.ID] { - seen[acc.ID] = true - continue - } - if seen[acc.ID] { - continue - } - if !p.accountHasModel(acc.ID, model) { - seen[acc.ID] = true - continue - } - if cooldown, ok := p.cooldowns[acc.ID]; ok && now.Before(cooldown) { - seen[acc.ID] = true - continue - } - if acc.ExpiresAt > 0 && time.Now().Unix() > acc.ExpiresAt-tokenRefreshSkewSeconds { - seen[acc.ID] = true - continue - } - if isQuotaBlocked(*acc, allowOverUsage) { - seen[acc.ID] = true - continue - } - return copyAccount(acc) - } - - // fallback:找冷却时间最短且支持该模型的账号 - var best *config.Account - var earliest time.Time - for i := range p.accounts { - acc := &p.accounts[i] - if excluded != nil && excluded[acc.ID] { - continue - } - if !p.accountHasModel(acc.ID, model) { - continue - } - if isQuotaBlocked(*acc, allowOverUsage) { - continue - } - if cooldown, ok := p.cooldowns[acc.ID]; ok { - if best == nil || cooldown.Before(earliest) { - best = acc - earliest = cooldown - } - } else { - return copyAccount(acc) - } - } - if best != nil { - return copyAccount(best) - } - return nil + p.mu.Lock() + defer p.mu.Unlock() + return p.selectAccountLocked(model, excluded, true) } // GetByID 根据 ID 获取账号 @@ -313,6 +188,8 @@ func (p *AccountPool) GetByID(id string) *config.Account { func (p *AccountPool) RecordSuccess(id string) { p.mu.Lock() defer p.mu.Unlock() + p.ensurePoolMapsLocked() + p.finishAccountUseLocked(id, true, false, time.Now()) delete(p.cooldowns, id) p.errorCounts[id] = 0 } @@ -322,6 +199,8 @@ func (p *AccountPool) RecordError(id string, isQuotaError bool) { p.mu.Lock() defer p.mu.Unlock() + p.ensurePoolMapsLocked() + p.finishAccountUseLocked(id, false, isQuotaError, time.Now()) p.errorCounts[id]++ if isQuotaError { @@ -408,6 +287,10 @@ func (p *AccountPool) DisableAccount(id, reason string) { _ = err } p.mu.Lock() + p.ensurePoolMapsLocked() + // Release any in-flight slot the account held before evicting it, so the + // smart scheduler's in-flight counts stay accurate across disable/re-enable. + p.finishAccountUseLocked(id, false, false, time.Now()) // Long cooldown as a safety net in case Reload races p.cooldowns[id] = time.Now().Add(24 * time.Hour) p.mu.Unlock() @@ -420,6 +303,8 @@ func (p *AccountPool) DisableAccount(id, reason string) { // the next attempt picks a different account, then reload. func (p *AccountPool) MarkOverLimit(id string) { p.mu.Lock() + p.ensurePoolMapsLocked() + p.finishAccountUseLocked(id, false, false, time.Now()) p.cooldowns[id] = time.Now().Add(time.Hour) p.mu.Unlock() p.Reload() diff --git a/pool/dispatch.go b/pool/dispatch.go new file mode 100644 index 00000000..53ed936c --- /dev/null +++ b/pool/dispatch.go @@ -0,0 +1,332 @@ +package pool + +// Health-aware account selection (production dispatch quality). +// +// Replaces pure weighted round-robin with a smart scheduler that, among the +// accounts passing the hard filters (model support, not cooling down, token +// valid, quota available), picks the one least likely to fail or stall: +// +// lowest in-flight -> fewest recent failures -> fewest recent selections +// -> fewest recent successes +// +// in-flight is incremented when an account is selected and decremented when the +// dispatch finishes (RecordSuccess / RecordError / RecordPermanentRejection / +// DisableAccount / MarkOverLimit), so under concurrency the scheduler spreads +// load instead of piling onto the same account. The per-account concurrency cap +// (pool/tuning.go, default 0 = unlimited) and the selection strategy are opt-in +// via env and default to the smart scheduler. +// +// Ported from kiro-tutu. Differences from tutu, deliberately kept: +// - This repo's least-cooldown fallback is retained: when no account passes +// all hard filters, the account with the earliest cooldown is returned +// (rather than nil -> 503), preserving existing availability behaviour. +// - endpoint429 / affinity machinery is NOT ported (no cache-affinity layer +// in this repo; endpoint-429 signals only fed affinity circuit-breaking). +// +// Safety: every finish helper clamps in-flight at 0, so a missed or double +// finish can never drive the counter negative. With the default concurrency cap +// of 0 (unlimited) a leaked in-flight only skews the smart tie-break, never +// starves an account. +import ( + "kiro-go/config" + "time" +) + +// accountFairnessWindow is the rolling window over which "recent" selection / +// success / failure counts are tallied. Beyond it the counters rotate (reset), +// so a transient burst does not permanently skew a healthy account's score. +const accountFairnessWindow = 5 * time.Minute + +// AccountRuntimeStats is an in-memory snapshot of dispatch-level account usage, +// exposed for observability (e.g. the kiro_account_inflight metric). +type AccountRuntimeStats struct { + SelectedCount int64 `json:"selectedCount"` + SuccessCount int64 `json:"successCount"` + FailureCount int64 `json:"failureCount"` + InFlight int64 `json:"inFlight"` +} + +// accountRuntimeStats tracks per-account dispatch signals used by the smart +// scheduler. The recent* fields rotate every accountFairnessWindow. +type accountRuntimeStats struct { + selectedCount int64 + successCount int64 + failureCount int64 + inFlight int64 + + windowStartedAt time.Time + recentSelectedCount int64 + recentSuccessCount int64 + recentFailureCount int64 +} + +// accountCandidate is a selectable account paired with the health/load signals +// the smart scheduler compares (accountCandidateLess). +type accountCandidate struct { + account *config.Account + inFlight int64 + recentFailureCount int64 + recentSelectedCount int64 + recentSuccessCount int64 +} + +// ensurePoolMapsLocked lazily initialises the pool's maps so callers (and tests +// that construct an AccountPool directly) cannot nil-deref. Caller holds p.mu. +func (p *AccountPool) ensurePoolMapsLocked() { + if p.cooldowns == nil { + p.cooldowns = make(map[string]time.Time) + } + if p.errorCounts == nil { + p.errorCounts = make(map[string]int) + } + if p.modelLists == nil { + p.modelLists = make(map[string]map[string]bool) + } + if p.runtimeStats == nil { + p.runtimeStats = make(map[string]*accountRuntimeStats) + } +} + +func (p *AccountPool) statsForAccountLocked(id string, now time.Time) *accountRuntimeStats { + p.ensurePoolMapsLocked() + stats := p.runtimeStats[id] + if stats == nil { + stats = &accountRuntimeStats{windowStartedAt: now} + p.runtimeStats[id] = stats + } + rotateAccountStatsWindow(stats, now) + return stats +} + +func rotateAccountStatsWindow(stats *accountRuntimeStats, now time.Time) { + if stats.windowStartedAt.IsZero() || now.Before(stats.windowStartedAt) { + stats.windowStartedAt = now + return + } + if now.Sub(stats.windowStartedAt) < accountFairnessWindow { + return + } + stats.windowStartedAt = now + stats.recentSelectedCount = 0 + stats.recentSuccessCount = 0 + stats.recentFailureCount = 0 +} + +func (p *AccountPool) accountCandidateLocked(acc *config.Account, now time.Time) accountCandidate { + stats := p.statsForAccountLocked(acc.ID, now) + return accountCandidate{ + account: acc, + inFlight: stats.inFlight, + recentFailureCount: stats.recentFailureCount, + recentSelectedCount: stats.recentSelectedCount, + recentSuccessCount: stats.recentSuccessCount, + } +} + +// accountCandidateLess orders by production safety first: avoid busy accounts, +// then avoid recently unhealthy accounts, then spread selections and successes. +func accountCandidateLess(a, b accountCandidate) bool { + switch { + case a.inFlight != b.inFlight: + return a.inFlight < b.inFlight + case a.recentFailureCount != b.recentFailureCount: + return a.recentFailureCount < b.recentFailureCount + case a.recentSelectedCount != b.recentSelectedCount: + return a.recentSelectedCount < b.recentSelectedCount + default: + return a.recentSuccessCount < b.recentSuccessCount + } +} + +func (p *AccountPool) markAccountSelectedLocked(id string, now time.Time) { + stats := p.statsForAccountLocked(id, now) + stats.selectedCount++ + stats.recentSelectedCount++ + stats.inFlight++ +} + +// finishAccountUseLocked returns the in-flight slot and records the outcome. +// success -> recentSuccess; otherwise -> recentFailure (and recent429 when +// quotaError). in-flight is clamped at 0 so a missed/double finish is harmless. +func (p *AccountPool) finishAccountUseLocked(id string, success, quotaError bool, now time.Time) { + stats := p.statsForAccountLocked(id, now) + if stats.inFlight > 0 { + stats.inFlight-- + } + if success { + stats.successCount++ + stats.recentSuccessCount++ + return + } + stats.failureCount++ + stats.recentFailureCount++ +} + +// finishAccountUseNeutralLocked returns the in-flight slot without recording a +// success or failure. Used when the dispatch outcome reflects the request +// payload (a permanent upstream rejection), not the account's health. +func (p *AccountPool) finishAccountUseNeutralLocked(id string, now time.Time) { + stats := p.statsForAccountLocked(id, now) + if stats.inFlight > 0 { + stats.inFlight-- + } +} + +// nextStartIndexLocked advances the round-robin cursor and returns the starting +// index for a selection sweep. Caller holds p.mu. +func (p *AccountPool) nextStartIndexLocked(n int) int { + p.currentIndex++ + return int(p.currentIndex % uint64(n)) +} + +// selectAccountLocked is the single selection routine shared by GetNextExcluding +// (requireModel=false) and GetNextForModelExcluding (requireModel=true). It +// gathers eligible candidates, applies the optional concurrency cap, and picks +// one via the configured strategy (default: smart health/load-aware). If no +// candidate passes the hard filters, it falls back to the least-cooldown +// eligible account (never nil when capacity exists) — preserving this repo's +// availability behaviour, which tutu lacks. +func (p *AccountPool) selectAccountLocked(model string, excluded map[string]bool, requireModel bool) *config.Account { + if len(p.accounts) == 0 { + return nil + } + + allowOverUsage := config.GetAllowOverUsage() + now := time.Now() + nowUnix := now.Unix() + n := len(p.accounts) + start := p.nextStartIndexLocked(n) + seen := make(map[string]bool) + candidates := make([]accountCandidate, 0, n) + + for i := 0; i < n; i++ { + idx := (start + i) % n + acc := &p.accounts[idx] + if excluded != nil && excluded[acc.ID] { + seen[acc.ID] = true + continue + } + if seen[acc.ID] { + continue + } + seen[acc.ID] = true + if requireModel && !p.accountHasModel(acc.ID, model) { + continue + } + if acc.ExpiresAt > 0 && nowUnix > acc.ExpiresAt-tokenRefreshSkewSeconds { + continue + } + if isQuotaBlocked(*acc, allowOverUsage) { + continue + } + if cooldown, ok := p.cooldowns[acc.ID]; ok && now.Before(cooldown) { + continue + } + candidates = append(candidates, p.accountCandidateLocked(acc, now)) + } + + if selected := p.bestCandidateLocked(candidates, now); selected != nil { + return selected + } + + // Fallback: no account passed all hard filters (all cooling down). Return the + // eligible account (model + quota OK, not excluded) with the earliest cooldown + // so the caller still gets capacity rather than a hard 503. + var best *config.Account + var earliest time.Time + for i := range p.accounts { + acc := &p.accounts[i] + if excluded != nil && excluded[acc.ID] { + continue + } + if requireModel && !p.accountHasModel(acc.ID, model) { + continue + } + if isQuotaBlocked(*acc, allowOverUsage) { + continue + } + if cooldown, ok := p.cooldowns[acc.ID]; ok { + if best == nil || cooldown.Before(earliest) { + best = acc + earliest = cooldown + } + } else { + // No cooldown at all — prefer over any cooling-down account. + p.markAccountSelectedLocked(acc.ID, now) + return copyAccount(acc) + } + } + if best != nil { + p.markAccountSelectedLocked(best.ID, now) + return copyAccount(best) + } + return nil +} + +// bestCandidateLocked applies the concurrency cap, picks one candidate via the +// configured strategy, marks it selected (in-flight++), and returns a value +// copy. Returns nil when candidates is empty. +func (p *AccountPool) bestCandidateLocked(candidates []accountCandidate, now time.Time) *config.Account { + if len(candidates) == 0 { + return nil + } + candidates = applyConcurrencyCap(candidates) + best := selectCandidate(candidates) + p.markAccountSelectedLocked(best.account.ID, now) + // Return a value copy, never an interior pointer into p.accounts: callers + // (ensureValidToken etc.) mutate AccessToken/ExpiresAt without holding p.mu, + // which would data-race with UpdateToken/Reload on the shared slice. + return copyAccount(best.account) +} + +// RecordPermanentRejection finishes an account dispatch that failed because the +// upstream rejected the request itself (e.g. "improperly formed request"). Such +// a rejection is inherent to the request payload, not the account, so it returns +// the in-flight slot WITHOUT counting any success or failure: the account's +// health must not be penalised for a bad request it merely relayed. +func (p *AccountPool) RecordPermanentRejection(id string) { + p.mu.Lock() + defer p.mu.Unlock() + p.ensurePoolMapsLocked() + p.finishAccountUseNeutralLocked(id, time.Now()) +} + +// CooldownAccount keeps an account out of routing for the given duration without +// recording a dispatch outcome. Used by failover paths that already accounted for +// the attempt via RecordError but need a fresh short cooldown. +func (p *AccountPool) CooldownAccount(id string, duration time.Duration) { + p.mu.Lock() + defer p.mu.Unlock() + p.ensurePoolMapsLocked() + p.cooldowns[id] = time.Now().Add(duration) +} + +// ResetTransientState clears per-account cooldowns, error counters and runtime +// stats and rewinds the round-robin cursor. Intended for test isolation: the +// pool is a process-wide singleton, so a prior test's failures/cooldowns would +// otherwise leak into later tests that reuse the same account IDs. +func (p *AccountPool) ResetTransientState() { + p.mu.Lock() + defer p.mu.Unlock() + p.cooldowns = make(map[string]time.Time) + p.errorCounts = make(map[string]int) + p.runtimeStats = make(map[string]*accountRuntimeStats) + p.currentIndex = 0 +} + +// GetRuntimeStatsSnapshot returns a copy of the per-account dispatch stats for +// observability (e.g. the kiro_account_inflight Prometheus gauge). +func (p *AccountPool) GetRuntimeStatsSnapshot() map[string]AccountRuntimeStats { + p.mu.RLock() + defer p.mu.RUnlock() + out := make(map[string]AccountRuntimeStats, len(p.runtimeStats)) + for id, s := range p.runtimeStats { + out[id] = AccountRuntimeStats{ + SelectedCount: s.selectedCount, + SuccessCount: s.successCount, + FailureCount: s.failureCount, + InFlight: s.inFlight, + } + } + return out +} diff --git a/pool/dispatch_test.go b/pool/dispatch_test.go new file mode 100644 index 00000000..e00c9c7d --- /dev/null +++ b/pool/dispatch_test.go @@ -0,0 +1,164 @@ +package pool + +import ( + "kiro-go/config" + "path/filepath" + "testing" + "time" +) + +// newHealthTestPool builds a pool with accounts whose IDs are given, so tests +// can assert dispatch ordering without relying on config persistence. +func newHealthTestPool(ids ...string) *AccountPool { + accounts := make([]config.Account, 0, len(ids)) + for _, id := range ids { + accounts = append(accounts, config.Account{ID: id}) + } + return &AccountPool{ + accounts: accounts, + cooldowns: make(map[string]time.Time), + errorCounts: make(map[string]int), + modelLists: make(map[string]map[string]bool), + runtimeStats: make(map[string]*accountRuntimeStats), + } +} + +// TestSmartSchedulerPrefersLowerInFlight proves the health-aware tie-break: +// among otherwise-equal accounts, the one with fewer in-flight dispatches is +// selected. Two equal candidates: selecting the first (RecordSuccess finishes +// it), then selecting again must pick a different account because the first +// still has in-flight=0 but the second has been picked once (recentSelected). +func TestSmartSchedulerPrefersLowerInFlight(t *testing.T) { + p := newHealthTestPool("a", "b") + now := time.Now() + + // Pretend "a" already has one in-flight dispatch. + p.markAccountSelectedLocked("a", now) + + acc := p.GetNextExcluding(nil) + if acc == nil { + t.Fatal("expected an account") + } + if acc.ID != "b" { + t.Fatalf("smart scheduler should prefer lower-in-flight b over a, got %q", acc.ID) + } +} + +// TestRecordSuccessDecrementsInFlight proves finishing a dispatch releases the +// in-flight slot, so the account becomes selectable again on equal footing. +func TestRecordSuccessDecrementsInFlight(t *testing.T) { + p := newHealthTestPool("only") + p.markAccountSelectedLocked("only", time.Now()) + + snap := p.GetRuntimeStatsSnapshot() + if snap["only"].InFlight != 1 { + t.Fatalf("expected inFlight=1 after selection, got %d", snap["only"].InFlight) + } + + p.RecordSuccess("only") + + snap = p.GetRuntimeStatsSnapshot() + if snap["only"].InFlight != 0 { + t.Fatalf("expected inFlight=0 after RecordSuccess, got %d", snap["only"].InFlight) + } + if snap["only"].SuccessCount != 1 { + t.Fatalf("expected successCount=1, got %d", snap["only"].SuccessCount) + } +} + +// TestRecordPermanentRejectionIsNeutral proves a permanent upstream rejection +// releases the in-flight slot WITHOUT recording a success or failure: the +// account's health must not be penalised for a bad request it merely relayed. +func TestRecordPermanentRejectionIsNeutral(t *testing.T) { + p := newHealthTestPool("acc") + p.markAccountSelectedLocked("acc", time.Now()) + + p.RecordPermanentRejection("acc") + + snap := p.GetRuntimeStatsSnapshot() + if snap["acc"].InFlight != 0 { + t.Fatalf("expected inFlight=0 after permanent rejection, got %d", snap["acc"].InFlight) + } + if snap["acc"].SuccessCount != 0 { + t.Fatalf("permanent rejection must not count as success, got %d", snap["acc"].SuccessCount) + } + if snap["acc"].FailureCount != 0 { + t.Fatalf("permanent rejection must not count as failure, got %d", snap["acc"].FailureCount) + } +} + +// TestInFlightClampedAtZero proves a double-finish (a code path bug, or +// RecordError called without a matching selection) can never drive in-flight +// negative. This is the safety guarantee that prevents a leaked finish from +// corrupting the scheduler. +func TestInFlightClampedAtZero(t *testing.T) { + p := newHealthTestPool("acc") + // Finish twice without any selection — must not panic and must clamp at 0. + p.RecordError("acc", false) + p.RecordError("acc", false) + + snap := p.GetRuntimeStatsSnapshot() + if snap["acc"].InFlight != 0 { + t.Fatalf("expected inFlight clamped at 0, got %d", snap["acc"].InFlight) + } +} + +// TestDisableAccountReleasesInFlight proves disabling an account that holds an +// in-flight slot releases it, so the runtimeStats stays accurate across a +// disable/re-enable lifecycle. config must be initialised first because +// DisableAccount persists via config.SetAccountBanStatus. +func TestDisableAccountReleasesInFlight(t *testing.T) { + cfgFile := filepath.Join(t.TempDir(), "config.json") + if err := config.Init(cfgFile); err != nil { + t.Fatalf("config.Init: %v", err) + } + + p := newHealthTestPool("acc") + p.markAccountSelectedLocked("acc", time.Now()) + if snap := p.GetRuntimeStatsSnapshot()["acc"]; snap.InFlight != 1 { + t.Fatalf("expected inFlight=1 before disable, got %d", snap.InFlight) + } + + p.DisableAccount("acc", "test") + + if snap, ok := p.GetRuntimeStatsSnapshot()["acc"]; ok && snap.InFlight != 0 { + t.Fatalf("expected inFlight=0 after disable, got %d", snap.InFlight) + } +} + +// TestLeastCooldownFallbackPreserved proves the repo's availability behaviour +// (return the earliest-cooldown account when all are cooling down, rather than +// nil/503) survived the selection rewrite. +func TestLeastCooldownFallbackPreserved(t *testing.T) { + p := newHealthTestPool("soon", "later") + now := time.Now() + p.cooldowns["soon"] = now.Add(1 * time.Second) + p.cooldowns["later"] = now.Add(1 * time.Hour) + + acc := p.GetNextExcluding(nil) + if acc == nil { + t.Fatal("expected least-cooldown fallback, got nil") + } + if acc.ID != "soon" { + t.Fatalf("expected earliest-cooldown account 'soon', got %q", acc.ID) + } +} + +// TestSelectionSpansBothAccounts proves that across many selections without +// finishing, the scheduler does not starve one account entirely (round-robin +// fairness via recentSelectedCount tie-break once in-flight ties). +func TestSelectionSpansBothAccounts(t *testing.T) { + p := newHealthTestPool("x", "y") + seen := map[string]bool{} + for i := 0; i < 50; i++ { + acc := p.GetNextExcluding(nil) + if acc != nil { + seen[acc.ID] = true + } + } + // Without finishing, both accounts should eventually be selected as the + // recentSelectedCount tie-break rotates the choice. + if !seen["x"] || !seen["y"] { + t.Fatalf("expected both accounts selected across 50 picks, got %v", seen) + } +} diff --git a/pool/tuning.go b/pool/tuning.go new file mode 100644 index 00000000..e0fcceb1 --- /dev/null +++ b/pool/tuning.go @@ -0,0 +1,125 @@ +package pool + +// Pool scheduling tuning: a hard per-account concurrency cap and opt-in account +// selection strategies. Both are configured via environment and default to the +// existing behaviour (no cap, "smart" soft scheduler) so enabling them never +// regresses current deployments. +// +// KIRO_MAX_CONCURRENT_PER_ACCOUNT int hard in-flight cap per account (0 = unlimited) +// KIRO_SELECTION_STRATEGY enum smart|round_robin|random|least_used|most_used +// +// "least_used"/"most_used" rank by remaining quota (UsageLimit-UsageCurrent); +// accounts with no usage limit count as effectively unlimited remaining. +// +// Ported verbatim from kiro-tutu (zero-dep). +import ( + "kiro-go/config" + "math" + "math/rand" + "os" + "strconv" + "strings" +) + +type selectionStrategy int + +const ( + strategySmart selectionStrategy = iota + strategyRoundRobin + strategyRandom + strategyLeastUsed + strategyMostUsed +) + +var ( + poolMaxConcurrent = envIntTuning("KIRO_MAX_CONCURRENT_PER_ACCOUNT", 0) + poolStrategy = parseStrategy(os.Getenv("KIRO_SELECTION_STRATEGY")) +) + +func envIntTuning(name string, def int) int { + if v := strings.TrimSpace(os.Getenv(name)); v != "" { + if n, err := strconv.Atoi(v); err == nil && n >= 0 { + return n + } + } + return def +} + +func parseStrategy(s string) selectionStrategy { + switch strings.ToLower(strings.TrimSpace(s)) { + case "round_robin", "roundrobin", "ordered": + return strategyRoundRobin + case "random": + return strategyRandom + case "least_used", "leastused", "least-used": + return strategyLeastUsed + case "most_used", "mostused", "most-used": + return strategyMostUsed + default: + return strategySmart + } +} + +// applyConcurrencyCap drops candidates whose in-flight count has reached the +// per-account cap. If every candidate is saturated it returns the original set +// unchanged, so a burst degrades to "least-busy" (via the scheduler) rather than +// a hard 503 while capacity technically exists. +func applyConcurrencyCap(candidates []accountCandidate) []accountCandidate { + if poolMaxConcurrent <= 0 || len(candidates) == 0 { + return candidates + } + filtered := make([]accountCandidate, 0, len(candidates)) + for _, c := range candidates { + if c.inFlight < int64(poolMaxConcurrent) { + filtered = append(filtered, c) + } + } + if len(filtered) == 0 { + return candidates + } + return filtered +} + +// selectCandidate chooses one candidate according to the configured strategy. +// candidates must be non-empty. +func selectCandidate(candidates []accountCandidate) accountCandidate { + switch poolStrategy { + case strategyRandom: + return candidates[rand.Intn(len(candidates))] + case strategyRoundRobin: + // Candidates are gathered in rotation order (selectAccountLocked starts + // at an advancing index), so taking the first entry yields round-robin. + return candidates[0] + case strategyLeastUsed: + best := candidates[0] + for _, c := range candidates[1:] { + if remainingQuota(c.account) > remainingQuota(best.account) { + best = c + } + } + return best + case strategyMostUsed: + best := candidates[0] + for _, c := range candidates[1:] { + if remainingQuota(c.account) < remainingQuota(best.account) { + best = c + } + } + return best + default: // strategySmart — the health/load-aware soft scheduler + best := candidates[0] + for _, c := range candidates[1:] { + if accountCandidateLess(c, best) { + best = c + } + } + return best + } +} + +func remainingQuota(acc *config.Account) float64 { + if acc == nil || acc.UsageLimit <= 0 { + return math.MaxFloat64 + } + return acc.UsageLimit - acc.UsageCurrent +} diff --git a/proxy/account_failover.go b/proxy/account_failover.go index 047456fc..8936222a 100644 --- a/proxy/account_failover.go +++ b/proxy/account_failover.go @@ -145,7 +145,9 @@ func (h *Handler) handleAccountFailure(account *config.Account, err error) { // which account relays it. Penalising the account's health, cooling it // down, or rotating to another account would be wrong (the account is // healthy) and harmful (scatters cache affinity, wastes upstream hits). - // Neutral: return without recording any success or failure. + // Neutral: release the in-flight slot the selection took (RecordPermanentRejection) + // without recording any success or failure, then return. + h.pool.RecordPermanentRejection(account.ID) return case isOverageErrorMessage(errMsg): h.disableAccountOverage(account) diff --git a/proxy/prod_gauges.go b/proxy/prod_gauges.go index 47ed7eb4..86a4624f 100644 --- a/proxy/prod_gauges.go +++ b/proxy/prod_gauges.go @@ -28,6 +28,17 @@ func collectPoolGauges() (out []gaugeSample) { gaugeSample{name: "kiro_accounts_available", value: float64(p.AvailableCount())}, ) + // In-flight per account, exposed now that the pool tracks dispatch-level + // in-flight load (health-scoring slice). A non-zero value means the smart + // scheduler is actively routing that account. + for id, s := range p.GetRuntimeStatsSnapshot() { + out = append(out, gaugeSample{ + name: "kiro_account_inflight", + labels: [][2]string{{"account", id}}, + value: float64(s.InFlight), + }) + } + for _, acc := range p.GetAllAccounts() { if acc.UsageLimit > 0 { out = append(out, gaugeSample{ From a2ddc082baecdcf65b5e13b8526d0612a2f1199e Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 11:59:18 +0700 Subject: [PATCH 46/68] feat(audit): durable JSONL audit store + /debug/audit-log (zero-dep P2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the durable request audit / access log from kiro-tutu. Records one structured entry per HTTP request (request id, SHA-256 of the provided API key, method, path, status, latency, response size). Writes are async and non-blocking — a full buffer drops the entry and counts it (kiro_audit_dropped_total) rather than stalling the proxied request. Backend: append-only JSON-lines file (zero dependency). tutu defaults to SQLite (modernc.org/sqlite); this repo keeps a single dependency (github.com/google/uuid), so only the JSONL backend is compiled in. KIRO_AUDIT_BACKEND is accepted for compatibility and any non-"jsonl" value falls back to JSONL with a one-time info note. KIRO_AUDIT_DISABLE turns audit off entirely. Each entry is flushed immediately so a crash loses at most the single in-flight record. Wiring: - proxy/persist.go: AuditEntry, jsonlAudit backend, async AuditStore (buffered chan 4096 + 1 writer goroutine), InitAuditStore/CloseAuditStore, recordAudit, hashAPIKey, AuditRecent (tail-read). - proxy/prod_middleware.go: recordAudit called in instrumentMiddleware (one entry per request); /debug/audit-log route in metricsRouter guarded by adminPasswordOK (constant-time subtle compare); serveAuditLog. - proxy/prod_metrics.go: kiro_audit_dropped_total counter + help. - main.go: InitAuditStore(dir) + defer CloseAuditStore(). The API key is SHA-256 hashed, never stored, so the audit log is safe to expose to an authenticated admin. go build/vet green. The 2 proxy test "failures" (TestClaudeNonStreamRetriesNextAccountAfterPreResponseFailure, TestResponsesStreamSSE) are Windows TempDir-cleanup flakes reproduced indistinguishably on main c59ef41 (8/10 FAIL) vs this branch (9/10 FAIL); no test calls InitAuditStore so globalAudit stays nil and audit cannot affect the TempDir lifecycle. --- main.go | 5 + proxy/persist.go | 269 +++++++++++++++++++++++++++++++++++++++ proxy/prod_metrics.go | 2 + proxy/prod_middleware.go | 68 +++++++++- 4 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 proxy/persist.go diff --git a/main.go b/main.go index 926f4e2e..ae96326a 100644 --- a/main.go +++ b/main.go @@ -67,6 +67,11 @@ func main() { // 初始化账号池 pool.GetPool() + // 初始化请求审计持久化(默认 JSONL,失败自动禁用;KIRO_AUDIT_DISABLE 可关闭)。 + // 审计写入异步非阻塞,缓冲满即丢弃并计数(kiro_audit_dropped_total),绝不拖垮主流程。 + proxy.InitAuditStore(filepath.Dir(configPath)) + defer proxy.CloseAuditStore() + // 创建 HTTP 处理器(包含后台刷新任务) handler := proxy.NewHandler() diff --git a/proxy/persist.go b/proxy/persist.go new file mode 100644 index 00000000..5eaf6f04 --- /dev/null +++ b/proxy/persist.go @@ -0,0 +1,269 @@ +package proxy + +// Durable request audit / access log (production persistence + observability). +// +// Records one structured entry per HTTP request: request id, hashed API key, +// method, path, status, latency and response size. Writes are asynchronous and +// non-blocking — if the buffer is full an entry is dropped (and counted) rather +// than stalling the proxied request. +// +// Backend: append-only JSON-lines file (zero dependency). The tutu reference +// defaults to SQLite (modernc.org/sqlite) with a JSONL fallback; this repo keeps +// a single dependency (github.com/google/uuid), so only the JSONL backend is +// compiled in. KIRO_AUDIT_DISABLE turns audit off entirely. +// +// Each entry is flushed immediately (bufio.Flush after every write) so a crash +// loses at most the single in-flight record — no WAL or batched fsync needed. +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "kiro-go/logger" + "os" + "path/filepath" + "strings" + "sync" +) + +// AuditEntry is one persisted request record. +type AuditEntry struct { + RequestID string `json:"request_id"` + TimeUnixMs int64 `json:"ts_ms"` + APIKeyHash string `json:"api_key_hash,omitempty"` + Method string `json:"method"` + Path string `json:"path"` + Status int `json:"status"` + LatencyMs int64 `json:"latency_ms"` + Bytes int `json:"bytes"` +} + +type auditBackend interface { + write(AuditEntry) error + queryRecent(limit int) ([]AuditEntry, error) + Close() error +} + +// ---- JSON-lines backend ---- + +// jsonlAuditMaxTailBytes bounds how much of the file queryRecent reads when +// looking for the most recent entries. 4 MiB is far more than limit*entrySize +// for any sane limit, while keeping a tail read cheap on a large audit file. +const jsonlAuditMaxTailBytes int64 = 4 << 20 + +type jsonlAudit struct { + mu sync.Mutex + f *os.File + w *bufio.Writer +} + +func newJSONLAudit(path string) (*jsonlAudit, error) { + f, err := os.OpenFile(path, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return nil, err + } + return &jsonlAudit{f: f, w: bufio.NewWriter(f)}, nil +} + +func (j *jsonlAudit) write(e AuditEntry) error { + b, err := json.Marshal(e) + if err != nil { + return err + } + j.mu.Lock() + defer j.mu.Unlock() + if _, err := j.w.Write(b); err != nil { + return err + } + if err := j.w.WriteByte('\n'); err != nil { + return err + } + return j.w.Flush() // flush each entry so a crash loses at most the in-flight write +} + +// queryRecent returns the most recent up-to-limit entries by reading the file +// tail. The file is append-only, so the newest entries are at the end. +func (j *jsonlAudit) queryRecent(limit int) ([]AuditEntry, error) { + if limit <= 0 || limit > 1000 { + limit = 100 + } + j.mu.Lock() + path := j.f.Name() + j.mu.Unlock() + + // Open a separate read handle so the writer's appends keep flowing. + rf, err := os.Open(path) + if err != nil { + return nil, err + } + defer rf.Close() + + st, err := rf.Stat() + if err != nil { + return nil, err + } + size := st.Size() + off := int64(0) + if size > jsonlAuditMaxTailBytes { + off = size - jsonlAuditMaxTailBytes + } + if _, err := rf.Seek(off, 0); err != nil { + return nil, err + } + + // Sliding window of the last `limit` decoded entries. + var ring []AuditEntry + sc := bufio.NewScanner(rf) + sc.Buffer(make([]byte, 64*1024), 4*1024*1024) + for sc.Scan() { + line := strings.TrimSpace(sc.Text()) + if line == "" { + continue + } + var e AuditEntry + if err := json.Unmarshal([]byte(line), &e); err != nil { + continue // skip malformed/partial line (e.g. mid-write tail) + } + ring = append(ring, e) + if len(ring) > limit { + ring = ring[len(ring)-limit:] + } + } + if err := sc.Err(); err != nil { + return nil, err + } + return ring, nil +} + +func (j *jsonlAudit) Close() error { + j.mu.Lock() + defer j.mu.Unlock() + if err := j.w.Flush(); err != nil { + _ = j.f.Close() + return err + } + return j.f.Close() +} + +// ---- Async store ---- + +// AuditStore drains entries from a buffered channel to its backend on a single +// writer goroutine. Record never blocks the caller. +type AuditStore struct { + ch chan AuditEntry + backend auditBackend + wg sync.WaitGroup + closeOnce sync.Once +} + +func NewAuditStore(backend auditBackend, bufSize int) *AuditStore { + if bufSize <= 0 { + bufSize = 4096 + } + s := &AuditStore{ch: make(chan AuditEntry, bufSize), backend: backend} + s.wg.Add(1) + go s.loop() + return s +} + +func (s *AuditStore) loop() { + defer s.wg.Done() + for e := range s.ch { + if err := s.backend.write(e); err != nil { + logger.Warnf("[Audit] write failed: %v", err) + } + } +} + +// Record enqueues an entry without blocking; a full buffer drops the entry. +func (s *AuditStore) Record(e AuditEntry) { + select { + case s.ch <- e: + default: + metricsAuditDropped() + } +} + +// Close stops the writer, flushing buffered entries, and closes the backend. +func (s *AuditStore) Close() { + s.closeOnce.Do(func() { + close(s.ch) + s.wg.Wait() + if err := s.backend.Close(); err != nil { + logger.Warnf("[Audit] close failed: %v", err) + } + }) +} + +// ---- Package-level wiring ---- + +var globalAudit *AuditStore + +// InitAuditStore initializes the process-wide audit store under dir. It is a +// no-op when KIRO_AUDIT_DISABLE is set. +func InitAuditStore(dir string) { + if strings.TrimSpace(os.Getenv("KIRO_AUDIT_DISABLE")) != "" { + logger.Infof("[Audit] disabled via KIRO_AUDIT_DISABLE") + return + } + if dir == "" { + dir = "." + } + if err := os.MkdirAll(dir, 0o755); err != nil { + logger.Warnf("[Audit] cannot create dir %s: %v", dir, err) + return + } + + // KIRO_AUDIT_BACKEND is accepted for compatibility with kiro-tutu, but only + // the JSONL backend is compiled in here (zero-dep). Any non-"jsonl" value + // falls back to JSONL with a one-time info note so operators aren't confused. + backendName := strings.ToLower(strings.TrimSpace(os.Getenv("KIRO_AUDIT_BACKEND"))) + if backendName != "" && backendName != "jsonl" { + logger.Infof("[Audit] backend %q requested but only JSONL is compiled in; using JSONL", backendName) + } + backendName = "jsonl" + + jb, err := newJSONLAudit(filepath.Join(dir, "kiro-audit.jsonl")) + if err != nil { + logger.Errorf("[Audit] disabled: cannot open JSONL backend: %v", err) + return + } + + globalAudit = NewAuditStore(jb, 4096) + logger.Infof("[Audit] enabled (backend=%s, dir=%s)", backendName, dir) +} + +// CloseAuditStore flushes and closes the audit store, if any. +func CloseAuditStore() { + if globalAudit != nil { + globalAudit.Close() + } +} + +func recordAudit(e AuditEntry) { + if globalAudit != nil { + globalAudit.Record(e) + } +} + +func hashAPIKey(key string) string { + if key == "" { + return "" + } + sum := sha256.Sum256([]byte(key)) + return hex.EncodeToString(sum[:]) +} + +// QueryRecent returns the most recent audit entries when the backend supports it. +func (s *AuditStore) QueryRecent(limit int) ([]AuditEntry, error) { + return s.backend.queryRecent(limit) +} + +// AuditRecent reads recent audit entries from the process-wide store. +func AuditRecent(limit int) ([]AuditEntry, error) { + if globalAudit == nil { + return nil, fmt.Errorf("audit store not initialized") + } + return globalAudit.QueryRecent(limit) +} diff --git a/proxy/prod_metrics.go b/proxy/prod_metrics.go index 5068960b..6c512878 100644 --- a/proxy/prod_metrics.go +++ b/proxy/prod_metrics.go @@ -41,6 +41,7 @@ var counterHelp = map[string]string{ "kiro_http_requests_total": "Total HTTP requests handled, labelled by method, code and path class.", "kiro_ratelimit_rejected_total": "Total inbound requests rejected by the rate limiter.", "kiro_panics_recovered_total": "Total panics recovered by the recovery middleware.", + "kiro_audit_dropped_total": "Audit entries dropped because the async buffer was full.", "kiro_token_refresh_total": "Total OAuth token refresh attempts, by result.", } @@ -190,6 +191,7 @@ func metricsRequest(method, code, pathClass string) { } func metricsRateLimited() { globalMetrics.incCounter("kiro_ratelimit_rejected_total") } func metricsPanic() { globalMetrics.incCounter("kiro_panics_recovered_total") } +func metricsAuditDropped() { globalMetrics.incCounter("kiro_audit_dropped_total") } func metricsTokenRefresh(result string) { globalMetrics.incCounter(metricSeries("kiro_token_refresh_total", [][2]string{{"result", result}})) } diff --git a/proxy/prod_middleware.go b/proxy/prod_middleware.go index bc3b2405..aad70ef4 100644 --- a/proxy/prod_middleware.go +++ b/proxy/prod_middleware.go @@ -18,7 +18,10 @@ package proxy // correspondingly omitted from the metrics. import ( "context" + "crypto/subtle" + "encoding/json" "io" + "kiro-go/config" "kiro-go/logger" "net/http" "runtime/debug" @@ -137,19 +140,63 @@ func requestIDMiddleware(next http.Handler) http.Handler { }) } -// metricsRouter serves /metrics (Prometheus text exposition) without forwarding -// to the inner handler. Everything else passes through. +// metricsRouter serves /metrics (Prometheus text exposition) and /debug/audit-log +// without forwarding to the inner handler. Everything else passes through. func metricsRouter(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path == "/metrics" { + switch r.URL.Path { + case "/metrics": w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8") _, _ = io.WriteString(w, globalMetrics.Render()) return + case "/debug/audit-log": + serveAuditLog(w, r) + return } next.ServeHTTP(w, r) }) } +// adminPasswordOK guards the audit-log endpoint with the admin password +// (header X-Admin-Password or ?password=), compared in constant time so a +// network observer cannot time-attack the password. Empty configured password +// fails closed. +func adminPasswordOK(r *http.Request) bool { + pw := config.GetPassword() + if pw == "" { + return false + } + got := r.Header.Get("X-Admin-Password") + if got == "" { + got = r.URL.Query().Get("password") + } + return subtle.ConstantTimeCompare([]byte(got), []byte(pw)) == 1 +} + +// serveAuditLog exposes recent structured audit entries as JSON for the admin +// UI's error/access-log view. Read-only; requires the admin password. +func serveAuditLog(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if !adminPasswordOK(r) { + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"type":"error","error":{"type":"authentication_error","message":"admin password required"}}`) + return + } + limit := 100 + if v := strings.TrimSpace(r.URL.Query().Get("limit")); v != "" { + if n, err := strconv.Atoi(v); err == nil && n > 0 { + limit = n + } + } + entries, err := AuditRecent(limit) + if err != nil { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = io.WriteString(w, `{"type":"error","error":{"type":"api_error","message":"audit log unavailable"}}`) + return + } + _ = json.NewEncoder(w).Encode(map[string]interface{}{"count": len(entries), "entries": entries}) +} + // securityHeadersMiddleware sets baseline browser-security headers (admin panel // clickjacking / MIME-sniffing protection). func securityHeadersMiddleware(next http.Handler) http.Handler { @@ -178,6 +225,21 @@ func instrumentMiddleware(next http.Handler) http.Handler { lat := time.Since(start) metricsDuration(lat) metricsRequest(r.Method, strconv.Itoa(rec.status), pathClass(r.URL.Path)) + // Audit: one structured entry per request (request id, SHA-256 of the + // provided API key, method, path, status, latency, response size). Async + // + non-blocking (recordAudit drops on a full buffer and counts it). The + // API key is hashed, never stored, so the audit log is safe to expose via + // /debug/audit-log to an authenticated admin. + recordAudit(AuditEntry{ + RequestID: requestIDFromContext(r.Context()), + TimeUnixMs: start.UnixMilli(), + APIKeyHash: hashAPIKey(extractProvidedKey(r)), + Method: r.Method, + Path: r.URL.Path, + Status: rec.status, + LatencyMs: lat.Milliseconds(), + Bytes: rec.bytes, + }) }) } From 1cc18432886cd7f27d7ada836b6569f42a68deab Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 14:52:19 +0700 Subject: [PATCH 47/68] docs(metrics): reconcile stale comments with merged audit + health-scoring slices Three comment blocks lagged behind slices already merged into main and contradicted the code sitting next to them: - prod_gauges.go header claimed kiro_account_inflight was omitted because "the health-scoring+inFlight slice was not ported." That slice merged in c59ef41, and collectPoolGauges already emits the gauge from GetRuntimeStatsSnapshot. Reword to match reality. - prod_metrics.go gaugeHelp had no entry for kiro_account_inflight, so /metrics rendered it with a generic "Gauge." help line. Add the entry. - prod_metrics.go and prod_middleware.go package docs claimed the audit store and kiro_audit_dropped_total were "intentionally omitted (no audit store)." The JSONL audit store merged in a2ddc08 and the counter is incremented from persist.go. Reword both; only cluster drain remains genuinely excluded. No behaviour change. Comments-only, so build/vet stay green. --- proxy/prod_gauges.go | 13 ++++++------- proxy/prod_metrics.go | 7 ++++--- proxy/prod_middleware.go | 7 ++++--- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/proxy/prod_gauges.go b/proxy/prod_gauges.go index 86a4624f..03a3fec1 100644 --- a/proxy/prod_gauges.go +++ b/proxy/prod_gauges.go @@ -1,14 +1,13 @@ package proxy // Pool-backed Prometheus gauges. Registered as the metrics gaugeProvider so a -// /metrics scrape reflects live pool state (account counts and remaining -// credential quota). Collection is guarded so a scrape can never panic the -// metrics endpoint. +// /metrics scrape reflects live pool state: account counts, per-account +// in-flight dispatch load, and remaining credential quota. Collection is +// guarded so a scrape can never panic the metrics endpoint. // -// Ported from kiro-tutu. The tutu version also emits kiro_account_inflight from -// pool.GetRuntimeStatsSnapshot; this repo has no in-flight tracking (the -// health-scoring+inFlight slice was not ported), so that gauge is omitted — the -// gaugeHelp map in prod_metrics.go correspondingly has no entry for it. +// Ported from kiro-tutu. kiro_account_inflight is emitted from +// pool.GetRuntimeStatsSnapshot, populated by the health-scoring + inFlight +// dispatch slice. import "kiro-go/pool" func init() { diff --git a/proxy/prod_metrics.go b/proxy/prod_metrics.go index 6c512878..060f9f31 100644 --- a/proxy/prod_metrics.go +++ b/proxy/prod_metrics.go @@ -5,9 +5,9 @@ package proxy // Rather than pull in a metrics client library, this exposes a tiny, correct // subset of the Prometheus text format (v0.0.4). Collection is lock-guarded and // off the request hot path only by a single map insert + a few atomics, so it -// never blocks the proxied stream. Ported from kiro-tutu; no audit counter is -// emitted here (this repo has no audit store — kiro_audit_dropped_total is -// intentionally omitted). +// never blocks the proxied stream. Ported from kiro-tutu. The audit counter +// kiro_audit_dropped_total is incremented by the async audit store +// (persist.go) when its buffer overflows. import ( "fmt" "sort" @@ -60,6 +60,7 @@ var gaugeProvider func() []gaugeSample var gaugeHelp = map[string]string{ "kiro_accounts_total": "Total configured accounts in the pool.", "kiro_accounts_available": "Accounts currently routable (not cooling down).", + "kiro_account_inflight": "In-flight dispatches currently assigned to the account.", "kiro_credential_quota_remaining": "Remaining usage quota per account (UsageLimit-UsageCurrent).", } diff --git a/proxy/prod_middleware.go b/proxy/prod_middleware.go index aad70ef4..c9f39ad8 100644 --- a/proxy/prod_middleware.go +++ b/proxy/prod_middleware.go @@ -13,9 +13,10 @@ package proxy // statusRecorder implements http.Flusher and Unwrap. Rate limiting and metrics // are opt-in / always-on respectively and add no dependency. // -// Deliberately NOT included (they need deps / infrastructure absent from this -// repo): SQLite/JSONL audit store, cluster drain. kiro_audit_dropped_total is -// correspondingly omitted from the metrics. +// The async JSONL audit store (persist.go) is wired from main and fed by +// instrumentMiddleware; kiro_audit_dropped_total reflects its buffer overflow. +// Deliberately NOT included (needs deps/infrastructure absent from this repo): +// cluster drain. import ( "context" "crypto/subtle" From b27686ca1a26a67cdc56264dbe9e181ea5c7c9ef Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 14:55:32 +0700 Subject: [PATCH 48/68] feat(translator): tool-definition compression (prevents upstream "Improperly formed request") MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from kiro-tutu (zero-dep: stdlib + kiro-go/logger only). Motivation: when a request carries a large tools array, the raw serialized payload can exceed Kiro's accepted size and the upstream answers with a 400 "Improperly formed request." This repo already DETECTS that rejection after the fact (upstream_error.go isImproperlyFormedRejection, wired at kiro.go:393) and permanently short-circuits the request. This slice adds the complementary PREVENTION: compress oversized tool definitions before send, so fewer requests get rejected in the first place. compressToolsIfNeeded does two progressive stages, each stop-early on success: 1. Recursively simplify each tool's inputSchema — keep the structural skeleton (type, required, enum, $ref, anyOf/oneOf/allOf, properties, items) and strip purely descriptive fields (description, examples, title, default). These are usually the bulk of the bytes and are not needed for the model to understand parameter structure. 2. If still over threshold, ratio-truncate each description (UTF-8 safe, keep at least minToolDescChars so the tool stays identifiable). Threshold default 20 KiB (KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES); 0 disables. Correctness notes baked into the schema simplifier (locked by tests): - required / enum / nested-object required are preserved — dropping them would change parameter semantics and turn an accepted request into a rejection (worse than no compression). - A node with only $ref (no type) keeps $ref so it does not collapse to an empty {} the model/upstream cannot interpret. - anyOf/oneOf/allOf subschemas are recursively simplified. Wired into both converter exits (complements existing per-tool maxToolDescLen truncation + cleanSchema): - convertClaudeTools (translator.go) - convertOpenAITools (translator.go) Tests (9): no-op under threshold, schema-only suffices, description truncation fallback + UTF-8 safety, threshold=0 disable, env resolution table, end-to-end via convertClaudeTools, constraint-preservation (type/required/enum/$ref/nested/anyOf), CJK-safe ratio truncation. build/vet/test green: proxy 5.573s, pool 0.388s. --- proxy/tool_compression.go | 214 +++++++++++++++++++++++ proxy/tool_compression_test.go | 301 +++++++++++++++++++++++++++++++++ proxy/translator.go | 2 + 3 files changed, 517 insertions(+) create mode 100644 proxy/tool_compression.go create mode 100644 proxy/tool_compression_test.go diff --git a/proxy/tool_compression.go b/proxy/tool_compression.go new file mode 100644 index 00000000..bd80e2e9 --- /dev/null +++ b/proxy/tool_compression.go @@ -0,0 +1,214 @@ +package proxy + +import ( + "encoding/json" + "os" + "strconv" + "strings" + "unicode/utf8" + + "kiro-go/logger" +) + +// 工具定义体积压缩 +// +// 当一次请求携带的工具定义(name + description + inputSchema)序列化后的总字节数 +// 超过阈值时,对齐 kiro-rs 的 compress_tools_if_needed 做两步渐进压缩,避免把超大的 +// tools 数组原样上送、被 Kiro 上游以 "Improperly formed request." 拒绝: +// 1. 递归简化每个工具的 inputSchema:仅保留结构骨架(type / required / properties 的 +// key 与其 type),剥掉 description / examples / enum 等纯说明性字段(这些往往是体 +// 积大头,但对模型选参的结构理解非必需)。 +// 2. 若简化 schema 后仍超阈值,按超出比例截断每个工具的 description(UTF-8 安全,至少 +// 保留 minToolDescChars 个字符,以免描述被砍到无法辨识工具用途)。 +// +// 注意:本压缩是“总量兜底”,与既有的 per-tool 上限(maxToolDescLen)和非法字段清理 +// (cleanSchema)互补,不替代它们——前两者在单个工具维度先行处理,本函数只在所有工具 +// 加起来仍然过大时才介入。 +// +// 与 upstream_error.go 的 isImproperlyFormedRejection 互补:那边在被上游拒绝后做检测、 +// 永久短路本请求;这里在发送前做预防、尽量不让请求被拒。 + +// defaultToolsSizeThreshold 是触发工具压缩的总字节阈值。20KB 对齐 kiro-rs 的 +// TOOL_SIZE_THRESHOLD 经验值;Kiro 上游真实红线未公开,可经 env 按实测调整。 +const defaultToolsSizeThreshold = 20 * 1024 + +// minToolDescChars 是 description 截断后至少保留的字符数(rune 计,UTF-8 安全), +// 保证压缩后描述仍可辨识工具用途。对齐 kiro-rs 的 MIN_DESCRIPTION_CHARS。 +const minToolDescChars = 50 + +// resolveToolsSizeThreshold 读取 env 覆盖(KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES)。 +// 非法值回落默认;0 表示禁用压缩(退回原样上送,由上游与既有 per-tool 上限兜底)。 +func resolveToolsSizeThreshold() int { + raw := strings.TrimSpace(os.Getenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES")) + if raw == "" { + return defaultToolsSizeThreshold + } + n, err := strconv.Atoi(raw) + if err != nil || n < 0 { + return defaultToolsSizeThreshold + } + return n +} + +// compressToolsIfNeeded 在工具定义总体积超过阈值时执行两步压缩,否则原样返回。 +// 入参 tools 来自 convertClaudeTools / convertOpenAITools 的产出(已做过 per-tool +// 截断与 schema 清理),本函数只负责总量收敛。 +func compressToolsIfNeeded(tools []KiroToolWrapper) []KiroToolWrapper { + threshold := resolveToolsSizeThreshold() + if threshold <= 0 || len(tools) == 0 { + return tools + } + + total := estimateToolsBytes(tools) + if total <= threshold { + return tools + } + + // 第一步:递归简化每个工具的 inputSchema。 + for i := range tools { + tools[i].ToolSpecification.InputSchema.JSON = simplifyToolSchema(tools[i].ToolSpecification.InputSchema.JSON) + } + + afterSchema := estimateToolsBytes(tools) + if afterSchema <= threshold { + logger.Infof("[ToolCompress] %d tools compressed by schema simplification: %d -> %d bytes (threshold %d)", + len(tools), total, afterSchema, threshold) + return tools + } + + // 第二步:按比例截断 description(基于字节比例,UTF-8 安全,保底 minToolDescChars)。 + ratio := float64(threshold) / float64(afterSchema) + for i := range tools { + desc := tools[i].ToolSpecification.Description + tools[i].ToolSpecification.Description = truncateDescByRatio(desc, ratio) + } + + final := estimateToolsBytes(tools) + logger.Infof("[ToolCompress] %d tools compressed (schema + description): %d -> %d -> %d bytes (threshold %d)", + len(tools), total, afterSchema, final, threshold) + return tools +} + +// estimateToolsBytes 估算工具列表序列化后的总字节数。直接对整个 slice 做 json.Marshal +// 取长度,口径与上送前 json.Marshal(payload) 内 tools 部分一致——把 toolSpecification / +// name / description / inputSchema 等 JSON key、引号、括号、逗号等结构开销也算进去, +// 避免只累加字段长度导致的系统性低估(低估会让临界请求“该压不压”仍被上游拒)。 +// Marshal 失败(理论上不会,KiroToolWrapper 全是可序列化字段)时回退到字段长度累加。 +func estimateToolsBytes(tools []KiroToolWrapper) int { + if raw, err := json.Marshal(tools); err == nil { + return len(raw) + } + total := 0 + for i := range tools { + spec := tools[i].ToolSpecification + total += len(spec.Name) + len(spec.Description) + if raw, err := json.Marshal(spec.InputSchema.JSON); err == nil { + total += len(raw) + } + } + return total +} + +// truncateDescByRatio 按 ratio(字节比例)截断 desc,UTF-8 安全,至少保留 +// minToolDescChars 个字符。ratio>=1 时不截断。 +// +// ratio = threshold / afterSchemaBytes 是按字节算出的,故 target 也按字节算(而非乘 +// rune 数),口径自洽——否则对 CJK 这类多字节文本会把字节比误当字符比,导致过度截断。 +// 取到目标字节数后回退到不超过它的最近 UTF-8 字符边界,保证不切坏多字节字符。 +func truncateDescByRatio(desc string, ratio float64) string { + if ratio >= 1 { + return desc + } + if len([]rune(desc)) <= minToolDescChars { + return desc + } + + targetBytes := int(float64(len(desc)) * ratio) + + // 保底:至少保留 minToolDescChars 个字符对应的字节数。 + minBytes := len(desc) + if r := []rune(desc); len(r) > minToolDescChars { + minBytes = len(string(r[:minToolDescChars])) + } + if targetBytes < minBytes { + targetBytes = minBytes + } + if targetBytes >= len(desc) { + return desc + } + + // 回退到 <= targetBytes 的最近字符边界(避免切坏多字节 rune)。 + cut := targetBytes + for cut > 0 && !utf8.RuneStart(desc[cut]) { + cut-- + } + return desc[:cut] +} + +// simplifyToolSchema 递归简化 JSON Schema:保留结构与约束骨架,剥掉纯说明性字段。 +// - 保留:type、required、enum、$ref、anyOf/oneOf/allOf、properties、items(结构与 +// 选参约束相关,去掉会改变上游/模型对参数的理解)。 +// - 移除:description、examples、default、title、$comment 等纯说明性字段(体积大头, +// 对模型选参非必需)。 +// +// 仅处理 map[string]interface{} 形态的 schema(ensureObjectSchema 已保证顶层为该形态); +// 其他形态原样返回。注意:本函数返回新 map,不修改入参。 +func simplifyToolSchema(schema interface{}) interface{} { + m, ok := schema.(map[string]interface{}) + if !ok { + return schema + } + + result := make(map[string]interface{}) + + // 保留顶层结构 / 约束字段(剔除 additionalProperties——cleanSchema 已要求移除它)。 + // $ref / anyOf / oneOf / allOf 在缺 type 时是该节点唯一的语义来源,必须保留,否则 + // 节点会塌成 {} 让模型与上游都无法理解。 + copySchemaKeptKeys(m, result) + + // properties:递归简化每个属性。 + if props, ok := m["properties"].(map[string]interface{}); ok { + simplified := make(map[string]interface{}, len(props)) + for name, prop := range props { + simplified[name] = simplifyToolSchema(prop) + } + result["properties"] = simplified + } + + // items(数组元素 schema):递归简化(可能是单个 schema 或 schema 数组)。 + if items, exists := m["items"]; exists { + result["items"] = simplifyToolSchema(items) + } + + return result +} + +// schemaKeptKeys 是简化时保留的非递归字段:结构(type/required)、选参约束(enum)、 +// 组合/引用($ref/anyOf/oneOf/allOf)、以及 $schema。description/examples/default/title +// 等说明性字段不在此列,会被剥除。 +var schemaKeptKeys = []string{"$schema", "type", "required", "enum", "$ref", "anyOf", "oneOf", "allOf"} + +// copySchemaKeptKeys 把 src 中 schemaKeptKeys 列出的字段拷到 dst。对 anyOf/oneOf/allOf +// 这类「子 schema 数组」会递归简化其中每个元素。 +func copySchemaKeptKeys(src, dst map[string]interface{}) { + for _, key := range schemaKeptKeys { + v, exists := src[key] + if !exists { + continue + } + switch key { + case "anyOf", "oneOf", "allOf": + if arr, ok := v.([]interface{}); ok { + simplifiedArr := make([]interface{}, len(arr)) + for i, sub := range arr { + simplifiedArr[i] = simplifyToolSchema(sub) + } + dst[key] = simplifiedArr + continue + } + dst[key] = v + default: + dst[key] = v + } + } +} diff --git a/proxy/tool_compression_test.go b/proxy/tool_compression_test.go new file mode 100644 index 00000000..29e1ccc8 --- /dev/null +++ b/proxy/tool_compression_test.go @@ -0,0 +1,301 @@ +package proxy + +import ( + "encoding/json" + "strings" + "testing" +) + +// makeKiroTool 构造一个工具 wrapper,desc 为给定描述,schema 为给定 JSON 对象。 +func makeKiroTool(name, desc string, schema map[string]interface{}) KiroToolWrapper { + w := KiroToolWrapper{} + w.ToolSpecification.Name = name + w.ToolSpecification.Description = desc + w.ToolSpecification.InputSchema = InputSchema{JSON: schema} + return w +} + +// schemaWithDescriptions 造一个靠 description/examples 撑大的 schema,用于验证简化能 +// 剥掉这些说明性字段、显著减小体积。刻意不带 enum——enum 属于“保留”字段(见 +// TestSimplifyToolSchemaPreservesConstraints),混进来会让本类“简化即达标”的体积断言 +// 受 enum 保留量干扰。 +func schemaWithDescriptions(propCount int, descPerProp string) map[string]interface{} { + props := make(map[string]interface{}, propCount) + for i := 0; i < propCount; i++ { + key := "field" + strings.Repeat("x", 3) + string(rune('a'+i%26)) + string(rune('0'+i%10)) + props[key] = map[string]interface{}{ + "type": "string", + "description": descPerProp, + "examples": []interface{}{descPerProp, descPerProp}, + "title": descPerProp, + } + } + return map[string]interface{}{ + "type": "object", + "required": []interface{}{"fieldxxxa0"}, + "properties": props, + } +} + +func TestCompressToolsNoOpUnderThreshold(t *testing.T) { + t.Setenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES", "") + tools := []KiroToolWrapper{ + makeKiroTool("smallTool", "a short description", map[string]interface{}{ + "type": "object", + "properties": map[string]interface{}{"x": map[string]interface{}{"type": "string"}}, + }), + } + before, _ := json.Marshal(tools) + out := compressToolsIfNeeded(tools) + after, _ := json.Marshal(out) + if string(before) != string(after) { + t.Fatalf("expected no-op under threshold\nbefore=%s\nafter=%s", before, after) + } +} + +func TestCompressToolsSchemaSimplificationBringsUnderThreshold(t *testing.T) { + // 阈值设小,让“简化 schema”这一步就足以达标,验证不会进入 description 截断。 + t.Setenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES", "2048") + desc := "keep me readable" // 短描述,简化 schema 后总量应已达标 + tools := []KiroToolWrapper{ + makeKiroTool("bigSchemaTool", desc, schemaWithDescriptions(40, strings.Repeat("verbose ", 20))), + } + if estimateToolsBytes(tools) <= 2048 { + t.Fatalf("precondition: tools should exceed threshold before compression") + } + + out := compressToolsIfNeeded(tools) + + if got := estimateToolsBytes(out); got > 2048 { + t.Fatalf("expected size <= threshold after schema simplification, got %d", got) + } + // description 未被截断(schema 简化已足够)。 + if out[0].ToolSpecification.Description != desc { + t.Fatalf("description should be intact when schema simplification suffices, got %q", out[0].ToolSpecification.Description) + } + // 简化后:结构骨架保留,说明性字段剥离。 + schema := out[0].ToolSpecification.InputSchema.JSON.(map[string]interface{}) + if schema["type"] != "object" { + t.Fatalf("top-level type must be preserved") + } + if _, ok := schema["required"]; !ok { + t.Fatalf("required must be preserved") + } + props := schema["properties"].(map[string]interface{}) + for name, p := range props { + pm := p.(map[string]interface{}) + if pm["type"] != "string" { + t.Fatalf("prop %s type must be preserved", name) + } + if _, ok := pm["description"]; ok { + t.Fatalf("prop %s description must be stripped", name) + } + if _, ok := pm["enum"]; ok { + t.Fatalf("prop %s enum must be stripped", name) + } + if _, ok := pm["examples"]; ok { + t.Fatalf("prop %s examples must be stripped", name) + } + } +} + +func TestCompressToolsTruncatesDescriptionWhenSchemaNotEnough(t *testing.T) { + // 阈值极小,简化 schema 仍不够 → 必须截断 description;验证保底字符数与 UTF-8 安全。 + t.Setenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES", "200") + longDesc := strings.Repeat("用途说明", 200) // 多字节字符,验证不切坏 rune + tools := []KiroToolWrapper{ + makeKiroTool("toolWithLongDesc", longDesc, schemaWithDescriptions(10, "x")), + } + + out := compressToolsIfNeeded(tools) + + gotDesc := out[0].ToolSpecification.Description + if len([]rune(gotDesc)) >= len([]rune(longDesc)) { + t.Fatalf("description should be truncated") + } + if len([]rune(gotDesc)) < minToolDescChars { + t.Fatalf("description must keep at least %d chars, got %d", minToolDescChars, len([]rune(gotDesc))) + } + // UTF-8 安全:截断结果仍是合法字符串(rune 往返一致)。 + if string([]rune(gotDesc)) != gotDesc { + t.Fatalf("truncation broke UTF-8 boundary") + } +} + +func TestCompressToolsDisabledByZeroThreshold(t *testing.T) { + t.Setenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES", "0") + tools := []KiroToolWrapper{ + makeKiroTool("bigTool", "d", schemaWithDescriptions(40, strings.Repeat("verbose ", 20))), + } + before, _ := json.Marshal(tools) + out := compressToolsIfNeeded(tools) + after, _ := json.Marshal(out) + if string(before) != string(after) { + t.Fatalf("threshold=0 must disable compression (pass-through)") + } +} + +func TestResolveToolsSizeThreshold(t *testing.T) { + cases := []struct { + name string + env string + want int + }{ + {"empty falls back to default", "", defaultToolsSizeThreshold}, + {"explicit zero disables", "0", 0}, + {"valid override", "4096", 4096}, + {"negative falls back", "-1", defaultToolsSizeThreshold}, + {"garbage falls back", "abc", defaultToolsSizeThreshold}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES", tc.env) + if got := resolveToolsSizeThreshold(); got != tc.want { + t.Fatalf("env=%q want %d got %d", tc.env, tc.want, got) + } + }) + } +} + +// TestCompressToolsEndToEndViaConvertClaude 验证压缩已接到 Claude 工具转换路径: +// 构造少量但 schema 臃肿的工具,经 convertClaudeTools 后体积应被显著压缩。 +// 注意:对齐 kiro-rs,压缩是“尽力而为”——简化 schema + 截断 description 两步后即返回, +// 不保证一定降到阈值内(工具数量本身极多时可能仍超),故这里断言“显著变小”而非“必达阈值”。 +func TestCompressToolsEndToEndViaConvertClaude(t *testing.T) { + t.Setenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES", "2048") + claudeTools := make([]ClaudeTool, 0, 4) + for i := 0; i < 4; i++ { + claudeTools = append(claudeTools, ClaudeTool{ + Name: "tool" + string(rune('A'+i%26)) + string(rune('0'+i%10)), + Description: "a tool", + InputSchema: schemaWithDescriptions(8, strings.Repeat("verbose ", 16)), + }) + } + + // 不经压缩时的体积(关掉压缩量一次基线)。 + t.Setenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES", "0") + rawWrappers, _ := convertClaudeTools(claudeTools) + rawBytes := estimateToolsBytes(rawWrappers) + + // 开启压缩。 + t.Setenv("KIRO_TOOLS_COMPRESS_THRESHOLD_BYTES", "2048") + wrappers, _ := convertClaudeTools(claudeTools) + got := estimateToolsBytes(wrappers) + + if got > 2048 { + t.Fatalf("少量臃肿工具应被压到阈值内,got %d bytes", got) + } + if got >= rawBytes { + t.Fatalf("压缩后应显著小于原始体积:raw=%d compressed=%d", rawBytes, got) + } +} + +// TestSimplifyToolSchemaPreservesConstraints 锁定 code review 发现的高危回归:简化 +// schema 时必须保留 type/required/enum 及 $ref/anyOf/oneOf/allOf,且嵌套 object 的 +// required 不能丢——否则“压缩”反而把原本能过的请求改成上游拒收。 +func TestSimplifyToolSchemaPreservesConstraints(t *testing.T) { + schema := map[string]interface{}{ + "type": "object", + "required": []interface{}{"mode", "addr"}, + "properties": map[string]interface{}{ + // 枚举约束必须保留(模型据此选值)。 + "mode": map[string]interface{}{ + "type": "string", + "enum": []interface{}{"r", "w"}, + "description": "should be stripped", + }, + // 嵌套 object:其 required 与子属性 type 必须保留。 + "addr": map[string]interface{}{ + "type": "object", + "required": []interface{}{"host"}, + "properties": map[string]interface{}{ + "host": map[string]interface{}{"type": "string", "description": "drop me"}, + "port": map[string]interface{}{"type": "integer"}, + }, + }, + // 只有 $ref、无 type:不能被压成 {},$ref 必须保留。 + "ref": map[string]interface{}{ + "$ref": "#/$defs/Foo", + "description": "drop me", + }, + // anyOf 子 schema 需递归简化但保留结构。 + "choice": map[string]interface{}{ + "anyOf": []interface{}{ + map[string]interface{}{"type": "string", "description": "drop"}, + map[string]interface{}{"type": "null"}, + }, + }, + }, + } + + out := simplifyToolSchema(schema).(map[string]interface{}) + + // 顶层 required 保留。 + if req, _ := out["required"].([]interface{}); len(req) != 2 { + t.Fatalf("top-level required must be preserved, got %v", out["required"]) + } + props := out["properties"].(map[string]interface{}) + + // enum 保留、description 剥离。 + mode := props["mode"].(map[string]interface{}) + if _, ok := mode["enum"]; !ok { + t.Fatal("enum must be preserved (model needs it to pick valid values)") + } + if _, ok := mode["description"]; ok { + t.Fatal("description must be stripped") + } + + // 嵌套 object 的 required 保留。 + addr := props["addr"].(map[string]interface{}) + if req, _ := addr["required"].([]interface{}); len(req) != 1 { + t.Fatalf("nested object required must be preserved, got %v", addr["required"]) + } + addrProps := addr["properties"].(map[string]interface{}) + if addrProps["host"].(map[string]interface{})["type"] != "string" { + t.Fatal("nested property type must be preserved") + } + if _, ok := addrProps["host"].(map[string]interface{})["description"]; ok { + t.Fatal("nested property description must be stripped") + } + + // 只有 $ref 的节点不能塌成空对象。 + ref := props["ref"].(map[string]interface{}) + if ref["$ref"] != "#/$defs/Foo" { + t.Fatalf("$ref must be preserved to avoid empty {} node, got %v", ref) + } + if len(ref) == 0 { + t.Fatal("ref node collapsed to empty object — model/upstream cannot interpret it") + } + + // anyOf 保留且子 schema 简化。 + choice := props["choice"].(map[string]interface{}) + anyOf, ok := choice["anyOf"].([]interface{}) + if !ok || len(anyOf) != 2 { + t.Fatalf("anyOf must be preserved with its subschemas, got %v", choice) + } + if anyOf[0].(map[string]interface{})["type"] != "string" { + t.Fatal("anyOf subschema type must survive simplification") + } + if _, ok := anyOf[0].(map[string]interface{})["description"]; ok { + t.Fatal("anyOf subschema description must be stripped") + } +} + +// TestTruncateDescByRatioCJKSafe 验证 CJK 描述按字节比例截断、不切坏 rune、保底字符数。 +func TestTruncateDescByRatioCJKSafe(t *testing.T) { + desc := strings.Repeat("中文说明", 100) // 1200 bytes (每字 3B), 400 runes + got := truncateDescByRatio(desc, 0.25) + + // 结果合法 UTF-8、未切坏 rune。 + if string([]rune(got)) != got { + t.Fatal("truncation broke a multi-byte rune boundary") + } + // 比原文短。 + if len(got) >= len(desc) { + t.Fatalf("expected truncation, got %d >= %d bytes", len(got), len(desc)) + } + // 保底字符数。 + if len([]rune(got)) < minToolDescChars { + t.Fatalf("must keep at least %d chars, got %d", minToolDescChars, len([]rune(got))) + } +} diff --git a/proxy/translator.go b/proxy/translator.go index 9bcd61c8..faf17320 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -807,6 +807,7 @@ func convertClaudeTools(tools []ClaudeTool) ([]KiroToolWrapper, map[string]strin w.ToolSpecification.InputSchema = InputSchema{JSON: ensureObjectSchema(tool.InputSchema)} result = append(result, w) } + result = compressToolsIfNeeded(result) return result, nameMap } @@ -2033,6 +2034,7 @@ func convertOpenAITools(tools []OpenAITool) []KiroToolWrapper { wrapper.ToolSpecification.InputSchema = InputSchema{JSON: ensureObjectSchema(tool.Function.Parameters)} result = append(result, wrapper) } + result = compressToolsIfNeeded(result) return result } From 1add9bbab3983894074d266e81923c7b635f1973 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 15:21:52 +0700 Subject: [PATCH 49/68] feat(cache): windowed dispatch metrics + hit-rate safety valve (zero-dep P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from kiro-tutu (zero-dep: stdlib sync/time + kiro-go/config/logger). Motivation: this repo returns a SIMULATED cache_read to downstream clients (handler.go resp.Usage.CacheReadInputTokens = cacheUsage). The higher the reported hit rate, the less the downstream pays and the more this gateway subsidises. cache_metrics.go adds two missing capabilities: 1. Windowed observability: aggregate per-dispatch cache diagnostics into rolling 5m/15m/1h windows (by source / model / account), exposed via /v1/stats under "cacheWindows" — so cache coverage is auditable without digging through info logs. Previously only point-in-time PromptCacheStats (cumulative hits/misses) was surfaced. 2. Hit-rate safety valve: when the aggregate cache hit ratio over a rolling 5m window exceeds the agreed 88% ceiling (cacheHitRatioWarnThreshold) with enough samples, emit a WARN so runaway subsidy is detectable rather than only governed by a code comment. Warn-only — never mutates the numbers or affects billing. Denominator correctness (locked by test, reproduces a real prod regression): computeCacheMetricRatios uses max(InputTokens, cacheRead+cacheCreation) as the denominator. cache_read/creation come from the local prompt-cache simulator while InputTokens comes from the upstream report/estimate — different bases. On long high-hit sessions cache_read routinely exceeds InputTokens, so using InputTokens as denominator produced ratios > 1 (prod saw cacheHitRatio=1.4). The max() denominator keeps ratios in [0,1] and matches Anthropic's official read/(read+create+uncached) basis. Wiring: 4 dispatch-completion hooks in both Claude paths (recordClaudeCacheDispatch): stream success (handler.go:1281), stream failure (1227), nonstream success (1564), nonstream failure (1525). Failures record 0 tokens so a failed dispatch cannot inflate the simulated cache_read subsidy. Source dimension is "stream"/"nonstream" (this repo has no cache affinity, so the tutu affinity-source classification does not apply). Tests (9): totals+ratio aggregation, dimension grouping, window boundary exclusion, retain-based prune, reset, empty-state ratios, the >1-ratio regression guard, and the safety-valve (threshold trigger + throttle + min-sample + below-threshold silence). build/vet/test green: proxy 5.587s, pool 0.382s, config 0.542s. --- proxy/cache_metrics.go | 367 ++++++++++++++++++++++++++++++++++++ proxy/cache_metrics_test.go | 239 +++++++++++++++++++++++ proxy/handler.go | 9 + 3 files changed, 615 insertions(+) create mode 100644 proxy/cache_metrics.go create mode 100644 proxy/cache_metrics_test.go diff --git a/proxy/cache_metrics.go b/proxy/cache_metrics.go new file mode 100644 index 00000000..d9a3b222 --- /dev/null +++ b/proxy/cache_metrics.go @@ -0,0 +1,367 @@ +package proxy + +import ( + "sync" + "time" + + "kiro-go/config" + "kiro-go/logger" +) + +// claudeCacheDispatchDiagnostics captures one cache dispatch's outcome and +// token usage for aggregation by cacheDispatchMetrics. This is a minimal shape +// of the type kiro-tutu defines in cache_affinity.go (which also carries +// affinity-key / endpoint / request-id fields used for affinity logging). This +// repo deliberately has no cache affinity (global fingerprints + simulated +// cache → session→account stickiness yields no real hit-rate gain), so only the +// fields the metrics consume are retained. The fields kept match what tutu's +// cache_metrics.go reads, so the aggregation logic ports verbatim. +type claudeCacheDispatchDiagnostics struct { + Outcome string // "success" | "failure" + Model string + AffinitySource string + Account *config.Account + Usage promptCacheUsage + InputTokens int + OutputTokens int +} + +// cacheDispatchMetrics 在内存中按时间窗口聚合 [claudeCacheDispatchDiagnostics] 诊断数据, +// 使缓存命中率指标无需开启 info 日志即可审计(见 issue #5)。聚合在诊断汇聚点 +// recordClaudeCacheDispatch 中无条件进行,因此与日志级别完全解耦。 +// +// 数据按分钟桶保留,Snapshot 时合并落在查询窗口内的桶;过期桶在写入时惰性清理, +// 内存占用上界为 retain 分钟 × (1 + 维度基数) 个计数器。 +const ( + cacheMetricsBucketSpan = time.Minute + // 比最大查询窗口(1h)多保留几分钟,避免边界请求被提前裁剪。 + cacheMetricsRetain = 65 * time.Minute +) + +// 缓存命中率安全阀:本网关返回给下游的 cache_read 是本地模拟值,命中率越高下游 +// 付费越少、由我方补贴。业务约定聚合命中率上限 ~88%(见 ComputeScoped 的 cap 注释)。 +// 三个旋钮(前缀断点 / 1h 本地存活 / 0.99 单请求 cap)叠加后真实聚合可能冲破该线, +// 而 cap 只约束单请求、不强制聚合。这里在聚合侧加观测告警:滚动窗口聚合命中率超阈值 +// 时打 WARN,使补贴失控可被发现,而不是只靠注释。仅告警、不改数,不影响计费。 +const ( + // cacheHitRatioWarnThreshold 是触发 WARN 的聚合命中率(read/总输入,正确口径)。 + cacheHitRatioWarnThreshold = 0.88 + // cacheHitRatioWarnWindow 是计算聚合命中率的滚动窗口。 + cacheHitRatioWarnWindow = 5 * time.Minute + // cacheHitRatioWarnInterval 是两次告警的最小间隔,避免高频请求刷屏。 + cacheHitRatioWarnInterval = time.Minute + // cacheHitRatioWarnMinDispatches 是触发告警所需的最小窗口样本量,避免小样本 + // (首轮/冷启动)噪声造成的偶发高比率误报。 + cacheHitRatioWarnMinDispatches = 50 +) + +// cacheMetricCounters 是一组可累加的计数器。token 字段累加的是每条 dispatch 的 +// 原始值:InputTokens 为输入总量(含 cache read+creation),成功路径才非零。 +type cacheMetricCounters struct { + Dispatches int64 `json:"dispatches"` + Success int64 `json:"success"` + Failure int64 `json:"failure"` + CacheReadInputTokens int64 `json:"cacheReadInputTokens"` + CacheCreationInputTokens int64 `json:"cacheCreationInputTokens"` + InputTokens int64 `json:"inputTokens"` + OutputTokens int64 `json:"outputTokens"` +} + +func (c *cacheMetricCounters) add(o cacheMetricCounters) { + c.Dispatches += o.Dispatches + c.Success += o.Success + c.Failure += o.Failure + c.CacheReadInputTokens += o.CacheReadInputTokens + c.CacheCreationInputTokens += o.CacheCreationInputTokens + c.InputTokens += o.InputTokens + c.OutputTokens += o.OutputTokens +} + +type cacheMetricBucket struct { + minute int64 + total cacheMetricCounters + bySource map[string]*cacheMetricCounters + byModel map[string]*cacheMetricCounters + byAccount map[string]*cacheMetricCounters +} + +func newCacheMetricBucket(minute int64) *cacheMetricBucket { + return &cacheMetricBucket{ + minute: minute, + bySource: make(map[string]*cacheMetricCounters), + byModel: make(map[string]*cacheMetricCounters), + byAccount: make(map[string]*cacheMetricCounters), + } +} + +func addDimension(dim map[string]*cacheMetricCounters, key string, delta cacheMetricCounters) { + if key == "" { + key = "unknown" + } + counter := dim[key] + if counter == nil { + counter = &cacheMetricCounters{} + dim[key] = counter + } + counter.add(delta) +} + +type cacheDispatchMetrics struct { + mu sync.Mutex + buckets map[int64]*cacheMetricBucket + retain time.Duration + // lastHitRatioWarn 节流命中率告警,避免高频 dispatch 下刷屏。 + lastHitRatioWarn time.Time +} + +func newCacheDispatchMetrics(retain time.Duration) *cacheDispatchMetrics { + if retain <= 0 { + retain = cacheMetricsRetain + } + return &cacheDispatchMetrics{ + buckets: make(map[int64]*cacheMetricBucket), + retain: retain, + } +} + +// Record 累加一条 dispatch 诊断。now 由调用方注入以便测试。 +func (m *cacheDispatchMetrics) Record(diag claudeCacheDispatchDiagnostics, now time.Time) { + minute := now.Unix() / 60 + delta := cacheMetricCounters{ + Dispatches: 1, + CacheReadInputTokens: int64(diag.Usage.CacheReadInputTokens), + CacheCreationInputTokens: int64(diag.Usage.CacheCreationInputTokens), + InputTokens: int64(diag.InputTokens), + OutputTokens: int64(diag.OutputTokens), + } + if diag.Outcome == "success" { + delta.Success = 1 + } else { + delta.Failure = 1 + } + + source := diag.AffinitySource + model := diag.Model + accountID := "" + if diag.Account != nil { + accountID = diag.Account.ID + } + + m.mu.Lock() + m.pruneLocked(minute) + + bucket := m.buckets[minute] + if bucket == nil { + bucket = newCacheMetricBucket(minute) + m.buckets[minute] = bucket + } + bucket.total.add(delta) + addDimension(bucket.bySource, source, delta) + addDimension(bucket.byModel, model, delta) + addDimension(bucket.byAccount, accountID, delta) + + // 命中率安全阀:在锁内聚合近窗并判定,把日志 I/O 留到释放锁之后,避免持锁做 I/O。 + warn, ratio, dispatches := m.shouldWarnHitRatioLocked(now) + m.mu.Unlock() + + if warn { + logger.Warnf( + "[CacheHitRatioGuard] 聚合缓存命中率 %.1f%% 超过约定上限 %.0f%%(近 %s,样本 %d 条):下游补贴可能失控,请核对 /v1/stats 与 ComputeScoped cap/TTL 参数", + ratio*100, cacheHitRatioWarnThreshold*100, cacheHitRatioWarnWindow, dispatches, + ) + } +} + +// shouldWarnHitRatioLocked 在持锁状态下聚合近 cacheHitRatioWarnWindow 窗口,判断 +// 聚合命中率是否超阈值且满足节流与最小样本量。命中口径复用 computeCacheMetricRatios +// (read/(read+create+input) 取 max 分母),与 admin 接口一致,规避净输入做分母的 +// 假数 bug。返回是否告警、命中率、窗口样本量;返回 true 时已就地更新 lastHitRatioWarn。 +func (m *cacheDispatchMetrics) shouldWarnHitRatioLocked(now time.Time) (bool, float64, int64) { + if now.Sub(m.lastHitRatioWarn) < cacheHitRatioWarnInterval { + return false, 0, 0 + } + currentMinute := now.Unix() / 60 + windowMinutes := int64((cacheHitRatioWarnWindow + cacheMetricsBucketSpan - 1) / cacheMetricsBucketSpan) + cutoff := currentMinute - windowMinutes + 1 + + var agg cacheMetricCounters + for minute, bucket := range m.buckets { + if minute < cutoff || minute > currentMinute { + continue + } + agg.add(bucket.total) + } + if agg.Dispatches < cacheHitRatioWarnMinDispatches { + return false, 0, 0 + } + ratio := computeCacheMetricRatios(agg).CacheHitRatio + if ratio <= cacheHitRatioWarnThreshold { + return false, 0, 0 + } + m.lastHitRatioWarn = now + return true, ratio, agg.Dispatches +} + +func (m *cacheDispatchMetrics) pruneLocked(currentMinute int64) { + cutoff := currentMinute - int64(m.retain/time.Minute) + for minute := range m.buckets { + if minute < cutoff { + delete(m.buckets, minute) + } + } +} + +// cacheMetricsWindowSnapshot 是单个时间窗口的聚合结果。 +type cacheMetricsWindowSnapshot struct { + WindowSeconds int64 `json:"windowSeconds"` + Total cacheMetricCounters `json:"total"` + Ratios cacheMetricRatios `json:"ratios"` + BySource map[string]cacheMetricGroup `json:"bySource"` + ByModel map[string]cacheMetricGroup `json:"byModel"` + ByAccount map[string]cacheMetricGroup `json:"byAccount"` +} + +// cacheMetricGroup 是某个分组维度下的计数器加该分组自身的命中率。 +type cacheMetricGroup struct { + cacheMetricCounters + Ratios cacheMetricRatios `json:"ratios"` +} + +// cacheMetricRatios 给出多个常用缓存率口径,避免与客户口径对不上。 +// - CacheReadRatio: cache_read / total_input,真正从缓存读取的输入占比。 +// - CacheHitRatio: (cache_read + cache_creation) / total_input,广义缓存覆盖占比。 +// - BilledInputTokens: total_input - cache_read - cache_creation,实际计费的未缓存输入。 +// - SuccessRate: success / dispatches,分发成功率。 +// +// 分母 total_input 取 max(InputTokens, cache_read + cache_creation):缓存读写的 +// token 本身就是输入的一部分,总输入不可能小于它们之和。InputTokens 累加的是上游 +// 实报/估算的总输入,与 cache_read/creation(本地 prompt cache 模拟器产出)口径不同, +// 长会话高命中时常出现 InputTokens < cache_read,若直接以 InputTokens 作分母会让 +// 比率突破 1(线上实测 cacheHitRatio 达 1.4)。以两者较大值为分母即可保证比率 ∈[0,1] +// 且与 Anthropic 官方口径 read/(read+create+uncached) 一致。分母为 0 时返回 0。 +type cacheMetricRatios struct { + CacheReadRatio float64 `json:"cacheReadRatio"` + CacheHitRatio float64 `json:"cacheHitRatio"` + BilledInputTokens int64 `json:"billedInputTokens"` + SuccessRate float64 `json:"successRate"` +} + +func computeCacheMetricRatios(c cacheMetricCounters) cacheMetricRatios { + ratios := cacheMetricRatios{} + cached := c.CacheReadInputTokens + c.CacheCreationInputTokens + totalInput := max(c.InputTokens, cached) + ratios.BilledInputTokens = totalInput - cached + if totalInput > 0 { + ratios.CacheReadRatio = float64(c.CacheReadInputTokens) / float64(totalInput) + ratios.CacheHitRatio = float64(cached) / float64(totalInput) + } + if c.Dispatches > 0 { + ratios.SuccessRate = float64(c.Success) / float64(c.Dispatches) + } + return ratios +} + +func dimensionSnapshot(dim map[string]*cacheMetricCounters) map[string]cacheMetricGroup { + out := make(map[string]cacheMetricGroup, len(dim)) + for key, counter := range dim { + out[key] = cacheMetricGroup{ + cacheMetricCounters: *counter, + Ratios: computeCacheMetricRatios(*counter), + } + } + return out +} + +// Snapshot 合并落在 [now-window, now] 内的桶。window 超过 retain 时按 retain 截断。 +func (m *cacheDispatchMetrics) Snapshot(window time.Duration, now time.Time) cacheMetricsWindowSnapshot { + if window <= 0 { + window = cacheMetricsBucketSpan + } + if window > m.retain { + window = m.retain + } + currentMinute := now.Unix() / 60 + // 向上取整覆盖整个窗口:window 内涉及的最早分钟桶。 + windowMinutes := int64((window + cacheMetricsBucketSpan - 1) / cacheMetricsBucketSpan) + cutoff := currentMinute - windowMinutes + 1 + + snapshot := cacheMetricsWindowSnapshot{ + WindowSeconds: int64(window / time.Second), + } + merged := newCacheMetricBucket(0) + + m.mu.Lock() + for minute, bucket := range m.buckets { + if minute < cutoff || minute > currentMinute { + continue + } + merged.total.add(bucket.total) + for k, v := range bucket.bySource { + addDimension(merged.bySource, k, *v) + } + for k, v := range bucket.byModel { + addDimension(merged.byModel, k, *v) + } + for k, v := range bucket.byAccount { + addDimension(merged.byAccount, k, *v) + } + } + m.mu.Unlock() + + snapshot.Total = merged.total + snapshot.Ratios = computeCacheMetricRatios(merged.total) + snapshot.BySource = dimensionSnapshot(merged.bySource) + snapshot.ByModel = dimensionSnapshot(merged.byModel) + snapshot.ByAccount = dimensionSnapshot(merged.byAccount) + return snapshot +} + +// Reset 清空所有桶,供测试使用。 +func (m *cacheDispatchMetrics) Reset() { + m.mu.Lock() + m.buckets = make(map[int64]*cacheMetricBucket) + m.mu.Unlock() +} + +// 包级单例:诊断汇聚点为包函数(非 Handler 方法,且被测试直接调用),用单例可零侵入 +// 接入而无需改各调用方签名。 +var defaultCacheDispatchMetrics = newCacheDispatchMetrics(cacheMetricsRetain) + +func recordCacheDispatchMetric(diag claudeCacheDispatchDiagnostics) { + defaultCacheDispatchMetrics.Record(diag, time.Now()) +} + +// recordClaudeCacheDispatch 是分发完成处的汇聚钩子,把一次 Claude 分发(成功/失败、 +// 流式/非流式)喂给窗口化指标。失败路径 token 全部传 0:调用未真正完成,没有 token +// 被读取/生成,喂入模拟的 cache 值会让命中率虚高。source 用 "stream"/"nonstream", +// 给运维一个真实维度(流式与非流式的缓存覆盖是否不同);本仓库无 cache affinity, +// 故不复用 tutu 的 affinity source 分类。 +func recordClaudeCacheDispatch(outcome, model, source string, account *config.Account, usage promptCacheUsage, inputTokens, outputTokens int) { + recordCacheDispatchMetric(claudeCacheDispatchDiagnostics{ + Outcome: outcome, + Model: model, + AffinitySource: source, + Account: account, + Usage: usage, + InputTokens: inputTokens, + OutputTokens: outputTokens, + }) +} + +// cacheMetricsWindowDurations 是 /v1/stats 默认输出的窗口口径。 +var cacheMetricsWindowDurations = []struct { + Label string + Duration time.Duration +}{ + {"5m", 5 * time.Minute}, + {"15m", 15 * time.Minute}, + {"1h", time.Hour}, +} + +func cacheMetricsSnapshotByWindows(now time.Time) map[string]cacheMetricsWindowSnapshot { + out := make(map[string]cacheMetricsWindowSnapshot, len(cacheMetricsWindowDurations)) + for _, w := range cacheMetricsWindowDurations { + out[w.Label] = defaultCacheDispatchMetrics.Snapshot(w.Duration, now) + } + return out +} diff --git a/proxy/cache_metrics_test.go b/proxy/cache_metrics_test.go new file mode 100644 index 00000000..0005a873 --- /dev/null +++ b/proxy/cache_metrics_test.go @@ -0,0 +1,239 @@ +package proxy + +import ( + "testing" + "time" + + "kiro-go/config" +) + +func dispatch(source, model, accountID, outcome string, read, creation, input, output int) claudeCacheDispatchDiagnostics { + diag := claudeCacheDispatchDiagnostics{ + Outcome: outcome, + Model: model, + AffinitySource: source, + Usage: promptCacheUsage{ + CacheReadInputTokens: read, + CacheCreationInputTokens: creation, + }, + InputTokens: input, + OutputTokens: output, + } + if accountID != "" { + diag.Account = &config.Account{ID: accountID} + } + return diag +} + +func TestCacheDispatchMetricsAggregatesTotalsAndRatios(t *testing.T) { + m := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(1_000_000, 0) + + // 两条成功 dispatch,输入总量合计 1000,其中 cache_read 600、creation 200。 + m.Record(dispatch("conversation", "claude-sonnet-4.5", "acct-1", "success", 300, 100, 500, 50), now) + m.Record(dispatch("conversation", "claude-sonnet-4.5", "acct-1", "success", 300, 100, 500, 50), now) + // 一条失败 dispatch(失败路径 token 为 0)。 + m.Record(dispatch("conversation", "claude-sonnet-4.5", "acct-1", "failure", 0, 0, 0, 0), now) + + snap := m.Snapshot(5*time.Minute, now) + if snap.Total.Dispatches != 3 { + t.Fatalf("dispatches = %d, want 3", snap.Total.Dispatches) + } + if snap.Total.Success != 2 || snap.Total.Failure != 1 { + t.Fatalf("success/failure = %d/%d, want 2/1", snap.Total.Success, snap.Total.Failure) + } + if snap.Total.InputTokens != 1000 || snap.Total.CacheReadInputTokens != 600 || snap.Total.CacheCreationInputTokens != 200 { + t.Fatalf("token totals = in:%d read:%d creation:%d, want 1000/600/200", + snap.Total.InputTokens, snap.Total.CacheReadInputTokens, snap.Total.CacheCreationInputTokens) + } + // cache_read / total_input = 600/1000 = 0.6 + if got := snap.Ratios.CacheReadRatio; got < 0.5999 || got > 0.6001 { + t.Fatalf("cacheReadRatio = %v, want 0.6", got) + } + // (read + creation) / total = 800/1000 = 0.8 + if got := snap.Ratios.CacheHitRatio; got < 0.7999 || got > 0.8001 { + t.Fatalf("cacheHitRatio = %v, want 0.8", got) + } + // billed = 1000 - 600 - 200 = 200 + if snap.Ratios.BilledInputTokens != 200 { + t.Fatalf("billedInputTokens = %d, want 200", snap.Ratios.BilledInputTokens) + } + // success rate = 2/3 + if got := snap.Ratios.SuccessRate; got < 0.6665 || got > 0.6668 { + t.Fatalf("successRate = %v, want ~0.6667", got) + } +} + +func TestCacheDispatchMetricsGroupsByDimension(t *testing.T) { + m := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(2_000_000, 0) + + m.Record(dispatch("metadata-conversation", "claude-opus", "acct-A", "success", 100, 0, 200, 10), now) + m.Record(dispatch("cache-prefix", "claude-sonnet-4.5", "acct-B", "success", 50, 0, 100, 5), now) + + snap := m.Snapshot(time.Hour, now) + if len(snap.BySource) != 2 { + t.Fatalf("bySource keys = %d, want 2 (%v)", len(snap.BySource), snap.BySource) + } + if g, ok := snap.BySource["metadata-conversation"]; !ok || g.InputTokens != 200 { + t.Fatalf("metadata-conversation group missing or wrong: %+v ok=%v", g, ok) + } + if g, ok := snap.ByModel["claude-opus"]; !ok || g.CacheReadInputTokens != 100 { + t.Fatalf("claude-opus group missing or wrong: %+v ok=%v", g, ok) + } + if g, ok := snap.ByAccount["acct-B"]; !ok || g.InputTokens != 100 { + t.Fatalf("acct-B group missing or wrong: %+v ok=%v", g, ok) + } +} + +func TestCacheDispatchMetricsWindowExcludesOldBuckets(t *testing.T) { + m := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(3_000_000, 0) + + // 10 分钟前的一条记录,落在 5m 窗口外、1h 窗口内。 + m.Record(dispatch("conversation", "m", "a", "success", 10, 0, 20, 1), now.Add(-10*time.Minute)) + // 当前一条记录。 + m.Record(dispatch("conversation", "m", "a", "success", 30, 0, 60, 2), now) + + short := m.Snapshot(5*time.Minute, now) + if short.Total.Dispatches != 1 || short.Total.InputTokens != 60 { + t.Fatalf("5m window = dispatches:%d input:%d, want 1/60", short.Total.Dispatches, short.Total.InputTokens) + } + + long := m.Snapshot(time.Hour, now) + if long.Total.Dispatches != 2 || long.Total.InputTokens != 80 { + t.Fatalf("1h window = dispatches:%d input:%d, want 2/80", long.Total.Dispatches, long.Total.InputTokens) + } +} + +func TestCacheDispatchMetricsPrunesBeyondRetain(t *testing.T) { + m := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(4_000_000, 0) + + m.Record(dispatch("conversation", "m", "a", "success", 1, 0, 2, 1), now.Add(-2*time.Hour)) + // 一条新记录触发对超过 retain 的旧桶的惰性清理。 + m.Record(dispatch("conversation", "m", "a", "success", 1, 0, 2, 1), now) + + m.mu.Lock() + buckets := len(m.buckets) + m.mu.Unlock() + if buckets != 1 { + t.Fatalf("buckets after prune = %d, want 1", buckets) + } +} + +func TestCacheDispatchMetricsReset(t *testing.T) { + m := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(5_000_000, 0) + m.Record(dispatch("conversation", "m", "a", "success", 1, 0, 2, 1), now) + m.Reset() + snap := m.Snapshot(time.Hour, now) + if snap.Total.Dispatches != 0 { + t.Fatalf("dispatches after reset = %d, want 0", snap.Total.Dispatches) + } +} + +func TestCacheDispatchMetricsEmptyRatios(t *testing.T) { + m := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(6_000_000, 0) + snap := m.Snapshot(time.Hour, now) + if snap.Ratios.CacheReadRatio != 0 || snap.Ratios.CacheHitRatio != 0 || snap.Ratios.SuccessRate != 0 { + t.Fatalf("empty ratios should be 0, got %+v", snap.Ratios) + } + if snap.Ratios.BilledInputTokens != 0 { + t.Fatalf("empty billed should be 0, got %d", snap.Ratios.BilledInputTokens) + } +} + +// cache_read/creation 来自本地 prompt cache 模拟器,InputTokens 来自上游实报/估算, +// 两者口径不同:长会话高命中时 cache_read 常超过 InputTokens。此时命中率必须仍 +// ≤ 1(分母取 max(input, read+creation)),否则面板会出现 >100% 的假命中率。 +// 复现线上实测 cacheHitRatio=1.4 的回归。 +func TestCacheDispatchMetricsRatiosNeverExceedOneWhenCachedExceedsInput(t *testing.T) { + m := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(7_000_000, 0) + + // read=205000、creation=49000、input(净/估算)=181000 —— read 已超过 input。 + m.Record(dispatch("agent-prefix", "claude-opus-4.6", "acct-1", "success", 205000, 49000, 181000, 100), now) + + snap := m.Snapshot(5*time.Minute, now) + if r := snap.Ratios.CacheHitRatio; r > 1.0 { + t.Fatalf("cacheHitRatio = %v, must be <= 1", r) + } + if r := snap.Ratios.CacheReadRatio; r > 1.0 { + t.Fatalf("cacheReadRatio = %v, must be <= 1", r) + } + // 分母 = max(181000, 254000) = 254000;hit = 254000/254000 = 1.0;read = 205000/254000 ≈ 0.807。 + if r := snap.Ratios.CacheHitRatio; r < 0.9999 || r > 1.0001 { + t.Fatalf("cacheHitRatio = %v, want ~1.0", r) + } + if r := snap.Ratios.CacheReadRatio; r < 0.8070 || r > 0.8072 { + t.Fatalf("cacheReadRatio = %v, want ~0.8071", r) + } + // billed = 254000 - 254000 = 0(净输入被缓存量覆盖,不可为负)。 + if snap.Ratios.BilledInputTokens != 0 { + t.Fatalf("billedInputTokens = %d, want 0", snap.Ratios.BilledInputTokens) + } +} + +// TestCacheHitRatioGuardWarnsAboveThreshold 验证命中率安全阀:滚动窗口聚合命中率 +// 超过 cacheHitRatioWarnThreshold 且样本量达标时触发告警,并受节流与最小样本量保护。 +// 直接测纯判定函数 shouldWarnHitRatioLocked(无日志 I/O),覆盖阈值/样本量/节流三条。 +func TestCacheHitRatioGuardWarnsAboveThreshold(t *testing.T) { + m := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(8_000_000, 0) + + // 喂入命中率 ~95% 的高命中样本,数量越过最小样本量门槛。每条 read=950、 + // creation=0、input=50 → 分母 max(50, 950)=950,hit=950/950=1.0(单条), + // 聚合命中率远超 0.88。 + for i := 0; i < cacheHitRatioWarnMinDispatches+5; i++ { + m.Record(dispatch("agent-prefix", "claude-opus-4.6", "acct-1", "success", 950, 0, 50, 100), now) + } + + // 安全阀应已在某条 Record 中触发并更新 lastHitRatioWarn(节流时间戳被设为 now)。 + if m.lastHitRatioWarn.IsZero() { + t.Fatalf("高命中率 + 足量样本应已触发命中率告警,但 lastHitRatioWarn 未设置") + } + + // 节流:紧接着同一时刻再判定应被抑制(距上次告警 < cacheHitRatioWarnInterval)。 + m.mu.Lock() + warnAgain, _, _ := m.shouldWarnHitRatioLocked(now) + m.mu.Unlock() + if warnAgain { + t.Fatalf("节流失效:同一时刻不应重复告警") + } + + // 过了节流间隔后应能再次告警(命中率仍高)。 + later := now.Add(cacheHitRatioWarnInterval + time.Second) + m.mu.Lock() + warnLater, ratioLater, _ := m.shouldWarnHitRatioLocked(later) + m.mu.Unlock() + if !warnLater { + t.Fatalf("过节流间隔后命中率仍高,应再次告警,got ratio=%.3f", ratioLater) + } +} + +// TestCacheHitRatioGuardSilentBelowThresholdAndSmallSample 验证安全阀不误报: +// 命中率低于阈值、或样本量不足时都不告警。 +func TestCacheHitRatioGuardSilentBelowThresholdAndSmallSample(t *testing.T) { + // 场景一:足量样本但命中率低(~40%),不应告警。 + low := newCacheDispatchMetrics(cacheMetricsRetain) + now := time.Unix(8_100_000, 0) + for i := 0; i < cacheHitRatioWarnMinDispatches+5; i++ { + // read=300、creation=0、input=700 → 分母 max(700,300)=700,hit≈0.43,低于 0.88。 + low.Record(dispatch("agent-prefix", "claude-opus-4.6", "acct-1", "success", 300, 0, 700, 100), now) + } + if !low.lastHitRatioWarn.IsZero() { + t.Fatalf("命中率低于阈值不应告警,但 lastHitRatioWarn 被设置") + } + + // 场景二:命中率高但样本量不足(< 最小样本量),不应告警。 + small := newCacheDispatchMetrics(cacheMetricsRetain) + now2 := time.Unix(8_200_000, 0) + for i := 0; i < cacheHitRatioWarnMinDispatches-1; i++ { + small.Record(dispatch("agent-prefix", "claude-opus-4.6", "acct-1", "success", 950, 0, 50, 100), now2) + } + if !small.lastHitRatioWarn.IsZero() { + t.Fatalf("样本量不足不应告警,但 lastHitRatioWarn 被设置") + } +} diff --git a/proxy/handler.go b/proxy/handler.go index 94021a84..87e36196 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -450,6 +450,7 @@ func (h *Handler) handleStats(w http.ResponseWriter, r *http.Request) { "totalTokens": atomic.LoadInt64(&h.totalTokens), "totalCredits": h.getCredits(), "cache": h.promptCache.Stats(), + "cacheWindows": cacheMetricsSnapshotByWindows(time.Now()), "uptime": time.Now().Unix() - h.startTime, }) } @@ -1221,6 +1222,9 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload lastErr = err excluded[account.ID] = true h.handleAccountFailure(account, err) + // Cache-dispatch observability: failed dispatch contributes 0 tokens so + // it cannot inflate the simulated cache_read subsidy (see cache_metrics.go). + recordClaudeCacheDispatch("failure", model, "stream", account, promptCacheUsage{}, 0, 0) if isUpstreamPermanentError(err) { // Malformed request — no other account can succeed. Stop retrying; // lastErr flows to the client. The relaying account was left neutral @@ -1274,6 +1278,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) h.promptCache.Update(account.ID, cacheProfile) h.recordSuccessLog("claude", model, account.ID, inputTokens+outputTokens, credits, time.Since(reqStart).Milliseconds()) + recordClaudeCacheDispatch("success", model, "stream", account, cacheUsage, inputTokens, outputTokens) stopReason := "end_turn" if len(toolUses) > 0 { @@ -1515,6 +1520,9 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayl lastErr = err excluded[account.ID] = true h.handleAccountFailure(account, err) + // Cache-dispatch observability: failed dispatch contributes 0 tokens so + // it cannot inflate the simulated cache_read subsidy (see cache_metrics.go). + recordClaudeCacheDispatch("failure", model, "nonstream", account, promptCacheUsage{}, 0, 0) if isUpstreamPermanentError(err) { break } @@ -1553,6 +1561,7 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayl h.pool.UpdateStats(account.ID, inputTokens+outputTokens, credits) h.promptCache.Update(account.ID, cacheProfile) h.recordSuccessLog("claude", model, account.ID, inputTokens+outputTokens, credits, time.Since(reqStart).Milliseconds()) + recordClaudeCacheDispatch("success", model, "nonstream", account, cacheUsage, inputTokens, outputTokens) responseThinkingContent := rawThinkingContent includeEmptyThinkingBlock := thinking && thinkingOpts.OmitDisplay && rawThinkingContent != "" From ae07ac06815ff2e5cd3a624014e9741b938d89ee Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 16:30:59 +0700 Subject: [PATCH 50/68] feat(stream): SSE keepalive heartbeat during upstream stalls (zero-dep P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from kiro-tutu (zero-dep: stdlib sync/time + fmt). Motivation: this repo had removed its total stream timeout (letting very long opus thinking run unbounded), but emitted nothing to the client while the upstream was silent — not before first byte, and not between data chunks. Cloudflare's ~120s Proxy Read Timeout then closed the connection mid-stream, surfacing to users as "the reply cuts off halfway". This repo only set the Connection: keep-alive header; it never sent a heartbeat. streamKeepaliveInterval (default 10s) drives a goroutine that emits a ': keepalive\n\n' SSE comment line whenever the connection has been idle for that long. Comment lines are ignored by Anthropic/OpenAI SDKs and Claude Code, so they keep the client/CDN connection alive without polluting the response. It covers both stall cases: slow prefill before first byte, and silent gaps between data chunks during heavy thinking. Concurrency: the heartbeat goroutine and the main goroutine both write to the same ResponseWriter, so every real write is funnelled through a mutex- guarded emitter (emit / emitRaw) that shares hbMu with the heartbeat. lastWriteNano gates the heartbeat so it only fires on genuine silence, not on top of active data. hbStopped + hbDone shut the goroutine down cleanly. Committed-flag error path: once the heartbeat has flushed a 200 header (committed), the HTTP status can no longer change — the client is parsing SSE. On the loop-exit error path (no account / all failed before any data), if committed the handler closes the stream with a valid SSE error sequence (Claude: error+message_delta+message_stop; OpenAI: error chunk+[DONE]) instead of sendClaudeError/sendOpenAIError's WriteHeader+JSON, which would trigger a superfluous WriteHeader and corrupt the stream. If not committed, the original HTTP error response is preserved. stopHeartbeat is called before deciding the form so the heartbeat cannot race the final write. Wired into both streaming handlers in handler.go: - handleClaudeStream (emit replaces every h.sendSSE on the hot path) - handleOpenAIStream (emitRaw replaces the three direct fmt.Fprintf writes + the tail) Tests (handler_keepalive_test.go): a stalled upstream server emits one text frame, sleeps long enough to cross multiple heartbeat intervals, then emits a second frame. Asserts ': keepalive' appears between the two real frames, both real frames are delivered intact, and the protocol terminator (message_stop / [DONE]) is present. Uses a lockedFlushRecorder so a -race run catches any handler-internal contention; the real mutual exclusion is hbMu by construction. -race needs cgo (unavailable in this env), so correctness is verified by stable isolation runs (3/3 green, both paths) plus the full suite. build/vet/test green: proxy 6.110s, pool 0.431s, config 0.528s. One FAIL in the full run (TestResponsesContinuationKeepsNewInstructions → unlinkat ... directory is not empty) is the pre-existing Windows TempDir cleanup flake, reproducible on main before this slice; passes in isolation. --- proxy/handler.go | 222 ++++++++++++++++++++++++++++---- proxy/handler_keepalive_test.go | 200 ++++++++++++++++++++++++++++ 2 files changed, 399 insertions(+), 23 deletions(-) create mode 100644 proxy/handler_keepalive_test.go diff --git a/proxy/handler.go b/proxy/handler.go index 87e36196..19e30271 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -841,6 +841,12 @@ func (h *Handler) handleClaudeMessagesInternal(w http.ResponseWriter, r *http.Re } } +// streamKeepaliveInterval 是流式响应期间向客户端发送 SSE 保活注释行的间隔。远小于 +// Cloudflare ~120s 的 Proxy Read Timeout,确保上游长静默(opus 高强度 thinking 两个数据 +// 块之间、或去掉总超时后的超长推理)期间客户端/CDN 连接不被中途判超时断开。为 var 以便 +// 测试注入更小的间隔来覆盖"流中途静默触发 ping 与数据并发写"路径。Ported from kiro-tutu。 +var streamKeepaliveInterval = 10 * time.Second + // handleClaudeStream Claude 流式响应 func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload, model string, thinking bool, thinkingOpts claudeThinkingResponseOptions, estimatedInputTokens int, cacheProfile *promptCacheProfile, apiKeyID string) { w.Header().Set("Content-Type", "text/event-stream; charset=utf-8") @@ -853,6 +859,58 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload return } + // SSE 保活:整个流式期间(不止首字节前)周期性发送注释行,使 200 响应头立即抵达 + // 客户端/CDN 并持续刷新其 Proxy Read Timeout——既覆盖上游 prefill 迟迟不返回首字节, + // 也覆盖上游输出中途长静默(opus 高强度 thinking 两个数据块之间可静默数十秒,且本服务 + // 已去掉总超时),避免客户端/CDN 在 ~120s 处判超时断开(用户侧表现为"说一半就断")。 + // ':' 开头的注释行被 Anthropic SDK / Claude Code 等客户端忽略,不污染响应内容。 + // + // 并发:保活 goroutine 与主 goroutine 都会写 w,故所有真实事件写出统一走 emit(),与 + // 保活共用 hbMu 串行化;lastWriteNano 记录最近一次写出时刻,保活仅在距上次写出已满 + // streamKeepaliveInterval(确有静默)时才补发,数据正常流动时不插入多余注释行。 + var hbMu sync.Mutex + hbStopped := false + committed := false // 200 响应头是否已 flush 到客户端(由保活触发) + lastWriteNano := time.Now().UnixNano() + hbDone := make(chan struct{}) + var hbStopOnce sync.Once + stopHeartbeat := func() { + hbStopOnce.Do(func() { + hbMu.Lock() + hbStopped = true + hbMu.Unlock() + close(hbDone) + }) + } + defer stopHeartbeat() + + emit := func(event string, data interface{}) { + hbMu.Lock() + h.sendSSE(w, flusher, event, data) + lastWriteNano = time.Now().UnixNano() + hbMu.Unlock() + } + + go func() { + ticker := time.NewTicker(streamKeepaliveInterval) + defer ticker.Stop() + for { + select { + case <-hbDone: + return + case <-ticker.C: + hbMu.Lock() + if !hbStopped && time.Since(time.Unix(0, lastWriteNano)) >= streamKeepaliveInterval { + fmt.Fprint(w, ": keepalive\n\n") + flusher.Flush() + committed = true + lastWriteNano = time.Now().UnixNano() + } + hbMu.Unlock() + } + } + }() + // 获取 thinking 输出格式配置 thinkingFormat := thinkingOpts.Format @@ -868,7 +926,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload if messageStarted { return } - h.sendSSE(w, flusher, "message_start", map[string]interface{}{ + emit("message_start", map[string]interface{}{ "type": "message_start", "message": map[string]interface{}{ "id": msgID, @@ -916,7 +974,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload if activeBlockIndex < 0 { return } - h.sendSSE(w, flusher, "content_block_stop", map[string]interface{}{ + emit("content_block_stop", map[string]interface{}{ "type": "content_block_stop", "index": activeBlockIndex, }) @@ -935,7 +993,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload nextContentIndex++ if blockType == "thinking" { - h.sendSSE(w, flusher, "content_block_start", map[string]interface{}{ + emit("content_block_start", map[string]interface{}{ "type": "content_block_start", "index": idx, "content_block": map[string]string{ @@ -944,7 +1002,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload }, }) } else { - h.sendSSE(w, flusher, "content_block_start", map[string]interface{}{ + emit("content_block_start", map[string]interface{}{ "type": "content_block_start", "index": idx, "content_block": map[string]string{ @@ -971,7 +1029,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload return } startContentBlock("text") - h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ + emit("content_block_delta", map[string]interface{}{ "type": "content_block_delta", "index": activeBlockIndex, "delta": map[string]string{"type": "text_delta", "text": text}, @@ -998,7 +1056,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload return } startContentBlock("text") - h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ + emit("content_block_delta", map[string]interface{}{ "type": "content_block_delta", "index": activeBlockIndex, "delta": map[string]string{"type": "text_delta", "text": outputText}, @@ -1008,7 +1066,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload return } startContentBlock("text") - h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ + emit("content_block_delta", map[string]interface{}{ "type": "content_block_delta", "index": activeBlockIndex, "delta": map[string]string{"type": "text_delta", "text": text}, @@ -1035,7 +1093,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload } if text != "" { startContentBlock("thinking") - h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ + emit("content_block_delta", map[string]interface{}{ "type": "content_block_delta", "index": activeBlockIndex, "delta": map[string]string{"type": "thinking_delta", "thinking": text}, @@ -1179,7 +1237,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload idx := nextContentIndex nextContentIndex++ - h.sendSSE(w, flusher, "content_block_start", map[string]interface{}{ + emit("content_block_start", map[string]interface{}{ "type": "content_block_start", "index": idx, "content_block": map[string]interface{}{ @@ -1191,7 +1249,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload }) inputJSON, _ := json.Marshal(tu.Input) - h.sendSSE(w, flusher, "content_block_delta", map[string]interface{}{ + emit("content_block_delta", map[string]interface{}{ "type": "content_block_delta", "index": idx, "delta": map[string]interface{}{ @@ -1200,7 +1258,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload }, }) - h.sendSSE(w, flusher, "content_block_stop", map[string]interface{}{ + emit("content_block_stop", map[string]interface{}{ "type": "content_block_stop", "index": idx, }) @@ -1235,7 +1293,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload continue } h.recordFailureWithDetails("claude", model, account.ID, err) - h.sendSSE(w, flusher, "error", map[string]interface{}{ + emit("error", map[string]interface{}{ "type": "error", "error": map[string]string{"type": "api_error", "message": err.Error()}, }) @@ -1286,7 +1344,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload } ensureMessageStart() - h.sendSSE(w, flusher, "message_delta", map[string]interface{}{ + emit("message_delta", map[string]interface{}{ "type": "message_delta", "delta": map[string]interface{}{ "stop_reason": stopReason, @@ -1294,7 +1352,41 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload "usage": buildClaudeUsageMap(inputTokens, outputTokens, cacheUsage, cacheProfile != nil), }) - h.sendSSE(w, flusher, "message_stop", map[string]interface{}{ + emit("message_stop", map[string]interface{}{ + "type": "message_stop", + }) + return + } + + // 循环外的错误/无账号路径。保活 goroutine 可能仍在写 w,先停掉再决定响应形态, + // 避免 sendClaudeError 的 WriteHeader+JSON 与心跳的 ': keepalive' 并发写同一个 w。 + stopHeartbeat() + if lastErr != nil { + h.recordFailureWithDetails("claude", model, "", lastErr) + } + hbMu.Lock() + didCommit := committed + hbMu.Unlock() + + if didCommit { + // 心跳已落地 200 响应头:HTTP 状态码已无法更改,客户端正按 SSE 解析——必须以 + // SSE error 序列收尾,而非 sendClaudeError 的 WriteHeader+JSON(后者会触发 + // superfluous WriteHeader 告警且破坏流式协议)。 + msg := "No available accounts" + if lastErr != nil { + msg = lastErr.Error() + } + ensureMessageStart() + emit("error", map[string]interface{}{ + "type": "error", + "error": map[string]string{"type": "api_error", "message": msg}, + }) + emit("message_delta", map[string]interface{}{ + "type": "message_delta", + "delta": map[string]interface{}{"stop_reason": "error"}, + "usage": map[string]interface{}{"output_tokens": 0}, + }) + emit("message_stop", map[string]interface{}{ "type": "message_stop", }) return @@ -1305,7 +1397,6 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload return } - h.recordFailureWithDetails("claude", model, "", lastErr) h.sendClaudeError(w, 500, "api_error", lastErr.Error()) } @@ -1668,6 +1759,59 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload return } + // 流式保活:与 handleClaudeStream 同构。整个流式期间周期性发送 SSE 注释行(OpenAI 流 + // 亦以 ':' 注释行合法、客户端忽略),落地 200 头并持续刷新 CF/客户端的 Proxy Read + // Timeout,既覆盖上游 prefill 迟迟无首字节、也覆盖输出中途长静默(opus 高强度 thinking + // 两个数据块之间、去掉 5min 总超时后的超长推理),避免连接在 ~120s 处被判超时断开 + // (用户侧表现为“说一半就断”)。 + // + // 并发:保活 goroutine 与主 goroutine 都会写 w,故所有真实 chunk 写出统一走 emitRaw(), + // 与保活共用 hbMu 串行化;lastWriteNano 记录最近写出时刻,保活仅在确有静默时才补发。 + var hbMu sync.Mutex + hbStopped := false + committed := false // 200 响应头是否已 flush 到客户端(由保活或首个真实 chunk 触发) + lastWriteNano := time.Now().UnixNano() + hbDone := make(chan struct{}) + var hbStopOnce sync.Once + stopHeartbeat := func() { + hbStopOnce.Do(func() { + hbMu.Lock() + hbStopped = true + hbMu.Unlock() + close(hbDone) + }) + } + defer stopHeartbeat() + + // emitRaw 串行化所有真实 chunk 写出,与保活 goroutine 互斥(共用 hbMu),并记录写出时刻。 + emitRaw := func(s string) { + hbMu.Lock() + fmt.Fprint(w, s) + flusher.Flush() + lastWriteNano = time.Now().UnixNano() + hbMu.Unlock() + } + + go func() { + ticker := time.NewTicker(streamKeepaliveInterval) + defer ticker.Stop() + for { + select { + case <-hbDone: + return + case <-ticker.C: + hbMu.Lock() + if !hbStopped && time.Since(time.Unix(0, lastWriteNano)) >= streamKeepaliveInterval { + fmt.Fprint(w, ": keepalive\n\n") + flusher.Flush() + committed = true + lastWriteNano = time.Now().UnixNano() + } + hbMu.Unlock() + } + } + }() + // 获取 thinking 输出格式配置 thinkingFormat := config.GetThinkingConfig().OpenAIFormat @@ -1800,8 +1944,7 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload } } data, _ := json.Marshal(chunk) - fmt.Fprintf(w, "data: %s\n\n", string(data)) - flusher.Flush() + emitRaw(fmt.Sprintf("data: %s\n\n", string(data))) responseStarted = true } @@ -1957,8 +2100,7 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload } toolCallIndex++ data, _ := json.Marshal(chunk) - fmt.Fprintf(w, "data: %s\n\n", string(data)) - flusher.Flush() + emitRaw(fmt.Sprintf("data: %s\n\n", string(data))) responseStarted = true }, OnComplete: func(inTok, outTok int) { @@ -2043,9 +2185,44 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload }, } data, _ := json.Marshal(chunk) - fmt.Fprintf(w, "data: %s\n\n", string(data)) - fmt.Fprintf(w, "data: [DONE]\n\n") - flusher.Flush() + emitRaw(fmt.Sprintf("data: %s\n\n", string(data))) + emitRaw("data: [DONE]\n\n") + return + } + + // 循环外的错误/无账号路径。保活 goroutine 可能仍在写 w,先停掉再决定响应形态, + // 避免 sendOpenAIError 的 WriteHeader+JSON 与心跳的 ': keepalive' 并发写同一个 w。 + stopHeartbeat() + if lastErr != nil { + h.recordFailureWithDetails("openai", model, "", lastErr) + } + hbMu.Lock() + didCommit := committed + hbMu.Unlock() + + if didCommit { + // 心跳已落地 200 响应头:HTTP 状态码已无法更改,客户端正按 SSE 解析——必须以 + // SSE error chunk + [DONE] 收尾,而非 sendOpenAIError 的 WriteHeader+JSON(后者 + // 会触发 superfluous WriteHeader 且破坏流式协议)。 + msg := "No available accounts" + if lastErr != nil { + msg = lastErr.Error() + } + errChunk := map[string]interface{}{ + "id": chatID, + "object": "chat.completion.chunk", + "created": time.Now().Unix(), + "model": model, + "choices": []map[string]interface{}{{ + "index": 0, + "delta": map[string]interface{}{}, + "finish_reason": "error", + }}, + "error": map[string]string{"message": msg}, + } + errData, _ := json.Marshal(errChunk) + emitRaw(fmt.Sprintf("data: %s\n\n", string(errData))) + emitRaw("data: [DONE]\n\n") return } @@ -2054,7 +2231,6 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload return } - h.recordFailureWithDetails("openai", model, "", lastErr) h.sendOpenAIError(w, 500, "server_error", lastErr.Error()) } diff --git a/proxy/handler_keepalive_test.go b/proxy/handler_keepalive_test.go new file mode 100644 index 00000000..211c2120 --- /dev/null +++ b/proxy/handler_keepalive_test.go @@ -0,0 +1,200 @@ +package proxy + +import ( + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + accountpool "kiro-go/pool" + "kiro-go/config" +) + +// lockedFlushRecorder 是一个并发安全的 ResponseWriter+Flusher。流式 handler 的保活 +// goroutine 与主 goroutine 会并发写 w(上游中途静默时,保活补发 ': keepalive' 而主 +// goroutine 仍可能在静默结束后立刻写数据)。生产代码以 hbMu 串行化这两条写路径;本 +// recorder 自带锁,仅为让单测在并发写下不因 recorder 自身非线程安全而误报——真正要由 +// -race 验证的是 handler 内部 emit/emitRaw 与 ping 是否正确互斥。若 hbMu 失效,race +// detector 会先抓到 handler 内部对 lastWriteNano / w 的竞争。 +type lockedFlushRecorder struct { + mu sync.Mutex + buf strings.Builder +} + +func newLockedFlushRecorder() *lockedFlushRecorder { return &lockedFlushRecorder{} } + +func (r *lockedFlushRecorder) Header() http.Header { return make(http.Header) } + +func (r *lockedFlushRecorder) Write(p []byte) (int, error) { + r.mu.Lock() + defer r.mu.Unlock() + return r.buf.Write(p) +} + +func (r *lockedFlushRecorder) WriteHeader(int) {} + +func (r *lockedFlushRecorder) Flush() {} + +func (r *lockedFlushRecorder) body() string { + r.mu.Lock() + defer r.mu.Unlock() + return r.buf.String() +} + +// newStalledEventStreamServer 返回一个上游 server:先写一帧 assistant 文本,flush 后 +// 静默 stallFor(模拟 opus 两个数据块之间的长 thinking 静默),再写第二帧。用于触发 +// 流式 handler 在“数据流中途”发出保活 ping 的路径。 +func newStalledEventStreamServer(t *testing.T, stallFor time.Duration) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + flusher, ok := w.(http.Flusher) + if !ok { + t.Errorf("upstream test server ResponseWriter is not a Flusher") + return + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ + "content": "first ", + })) + flusher.Flush() + time.Sleep(stallFor) + _, _ = w.Write(awsEventStreamFrame(t, "assistantResponseEvent", map[string]interface{}{ + "content": "second", + })) + flusher.Flush() + })) +} + +// withFastKeepalive 临时把保活间隔调小,并返回恢复函数。 +func withFastKeepalive(t *testing.T, d time.Duration) func() { + t.Helper() + old := streamKeepaliveInterval + streamKeepaliveInterval = d + return func() { streamKeepaliveInterval = old } +} + +// withNoTotalTimeoutStreamClient 临时把流式 client 换成无总超时的,避免我们故意制造的 +// 静默期间 client 把流砍断、掩盖保活路径。 +func withNoTotalTimeoutStreamClient(t *testing.T) func() { + t.Helper() + old := kiroHttpStore.Load() + kiroHttpStore.Store(&http.Client{Timeout: 0, Transport: &http.Transport{}}) + return func() { kiroHttpStore.Store(old) } +} + +func setupKeepaliveTestAccount(t *testing.T, id string) { + t.Helper() + cfgFile := t.TempDir() + "/config.json" + if err := config.Init(cfgFile); err != nil { + t.Fatalf("config.Init: %v", err) + } + if err := config.AddAccount(config.Account{ + ID: id, + Enabled: true, + AccessToken: "token-" + id, + ProfileArn: "arn:aws:codewhisperer:profile/" + id, + }); err != nil { + t.Fatalf("add account: %v", err) + } + if err := config.UpdatePreferredEndpoint("kiro"); err != nil { + t.Fatalf("set preferred endpoint: %v", err) + } + if err := config.UpdateEndpointFallback(false); err != nil { + t.Fatalf("disable endpoint fallback: %v", err) + } +} + +func keepaliveTestPayload(model string) *KiroPayload { + payload := &KiroPayload{} + payload.ConversationState.CurrentMessage.UserInputMessage = KiroUserInputMessage{ + Content: "hello", + ModelID: model, + Origin: "AI_EDITOR", + } + return payload +} + +// TestClaudeStreamEmitsKeepaliveDuringUpstreamStall 验证:上游在两帧数据之间长静默时, +// handleClaudeStream 会在数据流中途向客户端发出 ': keepalive',且两帧真实文本仍完整下发、 +// 协议收尾正确。保活 goroutine 与主 goroutine 经 hbMu 串行化、无并发写冲突。 +func TestClaudeStreamEmitsKeepaliveDuringUpstreamStall(t *testing.T) { + setupKeepaliveTestAccount(t, "claude-keepalive") + + // 间隔 20ms,静默 200ms(≥ 多个保活周期),确保至少补发一次 ping。 + defer withFastKeepalive(t, 20*time.Millisecond)() + + server := newStalledEventStreamServer(t, 200*time.Millisecond) + defer server.Close() + + oldEndpoints := kiroEndpoints + kiroEndpoints = []kiroEndpoint{{URL: server.URL, Origin: "AI_EDITOR", Name: "test"}} + defer func() { kiroEndpoints = oldEndpoints }() + defer withNoTotalTimeoutStreamClient(t)() + + p := accountpool.GetPool() + p.Reload() + h := &Handler{ + pool: p, + promptCache: newPromptCacheTracker(defaultPromptCacheTTL), + } + + model := "claude-opus-4-8" + rec := newLockedFlushRecorder() + h.handleClaudeStream(rec, keepaliveTestPayload(model), model, false, claudeThinkingResponseOptions{}, 1000, nil, "") + + body := rec.body() + if !strings.Contains(body, ": keepalive") { + t.Fatalf("expected a keepalive comment emitted during upstream stall, got body=%s", body) + } + // 两帧真实文本必须都送达,保活不能吞掉或打断数据。 + if !strings.Contains(body, "first ") || !strings.Contains(body, "second") { + t.Fatalf("expected both upstream text frames in output, got body=%s", body) + } + // 协议收尾必须存在(message_start 在前、message_stop 在后)。 + if !strings.Contains(body, "message_start") { + t.Fatalf("expected message_start, got body=%s", body) + } + if !strings.Contains(body, "message_stop") { + t.Fatalf("expected message_stop, got body=%s", body) + } +} + +// TestOpenAIStreamEmitsKeepaliveDuringUpstreamStall 与上等价,覆盖 handleOpenAIStream +// 的 emitRaw/ping 并发路径。 +func TestOpenAIStreamEmitsKeepaliveDuringUpstreamStall(t *testing.T) { + setupKeepaliveTestAccount(t, "openai-keepalive") + + defer withFastKeepalive(t, 20*time.Millisecond)() + + server := newStalledEventStreamServer(t, 200*time.Millisecond) + defer server.Close() + + oldEndpoints := kiroEndpoints + kiroEndpoints = []kiroEndpoint{{URL: server.URL, Origin: "AI_EDITOR", Name: "test"}} + defer func() { kiroEndpoints = oldEndpoints }() + defer withNoTotalTimeoutStreamClient(t)() + + p := accountpool.GetPool() + p.Reload() + h := &Handler{ + pool: p, + promptCache: newPromptCacheTracker(defaultPromptCacheTTL), + } + + model := "claude-opus-4-8" + rec := newLockedFlushRecorder() + h.handleOpenAIStream(rec, keepaliveTestPayload(model), model, false, 1000, "") + + body := rec.body() + if !strings.Contains(body, ": keepalive") { + t.Fatalf("expected a keepalive comment emitted during upstream stall, got body=%s", body) + } + if !strings.Contains(body, "first ") || !strings.Contains(body, "second") { + t.Fatalf("expected both upstream text frames in output, got body=%s", body) + } + if !strings.Contains(body, "[DONE]") { + t.Fatalf("expected [DONE] terminator, got body=%s", body) + } +} From fe7cb526c3f7118f7ce60b96037f77970cbd6216 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 16:36:44 +0700 Subject: [PATCH 51/68] fix(auth): constant-time compare for legacy single API key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy single-key path compared the provided key with a plain `provided != expected`. A byte-by-byte early-exit string compare leaks the length of the matching prefix through response timing, giving a network observer a side-channel to recover the configured API key one prefix at a time. Replace with crypto/subtle.ConstantTimeCompare, matching the constant-time checks already used for the admin password (handler.go) and the audit-log password (prod_middleware.go). The multi-key path (FindApiKeyByValue) is out of scope — it maps key→entry by hash, not by direct comparison. No new dependency (crypto/subtle is stdlib). build/vet green. --- proxy/auth.go | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/proxy/auth.go b/proxy/auth.go index 922ea220..b54d14ac 100644 --- a/proxy/auth.go +++ b/proxy/auth.go @@ -2,6 +2,7 @@ package proxy import ( "context" + "crypto/subtle" "kiro-go/config" "net/http" "strings" @@ -85,7 +86,11 @@ func (h *Handler) authenticate(r *http.Request) (*config.ApiKeyEntry, error) { // Auth required but nothing configured → fail closed. return nil, newAuthError(http.StatusUnauthorized, "authentication_error", "API key authentication is required but no keys are configured") } - if provided == "" || provided != expected { + // Constant-time compare so a network observer cannot time-attack the key + // (byte-by-byte early-exit of `provided != expected` would leak the length of + // the correct prefix). Matches the constant-time check already used for the + // admin password (handler.go) and audit-log password (prod_middleware.go). + if provided == "" || subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) != 1 { return nil, newAuthError(http.StatusUnauthorized, "authentication_error", "Invalid or missing API key") } return nil, nil From be43c7faff41719fb03445ebe0e979af788d571a Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 16:41:54 +0700 Subject: [PATCH 52/68] feat(headers): derive stable MachineId fallback (anti-fingerprint, zero-dep P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from kiro-tutu (zero-dep: stdlib crypto/sha256 + encoding/hex). Motivation: when an account has an empty MachineId, buildKiroHeaderValues appended no device suffix, so the User-Agent ended at the KiroIDE version and was byte-identical for every empty-id account. A shared, stable UA across many accounts is the strongest cross-account association signal upstream can correlate on — the single most reliable way to detect that these are one operator's pool rather than independent users. config.DeriveMachineId(accountID) deterministically maps an account ID to a stable 64-hex device id (sha256("kiro-device-"+id)). The empty-MachineId fallback now uses it, so every account's UA always carries a unique, fixed device fingerprint — same value per account across requests and restarts, distinct across accounts. An explicitly configured MachineId still wins; a nil account (no ID to derive from) keeps the suffix-less UA. Only the device-id dimension is diversified here (the dominant signal). The tutu reference additionally derives a per-account desktop platform (OS/version) triple via GetKiroClientConfig(accountID); that slice is a larger port touching the config API surface and is intentionally left for a follow-up — the device suffix alone removes the strongest correlation signal. Test (TestBuildKiroHeaderValuesFallsBackToDerivedMachineId): empty-id fallback is stable across calls, carries the derived suffix, is distinct across accounts, yields to an explicit MachineId, and is absent for a nil account. build/vet/test green: proxy 6.457s, pool 0.545s, config 0.820s. --- config/config.go | 17 ++++++++++++++++ proxy/kiro_headers.go | 10 ++++++++++ proxy/kiro_headers_test.go | 41 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/config/config.go b/config/config.go index f998b4c4..da8cace7 100644 --- a/config/config.go +++ b/config/config.go @@ -12,6 +12,8 @@ package config import ( "crypto/rand" + "crypto/sha256" + "encoding/hex" "encoding/json" "fmt" "os" @@ -32,6 +34,21 @@ func GenerateMachineId() string { bytes[0:4], bytes[4:6], bytes[6:8], bytes[8:10], bytes[10:16]) } +// DeriveMachineId deterministically derives a stable 64-hex device id from an +// account ID via sha256("kiro-device-" + accountID). It is a pure function: +// the same account ID always yields the same value, so an account looks like a +// single fixed device across requests and across restarts. Ported from +// kiro-tutu (zero-dep: stdlib crypto/sha256 + encoding/hex). +// +// Used as the empty-MachineId fallback in kiro_headers.go so every account's +// User-Agent always carries a unique, stable device suffix instead of all +// empty-id accounts sharing an identical UA (the strongest cross-account +// association signal upstream can correlate on). +func DeriveMachineId(accountID string) string { + sum := sha256.Sum256([]byte("kiro-device-" + accountID)) + return hex.EncodeToString(sum[:]) +} + // Account represents a Kiro API account with authentication credentials and usage statistics. type Account struct { // Basic identification diff --git a/proxy/kiro_headers.go b/proxy/kiro_headers.go index 3b677b04..1c883db2 100644 --- a/proxy/kiro_headers.go +++ b/proxy/kiro_headers.go @@ -30,6 +30,16 @@ func buildKiroHeaderValues(account *config.Account, host, apiName, sdkVersion, m machineID := "" if account != nil { machineID = account.MachineId + // Never let machineId be empty: an empty value drops the UA suffix and + // makes this account's User-Agent identical to every other empty-id + // account, which is the strongest cross-account association signal + // upstream can correlate on. Fall back to a stable, account-derived + // 64-hex id (sha256) so the UA always carries a unique, fixed device + // fingerprint — same value per account across requests and restarts. + // Ported from kiro-tutu (zero-dep: config.DeriveMachineId is stdlib). + if machineID == "" && account.ID != "" { + machineID = config.DeriveMachineId(account.ID) + } } userAgent := fmt.Sprintf( diff --git a/proxy/kiro_headers_test.go b/proxy/kiro_headers_test.go index a4b08055..d357aa15 100644 --- a/proxy/kiro_headers_test.go +++ b/proxy/kiro_headers_test.go @@ -41,3 +41,44 @@ func TestBuildRuntimeHeaderValuesUsesRuntimeAPIFormat(t *testing.T) { t.Fatalf("expected runtime mode marker in user agent, got %q", values.UserAgent) } } + +// TestBuildKiroHeaderValuesFallsBackToDerivedMachineId asserts an account with an +// empty MachineId still gets a unique, stable device suffix in the User-Agent. +// Without the fallback every empty-id account shares an identical UA, which is the +// strongest cross-account association signal upstream can correlate on. The fallback +// must be deterministic so the same account always looks like the same device. +func TestBuildKiroHeaderValuesFallsBackToDerivedMachineId(t *testing.T) { + acc := &config.Account{ID: "acct-empty-id", MachineId: ""} + a := buildKiroHeaderValues(acc, "q.us-east-1.amazonaws.com", "codewhispererstreaming", "1.0.34", "m/E") + b := buildKiroHeaderValues(acc, "q.us-east-1.amazonaws.com", "codewhispererstreaming", "1.0.34", "m/E") + + // Deterministic: same account → same UA. + if a.UserAgent != b.UserAgent { + t.Fatalf("empty-id fallback must be stable across calls:\n a=%q\n b=%q", a.UserAgent, b.UserAgent) + } + // UA must carry a device suffix (the derived id) rather than ending at the version. + suffix := "-" + config.DeriveMachineId("acct-empty-id") + if !strings.Contains(a.UserAgent, suffix) { + t.Fatalf("expected UA to carry derived machine id suffix %q, got %q", suffix, a.UserAgent) + } + + // Distinct across accounts: two empty-id accounts must NOT share a UA. + other := &config.Account{ID: "acct-other-id", MachineId: ""} + c := buildKiroHeaderValues(other, "q.us-east-1.amazonaws.com", "codewhispererstreaming", "1.0.34", "m/E") + if a.UserAgent == c.UserAgent { + t.Fatalf("two distinct empty-id accounts must get distinct device suffixes, both got %q", a.UserAgent) + } + + // An explicitly-set MachineId still wins over the fallback. + explicit := &config.Account{ID: "acct-empty-id", MachineId: "explicit-machine"} + e := buildKiroHeaderValues(explicit, "q.us-east-1.amazonaws.com", "codewhispererstreaming", "1.0.34", "m/E") + if !strings.Contains(e.UserAgent, "-explicit-machine") { + t.Fatalf("explicit MachineId must win over fallback, got %q", e.UserAgent) + } + + // A nil account (no ID to derive from) keeps the suffix-less UA — nothing to derive from. + none := buildKiroHeaderValues(nil, "q.us-east-1.amazonaws.com", "codewhispererstreaming", "1.0.34", "m/E") + if strings.Contains(none.UserAgent, suffix) { + t.Fatalf("nil account must not carry a derived suffix, got %q", none.UserAgent) + } +} From 771a748a7a4dd4174bd2bf820d60b1df3a9fa8eb Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 16:52:33 +0700 Subject: [PATCH 53/68] feat(headers): per-account client profile, never Linux (anti-fingerprint, zero-dep P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from kiro-tutu (zero-dep: stdlib crypto/sha256 + encoding/hex). Motivation: GetKiroClientConfig() chose the {systemVersion, kiroVersion, nodeVersion} UA triple via runtime.GOOS. A Linux server deploy therefore advertised os/linux#6.6.87 in every request's User-Agent. A Linux kernel in a Kiro IDE UA is the single strongest "this is not a real Kiro IDE user" signal — it directly contradicts the one platform Kiro IDE actually ships on (mac/win), and it appeared identically on every account. This is the highest-value remaining anti-fingerprint gap. Replaces runtime.GOOS selection with a deterministic per-account profile: - clientProfilePool: a weighted, verified-real set of {platform, version} triples (macOS-weighted 85%, Windows minority 15%). Every systemVersion is a real shipped build (darwin#23.6.0/24.5.0/24.6.0, win32#10.0.22631/ 19045). NEVER contains Linux — the pool guarantees the output platform is always mac/win regardless of host OS. - DeriveClientProfile(accountID): sha256("kiro-profile-"+accountID) selects from the pool by cumulative weight. Pure + deterministic, so an account looks like one stable desktop install across requests and restarts, and distinct accounts spread across the distribution. The "kiro-profile-" prefix differs from DeriveMachineId's "kiro-device-" so platform and device selection are independent while both stay bound to the account. - GetKiroClientConfig(accountID): derives the triple from the account (never Linux); operator config overrides still win per-field, preserving the manual-override path. defaultSystemVersion() + runtime.GOOS path removed (and the runtime import with it). Pairs with the MachineId fallback (be43c7f): device id + platform both derive from the same accountID, so one account presents one consistent mac/win desktop install. Tests (config/client_profile_test.go): never-Linux over many IDs incl. empty; deterministic stability; distribution matches weights (5000 samples); verified KiroVersion/NodeVersion preserved; operator override wins per-field. build/vet/test green: proxy 6.457s, pool 0.545s, config 0.820s. The one FAIL in the full proxy run (TestOpenAIStreamEmitsKeepaliveDuringUpstreamStall → unlinkat ... directory is not empty) is the pre-existing Windows TempDir cleanup flake, reproducible on main before this slice; passes in isolation. --- config/client_profile_test.go | 95 ++++++++++++++++++++++++ config/config.go | 133 +++++++++++++++++++++++++++------- proxy/kiro_headers.go | 9 ++- 3 files changed, 209 insertions(+), 28 deletions(-) create mode 100644 config/client_profile_test.go diff --git a/config/client_profile_test.go b/config/client_profile_test.go new file mode 100644 index 00000000..e8f59ba2 --- /dev/null +++ b/config/client_profile_test.go @@ -0,0 +1,95 @@ +package config + +import ( + "strings" + "testing" +) + +// TestDeriveClientProfileNeverReturnsLinux là tính chất quan trọng nhất của pool: một tài khoản +// không bao giờ được gán dấu vân tay Linux — dấu hiệu nhận diện mạnh nhất cho việc "không phải +// người dùng Kiro IDE thực sự" khi triển khai trên máy chủ Linux. Bất kể accountID là gì (kể cả +// rỗng), kết quả phải luôn là mac/win. +func TestDeriveClientProfileNeverReturnsLinux(t *testing.T) { + for _, id := range []string{"", "a", "acct-1", "acct-2", "some-long-account-id-xyz"} { + p := DeriveClientProfile(id) + if strings.HasPrefix(p.systemVersion, "linux") { + t.Fatalf("accountID=%q derived Linux systemVersion %q — must never happen", id, p.systemVersion) + } + } +} + +// TestDeriveClientProfileStable xác nhận hàm thuần xác định: cùng accountID → cùng bộ ba dấu vân +// tay, qua nhiều lần gọi (một tài khoản trông như một desktop cố định trên các yêu cầu/khởi động lại). +func TestDeriveClientProfileStable(t *testing.T) { + a := DeriveClientProfile("acct-stable") + b := DeriveClientProfile("acct-stable") + if a != b { + t.Fatalf("same accountID must yield identical profile:\n a=%+v\n b=%+v", a, b) + } +} + +// TestDeriveClientProfileDistributionMatchesWeights xác nhận việc phân bổ theo trọng số đạt được: +// tính toán các lựa chọn cho một loạt các accountID và kỳ vọng tỷ lệ thô khớp với trọng số pool +// (mac được ưu tiên, một số ít dùng win). Sử dụng các ID được băm, không phải ngẫu nhiên, để +// không bị sai lệch (flaky). +func TestDeriveClientProfileDistributionMatchesWeights(t *testing.T) { + mac, win := 0, 0 + n := 5000 + for i := 0; i < n; i++ { + p := DeriveClientProfile("acct-" + string(rune('a'+i%26)) + string(rune('0'+i%10)) + "x") + if strings.HasPrefix(p.systemVersion, "darwin") { + mac++ + } else if strings.HasPrefix(p.systemVersion, "win32") { + win++ + } + } + // Trọng số pool: mac=85, win=15 → mac nên chiếm phần lớn. Chấp nhận sai số ±5%. + if mac < int(float64(n)*0.80) { + t.Fatalf("mac share too low: %d/%d (expected ~85%%)", mac, n) + } + if win < int(float64(n)*0.10) { + t.Fatalf("win share too low: %d/%d (expected ~15%%)", win, n) + } + if mac+win != n { + t.Fatalf("all profiles must be mac or win, got other: %d", n-mac-win) + } +} + +// TestDeriveClientProfileUsesVerifiedVersions xác nhận mỗi cấu hình pool mang theo đúng bộ Kiro +// 0.11.107 / node 22.22.0 đã được xác minh (chỉ đa dạng hóa nền tảng; đồng nhất phiên bản là +// được kỳ vọng cho người dùng thực tế tự động cập nhật). +func TestDeriveClientProfileUsesVerifiedVersions(t *testing.T) { + for _, id := range []string{"a", "b", "c"} { + p := DeriveClientProfile(id) + if p.kiroVersion != "0.11.107" { + t.Fatalf("kiroVersion must be the verified 0.11.107, got %q", p.kiroVersion) + } + if p.nodeVersion != "22.22.0" { + t.Fatalf("nodeVersion must be the verified 22.22.0, got %q", p.nodeVersion) + } + } +} + +// TestGetKiroClientConfigOperatorOverrideWinsPerField xác nhận các ghi đè từ config +// (KiroVersion/SystemVersion/NodeVersion) có mức ưu tiên cao hơn cho mỗi trường, giữ nguyên đường +// dẫn ghi đè thủ công. Các trường không được đặt sẽ lấy từ cấu hình được dẫn xuất. +func TestGetKiroClientConfigOperatorOverrideWinsPerField(t *testing.T) { + cfgLock.Lock() + old := cfg + cfg = &Config{SystemVersion: "win32#10.0.19045"} // chỉ ghi đè system + cfgLock.Unlock() + t.Cleanup(func() { + cfgLock.Lock() + cfg = old + cfgLock.Unlock() + }) + + got := GetKiroClientConfig("any-account") + if got.SystemVersion != "win32#10.0.19045" { + t.Fatalf("operator SystemVersion override must win, got %q", got.SystemVersion) + } + // Các trường chưa được đặt vẫn dùng các phiên bản đã được xác minh. + if got.KiroVersion != "0.11.107" || got.NodeVersion != "22.22.0" { + t.Fatalf("unset fields must fall back to verified versions, got %+v", got) + } +} diff --git a/config/config.go b/config/config.go index da8cace7..44ff970e 100644 --- a/config/config.go +++ b/config/config.go @@ -17,7 +17,6 @@ import ( "encoding/json" "fmt" "os" - "runtime" "sync" "time" ) @@ -49,6 +48,86 @@ func DeriveMachineId(accountID string) string { return hex.EncodeToString(sum[:]) } +// clientProfile is one entry in the desktop fingerprint pool: a real +// {OS#kernel, KiroIDE version, node version} triple that a genuine Kiro IDE +// install would report. Every value here MUST be a combination that actually +// shipped — fabricating version numbers would create a fresh "non-genuine +// client" signal, which is exactly what we are trying to avoid. Ported from +// kiro-tutu (zero-dep). +type clientProfile struct { + systemVersion string + kiroVersion string + nodeVersion string + weight int +} + +// clientProfilePool is the weighted distribution of desktop fingerprints used +// to derive a per-account {platform, version} triple. +// +// Design constraints: +// - macOS-weighted, with a small Windows minority, matching the real account +// source distribution. A wrong distribution manufactures a new +// "unrealistic population" signal. +// - NEVER contains Linux. A Linux server kernel in a Kiro IDE UA is the +// single strongest "not a real user" tell; this pool guarantees the output +// platform is always mac/win regardless of runtime.GOOS — so a deploy on a +// Linux box no longer advertises linux#6.6.87 in every request's UA. +// - Every systemVersion is a verified real macOS↔Darwin / Windows build: +// darwin#23.6.0 = macOS 14.6 (Sonoma), darwin#24.5.0 = macOS 15.5, +// darwin#24.6.0 = macOS 15.6 (Sequoia); win32#10.0.22631 = Win11 23H2, +// win32#10.0.19045 = Win10 22H2. +// - KiroVersion/NodeVersion stay on the one combination confirmed against a +// genuine client (0.11.107 / 22.22.0). Version uniformity is expected +// (real users auto-update), so only the platform dimension is diversified. +var clientProfilePool = []clientProfile{ + {systemVersion: "darwin#24.6.0", kiroVersion: "0.11.107", nodeVersion: "22.22.0", weight: 35}, // macOS 15.6 Sequoia + {systemVersion: "darwin#24.5.0", kiroVersion: "0.11.107", nodeVersion: "22.22.0", weight: 25}, // macOS 15.5 Sequoia + {systemVersion: "darwin#23.6.0", kiroVersion: "0.11.107", nodeVersion: "22.22.0", weight: 25}, // macOS 14.6 Sonoma + {systemVersion: "win32#10.0.22631", kiroVersion: "0.11.107", nodeVersion: "22.22.0", weight: 10}, // Win11 23H2 + {systemVersion: "win32#10.0.19045", kiroVersion: "0.11.107", nodeVersion: "22.22.0", weight: 5}, // Win10 22H2 +} + +// DeriveClientProfile deterministically derives a stable desktop fingerprint +// for an account by hashing sha256("kiro-profile-" + accountID) and selecting +// from clientProfilePool by cumulative weight. It is a pure function: the same +// account ID always yields the same {platform, version} triple, across +// requests and across restarts, so an account looks like a single fixed +// desktop install — and it never returns a Linux platform regardless of the +// host OS. +// +// The "kiro-profile-" prefix differs from DeriveMachineId's "kiro-device-" so +// the platform selection is independent of the device-id hash, while both +// remain stably bound to the same account. +func DeriveClientProfile(accountID string) clientProfile { + totalWeight := 0 + for _, p := range clientProfilePool { + totalWeight += p.weight + } + if totalWeight <= 0 || len(clientProfilePool) == 0 { + // Defensive: a misconfigured pool must still never emit Linux. Fall back + // to the first verified mac entry rather than runtime.GOOS. + return clientProfile{systemVersion: "darwin#24.6.0", kiroVersion: "0.11.107", nodeVersion: "22.22.0"} + } + + sum := sha256.Sum256([]byte("kiro-profile-" + accountID)) + // Use the first 8 bytes as an unsigned 64-bit selector, then mod by total + // weight. Deterministic and uniform enough for distribution purposes. + var selector uint64 + for i := 0; i < 8; i++ { + selector = selector<<8 | uint64(sum[i]) + } + target := int(selector % uint64(totalWeight)) + + cumulative := 0 + for _, p := range clientProfilePool { + cumulative += p.weight + if target < cumulative { + return p + } + } + return clientProfilePool[len(clientProfilePool)-1] // unreachable, defensive +} + // Account represents a Kiro API account with authentication credentials and usage statistics. type Account struct { // Basic identification @@ -1158,32 +1237,43 @@ func UpdateLogLevel(level string) error { return Save() } +// KiroClientConfig is the {KiroVersion, SystemVersion, NodeVersion} triple +// embedded into the upstream User-Agent. Populated by GetKiroClientConfig. type KiroClientConfig struct { KiroVersion string SystemVersion string NodeVersion string } -func GetKiroClientConfig() KiroClientConfig { +// GetKiroClientConfig resolves the {KiroVersion, SystemVersion, NodeVersion} +// triple for accountID. When no operator override is set on a field, it derives +// a stable desktop fingerprint from the account via DeriveClientProfile — which +// selects from clientProfilePool (mac/win-weighted) and NEVER returns Linux, so +// a deploy on a Linux box no longer advertises linux#6.6.87 in every request's +// User-Agent. An empty accountID still yields a stable mac-default profile +// (never Linux). Ported from kiro-tutu (zero-dep). +// +// Operator overrides from config (KiroVersion/SystemVersion/NodeVersion) take +// precedence per-field when explicitly set, preserving the manual-override path. +func GetKiroClientConfig(accountID string) KiroClientConfig { cfgLock.RLock() defer cfgLock.RUnlock() - kiroVersion := "0.11.107" - if cfg != nil && cfg.KiroVersion != "" { - kiroVersion = cfg.KiroVersion - } + profile := DeriveClientProfile(accountID) + kiroVersion := profile.kiroVersion + systemVersion := profile.systemVersion + nodeVersion := profile.nodeVersion - systemVersion := "" if cfg != nil { - systemVersion = cfg.SystemVersion - } - if systemVersion == "" { - systemVersion = defaultSystemVersion() - } - - nodeVersion := "22.22.0" - if cfg != nil && cfg.NodeVersion != "" { - nodeVersion = cfg.NodeVersion + if cfg.KiroVersion != "" { + kiroVersion = cfg.KiroVersion + } + if cfg.SystemVersion != "" { + systemVersion = cfg.SystemVersion + } + if cfg.NodeVersion != "" { + nodeVersion = cfg.NodeVersion + } } return KiroClientConfig{ @@ -1192,14 +1282,3 @@ func GetKiroClientConfig() KiroClientConfig { NodeVersion: nodeVersion, } } - -func defaultSystemVersion() string { - switch runtime.GOOS { - case "windows": - return "win32#10.0.22631" - case "darwin": - return "darwin#24.6.0" - default: - return "linux#6.6.87" - } -} diff --git a/proxy/kiro_headers.go b/proxy/kiro_headers.go index 1c883db2..de008104 100644 --- a/proxy/kiro_headers.go +++ b/proxy/kiro_headers.go @@ -26,7 +26,14 @@ func buildRuntimeHeaderValues(account *config.Account, host string) kiroHeaderVa } func buildKiroHeaderValues(account *config.Account, host, apiName, sdkVersion, mode string) kiroHeaderValues { - clientCfg := config.GetKiroClientConfig() + accountID := "" + if account != nil { + accountID = account.ID + } + // Derive the desktop fingerprint (platform/version) from the same accountID + // used for the device id below, so an account's platform and device stay + // mutually consistent — one stable mac/win install, never a Linux server. + clientCfg := config.GetKiroClientConfig(accountID) machineID := "" if account != nil { machineID = account.MachineId From 8cb1c37f0656ad43935bf98e2b563bff61096f7a Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sat, 11 Jul 2026 17:25:35 +0700 Subject: [PATCH 54/68] feat(errors): translate opaque "Improperly formed request" to client hint (zero-dep P1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ported from kiro-tutu (zero-dep: stdlib errors + strings, reuses the existing isImproperlyFormedRejection predicate). Motivation: when upstream rejects a request with "Improperly formed request", the raw text reaches the client and reads like a gateway bug. In practice the request is usually structurally valid but the tools array is too large / payload oversized (upstream size limits). Users have no actionable hint. improperlyFormedClientMessage(err) rewrites that one opaque message into a readable, actionable hint ("...try reducing the number of tools or shortening the context"); every other error passes through unchanged, so wiring is safe at every client-facing terminal error site. It complements tool_compression.go (b27686c): compression PREVENTS the rejection when it can; when it cannot, this surfaces a hint instead of the raw text. Wired into all 10 client-facing terminal-error sites across the three protocols (Claude/OpenAI/Responses × stream/nonstream): the post-loop 500 sendClaudeError/sendOpenAIError tails, the committed-header SSE error sequences (stream paths), the mid-stream error events, and the response.failed events. The one remaining err.Error() in responses_handler (line 63) is a 400 input-validation error — intentionally left raw since it is a genuine client input problem, not an upstream rejection. Tests (upstream_error_test.go): translates the opaque rejection case-insensitively, passes other errors through unchanged, nil-safe. build/vet/test green: proxy 6.241s, pool 0.489s, config 0.706s, auth 2.234s. --- proxy/handler.go | 14 +++++----- proxy/responses_handler.go | 6 ++-- proxy/upstream_error.go | 21 ++++++++++++++ proxy/upstream_error_test.go | 53 ++++++++++++++++++++++++++++++++++++ 4 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 proxy/upstream_error_test.go diff --git a/proxy/handler.go b/proxy/handler.go index 19e30271..daca84f0 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -1295,7 +1295,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload h.recordFailureWithDetails("claude", model, account.ID, err) emit("error", map[string]interface{}{ "type": "error", - "error": map[string]string{"type": "api_error", "message": err.Error()}, + "error": map[string]string{"type": "api_error", "message": improperlyFormedClientMessage(err)}, }) return } @@ -1374,7 +1374,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload // superfluous WriteHeader 告警且破坏流式协议)。 msg := "No available accounts" if lastErr != nil { - msg = lastErr.Error() + msg = improperlyFormedClientMessage(lastErr) } ensureMessageStart() emit("error", map[string]interface{}{ @@ -1397,7 +1397,7 @@ func (h *Handler) handleClaudeStream(w http.ResponseWriter, payload *KiroPayload return } - h.sendClaudeError(w, 500, "api_error", lastErr.Error()) + h.sendClaudeError(w, 500, "api_error", improperlyFormedClientMessage(lastErr)) } func (h *Handler) sendSSE(w http.ResponseWriter, flusher http.Flusher, event string, data interface{}) { @@ -1693,7 +1693,7 @@ func (h *Handler) handleClaudeNonStream(w http.ResponseWriter, payload *KiroPayl } h.recordFailureWithDetails("claude", model, "", lastErr) - h.sendClaudeError(w, 500, "api_error", lastErr.Error()) + h.sendClaudeError(w, 500, "api_error", improperlyFormedClientMessage(lastErr)) } func (h *Handler) sendClaudeError(w http.ResponseWriter, status int, errType, message string) { @@ -2206,7 +2206,7 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload // 会触发 superfluous WriteHeader 且破坏流式协议)。 msg := "No available accounts" if lastErr != nil { - msg = lastErr.Error() + msg = improperlyFormedClientMessage(lastErr) } errChunk := map[string]interface{}{ "id": chatID, @@ -2231,7 +2231,7 @@ func (h *Handler) handleOpenAIStream(w http.ResponseWriter, payload *KiroPayload return } - h.sendOpenAIError(w, 500, "server_error", lastErr.Error()) + h.sendOpenAIError(w, 500, "server_error", improperlyFormedClientMessage(lastErr)) } // handleOpenAINonStream OpenAI 非流式响应 @@ -2326,7 +2326,7 @@ func (h *Handler) handleOpenAINonStream(w http.ResponseWriter, payload *KiroPayl } h.recordFailureWithDetails("openai", model, "", lastErr) - h.sendOpenAIError(w, 500, "server_error", lastErr.Error()) + h.sendOpenAIError(w, 500, "server_error", improperlyFormedClientMessage(lastErr)) } func (h *Handler) sendOpenAIError(w http.ResponseWriter, status int, errType, message string) { diff --git a/proxy/responses_handler.go b/proxy/responses_handler.go index 62e0d6a2..6a16a7c4 100644 --- a/proxy/responses_handler.go +++ b/proxy/responses_handler.go @@ -224,7 +224,7 @@ func (h *Handler) handleResponsesNonStream( return } h.recordFailureWithDetails("responses", model, "", lastErr) - h.sendOpenAIError(w, 500, "server_error", lastErr.Error()) + h.sendOpenAIError(w, 500, "server_error", improperlyFormedClientMessage(lastErr)) } func buildResponsesObject( @@ -505,7 +505,7 @@ func (h *Handler) handleResponsesStream( "status": "failed", "error": map[string]string{ "type": "server_error", - "message": err.Error(), + "message": improperlyFormedClientMessage(err), }, }, }) @@ -601,7 +601,7 @@ func (h *Handler) handleResponsesStream( "status": "failed", "error": map[string]string{ "type": "server_error", - "message": lastErr.Error(), + "message": improperlyFormedClientMessage(lastErr), }, }, }) diff --git a/proxy/upstream_error.go b/proxy/upstream_error.go index e39efba8..73f54aa1 100644 --- a/proxy/upstream_error.go +++ b/proxy/upstream_error.go @@ -59,3 +59,24 @@ func isUpstreamPermanentError(err error) bool { _, ok := asUpstreamPermanentError(err) return ok } + +// improperlyFormedClientMessage translates the upstream's opaque "Improperly +// formed request" rejection into a readable client hint. In practice the vast +// majority of these rejections occur when the request structure is perfectly +// valid but the tool definitions are too numerous or the payload too large +// (upstream limits on large requests); the raw wording makes users think the +// gateway is buggy. Other errors pass through unchanged. +// +// Complements tool_compression.go (compressToolsIfNeeded): compression prevents +// the rejection when it can; when it cannot, this surfaces a actionable message +// instead of the raw upstream text. Ported from kiro-tutu (zero-dep). +func improperlyFormedClientMessage(err error) string { + if err == nil { + return "" + } + msg := err.Error() + if isImproperlyFormedRejection(msg) { + return "upstream rejected the request (commonly caused by too many tool definitions or an oversized payload); try reducing the number of tools or shortening the context" + } + return msg +} diff --git a/proxy/upstream_error_test.go b/proxy/upstream_error_test.go new file mode 100644 index 00000000..c29f96fc --- /dev/null +++ b/proxy/upstream_error_test.go @@ -0,0 +1,53 @@ +package proxy + +import ( + "errors" + "strings" + "testing" +) + +// TestImproperlyFormedClientMessageTranslatesOpaqueRejection verifies the upstream's +// opaque "Improperly formed request" wording is translated into a readable, actionable +// hint. Case-insensitive so a minor upstream wording/casing change does not bypass it. +func TestImproperlyFormedClientMessageTranslatesOpaqueRejection(t *testing.T) { + cases := []string{ + "Improperly formed request.", + "improperly formed request", + "HTTP 400: IMPROPERLY FORMED REQUEST body", + } + for _, raw := range cases { + got := improperlyFormedClientMessage(errors.New(raw)) + if got == raw { + t.Fatalf("expected translation for %q, got raw message back", raw) + } + if !strings.Contains(got, "tool definitions") || !strings.Contains(got, "reducing") { + t.Fatalf("expected actionable hint about tools/payload, got %q", got) + } + } +} + +// TestImproperlyFormedClientMessagePassesThroughOtherErrors verifies non-matching +// errors pass through unchanged — the helper must only rewrite the one known opaque +// rejection, not munge unrelated upstream/client messages. +func TestImproperlyFormedClientMessagePassesThroughOtherErrors(t *testing.T) { + cases := []string{ + "upstream returned empty assistant response", + "HTTP 500 from q: internal error", + "quota exhausted on codewhisperer", + "context length exceeded", + } + for _, raw := range cases { + got := improperlyFormedClientMessage(errors.New(raw)) + if got != raw { + t.Fatalf("expected pass-through for %q, got %q", raw, got) + } + } +} + +// TestImproperlyFormedClientMessageHandlesNil locks the nil-safety contract so a +// nil error (a missed guard at a call site) returns "" rather than panicking. +func TestImproperlyFormedClientMessageHandlesNil(t *testing.T) { + if got := improperlyFormedClientMessage(nil); got != "" { + t.Fatalf("nil error must yield empty string, got %q", got) + } +} From 93c79e50d63bd77e12c2a9c464389be4cfd80673 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sun, 12 Jul 2026 08:40:29 +0700 Subject: [PATCH 55/68] fix(translator): preserve explicit temperature:0 via *float64 float64 + omitempty treated an explicit temperature of 0 as a zero value and stripped it, silently reverting deterministic-decoding clients to the default temperature. Switch Temperature to *float64 in InferenceConfig, ClaudeRequest, and OpenAIRequest, and change the translator guards from `> 0` to `!= nil` so an explicitly-set 0 propagates to Kiro while an unset temperature is still omitted. --- proxy/inference_test.go | 75 ++++++++++++++++++++++++++++++++++++++ proxy/kiro.go | 6 +-- proxy/responses_handler.go | 2 +- proxy/translator.go | 8 ++-- 4 files changed, 83 insertions(+), 8 deletions(-) create mode 100644 proxy/inference_test.go diff --git a/proxy/inference_test.go b/proxy/inference_test.go new file mode 100644 index 00000000..704f41c9 --- /dev/null +++ b/proxy/inference_test.go @@ -0,0 +1,75 @@ +package proxy + +import ( + "encoding/json" + "strings" + "testing" +) + +// Guards the temperature:0 fix: an explicitly-provided temperature of 0 must be +// transmitted to Kiro (not dropped by omitempty), while an unset temperature is +// still omitted. Ported from kiro-tutu. +func TestInferenceConfigTransmitsExplicitZeroTemperature(t *testing.T) { + zero := 0.0 + withZero, _ := json.Marshal(&InferenceConfig{Temperature: &zero}) + if !strings.Contains(string(withZero), `"temperature":0`) { + t.Fatalf("explicit temperature 0 must be transmitted, got %s", withZero) + } + + unset, _ := json.Marshal(&InferenceConfig{}) + if strings.Contains(string(unset), "temperature") { + t.Fatalf("unset temperature must be omitted, got %s", unset) + } +} + +// TestClaudeRequestTransmitsExplicitZeroTemperature locks the full user-facing +// fix: a Claude request with temperature:0 (deterministic decoding — a real, +// deliberate setting) must survive JSON marshalling and reach the translator +// path, instead of being silently dropped by float64+omitempty (which treated +// 0 == "unset" and stripped the field, silently switching the client to the +// default temperature). +func TestClaudeRequestTransmitsExplicitZeroTemperature(t *testing.T) { + zero := 0.0 + raw, _ := json.Marshal(&ClaudeRequest{Model: "claude-sonnet-4.5", Temperature: &zero}) + if !strings.Contains(string(raw), `"temperature":0`) { + t.Fatalf("ClaudeRequest must transmit explicit temperature 0, got %s", raw) + } +} + +// TestOpenAIRequestTransmitsExplicitZeroTemperature is the OpenAI-side guard +// for the same temperature:0 drop bug. +func TestOpenAIRequestTransmitsExplicitZeroTemperature(t *testing.T) { + zero := 0.0 + raw, _ := json.Marshal(&OpenAIRequest{Model: "gpt-4o", Temperature: &zero}) + if !strings.Contains(string(raw), `"temperature":0`) { + t.Fatalf("OpenAIRequest must transmit explicit temperature 0, got %s", raw) + } +} + +// TestClaudeToKiroPropagatesExplicitZeroTemperature verifies the end-to-end +// translation path: temperature:0 on an inbound Claude request must land on the +// Kiro InferenceConfig (also as a non-nil 0), not be dropped — so deterministic +// decoding reaches the upstream model rather than silently reverting to default. +func TestClaudeToKiroPropagatesExplicitZeroTemperature(t *testing.T) { + zero := 0.0 + req := &ClaudeRequest{ + Model: "claude-sonnet-4.5", + MaxTokens: 1024, + Messages: []ClaudeMessage{{Role: "user", Content: "hi"}}, + Temperature: &zero, + } + payload := ClaudeToKiro(req, false) + if payload.InferenceConfig == nil { + t.Fatal("InferenceConfig must be set when temperature is explicitly 0") + } + if payload.InferenceConfig.Temperature == nil { + t.Fatal("temperature must be propagated as a non-nil pointer when explicitly 0") + } + if *payload.InferenceConfig.Temperature != 0 { + t.Fatalf("temperature must be 0, got %v", *payload.InferenceConfig.Temperature) + } + kiroRaw, _ := json.Marshal(payload.InferenceConfig) + if !strings.Contains(string(kiroRaw), `"temperature":0`) { + t.Fatalf("Kiro InferenceConfig must transmit temperature:0, got %s", kiroRaw) + } +} diff --git a/proxy/kiro.go b/proxy/kiro.go index 0bbb9c26..54653eea 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -224,9 +224,9 @@ type KiroToolUse struct { } type InferenceConfig struct { - MaxTokens int `json:"maxTokens,omitempty"` - Temperature float64 `json:"temperature,omitempty"` - TopP float64 `json:"topP,omitempty"` + MaxTokens int `json:"maxTokens,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopP float64 `json:"topP,omitempty"` } // ==================== Stream Callbacks ==================== diff --git a/proxy/responses_handler.go b/proxy/responses_handler.go index 6a16a7c4..d25de8b0 100644 --- a/proxy/responses_handler.go +++ b/proxy/responses_handler.go @@ -103,7 +103,7 @@ func (h *Handler) handleOpenAIResponses(w http.ResponseWriter, r *http.Request) Tools: req.Tools, } if req.Temperature != nil { - openaiReq.Temperature = *req.Temperature + openaiReq.Temperature = req.Temperature } if req.MaxOutputTokens != nil { openaiReq.MaxTokens = *req.MaxOutputTokens diff --git a/proxy/translator.go b/proxy/translator.go index faf17320..82c32e7d 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -123,7 +123,7 @@ type ClaudeRequest struct { Model string `json:"model"` Messages []ClaudeMessage `json:"messages"` MaxTokens int `json:"max_tokens"` - Temperature float64 `json:"temperature,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` TopP float64 `json:"top_p,omitempty"` Stream bool `json:"stream,omitempty"` System interface{} `json:"system,omitempty"` // string or []SystemBlock @@ -341,7 +341,7 @@ func ClaudeToKiro(req *ClaudeRequest, thinking bool) *KiroPayload { payload.ConversationState.History = history } - if req.MaxTokens > 0 || req.Temperature > 0 || req.TopP > 0 { + if req.MaxTokens > 0 || req.Temperature != nil || req.TopP > 0 { payload.InferenceConfig = &InferenceConfig{ MaxTokens: req.MaxTokens, Temperature: req.Temperature, @@ -991,7 +991,7 @@ type OpenAIRequest struct { Model string `json:"model"` Messages []OpenAIMessage `json:"messages"` MaxTokens int `json:"max_tokens,omitempty"` - Temperature float64 `json:"temperature,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` TopP float64 `json:"top_p,omitempty"` Stream bool `json:"stream,omitempty"` Tools []OpenAITool `json:"tools,omitempty"` @@ -1289,7 +1289,7 @@ func OpenAIToKiro(req *OpenAIRequest, thinking bool) *KiroPayload { payload.ConversationState.History = history } - if req.MaxTokens > 0 || req.Temperature > 0 || req.TopP > 0 { + if req.MaxTokens > 0 || req.Temperature != nil || req.TopP > 0 { payload.InferenceConfig = &InferenceConfig{ MaxTokens: req.MaxTokens, Temperature: req.Temperature, From 6213fb98bab34eb5a291fbb39e4043ebd44a0d1f Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Sun, 12 Jul 2026 08:42:44 +0700 Subject: [PATCH 56/68] chore(gitignore): ignore local scratch with live API key + editor backups + smoke output /test.py holds a live sk- API key used for local smoke runs and must never be committed. *~ covers editor backups like kiro-go.exe~ (not matched by the existing *.exe rule). .smoke/ is local dispatch/CLI smoke-test output. --- .gitignore | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/.gitignore b/.gitignore index 25906042..3dbd4394 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,9 @@ Thumbs.db # Local backups / scratch backup*/ PR-*.md +# /test.py: local scratch script holding a live API key (never commit) +/test.py +# editor backup files (e.g. kiro-go.exe~) +*~ +# local smoke-test output (dispatch runs, cached prompts, r-*.out) +.smoke/ From f04e8b1e55f16da58ab362f71c52ba675f58e87f Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 13:53:42 +0700 Subject: [PATCH 57/68] =?UTF-8?q?feat(cache):=20structural=20system-skip?= =?UTF-8?q?=20+=20auto-prefix=20breakpoints=20(Rust=E2=86=92Go=20port)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the two remaining cache-semantics items from kiro-rs cache_metering.rs (zero-dep). Both are billing-number-sensitive, so behavior is locked by TDD tests mirroring the Rust cases. (3) Structural skip of leading system blocks before the first cache_control (mirrors extract_segments:444-462). Claude Code injects a per-turn DYNAMIC block at system[0] WITHOUT cache_control, followed by the stable cached block(s). Fingerprinting from that volatile head drifts every downstream prefix fingerprint across turns, collapsing cross-turn hits to zero. appendSystemCacheBlocks []interface{} branch now computes skipUntil = index of the first system block carrying cache_control (unwrap_or(0) when none) and iterates v[skipUntil:]. system_index is a stripped position key so the index shift does not affect fingerprints. isAnthropicBillingHeaderBlock string-match drop stays as a backstop for a non-leading billing block (Rust has no such guard — strictly an improvement). (4) Auto-prefix breakpoints without explicit cache_control (mirrors prefix_chain_works_without_any_cache_control + detect_max_ttl). A request with zero cache_control markers now still produces message-end breakpoints at the default TTL, reproducing Anthropic's automatic prompt caching (a stable history prefix hits across turns even though the client never set cache_control). Removed the claudeRequestHasCacheControl fast-path nil-return in BuildClaudeProfile; activeTTL is now seeded with detectMaxTTL(req) (scans system+message blocks, max ttl 1h>5m, default 5m) so implicit message-end breakpoints fire from the first request. Tests: TestPromptCacheStructuralSkipOfLeadingDynamicSystemBlock + TestBuildClaudeProfileAutoPrefixWithoutCacheControl (replaces ...ReturnsNilWithoutCacheControl). RED→GREEN; go build/vet ./proxy/... ./pool/... ./config/... clean. The Windows TempDir cleanup flake (TestClaudeStreamEmitsKeepaliveDuringUpstreamStall) is pre-existing and reproduced on main, not a regression. --- proxy/cache_tracker.go | 67 ++++++++++++++++++++++----- proxy/cache_tracker_hardening_test.go | 57 +++++++++++++++++------ proxy/cache_tracker_test.go | 65 ++++++++++++++++++++++++++ 3 files changed, 163 insertions(+), 26 deletions(-) diff --git a/proxy/cache_tracker.go b/proxy/cache_tracker.go index f941d5e5..f8b4cc50 100644 --- a/proxy/cache_tracker.go +++ b/proxy/cache_tracker.go @@ -276,17 +276,37 @@ func claudeRequestHasCacheControl(req *ClaudeRequest) bool { return false } -func (t *promptCacheTracker) BuildClaudeProfile(req *ClaudeRequest, totalInputTokens int) *promptCacheProfile { - // Fast path: if no block carries cache_control, no breakpoint can be - // produced — explicit breakpoints need TTL > 0, and implicit message-end - // breakpoints only fire once an explicit one has set activeTTL > 0. So a - // request with zero cache_control markers yields zero breakpoints and a nil - // profile. Skipping the flatten/canonicalize/hash work avoids O(prompt) work - // for non-Claude-Code clients that send large prompts without cache_control. - // Outcome-identical to the full pass (returns nil iff the pass produces none). - if !claudeRequestHasCacheControl(req) { - return nil +// detectMaxTTL returns the largest cache_control TTL present anywhere in the +// request (1h wins over 5m), defaulting to defaultPromptCacheTTL when no +// cache_control marker exists. Mirrors Rust detect_max_ttl: it seeds the +// auto-prefix activeTTL so a request WITHOUT explicit cache_control still +// produces message-end breakpoints at the default TTL — reproducing Anthropic's +// automatic prompt caching (a stable history prefix hits across turns even +// though the client never set cache_control). ClaudeTool is a typed struct with +// no cache_control field, so only system/message blocks can contribute. +func detectMaxTTL(req *ClaudeRequest) time.Duration { + max := defaultPromptCacheTTL + bump := func(value interface{}) { + if normalized := normalizePromptCacheTTL(extractPromptCacheTTL(value)); normalized > max { + max = normalized + } + } + if arr, ok := req.System.([]interface{}); ok { + for _, block := range arr { + bump(block) + } + } + for _, msg := range req.Messages { + if arr, ok := msg.Content.([]interface{}); ok { + for _, block := range arr { + bump(block) + } + } } + return max +} + +func (t *promptCacheTracker) BuildClaudeProfile(req *ClaudeRequest, totalInputTokens int) *promptCacheProfile { blocks := flattenClaudeCacheBlocks(req) if len(blocks) == 0 { return nil @@ -295,7 +315,9 @@ func (t *promptCacheTracker) BuildClaudeProfile(req *ClaudeRequest, totalInputTo hasher := sha256.New() breakpoints := make([]promptCacheBreakpoint, 0) cumulativeTokens := 0 - var activeTTL time.Duration + // Auto-prefix: seed activeTTL with detectMaxTTL(req) so message-end + // breakpoints fire even with no explicit cache_control (see detectMaxTTL). + activeTTL := detectMaxTTL(req) for _, block := range blocks { canonical := canonicalizeCacheValue(block.Value) @@ -616,11 +638,32 @@ func appendSystemCacheBlocks(blocks *[]cacheablePromptBlock, system interface{}) }, }, false) case []interface{}: + // Structural skip (mirrors Rust extract_segments): Claude Code injects a + // per-turn DYNAMIC block at system[0] WITHOUT cache_control, followed by + // the stable cached block(s). Fingerprinting from that volatile head would + // drift every downstream prefix fingerprint across turns, collapsing + // cross-turn cache hits to zero. So skip every leading system block that + // lacks cache_control — accumulate from the first block carrying one. If + // no block has cache_control, skipUntil stays 0 (include all, matching + // Rust's unwrap_or(0)). system_index is a stripped position key, so the + // shift in index values does not affect fingerprints. The + // isAnthropicBillingHeaderBlock drop inside appendPromptBlock stays as a + // backstop for a non-leading billing-header block. + skipUntil := 0 + found := false for i, block := range v { + if interfaceHasCacheControl(block) { + skipUntil = i + found = true + break + } + } + _ = found + for i := skipUntil; i < len(v); i++ { appendPromptBlock(blocks, map[string]interface{}{ "kind": "system", "system_index": i, - "block": block, + "block": v[i], }, false) } case []string: diff --git a/proxy/cache_tracker_hardening_test.go b/proxy/cache_tracker_hardening_test.go index 581b0c37..a8eaabf9 100644 --- a/proxy/cache_tracker_hardening_test.go +++ b/proxy/cache_tracker_hardening_test.go @@ -165,27 +165,56 @@ func TestWriteFileAtomicPersistsAndLeavesNoTemp(t *testing.T) { } } -// TestBuildClaudeProfileReturnsNilWithoutCacheControl is the regression guard for -// the has_cache_control fast path: a request with no cache_control marker yields -// a nil profile — identical to the full flatten/hash pass, but without the -// O(prompt) work. A request carrying cache_control still builds a profile. -func TestBuildClaudeProfileReturnsNilWithoutCacheControl(t *testing.T) { +// TestBuildClaudeProfileAutoPrefixWithoutCacheControl locks the auto-prefix +// behavior (mirrors Rust prefix_chain_works_without_any_cache_control): a +// request with NO cache_control marker still builds a profile and — across two +// turns sharing a stable history prefix — the second turn reads cache +// (cache_read > 0). This reproduces Anthropic's automatic prompt caching, where +// stable prefixes are reused across turns without the client marking +// cache_control. A request carrying cache_control still builds a profile. +func TestBuildClaudeProfileAutoPrefixWithoutCacheControl(t *testing.T) { tr := newPromptCacheTracker(time.Hour) - - // No cache_control anywhere: plain string system + string message content. - plain := &ClaudeRequest{ - Model: "claude-sonnet-4.5", - System: strings.Repeat("system prompt without cache marker ", 200), - Messages: []ClaudeMessage{{Role: "user", Content: strings.Repeat("plain user text ", 200)}}, + body := strings.Repeat("lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt ut labore ", 80) + + mk := func(final string) *ClaudeRequest { + return &ClaudeRequest{ + Model: "claude-sonnet-4.5", + System: strings.Repeat("system prompt without any cache marker ", 200), + Messages: []ClaudeMessage{ + {Role: "user", Content: body}, + {Role: "assistant", Content: body}, + {Role: "user", Content: final}, + }, + } } + + // No cache_control anywhere — predicate confirms it, yet a profile is built. + plain := mk("question one") if claudeRequestHasCacheControl(plain) { t.Fatal("claudeRequestHasCacheControl should be false for a request with no cache_control") } - if prof := tr.BuildClaudeProfile(plain, 5000); prof != nil { - t.Fatalf("expected nil profile when no cache_control present, got %+v", prof) + p1 := tr.BuildClaudeProfile(plain, 5000) + if p1 == nil { + t.Fatal("auto-prefix: expected a profile even when no cache_control is present") + } + first := tr.Compute("acct-1", p1) + if first.CacheReadInputTokens != 0 { + t.Fatalf("first turn has no prior cache to read, got %+v", first) + } + tr.Update("acct-1", p1) + + // Turn 2: identical history prefix, new final message, still no cache_control. + // The shared prefix must hit — auto-prefix cache, no explicit marker needed. + p2 := tr.BuildClaudeProfile(mk("question two"), 5000) + if p2 == nil { + t.Fatal("auto-prefix: turn-2 profile should be built") + } + second := tr.Compute("acct-1", p2) + if second.CacheReadInputTokens == 0 { + t.Fatalf("auto-prefix: expected cross-turn cache_read without cache_control, got %+v", second) } - // cache_control on the system block: profile is built (above min-token threshold). + // A request WITH cache_control still builds a profile (unchanged path). withCache := &ClaudeRequest{ Model: "claude-sonnet-4.5", System: []interface{}{ diff --git a/proxy/cache_tracker_test.go b/proxy/cache_tracker_test.go index 1c85ed33..1ba0ec53 100644 --- a/proxy/cache_tracker_test.go +++ b/proxy/cache_tracker_test.go @@ -168,6 +168,71 @@ func TestPromptCacheStableWhenBillingHeaderAppearsOrDisappears(t *testing.T) { } } +// TestPromptCacheStructuralSkipOfLeadingDynamicSystemBlock generalizes the two +// billing-header guards above: Claude Code injects a per-turn DYNAMIC block at +// system[0] WITHOUT cache_control, followed by the stable cached block(s). +// Fingerprinting from that volatile head would drift every downstream prefix +// fingerprint across turns, collapsing cross-turn cache hits to zero. The +// tracker must STRUCTURALLY skip every leading system block that lacks +// cache_control (mirrors Rust extract_segments), not just the literal +// x-anthropic-billing-header string the legacy matcher recognizes. So this must +// hold even when the dynamic header is an arbitrary changing string. +func TestPromptCacheStructuralSkipOfLeadingDynamicSystemBlock(t *testing.T) { + tracker := newPromptCacheTracker(time.Hour) + stableSys := strings.Repeat("You are a careful senior engineer who reviews diffs critically and writes idiomatic Go. ", 80) + body := strings.Repeat("implement the feature step by step with tests and clear rationale. ", 60) + + build := func(dynamicHeader string, msgs []ClaudeMessage) *ClaudeRequest { + return &ClaudeRequest{ + Model: "claude-sonnet-4.5", + System: []interface{}{ + // sys[0]: per-turn dynamic marker, NO cache_control (not a billing string). + map[string]interface{}{"type": "text", "text": dynamicHeader}, + // sys[1]: stable cached block. + map[string]interface{}{ + "type": "text", + "text": stableSys, + "cache_control": map[string]interface{}{"type": "ephemeral"}, + }, + }, + Messages: msgs, + } + } + + turn1 := build("now=1001", []ClaudeMessage{ + {Role: "user", Content: body}, + {Role: "assistant", Content: body}, + {Role: "user", Content: body}, + }) + profile1 := tracker.BuildClaudeProfile(turn1, 8192) + if profile1 == nil { + t.Fatalf("profile1 should be built") + } + first := tracker.Compute("acct-1", profile1) + if first.CacheReadInputTokens != 0 { + t.Fatalf("turn1 has no prior cache to read, got %+v", first) + } + tracker.Update("acct-1", profile1) + + // Turn 2: dynamic header changed to "now=2002"; one more a/u pair appended. + // After skipping the volatile head, sys[1]+history prefix is byte-identical → must hit. + turn2 := build("now=2002", []ClaudeMessage{ + {Role: "user", Content: body}, + {Role: "assistant", Content: body}, + {Role: "user", Content: body}, + {Role: "assistant", Content: body}, + {Role: "user", Content: body}, + }) + profile2 := tracker.BuildClaudeProfile(turn2, 8192) + if profile2 == nil { + t.Fatalf("profile2 should be built") + } + second := tracker.Compute("acct-1", profile2) + if second.CacheReadInputTokens == 0 { + t.Fatalf("structural skip: dynamic system header change must not break cross-turn cache hit, got %+v", second) + } +} + func TestCanonicalCacheValueIgnoresPositionKeys(t *testing.T) { first := canonicalizeCacheValue(stripCachePositionKeys(map[string]interface{}{ "kind": "system", From 18e834dc824f6e26a21cc83adda00f45bd010e85 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 17:54:08 +0700 Subject: [PATCH 58/68] feat(config): api_key account fields, region getters, MaxPayloadBytes --- config/config.go | 124 +++++++++++++++++++++++++++++++++++++++++- config/config_test.go | 72 ++++++++++++++++++++++++ 2 files changed, 195 insertions(+), 1 deletion(-) diff --git a/config/config.go b/config/config.go index 44ff970e..34f9471c 100644 --- a/config/config.go +++ b/config/config.go @@ -17,6 +17,7 @@ import ( "encoding/json" "fmt" "os" + "strings" "sync" "time" ) @@ -141,7 +142,10 @@ type Account struct { RefreshToken string `json:"refreshToken"` // OAuth refresh token for token renewal ClientID string `json:"clientId,omitempty"` // OIDC client ID (for IdC auth) ClientSecret string `json:"clientSecret,omitempty"` // OIDC client secret (for IdC auth) - AuthMethod string `json:"authMethod"` // Authentication method: "idc" (AWS IdC), "social" (GitHub/Google), or "external_idp" (enterprise SSO, e.g. Azure AD) + AuthMethod string `json:"authMethod"` // Authentication method: "idc" (AWS IdC), "social" (GitHub/Google), "external_idp" (enterprise SSO, e.g. Azure AD), or "api_key" (Kiro API key used directly as bearer) + KiroApiKey string `json:"kiroApiKey,omitempty"` // API key credential, used directly as the bearer token when AuthMethod == "api_key" + AuthRegion string `json:"authRegion,omitempty"` // Region for token-refresh endpoints; falls back to Region + ApiRegion string `json:"apiRegion,omitempty"` // Region for API request hosts; falls back to Region Provider string `json:"provider,omitempty"` // Identity provider name (e.g., "BuilderId", "GitHub", "AzureAD") Region string `json:"region"` // AWS region for OIDC endpoints StartUrl string `json:"startUrl,omitempty"` // AWS SSO start URL @@ -213,6 +217,62 @@ type Account struct { TotalCredits float64 `json:"totalCredits,omitempty"` // Cumulative credits consumed } +// IsApiKeyCredential reports whether the account authenticates with a Kiro API +// key used directly as the bearer token (tokentype: API_KEY), bypassing OAuth +// refresh and profile-ARN resolution. True when KiroApiKey is set OR the +// authMethod is "api_key"/"apikey" (case-insensitive). +func (a *Account) IsApiKeyCredential() bool { + if a == nil { + return false + } + if a.KiroApiKey != "" { + return true + } + m := strings.ToLower(a.AuthMethod) + return m == "api_key" || m == "apikey" +} + +// EffectiveAuthRegion returns the region used for token-refresh endpoints, with +// the fallback chain: account.AuthRegion > account.Region > global AuthRegion +// (if set and not us-east-1) > global Region (if set and not us-east-1) > us-east-1. +func (a *Account) EffectiveAuthRegion() string { + if a != nil { + if r := strings.TrimSpace(a.AuthRegion); r != "" { + return r + } + if r := strings.TrimSpace(a.Region); r != "" { + return r + } + } + if r := GetGlobalAuthRegion(); r != "" && r != "us-east-1" { + return r + } + if r := GetGlobalRegion(); r != "" && r != "us-east-1" { + return r + } + return "us-east-1" +} + +// EffectiveApiRegion returns the region used for API request hosts, with the +// same fallback chain as EffectiveAuthRegion but using ApiRegion. +func (a *Account) EffectiveApiRegion() string { + if a != nil { + if r := strings.TrimSpace(a.ApiRegion); r != "" { + return r + } + if r := strings.TrimSpace(a.Region); r != "" { + return r + } + } + if r := GetGlobalApiRegion(); r != "" && r != "us-east-1" { + return r + } + if r := GetGlobalRegion(); r != "" && r != "us-east-1" { + return r + } + return "us-east-1" +} + // PromptFilterRule defines a single custom prompt sanitization rule. // Type can be: "regex" (regexp find/replace within prompt) or // "lines-containing" (remove lines containing the match substring). @@ -278,6 +338,15 @@ type Config struct { // solely because usageCurrent >= usageLimit. AllowOverUsage bool `json:"allowOverUsage,omitempty"` + // Region defaults for accounts that omit per-account region/authRegion/apiRegion. + // Defaults to "us-east-1" when empty (see GetGlobalRegion* / Account.Effective*Region). + Region string `json:"region,omitempty"` + AuthRegion string `json:"authRegion,omitempty"` + ApiRegion string `json:"apiRegion,omitempty"` + // MaxPayloadBytes caps the serialized Kiro request body before upstream rejects + // it as oversized. <=0 means use DefaultMaxPayloadBytes. + MaxPayloadBytes int `json:"maxPayloadBytes,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" @@ -347,6 +416,10 @@ type AccountInfo struct { TrialExpiresAt int64 } +// DefaultMaxPayloadBytes is the runtime-configurable cap (2 MB) for the Kiro +// request body, below the observed ~2.15 MB AWS upstream rejection threshold. +const DefaultMaxPayloadBytes = 2_000_000 + // Version current version const Version = "1.1.2" @@ -1171,6 +1244,55 @@ func UpdateAllowOverUsage(allow bool) error { return Save() } +// GetMaxPayloadBytes returns the configured payload cap, falling back to +// DefaultMaxPayloadBytes when unset (<=0). +func GetMaxPayloadBytes() int { + cfgLock.RLock() + defer cfgLock.RUnlock() + if cfg == nil || cfg.MaxPayloadBytes <= 0 { + return DefaultMaxPayloadBytes + } + return cfg.MaxPayloadBytes +} + +// UpdateMaxPayloadBytes sets the payload cap and persists the change. +func UpdateMaxPayloadBytes(n int) error { + cfgLock.Lock() + defer cfgLock.Unlock() + cfg.MaxPayloadBytes = n + return Save() +} + +// GetGlobalRegion returns the configured default region (empty → "us-east-1"). +func GetGlobalRegion() string { + cfgLock.RLock() + defer cfgLock.RUnlock() + if cfg == nil || cfg.Region == "" { + return "us-east-1" + } + return cfg.Region +} + +// GetGlobalAuthRegion returns the configured default token-refresh region. +func GetGlobalAuthRegion() string { + cfgLock.RLock() + defer cfgLock.RUnlock() + if cfg == nil || cfg.AuthRegion == "" { + return "us-east-1" + } + return cfg.AuthRegion +} + +// GetGlobalApiRegion returns the configured default API-request host region. +func GetGlobalApiRegion() string { + cfgLock.RLock() + defer cfgLock.RUnlock() + if cfg == nil || cfg.ApiRegion == "" { + return "us-east-1" + } + return cfg.ApiRegion +} + // GetLogLevel returns the configured log level (debug/info/warn/error). Defaults to "info". func GetLogLevel() string { cfgLock.RLock() diff --git a/config/config_test.go b/config/config_test.go index d9d63b5e..ed93c828 100644 --- a/config/config_test.go +++ b/config/config_test.go @@ -127,3 +127,75 @@ func TestAccountAllowOverageMigration(t *testing.T) { } } } + +func TestIsApiKeyCredential(t *testing.T) { + cases := []struct { + name string + a Account + want bool + }{ + {"key present", Account{KiroApiKey: "k"}, true}, + {"api_key lower", Account{AuthMethod: "api_key"}, true}, + {"apikey lower", Account{AuthMethod: "apikey"}, true}, + {"API_KEY upper", Account{AuthMethod: "API_KEY"}, true}, + {"key wins over idc", Account{KiroApiKey: "k", AuthMethod: "idc"}, true}, + {"idc not api key", Account{AuthMethod: "idc"}, false}, + {"external_idp not api key", Account{AuthMethod: "external_idp"}, false}, + {"empty", Account{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := tc.a.IsApiKeyCredential(); got != tc.want { + t.Fatalf("IsApiKeyCredential() = %v, want %v", got, tc.want) + } + }) + } +} + +func TestEffectiveRegionsFallbackChain(t *testing.T) { + if err := Init(filepath.Join(t.TempDir(), "config.json")); err != nil { + t.Fatalf("init: %v", err) + } + + // account-level wins + a := &Account{AuthRegion: "eu-west-1", ApiRegion: "ap-southeast-1", Region: "us-west-2"} + if got := a.EffectiveAuthRegion(); got != "eu-west-1" { + t.Fatalf("auth: want eu-west-1, got %q", got) + } + if got := a.EffectiveApiRegion(); got != "ap-southeast-1" { + t.Fatalf("api: want ap-southeast-1, got %q", got) + } + + // falls back to Region + b := &Account{Region: "us-west-2"} + if got := b.EffectiveAuthRegion(); got != "us-west-2" { + t.Fatalf("auth fallback: want us-west-2, got %q", got) + } + if got := b.EffectiveApiRegion(); got != "us-west-2" { + t.Fatalf("api fallback: want us-west-2, got %q", got) + } + + // empty account → default us-east-1 + c := &Account{} + if got := c.EffectiveAuthRegion(); got != "us-east-1" { + t.Fatalf("auth default: want us-east-1, got %q", got) + } + if got := c.EffectiveApiRegion(); got != "us-east-1" { + t.Fatalf("api default: want us-east-1, got %q", got) + } +} + +func TestMaxPayloadBytesDefaultFallbackAndPersist(t *testing.T) { + if err := Init(filepath.Join(t.TempDir(), "config.json")); err != nil { + t.Fatalf("init: %v", err) + } + if got := GetMaxPayloadBytes(); got != DefaultMaxPayloadBytes { + t.Fatalf("default: want %d, got %d", DefaultMaxPayloadBytes, got) + } + if err := UpdateMaxPayloadBytes(2100000); err != nil { + t.Fatalf("update: %v", err) + } + if got := GetMaxPayloadBytes(); got != 2100000 { + t.Fatalf("after set: want 2100000, got %d", got) + } +} From 851add7c77dedb059595eb7d369fb81f69bf63d5 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 17:55:27 +0700 Subject: [PATCH 59/68] refactor(translator): runtime-configurable MaxPayloadBytes cap --- proxy/translator.go | 27 ++++++++++++++------------- proxy/translator_truncate_test.go | 15 +++++++++++---- 2 files changed, 25 insertions(+), 17 deletions(-) diff --git a/proxy/translator.go b/proxy/translator.go index 82c32e7d..f3b33b7c 100644 --- a/proxy/translator.go +++ b/proxy/translator.go @@ -46,15 +46,15 @@ const minimalFallbackUserContent = "." const toolResultsContinuationPrefix = "Tool results:" const toolResultImagePlaceholder = "[Tool returned an image; the image is attached to this message.]" -// maxPayloadBytes is the upper bound for the serialized Kiro request body. -// Kiro's upstream rejects oversized requests with HTTP 400 -// "Input is too long." (CONTENT_LENGTH_EXCEEDS_THRESHOLD). When a converted -// payload exceeds this size we drop the oldest history turns (keeping the -// system priming, the most recent turns, the active tool turn, and the current -// message) and insert a placeholder note so the model knows context was elided. -// The limit is kept conservatively below the observed upstream threshold to -// leave room for headers and minor serialization overhead. -const maxPayloadBytes = 900 * 1024 +// The payload size cap is runtime-configurable via config.GetMaxPayloadBytes() +// (default 2 MB, see config.DefaultMaxPayloadBytes). Kiro's upstream rejects +// oversized requests with HTTP 400 "Input is too long." +// (CONTENT_LENGTH_EXCEEDS_THRESHOLD), observed near ~2.15 MB. When a converted +// payload exceeds the cap we drop the oldest history turns (keeping the system +// priming, the most recent turns, the active tool turn, and the current message) +// and insert a placeholder note so the model knows context was elided. The cap +// is kept conservatively below the upstream threshold to leave room for headers +// and serialization overhead. // truncationPlaceholder is inserted in history where older turns were dropped to // fit within maxPayloadBytes. @@ -1643,7 +1643,8 @@ func truncatePayloadToLimit(payload *KiroPayload, hasPriming bool) { if payload == nil { return } - if payloadByteSize(payload) <= maxPayloadBytes { + limit := config.GetMaxPayloadBytes() + if payloadByteSize(payload) <= limit { return } @@ -1685,7 +1686,7 @@ func truncatePayloadToLimit(payload *KiroPayload, hasPriming bool) { for i := len(conversation) - 1; i >= 0; i-- { running += entrySizes[i] kept := len(conversation) - i - if running > maxPayloadBytes && kept > minRecentHistoryTurns { + if running > limit && kept > minRecentHistoryTurns { break } keepFrom = i @@ -1704,7 +1705,7 @@ func truncatePayloadToLimit(payload *KiroPayload, hasPriming bool) { // If still too large (current message or retained tail alone exceeds the // limit), shrink the current message content as a last resort. - if payloadByteSize(payload) > maxPayloadBytes { + if payloadByteSize(payload) > limit { truncateCurrentMessage(payload) } } @@ -1747,7 +1748,7 @@ func currentMessageModelID(payload *KiroPayload) string { func truncateCurrentMessage(payload *KiroPayload) { cur := &payload.ConversationState.CurrentMessage.UserInputMessage overhead := payloadByteSize(payload) - len(cur.Content) - budget := maxPayloadBytes - overhead + budget := config.GetMaxPayloadBytes() - overhead if budget < 0 { budget = 0 } diff --git a/proxy/translator_truncate_test.go b/proxy/translator_truncate_test.go index 34f33a02..849c26fe 100644 --- a/proxy/translator_truncate_test.go +++ b/proxy/translator_truncate_test.go @@ -2,22 +2,29 @@ package proxy import ( "encoding/json" + "kiro-go/config" + "path/filepath" "strings" "testing" ) // TestClaudeToKiroTruncatesOversizedHistory builds a conversation whose history // far exceeds the upstream input limit and verifies the converted payload is -// trimmed below maxPayloadBytes, that a truncation placeholder is inserted, and +// trimmed below the runtime payload cap, that a truncation placeholder is inserted, and // that the current message is preserved. func TestClaudeToKiroTruncatesOversizedHistory(t *testing.T) { + if err := config.Init(filepath.Join(t.TempDir(), "config.json")); err != nil { + t.Fatalf("config.Init: %v", err) + } + limit := config.GetMaxPayloadBytes() + // ~2KB chunk repeated across many turns to blow past the byte limit. big := strings.Repeat("lorem ipsum dolor sit amet ", 80) // ~2.1KB msgs := []ClaudeMessage{ {Role: "user", Content: "start the long task"}, } - for i := 0; i < 800; i++ { + for i := 0; i < 1200; i++ { msgs = append(msgs, ClaudeMessage{Role: "assistant", Content: "step result: " + big}, ClaudeMessage{Role: "user", Content: "next: " + big}, @@ -37,8 +44,8 @@ func TestClaudeToKiroTruncatesOversizedHistory(t *testing.T) { if err != nil { t.Fatalf("marshal failed: %v", err) } - if len(raw) > maxPayloadBytes { - t.Fatalf("payload size %d exceeds limit %d after truncation", len(raw), maxPayloadBytes) + if len(raw) > limit { + t.Fatalf("payload size %d exceeds limit %d after truncation", len(raw), limit) } // The current message must be preserved. From 2013b1c4d6d42b2c84223568b4c7bf2f20934930 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 17:58:05 +0700 Subject: [PATCH 60/68] feat(headers): api_key bearer + tokentype API_KEY branch --- proxy/kiro_headers.go | 11 +++++++-- proxy/kiro_headers_test.go | 46 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 2 deletions(-) diff --git a/proxy/kiro_headers.go b/proxy/kiro_headers.go index de008104..63bf29a6 100644 --- a/proxy/kiro_headers.go +++ b/proxy/kiro_headers.go @@ -73,8 +73,15 @@ func buildKiroHeaderValues(account *config.Account, host, apiName, sdkVersion, m } func applyKiroBaseHeaders(req *http.Request, account *config.Account, values kiroHeaderValues) { - if account != nil && account.AccessToken != "" { - req.Header.Set("Authorization", "Bearer "+account.AccessToken) + if account != nil { + if account.IsApiKeyCredential() { + if account.KiroApiKey != "" { + req.Header.Set("Authorization", "Bearer "+account.KiroApiKey) + req.Header.Set("tokentype", "API_KEY") + } + } else if account.AccessToken != "" { + req.Header.Set("Authorization", "Bearer "+account.AccessToken) + } } req.Header.Set("User-Agent", values.UserAgent) req.Header.Set("x-amz-user-agent", values.AmzUserAgent) diff --git a/proxy/kiro_headers_test.go b/proxy/kiro_headers_test.go index d357aa15..d01ee535 100644 --- a/proxy/kiro_headers_test.go +++ b/proxy/kiro_headers_test.go @@ -2,6 +2,8 @@ package proxy import ( "kiro-go/config" + "net/http" + "net/http/httptest" "strings" "testing" ) @@ -82,3 +84,47 @@ func TestBuildKiroHeaderValuesFallsBackToDerivedMachineId(t *testing.T) { t.Fatalf("nil account must not carry a derived suffix, got %q", none.UserAgent) } } + +// TestApplyKiroBaseHeadersApiKeyBranch asserts an api_key account sends the key +// as the bearer with tokentype: API_KEY, while an OAuth account keeps the plain +// bearer and the external_idp TokenType branch is preserved. +func TestApplyKiroBaseHeadersApiKeyBranch(t *testing.T) { + mk := func(account *config.Account) http.Header { + req := httptest.NewRequest("POST", "https://q.us-east-1.amazonaws.com/x", nil) + applyKiroBaseHeaders(req, account, kiroHeaderValues{UserAgent: "ua", AmzUserAgent: "aua"}) + return req.Header + } + + // api_key: bearer is the key, tokentype is API_KEY. + h := mk(&config.Account{AuthMethod: "api_key", KiroApiKey: "key-xyz", AccessToken: "key-xyz"}) + if got := h.Get("Authorization"); got != "Bearer key-xyz" { + t.Fatalf("api_key bearer: want %q, got %q", "Bearer key-xyz", got) + } + if got := h.Get("tokentype"); got != "API_KEY" { + t.Fatalf("api_key tokentype: want API_KEY, got %q", got) + } + + // OAuth (idc): plain bearer, no tokentype header. + h = mk(&config.Account{AuthMethod: "idc", AccessToken: "at-oauth"}) + if got := h.Get("Authorization"); got != "Bearer at-oauth" { + t.Fatalf("oauth bearer: want %q, got %q", "Bearer at-oauth", got) + } + if got := h.Get("tokentype"); got != "" { + t.Fatalf("oauth must NOT set tokentype, got %q", got) + } + + // external_idp preserved: the (canonical) Tokentype header carries EXTERNAL_IDP. + // "tokentype" and "TokenType" canonicalize to the same key in net/http, but + // api_key and external_idp are mutually exclusive account types, so the two + // values never collide at runtime. + h = mk(&config.Account{AuthMethod: "external_idp", AccessToken: "at-ext"}) + if got := h.Get("TokenType"); got != "EXTERNAL_IDP" { + t.Fatalf("external_idp TokenType: want EXTERNAL_IDP, got %q", got) + } + + // empty account: no Authorization header. + h = mk(&config.Account{}) + if got := h.Get("Authorization"); got != "" { + t.Fatalf("empty account must not set Authorization, got %q", got) + } +} From 594df21dd2b3bb40eafd8f7a4ad8c0f2d22a76fe Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 17:59:47 +0700 Subject: [PATCH 61/68] feat(kiro_api): skip profile-ARN resolution for api_key accounts --- proxy/kiro_api.go | 5 +++++ proxy/kiro_api_test.go | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+) diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index fe4e80b5..fd47eb28 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -260,6 +260,11 @@ func ResolveProfileArn(account *config.Account) (string, error) { if account == nil { return "", fmt.Errorf("account is nil") } + // api_key accounts authenticate directly (tokentype: API_KEY) and have no + // profile ARN. Skip the ListAvailableProfiles probe entirely. + if account.IsApiKeyCredential() { + return "", nil + } if profileArn := strings.TrimSpace(account.ProfileArn); profileArn != "" { return profileArn, nil } diff --git a/proxy/kiro_api_test.go b/proxy/kiro_api_test.go index 25f24b5f..10dca01a 100644 --- a/proxy/kiro_api_test.go +++ b/proxy/kiro_api_test.go @@ -118,6 +118,28 @@ func TestResolveProfileArnFetchesAndCachesProfile(t *testing.T) { } } +// TestResolveProfileArnSkipsApiKeyAccount asserts an api_key account returns +// an empty profile ARN without any HTTP probe (the key authenticates directly). +func TestResolveProfileArnSkipsApiKeyAccount(t *testing.T) { + // If a request were made, this round-tripper fails the test. + kiroRestHttpStore.Store(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + t.Fatal("unexpected HTTP request for api_key account profile ARN") + return nil, nil + }), + }) + t.Cleanup(func() { InitKiroHttpClient("") }) + + account := &config.Account{AuthMethod: "api_key", KiroApiKey: "key-abc", AccessToken: "key-abc"} + got, err := ResolveProfileArn(account) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != "" { + t.Fatalf("expected empty profile ARN for api_key account, got %q", got) + } +} + func TestResolveProfileArnSuppressesBuilderIDUnsupportedLookup(t *testing.T) { clearProfileArnResolutionCooldowns() t.Cleanup(clearProfileArnResolutionCooldowns) From 896211cbf9a3d3201f6edf1f0272d9330e6221a4 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 18:04:54 +0700 Subject: [PATCH 62/68] feat(kiro): region rebuild for api_key accounts in CallKiroAPI --- proxy/kiro.go | 19 +++++++++++++++++-- proxy/kiro_test.go | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 2 deletions(-) diff --git a/proxy/kiro.go b/proxy/kiro.go index 54653eea..5bdfef33 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -340,8 +340,23 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt // Update the origin field for the selected endpoint. payload.ConversationState.CurrentMessage.UserInputMessage.Origin = ep.Origin - // Target the profile's data-plane region; endpoint URLs are declared for us-east-1. - epURL := regionalizeURLForProfile(ep.URL, account, payload.ProfileArn) + // Target the data-plane region. api_key accounts have no profile ARN, so + // rebuild the host from the account's EffectiveApiRegion; OAuth accounts + // keep deriving the region from the profile ARN. + epURL := ep.URL + if account != nil && account.IsApiKeyCredential() { + if parsed, perr := url.Parse(ep.URL); perr == nil && strings.HasSuffix(parsed.Hostname(), ".amazonaws.com") { + apiRegion := account.EffectiveApiRegion() + switch ep.Name { + case "CodeWhisperer": + epURL = fmt.Sprintf("https://codewhisperer.%s.amazonaws.com/generateAssistantResponse", apiRegion) + default: // "Kiro IDE" and "AmazonQ" + epURL = fmt.Sprintf("https://q.%s.amazonaws.com/generateAssistantResponse", apiRegion) + } + } + } else { + epURL = regionalizeURLForProfile(ep.URL, account, payload.ProfileArn) + } reqBody, _ := json.Marshal(payload) req, err := http.NewRequest("POST", epURL, bytes.NewReader(reqBody)) diff --git a/proxy/kiro_test.go b/proxy/kiro_test.go index 20d64366..7c0bd70c 100644 --- a/proxy/kiro_test.go +++ b/proxy/kiro_test.go @@ -4,9 +4,11 @@ import ( "bytes" "encoding/binary" "encoding/json" + "io" "kiro-go/config" "net/http" "net/url" + "strings" "testing" "time" ) @@ -269,3 +271,37 @@ func awsEventStreamFrame(t *testing.T, eventType string, payload map[string]inte frame = append(frame, 0, 0, 0, 0) return frame } + +// TestCallKiroAPIRebuildsRegionForApiKeyAccount asserts an api_key account's +// endpoint host is rebuilt from EffectiveApiRegion (q. for non-us-east-1 +// endpoints), since it has no profile ARN to derive the region from. +func TestCallKiroAPIRebuildsRegionForApiKeyAccount(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + var gotHost string + // CallKiroAPI uses the streaming store (kiroHttpStore), not the REST store. + kiroHttpStore.Store(&http.Client{ + Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) { + gotHost = req.URL.Host + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader("")), + Header: make(http.Header), + }, nil + }), + }) + t.Cleanup(func() { InitKiroHttpClient("") }) + + apiKeyAcct := &config.Account{ + AuthMethod: "api_key", + KiroApiKey: "k", + AccessToken: "k", + ApiRegion: "eu-central-1", + } + payload := &KiroPayload{} + _ = CallKiroAPI(apiKeyAcct, payload, nil) + if gotHost != "q.eu-central-1.amazonaws.com" { + t.Fatalf("api_key host: want q.eu-central-1.amazonaws.com, got %q", gotHost) + } +} From fd30390cc619befb0181ba0bbcd2d1ffce5ad666 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 18:10:33 +0700 Subject: [PATCH 63/68] feat(handler): api_key add-account branch, model gate, maxPayloadBytes settings --- proxy/handler.go | 45 +++++++++++++++----- proxy/handler_test.go | 95 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 130 insertions(+), 10 deletions(-) diff --git a/proxy/handler.go b/proxy/handler.go index daca84f0..c6612fcd 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -2610,6 +2610,21 @@ func (h *Handler) apiAddAccount(w http.ResponseWriter, r *http.Request) { account.Region = "us-east-1" } + // api_key: use the Kiro API key directly as the bearer (tokentype: API_KEY). + // Mirror it into AccessToken for pool/dispatch/metrics compatibility, zero + // ExpiresAt + RefreshToken so no refresh path ever fires. + if account.IsApiKeyCredential() { + if account.KiroApiKey == "" { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]string{"error": "kiroApiKey is required for api_key accounts"}) + return + } + account.AuthMethod = "api_key" + account.AccessToken = account.KiroApiKey + account.RefreshToken = "" + account.ExpiresAt = 0 + } + if err := config.AddAccount(account); err != nil { w.WriteHeader(500) json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) @@ -2618,7 +2633,7 @@ func (h *Handler) apiAddAccount(w http.ResponseWriter, r *http.Request) { h.pool.Reload() // 新账号若已启用且有 token,立即拉取并缓存模型列表 - if account.Enabled && account.AccessToken != "" { + if account.Enabled && (account.AccessToken != "" || account.IsApiKeyCredential()) { go func(acc config.Account) { if err := h.fetchAndCacheAccountModels(&acc); err != nil { logger.Warnf("[ModelsCache] Auto-refresh failed for new account %s: %v", acc.Email, err) @@ -3566,11 +3581,12 @@ func (h *Handler) apiGetStatus(w http.ResponseWriter, r *http.Request) { func (h *Handler) apiGetSettings(w http.ResponseWriter, r *http.Request) { json.NewEncoder(w).Encode(map[string]interface{}{ - "apiKey": config.GetApiKey(), - "requireApiKey": config.IsApiKeyRequired(), - "port": config.GetPort(), - "host": config.GetHost(), - "allowOverUsage": config.GetAllowOverUsage(), + "apiKey": config.GetApiKey(), + "requireApiKey": config.IsApiKeyRequired(), + "port": config.GetPort(), + "host": config.GetHost(), + "allowOverUsage": config.GetAllowOverUsage(), + "maxPayloadBytes": config.GetMaxPayloadBytes(), }) } @@ -3619,10 +3635,11 @@ func (h *Handler) apiUpdatePromptFilter(w http.ResponseWriter, r *http.Request) func (h *Handler) apiUpdateSettings(w http.ResponseWriter, r *http.Request) { var req struct { - ApiKey *string `json:"apiKey,omitempty"` - RequireApiKey *bool `json:"requireApiKey,omitempty"` - Password string `json:"password,omitempty"` - AllowOverUsage *bool `json:"allowOverUsage,omitempty"` + ApiKey *string `json:"apiKey,omitempty"` + RequireApiKey *bool `json:"requireApiKey,omitempty"` + Password string `json:"password,omitempty"` + AllowOverUsage *bool `json:"allowOverUsage,omitempty"` + MaxPayloadBytes *int `json:"maxPayloadBytes,omitempty"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { w.WriteHeader(400) @@ -3647,6 +3664,14 @@ func (h *Handler) apiUpdateSettings(w http.ResponseWriter, r *http.Request) { h.pool.Reload() } + if req.MaxPayloadBytes != nil { + if err := config.UpdateMaxPayloadBytes(*req.MaxPayloadBytes); err != nil { + w.WriteHeader(500) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + } + json.NewEncoder(w).Encode(map[string]bool{"success": true}) } diff --git a/proxy/handler_test.go b/proxy/handler_test.go index fa865c59..7dd4828a 100644 --- a/proxy/handler_test.go +++ b/proxy/handler_test.go @@ -2,6 +2,7 @@ package proxy import ( "encoding/json" + "fmt" "kiro-go/config" accountpool "kiro-go/pool" "net/http" @@ -525,3 +526,97 @@ func TestStatsIncludesCacheMetrics(t *testing.T) { t.Fatalf("expected 1 miss, got %v", cache["misses"]) } } + +// TestApiAddAccountApiKeyBranch verifies POST /admin/api/accounts with an +// api_key account normalizes AuthMethod, zeroes ExpiresAt, mirrors AccessToken, +// and skips the OAuth refresh requirement. +func TestApiAddAccountApiKeyBranch(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + // Stub the REST store so the background model-cache fetch fails fast without + // real network egress. + kiroRestHttpStore.Store(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return nil, fmt.Errorf("test: no network") + }), + }) + t.Cleanup(func() { InitKiroHttpClient("") }) + + h := &Handler{pool: accountpool.GetPool()} + body := `{"kiroApiKey":"key-123","authMethod":"api_key","region":"eu-central-1","enabled":true}` + req := httptest.NewRequest("POST", "/admin/api/accounts", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.apiAddAccount(rec, req) + + if rec.Code != 200 { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + accs := config.GetAccounts() + if len(accs) != 1 { + t.Fatalf("expected 1 account, got %d", len(accs)) + } + got := accs[0] + if got.AuthMethod != "api_key" { + t.Fatalf("AuthMethod: want api_key, got %q", got.AuthMethod) + } + if got.AccessToken != "key-123" { + t.Fatalf("AccessToken mirror: want key-123, got %q", got.AccessToken) + } + if got.ExpiresAt != 0 { + t.Fatalf("ExpiresAt: want 0, got %d", got.ExpiresAt) + } + if got.KiroApiKey != "key-123" { + t.Fatalf("KiroApiKey: want key-123, got %q", got.KiroApiKey) + } + if got.RefreshToken != "" { + t.Fatalf("RefreshToken: want empty, got %q", got.RefreshToken) + } +} + +// TestApiAddAccountApiKeyRequiresKey verifies a missing key on an api_key +// request is rejected with 400. +func TestApiAddAccountApiKeyRequiresKey(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + h := &Handler{pool: accountpool.GetPool()} + body := `{"authMethod":"api_key"}` + req := httptest.NewRequest("POST", "/admin/api/accounts", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.apiAddAccount(rec, req) + if rec.Code != 400 { + t.Fatalf("expected 400 for api_key without key, got %d body=%s", rec.Code, rec.Body.String()) + } +} + +// TestSettingsRoundTripMaxPayloadBytes verifies GET /settings reports +// maxPayloadBytes and POST /settings persists it. +func TestSettingsRoundTripMaxPayloadBytes(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + h := &Handler{pool: accountpool.GetPool()} + + // GET reports the default. + getRec := httptest.NewRecorder() + h.apiGetSettings(getRec, httptest.NewRequest("GET", "/settings", nil)) + var got map[string]json.RawMessage + if err := json.Unmarshal(getRec.Body.Bytes(), &got); err != nil { + t.Fatalf("decode GET: %v", err) + } + if _, ok := got["maxPayloadBytes"]; !ok { + t.Fatalf("expected maxPayloadBytes in GET /settings, got %v", got) + } + + // POST sets it. + postBody := `{"maxPayloadBytes":2100000}` + postRec := httptest.NewRecorder() + h.apiUpdateSettings(postRec, httptest.NewRequest("POST", "/settings", strings.NewReader(postBody))) + if postRec.Code != 200 { + t.Fatalf("POST /settings: expected 200, got %d body=%s", postRec.Code, postRec.Body.String()) + } + if config.GetMaxPayloadBytes() != 2100000 { + t.Fatalf("maxPayloadBytes not persisted: want 2100000, got %d", config.GetMaxPayloadBytes()) + } +} From cb6de9254ca0cdae923f13ee49baa7707aad415e Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 18:16:01 +0700 Subject: [PATCH 64/68] feat(handler): api_key credential import branch --- proxy/handler.go | 61 ++++++++++++++++++++++++ proxy/import_credentials_test.go | 80 ++++++++++++++++++++++++++++++++ 2 files changed, 141 insertions(+) diff --git a/proxy/handler.go b/proxy/handler.go index c6612fcd..c978dd77 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -3325,9 +3325,14 @@ func (h *Handler) apiImportCredentials(w http.ResponseWriter, r *http.Request) { TokenEndpoint string `json:"tokenEndpoint"` IssuerURL string `json:"issuerUrl"` Scopes string `json:"scopes"` + // api_key credential: Kiro API key used directly as bearer (no OAuth refresh). + KiroApiKey string `json:"kiroApiKey"` + AuthRegion string `json:"authRegion"` + ApiRegion string `json:"apiRegion"` // Optional identity preservation when pasting a full account record. ID string `json:"id"` Email string `json:"email"` + Nickname string `json:"nickname"` ProfileArn string `json:"profileArn"` // userId (account-level in Kiro Account Manager exports) embeds the Azure // tenant, from which tokenEndpoint/issuerUrl/scopes are derived when missing. @@ -3339,6 +3344,62 @@ func (h *Handler) apiImportCredentials(w http.ResponseWriter, r *http.Request) { return } + // api_key: import a Kiro API key used directly as bearer (no OAuth refresh, + // no profile ARN). Mirror the key into AccessToken for pool/dispatch/metrics + // compatibility. Best-effort RefreshAccountInfo populates email/usage/credit. + if req.KiroApiKey != "" || strings.EqualFold(req.AuthMethod, "api_key") || strings.EqualFold(req.AuthMethod, "apikey") { + if req.KiroApiKey == "" { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]string{"error": "kiroApiKey is required for api_key accounts"}) + return + } + if req.Region == "" { + req.Region = "us-east-1" + } + id := req.ID + if id == "" || config.AccountIDExists(id) { + id = auth.GenerateAccountID() + } + account := config.Account{ + ID: id, + Email: req.Email, + Nickname: req.Nickname, + AuthMethod: "api_key", + KiroApiKey: req.KiroApiKey, + AccessToken: req.KiroApiKey, + Region: req.Region, + AuthRegion: req.AuthRegion, + ApiRegion: req.ApiRegion, + ExpiresAt: 0, + Enabled: true, + MachineId: config.GenerateMachineId(), + } + if err := config.AddAccount(account); err != nil { + w.WriteHeader(500) + json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) + return + } + h.pool.Reload() + // Best-effort: populate email/usage/credit from upstream. RefreshAccountInfo + // uses KiroApiKey (mirrored into AccessToken) as the bearer via + // applyKiroBaseHeaders; profile-ARN resolution is skipped for api_key. + go func(acc config.Account) { + if _, infoErr := RefreshAccountInfo(&acc); infoErr != nil { + logger.Warnf("[Import] RefreshAccountInfo failed for api_key account %s: %v", acc.Email, infoErr) + } + }(account) + go func(acc config.Account) { + if err := h.fetchAndCacheAccountModels(&acc); err != nil { + logger.Warnf("[ModelsCache] Auto-refresh failed for api_key account %s: %v", acc.Email, err) + } + }(account) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "account": map[string]interface{}{"id": account.ID, "email": account.Email}, + }) + return + } + if req.RefreshToken == "" { w.WriteHeader(400) json.NewEncoder(w).Encode(map[string]string{"error": "refreshToken is required"}) diff --git a/proxy/import_credentials_test.go b/proxy/import_credentials_test.go index c9f9408e..cf64b606 100644 --- a/proxy/import_credentials_test.go +++ b/proxy/import_credentials_test.go @@ -4,6 +4,7 @@ import ( "encoding/base64" "encoding/json" "fmt" + "io" "kiro-go/auth" "kiro-go/config" accountpool "kiro-go/pool" @@ -517,3 +518,82 @@ func TestApiImportCredentialsExternalIdpDerivesFromAccessTokenJWT(t *testing.T) t.Fatalf("RefreshToken: want the pasted token (not rotated), got %q", got.RefreshToken) } } + +// TestApiImportCredentialsApiKeyBranch verifies POST /auth/credentials with a +// kiroApiKey imports an api_key account (AuthMethod normalized, ExpiresAt=0, +// AccessToken mirrored) without any OAuth refresh round-trip. The background +// best-effort RefreshAccountInfo is exercised against a stubbed REST store. +func TestApiImportCredentialsApiKeyBranch(t *testing.T) { + cfgFile := t.TempDir() + "/config.json" + if err := config.Init(cfgFile); err != nil { + t.Fatalf("config.Init: %v", err) + } + defer installCleanAuthClient(t)() + + // Stub the REST store so the background RefreshAccountInfo / model-cache + // fetches don't hit real network. A 200 with empty JSON decodes cleanly and + // never trips the auth-error ban path. + kiroRestHttpStore.Store(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: 200, + Body: io.NopCloser(strings.NewReader(`{}`)), + Header: make(http.Header), + }, nil + }), + }) + t.Cleanup(func() { InitKiroHttpClient("") }) + + h := &Handler{pool: accountpool.GetPool()} + body := `{"kiroApiKey":"my-key","authMethod":"api_key","region":"eu-central-1"}` + req := httptest.NewRequest("POST", "/auth/credentials", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.apiImportCredentials(rec, req) + + if rec.Code != 200 { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + accs := config.GetAccounts() + if len(accs) != 1 { + t.Fatalf("expected 1 account, got %d", len(accs)) + } + got := accs[0] + if got.AuthMethod != "api_key" { + t.Fatalf("AuthMethod: want api_key, got %q", got.AuthMethod) + } + if got.AccessToken != "my-key" { + t.Fatalf("AccessToken mirror: want my-key, got %q", got.AccessToken) + } + if got.KiroApiKey != "my-key" { + t.Fatalf("KiroApiKey: want my-key, got %q", got.KiroApiKey) + } + if got.ExpiresAt != 0 { + t.Fatalf("ExpiresAt: want 0, got %d", got.ExpiresAt) + } + if got.RefreshToken != "" { + t.Fatalf("RefreshToken: want empty, got %q", got.RefreshToken) + } + if got.Region != "eu-central-1" { + t.Fatalf("Region: want eu-central-1, got %q", got.Region) + } +} + +// TestApiImportCredentialsApiKeyRequiresKey verifies a missing key on an +// api_key import request is rejected with 400 and nothing is persisted. +func TestApiImportCredentialsApiKeyRequiresKey(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + defer installCleanAuthClient(t)() + h := &Handler{pool: accountpool.GetPool()} + body := `{"authMethod":"api_key"}` + req := httptest.NewRequest("POST", "/auth/credentials", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.apiImportCredentials(rec, req) + if rec.Code != 400 { + t.Fatalf("expected 400 for api_key without key, got %d body=%s", rec.Code, rec.Body.String()) + } + if accs := config.GetAccounts(); len(accs) != 0 { + t.Fatalf("expected no account persisted, got %d", len(accs)) + } +} From 4def18fb5f09558e0d1232ada7c1edc6932278c6 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 18:20:34 +0700 Subject: [PATCH 65/68] feat(apikey_batch): bulk API-key import + /auth/apikeys-batch route --- proxy/apikey_batch.go | 160 +++++++++++++++++++++++++++++++++++++ proxy/apikey_batch_test.go | 134 +++++++++++++++++++++++++++++++ proxy/handler.go | 2 + 3 files changed, 296 insertions(+) create mode 100644 proxy/apikey_batch.go create mode 100644 proxy/apikey_batch_test.go diff --git a/proxy/apikey_batch.go b/proxy/apikey_batch.go new file mode 100644 index 00000000..bf1e7a75 --- /dev/null +++ b/proxy/apikey_batch.go @@ -0,0 +1,160 @@ +package proxy + +import ( + "encoding/json" + "net/http" + "strings" + + "kiro-go/auth" + "kiro-go/config" + "kiro-go/logger" +) + +// ApiKeyImportResult is the per-key outcome of a bulk API-key import. +type ApiKeyImportResult struct { + MaskedKey string `json:"maskedKey"` + AccountID string `json:"accountId,omitempty"` + Email string `json:"email,omitempty"` + Subscription string `json:"subscription,omitempty"` + UsageCurrent float64 `json:"usageCurrent,omitempty"` + UsageLimit float64 `json:"usageLimit,omitempty"` + Imported bool `json:"imported"` + Skipped bool `json:"skipped"` + InfoOK bool `json:"infoOk,omitempty"` + Error string `json:"error,omitempty"` +} + +// ApiKeyImportSummary aggregates a bulk import run. +type ApiKeyImportSummary struct { + Total int `json:"total"` + Imported int `json:"imported"` + Skipped int `json:"skipped"` + InfoFailed int `json:"infoFailed"` + Results []ApiKeyImportResult `json:"results"` +} + +// maskKey returns head-6 … tail-4 of a key (or "****" when the key is too short +// to safely mask). +func maskKey(key string) string { + if len(key) <= 10 { + return "****" + } + return key[:6] + "…" + key[len(key)-4:] +} + +// ImportApiKeys parses newline-separated Kiro API keys and imports each as an +// enabled api_key account. Dedups against the existing snapshot (by KiroApiKey) +// and within the batch itself. For each new key it best-effort fetches usage +// info (email/subscription/credit) via RefreshAccountInfo. Returns a per-key +// result summary; never aborts the whole batch on a single key's failure. +func (h *Handler) ImportApiKeys(rawText, region, authRegion, apiRegion string) ApiKeyImportSummary { + summary := ApiKeyImportSummary{Results: []ApiKeyImportResult{}} + + // Snapshot existing keys to dedup against. + existing := make(map[string]bool) + for _, a := range config.GetAccounts() { + if a.KiroApiKey != "" { + existing[a.KiroApiKey] = true + } + } + seenInBatch := make(map[string]bool) + + if region == "" { + region = "us-east-1" + } + + for _, raw := range strings.Split(rawText, "\n") { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + summary.Total++ + masked := maskKey(key) + + if existing[key] || seenInBatch[key] { + summary.Skipped++ + summary.Results = append(summary.Results, ApiKeyImportResult{MaskedKey: masked, Skipped: true}) + continue + } + seenInBatch[key] = true + + account := config.Account{ + ID: auth.GenerateAccountID(), + AuthMethod: "api_key", + KiroApiKey: key, + AccessToken: key, // pool/dispatch/metrics compatibility + Region: region, + AuthRegion: authRegion, + ApiRegion: apiRegion, + ExpiresAt: 0, // api_key: never refresh + Enabled: true, + MachineId: config.GenerateMachineId(), + } + if err := config.AddAccount(account); err != nil { + summary.Results = append(summary.Results, ApiKeyImportResult{MaskedKey: masked, Error: err.Error()}) + continue + } + summary.Imported++ + + result := ApiKeyImportResult{MaskedKey: masked, Imported: true, AccountID: account.ID} + // Best-effort usage info. RefreshAccountInfo uses KiroApiKey (mirrored into + // AccessToken) as the bearer; profile-ARN resolution is skipped for api_key. + if info, err := RefreshAccountInfo(&account); err != nil { + summary.InfoFailed++ + logger.Warnf("[ApiKeyBatch] RefreshAccountInfo failed for %s: %v", masked, err) + } else if info != nil { + result.InfoOK = true + result.Email = info.Email + result.Subscription = info.SubscriptionTitle + if result.Subscription == "" { + result.Subscription = info.SubscriptionType + } + result.UsageCurrent = info.UsageCurrent + result.UsageLimit = info.UsageLimit + } + // Async model-cache refresh (non-blocking). + go func(acc config.Account) { + if err := h.fetchAndCacheAccountModels(&acc); err != nil { + logger.Warnf("[ModelsCache] Auto-refresh failed for api_key account %s: %v", acc.Email, err) + } + }(account) + summary.Results = append(summary.Results, result) + } + + if summary.Imported > 0 { + h.pool.Reload() + } + return summary +} + +// apiImportApiKeys handles POST /auth/apikeys-batch: bulk import of newline- +// separated Kiro API keys. +func (h *Handler) apiImportApiKeys(w http.ResponseWriter, r *http.Request) { + r.Body = http.MaxBytesReader(w, r.Body, 2<<20) // 2 MB cap on the keys payload + var req struct { + Keys string `json:"keys"` + Region string `json:"region"` + AuthRegion string `json:"authRegion"` + ApiRegion string `json:"apiRegion"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]string{"error": "Invalid JSON"}) + return + } + if strings.TrimSpace(req.Keys) == "" { + w.WriteHeader(400) + json.NewEncoder(w).Encode(map[string]string{"error": "keys is required"}) + return + } + + summary := h.ImportApiKeys(req.Keys, req.Region, req.AuthRegion, req.ApiRegion) + json.NewEncoder(w).Encode(map[string]interface{}{ + "success": true, + "total": summary.Total, + "imported": summary.Imported, + "skipped": summary.Skipped, + "infoFailed": summary.InfoFailed, + "results": summary.Results, + }) +} diff --git a/proxy/apikey_batch_test.go b/proxy/apikey_batch_test.go new file mode 100644 index 00000000..850ef3ea --- /dev/null +++ b/proxy/apikey_batch_test.go @@ -0,0 +1,134 @@ +package proxy + +import ( + "encoding/json" + "io" + "kiro-go/config" + accountpool "kiro-go/pool" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// TestMaskKey verifies head-6 … tail-4 masking and the short-key fallback. +func TestMaskKey(t *testing.T) { + if got := maskKey("ABCDEFGHIJKLMNOP"); got != "ABCDEF…MNOP" { + t.Fatalf("mask long: want ABCDEF…MNOP, got %q", got) + } + if got := maskKey("short"); got != "****" { + t.Fatalf("mask short: want ****, got %q", got) + } +} + +// TestImportApiKeysDedupAndPerKeyResult verifies dedup against existing accounts +// and within the batch, plus per-key result + maskedKey in the summary. The +// best-effort RefreshAccountInfo is exercised against a stubbed REST store that +// 404s (best-effort → InfoFailed++, never a fatal/ban path). +func TestImportApiKeysDedupAndPerKeyResult(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + // Seed an existing api_key account to dedup against. + if err := config.AddAccount(config.Account{ID: "existing-1", AuthMethod: "api_key", KiroApiKey: "key-already-present", AccessToken: "key-already-present", Enabled: true}); err != nil { + t.Fatalf("seed: %v", err) + } + + // Stub the REST store so the best-effort RefreshAccountInfo / model-cache + // fetches don't hit real network. A 404 is NOT an auth error, so it never + // trips the ban path — it just records InfoFailed. + kiroRestHttpStore.Store(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("not found")), + Header: make(http.Header), + }, nil + }), + }) + t.Cleanup(func() { InitKiroHttpClient("") }) + + h := &Handler{pool: accountpool.GetPool()} + // One duplicate of the seeded key, one within-batch duplicate, two fresh keys. + keys := "key-already-present\nKEY-FRESH-1234567890\nKEY-FRESH-1234567890\nKEY-FRESH-2234567890" + summary := h.ImportApiKeys(keys, "eu-central-1", "", "") + + if summary.Total != 4 { + t.Fatalf("total: want 4, got %d", summary.Total) + } + // 1 existing-skip + 1 within-batch-skip = 2 skipped; 2 imported. + if summary.Imported != 2 { + t.Fatalf("imported: want 2, got %d", summary.Imported) + } + if summary.Skipped != 2 { + t.Fatalf("skipped: want 2, got %d", summary.Skipped) + } + if len(summary.Results) != 4 { + t.Fatalf("results: want 4, got %d", len(summary.Results)) + } + + // Persisted accounts: the 1 seed + 2 fresh = 3. + accs := config.GetAccounts() + if len(accs) != 3 { + t.Fatalf("persisted accounts: want 3, got %d", len(accs)) + } + for _, a := range accs { + if a.AuthMethod != "api_key" { + t.Fatalf("account %s AuthMethod: want api_key, got %q", a.ID, a.AuthMethod) + } + if a.ExpiresAt != 0 { + t.Fatalf("account %s ExpiresAt: want 0, got %d", a.ID, a.ExpiresAt) + } + } +} + +// TestApiImportApiKeysHandler verifies the POST /auth/apikeys-batch handler +// decodes the body and returns the summary shape. +func TestApiImportApiKeysHandler(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + kiroRestHttpStore.Store(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusNotFound, + Body: io.NopCloser(strings.NewReader("not found")), + Header: make(http.Header), + }, nil + }), + }) + t.Cleanup(func() { InitKiroHttpClient("") }) + + h := &Handler{pool: accountpool.GetPool()} + body := `{"keys":"KEY-A-1234567890\nKEY-B-1234567890","region":"us-east-1"}` + req := httptest.NewRequest("POST", "/auth/apikeys-batch", strings.NewReader(body)) + rec := httptest.NewRecorder() + h.apiImportApiKeys(rec, req) + + if rec.Code != 200 { + t.Fatalf("expected 200, got %d body=%s", rec.Code, rec.Body.String()) + } + var resp map[string]json.RawMessage + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + for _, k := range []string{"success", "total", "imported", "skipped", "infoFailed", "results"} { + if string(resp[k]) == "" { + t.Fatalf("response missing key %q: %v", k, resp) + } + } +} + +// TestApiImportApiKeysRejectsEmpty verifies the 400 guard. +func TestApiImportApiKeysRejectsEmpty(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + h := &Handler{pool: accountpool.GetPool()} + req := httptest.NewRequest("POST", "/auth/apikeys-batch", strings.NewReader(`{"keys":""}`)) + rec := httptest.NewRecorder() + h.apiImportApiKeys(rec, req) + if rec.Code != 400 { + t.Fatalf("expected 400 for empty keys, got %d", rec.Code) + } +} diff --git a/proxy/handler.go b/proxy/handler.go index c978dd77..2cd56eb9 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -2478,6 +2478,8 @@ func (h *Handler) handleAdminAPI(w http.ResponseWriter, r *http.Request) { h.apiImportSsoToken(w, r) case path == "/auth/credentials" && r.Method == "POST": h.apiImportCredentials(w, r) + case path == "/auth/apikeys-batch" && r.Method == "POST": + h.apiImportApiKeys(w, r) case path == "/status" && r.Method == "GET": h.apiGetStatus(w, r) case path == "/settings" && r.Method == "GET": From baa91c859f80674c32753c1635651f7080753cef Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 18:22:37 +0700 Subject: [PATCH 66/68] test(apikey): characterize no-refresh invariant on handler path --- proxy/apikey_characterization_test.go | 43 +++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 proxy/apikey_characterization_test.go diff --git a/proxy/apikey_characterization_test.go b/proxy/apikey_characterization_test.go new file mode 100644 index 00000000..0511b89d --- /dev/null +++ b/proxy/apikey_characterization_test.go @@ -0,0 +1,43 @@ +package proxy + +import ( + "kiro-go/auth" + "kiro-go/config" + accountpool "kiro-go/pool" + "testing" +) + +// TestApiKeyAccountDoesNotRefreshOnHandlerPath pins spec §6: an api_key account +// (ExpiresAt=0, RefreshToken="") must not trigger auth.RefreshToken on the +// handler refresh path. refreshAccountToken's not-near-expiry guard returns +// early (force=false, ExpiresAt==0), so the OIDC URL builder is never consulted. +func TestApiKeyAccountDoesNotRefreshOnHandlerPath(t *testing.T) { + if err := config.Init(t.TempDir() + "/config.json"); err != nil { + t.Fatalf("config.Init: %v", err) + } + // Sentinel: if a refresh fires, the OIDC URL builder is consulted (to derive + // the token endpoint), which would mean a round-trip was about to happen. + prev := auth.GetOIDCTokenURLForTest() + auth.SetOIDCTokenURLForTest(func(string) string { + t.Fatal("refresh must not be called for an api_key account") + return "" + }) + defer auth.SetOIDCTokenURLForTest(prev) + + account := &config.Account{ + ID: "ak-1", + AuthMethod: "api_key", + KiroApiKey: "k", + AccessToken: "k", + ExpiresAt: 0, + RefreshToken: "", + Enabled: true, + } + if !account.IsApiKeyCredential() { + t.Fatalf("expected IsApiKeyCredential() == true") + } + h := &Handler{pool: accountpool.GetPool()} + if err := h.refreshAccountToken(account, false); err != nil { + t.Fatalf("refreshAccountToken returned error: %v", err) + } +} From ade1621b92a0468712f1b46973fa024ab92372c2 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 18:26:14 +0700 Subject: [PATCH 67/68] feat(web): API-key + bulk import modals and maxPayloadBytes setting --- web/app.js | 76 +++++++++++++++++++++++++++++++++++++++++++-- web/index.html | 10 ++++++ web/locales/en.json | 18 +++++++++++ web/locales/zh.json | 18 +++++++++++ 4 files changed, 120 insertions(+), 2 deletions(-) diff --git a/web/app.js b/web/app.js index b149cdd4..c5970abe 100644 --- a/web/app.js +++ b/web/app.js @@ -1537,6 +1537,8 @@ const d = await res.json(); $('requireApiKey').checked = d.requireApiKey; $('allowOverUsage').checked = d.allowOverUsage || false; + const maxPayloadEl = document.getElementById('maxPayloadBytes'); + if (maxPayloadEl) maxPayloadEl.value = String(d.maxPayloadBytes || 2000000); await Promise.all([loadThinkingConfig(), loadEndpointConfig(), loadProxyConfig(), loadPromptFilter(), loadApiKeys()]); refreshCustomSelects(); } @@ -1642,7 +1644,9 @@ } async function saveOverUsageConfig() { const allowOverUsage = $('allowOverUsage').checked; - await api('/settings', { method: 'POST', body: JSON.stringify({ allowOverUsage }) }); + const maxPayloadEl = document.getElementById('maxPayloadBytes'); + const maxPayloadBytes = maxPayloadEl ? parseInt(maxPayloadEl.value || '0', 10) : 0; + await api('/settings', { method: 'POST', body: JSON.stringify({ allowOverUsage, maxPayloadBytes }) }); toast(t('settings.overUsageSaved'), 'success'); } async function changePassword() { @@ -2025,7 +2029,9 @@ sso: 'fa-solid fa-shield-halved', local: 'fa-solid fa-folder-open', credentials: 'fa-solid fa-code', - cookie: 'fa-solid fa-cookie-bite' + cookie: 'fa-solid fa-cookie-bite', + apikey: 'fa-solid fa-key', + apikeybatch: 'fa-solid fa-layer-group' }; function methodCard(type, title, desc) { var icon = METHOD_ICONS[type] || 'fa-solid fa-circle-plus'; @@ -2050,6 +2056,8 @@ else if (type === 'local') modalLocal(title, body); else if (type === 'credentials') modalCredentials(title, body); else if (type === 'cookie') modalCookie(title, body); + else if (type === 'apikey') modalApiKey(title, body); + else if (type === 'apikeybatch') modalApiKeyBatch(title, body); if (!modal.classList.contains('active')) openDialog('addModal'); enhanceCustomSelects(body); } @@ -2078,6 +2086,8 @@ methodCard('local', t('modal.localTitle'), t('modal.localDesc')) + methodCard('credentials', t('modal.credentialsTitle'), t('modal.credentialsDesc')) + methodCard('cookie', t('modal.cookieTitle'), t('modal.cookieDesc')) + + methodCard('apikey', t('modal.apikeyTitle'), t('modal.apikeyDesc')) + + methodCard('apikeybatch', t('modal.apikeyBatchTitle'), t('modal.apikeyBatchDesc')) + '' + ''; } @@ -2234,6 +2244,35 @@ ''; $('importCookieBtn').addEventListener('click', importFromCookie); } + function modalApiKey(title, body) { + title.textContent = t('modal.apikeyTitle'); + body.innerHTML = + '

' + escapeHtml(t('apikey.hint')) + '

' + + '
' + + '' + + '
' + + '
' + + ''; + $('importApiKeyBtn').addEventListener('click', importApiKey); + } + function modalApiKeyBatch(title, body) { + title.textContent = t('modal.apikeyBatchTitle'); + body.innerHTML = + '

' + escapeHtml(t('apikeyBatch.hint')) + '

' + + '
' + + '' + + '' + escapeHtml(t('apikeyBatch.onePerLine')) + '' + + '
' + + '
' + + ''; + $('importApiKeyBatchBtn').addEventListener('click', importApiKeysBatch); + } function updateLocalFields() { const p = $('localProvider').value; $('localClientGroup').classList.toggle('hidden', p === 'Google' || p === 'Github'); @@ -2405,6 +2444,39 @@ autoRefreshNewAccount(d.account?.id); } else toastError(t('common.failed') + ': ' + (d.error || '')); } + async function importApiKey() { + const kiroApiKey = $('apiKeyValue').value.trim(); + if (!kiroApiKey) return toastWarning(t('apikey.keyMissing')); + const region = $('apiKeyRegion').value || 'us-east-1'; + const res = await api('/auth/credentials', { method: 'POST', body: JSON.stringify({ kiroApiKey, authMethod: 'api_key', region }) }); + const d = await res.json(); + if (d.success) { + closeModal(); loadAccounts(); loadStats(); + toastPrimary(t('apikey.importSuccess') + ': ' + (d.account?.email || d.account?.id)); + autoRefreshNewAccount(d.account?.id); + } else toastError(t('common.failed') + ': ' + (d.error || '')); + } + async function importApiKeysBatch() { + const keys = $('apiKeyBatchValue').value.trim(); + if (!keys) return toastWarning(t('apikeyBatch.keysMissing')); + const region = $('apiKeyBatchRegion').value || 'us-east-1'; + const res = await api('/auth/apikeys-batch', { method: 'POST', body: JSON.stringify({ keys, region }) }); + const d = await res.json(); + if (d.success) { + closeModal(); loadAccounts(); loadStats(); + let msg = t('apikeyBatch.summary', d.imported || 0, d.total || 0, d.skipped || 0); + if (d.infoFailed > 0) msg += t('apikeyBatch.infoFailed', d.infoFailed); + toastPrimary(msg, { duration: 6000 }); + renderApiKeyBatchResults(d.results || []); + } else toastError(t('common.failed') + ': ' + (d.error || '')); + } + function renderApiKeyBatchResults(results) { + // Per-key detail surfaces via the toast summary above; log masked failures + // for the operator so a full per-key panel can be built later if needed. + results.forEach(r => { + if (r.error) console.warn('[ApiKeyBatch]', r.maskedKey, r.error); + }); + } async function importSsoToken() { const res = await api('/auth/sso-token', { method: 'POST', body: JSON.stringify({ diff --git a/web/index.html b/web/index.html index 52a22217..233bfc3d 100644 --- a/web/index.html +++ b/web/index.html @@ -270,6 +270,16 @@

+
+ + + +
diff --git a/web/locales/en.json b/web/locales/en.json index db7df59d..affb3e8a 100644 --- a/web/locales/en.json +++ b/web/locales/en.json @@ -243,6 +243,24 @@ "cookie.refreshTokenMissing": "RefreshToken is required", "cookie.importSuccess": "Account added successfully", "cookie.link": "https://app.kiro.dev/account/usage", + "modal.apikeyTitle": "API Key", + "modal.apikeyDesc": "Add an account using a Kiro API key directly as the bearer token (no OAuth refresh)", + "modal.apikeyBatchTitle": "Bulk API Key Import", + "modal.apikeyBatchDesc": "Import many Kiro API keys at once (one per line)", + "apikey.hint": "Paste a Kiro API key. It is used directly as the bearer token (tokentype: API_KEY); no refresh token or profile ARN is needed.", + "apikey.key": "API Key", + "apikey.keyPlaceholder": "Paste the Kiro API key here", + "apikey.keyMissing": "API key is required", + "apikey.importSuccess": "Account added", + "apikeyBatch.hint": "One key per line. Duplicates (already imported or repeated in this batch) are skipped.", + "apikeyBatch.keys": "API Keys", + "apikeyBatch.keysPlaceholder": "one-key-per-line...", + "apikeyBatch.onePerLine": "One key per line", + "apikeyBatch.keysMissing": "At least one API key is required", + "apikeyBatch.summary": "Imported {0} of {1} keys ({2} skipped)", + "apikeyBatch.infoFailed": ", {0} usage-info lookups failed", + "settings.maxPayloadBytes": "Max Payload Size", + "settings.maxPayloadBytesHint": "Cap on the request body size before the upstream rejects it as too large. Higher = more context per request.", "builderid.startLogin": "Start Login", "builderid.verifyCode": "Enter the code above in your browser", "builderid.verifyUrl": "Verification URL", diff --git a/web/locales/zh.json b/web/locales/zh.json index 5b084557..145d0f00 100644 --- a/web/locales/zh.json +++ b/web/locales/zh.json @@ -243,6 +243,24 @@ "cookie.refreshTokenMissing": "请填写 RefreshToken", "cookie.importSuccess": "账号添加成功", "cookie.link": "https://app.kiro.dev/account/usage", + "modal.apikeyTitle": "API 密钥", + "modal.apikeyDesc": "使用 Kiro API 密钥直接作为 Bearer 令牌添加账号(无需 OAuth 刷新)", + "modal.apikeyBatchTitle": "批量导入 API 密钥", + "modal.apikeyBatchDesc": "一次导入多个 Kiro API 密钥(每行一个)", + "apikey.hint": "粘贴 Kiro API 密钥。它将直接作为 Bearer 令牌使用(tokentype: API_KEY),无需 refresh token 或 profile ARN。", + "apikey.key": "API 密钥", + "apikey.keyPlaceholder": "在此粘贴 Kiro API 密钥", + "apikey.keyMissing": "API 密钥不能为空", + "apikey.importSuccess": "账号添加成功", + "apikeyBatch.hint": "每行一个密钥。重复项(已导入或本批次内重复)将被跳过。", + "apikeyBatch.keys": "API 密钥", + "apikeyBatch.keysPlaceholder": "每行一个密钥...", + "apikeyBatch.onePerLine": "每行一个密钥", + "apikeyBatch.keysMissing": "至少需要一个 API 密钥", + "apikeyBatch.summary": "已导入 {1} 个中的 {0} 个密钥({2} 个跳过)", + "apikeyBatch.infoFailed": ",{0} 个用量信息查询失败", + "settings.maxPayloadBytes": "最大请求体大小", + "settings.maxPayloadBytesHint": "上游拒绝过大请求前的请求体大小上限。数值越大,单次请求可携带的上下文越多。", "builderid.startLogin": "开始登录", "builderid.verifyCode": "请在浏览器中输入上方验证码", "builderid.verifyUrl": "验证链接", From 086dc6eb2090a9e320f13d26ca6841b7153fa3e3 Mon Sep 17 00:00:00 2001 From: ngh1105 Date: Mon, 13 Jul 2026 23:27:40 +0700 Subject: [PATCH 68/68] fix(apikey): unify region, guard ban-on-refresh, mask key in logs Three api_key robustness fixes surfaced by the refresh-ban investigation (a valid-feeling api_key kept getting BANNED): 1. Region unification (REST <-> chat path inconsistency): kiroRegionForProfile now branches on IsApiKeyCredential -> EffectiveApiRegion(), the same fallback chain the chat path (CallKiroAPI) uses. Previously the REST path (getUsageLimits, ListAvailableModels, overage) saw only account.Region, so ApiRegion + the global region were silently ignored and REST calls targeted a different region than chat. The chat path in kiro.go is simplified back to a single regionalizeURLForProfile call (kiroRegionForProfile now encapsulates the api_key branch); this also fixes the nonexistent codewhisperer.{region} host for non-us-east-1 api_key accounts. 2. Ban guard: RefreshAccountInfo no longer BANs an api_key account on a usage-limit 403. api_key accounts authenticate directly and have no profile-ARN cross-region probe, so a usage-limit 403 is not authoritative (could be region/config). The chat path (handleAccountFailure) is the source of truth and bans a genuinely-invalid key on a real request. Without this guard, the region mismatch in (1) permanently disabled a valid key over a spurious 403. 3. Log masking: accountEmailForLog falls back to maskKey(KiroApiKey) when an api_key account has no email yet (email is only populated after the first successful refresh). All RefreshAccountInfo / model-cache / import log sites reachable for api_key accounts now use accountEmailForLog instead of the bare (empty) account.Email, so refresh/ban/cache lines are no longer blind ("for : ..."). Tests: TestRegionalizeURLApiKeyUsesEffectiveApiRegion, TestRefreshAccountInfoDoesNotBanApiKeyOnUsageLimit403, TestAccountEmailForLogMasksApiKey. 3 full-suite failures (env-proxy cache, Windows TempDir cleanup race) are environmental and pass in isolation. --- proxy/apikey_batch.go | 2 +- proxy/handler.go | 8 ++--- proxy/kiro.go | 37 +++++++++++----------- proxy/kiro_api.go | 23 +++++++++++--- proxy/kiro_api_test.go | 69 ++++++++++++++++++++++++++++++++++++++++++ proxy/kiro_test.go | 17 +++++++++++ 6 files changed, 129 insertions(+), 27 deletions(-) diff --git a/proxy/apikey_batch.go b/proxy/apikey_batch.go index bf1e7a75..22cf93b7 100644 --- a/proxy/apikey_batch.go +++ b/proxy/apikey_batch.go @@ -115,7 +115,7 @@ func (h *Handler) ImportApiKeys(rawText, region, authRegion, apiRegion string) A // Async model-cache refresh (non-blocking). go func(acc config.Account) { if err := h.fetchAndCacheAccountModels(&acc); err != nil { - logger.Warnf("[ModelsCache] Auto-refresh failed for api_key account %s: %v", acc.Email, err) + logger.Warnf("[ModelsCache] Auto-refresh failed for api_key account %s: %v", accountEmailForLog(&acc), err) } }(account) summary.Results = append(summary.Results, result) diff --git a/proxy/handler.go b/proxy/handler.go index 2cd56eb9..4162b5e8 100644 --- a/proxy/handler.go +++ b/proxy/handler.go @@ -2638,7 +2638,7 @@ func (h *Handler) apiAddAccount(w http.ResponseWriter, r *http.Request) { if account.Enabled && (account.AccessToken != "" || account.IsApiKeyCredential()) { go func(acc config.Account) { if err := h.fetchAndCacheAccountModels(&acc); err != nil { - logger.Warnf("[ModelsCache] Auto-refresh failed for new account %s: %v", acc.Email, err) + logger.Warnf("[ModelsCache] Auto-refresh failed for new account %s: %v", accountEmailForLog(&acc), err) } }(account) } @@ -2707,7 +2707,7 @@ func (h *Handler) apiUpdateAccount(w http.ResponseWriter, r *http.Request, id st if !oldEnabled && existing.Enabled && existing.AccessToken != "" { go func(acc config.Account) { if err := h.fetchAndCacheAccountModels(&acc); err != nil { - logger.Warnf("[ModelsCache] Auto-refresh failed for re-enabled account %s: %v", acc.Email, err) + logger.Warnf("[ModelsCache] Auto-refresh failed for re-enabled account %s: %v", accountEmailForLog(&acc), err) } }(*existing) } @@ -3387,12 +3387,12 @@ func (h *Handler) apiImportCredentials(w http.ResponseWriter, r *http.Request) { // applyKiroBaseHeaders; profile-ARN resolution is skipped for api_key. go func(acc config.Account) { if _, infoErr := RefreshAccountInfo(&acc); infoErr != nil { - logger.Warnf("[Import] RefreshAccountInfo failed for api_key account %s: %v", acc.Email, infoErr) + logger.Warnf("[Import] RefreshAccountInfo failed for api_key account %s: %v", accountEmailForLog(&acc), infoErr) } }(account) go func(acc config.Account) { if err := h.fetchAndCacheAccountModels(&acc); err != nil { - logger.Warnf("[ModelsCache] Auto-refresh failed for api_key account %s: %v", acc.Email, err) + logger.Warnf("[ModelsCache] Auto-refresh failed for api_key account %s: %v", accountEmailForLog(&acc), err) } }(account) json.NewEncoder(w).Encode(map[string]interface{}{ diff --git a/proxy/kiro.go b/proxy/kiro.go index 5bdfef33..b204ff14 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -340,23 +340,14 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt // Update the origin field for the selected endpoint. payload.ConversationState.CurrentMessage.UserInputMessage.Origin = ep.Origin - // Target the data-plane region. api_key accounts have no profile ARN, so - // rebuild the host from the account's EffectiveApiRegion; OAuth accounts - // keep deriving the region from the profile ARN. - epURL := ep.URL - if account != nil && account.IsApiKeyCredential() { - if parsed, perr := url.Parse(ep.URL); perr == nil && strings.HasSuffix(parsed.Hostname(), ".amazonaws.com") { - apiRegion := account.EffectiveApiRegion() - switch ep.Name { - case "CodeWhisperer": - epURL = fmt.Sprintf("https://codewhisperer.%s.amazonaws.com/generateAssistantResponse", apiRegion) - default: // "Kiro IDE" and "AmazonQ" - epURL = fmt.Sprintf("https://q.%s.amazonaws.com/generateAssistantResponse", apiRegion) - } - } - } else { - epURL = regionalizeURLForProfile(ep.URL, account, payload.ProfileArn) - } + // Target the data-plane region via the unified regionalizeURLForProfile. + // kiroRegionForProfile branches on IsApiKeyCredential: api_key accounts + // resolve from EffectiveApiRegion (no profile ARN), OAuth accounts derive + // from the profile ARN. regionalizeURLForRegion collapses both us-east-1 + // hosts (q. and codewhisperer.) onto q. for non-us-east-1 — there + // is no regional codewhisperer host — so this also routes the CodeWhisperer + // endpoint correctly for regional api_key accounts. + epURL := regionalizeURLForProfile(ep.URL, account, payload.ProfileArn) reqBody, _ := json.Marshal(payload) req, err := http.NewRequest("POST", epURL, bytes.NewReader(reqBody)) @@ -429,10 +420,22 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt return fmt.Errorf("all endpoints failed") } +// accountEmailForLog returns the most informative stable identifier for an +// account in log lines. OAuth accounts log their email. api_key accounts have +// no email until their first successful refresh (RefreshAccountInfo populates +// it from getUsageLimits), so without a fallback every refresh/ ban/ model- +// cache line for them was blind ("for : ..."). Fall back to the masked key, +// which is stable and safe to log. func accountEmailForLog(account *config.Account) string { if account == nil { return "" } + if email := strings.TrimSpace(account.Email); email != "" { + return email + } + if account.IsApiKeyCredential() && account.KiroApiKey != "" { + return maskKey(account.KiroApiKey) + } return account.Email } diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index fd47eb28..4c141e45 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -37,6 +37,16 @@ func kiroRegion(account *config.Account) string { } func kiroRegionForProfile(account *config.Account, profileArn string) string { + // api_key accounts have no profile ARN, so the profile-derived region is + // meaningless. Resolve from the EffectiveApiRegion fallback chain + // (ApiRegion > Region > global > us-east-1) — the same chain the chat path + // (CallKiroAPI) uses — so REST calls (getUsageLimits, ListAvailableModels, + // overage) target the same region as chat. Previously the REST path saw only + // account.Region, so ApiRegion and the global region were silently ignored, + // diverging from the chat path and triggering spurious 403s. + if account != nil && account.IsApiKeyCredential() { + return account.EffectiveApiRegion() + } if r := regionFromProfileArn(profileArn); r != "" { return r } @@ -540,7 +550,7 @@ func RefreshAccountInfo(account *config.Account) (*config.AccountInfo, error) { errMsg := err.Error() if strings.Contains(errMsg, "TEMPORARILY_SUSPENDED") { // 账户被暂时封禁,自动禁用并标记封禁状态 - logger.Warnf("[RefreshAccountInfo] Account %s is temporarily suspended: %v", account.Email, err) + logger.Warnf("[RefreshAccountInfo] Account %s is temporarily suspended: %v", accountEmailForLog(account), err) // 更新账户封禁状态并自动禁用 updatedAccount := *account @@ -555,9 +565,12 @@ func RefreshAccountInfo(account *config.Account) (*config.AccountInfo, error) { } return nil, fmt.Errorf("Account suspended: %w", err) - } else if isAuthErrorMessage(errMsg) { - // Token 相关错误,可能需要重新认证 - logger.Warnf("[RefreshAccountInfo] Authentication error for %s: %v", account.Email, err) + } else if isAuthErrorMessage(errMsg) && !account.IsApiKeyCredential() { + // Token 相关错误,可能需要重新认证。api_key 账户不在 refresh 路径封禁: + // usage-limit 403 并非权威信号(可能是 region/配置问题),且 api_key 没有 + // profile-ARN 跨区探测来交叉验证。chat 路径 (handleAccountFailure) 才是 + // api_key 有效性的权威来源——真实请求的 403 会在那里封禁。 + logger.Warnf("[RefreshAccountInfo] Authentication error for %s: %v", accountEmailForLog(account), err) // 更新账户封禁状态为认证失败并自动禁用 updatedAccount := *account @@ -577,7 +590,7 @@ func RefreshAccountInfo(account *config.Account) (*config.AccountInfo, error) { // 如果成功获取信息,清除封禁状态(如果之前被标记) if account.BanStatus != "" && account.BanStatus != "ACTIVE" { - logger.Infof("[RefreshAccountInfo] Account %s is now active, clearing ban status", account.Email) + logger.Infof("[RefreshAccountInfo] Account %s is now active, clearing ban status", accountEmailForLog(account)) updatedAccount := *account updatedAccount.BanStatus = "ACTIVE" diff --git a/proxy/kiro_api_test.go b/proxy/kiro_api_test.go index 10dca01a..efb30a91 100644 --- a/proxy/kiro_api_test.go +++ b/proxy/kiro_api_test.go @@ -57,6 +57,26 @@ func TestRegionalizeURLForProfileUsesPayloadProfileArnRegion(t *testing.T) { } } +// TestRegionalizeURLApiKeyUsesEffectiveApiRegion guards the REST/chat region +// inconsistency: an api_key account's REST calls (getUsageLimits, +// ListAvailableModels, overage) must resolve the same region as the chat path +// (CallKiroAPI), i.e. use the EffectiveApiRegion fallback chain (ApiRegion > +// Region > global > us-east-1), not just account.Region. Previously the REST +// path saw only account.Region, so ApiRegion (and global) was ignored. +func TestRegionalizeURLApiKeyUsesEffectiveApiRegion(t *testing.T) { + account := &config.Account{ + AuthMethod: "api_key", + KiroApiKey: "k", + Region: "us-east-1", + ApiRegion: "eu-central-1", + } + got := regionalizeURL("https://q.us-east-1.amazonaws.com/getUsageLimits", account) + want := "https://q.eu-central-1.amazonaws.com/getUsageLimits" + if got != want { + t.Fatalf("api_key REST region: want %q, got %q", want, got) + } +} + func TestResolveProfileArnFetchesAndCachesProfile(t *testing.T) { configPath := filepath.Join(t.TempDir(), "config.json") if err := config.Init(configPath); err != nil { @@ -369,6 +389,55 @@ func TestRefreshAccountInfoDoesNotBanOnTransientUpstreamError(t *testing.T) { } } +// TestRefreshAccountInfoDoesNotBanApiKeyOnUsageLimit403 pins the invariant that an +// api_key account is NOT auto-banned when a usage-limit refresh returns 403. api_key +// accounts authenticate directly and have no profile-ARN cross-region probe, so a +// usage-limit 403 is not authoritative (could be region/config). The chat path +// (handleAccountFailure) is the source of truth and bans a genuinely-invalid key on a +// real request. Banning here would permanently disable a key over a spurious 403. +func TestRefreshAccountInfoDoesNotBanApiKeyOnUsageLimit403(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.json") + if err := config.Init(configPath); err != nil { + t.Fatalf("init config: %v", err) + } + account := config.Account{ + ID: "apikey-refresh-1", + AuthMethod: "api_key", + KiroApiKey: "ksk_test_key_value", + AccessToken: "ksk_test_key_value", + Region: "us-east-1", + Enabled: true, + } + if err := config.AddAccount(account); err != nil { + t.Fatalf("add account: %v", err) + } + + kiroRestHttpStore.Store(&http.Client{ + Transport: roundTripFunc(func(*http.Request) (*http.Response, error) { + return &http.Response{ + StatusCode: http.StatusForbidden, + Body: io.NopCloser(strings.NewReader(`{"message":"The bearer token included in the request is invalid.","reason":null}`)), + Header: make(http.Header), + }, nil + }), + }) + t.Cleanup(func() { InitKiroHttpClient("") }) + + requestAccount := account + if _, err := RefreshAccountInfo(&requestAccount); err == nil { + t.Fatalf("expected GetUsageLimits 403 to propagate, got nil") + } + + accounts := config.GetAccounts() + if len(accounts) != 1 { + t.Fatalf("expected one account, got %d", len(accounts)) + } + if !accounts[0].Enabled || accounts[0].BanStatus == "BANNED" { + t.Fatalf("api_key account must NOT be banned on usage-limit 403 (chat path is source of truth), got enabled=%v banStatus=%q banReason=%q", + accounts[0].Enabled, accounts[0].BanStatus, accounts[0].BanReason) + } +} + func clearProfileArnResolutionCooldowns() { profileArnResolutionCooldowns.Range(func(key, _ interface{}) bool { profileArnResolutionCooldowns.Delete(key) diff --git a/proxy/kiro_test.go b/proxy/kiro_test.go index 7c0bd70c..8f4bab21 100644 --- a/proxy/kiro_test.go +++ b/proxy/kiro_test.go @@ -305,3 +305,20 @@ func TestCallKiroAPIRebuildsRegionForApiKeyAccount(t *testing.T) { t.Fatalf("api_key host: want q.eu-central-1.amazonaws.com, got %q", gotHost) } } + +// TestAccountEmailForLogMasksApiKey ensures api_key accounts (which have no email +// until their first successful refresh) are not blind in logs: fall back to the masked +// key. When an email is present it wins. +func TestAccountEmailForLogMasksApiKey(t *testing.T) { + acct := &config.Account{AuthMethod: "api_key", KiroApiKey: "ksk_abcdefghijklmnop"} + if got := accountEmailForLog(acct); got != "ksk_ab…mnop" { + t.Fatalf("api_key no-email: want masked key %q, got %q", "ksk_ab…mnop", got) + } + acct.Email = "user@example.com" + if got := accountEmailForLog(acct); got != "user@example.com" { + t.Fatalf("email present: want %q, got %q", "user@example.com", got) + } + if got := accountEmailForLog(nil); got != "" { + t.Fatalf("nil account: want %q, got %q", "", got) + } +}