feat(providers): make provider request timeout configurable (default 30s) (#1400)

The provider verify call and the provider models-list call used hardcoded
timeouts (30s and 15s) that were too low for slow models. Introduce a
tenant-scoped, UI-editable setting `providers.request_timeout_sec`
(default 30) stored in system_configs, consumed by both handlers, and
overlaid from config.json like tts.timeout_ms. Adds a field to the System
Settings modal (all 4 locales) so operators can raise it without a
redeploy.

Co-authored-by: Bruno Clermont <bruno.clermont@gmail.com>
This commit is contained in:
Bruno ClermontandBruno Clermont authored and GitHub committed 2026-07-09 04:54:15 +07:00
1 parent 951acad714
commit d856a58218
13 files changed
+122 -3

No files matched your search

+3
View File
@@ -127,6 +127,9 @@ func seedConfigForContext(ctx context.Context, sc store.SystemConfigStore, cfg *
setInt("tools.browser.max_pages", cfg.Tools.Browser.MaxPages)
set("tools.browser.cookie_sync_enabled", fmt.Sprintf("%t", cfg.Tools.Browser.CookieSyncEnabled))
// Providers
setInt("providers.request_timeout_sec", cfg.Providers.RequestTimeoutSec)
// TTS
set("tts.provider", cfg.Tts.Provider)
set("tts.auto", cfg.Tts.Auto)
+4
View File
@@ -276,6 +276,10 @@ type ProvidersConfig struct {
BytePlus ProviderConfig `json:"byteplus"` // BytePlus ModelArk (Seed 2.0)
BytePlusCoding ProviderConfig `json:"byteplus_coding"` // BytePlus ModelArk Coding Plan
Vertex VertexConfig `json:"vertex"` // Google Cloud Vertex AI (OAuth2 service account + ADC)
// RequestTimeoutSec bounds provider verify and models-list HTTP calls.
// Tenant-scoped, overridable via the "providers.request_timeout_sec" system config.
RequestTimeoutSec int `json:"request_timeout_sec,omitempty"`
}
// VertexConfig configures Google Cloud Vertex AI.
+3
View File
@@ -86,6 +86,9 @@ func (c *Config) ApplySystemConfigs(configs map[string]string) {
boolValue(SkillSlashPartialMatchingSystemConfigKey, &c.Skills.SlashCommands.PartialMatching)
str(SkillSlashCommandPrefixSystemConfigKey, &c.Skills.SlashCommands.Prefix)
// Providers
integer("providers.request_timeout_sec", &c.Providers.RequestTimeoutSec)
// TTS
str("tts.provider", &c.Tts.Provider)
str("tts.auto", &c.Tts.Auto)
+2 -2
View File
@@ -70,7 +70,7 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
// Ollama: use native /api/tags for richer metadata (parameter size, quantization, family).
// ProviderOllama has no API key; ProviderOllamaCloud requires one but both use the same endpoint.
if p.ProviderType == store.ProviderOllama || p.ProviderType == store.ProviderOllamaCloud {
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(loadProviderRequestTimeoutSec(r.Context(), h.sysConfigStore))*time.Second)
defer cancel()
apiBase := h.resolveAPIBase(p)
if apiBase == "" {
@@ -91,7 +91,7 @@ func (h *ProvidersHandler) handleListProviderModels(w http.ResponseWriter, r *ht
return
}
ctx, cancel := context.WithTimeout(r.Context(), 15*time.Second)
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(loadProviderRequestTimeoutSec(r.Context(), h.sysConfigStore))*time.Second)
defer cancel()
var models []ModelInfo
+29
View File
@@ -0,0 +1,29 @@
package http
import (
"context"
"strconv"
"github.com/nextlevelbuilder/goclaw/internal/store"
)
// defaultProviderRequestTimeoutSec is used when the tenant has not configured
// (or has configured invalid values for) providers.request_timeout_sec.
const defaultProviderRequestTimeoutSec = 30
// loadProviderRequestTimeoutSec reads the tenant's providers.request_timeout_sec
// system config. Returns defaultProviderRequestTimeoutSec (30) when unset/invalid.
func loadProviderRequestTimeoutSec(ctx context.Context, sc store.SystemConfigStore) int {
if sc == nil {
return defaultProviderRequestTimeoutSec
}
raw, err := sc.Get(ctx, "providers.request_timeout_sec")
if err != nil || raw == "" {
return defaultProviderRequestTimeoutSec
}
timeoutSec, err := strconv.Atoi(raw)
if err != nil || timeoutSec <= 0 {
return defaultProviderRequestTimeoutSec
}
return timeoutSec
}
@@ -0,0 +1,52 @@
package http
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
)
// stubProviderTimeoutStore returns a configured value for
// "providers.request_timeout_sec" and ignores everything else.
type stubProviderTimeoutStore struct {
value string
}
func (s *stubProviderTimeoutStore) Get(_ context.Context, key string) (string, error) {
if key == "providers.request_timeout_sec" {
return s.value, nil
}
return "", nil
}
func (s *stubProviderTimeoutStore) Set(_ context.Context, _, _ string) error { return nil }
func (s *stubProviderTimeoutStore) Delete(_ context.Context, _ string) error { return nil }
func (s *stubProviderTimeoutStore) List(_ context.Context) (map[string]string, error) {
return map[string]string{}, nil
}
func TestLoadProviderRequestTimeoutSec(t *testing.T) {
tests := []struct {
name string
store *stubProviderTimeoutStore
want int
}{
{name: "unset falls back to default", store: &stubProviderTimeoutStore{value: ""}, want: defaultProviderRequestTimeoutSec},
{name: "valid value parsed", store: &stubProviderTimeoutStore{value: "45"}, want: 45},
{name: "invalid non-numeric falls back to default", store: &stubProviderTimeoutStore{value: "abc"}, want: defaultProviderRequestTimeoutSec},
{name: "zero falls back to default", store: &stubProviderTimeoutStore{value: "0"}, want: defaultProviderRequestTimeoutSec},
{name: "negative falls back to default", store: &stubProviderTimeoutStore{value: "-5"}, want: defaultProviderRequestTimeoutSec},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := loadProviderRequestTimeoutSec(context.Background(), tt.store)
assert.Equal(t, tt.want, got)
})
}
}
func TestLoadProviderRequestTimeoutSec_NilStore(t *testing.T) {
got := loadProviderRequestTimeoutSec(context.Background(), nil)
assert.Equal(t, defaultProviderRequestTimeoutSec, got)
}
+2 -1
View File
@@ -125,7 +125,8 @@ func (h *ProvidersHandler) handleVerifyProvider(w http.ResponseWriter, r *http.R
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
timeoutSec := loadProviderRequestTimeoutSec(r.Context(), h.sysConfigStore)
ctx, cancel := context.WithTimeout(r.Context(), time.Duration(timeoutSec)*time.Second)
defer cancel()
ctx = store.WithTenantID(ctx, p.TenantID)
@@ -43,6 +43,7 @@ export interface InitState {
kgMinConfidence: string;
bgProvider: string;
bgModel: string;
providerRequestTimeoutSec: string;
skillUploadMaxSize: string;
skillSlashEnabled: boolean;
skillSlashSuggest: boolean;
@@ -59,6 +60,7 @@ export const DEFAULTS: InitState = {
compThreshold: "", compKeepRecent: "", compMaxTokens: "",
kgProvider: "", kgModel: "", kgMinConfidence: "0.75",
bgProvider: "", bgModel: "",
providerRequestTimeoutSec: "",
skillUploadMaxSize: "20",
skillSlashEnabled: true,
skillSlashSuggest: true,
@@ -59,6 +59,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
// Background Workers
const [bgProvider, setBgProvider] = useState("");
const [bgModel, setBgModel] = useState("");
const [providerRequestTimeoutSec, setProviderRequestTimeoutSec] = useState("");
const [skillUploadMaxSize, setSkillUploadMaxSize] = useState("20");
const [skillSlashEnabled, setSkillSlashEnabled] = useState(true);
const [skillSlashSuggest, setSkillSlashSuggest] = useState(true);
@@ -80,6 +81,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
kgProvider: kgSettings?.extraction_provider ?? "", kgModel: kgSettings?.extraction_model ?? "",
kgMinConfidence: String(kgSettings?.min_confidence ?? 0.75),
bgProvider: configs["background.provider"] ?? "", bgModel: configs["background.model"] ?? "",
providerRequestTimeoutSec: configs["providers.request_timeout_sec"] ?? "",
skillUploadMaxSize: configs["skills.max_upload_size_mb"] ?? "20",
skillSlashEnabled: parseBool(configs["skills.slash_commands.enabled"], true),
skillSlashSuggest: parseBool(configs["skills.slash_commands.suggest_not_found"], true),
@@ -93,6 +95,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
setCompProvider(s.compProvider); setCompModel(s.compModel); setCompThreshold(s.compThreshold); setCompKeepRecent(s.compKeepRecent); setCompMaxTokens(s.compMaxTokens);
setKgProvider(s.kgProvider); setKgModel(s.kgModel); setKgMinConfidence(s.kgMinConfidence);
setBgProvider(s.bgProvider); setBgModel(s.bgModel);
setProviderRequestTimeoutSec(s.providerRequestTimeoutSec);
setSkillUploadMaxSize(s.skillUploadMaxSize);
setSkillSlashEnabled(s.skillSlashEnabled);
setSkillSlashSuggest(s.skillSlashSuggest);
@@ -140,6 +143,7 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
if (compMaxTokens !== init.compMaxTokens) updates["compaction.max_tokens"] = compMaxTokens;
if (bgProvider !== init.bgProvider) updates["background.provider"] = bgProvider;
if (bgModel !== init.bgModel) updates["background.model"] = bgModel;
if (providerRequestTimeoutSec !== init.providerRequestTimeoutSec) updates["providers.request_timeout_sec"] = providerRequestTimeoutSec.trim();
if (skillUploadMaxSize !== init.skillUploadMaxSize) updates["skills.max_upload_size_mb"] = skillUploadMaxSize;
if (skillSlashEnabled !== init.skillSlashEnabled) updates["skills.slash_commands.enabled"] = String(skillSlashEnabled);
if (skillSlashSuggest !== init.skillSlashSuggest) updates["skills.slash_commands.suggest_not_found"] = String(skillSlashSuggest);
@@ -213,6 +217,11 @@ export function SystemSettingsModal({ open, onOpenChange }: SystemSettingsModalP
</CardHeader>
<CardContent className="space-y-4 pt-0">
<ProviderModelSelect provider={bgProvider} onProviderChange={(v) => { setBgProvider(v); setBgModel(""); }} model={bgModel} onModelChange={setBgModel} allowEmpty providerLabel={t("bg.provider")} modelLabel={t("bg.model")} providerTip={t("bg.providerTip")} modelTip={t("bg.modelTip")} providerPlaceholder={t("bg.providerPlaceholder")} modelPlaceholder={t("bg.modelPlaceholder")} />
<div className="space-y-1.5">
<Label htmlFor="providerRequestTimeout" className="text-xs">{t("providers.requestTimeout")}</Label>
<Input id="providerRequestTimeout" type="number" min={1} step={1} placeholder="30" value={providerRequestTimeoutSec} onChange={(e) => setProviderRequestTimeoutSec(e.target.value)} className="max-w-[120px] text-base md:text-sm" />
<p className="text-xs text-muted-foreground">{t("providers.requestTimeoutHint")}</p>
</div>
<div className="flex items-start gap-2 rounded-md border border-slate-200 bg-slate-50 px-3 py-2 text-xs text-slate-700 dark:border-slate-700 dark:bg-slate-950/30 dark:text-slate-300">
<Info className="mt-0.5 h-3.5 w-3.5 shrink-0" /><span>{t("bg.info")}</span>
</div>
@@ -62,6 +62,10 @@
"modelPlaceholder": "(default)",
"info": "Used for vault enrichment (document summarization), consolidation (session summaries), and dreaming. Leave empty to fall back to the agent default provider."
},
"providers": {
"requestTimeout": "Provider request timeout (seconds)",
"requestTimeoutHint": "Timeout for model verification and listing available models (default 30). Increase for slow models. Leave empty to use the default."
},
"skills": {
"title": "Skills",
"description": "Tenant skill package and slash command settings.",
@@ -68,6 +68,10 @@
"modelPlaceholder": "(기본값)",
"info": "볼트 보강 (문서 요약), 통합 (세션 요약), 드리밍에 사용됩니다. 에이전트 기본 공급자로 대체하려면 비워두세요."
},
"providers": {
"requestTimeout": "공급자 요청 시간 초과 (초)",
"requestTimeoutHint": "모델 검증 및 사용 가능한 모델 나열에 대한 시간 초과 (기본값 30). 느린 모델의 경우 늘리세요. 기본값을 사용하려면 비워두세요."
},
"compaction": {
"title": "대기 메시지 압축",
"description": "컨텍스트 한도 내에 유지하기 위해 긴 대기 메시지 기록을 요약합니다.",
@@ -62,6 +62,10 @@
"modelPlaceholder": "(mặc định)",
"info": "Dùng cho vault enrichment (tóm tắt tài liệu), consolidation (tóm tắt phiên) và dreaming. Để trống sẽ fallback về agent default provider."
},
"providers": {
"requestTimeout": "Thời gian chờ yêu cầu provider (giây)",
"requestTimeoutHint": "Thời gian chờ để xác minh model và liệt kê các model khả dụng (mặc định 30). Tăng lên cho các model chậm. Để trống để dùng giá trị mặc định."
},
"skills": {
"title": "Skills",
"description": "Cài đặt gói skill và slash command theo tenant.",
@@ -62,6 +62,10 @@
"modelPlaceholder": "(默认)",
"info": "用于知识库文档摘要、会话整合和梦境。留空则回退到Agent默认Provider。"
},
"providers": {
"requestTimeout": "Provider请求超时(秒)",
"requestTimeoutHint": "用于模型验证和列出可用模型的超时时间(默认30)。慢速模型请调大。留空则使用默认值。"
},
"skills": {
"title": "技能",
"description": "租户技能包和斜杠命令设置。",