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 @@