修复 LLM Provider 配置热更新问题
问题:在 /admin/llm/api 页面修改 AI 提供商配置后,配置没有立即生效,AI 依然使用旧的提供商工作 根本原因: - LLM Provider 配置在程序启动时加载到内存 (llm.State) - 管理后台修改配置时,只更新了数据库,内存配置未更新 - AI 继续使用内存中的旧配置 解决方案: 1. 为 llm.State 添加动态更新方法 (UpdateProvider/AddProvider/RemoveProvider) 2. 在 AI Service 中暴露配置更新接口,支持运行时重新加载 3. 在配置保存后自动触发内存配置重新加载 4. 创建新的 LLM Client 使新配置立即生效 关键特性: - 线程安全:使用 sync.RWMutex 保护并发访问 - 容错处理:重新加载失败不影响数据库更新 - 无需重启:配置修改后立即生效 - 完整测试:添加单元测试验证功能 修改文件: - internal/llm/state.go: 添加配置更新方法 - internal/ai/service.go: 添加配置重新加载接口 - internal/llmadmin/admin_llm_routes.go: 配置更新时触发重新加载 - internal/web/web.go: 传递 AI Service 到路由 - main.go: 连接组件 - internal/llm/state_test.go: 新增单元测试 - internal/web/map_tile_proxy_routes_test.go: 修复测试 测试: - ✅ 所有现有测试通过 - ✅ 新增测试覆盖核心功能 - ✅ 项目成功编译 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -135,3 +135,137 @@ func (s *State) ListProfiles() []ProviderConfig {
|
||||
}
|
||||
return profiles
|
||||
}
|
||||
|
||||
// UpdateProvider updates an existing provider's configuration
|
||||
func (s *State) UpdateProvider(config ProviderConfig) error {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
if strings.TrimSpace(config.APIKey) == "" {
|
||||
return fmt.Errorf("llm provider %s api_key is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.Model) == "" {
|
||||
return fmt.Errorf("llm provider %s model is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return fmt.Errorf("llm provider %s base_url is required", name)
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 120
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; !ok {
|
||||
return fmt.Errorf("llm provider not found: %s", name)
|
||||
}
|
||||
|
||||
// Create new client with updated config
|
||||
s.profiles[name] = &Profile{
|
||||
Config: config,
|
||||
Client: ark.NewClientWithApiKey(
|
||||
config.APIKey,
|
||||
ark.WithBaseUrl(config.BaseURL),
|
||||
ark.WithTimeout(time.Duration(config.Timeout)*time.Second),
|
||||
),
|
||||
}
|
||||
|
||||
// Update active status if needed
|
||||
if config.Active && s.activeName != name {
|
||||
s.activeName = name
|
||||
} else if !config.Active && s.activeName == name {
|
||||
// If we're deactivating the current active provider, switch to the first available
|
||||
for _, otherName := range s.order {
|
||||
if otherName != name {
|
||||
s.activeName = otherName
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddProvider adds a new provider to the state
|
||||
func (s *State) AddProvider(config ProviderConfig) error {
|
||||
name := strings.TrimSpace(config.Name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
if strings.TrimSpace(config.APIKey) == "" {
|
||||
return fmt.Errorf("llm provider %s api_key is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.Model) == "" {
|
||||
return fmt.Errorf("llm provider %s model is required", name)
|
||||
}
|
||||
if strings.TrimSpace(config.BaseURL) == "" {
|
||||
return fmt.Errorf("llm provider %s base_url is required", name)
|
||||
}
|
||||
if config.Timeout <= 0 {
|
||||
config.Timeout = 120
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; ok {
|
||||
return fmt.Errorf("llm provider already exists: %s", name)
|
||||
}
|
||||
|
||||
s.profiles[name] = &Profile{
|
||||
Config: config,
|
||||
Client: ark.NewClientWithApiKey(
|
||||
config.APIKey,
|
||||
ark.WithBaseUrl(config.BaseURL),
|
||||
ark.WithTimeout(time.Duration(config.Timeout)*time.Second),
|
||||
),
|
||||
}
|
||||
s.order = append(s.order, name)
|
||||
|
||||
// Set as active if it's the first one or explicitly marked active
|
||||
if len(s.profiles) == 1 || config.Active {
|
||||
s.activeName = name
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RemoveProvider removes a provider from the state
|
||||
func (s *State) RemoveProvider(name string) error {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" {
|
||||
return errors.New("llm provider name cannot be empty")
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
|
||||
if _, ok := s.profiles[name]; !ok {
|
||||
return fmt.Errorf("llm provider not found: %s", name)
|
||||
}
|
||||
|
||||
// Don't allow removing the last provider
|
||||
if len(s.profiles) == 1 {
|
||||
return errors.New("cannot remove the last llm provider")
|
||||
}
|
||||
|
||||
delete(s.profiles, name)
|
||||
|
||||
// Remove from order
|
||||
newOrder := make([]string, 0, len(s.order)-1)
|
||||
for _, n := range s.order {
|
||||
if n != name {
|
||||
newOrder = append(newOrder, n)
|
||||
}
|
||||
}
|
||||
s.order = newOrder
|
||||
|
||||
// If we removed the active provider, switch to the first available
|
||||
if s.activeName == name {
|
||||
s.activeName = s.order[0]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestUpdateProvider(t *testing.T) {
|
||||
// Create initial state with one provider
|
||||
configs := []ProviderConfig{
|
||||
{
|
||||
Name: "test-provider",
|
||||
Active: true,
|
||||
APIKey: "test-key",
|
||||
BaseURL: "https://test.example.com",
|
||||
Model: "test-model",
|
||||
Timeout: 120,
|
||||
ContextWindowTokens: 4096,
|
||||
},
|
||||
}
|
||||
|
||||
state, err := NewState(configs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create state: %v", err)
|
||||
}
|
||||
|
||||
// Get the initial profile
|
||||
profile := state.ActiveProfile()
|
||||
if profile.Config.APIKey != "test-key" {
|
||||
t.Errorf("expected APIKey 'test-key', got '%s'", profile.Config.APIKey)
|
||||
}
|
||||
|
||||
// Update the provider with new config
|
||||
updatedConfig := ProviderConfig{
|
||||
Name: "test-provider",
|
||||
Active: true,
|
||||
APIKey: "new-key",
|
||||
BaseURL: "https://new.example.com",
|
||||
Model: "new-model",
|
||||
Timeout: 60,
|
||||
ContextWindowTokens: 8192,
|
||||
}
|
||||
|
||||
err = state.UpdateProvider(updatedConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to update provider: %v", err)
|
||||
}
|
||||
|
||||
// Verify the update
|
||||
profile = state.ActiveProfile()
|
||||
if profile.Config.APIKey != "new-key" {
|
||||
t.Errorf("expected updated APIKey 'new-key', got '%s'", profile.Config.APIKey)
|
||||
}
|
||||
if profile.Config.BaseURL != "https://new.example.com" {
|
||||
t.Errorf("expected updated BaseURL 'https://new.example.com', got '%s'", profile.Config.BaseURL)
|
||||
}
|
||||
if profile.Config.Model != "new-model" {
|
||||
t.Errorf("expected updated Model 'new-model', got '%s'", profile.Config.Model)
|
||||
}
|
||||
if profile.Config.Timeout != 60 {
|
||||
t.Errorf("expected updated Timeout 60, got %d", profile.Config.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddProvider(t *testing.T) {
|
||||
// Create initial state with one provider
|
||||
configs := []ProviderConfig{
|
||||
{
|
||||
Name: "provider1",
|
||||
Active: true,
|
||||
APIKey: "key1",
|
||||
BaseURL: "https://example1.com",
|
||||
Model: "model1",
|
||||
Timeout: 120,
|
||||
ContextWindowTokens: 4096,
|
||||
},
|
||||
}
|
||||
|
||||
state, err := NewState(configs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create state: %v", err)
|
||||
}
|
||||
|
||||
// Add a second provider
|
||||
newConfig := ProviderConfig{
|
||||
Name: "provider2",
|
||||
Active: false,
|
||||
APIKey: "key2",
|
||||
BaseURL: "https://example2.com",
|
||||
Model: "model2",
|
||||
Timeout: 60,
|
||||
ContextWindowTokens: 8192,
|
||||
}
|
||||
|
||||
err = state.AddProvider(newConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to add provider: %v", err)
|
||||
}
|
||||
|
||||
// Verify the new provider exists
|
||||
profile, err := state.GetProfile("provider2")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to get provider2: %v", err)
|
||||
}
|
||||
if profile.Config.Name != "provider2" {
|
||||
t.Errorf("expected name 'provider2', got '%s'", profile.Config.Name)
|
||||
}
|
||||
|
||||
// Verify active provider is still provider1
|
||||
activeProfile := state.ActiveProfile()
|
||||
if activeProfile.Config.Name != "provider1" {
|
||||
t.Errorf("expected active provider 'provider1', got '%s'", activeProfile.Config.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveProvider(t *testing.T) {
|
||||
// Create initial state with two providers
|
||||
configs := []ProviderConfig{
|
||||
{
|
||||
Name: "provider1",
|
||||
Active: true,
|
||||
APIKey: "key1",
|
||||
BaseURL: "https://example1.com",
|
||||
Model: "model1",
|
||||
Timeout: 120,
|
||||
ContextWindowTokens: 4096,
|
||||
},
|
||||
{
|
||||
Name: "provider2",
|
||||
Active: false,
|
||||
APIKey: "key2",
|
||||
BaseURL: "https://example2.com",
|
||||
Model: "model2",
|
||||
Timeout: 60,
|
||||
ContextWindowTokens: 8192,
|
||||
},
|
||||
}
|
||||
|
||||
state, err := NewState(configs)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create state: %v", err)
|
||||
}
|
||||
|
||||
// Remove provider2
|
||||
err = state.RemoveProvider("provider2")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to remove provider: %v", err)
|
||||
}
|
||||
|
||||
// Verify provider2 is gone
|
||||
_, err = state.GetProfile("provider2")
|
||||
if err == nil {
|
||||
t.Error("expected error when getting removed provider, got nil")
|
||||
}
|
||||
|
||||
// Try to remove the last provider (should fail)
|
||||
err = state.RemoveProvider("provider1")
|
||||
if err == nil {
|
||||
t.Error("expected error when removing last provider, got nil")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user