diff --git a/config/config.go b/config/config.go index e098a3a2..a71ba713 100644 --- a/config/config.go +++ b/config/config.go @@ -595,6 +595,24 @@ func UpdateAccountProfileArn(id, profileArn string) error { return fmt.Errorf("account not found: %s", id) } +// UpdateAccountRegion persists a re-detected data-plane region for an account. +// Used by the api_key region auto-detection path (proxy.reprobeApiKeyRegion): a +// ksk_ key is bound to the region it was minted in, so when a call is rejected as +// an invalid bearer token in the stored region the caller probes other regions and +// persists the one that works here, so future calls target it directly. A missing +// id is an error so callers cannot mistake a no-op for a successful write. +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 fmt.Errorf("account not found: %s", id) +} + func DeleteAccount(id string) error { cfgLock.Lock() defer cfgLock.Unlock() diff --git a/proxy/kiro.go b/proxy/kiro.go index b3ecf567..eb1a4568 100644 --- a/proxy/kiro.go +++ b/proxy/kiro.go @@ -10,6 +10,7 @@ import ( "io" "kiro-go/config" "kiro-go/logger" + "kiro-go/pool" "net/http" "net/url" "regexp" @@ -356,6 +357,25 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt } } + err := callKiroEndpointsOnce(account, payload, callback) + // A Kiro API-key (ksk_) key is region-bound: a request sent to the wrong + // data-plane region is rejected as an invalid bearer token (HTTP 403) even though + // the key is valid elsewhere. On that auth failure, re-detect the key's region and, + // if a different region accepts it, retry once there. Auth errors are raised before + // any streaming callback fires (the status check precedes parseAndStream), so the + // single retry cannot double-emit output to the client. + if err != nil && account.IsApiKeyCredential() && pool.IsAuthFailure(err) { + if _, ok := reprobeApiKeyRegion(account); ok { + err = callKiroEndpointsOnce(account, payload, callback) + } + } + return err +} + +// callKiroEndpointsOnce performs a single pass over the configured Kiro endpoints for +// the account's currently-resolved region, returning on the first success or on a +// terminal (401/403/402) error. CallKiroAPI wraps it with api_key region-failover. +func callKiroEndpointsOnce(account *config.Account, payload *KiroPayload, callback *KiroStreamCallback) error { // Build endpoint list ordered by configuration. endpoints := getSortedEndpoints(config.GetPreferredEndpoint()) diff --git a/proxy/kiro_api.go b/proxy/kiro_api.go index ce79e66a..789bea70 100644 --- a/proxy/kiro_api.go +++ b/proxy/kiro_api.go @@ -148,6 +148,53 @@ func shouldProbeFallbackRegions(account *config.Account) bool { return strings.EqualFold(strings.TrimSpace(account.AuthMethod), "external_idp") } +// reprobeApiKeyRegion re-detects the data-plane region for a Kiro API-key account +// after an upstream auth failure (typically "HTTP 403 ... bearer token invalid"). +// A ksk_ key is bound to the region it was minted in, so a request sent to the wrong +// region is rejected as an invalid bearer token even though the key is perfectly +// valid elsewhere. The stored region can be wrong when the account was imported +// without a region probe, or the key was later re-issued in a different region. +// +// It probes each candidate region (skipping the one that just failed) using the same +// key-validating probe the "add Kiro API key" flow uses, and on the first region that +// accepts the key it persists the new region to config + the in-memory account and +// returns it. Returns ("", false) when no other region accepts the key (the key is +// genuinely dead / revoked) so the caller surfaces the original auth error unchanged. +func reprobeApiKeyRegion(account *config.Account) (string, bool) { + if account == nil || !account.IsApiKeyCredential() { + return "", false + } + key := strings.TrimSpace(account.KiroApiKey) + if key == "" { + return "", false + } + current := normalizeRegion(account.Region) + for _, region := range kiroApiKeyCandidateRegions() { + if normalizeRegion(region) == current { + continue + } + info, err := probeKiroApiKey(key, region) + if err != nil { + logger.Debugf("[KiroAPI] api_key region probe failed for %s in %s: %v", accountEmailForLog(account), region, err) + continue + } + // This region accepts the key. Persist it so subsequent calls target it + // directly and no longer trip the auth-error → reprobe path. + account.Region = region + if updateErr := config.UpdateAccountRegion(account.ID, region); updateErr != nil { + logger.Warnf("[KiroAPI] failed to persist re-detected region %s for %s: %v", region, accountEmailForLog(account), updateErr) + } + if info != nil { + if infoErr := config.UpdateAccountInfo(account.ID, *info); infoErr != nil { + logger.Debugf("[KiroAPI] failed to persist account info after region re-detect for %s: %v", accountEmailForLog(account), infoErr) + } + } + logger.Infof("[KiroAPI] api_key account %s re-detected region %s after auth error", accountEmailForLog(account), region) + return region, true + } + return "", false +} + // GetUsageLimits 获取账户使用量和订阅信息 func GetUsageLimits(account *config.Account) (*UsageLimitsResponse, error) { if err := ensureRestProfileArn(account); err != nil { diff --git a/proxy/kiro_apikey_region_test.go b/proxy/kiro_apikey_region_test.go new file mode 100644 index 00000000..212eecc0 --- /dev/null +++ b/proxy/kiro_apikey_region_test.go @@ -0,0 +1,91 @@ +package proxy + +import ( + "kiro-go/config" + "testing" +) + +// reprobeApiKeyRegion must discover the region a ksk_ key actually serves when the +// stored region is wrong, persist it to config, and mutate the in-memory account so +// the caller's immediate retry targets the new region. +func TestReprobeApiKeyRegionRedetectsAndPersists(t *testing.T) { + mustInitConfig(t) + + acc := config.Account{ + ID: "acct-1", + AuthMethod: "api_key", + KiroApiKey: "ksk_key", + AccessToken: "ksk_key", + Region: "us-east-1", // wrong region — upstream rejects the bearer token here + Enabled: true, + } + if err := config.AddAccount(acc); err != nil { + t.Fatalf("AddAccount: %v", err) + } + + // The key is only valid in eu-central-1. + origProbe := probeKiroApiKey + defer func() { probeKiroApiKey = origProbe }() + probeKiroApiKey = func(key, region string) (*config.AccountInfo, error) { + if region != "eu-central-1" { + return nil, errTest("HTTP 403: not served in " + region) + } + return &config.AccountInfo{Email: "a@example.com", UserId: "u-1"}, nil + } + + account := acc // local copy the caller would hold + region, ok := reprobeApiKeyRegion(&account) + if !ok { + t.Fatalf("expected region re-detection to succeed") + } + if region != "eu-central-1" { + t.Fatalf("expected eu-central-1, got %q", region) + } + // In-memory account updated so the immediate retry targets the new region. + if account.Region != "eu-central-1" { + t.Fatalf("in-memory account region not updated, got %q", account.Region) + } + // Persisted to config so future calls skip the reprobe entirely. + var persisted string + for _, a := range config.GetAccounts() { + if a.ID == "acct-1" { + persisted = a.Region + } + } + if persisted != "eu-central-1" { + t.Fatalf("persisted region not updated, got %q", persisted) + } +} + +// A genuinely dead key (rejected in every region) must return ok=false and leave the +// stored region untouched, so the caller surfaces the original auth error. +func TestReprobeApiKeyRegionDeadKey(t *testing.T) { + mustInitConfig(t) + + origProbe := probeKiroApiKey + defer func() { probeKiroApiKey = origProbe }() + probeKiroApiKey = func(key, region string) (*config.AccountInfo, error) { + return nil, errTest("HTTP 403: The bearer token included in the request is invalid.") + } + + account := config.Account{ + ID: "acct-2", + AuthMethod: "api_key", + KiroApiKey: "ksk_dead", + Region: "us-east-1", + } + if region, ok := reprobeApiKeyRegion(&account); ok { + t.Fatalf("expected no region for a dead key, got %q", region) + } + if account.Region != "us-east-1" { + t.Fatalf("region must be unchanged for a dead key, got %q", account.Region) + } +} + +// Non-api_key accounts must never trigger the api_key reprobe path. +func TestReprobeApiKeyRegionSkipsNonApiKey(t *testing.T) { + account := &config.Account{ID: "acct-3", AuthMethod: "social", Region: "us-east-1"} + if _, ok := reprobeApiKeyRegion(account); ok { + t.Fatalf("expected no reprobe for non-api_key account") + } +} diff --git a/web/app.js b/web/app.js index 910a2a96..3730929f 100644 --- a/web/app.js +++ b/web/app.js @@ -141,11 +141,13 @@ qsa('.lang-btn').forEach(btn => btn.classList.toggle('active', btn.dataset.lang === currentLang)); qsa('.lang-toggle').forEach(btn => { const label = btn.querySelector('.lang-toggle-label'); - if (label) label.textContent = currentLang === 'zh' ? t('lang.zh') : t('lang.en'); + if (label) label.textContent = t('lang.' + currentLang); }); } function toggleLang() { - setLang(currentLang === 'zh' ? 'en' : 'zh'); + const order = ['zh', 'en', 'vi']; + const idx = order.indexOf(currentLang); + setLang(order[(idx + 1) % order.length]); } // Custom select @@ -3233,10 +3235,10 @@ const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; const parts = []; - if (d > 0) parts.push(d + (currentLang === 'zh' ? '天' : 'd')); - if (h > 0) parts.push(h + (currentLang === 'zh' ? '时' : 'h')); - if (m > 0) parts.push(m + (currentLang === 'zh' ? '分' : 'm')); - parts.push(s + (currentLang === 'zh' ? '秒' : 's')); + if (d > 0) parts.push(d + t('uptime.d')); + if (h > 0) parts.push(h + t('uptime.h')); + if (m > 0) parts.push(m + t('uptime.m')); + parts.push(s + t('uptime.s')); return parts.join(' '); } @@ -3321,7 +3323,7 @@ gemini: 'Gemini (Google)', meta: 'LLaMA (Meta)', proxy: 'Proxy Aliases', - other: currentLang === 'zh' ? '其他模型' : 'Other' + other: t('models.familyOther') }; return labels[family] || family; } diff --git a/web/index.html b/web/index.html index 52a22217..d6ebbfe4 100644 --- a/web/index.html +++ b/web/index.html @@ -49,6 +49,7 @@
+
+