feat: add INVALID_MODEL_ID retry config + detailed request logging

- Config: new InvalidModelRetries field (default 3, range 0-20)
- Admin API: /admin/api/general GET/POST for general settings
- Admin UI: new "通用设置" card with retry count input
- CallKiroAPI: same-endpoint retry on HTTP 400 INVALID_MODEL_ID
  before falling back to next endpoint
- CallKiroAPI: switched to log.Printf with timestamp, account,
  model, attempt counter, elapsed time, error body truncation
This commit is contained in:
2026-05-11 19:15:49 +08:00
parent 834890f4be
commit 3b791a6926
4 changed files with 214 additions and 48 deletions

View File

@@ -108,6 +108,9 @@ type Config struct {
// Endpoint configuration: "auto", "codewhisperer", or "amazonq"
PreferredEndpoint string `json:"preferredEndpoint,omitempty"`
// General behavior settings
InvalidModelRetries int `json:"invalidModelRetries,omitempty"` // Same-endpoint retry count on INVALID_MODEL_ID (default: 3)
// Global statistics (persisted across restarts)
TotalRequests int `json:"totalRequests,omitempty"` // Total API requests received
SuccessRequests int `json:"successRequests,omitempty"` // Successful requests count
@@ -445,6 +448,30 @@ func UpdatePreferredEndpoint(endpoint string) error {
return Save()
}
// GetInvalidModelRetries 返回 INVALID_MODEL_ID 同端点重试次数(默认 3
func GetInvalidModelRetries() int {
cfgLock.RLock()
defer cfgLock.RUnlock()
if cfg.InvalidModelRetries < 0 {
return 0
}
if cfg.InvalidModelRetries == 0 {
return 3
}
return cfg.InvalidModelRetries
}
// UpdateInvalidModelRetries 更新 INVALID_MODEL_ID 同端点重试次数
func UpdateInvalidModelRetries(n int) error {
cfgLock.Lock()
defer cfgLock.Unlock()
if n < 0 {
n = 0
}
cfg.InvalidModelRetries = n
return Save()
}
type KiroClientConfig struct {
KiroVersion string
SystemVersion string

View File

@@ -1781,6 +1781,10 @@ func (h *Handler) handleAdminAPI(w http.ResponseWriter, r *http.Request) {
h.apiGetEndpointConfig(w, r)
case path == "/endpoint" && r.Method == "POST":
h.apiUpdateEndpointConfig(w, r)
case path == "/general" && r.Method == "GET":
h.apiGetGeneralConfig(w, r)
case path == "/general" && r.Method == "POST":
h.apiUpdateGeneralConfig(w, r)
case path == "/version" && r.Method == "GET":
h.apiGetVersion(w, r)
case path == "/export" && r.Method == "POST":
@@ -2745,6 +2749,41 @@ func (h *Handler) apiUpdateEndpointConfig(w http.ResponseWriter, r *http.Request
json.NewEncoder(w).Encode(map[string]bool{"success": true})
}
// apiGetGeneralConfig 获取通用设置
func (h *Handler) apiGetGeneralConfig(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]interface{}{
"invalidModelRetries": config.GetInvalidModelRetries(),
})
}
// apiUpdateGeneralConfig 更新通用设置
func (h *Handler) apiUpdateGeneralConfig(w http.ResponseWriter, r *http.Request) {
var req struct {
InvalidModelRetries *int `json:"invalidModelRetries"`
}
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 req.InvalidModelRetries != nil {
n := *req.InvalidModelRetries
if n < 0 || n > 20 {
w.WriteHeader(400)
json.NewEncoder(w).Encode(map[string]string{"error": "invalidModelRetries must be 0-20"})
return
}
if err := config.UpdateInvalidModelRetries(n); 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})
}
// apiGetVersion 获取版本信息
func (h *Handler) apiGetVersion(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(map[string]string{

View File

@@ -8,6 +8,7 @@ import (
"fmt"
"io"
"kiro-go/config"
"log"
"net/http"
"net/url"
"strconv"
@@ -163,73 +164,130 @@ func CallKiroAPI(account *config.Account, payload *KiroPayload, callback *KiroSt
return err
}
modelID := payload.ConversationState.CurrentMessage.UserInputMessage.ModelID
accountLabel := account.Email
if accountLabel == "" {
accountLabel = account.ID
}
// 根据配置排序端点
endpoints := getSortedEndpoints(config.GetPreferredEndpoint())
invalidModelRetries := config.GetInvalidModelRetries()
endpointNames := make([]string, 0, len(endpoints))
for _, ep := range endpoints {
endpointNames = append(endpointNames, ep.Name)
}
log.Printf("[KiroAPI] request start account=%s model=%q endpoints=[%s] invalid_model_retries=%d", accountLabel, modelID, strings.Join(endpointNames, ","), invalidModelRetries)
requestStart := time.Now()
var lastErr error
for _, ep := range endpoints {
// 更新 payload 中的 origin
payload.ConversationState.CurrentMessage.UserInputMessage.Origin = ep.Origin
reqBody, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", ep.URL, bytes.NewReader(reqBody))
if err != nil {
lastErr = err
continue
}
host := ""
if parsedURL, parseErr := url.Parse(ep.URL); parseErr == nil {
host = parsedURL.Host
}
headerValues := buildStreamingHeaderValues(account, host)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("X-Amz-Target", ep.AmzTarget)
applyKiroBaseHeaders(req, account, headerValues)
req.Header.Set("x-amzn-kiro-agent-mode", "vibe")
req.Header.Set("x-amzn-codewhisperer-optout", "true")
req.Header.Set("Amz-Sdk-Request", "attempt=1; max=3")
req.Header.Set("Amz-Sdk-Invocation-Id", uuid.New().String())
resp, err := kiroHttpClient.Do(req)
if err != nil {
lastErr = err
fmt.Printf("[KiroAPI] Endpoint %s failed: %v\n", ep.Name, err)
continue
}
if resp.StatusCode == 429 {
resp.Body.Close()
fmt.Printf("[KiroAPI] Endpoint %s quota exhausted (429), trying next...\n", ep.Name)
lastErr = fmt.Errorf("quota exhausted on %s", ep.Name)
continue
}
if resp.StatusCode != 200 {
errBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, ep.Name, string(errBody))
// 认证错误不继续尝试
if resp.StatusCode == 401 || resp.StatusCode == 403 {
return lastErr
// 单端点内重试循环INVALID_MODEL_ID 时同端点重试
maxAttempts := invalidModelRetries + 1
shouldFallback := false
for attempt := 1; attempt <= maxAttempts; attempt++ {
reqBody, _ := json.Marshal(payload)
req, err := http.NewRequest("POST", ep.URL, bytes.NewReader(reqBody))
if err != nil {
lastErr = err
shouldFallback = true
break
}
fmt.Printf("[KiroAPI] Endpoint %s error: %v\n", ep.Name, lastErr)
continue
host := ""
if parsedURL, parseErr := url.Parse(ep.URL); parseErr == nil {
host = parsedURL.Host
}
headerValues := buildStreamingHeaderValues(account, host)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "*/*")
req.Header.Set("X-Amz-Target", ep.AmzTarget)
applyKiroBaseHeaders(req, account, headerValues)
req.Header.Set("x-amzn-kiro-agent-mode", "vibe")
req.Header.Set("x-amzn-codewhisperer-optout", "true")
req.Header.Set("Amz-Sdk-Request", fmt.Sprintf("attempt=%d; max=%d", attempt, maxAttempts))
req.Header.Set("Amz-Sdk-Invocation-Id", uuid.New().String())
attemptStart := time.Now()
log.Printf("[KiroAPI] try endpoint=%s attempt=%d/%d account=%s model=%q origin=%s", ep.Name, attempt, maxAttempts, accountLabel, modelID, ep.Origin)
resp, err := kiroHttpClient.Do(req)
if err != nil {
lastErr = err
log.Printf("[KiroAPI] endpoint=%s attempt=%d/%d account=%s model=%q transport_error elapsed=%s err=%v", ep.Name, attempt, maxAttempts, accountLabel, modelID, time.Since(attemptStart), err)
shouldFallback = true
break
}
if resp.StatusCode == 429 {
resp.Body.Close()
log.Printf("[KiroAPI] endpoint=%s attempt=%d/%d account=%s model=%q status=429 quota_exhausted elapsed=%s", ep.Name, attempt, maxAttempts, accountLabel, modelID, time.Since(attemptStart))
lastErr = fmt.Errorf("quota exhausted on %s", ep.Name)
shouldFallback = true
break
}
if resp.StatusCode != 200 {
errBody, _ := io.ReadAll(resp.Body)
resp.Body.Close()
lastErr = fmt.Errorf("HTTP %d from %s: %s", resp.StatusCode, ep.Name, string(errBody))
bodyStr := string(errBody)
// 认证错误不继续尝试
if resp.StatusCode == 401 || resp.StatusCode == 403 {
log.Printf("[KiroAPI] endpoint=%s attempt=%d/%d account=%s model=%q status=%d auth_error elapsed=%s body=%s", ep.Name, attempt, maxAttempts, accountLabel, modelID, resp.StatusCode, time.Since(attemptStart), truncateForLog(bodyStr, 300))
return lastErr
}
// INVALID_MODEL_ID: 同端点再试
if resp.StatusCode == 400 && strings.Contains(bodyStr, "INVALID_MODEL_ID") {
if attempt < maxAttempts {
log.Printf("[KiroAPI] endpoint=%s attempt=%d/%d account=%s model=%q status=400 INVALID_MODEL_ID retrying elapsed=%s", ep.Name, attempt, maxAttempts, accountLabel, modelID, time.Since(attemptStart))
continue
}
log.Printf("[KiroAPI] endpoint=%s attempt=%d/%d account=%s model=%q status=400 INVALID_MODEL_ID exhausted, fallback elapsed=%s", ep.Name, attempt, maxAttempts, accountLabel, modelID, time.Since(attemptStart))
shouldFallback = true
break
}
log.Printf("[KiroAPI] endpoint=%s attempt=%d/%d account=%s model=%q status=%d elapsed=%s body=%s", ep.Name, attempt, maxAttempts, accountLabel, modelID, resp.StatusCode, time.Since(attemptStart), truncateForLog(bodyStr, 300))
shouldFallback = true
break
}
log.Printf("[KiroAPI] endpoint=%s attempt=%d/%d account=%s model=%q status=200 headers_elapsed=%s, streaming...", ep.Name, attempt, maxAttempts, accountLabel, modelID, time.Since(attemptStart))
err = parseEventStream(resp.Body, callback)
resp.Body.Close()
log.Printf("[KiroAPI] endpoint=%s account=%s model=%q done total_elapsed=%s err=%v", ep.Name, accountLabel, modelID, time.Since(requestStart), err)
return err
}
err = parseEventStream(resp.Body, callback)
resp.Body.Close()
return err
if !shouldFallback {
break
}
}
log.Printf("[KiroAPI] all endpoints failed account=%s model=%q total_elapsed=%s last_err=%v", accountLabel, modelID, time.Since(requestStart), lastErr)
if lastErr != nil {
return lastErr
}
return fmt.Errorf("all endpoints failed")
}
func truncateForLog(s string, max int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) <= max {
return s
}
return s[:max] + "...(truncated)"
}
// ==================== Event Stream 解析 ====================
// parseEventStream 解析 AWS Event Stream 二进制格式

View File

@@ -969,6 +969,17 @@
data-i18n-placeholder="settings.apiKeyPlaceholder"><button class="btn btn-sm btn-secondary" onclick="generateApiKey()" data-i18n="settings.generateApiKey"></button></div></div>
<button class="btn btn-primary" onclick="saveSettings()" data-i18n="common.save"></button>
</div>
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="settings.generalSettings"></span></div>
<div class="form-group">
<label data-i18n="settings.invalidModelRetries"></label>
<input type="number" id="invalidModelRetries" min="0" max="20" step="1" placeholder="3">
<small style="color:#64748b;font-size:12px;margin-top:4px;display:block"
data-i18n="settings.invalidModelRetriesHint"></small>
</div>
<button class="btn btn-primary" onclick="saveGeneralConfig()"
data-i18n="settings.saveGeneral"></button>
</div>
<div class="card">
<div class="card-header"><span class="card-title" data-i18n="settings.thinkingSettings"></span></div>
<div class="form-group">
@@ -1123,6 +1134,11 @@
'settings.enableApiKey': '启用 API Key 验证',
'settings.apiKeyPlaceholder': '留空则不验证',
'settings.generateApiKey': '随机生成',
'settings.generalSettings': '通用设置',
'settings.invalidModelRetries': 'INVALID_MODEL_ID 同端点重试次数',
'settings.invalidModelRetriesHint': '当上游返回 INVALID_MODEL_IDHTTP 400先在当前端点重试 N 次后再 fallback 到下一个端点。默认 3范围 0-20',
'settings.saveGeneral': '保存通用设置',
'settings.generalSaved': '通用设置已保存',
'settings.thinkingSettings': 'Thinking 模式设置',
'settings.thinkingSuffix': '触发后缀',
'settings.thinkingSuffixHint': '模型名称加此后缀即启用思考模式,如 claude-sonnet-4.5-thinking',
@@ -1329,6 +1345,11 @@
'settings.enableApiKey': 'Enable API Key Verification',
'settings.apiKeyPlaceholder': 'Leave empty to disable',
'settings.generateApiKey': 'Generate',
'settings.generalSettings': 'General Settings',
'settings.invalidModelRetries': 'INVALID_MODEL_ID same-endpoint retries',
'settings.invalidModelRetriesHint': 'When upstream returns INVALID_MODEL_ID (HTTP 400), retry the current endpoint N times before falling back. Default 3, range 0-20',
'settings.saveGeneral': 'Save General Settings',
'settings.generalSaved': 'General settings saved',
'settings.thinkingSettings': 'Thinking Mode Settings',
'settings.thinkingSuffix': 'Trigger Suffix',
'settings.thinkingSuffixHint': 'Add this suffix to model name to enable thinking mode, e.g. claude-sonnet-4.5-thinking',
@@ -1991,6 +2012,7 @@
document.getElementById('apiKeyInput').value = d.apiKey || '';
loadThinkingConfig();
loadEndpointConfig();
loadGeneralConfig();
}
async function loadThinkingConfig() {
const res = await fetch('/admin/api/thinking', { headers: { 'X-Admin-Password': password } });
@@ -2020,6 +2042,26 @@
const d = await res.json();
if (d.success) { alert(t('settings.endpointSaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); }
}
async function loadGeneralConfig() {
const res = await fetch('/admin/api/general', { headers: { 'X-Admin-Password': password } });
const d = await res.json();
const v = (d && typeof d.invalidModelRetries === 'number') ? d.invalidModelRetries : 3;
document.getElementById('invalidModelRetries').value = v;
}
async function saveGeneralConfig() {
const raw = document.getElementById('invalidModelRetries').value;
const n = parseInt(raw, 10);
if (isNaN(n) || n < 0 || n > 20) {
alert(t('common.saveFailed') + ': 0-20');
return;
}
const res = await fetch('/admin/api/general', {
method: 'POST', headers: { 'Content-Type': 'application/json', 'X-Admin-Password': password },
body: JSON.stringify({ invalidModelRetries: n })
});
const d = await res.json();
if (d.success) { alert(t('settings.generalSaved')); } else { alert(t('common.saveFailed') + ': ' + d.error); }
}
function generateApiKey() {
const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
let key = 'sk-';