From 3ae2cd66654ba815e5983e2e190878e69c700d35 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20Minh=20=C4=90=E1=BB=93ng?= Date: Wed, 15 Jul 2026 17:36:17 +0700 Subject: [PATCH 1/2] feat(kiro): auto re-detect region for api_key accounts on auth failure A Kiro API key (ksk_) is bound to the data-plane region it was minted in. A request sent to the wrong region is rejected as an invalid bearer token (HTTP 403) even though the key is valid elsewhere. The stored region can be wrong when an account was imported without a region probe or the key was re-issued in a different region. On an auth failure for an api_key account, reprobe the key against the candidate regions and, if another region accepts it, persist that region and retry the call once. Auth errors are raised before any streaming callback fires, so the single retry cannot double-emit output to the client. - config: add UpdateAccountRegion to persist a re-detected region - kiro: split callKiroEndpointsOnce out of CallKiroAPI and wrap it with the api_key region-failover retry - kiro_api: add reprobeApiKeyRegion using the same probe as the add-key flow - tests: cover redetect+persist, dead-key, and non-api_key skip paths --- config/config.go | 18 +++++++ proxy/kiro.go | 20 +++++++ proxy/kiro_api.go | 47 +++++++++++++++++ proxy/kiro_apikey_region_test.go | 91 ++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+) create mode 100644 proxy/kiro_apikey_region_test.go 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") + } +} From efb9594662dfc39c1f841a3c2f4322d7d77e18ad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ph=E1=BA=A1m=20Minh=20=C4=90=E1=BB=93ng?= Date: Wed, 15 Jul 2026 17:36:42 +0700 Subject: [PATCH 2/2] feat(web): add Vietnamese (vi) localization as a third UI language Adds a full Vietnamese locale alongside the existing Chinese (default) and English translations, and makes the language switching logic language-agnostic instead of a hardcoded zh/en binary. - web/locales/vi.json: complete 512-key Vietnamese translation - en.json / zh.json: add lang.vi plus uptime.d/h/m/s and models.familyOther keys (previously hardcoded in app.js) - index.html: add a VI button to both language switchers - app.js: language toggle now cycles zh -> en -> vi; formatUptime and the model-family "Other" label use translation keys instead of an inline zh/en check All three locales verified to share an identical 512-key set. --- web/app.js | 16 +- web/index.html | 2 + web/locales/en.json | 6 + web/locales/vi.json | 514 ++++++++++++++++++++++++++++++++++++++++++++ web/locales/zh.json | 6 + 5 files changed, 537 insertions(+), 7 deletions(-) create mode 100644 web/locales/vi.json 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 @@
+
+