diff --git a/internal/config/config.go b/internal/config/config.go index 0965ec85..cef4ddb3 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -6,6 +6,7 @@ import ( "encoding/json" "fmt" "io/fs" + "net/url" "os" "path/filepath" "strconv" @@ -946,10 +947,12 @@ func (c *Config) ApplyDefaultAIChannel() { if c == nil { return } + c.NormalizeAIProviderProfiles() c.AI.EnsureDefaultFromOpenAI(c.OpenAI) if oa, _, ok := c.AI.ResolveChannel(c.AI.DefaultChannel); ok { c.OpenAI = oa } + c.NormalizeAIProviderProfiles() } func (c OpenAIConfig) MaxCompletionTokensEffective() int { @@ -968,6 +971,50 @@ func (c OpenAIConfig) IsDeepSeekEndpointOrModel() bool { return strings.Contains(baseURL, "deepseek") } +func (c OpenAIConfig) IsDeepSeekOfficialEndpoint() bool { + host := normalizedURLHost(c.BaseURL) + return host == "api.deepseek.com" +} + +func normalizedURLHost(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil || parsed.Host == "" { + parsed, err = url.Parse("https://" + strings.TrimLeft(raw, "/")) + if err != nil { + return "" + } + } + return strings.ToLower(strings.TrimPrefix(parsed.Hostname(), "www.")) +} + +func NormalizeOpenAIProviderProfile(oa *OpenAIConfig) { + if oa == nil { + return + } + if oa.IsDeepSeekOfficialEndpoint() { + oa.Reasoning.Profile = "deepseek" + } +} + +func (c *Config) NormalizeAIProviderProfiles() { + if c == nil { + return + } + NormalizeOpenAIProviderProfile(&c.OpenAI) + if c.AI.Channels != nil { + for id, ch := range c.AI.Channels { + oa := ch.ToOpenAIConfig() + NormalizeOpenAIProviderProfile(&oa) + ch.Reasoning = oa.Reasoning + c.AI.Channels[id] = ch + } + } +} + // OpenAIReasoningConfig 全局默认与网关 profile(对话页可通过 ChatRequest.reasoning 覆盖,受 AllowClientReasoning 约束)。 type OpenAIReasoningConfig struct { // Mode: auto(默认)| on | off | default(与 auto 相同)。 @@ -1399,6 +1446,7 @@ func Load(path string) (*Config, error) { if cfg.Audit.MaxDetailBytes <= 0 { cfg.Audit.MaxDetailBytes = 8192 } + cfg.NormalizeAIProviderProfiles() cfg.ApplyDefaultAIChannel() if err := validateOpenAIOutputLimits(cfg.OpenAI); err != nil { return nil, err diff --git a/internal/config/config_test.go b/internal/config/config_test.go index f4f8a9dd..f2d51e56 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -160,6 +160,84 @@ func TestLoadUsesAIDefaultChannelAsRuntimeOpenAI(t *testing.T) { } } +func TestNormalizeAIProviderProfilesForOfficialDeepSeekEndpoint(t *testing.T) { + cfg := &Config{ + OpenAI: OpenAIConfig{ + BaseURL: "https://api.deepseek.com/v1", + Model: "deepseek-chat", + Reasoning: OpenAIReasoningConfig{ + Profile: "openai_compat", + }, + }, + AI: AIConfig{ + Channels: map[string]AIChannelConfig{ + "official": { + BaseURL: "api.deepseek.com/v1", + Model: "deepseek-chat", + Reasoning: OpenAIReasoningConfig{ + Profile: "auto", + }, + }, + "gateway": { + BaseURL: "https://compatible.example.com/v1", + Model: "deepseek-chat", + Reasoning: OpenAIReasoningConfig{ + Profile: "openai_compat", + }, + }, + }, + }, + } + + cfg.NormalizeAIProviderProfiles() + + if cfg.OpenAI.Reasoning.Profile != "deepseek" { + t.Fatalf("openai profile = %q, want deepseek", cfg.OpenAI.Reasoning.Profile) + } + if got := cfg.AI.Channels["official"].Reasoning.Profile; got != "deepseek" { + t.Fatalf("official channel profile = %q, want deepseek", got) + } + if got := cfg.AI.Channels["gateway"].Reasoning.Profile; got != "openai_compat" { + t.Fatalf("gateway profile should be preserved, got %q", got) + } +} + +func TestLoadNormalizesDefaultChannelForOfficialDeepSeekEndpoint(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + initial := strings.Join([]string{ + "ai:", + " default_channel: deepseek", + " channels:", + " deepseek:", + " name: DeepSeek", + " provider: openai_compatible", + " base_url: https://api.deepseek.com/v1", + " api_key: deepseek-key", + " model: deepseek-chat", + " reasoning:", + " profile: openai_compat", + "server:", + " host: 127.0.0.1", + " port: 8080", + "", + }, "\n") + if err := os.WriteFile(path, []byte(initial), 0644); err != nil { + t.Fatalf("write config: %v", err) + } + + cfg, err := Load(path) + if err != nil { + t.Fatalf("Load: %v", err) + } + if cfg.OpenAI.Reasoning.Profile != "deepseek" { + t.Fatalf("runtime OpenAI profile = %q, want deepseek", cfg.OpenAI.Reasoning.Profile) + } + if got := cfg.AI.Channels["deepseek"].Reasoning.Profile; got != "deepseek" { + t.Fatalf("channel profile = %q, want deepseek", got) + } +} + func TestSummarizationUserIntentLedgerRunesEffective(t *testing.T) { var zero MultiAgentEinoMiddlewareConfig if got := zero.SummarizationUserIntentLedgerMaxRunesEffective(); got != DefaultSummarizationUserIntentLedgerMaxRunes { diff --git a/internal/handler/config.go b/internal/handler/config.go index 98b4e570..3d98c5ed 100644 --- a/internal/handler/config.go +++ b/internal/handler/config.go @@ -1169,6 +1169,8 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) { } } + h.config.NormalizeAIProviderProfiles() + // 保存配置到文件 if err := h.saveConfig(); err != nil { h.logger.Error("保存配置失败", zap.Error(err)) @@ -1744,6 +1746,8 @@ func (h *ConfigHandler) ApplyConfig(c *gin.Context) { // saveConfig 保存配置到文件 func (h *ConfigHandler) saveConfig() error { + h.config.NormalizeAIProviderProfiles() + // 读取现有配置文件并创建备份 data, err := os.ReadFile(h.configPath) if err != nil {