Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
20 changes: 20 additions & 0 deletions proxy/kiro.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"io"
"kiro-go/config"
"kiro-go/logger"
"kiro-go/pool"
"net/http"
"net/url"
"regexp"
Expand Down Expand Up @@ -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())

Expand Down
47 changes: 47 additions & 0 deletions proxy/kiro_api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
91 changes: 91 additions & 0 deletions proxy/kiro_apikey_region_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
16 changes: 9 additions & 7 deletions web/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(' ');
}

Expand Down Expand Up @@ -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;
}
Expand Down
2 changes: 2 additions & 0 deletions web/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@
<div class="lang-switch" id="loginLangSwitch" role="group" aria-label="" data-i18n-aria-label="lang.label">
<button class="lang-btn" data-lang="zh" type="button" data-i18n="lang.zh"></button>
<button class="lang-btn" data-lang="en" type="button" data-i18n="lang.en"></button>
<button class="lang-btn" data-lang="vi" type="button" data-i18n="lang.vi"></button>
</div>
<span class="divider" aria-hidden="true"></span>
<button id="loginThemeToggle" class="icon-btn theme-toggle" type="button" aria-label=""
Expand Down Expand Up @@ -115,6 +116,7 @@ <h1 class="login-title" data-i18n="login.title"></h1>
<div class="lang-switch" id="mainLangSwitch" role="group" aria-label="" data-i18n-aria-label="lang.label">
<button class="lang-btn" data-lang="zh" type="button" data-i18n="lang.zh"></button>
<button class="lang-btn" data-lang="en" type="button" data-i18n="lang.en"></button>
<button class="lang-btn" data-lang="vi" type="button" data-i18n="lang.vi"></button>
</div>
<button id="mainThemeToggle" class="icon-btn theme-toggle" type="button" aria-label=""
data-i18n-title="theme.toggle" data-i18n-aria-label="theme.toggle" data-theme="system">
Expand Down
6 changes: 6 additions & 0 deletions web/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,12 @@
"theme.status": "Theme: {0}",
"lang.zh": "中文",
"lang.en": "EN",
"lang.vi": "VI",
"uptime.d": "d",
"uptime.h": "h",
"uptime.m": "m",
"uptime.s": "s",
"models.familyOther": "Other",
"api.claude": "Claude API",
"api.openai": "OpenAI API",
"api.openaiResponses": "OpenAI Responses API",
Expand Down
Loading