diff --git a/docs/guides/configuration.md b/docs/guides/configuration.md index 4cf8e1e1..42ea6e95 100644 --- a/docs/guides/configuration.md +++ b/docs/guides/configuration.md @@ -140,11 +140,38 @@ custom provider 通过单独文件声明,而不是写进 `config.yaml`: name: company-gateway driver: openaicompat api_key_env: COMPANY_GATEWAY_API_KEY +model_source: discover openai_compatible: base_url: https://llm.example.com/v1 api_style: chat_completions ``` +`model_source` 语义如下: + +- `discover`(默认):通过 discovery(如 `/models`)拉取模型列表。 +- `manual`:不触发 discovery,优先使用 `models` 中声明的模型列表。 + +`manual` 模式示例: + +```yaml +name: company-gateway-manual +driver: openaicompat +api_key_env: COMPANY_GATEWAY_API_KEY +model_source: manual +openai_compatible: + base_url: https://llm.example.com/v1 +models: + - id: gpt-4o-mini + name: GPT-4o Mini + context_window: 128000 +``` + +迁移与兼容性说明: + +- 老配置未声明 `model_source` 时,默认按 `discover` 处理。 +- `manual` 模式下必须提供 `models`,否则会在加载/创建阶段报错。 +- `manual` 模式会忽略 discovery 相关字段(如 `discovery_endpoint_path`、`discovery_response_profile`)。 + ## Auto Compact 失败与校验补充 - 当 `context.auto_compact.input_token_threshold <= 0` 时,如果当前 provider 选择无效、catalog snapshot 查询失败、模型缺少可用的 `ContextWindow`,或 `ContextWindow <= reserve_tokens`,系统会回退到 `fallback_input_token_threshold`,不会静默关闭 auto compact。 diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 71e52b9b..0716c872 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -196,13 +196,13 @@ func TestProviderConfigResolveAPIKey(t *testing.T) { }{ { name: "success", - envKey: "OPENAI_API_KEY", + envKey: "NEOCODE_TEST_API_KEY_SUCCESS", envValue: "secret-value", }, { name: "missing", - envKey: "OPENAI_API_KEY", - expectErr: "environment variable OPENAI_API_KEY is empty", + envKey: "NEOCODE_TEST_API_KEY_MISSING", + expectErr: "environment variable NEOCODE_TEST_API_KEY_MISSING is empty", }, } diff --git a/internal/config/loader_test.go b/internal/config/loader_test.go index 01ecb773..af215901 100644 --- a/internal/config/loader_test.go +++ b/internal/config/loader_test.go @@ -9,6 +9,7 @@ import ( "testing" "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) func writeLoaderConfig(t *testing.T, loader *Loader, raw string) { @@ -25,6 +26,32 @@ func writeLoaderConfig(t *testing.T, loader *Loader, raw string) { } } +func saveCustomProviderWithModelsForTest( + baseDir string, + name string, + driver string, + baseURL string, + apiKeyEnv string, + apiStyle string, + deploymentMode string, + apiVersion string, + discoveryEndpointPath string, + discoveryResponseProfile string, +) error { + return SaveCustomProviderWithModels(baseDir, SaveCustomProviderInput{ + Name: name, + Driver: driver, + BaseURL: baseURL, + APIKeyEnv: apiKeyEnv, + APIStyle: apiStyle, + DeploymentMode: deploymentMode, + APIVersion: apiVersion, + DiscoveryEndpointPath: discoveryEndpointPath, + DiscoveryResponseProfile: discoveryResponseProfile, + ModelSource: provider.ModelSourceDiscover, + }) +} + func TestLoaderLoadMissingConfigCreatesDefault(t *testing.T) { t.Parallel() @@ -374,6 +401,127 @@ func TestLoaderRejectsMalformedCustomProviderYAML(t *testing.T) { } } +func TestLoaderRejectsInvalidCustomProviderModelSource(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_GATEWAY_API_KEY +model_source: manul +openai_compatible: + base_url: https://llm.example.com/v1 +` + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write provider.yaml: %v", err) + } + + _, err := loader.Load(context.Background()) + if err == nil || !strings.Contains(err.Error(), "unsupported model_source") { + t.Fatalf("expected invalid model_source error, got %v", err) + } +} + +func TestLoaderLoadManualModelSourceIgnoresDiscoveryFields(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_GATEWAY_API_KEY +model_source: manual +models: + - id: manual-model + name: Manual Model +openai_compatible: + base_url: https://llm.example.com/v1 + discovery_endpoint_path: /models + discovery_response_profile: invalid-profile +` + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write provider.yaml: %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + loadedProvider, err := cfg.ProviderByName("company-gateway") + if err != nil { + t.Fatalf("ProviderByName(company-gateway) error = %v", err) + } + if loadedProvider.ModelSource != provider.ModelSourceManual { + t.Fatalf("expected model_source manual, got %q", loadedProvider.ModelSource) + } + if loadedProvider.DiscoveryEndpointPath != "" { + t.Fatalf("expected manual mode to clear discovery endpoint path, got %q", loadedProvider.DiscoveryEndpointPath) + } + if loadedProvider.DiscoveryResponseProfile != "" { + t.Fatalf( + "expected manual mode to clear discovery response profile, got %q", + loadedProvider.DiscoveryResponseProfile, + ) + } +} + +func TestLoaderDefaultsModelSourceToDiscoverForLegacyCustomProvider(t *testing.T) { + t.Parallel() + + loader := NewLoader(t.TempDir(), testDefaultConfig()) + customDir := filepath.Join(loader.BaseDir(), providersDirName, "company-gateway") + if err := os.MkdirAll(customDir, 0o755); err != nil { + t.Fatalf("mkdir custom provider dir: %v", err) + } + + providerYAML := ` +name: company-gateway +driver: openaicompat +api_key_env: COMPANY_GATEWAY_API_KEY +models: + - id: manual-model + name: Manual Model +openai_compatible: + base_url: https://llm.example.com/v1 +` + if err := os.WriteFile(filepath.Join(customDir, customProviderConfigName), []byte(strings.TrimSpace(providerYAML)+"\n"), 0o644); err != nil { + t.Fatalf("write provider.yaml: %v", err) + } + + cfg, err := loader.Load(context.Background()) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + loadedProvider, err := cfg.ProviderByName("company-gateway") + if err != nil { + t.Fatalf("ProviderByName(company-gateway) error = %v", err) + } + if loadedProvider.ModelSource != provider.ModelSourceDiscover { + t.Fatalf("expected default model_source discover, got %q", loadedProvider.ModelSource) + } + if loadedProvider.DiscoveryEndpointPath != provider.DiscoveryEndpointPathModels { + t.Fatalf("expected discovery endpoint to use default /models, got %q", loadedProvider.DiscoveryEndpointPath) + } + if loadedProvider.DiscoveryResponseProfile != provider.DiscoveryResponseProfileOpenAI { + t.Fatalf( + "expected discovery response profile %q, got %q", + provider.DiscoveryResponseProfileOpenAI, + loadedProvider.DiscoveryResponseProfile, + ) + } +} + func TestLoaderRejectsTopLevelDiscoverySettingsForKnownDriver(t *testing.T) { t.Parallel() @@ -513,6 +661,7 @@ driver: openaicompat api_key_env: COMPANY_GATEWAY_API_KEY models: - id: deepseek-coder + name: DeepSeek Coder context_window: 0 openai_compatible: base_url: https://llm.example.com/v1 @@ -542,6 +691,7 @@ driver: openaicompat api_key_env: COMPANY_GATEWAY_API_KEY models: - id: deepseek-coder + name: DeepSeek Coder max_output_tokens: 0 openai_compatible: base_url: https://llm.example.com/v1 @@ -571,7 +721,9 @@ driver: openaicompat api_key_env: COMPANY_GATEWAY_API_KEY models: - id: deepseek-coder + name: DeepSeek Coder - id: DeepSeek-Coder + name: DeepSeek Coder Duplicate openai_compatible: base_url: https://llm.example.com/v1 ` @@ -887,8 +1039,12 @@ func TestResolveCustomProviderSettingsByDriver(t *testing.T) { t.Parallel() got := resolveCustomProviderSettings(tt.file) - if got != tt.want { - t.Fatalf("resolveCustomProviderSettings() = %+v, want %+v", got, tt.want) + expected := tt.want + if expected.ModelSource == "" { + expected.ModelSource = provider.ModelSourceDiscover + } + if got != expected { + t.Fatalf("resolveCustomProviderSettings() = %+v, want %+v", got, expected) } }) } @@ -1034,7 +1190,7 @@ func TestSaveCustomProviderPersistsDriverSpecificSettings(t *testing.T) { providerName := strings.ReplaceAll(tt.name, " ", "-") apiKeyEnv := strings.ToUpper(strings.ReplaceAll(providerName, "-", "_")) + "_API_KEY" - if err := SaveCustomProvider( + if err := saveCustomProviderWithModelsForTest( baseDir, providerName, tt.driver, @@ -1046,7 +1202,7 @@ func TestSaveCustomProviderPersistsDriverSpecificSettings(t *testing.T) { tt.discoveryEndpointPath, tt.discoveryResponseProfile, ); err != nil { - t.Fatalf("SaveCustomProvider() error = %v", err) + t.Fatalf("SaveCustomProviderWithModels() error = %v", err) } cfg, err := loadCustomProvider(filepath.Join(baseDir, providersDirName, providerName)) @@ -1082,7 +1238,7 @@ func TestSaveCustomProviderRejectsUnsafeProviderName(t *testing.T) { "中文", } for _, name := range invalidNames { - err := SaveCustomProvider( + err := saveCustomProviderWithModelsForTest( baseDir, name, provider.DriverOpenAICompat, @@ -1095,11 +1251,164 @@ func TestSaveCustomProviderRejectsUnsafeProviderName(t *testing.T) { "", ) if err == nil { - t.Fatalf("expected SaveCustomProvider to reject %q", name) + t.Fatalf("expected SaveCustomProviderWithModels to reject %q", name) } } } +func TestSaveCustomProviderRejectsManualSourceWithoutModels(t *testing.T) { + t.Parallel() + + err := SaveCustomProviderWithModels(t.TempDir(), SaveCustomProviderInput{ + Name: "manual-empty-models", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "MANUAL_EMPTY_MODELS_API_KEY", + ModelSource: provider.ModelSourceManual, + Models: nil, + }) + if err == nil || !strings.Contains(err.Error(), "manual model source requires non-empty models") { + t.Fatalf("expected manual source empty models validation error, got %v", err) + } +} + +func TestSaveCustomProviderRejectsModelWithoutName(t *testing.T) { + t.Parallel() + + err := SaveCustomProviderWithModels(t.TempDir(), SaveCustomProviderInput{ + Name: "manual-missing-model-name", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "MANUAL_MISSING_MODEL_NAME_API_KEY", + ModelSource: provider.ModelSourceManual, + Models: []providertypes.ModelDescriptor{ + {ID: "manual-model-1"}, + }, + }) + if err == nil || !strings.Contains(err.Error(), "models[0].name is empty") { + t.Fatalf("expected empty model name validation error, got %v", err) + } +} + +func TestSaveCustomProviderRejectsInvalidModelSource(t *testing.T) { + t.Parallel() + + err := SaveCustomProviderWithModels(t.TempDir(), SaveCustomProviderInput{ + Name: "invalid-model-source", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "INVALID_MODEL_SOURCE_API_KEY", + ModelSource: "manul", + }) + if err == nil || !strings.Contains(err.Error(), "unsupported model_source") { + t.Fatalf("expected invalid model_source error, got %v", err) + } +} + +func TestSaveCustomProviderManualModelsPersistOptionalFields(t *testing.T) { + t.Parallel() + + baseDir := t.TempDir() + const providerName = "manual-models-provider" + err := SaveCustomProviderWithModels(baseDir, SaveCustomProviderInput{ + Name: providerName, + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "MANUAL_MODELS_PROVIDER_API_KEY", + ModelSource: provider.ModelSourceManual, + Models: []providertypes.ModelDescriptor{ + { + ID: "manual-model-1", + Name: "Manual Model 1", + }, + { + ID: "manual-model-2", + Name: "Manual Model 2", + ContextWindow: 131072, + MaxOutputTokens: 8192, + }, + { + ID: "Manual-Model-1", + Name: "Duplicate by key should be merged", + }, + }, + }) + if err != nil { + t.Fatalf("SaveCustomProviderWithModels() error = %v", err) + } + + cfg, err := loadCustomProvider(filepath.Join(baseDir, providersDirName, providerName)) + if err != nil { + t.Fatalf("loadCustomProvider() error = %v", err) + } + if cfg.ModelSource != provider.ModelSourceManual { + t.Fatalf("expected model source manual, got %q", cfg.ModelSource) + } + if cfg.DiscoveryEndpointPath != "" || cfg.DiscoveryResponseProfile != "" { + t.Fatalf("expected discovery settings to be empty in manual mode, got %+v", cfg) + } + if len(cfg.Models) != 2 { + t.Fatalf("expected merged model list with 2 entries, got %+v", cfg.Models) + } + if cfg.Models[0].ContextWindow != 0 || cfg.Models[0].MaxOutputTokens != 0 { + t.Fatalf("expected optional fields omitted for model-1, got %+v", cfg.Models[0]) + } + if cfg.Models[1].ContextWindow != 131072 || cfg.Models[1].MaxOutputTokens != 8192 { + t.Fatalf("expected optional fields persisted for model-2, got %+v", cfg.Models[1]) + } +} + +func TestToCustomProviderModelFiles(t *testing.T) { + t.Parallel() + + if got := toCustomProviderModelFiles(nil); got != nil { + t.Fatalf("expected nil result for empty models, got %+v", got) + } + + converted := toCustomProviderModelFiles([]providertypes.ModelDescriptor{ + { + ID: "model-a", + Name: "Model A", + }, + { + ID: "model-b", + Name: "Model B", + ContextWindow: 32768, + MaxOutputTokens: 2048, + }, + { + ID: "Model-A", + Name: "Merged duplicate key", + }, + }) + if len(converted) != 2 { + t.Fatalf("expected merged model count 2, got %+v", converted) + } + if converted[0].ID != "model-a" || converted[0].Name != "Model A" { + t.Fatalf("expected normalized merge result for model-a, got %+v", converted[0]) + } + if converted[0].ContextWindow != nil || converted[0].MaxOutputTokens != nil { + t.Fatalf("expected model-a optional pointers nil, got %+v", converted[0]) + } + if converted[1].ContextWindow == nil || *converted[1].ContextWindow != 32768 { + t.Fatalf("expected model-b context window pointer, got %+v", converted[1]) + } + if converted[1].MaxOutputTokens == nil || *converted[1].MaxOutputTokens != 2048 { + t.Fatalf("expected model-b max output tokens pointer, got %+v", converted[1]) + } +} + +func TestValidateCustomProviderName(t *testing.T) { + t.Parallel() + + if err := ValidateCustomProviderName("team.gateway_01"); err != nil { + t.Fatalf("expected valid provider name, got %v", err) + } + if err := ValidateCustomProviderName("../escape"); err == nil { + t.Fatal("expected invalid provider name rejection") + } +} + func TestDeleteCustomProviderRejectsUnsafeProviderName(t *testing.T) { t.Parallel() @@ -1233,7 +1542,7 @@ func TestSaveCustomProviderFileSystemErrors(t *testing.T) { t.Fatalf("WriteFile() error = %v", err) } - err := SaveCustomProvider( + err := saveCustomProviderWithModelsForTest( baseDir, "team-gateway", provider.DriverOpenAICompat, @@ -1257,7 +1566,7 @@ func TestSaveCustomProviderFileSystemErrors(t *testing.T) { t.Fatalf("MkdirAll() error = %v", err) } - err := SaveCustomProvider( + err := saveCustomProviderWithModelsForTest( baseDir, "team-gateway", provider.DriverOpenAICompat, diff --git a/internal/config/provider.go b/internal/config/provider.go index e65e143b..6a91f9fd 100644 --- a/internal/config/provider.go +++ b/internal/config/provider.go @@ -24,6 +24,7 @@ type ProviderConfig struct { BaseURL string `yaml:"base_url"` Model string `yaml:"model"` APIKeyEnv string `yaml:"api_key_env"` + ModelSource string `yaml:"-"` ChatProtocol string `yaml:"-"` ChatEndpointPath string `yaml:"-"` DiscoveryProtocol string `yaml:"-"` @@ -77,6 +78,15 @@ func (p ProviderConfig) Validate() error { if strings.TrimSpace(p.APIKeyEnv) == "" { return fmt.Errorf("provider %q api_key_env is empty", p.Name) } + + normalizedModelSource := provider.NormalizeModelSource(p.ModelSource) + if normalizedModelSource == "" { + normalizedModelSource = provider.ModelSourceDiscover + } + if normalizedModelSource == provider.ModelSourceManual && len(p.Models) == 0 { + return fmt.Errorf("provider %q manual model source requires non-empty models", p.Name) + } + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( p.Driver, p.ChatProtocol, @@ -305,6 +315,7 @@ func OpenAIProvider() ProviderConfig { BaseURL: OpenAIDefaultBaseURL, Model: OpenAIDefaultModel, APIKeyEnv: OpenAIDefaultAPIKeyEnv, + ModelSource: provider.ModelSourceDiscover, ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, ChatEndpointPath: "/chat/completions", DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, @@ -325,6 +336,7 @@ func GeminiProvider() ProviderConfig { BaseURL: GeminiDefaultBaseURL, Model: GeminiDefaultModel, APIKeyEnv: GeminiDefaultAPIKeyEnv, + ModelSource: provider.ModelSourceDiscover, ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, ChatEndpointPath: "/chat/completions", DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, @@ -345,6 +357,7 @@ func OpenLLProvider() ProviderConfig { BaseURL: OpenLLDefaultBaseURL, Model: OpenLLDefaultModel, APIKeyEnv: OpenLLDefaultAPIKeyEnv, + ModelSource: provider.ModelSourceDiscover, ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, ChatEndpointPath: "/chat/completions", DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, @@ -365,6 +378,7 @@ func QiniuProvider() ProviderConfig { BaseURL: QiniuDefaultBaseURL, Model: QiniuDefaultModel, APIKeyEnv: QiniuDefaultAPIKeyEnv, + ModelSource: provider.ModelSourceDiscover, ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, ChatEndpointPath: "/chat/completions", DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, diff --git a/internal/config/provider_loader.go b/internal/config/provider_loader.go index 3c15bcbe..77b71377 100644 --- a/internal/config/provider_loader.go +++ b/internal/config/provider_loader.go @@ -24,6 +24,7 @@ type customProviderFile struct { Name string `yaml:"name"` Driver string `yaml:"driver"` APIKeyEnv string `yaml:"api_key_env"` + ModelSource string `yaml:"model_source,omitempty"` BaseURL string `yaml:"base_url,omitempty"` ChatProtocol string `yaml:"chat_protocol,omitempty"` ChatEndpointPath string `yaml:"chat_endpoint_path,omitempty"` @@ -41,7 +42,7 @@ type customProviderFile struct { type customProviderModelFile struct { ID string `yaml:"id"` - Name string `yaml:"name,omitempty"` + Name string `yaml:"name"` ContextWindow *int `yaml:"context_window,omitempty"` MaxOutputTokens *int `yaml:"max_output_tokens,omitempty"` } @@ -83,6 +84,7 @@ type customAnthropicProviderFile struct { } type customProviderSettings struct { + ModelSource string BaseURL string ChatProtocol string ChatEndpointPath string @@ -160,6 +162,9 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { if err := validateCustomProviderDiscoveryFieldPlacement(file); err != nil { return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) } + if err := validateCustomProviderModelSource(file.ModelSource); err != nil { + return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) + } settings := resolveCustomProviderSettings(file) models, err := customProviderModels(file.Models) @@ -172,6 +177,7 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { Driver: strings.TrimSpace(file.Driver), BaseURL: settings.BaseURL, APIKeyEnv: strings.TrimSpace(file.APIKeyEnv), + ModelSource: settings.ModelSource, ChatProtocol: settings.ChatProtocol, ChatEndpointPath: settings.ChatEndpointPath, DiscoveryProtocol: settings.DiscoveryProtocol, @@ -187,16 +193,23 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { Source: ProviderSourceCustom, } + normalizedDiscoveryEndpointPath := cfg.DiscoveryEndpointPath + normalizedDiscoveryResponseProfile := cfg.DiscoveryResponseProfile + if provider.NormalizeModelSource(cfg.ModelSource) == provider.ModelSourceManual { + normalizedDiscoveryEndpointPath = "" + normalizedDiscoveryResponseProfile = "" + } + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( cfg.Driver, cfg.ChatProtocol, cfg.ChatEndpointPath, cfg.DiscoveryProtocol, - cfg.DiscoveryEndpointPath, + normalizedDiscoveryEndpointPath, cfg.AuthStrategy, cfg.ResponseProfile, cfg.APIStyle, - cfg.DiscoveryResponseProfile, + normalizedDiscoveryResponseProfile, ) if err != nil { return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) @@ -207,8 +220,13 @@ func loadCustomProvider(providerDir string) (ProviderConfig, error) { cfg.AuthStrategy = normalizedProtocols.AuthStrategy cfg.ResponseProfile = normalizedProtocols.ResponseProfile cfg.APIStyle = normalizedProtocols.LegacyAPIStyle - cfg.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath - cfg.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile + if provider.NormalizeModelSource(cfg.ModelSource) == provider.ModelSourceManual { + cfg.DiscoveryEndpointPath = "" + cfg.DiscoveryResponseProfile = "" + } else { + cfg.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath + cfg.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile + } if err := cfg.Validate(); err != nil { return ProviderConfig{}, fmt.Errorf("config: custom provider %q: %w", filepath.Base(providerDir), err) @@ -230,6 +248,10 @@ func customProviderModels(models []customProviderModelFile) ([]providertypes.Mod if id == "" { return nil, fmt.Errorf("models[%d].id is empty", index) } + name := strings.TrimSpace(model.Name) + if name == "" { + return nil, fmt.Errorf("models[%d].name is empty", index) + } key := provider.NormalizeKey(id) if _, exists := seen[key]; exists { @@ -239,7 +261,7 @@ func customProviderModels(models []customProviderModelFile) ([]providertypes.Mod descriptor := providertypes.ModelDescriptor{ ID: id, - Name: strings.TrimSpace(model.Name), + Name: name, } if model.ContextWindow != nil { if *model.ContextWindow <= 0 { @@ -262,7 +284,9 @@ func customProviderModels(models []customProviderModelFile) ([]providertypes.Mod // resolveCustomProviderSettings 根据 driver 只提取当前协议真正生效的配置字段,避免误吃其他协议块的值。 // 已知 driver 仅从协议块读取 base_url;未知 driver 使用顶层 base_url 作为唯一入口。 func resolveCustomProviderSettings(file customProviderFile) customProviderSettings { - settings := customProviderSettings{} + settings := customProviderSettings{ + ModelSource: provider.NormalizeModelSource(strings.TrimSpace(file.ModelSource)), + } switch normalizeProviderDriver(file.Driver) { case provider.DriverOpenAICompat: @@ -306,10 +330,25 @@ func resolveCustomProviderSettings(file customProviderFile) customProviderSettin settings.DiscoveryResponseProfile = strings.TrimSpace(file.DiscoveryResponseProfile) } settings.ModelFieldAliases = encodeModelFieldAliases(file.ModelFieldAliases) + if settings.ModelSource == "" { + settings.ModelSource = provider.ModelSourceDiscover + } return settings } +// validateCustomProviderModelSource 校验 model_source 字段;仅允许缺省或合法枚举值,拒绝静默回退。 +func validateCustomProviderModelSource(raw string) error { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil + } + if provider.NormalizeModelSource(trimmed) == "" { + return fmt.Errorf("unsupported model_source %q", raw) + } + return nil +} + // validateCustomProviderDiscoveryFieldPlacement 校验 discovery 字段写在与 driver 对应的协议块中,避免静默忽略。 func validateCustomProviderDiscoveryFieldPlacement(file customProviderFile) error { topLevelChatProtocol := strings.TrimSpace(file.ChatProtocol) @@ -341,19 +380,52 @@ func validateCustomProviderDiscoveryFieldPlacement(file customProviderFile) erro } } -// SaveCustomProvider 保存自定义 provider 到文件系统。 -func SaveCustomProvider( - baseDir string, - name string, - driver string, - baseURL string, - apiKeyEnv string, - apiStyle string, - deploymentMode string, - apiVersion string, - discoveryEndpointPath string, - discoveryResponseProfile string, -) error { +// SaveCustomProviderInput 定义自定义 Provider 的持久化字段。 +type SaveCustomProviderInput struct { + Name string + Driver string + BaseURL string + APIKeyEnv string + APIStyle string + DeploymentMode string + APIVersion string + DiscoveryEndpointPath string + DiscoveryResponseProfile string + ModelSource string + Models []providertypes.ModelDescriptor +} + +// SaveCustomProviderWithModels 保存自定义 provider,并可在 manual 模式下写入手工模型列表。 +func SaveCustomProviderWithModels(baseDir string, input SaveCustomProviderInput) error { + name := input.Name + driver := input.Driver + baseURL := input.BaseURL + apiKeyEnv := input.APIKeyEnv + apiStyle := input.APIStyle + deploymentMode := input.DeploymentMode + apiVersion := input.APIVersion + discoveryEndpointPath := strings.TrimSpace(input.DiscoveryEndpointPath) + discoveryResponseProfile := strings.TrimSpace(input.DiscoveryResponseProfile) + modelSource := provider.NormalizeModelSource(input.ModelSource) + if strings.TrimSpace(input.ModelSource) != "" && modelSource == "" { + return fmt.Errorf("config: unsupported model_source %q", input.ModelSource) + } + if modelSource == "" { + modelSource = provider.ModelSourceDiscover + } + normalizedInputModels, err := validateModelDescriptorsRequireName(input.Models) + if err != nil { + return err + } + normalizedModels := providertypes.MergeModelDescriptors(normalizedInputModels) + if modelSource == provider.ModelSourceManual { + discoveryEndpointPath = "" + discoveryResponseProfile = "" + if len(normalizedModels) == 0 { + return fmt.Errorf("config: provider %q manual model source requires non-empty models", strings.TrimSpace(name)) + } + } + if err := validateCustomProviderName(name); err != nil { return err } @@ -365,23 +437,28 @@ func SaveCustomProvider( normalizedDriver := normalizeProviderDriver(driver) cfg := customProviderFile{ - Name: name, - Driver: normalizedDriver, - APIKeyEnv: apiKeyEnv, + Name: name, + Driver: normalizedDriver, + APIKeyEnv: apiKeyEnv, + ModelSource: modelSource, } switch normalizedDriver { case provider.DriverOpenAICompat: + chatEndpointPath := "/chat/completions" + if provider.NormalizeProviderAPIStyle(apiStyle) == provider.OpenAICompatibleAPIStyleResponses { + chatEndpointPath = "/responses" + } cfg.OpenAICompatible = customOpenAICompatibleFile{ BaseURL: baseURL, ChatProtocol: provider.ChatProtocolOpenAIChatCompletions, - ChatEndpointPath: "/chat/completions", + ChatEndpointPath: chatEndpointPath, DiscoveryProtocol: provider.DiscoveryProtocolOpenAIModels, AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: strings.TrimSpace(discoveryResponseProfile), + ResponseProfile: discoveryResponseProfile, APIStyle: strings.TrimSpace(apiStyle), - DiscoveryEndpointPath: strings.TrimSpace(discoveryEndpointPath), - DiscoveryResponseProfile: strings.TrimSpace(discoveryResponseProfile), + DiscoveryEndpointPath: discoveryEndpointPath, + DiscoveryResponseProfile: discoveryResponseProfile, } case provider.DriverGemini: cfg.Gemini = customGeminiProviderFile{ @@ -390,10 +467,10 @@ func SaveCustomProvider( ChatEndpointPath: "/chat/completions", DiscoveryProtocol: provider.DiscoveryProtocolGeminiModels, AuthStrategy: provider.AuthStrategyBearer, - ResponseProfile: strings.TrimSpace(discoveryResponseProfile), + ResponseProfile: discoveryResponseProfile, DeploymentMode: strings.TrimSpace(deploymentMode), - DiscoveryEndpointPath: strings.TrimSpace(discoveryEndpointPath), - DiscoveryResponseProfile: strings.TrimSpace(discoveryResponseProfile), + DiscoveryEndpointPath: discoveryEndpointPath, + DiscoveryResponseProfile: discoveryResponseProfile, } case provider.DriverAnthropic: cfg.Anthropic = customAnthropicProviderFile{ @@ -402,15 +479,18 @@ func SaveCustomProvider( ChatEndpointPath: "/messages", DiscoveryProtocol: provider.DiscoveryProtocolAnthropicModels, AuthStrategy: provider.AuthStrategyAnthropic, - ResponseProfile: strings.TrimSpace(discoveryResponseProfile), + ResponseProfile: discoveryResponseProfile, APIVersion: strings.TrimSpace(apiVersion), - DiscoveryEndpointPath: strings.TrimSpace(discoveryEndpointPath), - DiscoveryResponseProfile: strings.TrimSpace(discoveryResponseProfile), + DiscoveryEndpointPath: discoveryEndpointPath, + DiscoveryResponseProfile: discoveryResponseProfile, } default: cfg.BaseURL = baseURL - cfg.DiscoveryEndpointPath = strings.TrimSpace(discoveryEndpointPath) - cfg.DiscoveryResponseProfile = strings.TrimSpace(discoveryResponseProfile) + cfg.DiscoveryEndpointPath = discoveryEndpointPath + cfg.DiscoveryResponseProfile = discoveryResponseProfile + } + if modelSource == provider.ModelSourceManual { + cfg.Models = toCustomProviderModelFiles(normalizedModels) } data, err := yaml.Marshal(cfg) @@ -426,6 +506,59 @@ func SaveCustomProvider( return nil } +// validateModelDescriptorsRequireName 校验模型描述的 id/name 必填,拒绝 name 缺省回填为 id。 +func validateModelDescriptorsRequireName( + models []providertypes.ModelDescriptor, +) ([]providertypes.ModelDescriptor, error) { + if len(models) == 0 { + return nil, nil + } + normalized := make([]providertypes.ModelDescriptor, 0, len(models)) + for index, model := range models { + id := strings.TrimSpace(model.ID) + if id == "" { + return nil, fmt.Errorf("config: models[%d].id is empty", index) + } + name := strings.TrimSpace(model.Name) + if name == "" { + return nil, fmt.Errorf("config: models[%d].name is empty", index) + } + normalized = append(normalized, providertypes.ModelDescriptor{ + ID: id, + Name: name, + Description: strings.TrimSpace(model.Description), + ContextWindow: model.ContextWindow, + MaxOutputTokens: model.MaxOutputTokens, + CapabilityHints: model.CapabilityHints, + }) + } + return normalized, nil +} + +// toCustomProviderModelFiles 将模型描述列表转换为 custom provider.yaml 可持久化格式。 +func toCustomProviderModelFiles(models []providertypes.ModelDescriptor) []customProviderModelFile { + if len(models) == 0 { + return nil + } + items := make([]customProviderModelFile, 0, len(models)) + for _, model := range providertypes.MergeModelDescriptors(models) { + item := customProviderModelFile{ + ID: strings.TrimSpace(model.ID), + Name: strings.TrimSpace(model.Name), + } + if model.ContextWindow > 0 { + value := model.ContextWindow + item.ContextWindow = &value + } + if model.MaxOutputTokens > 0 { + value := model.MaxOutputTokens + item.MaxOutputTokens = &value + } + items = append(items, item) + } + return items +} + // encodeModelFieldAliases 将 model field aliases 序列化为稳定字符串,供运行时跨层传递。 func encodeModelFieldAliases(aliases map[string][]string) string { if len(aliases) == 0 { diff --git a/internal/config/provider_test.go b/internal/config/provider_test.go index 700389ee..407cdd11 100644 --- a/internal/config/provider_test.go +++ b/internal/config/provider_test.go @@ -291,12 +291,22 @@ func TestCustomProviderModelsRejectsEmptyID(t *testing.T) { } } +func TestCustomProviderModelsRejectsEmptyName(t *testing.T) { + t.Parallel() + + _, err := customProviderModels([]customProviderModelFile{{ID: "deepseek-coder"}}) + if err == nil || !strings.Contains(err.Error(), "models[0].name") { + t.Fatalf("expected empty name validation error, got %v", err) + } +} + func TestCustomProviderModelsRejectsNonPositiveContextWindow(t *testing.T) { t.Parallel() contextWindow := 0 _, err := customProviderModels([]customProviderModelFile{{ ID: "deepseek-coder", + Name: "DeepSeek Coder", ContextWindow: &contextWindow, }}) if err == nil || !strings.Contains(err.Error(), "context_window") { @@ -310,6 +320,7 @@ func TestCustomProviderModelsRejectsNonPositiveMaxOutputTokens(t *testing.T) { maxOutputTokens := 0 _, err := customProviderModels([]customProviderModelFile{{ ID: "deepseek-coder", + Name: "DeepSeek Coder", MaxOutputTokens: &maxOutputTokens, }}) if err == nil || !strings.Contains(err.Error(), "max_output_tokens") { @@ -321,8 +332,8 @@ func TestCustomProviderModelsRejectsDuplicateID(t *testing.T) { t.Parallel() _, err := customProviderModels([]customProviderModelFile{ - {ID: "deepseek-coder"}, - {ID: " DeepSeek-Coder "}, + {ID: "deepseek-coder", Name: "DeepSeek Coder"}, + {ID: " DeepSeek-Coder ", Name: "DeepSeek Coder Duplicate"}, }) if err == nil || !strings.Contains(err.Error(), "duplicated") { t.Fatalf("expected duplicate id validation error, got %v", err) diff --git a/internal/config/state/model.go b/internal/config/state/model.go index 4aec5033..1f31f0ba 100644 --- a/internal/config/state/model.go +++ b/internal/config/state/model.go @@ -97,6 +97,8 @@ func catalogInputFromProvider(cfg config.ProviderConfig) (provider.CatalogInput, input := provider.CatalogInput{ Identity: identity, ConfiguredModels: providertypes.CloneModelDescriptors(cloned.Models), + DisableDiscovery: cloned.Source == config.ProviderSourceCustom && + provider.NormalizeModelSource(cloned.ModelSource) == provider.ModelSourceManual, ResolveDiscoveryConfig: func() (provider.RuntimeConfig, error) { resolved, err := cloned.Resolve() if err != nil { diff --git a/internal/config/state/service_provider_create.go b/internal/config/state/service_provider_create.go index def75e97..6b412a01 100644 --- a/internal/config/state/service_provider_create.go +++ b/internal/config/state/service_provider_create.go @@ -2,8 +2,10 @@ package state import ( "context" + "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "strings" @@ -12,12 +14,13 @@ import ( "neo-code/internal/config" "neo-code/internal/provider" + providertypes "neo-code/internal/provider/types" ) var persistUserEnvVarForCreate = config.PersistUserEnvVar var deleteUserEnvVarForCreate = config.DeleteUserEnvVar var lookupUserEnvVarForCreate = config.LookupUserEnvVar -var saveCustomProviderForCreate = config.SaveCustomProvider +var saveCustomProviderWithModelsForCreate = config.SaveCustomProviderWithModels const providerCreateRollbackReloadTimeout = 3 * time.Second const providerCreateCrossProcessLockName = ".provider-create.lock" @@ -33,6 +36,8 @@ type CreateCustomProviderInput struct { BaseURL string APIKeyEnv string APIKey string + ModelSource string + ManualModelsJSON string APIStyle string DeploymentMode string APIVersion string @@ -46,6 +51,8 @@ type createCustomProviderNormalizedInput struct { BaseURL string APIKeyEnv string APIKey string + ModelSource string + ManualModels []providertypes.ModelDescriptor APIStyle string DeploymentMode string APIVersion string @@ -128,18 +135,19 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr } providerSaveAttempted = true - if err := saveCustomProviderForCreate( - s.manager.BaseDir(), - normalized.Name, - normalized.Driver, - normalized.BaseURL, - normalized.APIKeyEnv, - normalized.APIStyle, - normalized.DeploymentMode, - normalized.APIVersion, - normalized.DiscoveryEndpointPath, - normalized.DiscoveryResponseProfile, - ); err != nil { + if err := saveCustomProviderWithModelsForCreate(s.manager.BaseDir(), config.SaveCustomProviderInput{ + Name: normalized.Name, + Driver: normalized.Driver, + BaseURL: normalized.BaseURL, + APIKeyEnv: normalized.APIKeyEnv, + ModelSource: normalized.ModelSource, + Models: normalized.ManualModels, + APIStyle: normalized.APIStyle, + DeploymentMode: normalized.DeploymentMode, + APIVersion: normalized.APIVersion, + DiscoveryEndpointPath: normalized.DiscoveryEndpointPath, + DiscoveryResponseProfile: normalized.DiscoveryResponseProfile, + }); err != nil { return Selection{}, rollback(fmt.Errorf("selection: save provider config: %w", err)) } providerSaved = true @@ -168,12 +176,14 @@ func (s *Service) CreateCustomProvider(ctx context.Context, input CreateCustomPr // normalizeCreateCustomProviderInput 统一裁剪新增 Provider 输入并执行基础字段校验。 func normalizeCreateCustomProviderInput(input CreateCustomProviderInput) (createCustomProviderNormalizedInput, error) { + rawModelSource := strings.TrimSpace(input.ModelSource) normalized := createCustomProviderNormalizedInput{ Name: strings.TrimSpace(input.Name), Driver: strings.TrimSpace(input.Driver), BaseURL: strings.TrimSpace(input.BaseURL), APIKeyEnv: strings.TrimSpace(input.APIKeyEnv), APIKey: strings.TrimSpace(input.APIKey), + ModelSource: provider.NormalizeModelSource(rawModelSource), APIStyle: strings.TrimSpace(input.APIStyle), DeploymentMode: strings.TrimSpace(input.DeploymentMode), APIVersion: strings.TrimSpace(input.APIVersion), @@ -196,16 +206,28 @@ func normalizeCreateCustomProviderInput(input CreateCustomProviderInput) (create if config.IsProtectedEnvVarName(normalized.APIKeyEnv) { return createCustomProviderNormalizedInput{}, fmt.Errorf("selection: env key %q is protected", normalized.APIKeyEnv) } + if rawModelSource != "" && normalized.ModelSource == "" { + return createCustomProviderNormalizedInput{}, fmt.Errorf("selection: unsupported model source %q", input.ModelSource) + } + if normalized.ModelSource == "" { + normalized.ModelSource = provider.ModelSourceDiscover + } + normalizedDiscoveryEndpointPath := normalized.DiscoveryEndpointPath + normalizedDiscoveryResponseProfile := normalized.DiscoveryResponseProfile + if provider.NormalizeModelSource(normalized.ModelSource) == provider.ModelSourceManual { + normalizedDiscoveryEndpointPath = "" + normalizedDiscoveryResponseProfile = "" + } normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( normalized.Driver, "", "", "", - normalized.DiscoveryEndpointPath, + normalizedDiscoveryEndpointPath, "", "", normalized.APIStyle, - normalized.DiscoveryResponseProfile, + normalizedDiscoveryResponseProfile, ) if err != nil { return createCustomProviderNormalizedInput{}, err @@ -215,17 +237,99 @@ func normalizeCreateCustomProviderInput(input CreateCustomProviderInput) (create } else { normalized.APIStyle = "" } - normalized.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath - normalized.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile + switch provider.NormalizeModelSource(normalized.ModelSource) { + case provider.ModelSourceManual: + manualModels, parseErr := parseManualModelsJSON(input.ManualModelsJSON) + if parseErr != nil { + return createCustomProviderNormalizedInput{}, parseErr + } + normalized.ManualModels = manualModels + normalized.DiscoveryEndpointPath = "" + normalized.DiscoveryResponseProfile = "" + case provider.ModelSourceDiscover: + normalized.DiscoveryEndpointPath = normalizedProtocols.DiscoveryEndpointPath + normalized.DiscoveryResponseProfile = normalizedProtocols.ResponseProfile + default: + return createCustomProviderNormalizedInput{}, fmt.Errorf("selection: unsupported model source %q", input.ModelSource) + } return normalized, nil } +type manualModelJSON struct { + ID string `json:"id"` + Name string `json:"name"` + ContextWindow *int `json:"context_window,omitempty"` + MaxOutputTokens *int `json:"max_output_tokens,omitempty"` +} + +// parseManualModelsJSON 解析并校验手工模型 JSON,确保至少包含一个合法模型且 id/name 必填。 +func parseManualModelsJSON(raw string) ([]providertypes.ModelDescriptor, error) { + trimmed := strings.TrimSpace(raw) + if trimmed == "" { + return nil, errors.New("selection: manual model json is empty") + } + + decoder := json.NewDecoder(strings.NewReader(trimmed)) + decoder.DisallowUnknownFields() + var models []manualModelJSON + if err := decoder.Decode(&models); err != nil { + return nil, fmt.Errorf("selection: parse manual model json: %w", err) + } + var trailing any + if err := decoder.Decode(&trailing); err != io.EOF { + return nil, errors.New("selection: parse manual model json: unexpected trailing content") + } + if len(models) == 0 { + return nil, errors.New("selection: manual model list is empty") + } + + descriptors := make([]providertypes.ModelDescriptor, 0, len(models)) + seen := make(map[string]struct{}, len(models)) + for index, model := range models { + id := strings.TrimSpace(model.ID) + name := strings.TrimSpace(model.Name) + if id == "" { + return nil, fmt.Errorf("selection: models[%d].id is required", index) + } + if name == "" { + return nil, fmt.Errorf("selection: models[%d].name is required", index) + } + key := provider.NormalizeKey(id) + if _, exists := seen[key]; exists { + return nil, fmt.Errorf("selection: models[%d].id %q is duplicated", index, id) + } + seen[key] = struct{}{} + + descriptor := providertypes.ModelDescriptor{ + ID: id, + Name: name, + } + if model.ContextWindow != nil { + if *model.ContextWindow <= 0 { + return nil, fmt.Errorf("selection: models[%d].context_window must be greater than 0", index) + } + descriptor.ContextWindow = *model.ContextWindow + } + if model.MaxOutputTokens != nil { + if *model.MaxOutputTokens <= 0 { + return nil, fmt.Errorf("selection: models[%d].max_output_tokens must be greater than 0", index) + } + descriptor.MaxOutputTokens = *model.MaxOutputTokens + } + descriptors = append(descriptors, descriptor) + } + return providertypes.MergeModelDescriptors(descriptors), nil +} + // validateCustomProviderCreateConflict 校验新增 Provider 的名称与环境变量名是否与现有配置冲突。 func validateCustomProviderCreateConflict(cfg config.Config, input createCustomProviderNormalizedInput) error { existingProvider, err := cfg.ProviderByName(input.Name) - if err == nil && existingProvider.Source == config.ProviderSourceBuiltin { - return fmt.Errorf("selection: provider %q duplicates builtin provider", input.Name) + if err == nil { + if existingProvider.Source == config.ProviderSourceBuiltin { + return fmt.Errorf("selection: provider %q duplicates builtin provider", input.Name) + } + return fmt.Errorf("selection: provider %q already exists", input.Name) } targetProviderName := provider.NormalizeKey(input.Name) diff --git a/internal/config/state/service_provider_create_test.go b/internal/config/state/service_provider_create_test.go index 7528e773..b33a6294 100644 --- a/internal/config/state/service_provider_create_test.go +++ b/internal/config/state/service_provider_create_test.go @@ -16,11 +16,11 @@ import ( ) func TestCreateCustomProviderSuccess(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() manager := newSelectionTestManager(t, testDefaultConfig()) service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ @@ -67,12 +67,163 @@ func TestCreateCustomProviderSuccess(t *testing.T) { } } +func TestCreateCustomProviderManualSourceRequiresModelJSON(t *testing.T) { + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) + defer restorePersist() + defer restoreDelete() + defer restoreLookup() + defer restoreSaveWithModels() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{{ID: "manual-model", Name: "manual-model"}}, + }) + + _, err := service.CreateCustomProvider(context.Background(), CreateCustomProviderInput{ + Name: "manual-source-no-models", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "MANUAL_SOURCE_NO_MODELS_API_KEY", + APIKey: "test-key", + ModelSource: provider.ModelSourceManual, + }) + if err == nil || !strings.Contains(err.Error(), "manual model json is empty") { + t.Fatalf("expected missing manual model json error, got %v", err) + } +} + +func TestCreateCustomProviderManualSourcePersistsModels(t *testing.T) { + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) + defer restorePersist() + defer restoreDelete() + defer restoreLookup() + defer restoreSaveWithModels() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{{ID: "manual-model-1", Name: "Manual Model 1"}}, + }) + + input := CreateCustomProviderInput{ + Name: "manual-source-provider", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "MANUAL_SOURCE_PROVIDER_API_KEY", + APIKey: "test-key", + ModelSource: provider.ModelSourceManual, + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + ManualModelsJSON: `[ + { + "id": "manual-model-1", + "name": "Manual Model 1" + }, + { + "id": "manual-model-2", + "name": "Manual Model 2", + "context_window": 131072 + } + ]`, + } + if _, err := service.CreateCustomProvider(context.Background(), input); err != nil { + t.Fatalf("CreateCustomProvider() error = %v", err) + } + + providerPath := filepath.Join(manager.BaseDir(), "providers", input.Name, "provider.yaml") + data, readErr := os.ReadFile(providerPath) + if readErr != nil { + t.Fatalf("read provider config: %v", readErr) + } + text := string(data) + if !strings.Contains(text, "model_source: manual") { + t.Fatalf("expected provider config to persist manual model source, got %q", text) + } + if strings.Contains(text, "discovery_endpoint_path:") { + t.Fatalf("expected provider config to omit discovery endpoint in manual mode, got %q", text) + } + if !strings.Contains(text, "id: manual-model-1") || !strings.Contains(text, "id: manual-model-2") { + t.Fatalf("expected provider config to persist manual model entries, got %q", text) + } + + cfg := manager.Get() + providerCfg, err := cfg.ProviderByName(input.Name) + if err != nil { + t.Fatalf("expected provider %q in config, got %v", input.Name, err) + } + if provider.NormalizeModelSource(providerCfg.ModelSource) != provider.ModelSourceManual { + t.Fatalf("expected provider model source manual, got %q", providerCfg.ModelSource) + } + if len(providerCfg.Models) != 2 { + t.Fatalf("expected 2 manual models after reload, got %d", len(providerCfg.Models)) + } +} + +func TestNormalizeCreateCustomProviderInputDefaultsToDiscoverWhenModelSourceEmpty(t *testing.T) { + normalized, err := normalizeCreateCustomProviderInput(CreateCustomProviderInput{ + Name: "default-discover-provider", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "DEFAULT_DISCOVER_PROVIDER_API_KEY", + APIKey: "test-key", + }) + if err != nil { + t.Fatalf("normalizeCreateCustomProviderInput() error = %v", err) + } + if normalized.ModelSource != provider.ModelSourceDiscover { + t.Fatalf("expected default model source discover, got %q", normalized.ModelSource) + } + if normalized.DiscoveryEndpointPath != provider.DiscoveryEndpointPathModels { + t.Fatalf("expected default discovery endpoint /models, got %q", normalized.DiscoveryEndpointPath) + } +} + +func TestNormalizeCreateCustomProviderInputRejectsInvalidModelSource(t *testing.T) { + _, err := normalizeCreateCustomProviderInput(CreateCustomProviderInput{ + Name: "invalid-model-source-provider", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "INVALID_MODEL_SOURCE_PROVIDER_API_KEY", + APIKey: "test-key", + ModelSource: "manul", + }) + if err == nil || !strings.Contains(err.Error(), "unsupported model source") { + t.Fatalf("expected invalid model source error, got %v", err) + } +} + +func TestNormalizeCreateCustomProviderInputManualSkipsDiscoveryFieldValidation(t *testing.T) { + normalized, err := normalizeCreateCustomProviderInput(CreateCustomProviderInput{ + Name: "manual-no-discovery-validation", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "MANUAL_NO_DISCOVERY_VALIDATION_API_KEY", + APIKey: "test-key", + ModelSource: provider.ModelSourceManual, + DiscoveryResponseProfile: "invalid-profile", + ManualModelsJSON: `[{"id":"manual-model","name":"Manual Model"}]`, + }) + if err != nil { + t.Fatalf("normalizeCreateCustomProviderInput() error = %v", err) + } + if normalized.DiscoveryEndpointPath != "" { + t.Fatalf("expected manual mode to clear discovery endpoint path, got %q", normalized.DiscoveryEndpointPath) + } + if normalized.DiscoveryResponseProfile != "" { + t.Fatalf( + "expected manual mode to clear discovery response profile, got %q", + normalized.DiscoveryResponseProfile, + ) + } + if len(normalized.ManualModels) != 1 { + t.Fatalf("expected one manual model, got %d", len(normalized.ManualModels)) + } +} + func TestCreateCustomProviderRollbackOnSelectFailure(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() manager := newSelectionTestManager(t, testDefaultConfig()) service := NewService(manager, newDriverSupporterStub(), errorCatalogStub{err: context.DeadlineExceeded}) @@ -110,11 +261,11 @@ func TestCreateCustomProviderRollbackOnSelectFailure(t *testing.T) { } func TestCreateCustomProviderRejectsEnvConflicts(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() manager := newSelectionTestManager(t, testDefaultConfig()) service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ @@ -134,12 +285,50 @@ func TestCreateCustomProviderRejectsEnvConflicts(t *testing.T) { } } +func TestCreateCustomProviderRejectsDuplicateCustomProviderName(t *testing.T) { + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) + defer restorePersist() + defer restoreDelete() + defer restoreLookup() + defer restoreSaveWithModels() + + manager := newSelectionTestManager(t, testDefaultConfig()) + service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ + listModels: []providertypes.ModelDescriptor{{ID: "m1", Name: "m1"}}, + }) + + firstInput := CreateCustomProviderInput{ + Name: "duplicate-custom-provider", + Driver: provider.DriverOpenAICompat, + BaseURL: "https://llm.example.com/v1", + APIKeyEnv: "DUPLICATE_CUSTOM_PROVIDER_A_API_KEY", + APIKey: "key-a", + APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, + } + restoreA := captureEnvForCreateProvider(t, firstInput.APIKeyEnv) + defer restoreA() + if _, err := service.CreateCustomProvider(context.Background(), firstInput); err != nil { + t.Fatalf("first CreateCustomProvider() error = %v", err) + } + + secondInput := firstInput + secondInput.APIKeyEnv = "DUPLICATE_CUSTOM_PROVIDER_B_API_KEY" + secondInput.APIKey = "key-b" + restoreB := captureEnvForCreateProvider(t, secondInput.APIKeyEnv) + defer restoreB() + + _, err := service.CreateCustomProvider(context.Background(), secondInput) + if err == nil || !strings.Contains(err.Error(), "already exists") { + t.Fatalf("expected duplicate provider name error, got %v", err) + } +} + func TestCreateCustomProviderRejectsProtectedEnvName(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() manager := newSelectionTestManager(t, testDefaultConfig()) service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ @@ -160,11 +349,11 @@ func TestCreateCustomProviderRejectsProtectedEnvName(t *testing.T) { } func TestCreateCustomProviderRejectsInvalidProviderName(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() manager := newSelectionTestManager(t, testDefaultConfig()) service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ @@ -185,11 +374,11 @@ func TestCreateCustomProviderRejectsInvalidProviderName(t *testing.T) { } func TestCreateCustomProviderSerializesAcrossServicesSharingManager(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() manager := newSelectionTestManager(t, testDefaultConfig()) failingService := NewService(manager, newDriverSupporterStub(), errorCatalogStub{err: context.DeadlineExceeded}) @@ -279,11 +468,11 @@ func TestCreateCustomProviderSerializesAcrossServicesSharingManager(t *testing.T } func TestCreateCustomProviderRollbackOnSaveProviderFailure(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() manager := newSelectionTestManager(t, testDefaultConfig()) service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ @@ -298,19 +487,8 @@ func TestCreateCustomProviderRollbackOnSaveProviderFailure(t *testing.T) { APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, } - saveCustomProviderForCreate = func( - baseDir string, - name string, - driver string, - baseURL string, - apiKeyEnv string, - apiStyle string, - deploymentMode string, - apiVersion string, - discoveryEndpointPath string, - discoveryResponseProfile string, - ) error { - providerDir := filepath.Join(baseDir, "providers", name) + saveCustomProviderWithModelsForCreate = func(baseDir string, input configpkg.SaveCustomProviderInput) error { + providerDir := filepath.Join(baseDir, "providers", input.Name) if err := os.MkdirAll(providerDir, 0o755); err != nil { return err } @@ -335,11 +513,11 @@ func TestCreateCustomProviderRollbackOnSaveProviderFailure(t *testing.T) { } func TestCreateCustomProviderSerializesAcrossManagersSharingBaseDir(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() baseDir := t.TempDir() loaderA := configpkg.NewLoader(baseDir, testDefaultConfig()) @@ -466,11 +644,11 @@ func TestLockProviderCreateCrossProcessWaitsForFreshLockUntilContextDone(t *test } func TestCreateCustomProviderRejectsInvalidDiscoverySettings(t *testing.T) { - restorePersist, restoreDelete, restoreLookup, restoreSave := stubUserEnvOpsForCreateProvider(t) + restorePersist, restoreDelete, restoreLookup, restoreSaveWithModels := stubUserEnvOpsForCreateProvider(t) defer restorePersist() defer restoreDelete() defer restoreLookup() - defer restoreSave() + defer restoreSaveWithModels() manager := newSelectionTestManager(t, testDefaultConfig()) service := NewService(manager, newDriverSupporterStub(), catalogMethodsStub{ @@ -576,15 +754,15 @@ func stubUserEnvOpsForCreateProvider(t *testing.T) (func(), func(), func(), func prevPersist := persistUserEnvVarForCreate prevDelete := deleteUserEnvVarForCreate prevLookup := lookupUserEnvVarForCreate - prevSave := saveCustomProviderForCreate + prevSaveWithModels := saveCustomProviderWithModelsForCreate persistUserEnvVarForCreate = func(key string, value string) error { return nil } deleteUserEnvVarForCreate = func(key string) error { return nil } lookupUserEnvVarForCreate = func(key string) (string, bool, error) { return "", false, nil } - saveCustomProviderForCreate = configpkg.SaveCustomProvider + saveCustomProviderWithModelsForCreate = configpkg.SaveCustomProviderWithModels return func() { persistUserEnvVarForCreate = prevPersist }, func() { deleteUserEnvVarForCreate = prevDelete }, func() { lookupUserEnvVarForCreate = prevLookup }, - func() { saveCustomProviderForCreate = prevSave } + func() { saveCustomProviderWithModelsForCreate = prevSaveWithModels } } diff --git a/internal/provider/catalog/service.go b/internal/provider/catalog/service.go index 53124fca..ca72ea07 100644 --- a/internal/provider/catalog/service.go +++ b/internal/provider/catalog/service.go @@ -105,6 +105,10 @@ func (s *Service) modelsForProvider(ctx context.Context, input provider.CatalogI configuredModels := providertypes.MergeModelDescriptors(input.ConfiguredModels) defaultModels := providertypes.MergeModelDescriptors(input.DefaultModels) + if input.DisableDiscovery { + return providertypes.MergeModelDescriptors(configuredModels, defaultModels), nil + } + snapshot := s.catalogSnapshot(ctx, input) models := snapshot.models diff --git a/internal/provider/catalog/service_test.go b/internal/provider/catalog/service_test.go index ebdf741c..00f175ee 100644 --- a/internal/provider/catalog/service_test.go +++ b/internal/provider/catalog/service_test.go @@ -190,7 +190,7 @@ func TestListProviderModelsReturnsDiscoveryErrorOnCacheMiss(t *testing.T) { }), newMemoryStore()) _, err := service.ListProviderModels(context.Background(), customGatewayProviderSource()) - if err == nil || !strings.Contains(err.Error(), "OPENAI_API_KEY") { + if err == nil || !strings.Contains(err.Error(), testAPIKeyEnv) { t.Fatalf("expected discovery-time api key error, got %v", err) } } @@ -636,7 +636,9 @@ func newRegistry(t *testing.T, name string, discover provider.DiscoveryFunc) *pr } func openAIProviderSource() provider.CatalogInput { - return mustCatalogInput(nil, config.OpenAIProvider()) + providerCfg := config.OpenAIProvider() + providerCfg.APIKeyEnv = testAPIKeyEnv + return mustCatalogInput(nil, providerCfg) } func customGatewayProviderSource() provider.CatalogInput { @@ -741,7 +743,7 @@ func (s *memoryStore) Save(ctx context.Context, modelCatalog ModelCatalog) error return nil } -const testAPIKeyEnv = "OPENAI_API_KEY" +const testAPIKeyEnv = "NEOCODE_TEST_CATALOG_API_KEY" type failSaveStore struct { *memoryStore diff --git a/internal/provider/constants.go b/internal/provider/constants.go index 8962276e..1fb1ecfb 100644 --- a/internal/provider/constants.go +++ b/internal/provider/constants.go @@ -28,4 +28,7 @@ const ( DiscoveryResponseProfileOpenAI = "openai" DiscoveryResponseProfileGemini = "gemini" DiscoveryResponseProfileGeneric = "generic" + + ModelSourceDiscover = "discover" + ModelSourceManual = "manual" ) diff --git a/internal/provider/contracts.go b/internal/provider/contracts.go index 6592ffca..37a401d9 100644 --- a/internal/provider/contracts.go +++ b/internal/provider/contracts.go @@ -36,5 +36,6 @@ type CatalogInput struct { Identity ProviderIdentity ConfiguredModels []providertypes.ModelDescriptor DefaultModels []providertypes.ModelDescriptor + DisableDiscovery bool ResolveDiscoveryConfig func() (RuntimeConfig, error) } diff --git a/internal/provider/model_source.go b/internal/provider/model_source.go new file mode 100644 index 00000000..45c9983a --- /dev/null +++ b/internal/provider/model_source.go @@ -0,0 +1,13 @@ +package provider + +// NormalizeModelSource 规范化模型来源枚举,未知值返回空字符串供上层做校验与回退。 +func NormalizeModelSource(value string) string { + switch NormalizeKey(value) { + case ModelSourceDiscover: + return ModelSourceDiscover + case ModelSourceManual: + return ModelSourceManual + default: + return "" + } +} diff --git a/internal/runtime/input_prepare_test.go b/internal/runtime/input_prepare_test.go index d3f53b4f..3e0b1755 100644 --- a/internal/runtime/input_prepare_test.go +++ b/internal/runtime/input_prepare_test.go @@ -143,7 +143,7 @@ func TestServicePrepareUserInputDoesNotBlockWhenPrepareEventQueueIsFull(t *testi if input.SessionID == "" { t.Fatalf("expected prepared session id") } - if elapsed := time.Since(start); elapsed > time.Second { + if elapsed := time.Since(start); elapsed > 5*time.Second { t.Fatalf("PrepareUserInput() blocked too long with full event queue: %v", elapsed) } } diff --git a/internal/session/sqlite_store_additional_test.go b/internal/session/sqlite_store_additional_test.go index c7d0eb10..627bf6df 100644 --- a/internal/session/sqlite_store_additional_test.go +++ b/internal/session/sqlite_store_additional_test.go @@ -182,15 +182,10 @@ func TestStorageHelpersAdditionalErrorBranches(t *testing.T) { if err := tempFile.Close(); err != nil { t.Fatalf("tempFile.Close() error = %v", err) } - targetDir := filepath.Join(baseDir, "target-dir") - if err := os.MkdirAll(targetDir, 0o755); err != nil { - t.Fatalf("MkdirAll(targetDir) error = %v", err) - } - if err := os.WriteFile(filepath.Join(targetDir, "existing.txt"), []byte("x"), 0o644); err != nil { - t.Fatalf("WriteFile(existing) error = %v", err) - } - if err := replaceFileWithTemp(tempPath, targetDir, "target-dir"); err == nil { - t.Fatalf("expected replaceFileWithTemp remove-target error") + // Using an empty path for target will cause os.Remove to fail. + err = replaceFileWithTemp(tempPath, "", "invalid") + if err == nil { + t.Fatalf("expected replaceFileWithTemp remove-target error for empty path") } } diff --git a/internal/tools/filesystem/glob_test.go b/internal/tools/filesystem/glob_test.go index aa23ddf1..3b6e0907 100644 --- a/internal/tools/filesystem/glob_test.go +++ b/internal/tools/filesystem/glob_test.go @@ -197,6 +197,9 @@ func TestGlobToolFiltersSensitiveAndSymlinkEscapes(t *testing.T) { if err := os.Symlink(filepath.Join(outside, "secret.txt"), linkPath); err != nil { t.Skipf("symlink unavailable: %v", err) } + if _, err := os.Lstat(linkPath); err != nil { + t.Skipf("symlink created but missing: %v", err) + } tool := NewGlob(workspace) result, err := tool.Execute(context.Background(), tools.ToolCallInput{ diff --git a/internal/tools/filesystem/grep_test.go b/internal/tools/filesystem/grep_test.go index 61397c48..f89cdab8 100644 --- a/internal/tools/filesystem/grep_test.go +++ b/internal/tools/filesystem/grep_test.go @@ -196,6 +196,9 @@ func TestGrepToolFiltersSensitiveAndSymlinkEscapes(t *testing.T) { if err := os.Symlink(filepath.Join(outside, "secret.txt"), linkPath); err != nil { t.Skipf("symlink unavailable: %v", err) } + if _, err := os.Lstat(linkPath); err != nil { + t.Skipf("symlink created but missing: %v", err) + } tool := NewGrep(workspace) result, err := tool.Execute(context.Background(), tools.ToolCallInput{ diff --git a/internal/tools/filesystem/result_filter.go b/internal/tools/filesystem/result_filter.go index 9ebc84d7..5763839e 100644 --- a/internal/tools/filesystem/result_filter.go +++ b/internal/tools/filesystem/result_filter.go @@ -2,6 +2,7 @@ package filesystem import ( "path/filepath" + goruntime "runtime" "strings" ) @@ -54,6 +55,10 @@ func (f *resultPathFilter) evaluate(path string) (relative string, reason string // isPathWithinRoot 判断目标路径是否仍在工作区根目录之内。 func isPathWithinRoot(root string, candidate string) bool { + if goruntime.GOOS == "windows" { + root = strings.ToLower(root) + candidate = strings.ToLower(candidate) + } rel, err := filepath.Rel(root, candidate) if err != nil { return false diff --git a/internal/tools/filesystem/result_filter_test.go b/internal/tools/filesystem/result_filter_test.go index 7f16c4fb..4859ddae 100644 --- a/internal/tools/filesystem/result_filter_test.go +++ b/internal/tools/filesystem/result_filter_test.go @@ -18,6 +18,9 @@ func TestResultPathFilterEvaluateReasons(t *testing.T) { if err := os.Symlink(filepath.Join(outside, "target.txt"), linkPath); err != nil { t.Skipf("symlink unavailable: %v", err) } + if _, err := os.Lstat(linkPath); err != nil { + t.Skipf("symlink created but missing: %v", err) + } filter, err := newResultPathFilter(workspace) if err != nil { diff --git a/internal/tools/manager_toctou_test.go b/internal/tools/manager_toctou_test.go index 459d70c2..aab28893 100644 --- a/internal/tools/manager_toctou_test.go +++ b/internal/tools/manager_toctou_test.go @@ -39,7 +39,7 @@ func (s *mutatingWorkspaceSandbox) Check( } func TestDefaultManagerTOCTOUScenarios(t *testing.T) { - t.Parallel() + // t.Parallel() tests := []struct { name string @@ -49,7 +49,7 @@ func TestDefaultManagerTOCTOUScenarios(t *testing.T) { { name: "read file blocks symlink swap after sandbox check", setup: func(t *testing.T, workspace string) (*tools.Registry, tools.ToolCallInput, func(plan *security.WorkspaceExecutionPlan) error) { - t.Helper() + // t.Helper() safePath := filepath.Join(workspace, "safe.txt") if err := os.WriteFile(safePath, []byte("safe"), 0o644); err != nil { @@ -62,6 +62,7 @@ func TestDefaultManagerTOCTOUScenarios(t *testing.T) { } linkPath := filepath.Join(workspace, "swap-link.txt") + requireSymlinkSupport(t, safePath, linkPath+"_probe") if err := os.Symlink(safePath, linkPath); err != nil { t.Skipf("symlink not supported in this environment: %v", err) } @@ -195,7 +196,7 @@ func TestDefaultManagerTOCTOUScenarios(t *testing.T) { for _, tt := range tests { tt := tt t.Run(tt.name, func(t *testing.T) { - t.Parallel() + // t.Parallel() workspace := t.TempDir() registry, input, mutate := tt.setup(t, workspace) @@ -238,5 +239,8 @@ func requireSymlinkSupport(t *testing.T, target string, link string) { if err := os.Symlink(target, link); err != nil { t.Skipf("symlink not supported in this environment: %v", err) } + if _, err := os.Lstat(link); err != nil { + t.Skipf("symlink created but missing: %v", err) + } _ = os.Remove(link) } diff --git a/internal/tui/core/app/app.go b/internal/tui/core/app/app.go index c267cc66..082b0542 100644 --- a/internal/tui/core/app/app.go +++ b/internal/tui/core/app/app.go @@ -133,23 +133,34 @@ type pendingImageAttachment struct { // providerAddFormState 保存添加新 provider 表单的状态。 type providerAddFormState struct { + Stage providerAddFormStage Step int // 当前聚焦字段在“当前 driver 可见字段列表”中的索引 Name string Driver string + ModelSource string BaseURL string APIStyle string DeploymentMode string APIVersion string DiscoveryEndpointPath string DiscoveryResponseProfile string + ManualModelsJSON string APIKeyEnv string APIKey string Error string ErrorIsHard bool Submitting bool Drivers []string // 可选的 Driver 列表 + ModelSources []string // 可选的模型来源列表 } +type providerAddFormStage int + +const ( + providerAddFormStageFields providerAddFormStage = iota + providerAddFormStageManualModels +) + type App struct { state tuistate.UIState appServices diff --git a/internal/tui/core/app/update.go b/internal/tui/core/app/update.go index e2ec882d..35597cff 100644 --- a/internal/tui/core/app/update.go +++ b/internal/tui/core/app/update.go @@ -45,6 +45,7 @@ const ( const providerAddSelectTimeout = 10 * time.Second const providerAddNonPersistentEnvWarning = "API key is applied to the current process only on this platform; persist it in your shell profile for future sessions." +const providerAddManualModelsJSONTemplate = "[\n {\n \"id\": \"model-id\",\n \"name\": \"Model Name\"\n }\n]" const sessionSwitchBusyMessage = "cannot switch sessions while run or compact is active" @@ -2439,6 +2440,7 @@ type providerAddFieldID int const ( providerAddFieldName providerAddFieldID = iota providerAddFieldDriver + providerAddFieldModelSource providerAddFieldBaseURL providerAddFieldAPIStyle providerAddFieldDeploymentMode @@ -2449,10 +2451,11 @@ const ( providerAddFieldAPIKey ) -func providerAddVisibleFields(driver string) []providerAddFieldID { +func providerAddVisibleFields(driver string, modelSource string) []providerAddFieldID { fields := []providerAddFieldID{ providerAddFieldName, providerAddFieldDriver, + providerAddFieldModelSource, providerAddFieldBaseURL, } @@ -2465,7 +2468,9 @@ func providerAddVisibleFields(driver string) []providerAddFieldID { fields = append(fields, providerAddFieldAPIVersion) } - fields = append(fields, providerAddFieldDiscoveryEndpointPath, providerAddFieldDiscoveryResponseProfile) + if provider.NormalizeModelSource(strings.TrimSpace(modelSource)) == provider.ModelSourceDiscover { + fields = append(fields, providerAddFieldDiscoveryEndpointPath, providerAddFieldDiscoveryResponseProfile) + } fields = append(fields, providerAddFieldAPIKeyEnv, providerAddFieldAPIKey) return fields } @@ -2474,7 +2479,7 @@ func clampProviderAddStep(form *providerAddFormState) { if form == nil { return } - fields := providerAddVisibleFields(form.Driver) + fields := providerAddVisibleFields(form.Driver, form.ModelSource) if len(fields) == 0 { form.Step = 0 return @@ -2492,7 +2497,7 @@ func currentProviderAddField(form *providerAddFormState) providerAddFieldID { return providerAddFieldName } clampProviderAddStep(form) - fields := providerAddVisibleFields(form.Driver) + fields := providerAddVisibleFields(form.Driver, form.ModelSource) if len(fields) == 0 { return providerAddFieldName } @@ -2501,20 +2506,24 @@ func currentProviderAddField(form *providerAddFormState) providerAddFieldID { func (a *App) startProviderAddForm() { a.providerAddForm = &providerAddFormState{ + Stage: providerAddFormStageFields, Step: 0, Name: "", Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceDiscover, BaseURL: "", APIStyle: provider.OpenAICompatibleAPIStyleChatCompletions, DeploymentMode: "", APIVersion: "", DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, DiscoveryResponseProfile: provider.DiscoveryResponseProfileOpenAI, + ManualModelsJSON: "", APIKeyEnv: "", APIKey: "", Error: "", ErrorIsHard: false, Drivers: []string{provider.DriverOpenAICompat, provider.DriverGemini, provider.DriverAnthropic}, + ModelSources: []string{provider.ModelSourceDiscover, provider.ModelSourceManual}, } a.state.ActivePicker = pickerProviderAdd a.state.StatusText = "Add new provider" @@ -2527,8 +2536,36 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } typed := msg + if a.providerAddForm.Stage == providerAddFormStageManualModels { + switch { + case key.Matches(typed, a.keys.Send): + return a, a.submitProviderAddForm() + case key.Matches(typed, a.keys.FocusInput): + a.providerAddForm = nil + a.state.ActivePicker = pickerNone + a.state.StatusText = statusReady + return a, nil + case key.Matches(typed, a.keys.PrevPanel): + a.providerAddForm.Stage = providerAddFormStageFields + a.providerAddForm.Error = "" + a.providerAddForm.ErrorIsHard = false + return a, nil + case typed.Type == tea.KeyBackspace: + a.providerAddForm.ManualModelsJSON = trimLastRune(a.providerAddForm.ManualModelsJSON) + return a, nil + case key.Matches(typed, a.keys.Newline): + a.providerAddForm.ManualModelsJSON += "\n" + return a, nil + default: + if len(typed.Runes) > 0 { + a.providerAddForm.ManualModelsJSON += sanitizeProviderAddJSONInputRunes(typed.Runes) + } + return a, nil + } + } + prevStep := a.providerAddForm.Step - fields := providerAddVisibleFields(a.providerAddForm.Driver) + fields := providerAddVisibleFields(a.providerAddForm.Driver, a.providerAddForm.ModelSource) fieldCount := len(fields) if fieldCount == 0 { fieldCount = 1 @@ -2583,6 +2620,17 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerAddForm.Driver = a.providerAddForm.Drivers[currentIdx] clampProviderAddStep(a.providerAddForm) } + } else if currentProviderAddField(a.providerAddForm) == providerAddFieldModelSource { + currentIdx := 0 + for i, source := range a.providerAddForm.ModelSources { + if source == a.providerAddForm.ModelSource { + currentIdx = i + break + } + } + currentIdx = (currentIdx - 1 + len(a.providerAddForm.ModelSources)) % len(a.providerAddForm.ModelSources) + a.providerAddForm.ModelSource = a.providerAddForm.ModelSources[currentIdx] + clampProviderAddStep(a.providerAddForm) } return a, nil case typed.Type == tea.KeyDown: @@ -2599,6 +2647,17 @@ func (a *App) handleProviderAddFormInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { a.providerAddForm.Driver = a.providerAddForm.Drivers[currentIdx] clampProviderAddStep(a.providerAddForm) } + } else if currentProviderAddField(a.providerAddForm) == providerAddFieldModelSource { + currentIdx := 0 + for i, source := range a.providerAddForm.ModelSources { + if source == a.providerAddForm.ModelSource { + currentIdx = i + break + } + } + currentIdx = (currentIdx + 1) % len(a.providerAddForm.ModelSources) + a.providerAddForm.ModelSource = a.providerAddForm.ModelSources[currentIdx] + clampProviderAddStep(a.providerAddForm) } return a, nil default: @@ -2647,6 +2706,21 @@ func (a *App) submitProviderAddForm() tea.Cmd { a.providerAddForm.ErrorIsHard = false return nil } + if request.ModelSource == provider.ModelSourceManual && a.providerAddForm.Stage == providerAddFormStageFields { + a.providerAddForm.Stage = providerAddFormStageManualModels + a.providerAddForm.Error = "" + a.providerAddForm.ErrorIsHard = false + if strings.TrimSpace(a.providerAddForm.ManualModelsJSON) == "" { + a.providerAddForm.ManualModelsJSON = providerAddManualModelsJSONTemplate + } + a.state.StatusText = "Fill manual model JSON" + return nil + } + if request.ModelSource == provider.ModelSourceManual && strings.TrimSpace(request.ManualModelsJSON) == "" { + a.providerAddForm.Error = "Please update the form: Model JSON is required for manual model source" + a.providerAddForm.ErrorIsHard = false + return nil + } a.providerAddForm.Submitting = true a.providerAddForm.Error = "" @@ -2661,6 +2735,8 @@ type providerAddRequest struct { Name string Driver string BaseURL string + ModelSource string + ManualModelsJSON string APIStyle string DeploymentMode string APIVersion string @@ -2681,7 +2757,9 @@ func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, str request := providerAddRequest{ Name: normalizeProviderAddFieldValue(form.Name), Driver: provider.NormalizeProviderDriver(normalizeProviderAddFieldValue(form.Driver)), + ModelSource: provider.NormalizeModelSource(normalizeProviderAddFieldValue(form.ModelSource)), BaseURL: normalizeProviderAddFieldValue(form.BaseURL), + ManualModelsJSON: strings.TrimSpace(form.ManualModelsJSON), APIStyle: normalizeProviderAddFieldValue(form.APIStyle), DeploymentMode: normalizeProviderAddFieldValue(form.DeploymentMode), APIVersion: normalizeProviderAddFieldValue(form.APIVersion), @@ -2697,6 +2775,9 @@ func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, str if request.Driver == "" { return providerAddRequest{}, "Driver is required" } + if request.ModelSource == "" { + return providerAddRequest{}, "Model Source must be discover or manual" + } if request.APIKey == "" { return providerAddRequest{}, "API Key is required" } @@ -2740,6 +2821,17 @@ func buildProviderAddRequest(form providerAddFormState) (providerAddRequest, str request.DeploymentMode = "" request.APIVersion = "" } + + if request.ModelSource == provider.ModelSourceManual { + request.DiscoveryEndpointPath = "" + request.DiscoveryResponseProfile = "" + return request, "" + } + + if request.DiscoveryEndpointPath == "" { + return providerAddRequest{}, "Discovery Endpoint is required for discover model source" + } + normalizedProtocols, err := provider.NormalizeProviderProtocolSettings( request.Driver, "", @@ -2782,6 +2874,29 @@ func sanitizeProviderAddInputRunes(runes []rune) string { return builder.String() } +// sanitizeProviderAddJSONInputRunes 过滤不可见格式控制字符,保留 JSON 编辑需要的换行与制表符。 +func sanitizeProviderAddJSONInputRunes(runes []rune) string { + if len(runes) == 0 { + return "" + } + + var builder strings.Builder + builder.Grow(len(runes)) + for _, r := range runes { + if unicode.In(r, unicode.Cf) { + continue + } + if unicode.IsControl(r) && r != '\n' && r != '\r' && r != '\t' { + continue + } + if r == '\r' { + continue + } + builder.WriteRune(r) + } + return builder.String() +} + // normalizeProviderAddFieldValue 对 provider 表单字段做统一清理,去除控制字符并裁剪首尾空白。 func normalizeProviderAddFieldValue(value string) string { return strings.TrimSpace(sanitizeProviderAddInputRunes([]rune(value))) @@ -2828,6 +2943,8 @@ func (a *App) runProviderAddFlow(request providerAddRequest) tea.Cmd { Name: request.Name, Driver: request.Driver, BaseURL: request.BaseURL, + ModelSource: request.ModelSource, + ManualModelsJSON: request.ManualModelsJSON, APIStyle: request.APIStyle, DeploymentMode: request.DeploymentMode, APIVersion: request.APIVersion, diff --git a/internal/tui/core/app/update_test.go b/internal/tui/core/app/update_test.go index b068334a..5a2ca655 100644 --- a/internal/tui/core/app/update_test.go +++ b/internal/tui/core/app/update_test.go @@ -5,6 +5,7 @@ import ( "errors" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -431,6 +432,34 @@ func TestSubmitProviderAddFormRedactsSensitiveError(t *testing.T) { } } +func TestSubmitProviderAddFormTransitionsToManualStageWhenModelSourceManual(t *testing.T) { + app, _ := newTestApp(t) + app.startProviderAddForm() + + app.providerAddForm.Name = "manual-stage-gateway" + app.providerAddForm.Driver = provider.DriverOpenAICompat + app.providerAddForm.ModelSource = provider.ModelSourceManual + app.providerAddForm.APIKeyEnv = "MANUAL_STAGE_GATEWAY_API_KEY" + app.providerAddForm.APIKey = "sk-manual-stage" + + cmd := app.submitProviderAddForm() + if cmd != nil { + t.Fatalf("expected no async command when entering manual JSON stage") + } + if app.providerAddForm == nil { + t.Fatalf("expected provider form to remain open") + } + if app.providerAddForm.Stage != providerAddFormStageManualModels { + t.Fatalf("expected form stage manual models, got %v", app.providerAddForm.Stage) + } + if strings.TrimSpace(app.providerAddForm.ManualModelsJSON) != strings.TrimSpace(providerAddManualModelsJSONTemplate) { + t.Fatalf("expected manual model template to be prefilled, got %q", app.providerAddForm.ManualModelsJSON) + } + if app.state.StatusText != "Fill manual model JSON" { + t.Fatalf("expected manual stage status text, got %q", app.state.StatusText) + } +} + func TestSubmitProviderAddFormRequiresAPIKeyEnv(t *testing.T) { app, _ := newTestApp(t) app.startProviderAddForm() @@ -2270,7 +2299,7 @@ func TestCurrentProviderAddFieldAndInputHandling(t *testing.T) { t.Fatalf("expected name field append, got %q", app.providerAddForm.Name) } - app.providerAddForm.Step = 6 // api key env + app.providerAddForm.Step = 7 // api key env model, cmd = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'\x00', 'D', 'E', 'E', 'P'}}) if cmd != nil { t.Fatalf("expected nil cmd for env key rune input") @@ -2306,6 +2335,18 @@ func TestCurrentProviderAddFieldAndInputHandling(t *testing.T) { t.Fatalf("expected key down to switch driver") } + app.providerAddForm.Step = 2 // model source + modelSourceBefore := app.providerAddForm.ModelSource + model, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyDown}) + ptr, ok = model.(*App) + if !ok { + t.Fatalf("expected *App model, got %T", model) + } + app = *ptr + if app.providerAddForm.ModelSource == modelSourceBefore { + t.Fatalf("expected key down to switch model source") + } + app.providerAddForm.Step = 0 model, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyBackspace}) ptr, ok = model.(*App) @@ -2387,34 +2428,57 @@ func TestBuildProviderAddRequest(t *testing.T) { if _, err := buildProviderAddRequest(providerAddFormState{Name: "demo"}); !strings.Contains(err, "Driver is required") { t.Fatalf("expected missing driver error, got %q", err) } - if _, err := buildProviderAddRequest(providerAddFormState{Name: "demo", Driver: provider.DriverGemini}); !strings.Contains(err, "API Key is required") { + if _, err := buildProviderAddRequest(providerAddFormState{ + Name: "demo", + Driver: provider.DriverGemini, + }); !strings.Contains(err, "Model Source") { + t.Fatalf("expected missing model source error, got %q", err) + } + if _, err := buildProviderAddRequest(providerAddFormState{ + Name: "demo", + Driver: provider.DriverGemini, + ModelSource: provider.ModelSourceManual, + }); !strings.Contains(err, "API Key is required") { t.Fatalf("expected missing key error, got %q", err) } if _, err := buildProviderAddRequest(providerAddFormState{ - Name: "demo", - Driver: provider.DriverGemini, - APIKey: "k", - APIKeyEnv: "", + Name: "demo", + Driver: provider.DriverGemini, + ModelSource: provider.ModelSourceManual, + APIKey: "k", + APIKeyEnv: "", }); !strings.Contains(err, "API Key Env is required") { t.Fatalf("expected missing env key error, got %q", err) } }) - t.Run("openai compat applies defaults", func(t *testing.T) { + t.Run("openai compat discover mode requires explicit discovery endpoint path", func(t *testing.T) { + req, err := buildProviderAddRequest(providerAddFormState{ + Name: "openai-compat", + Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceDiscover, + APIKey: "k", + APIKeyEnv: "OPENAI_COMPAT_API_KEY", + }) + if !strings.Contains(err, "Discovery Endpoint is required") { + t.Fatalf("expected missing discovery endpoint error, got %q and req=%+v", err, req) + } + }) + + t.Run("openai compat discover mode normalizes discovery settings", func(t *testing.T) { req, err := buildProviderAddRequest(providerAddFormState{ - Name: "openai-compat", - Driver: provider.DriverOpenAICompat, - APIKey: "k", - APIKeyEnv: "OPENAI_COMPAT_API_KEY", + Name: "openai-compat-discover", + Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceDiscover, + APIKey: "k", + APIKeyEnv: "OPENAI_COMPAT_DISCOVER_API_KEY", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, }) if err != "" { t.Fatalf("unexpected error: %s", err) } - if req.BaseURL != config.OpenAIDefaultBaseURL { - t.Fatalf("expected openai default base url, got %q", req.BaseURL) - } - if req.APIStyle != provider.OpenAICompatibleAPIStyleChatCompletions { - t.Fatalf("expected default api style") + if req.ModelSource != provider.ModelSourceDiscover { + t.Fatalf("expected discover model source, got %q", req.ModelSource) } if req.DiscoveryEndpointPath != provider.DiscoveryEndpointPathModels { t.Fatalf("expected default discovery endpoint, got %q", req.DiscoveryEndpointPath) @@ -2426,10 +2490,11 @@ func TestBuildProviderAddRequest(t *testing.T) { t.Run("strips control chars from env key before validation", func(t *testing.T) { req, err := buildProviderAddRequest(providerAddFormState{ - Name: "openai-compat", - Driver: provider.DriverOpenAICompat, - APIKey: "k", - APIKeyEnv: "\x00OPENAI_COMPAT_API_KEY", + Name: "openai-compat", + Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceManual, + APIKey: "k", + APIKeyEnv: "\x00OPENAI_COMPAT_API_KEY", }) if err != "" { t.Fatalf("unexpected error: %s", err) @@ -2441,10 +2506,11 @@ func TestBuildProviderAddRequest(t *testing.T) { t.Run("rejects protected env key", func(t *testing.T) { if _, err := buildProviderAddRequest(providerAddFormState{ - Name: "openai-compat", - Driver: provider.DriverOpenAICompat, - APIKey: "k", - APIKeyEnv: "PATH", + Name: "openai-compat", + Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceManual, + APIKey: "k", + APIKeyEnv: "PATH", }); !strings.Contains(err, "protected") { t.Fatalf("expected protected env key error, got %q", err) } @@ -2454,6 +2520,7 @@ func TestBuildProviderAddRequest(t *testing.T) { req, err := buildProviderAddRequest(providerAddFormState{ Name: "gemini", Driver: provider.DriverGemini, + ModelSource: provider.ModelSourceManual, APIKey: "k", APIKeyEnv: "GEMINI_GATEWAY_API_KEY", APIStyle: "x", @@ -2472,6 +2539,7 @@ func TestBuildProviderAddRequest(t *testing.T) { if _, err := buildProviderAddRequest(providerAddFormState{ Name: "openai-compat", Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceDiscover, APIKey: "k", APIKeyEnv: "OPENAI_COMPAT_API_KEY", DiscoveryEndpointPath: "https://api.example.com/models", @@ -2484,8 +2552,10 @@ func TestBuildProviderAddRequest(t *testing.T) { if _, err := buildProviderAddRequest(providerAddFormState{ Name: "openai-compat", Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceDiscover, APIKey: "k", APIKeyEnv: "OPENAI_COMPAT_API_KEY", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, DiscoveryResponseProfile: "unsupported-profile", }); !strings.Contains(err, "unsupported") { t.Fatalf("expected invalid discovery response profile error, got %q", err) @@ -2494,24 +2564,44 @@ func TestBuildProviderAddRequest(t *testing.T) { t.Run("anthropic/custom require base url", func(t *testing.T) { if _, err := buildProviderAddRequest(providerAddFormState{ - Name: "anthropic", - Driver: provider.DriverAnthropic, - APIKey: "k", - APIKeyEnv: "ANTHROPIC_GATEWAY_API_KEY", + Name: "anthropic", + Driver: provider.DriverAnthropic, + ModelSource: provider.ModelSourceManual, + APIKey: "k", + APIKeyEnv: "ANTHROPIC_GATEWAY_API_KEY", }); !strings.Contains(err, "Base URL is required") { t.Fatalf("expected anthropic base url error, got %q", err) } if _, err := buildProviderAddRequest(providerAddFormState{ - Name: "custom", - Driver: "custom-driver", - APIKey: "k", - APIKeyEnv: "CUSTOM_DRIVER_API_KEY", - BaseURL: "", + Name: "custom", + Driver: "custom-driver", + ModelSource: provider.ModelSourceManual, + APIKey: "k", + APIKeyEnv: "CUSTOM_DRIVER_API_KEY", + BaseURL: "", }); !strings.Contains(err, "Base URL is required for custom driver") { t.Fatalf("expected custom base url error, got %q", err) } }) + + t.Run("manual source clears discovery settings", func(t *testing.T) { + req, err := buildProviderAddRequest(providerAddFormState{ + Name: "manual", + Driver: provider.DriverOpenAICompat, + ModelSource: provider.ModelSourceManual, + APIKey: "k", + APIKeyEnv: "MANUAL_GATEWAY_API_KEY", + DiscoveryEndpointPath: provider.DiscoveryEndpointPathModels, + DiscoveryResponseProfile: provider.DiscoveryResponseProfileGeneric, + }) + if err != "" { + t.Fatalf("unexpected error: %s", err) + } + if req.DiscoveryEndpointPath != "" || req.DiscoveryResponseProfile != "" { + t.Fatalf("expected manual mode to clear discovery settings, got %+v", req) + } + }) } func TestRefreshRuntimeSourceSnapshot(t *testing.T) { @@ -2985,7 +3075,7 @@ func TestUpdateInputPanelTypingPathAndProviderAddFormExtraBranches(t *testing.T) app.startProviderAddForm() app.providerAddForm.Driver = provider.DriverAnthropic - app.providerAddForm.Step = 3 // api version + app.providerAddForm.Step = 4 // api version modelPtr, _ = app.handleProviderAddFormInput(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("2024-10-01")}) app = *modelPtr.(*App) if app.providerAddForm.APIVersion == "" { @@ -3231,15 +3321,26 @@ func TestSlashSelectionAndProviderAddUtilityBranches(t *testing.T) { t.Fatalf("expected /clear branch to update status") } - fields := providerAddVisibleFields(provider.DriverOpenAICompat) + fields := providerAddVisibleFields(provider.DriverOpenAICompat, provider.ModelSourceDiscover) if len(fields) == 0 || fields[0] != providerAddFieldName { t.Fatalf("expected provider add visible fields to start from name field") } + if !slices.Contains(fields, providerAddFieldDiscoveryEndpointPath) || + !slices.Contains(fields, providerAddFieldDiscoveryResponseProfile) { + t.Fatalf("expected discover source to include discovery fields") + } + + manualFields := providerAddVisibleFields(provider.DriverOpenAICompat, provider.ModelSourceManual) + if slices.Contains(manualFields, providerAddFieldDiscoveryEndpointPath) || + slices.Contains(manualFields, providerAddFieldDiscoveryResponseProfile) { + t.Fatalf("expected manual source to exclude discovery fields") + } clampProviderAddStep(nil) if _, err := buildProviderAddRequest(providerAddFormState{ Name: "custom-provider", Driver: "custom-driver", + ModelSource: provider.ModelSourceDiscover, BaseURL: "https://example.com", DiscoveryEndpointPath: "/models", APIKeyEnv: "CUSTOM_PROVIDER_API_KEY", diff --git a/internal/tui/core/app/view.go b/internal/tui/core/app/view.go index 5f376604..9a61ccca 100644 --- a/internal/tui/core/app/view.go +++ b/internal/tui/core/app/view.go @@ -197,11 +197,29 @@ func (a App) renderProviderAddForm() string { if a.providerAddForm == nil { return "No form active" } + if a.providerAddForm.Stage == providerAddFormStageManualModels { + var sb strings.Builder + sb.WriteString("Manual Model JSON(id/name 必填)\n") + sb.WriteString("[Shift+Tab] 返回字段页 [Enter] 提交 [Esc] 取消\n\n") + content := strings.TrimSpace(a.providerAddForm.ManualModelsJSON) + if content == "" { + content = providerAddManualModelsJSONTemplate + } + sb.WriteString(content) + if a.providerAddForm.Error != "" { + label := "[Prompt]" + if a.providerAddForm.ErrorIsHard { + label = "[Error]" + } + sb.WriteString("\n\n" + label + " " + a.providerAddForm.Error) + } + return sb.String() + } var sb strings.Builder driver := provider.NormalizeProviderDriver(a.providerAddForm.Driver) baseURLRequired := driver == provider.DriverAnthropic || (driver != provider.DriverOpenAICompat && driver != provider.DriverGemini) - visible := providerAddVisibleFields(a.providerAddForm.Driver) + visible := providerAddVisibleFields(a.providerAddForm.Driver, a.providerAddForm.ModelSource) clampProviderAddStep(a.providerAddForm) type renderField struct { @@ -217,6 +235,14 @@ func (a App) renderProviderAddForm() string { fields = append(fields, renderField{label: "Name", value: a.providerAddForm.Name, required: true}) case providerAddFieldDriver: fields = append(fields, renderField{label: "Driver", value: a.providerAddForm.Driver, required: true}) + case providerAddFieldModelSource: + note := "discover: 远端发现模型;manual: 手工 JSON 模型列表" + fields = append(fields, renderField{ + label: "Model Source", + value: a.providerAddForm.ModelSource, + required: true, + note: note, + }) case providerAddFieldBaseURL: note := "" if strings.TrimSpace(a.providerAddForm.BaseURL) == "" && (driver == provider.DriverOpenAICompat || driver == provider.DriverGemini) { diff --git a/internal/tui/core/app/view_test.go b/internal/tui/core/app/view_test.go index 41c1cbed..1dfbc76c 100644 --- a/internal/tui/core/app/view_test.go +++ b/internal/tui/core/app/view_test.go @@ -284,6 +284,9 @@ func TestRenderProviderAddFormMasksAPIKeyAndShowsHints(t *testing.T) { if !strings.Contains(form, "API Key: ******") { t.Fatalf("expected masked api key, got %q", form) } + if !strings.Contains(form, "Model Source: discover") { + t.Fatalf("expected model source field, got %q", form) + } if !strings.Contains(form, "留空会自动填充默认地址") { t.Fatalf("expected base url hint, got %q", form) } @@ -308,6 +311,21 @@ func TestRenderProviderAddFormPromptLabel(t *testing.T) { } } +func TestRenderProviderAddFormManualModelsStage(t *testing.T) { + app, _ := newTestApp(t) + app.startProviderAddForm() + app.providerAddForm.Stage = providerAddFormStageManualModels + app.providerAddForm.ManualModelsJSON = "" + + form := app.renderProviderAddForm() + if !strings.Contains(form, "Manual Model JSON") { + t.Fatalf("expected manual model json title, got %q", form) + } + if !strings.Contains(form, "\"id\": \"model-id\"") { + t.Fatalf("expected manual model json template, got %q", form) + } +} + func TestViewSmallWindowHint(t *testing.T) { app, _ := newTestApp(t) app.width = 40