diff --git a/cmd/gateway_system_config_sync.go b/cmd/gateway_system_config_sync.go index 95a49b8a..096f2b3e 100644 --- a/cmd/gateway_system_config_sync.go +++ b/cmd/gateway_system_config_sync.go @@ -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) diff --git a/internal/config/config_channels.go b/internal/config/config_channels.go index 8ffca30c..f63440d0 100644 --- a/internal/config/config_channels.go +++ b/internal/config/config_channels.go @@ -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. diff --git a/internal/config/config_system.go b/internal/config/config_system.go index 956b2019..a93d92a4 100644 --- a/internal/config/config_system.go +++ b/internal/config/config_system.go @@ -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) diff --git a/internal/http/provider_models.go b/internal/http/provider_models.go index aaca05a6..5a1f1a3d 100644 --- a/internal/http/provider_models.go +++ b/internal/http/provider_models.go @@ -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 diff --git a/internal/http/provider_request_timeout.go b/internal/http/provider_request_timeout.go new file mode 100644 index 00000000..2158308e --- /dev/null +++ b/internal/http/provider_request_timeout.go @@ -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 +} diff --git a/internal/http/provider_request_timeout_test.go b/internal/http/provider_request_timeout_test.go new file mode 100644 index 00000000..79e2c3d5 --- /dev/null +++ b/internal/http/provider_request_timeout_test.go @@ -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) +} diff --git a/internal/http/provider_verify.go b/internal/http/provider_verify.go index 8b3e402e..b55078fa 100644 --- a/internal/http/provider_verify.go +++ b/internal/http/provider_verify.go @@ -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) diff --git a/ui/web/src/components/layout/system-settings-constants.ts b/ui/web/src/components/layout/system-settings-constants.ts index 5f9a7833..645c6499 100644 --- a/ui/web/src/components/layout/system-settings-constants.ts +++ b/ui/web/src/components/layout/system-settings-constants.ts @@ -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, diff --git a/ui/web/src/components/layout/system-settings-modal.tsx b/ui/web/src/components/layout/system-settings-modal.tsx index 7270d112..6aed12fa 100644 --- a/ui/web/src/components/layout/system-settings-modal.tsx +++ b/ui/web/src/components/layout/system-settings-modal.tsx @@ -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 { 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")} /> +
+ + setProviderRequestTimeoutSec(e.target.value)} className="max-w-[120px] text-base md:text-sm" /> +

{t("providers.requestTimeoutHint")}

+
{t("bg.info")}
diff --git a/ui/web/src/i18n/locales/en/system-settings.json b/ui/web/src/i18n/locales/en/system-settings.json index 07f3423a..52af3739 100644 --- a/ui/web/src/i18n/locales/en/system-settings.json +++ b/ui/web/src/i18n/locales/en/system-settings.json @@ -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.", diff --git a/ui/web/src/i18n/locales/ko/system-settings.json b/ui/web/src/i18n/locales/ko/system-settings.json index 08b76620..fa014811 100644 --- a/ui/web/src/i18n/locales/ko/system-settings.json +++ b/ui/web/src/i18n/locales/ko/system-settings.json @@ -68,6 +68,10 @@ "modelPlaceholder": "(기본값)", "info": "볼트 보강 (문서 요약), 통합 (세션 요약), 드리밍에 사용됩니다. 에이전트 기본 공급자로 대체하려면 비워두세요." }, + "providers": { + "requestTimeout": "공급자 요청 시간 초과 (초)", + "requestTimeoutHint": "모델 검증 및 사용 가능한 모델 나열에 대한 시간 초과 (기본값 30). 느린 모델의 경우 늘리세요. 기본값을 사용하려면 비워두세요." + }, "compaction": { "title": "대기 메시지 압축", "description": "컨텍스트 한도 내에 유지하기 위해 긴 대기 메시지 기록을 요약합니다.", diff --git a/ui/web/src/i18n/locales/vi/system-settings.json b/ui/web/src/i18n/locales/vi/system-settings.json index d1a41614..b2d77f09 100644 --- a/ui/web/src/i18n/locales/vi/system-settings.json +++ b/ui/web/src/i18n/locales/vi/system-settings.json @@ -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.", diff --git a/ui/web/src/i18n/locales/zh/system-settings.json b/ui/web/src/i18n/locales/zh/system-settings.json index 8ba4a5bc..7e5c7ad0 100644 --- a/ui/web/src/i18n/locales/zh/system-settings.json +++ b/ui/web/src/i18n/locales/zh/system-settings.json @@ -62,6 +62,10 @@ "modelPlaceholder": "(默认)", "info": "用于知识库文档摘要、会话整合和梦境。留空则回退到Agent默认Provider。" }, + "providers": { + "requestTimeout": "Provider请求超时(秒)", + "requestTimeoutHint": "用于模型验证和列出可用模型的超时时间(默认30)。慢速模型请调大。留空则使用默认值。" + }, "skills": { "title": "技能", "description": "租户技能包和斜杠命令设置。",