修复 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:
@@ -0,0 +1,113 @@
|
|||||||
|
# LLM Provider 配置热更新修复
|
||||||
|
|
||||||
|
## 问题描述
|
||||||
|
|
||||||
|
在 `/admin/llm/api` 页面修改 AI 提供商配置后,配置没有立即生效,AI 依然使用旧的提供商在工作。
|
||||||
|
|
||||||
|
## 根本原因
|
||||||
|
|
||||||
|
1. **LLM Provider 配置在程序启动时加载到内存** (`llm.State`)
|
||||||
|
2. **通过管理后台修改配置时,只更新了数据库**,内存中的配置没有更新
|
||||||
|
3. **AI 继续使用内存中的旧配置**工作
|
||||||
|
|
||||||
|
## 解决方案
|
||||||
|
|
||||||
|
### 1. 为 `llm.State` 添加动态更新方法
|
||||||
|
|
||||||
|
在 `internal/llm/state.go` 中添加了三个新方法:
|
||||||
|
|
||||||
|
- **`UpdateProvider(config ProviderConfig) error`** - 更新现有 provider 的配置
|
||||||
|
- **`AddProvider(config ProviderConfig) error`** - 添加新的 provider
|
||||||
|
- **`RemoveProvider(name string) error`** - 删除 provider
|
||||||
|
|
||||||
|
这些方法会:
|
||||||
|
- 更新内存中的配置
|
||||||
|
- 重新创建 LLM Client(使用新的 API Key、BaseURL 等)
|
||||||
|
- 管理 active provider 的切换
|
||||||
|
|
||||||
|
### 2. 在 AI Service 中暴露配置更新接口
|
||||||
|
|
||||||
|
在 `internal/ai/service.go` 中添加了:
|
||||||
|
|
||||||
|
```go
|
||||||
|
func (s *Service) ReloadLLMProvider(config interface{}) error
|
||||||
|
func (s *Service) AddLLMProvider(config interface{}) error
|
||||||
|
func (s *Service) RemoveLLMProvider(name string) error
|
||||||
|
```
|
||||||
|
|
||||||
|
这些方法接受 `interface{}` 类型(来自 HTTP handler 的 map),转换为 `llm.ProviderConfig`,然后调用 `llm.State` 的相应方法。
|
||||||
|
|
||||||
|
### 3. 在管理路由中触发配置重新加载
|
||||||
|
|
||||||
|
修改了 `internal/llmadmin/admin_llm_routes.go`:
|
||||||
|
|
||||||
|
- 添加了 `LLMProviderReloader` 接口
|
||||||
|
- `RegisterRoutes` 接受 `aiService LLMProviderReloader` 参数
|
||||||
|
- `handleCreateLLMProvider`、`handleUpdateLLMProvider`、`handleDeleteLLMProvider` 在数据库操作成功后,调用 `aiService` 的相应方法更新内存配置
|
||||||
|
|
||||||
|
### 4. 连接所有组件
|
||||||
|
|
||||||
|
修改了:
|
||||||
|
- `internal/web/web.go` - 添加 `LLMProviderReloader` 接口,并在路由注册时传递 `aiService`
|
||||||
|
- `main.go` - 将 `aiService` 传递给 web router
|
||||||
|
|
||||||
|
## 关键特性
|
||||||
|
|
||||||
|
### 线程安全
|
||||||
|
所有 `llm.State` 的更新操作都使用 `sync.RWMutex` 保护,确保并发安全。
|
||||||
|
|
||||||
|
### 容错处理
|
||||||
|
如果配置重新加载失败(例如 AI Service 未启用),不会导致请求失败。数据库已经更新,只会返回一个警告信息。
|
||||||
|
|
||||||
|
### Active Provider 管理
|
||||||
|
- 更新 provider 为 active 时,自动切换到该 provider
|
||||||
|
- 删除当前 active provider 时,自动切换到第一个可用的 provider
|
||||||
|
- 不允许删除最后一个 provider
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
创建了 `internal/llm/state_test.go`,包含以下测试:
|
||||||
|
- `TestUpdateProvider` - 验证配置更新功能
|
||||||
|
- `TestAddProvider` - 验证添加新 provider
|
||||||
|
- `TestRemoveProvider` - 验证删除 provider
|
||||||
|
|
||||||
|
所有测试都通过 ✅
|
||||||
|
|
||||||
|
## 使用效果
|
||||||
|
|
||||||
|
修复后,在 `/admin/llm/api` 页面修改 AI 提供商配置时:
|
||||||
|
1. 配置保存到数据库
|
||||||
|
2. 立即更新内存中的配置
|
||||||
|
3. 创建新的 LLM Client
|
||||||
|
4. **无需重启服务**,配置立即生效
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
### 修改的文件
|
||||||
|
- `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` - 单元测试
|
||||||
|
|
||||||
|
### 兼容性
|
||||||
|
- 完全向后兼容
|
||||||
|
- 如果 AI Service 未启用(`aiService == nil`),配置更新仍然正常工作,只是不会触发重新加载
|
||||||
|
- 不影响现有功能
|
||||||
|
|
||||||
|
## 类似功能参考
|
||||||
|
|
||||||
|
该实现参考了项目中已有的 ToolRouter 和 TopicRouter 的配置热更新机制,它们通过 `ConfigStore` 接口在每次使用时从数据库重新加载配置。
|
||||||
|
|
||||||
|
不同之处在于:
|
||||||
|
- **ToolRouter/TopicRouter**: 每次使用时从 DB 读取(配置小,读取频率低)
|
||||||
|
- **LLM Provider**: 在配置更新时主动重新加载(Client 创建有开销,不适合频繁创建)
|
||||||
|
|
||||||
|
## 未来改进建议
|
||||||
|
|
||||||
|
1. 添加配置验证:在更新前验证 API Key、BaseURL 是否可用
|
||||||
|
2. 添加配置变更日志:记录谁在何时修改了配置
|
||||||
|
3. 支持配置回滚:保存历史版本,出问题时可以快速回滚
|
||||||
@@ -258,3 +258,69 @@ func (s *Service) Stop() {
|
|||||||
func (s *Service) Enabled() bool {
|
func (s *Service) Enabled() bool {
|
||||||
return s.enabled
|
return s.enabled
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ReloadLLMProvider reloads a specific LLM provider configuration
|
||||||
|
func (s *Service) ReloadLLMProvider(config interface{}) error {
|
||||||
|
if !s.enabled || s.LLMState == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
providerConfig, err := convertToProviderConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.LLMState.UpdateProvider(providerConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddLLMProvider adds a new LLM provider
|
||||||
|
func (s *Service) AddLLMProvider(config interface{}) error {
|
||||||
|
if !s.enabled || s.LLMState == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
providerConfig, err := convertToProviderConfig(config)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return s.LLMState.AddProvider(providerConfig)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RemoveLLMProvider removes an LLM provider
|
||||||
|
func (s *Service) RemoveLLMProvider(name string) error {
|
||||||
|
if !s.enabled || s.LLMState == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return s.LLMState.RemoveProvider(name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// convertToProviderConfig converts a map to llm.ProviderConfig
|
||||||
|
func convertToProviderConfig(config interface{}) (llm.ProviderConfig, error) {
|
||||||
|
m, ok := config.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
return llm.ProviderConfig{}, fmt.Errorf("invalid config type: expected map[string]interface{}")
|
||||||
|
}
|
||||||
|
|
||||||
|
pc := llm.ProviderConfig{}
|
||||||
|
|
||||||
|
if v, ok := m["Name"].(string); ok {
|
||||||
|
pc.Name = v
|
||||||
|
}
|
||||||
|
if v, ok := m["Active"].(bool); ok {
|
||||||
|
pc.Active = v
|
||||||
|
}
|
||||||
|
if v, ok := m["APIKey"].(string); ok {
|
||||||
|
pc.APIKey = v
|
||||||
|
}
|
||||||
|
if v, ok := m["BaseURL"].(string); ok {
|
||||||
|
pc.BaseURL = v
|
||||||
|
}
|
||||||
|
if v, ok := m["Model"].(string); ok {
|
||||||
|
pc.Model = v
|
||||||
|
}
|
||||||
|
if v, ok := m["Timeout"].(int); ok {
|
||||||
|
pc.Timeout = v
|
||||||
|
}
|
||||||
|
if v, ok := m["ContextWindowTokens"].(int); ok {
|
||||||
|
pc.ContextWindowTokens = v
|
||||||
|
}
|
||||||
|
|
||||||
|
return pc, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,3 +135,137 @@ func (s *State) ListProfiles() []ProviderConfig {
|
|||||||
}
|
}
|
||||||
return profiles
|
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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,14 @@ import (
|
|||||||
"meshtastic_mqtt_server/internal/webutil"
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) {
|
// LLMProviderReloader is the interface for reloading LLM provider configuration
|
||||||
|
type LLMProviderReloader interface {
|
||||||
|
ReloadLLMProvider(config interface{}) error
|
||||||
|
AddLLMProvider(config interface{}) error
|
||||||
|
RemoveLLMProvider(name string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store, aiService LLMProviderReloader) {
|
||||||
group := r.Group("/llm")
|
group := r.Group("/llm")
|
||||||
{
|
{
|
||||||
// LLM Message Queue
|
// LLM Message Queue
|
||||||
@@ -27,9 +34,9 @@ func RegisterRoutes(r *gin.RouterGroup, store *storepkg.Store) {
|
|||||||
// LLM Providers
|
// LLM Providers
|
||||||
group.GET("/providers", handleListLLMProviders(store))
|
group.GET("/providers", handleListLLMProviders(store))
|
||||||
group.GET("/providers/:name", handleGetLLMProvider(store))
|
group.GET("/providers/:name", handleGetLLMProvider(store))
|
||||||
group.POST("/providers", handleCreateLLMProvider(store))
|
group.POST("/providers", handleCreateLLMProvider(store, aiService))
|
||||||
group.PUT("/providers/:name", handleUpdateLLMProvider(store))
|
group.PUT("/providers/:name", handleUpdateLLMProvider(store, aiService))
|
||||||
group.DELETE("/providers/:name", handleDeleteLLMProvider(store))
|
group.DELETE("/providers/:name", handleDeleteLLMProvider(store, aiService))
|
||||||
|
|
||||||
// LLM Tool Router
|
// LLM Tool Router
|
||||||
group.GET("/tool-router", handleGetLLMToolRouter(store))
|
group.GET("/tool-router", handleGetLLMToolRouter(store))
|
||||||
@@ -254,7 +261,7 @@ func handleGetLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
func handleCreateLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
@@ -290,11 +297,33 @@ func handleCreateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload AI service with new provider
|
||||||
|
if aiService != nil {
|
||||||
|
providerConfig := map[string]interface{}{
|
||||||
|
"Name": record.Name,
|
||||||
|
"Active": record.Active,
|
||||||
|
"APIKey": record.APIKey,
|
||||||
|
"BaseURL": record.BaseURL,
|
||||||
|
"Model": record.Model,
|
||||||
|
"Timeout": record.Timeout,
|
||||||
|
"ContextWindowTokens": record.ContextWindowTokens,
|
||||||
|
}
|
||||||
|
if err := aiService.AddLLMProvider(providerConfig); err != nil {
|
||||||
|
// Log warning but don't fail the request - database is already updated
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"item": llmProviderDTO(*record),
|
||||||
|
"warning": "provider created but failed to reload AI service: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
func handleUpdateLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -355,11 +384,33 @@ func handleUpdateLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reload AI service with updated provider
|
||||||
|
if aiService != nil {
|
||||||
|
providerConfig := map[string]interface{}{
|
||||||
|
"Name": record.Name,
|
||||||
|
"Active": record.Active,
|
||||||
|
"APIKey": record.APIKey,
|
||||||
|
"BaseURL": record.BaseURL,
|
||||||
|
"Model": record.Model,
|
||||||
|
"Timeout": record.Timeout,
|
||||||
|
"ContextWindowTokens": record.ContextWindowTokens,
|
||||||
|
}
|
||||||
|
if err := aiService.ReloadLLMProvider(providerConfig); err != nil {
|
||||||
|
// Log warning but don't fail the request - database is already updated
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"item": llmProviderDTO(*record),
|
||||||
|
"warning": "provider updated but failed to reload AI service: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
c.JSON(http.StatusOK, gin.H{"status": "ok", "item": llmProviderDTO(*record)})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
func handleDeleteLLMProvider(store *storepkg.Store, aiService LLMProviderReloader) gin.HandlerFunc {
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
name := c.Param("name")
|
name := c.Param("name")
|
||||||
if name == "" {
|
if name == "" {
|
||||||
@@ -372,6 +423,18 @@ func handleDeleteLLMProvider(store *storepkg.Store) gin.HandlerFunc {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove provider from AI service
|
||||||
|
if aiService != nil {
|
||||||
|
if err := aiService.RemoveLLMProvider(name); err != nil {
|
||||||
|
// Log warning but don't fail the request - database is already updated
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"status": "ok",
|
||||||
|
"warning": "provider deleted but failed to reload AI service: " + err.Error(),
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
c.JSON(http.StatusOK, gin.H{"status": "ok"})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ func TestMapTileProxyFetchesAndCaches(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cacheDir := t.TempDir()
|
cacheDir := t.TempDir()
|
||||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, false, st, nil, nil, nil, nil, nil, nil)
|
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: cacheDir}, false, st, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
|
url := "/api/map/" + row.URLTemplateHash + "?x=1&y=2&z=3"
|
||||||
for i := 0; i < 2; i++ {
|
for i := 0; i < 2; i++ {
|
||||||
@@ -75,7 +75,7 @@ func TestMapTileProxyRejectsInvalidCoordinates(t *testing.T) {
|
|||||||
t.Fatalf("CreateMapTileSource() error = %v", err)
|
t.Fatalf("CreateMapTileSource() error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil)
|
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
|
"/api/map/" + row.URLTemplateHash + "?y=0&z=0",
|
||||||
@@ -106,7 +106,7 @@ func TestMapTileProxyUnknownAndDisabledSource(t *testing.T) {
|
|||||||
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
|
t.Fatalf("CreateMapTileSource(proxy disabled) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil)
|
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
cases := []string{
|
cases := []string{
|
||||||
"/api/map/not-a-hash?x=0&y=0&z=0",
|
"/api/map/not-a-hash?x=0&y=0&z=0",
|
||||||
@@ -147,7 +147,7 @@ func TestMapTileProxyUpstreamStatus(t *testing.T) {
|
|||||||
t.Fatalf("CreateMapTileSource(500) error = %v", err)
|
t.Fatalf("CreateMapTileSource(500) error = %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil)
|
router := NewRouter(configpkg.WebConfig{StaticDir: t.TempDir(), MapTileCacheDir: t.TempDir()}, false, st, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
|
||||||
cases := []struct {
|
cases := []struct {
|
||||||
url string
|
url string
|
||||||
|
|||||||
+13
-6
@@ -27,10 +27,17 @@ import (
|
|||||||
"meshtastic_mqtt_server/internal/webutil"
|
"meshtastic_mqtt_server/internal/webutil"
|
||||||
)
|
)
|
||||||
|
|
||||||
func NewHTTPServer(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *http.Server {
|
// LLMProviderReloader is the interface for reloading LLM provider configuration
|
||||||
|
type LLMProviderReloader interface {
|
||||||
|
ReloadLLMProvider(config interface{}) error
|
||||||
|
AddLLMProvider(config interface{}) error
|
||||||
|
RemoveLLMProvider(name string) error
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewHTTPServer(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *http.Server {
|
||||||
return &http.Server{
|
return &http.Server{
|
||||||
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
Addr: net.JoinHostPort(cfg.Host, strconv.Itoa(cfg.Port)),
|
||||||
Handler: NewRouter(cfg, consoleLog, store, sessions, mqttStatus, blocking, forwarder, settings, botSender),
|
Handler: NewRouter(cfg, consoleLog, store, sessions, mqttStatus, blocking, forwarder, settings, botSender, aiService),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +67,7 @@ func ServeUnixSocket(server *http.Server, socketPath string) error {
|
|||||||
return server.Serve(listener)
|
return server.Serve(listener)
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) *gin.Engine {
|
func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) *gin.Engine {
|
||||||
r := gin.New()
|
r := gin.New()
|
||||||
if consoleLog {
|
if consoleLog {
|
||||||
r.Use(gin.Logger(), gin.Recovery())
|
r.Use(gin.Logger(), gin.Recovery())
|
||||||
@@ -69,7 +76,7 @@ func NewRouter(cfg configpkg.WebConfig, consoleLog bool, store *storepkg.Store,
|
|||||||
}
|
}
|
||||||
api := r.Group("/api")
|
api := r.Group("/api")
|
||||||
registerAPIRoutes(api, store, cfg.MapTileCacheDir)
|
registerAPIRoutes(api, store, cfg.MapTileCacheDir)
|
||||||
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender)
|
registerAdminRoutes(api.Group("/admin"), store, sessions, mqttStatus, blocking, forwarder, settings, botSender, aiService)
|
||||||
registerStaticRoutes(r, cfg.StaticDir)
|
registerStaticRoutes(r, cfg.StaticDir)
|
||||||
return r
|
return r
|
||||||
}
|
}
|
||||||
@@ -163,7 +170,7 @@ func registerAPIRoutes(r gin.IRouter, store *storepkg.Store, mapTileCacheDir str
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender) {
|
func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Manager, mqttStatus MQTTStatusProvider, blocking *blockingpkg.Cache, forwarder mqttforwardpkg.Reloader, settings *rspkg.Cache, botSender botpkg.TextSender, aiService LLMProviderReloader) {
|
||||||
type loginRequest struct {
|
type loginRequest struct {
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Password string `json:"password"`
|
Password string `json:"password"`
|
||||||
@@ -230,7 +237,7 @@ func registerAdminRoutes(r gin.IRouter, store *storepkg.Store, sessions *auth.Ma
|
|||||||
mappkg.RegisterAdminRoutes(protected, store)
|
mappkg.RegisterAdminRoutes(protected, store)
|
||||||
helppkg.RegisterAdminRoutes(protected, store)
|
helppkg.RegisterAdminRoutes(protected, store)
|
||||||
botpkg.RegisterRoutes(protected, store, botSender)
|
botpkg.RegisterRoutes(protected, store, botSender)
|
||||||
llmadminpkg.RegisterRoutes(protected, store)
|
llmadminpkg.RegisterRoutes(protected, store, aiService)
|
||||||
protected.GET("/me", func(c *gin.Context) {
|
protected.GET("/me", func(c *gin.Context) {
|
||||||
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
|
claims := c.MustGet("admin_claims").(*auth.SessionClaims)
|
||||||
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}})
|
c.JSON(http.StatusOK, gin.H{"user": auth.AdminUserDTO{Username: claims.Username, Role: claims.Role}})
|
||||||
|
|||||||
@@ -458,7 +458,7 @@ func run(cfg *configpkg.Config) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
mqttStatus := webpkg.MQTTRuntimeStatus{Server: server, Address: mqttAddr, TLS: cfg.MQTT.TLS.Enabled, Stats: messageStats, ClientStats: clientStats, DBQueue: dbQueue}
|
mqttStatus := webpkg.MQTTRuntimeStatus{Server: server, Address: mqttAddr, TLS: cfg.MQTT.TLS.Enabled, Stats: messageStats, ClientStats: clientStats, DBQueue: dbQueue}
|
||||||
handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender)
|
handler := webpkg.NewRouter(cfg.Web, cfg.ConsoleLog.Web, store, sessions, mqttStatus, blocking, forwardManager, settings, botSender, aiService)
|
||||||
webAddresses := []string{}
|
webAddresses := []string{}
|
||||||
if cfg.Web.PortEnabled {
|
if cfg.Web.PortEnabled {
|
||||||
httpServer := &http.Server{
|
httpServer := &http.Server{
|
||||||
|
|||||||
Reference in New Issue
Block a user